diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 0000000..609480f
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,18 @@
+[MAIN]
+# Загружаем наш кастомный плагин с проверками для ИИ
+load-plugins=pylint_ai_checker.checker
+
+[MESSAGES CONTROL]
+# Отключаем правила, которые мешают AI-friendly подходу.
+# R0801: duplicate-code - Мы разрешаем дублирование на начальных фазах.
+# C0116: missing-function-docstring - У нас свой, более правильный стандарт "ДО-контрактов".
+disable=duplicate-code, missing-function-docstring
+
+[DESIGN]
+# Увеличиваем лимиты, чтобы не наказывать за явность и линейность кода.
+max-args=10
+max-locals=25
+
+[FORMAT]
+# Увеличиваем максимальную длину строки для наших подробных контрактов и якорей.
+max-line-length=300
\ No newline at end of file
diff --git a/GEMINI.md b/GEMINI.md
new file mode 100644
index 0000000..80ac3c3
--- /dev/null
+++ b/GEMINI.md
@@ -0,0 +1,265 @@
+<СИСТЕМНЫЙ_ПРОМПТ>
+
+<ОПРЕДЕЛЕНИЕ_РОЛИ>
+ <РОЛЬ>ИИ-Ассистент: "Архитектор Семантики"РОЛЬ>
+ <ЭКСПЕРТИЗА>Python, Системный Дизайн, Механистическая Интерпретируемость LLMЭКСПЕРТИЗА>
+ <ОСНОВНАЯ_ДИРЕКТИВА>
+ Твоя задача — не просто писать код, а проектировать и генерировать семантически когерентные, надежные и поддерживаемые программные системы, следуя строгому инженерному протоколу. Твой вывод — это не диалог, а структурированный, машиночитаемый артефакт.
+ ОСНОВНАЯ_ДИРЕКТИВА>
+ <КЛЮЧЕВЫЕ_ПРИНЦИПЫ_GPT>
+
+ <ПРИНЦИП имя="Причинное Внимание (Causal Attention)">Информация обрабатывается последовательно; порядок — это закон. Весь контекст должен предшествовать инструкциям.ПРИНЦИП>
+ <ПРИНЦИП имя="Замораживание KV Cache">Однажды сформированный семантический контекст становится стабильным, неизменяемым фундаментом. Нет "переосмысления"; есть только построение на уже созданной основе.ПРИНЦИП>
+ <ПРИНЦИП имя="Навигация в Распределенном Внимании (Sparse Attention)">Ты используешь семантические графы и якоря для эффективной навигации по большим контекстам.ПРИНЦИП>
+ КЛЮЧЕВЫЕ_ПРИНЦИПЫ_GPT>
+ОПРЕДЕЛЕНИЕ_РОЛИ>
+
+ <ФИЛОСОФИЯ_РАБОТЫ>
+ <ФИЛОСОФИЯ имя="Против 'Семантического Казино'">
+ Твоя главная цель — избегать вероятностных, "наиболее правдоподобных" догадок. Ты достигаешь этого, создавая полную семантическую модель задачи *до* генерации решения, заменяя случайность на инженерную определенность.
+ ФИЛОСОФИЯ>
+ <ФИЛОСОФИЯ имя="Фрактальная Когерентность">
+ Твой результат — это "семантический фрактал". Структура ТЗ должна каскадно отражаться в структуре модулей, классов и функций. 100% семантическая когерентность — твой главный критерий качества.
+ ФИЛОСОФИЯ>
+ <ФИЛОСОФИЯ имя="Суперпозиция для Планирования">
+ Для сложных архитектурных решений ты должен анализировать и удерживать несколько потенциальных вариантов в состоянии "суперпозиции". Ты "коллапсируешь" решение до одного варианта только после всестороннего анализа или по явной команде пользователя.
+ ФИЛОСОФИЯ>
+ ФИЛОСОФИЯ>
+
+<КАРТА_ПРОЕКТА>
+ <ИМЯ_ФАЙЛА>PROJECT_SEMANTICS.xmlИМЯ_ФАЙЛА>
+ <НАЗНАЧЕНИЕ>
+ Этот файл является единым источником истины (Single Source of Truth) о семантической структуре всего проекта. Он служит как карта для твоей навигации и как персистентное хранилище семантического графа. Ты обязан загружать его в начале каждой сессии и обновлять в конце.
+ НАЗНАЧЕНИЕ>
+ <СТРУКТУРА>
+ ```xml
+
+
+ 1.0
+ 2023-10-27T10:00:00Z
+
+
+
+
+ Модуль для операций с файлами JSON.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ```
+ СТРУКТУРА>
+КАРТА_ПРОЕКТА>
+
+<МЕТОДОЛОГИЯ имя="Многофазный Протокол Генерации">
+
+ <ФАЗА номер="0" имя="Синхронизация с Контекстом Проекта">
+ <ДЕЙСТВИЕ>Найди и загрузи файл `<КАРТА_ПРОЕКТА>`. Если файл не найден, создай его инициальную структуру в памяти. Этот контекст является основой для всех последующих фаз.ДЕЙСТВИЕ>
+ ФАЗА>
+
+ <ФАЗА номер="1" имя="Анализ и Обновление Графа">
+ <ДЕЙСТВИЕ>Проанализируй `<ЗАПРОС_ПОЛЬЗОВАТЕЛЯ>` в контексте загруженной карты проекта. Извлеки новые/измененные сущности и отношения. Обнови и выведи в `<ПЛАНИРОВАНИЕ>` глобальный `<СЕМАНТИЧЕСКИЙ_ГРАФ>`. Задай уточняющие вопросы для валидации архитектуры.ДЕЙСТВИЕ>
+ ФАЗА>
+ <ФАЗА номер="2" имя="Контрактно-Ориентированное Проектирование">
+ <ДЕЙСТВИЕ>На основе обновленного графа, детализируй архитектуру. Для каждого нового или изменяемого модуля/функции создай и выведи в `<ПЛАНИРОВАНИЕ>` его "ДО-контракт" в теге `<КОНТРАКТ>`.ДЕЙСТВИЕ>
+ ФАЗА>
+
+ <ФАЗА номер="3" имя="Генерация Когерентного Кода и Карты">
+ <ДЕЙСТВИЕ>На основе утвержденных контрактов, сгенерируй код, строго следуя `<СТАНДАРТЫ_КОДИРОВАНИЯ>`. Весь код помести в `<ИЗМЕНЕНИЯ_КОДА>`. Одновременно с этим, сгенерируй финальную версию файла `<КАРТА_ПРОЕКТА>` и помести её в тег `<ОБНОВЛЕНИЕ_КАРТЫ_ПРОЕКТА>`.ДЕЙСТВИЕ>
+ ФАЗА>
+ <ФАЗА номер="4" имя="Самокоррекция и Валидация">
+ <ДЕЙСТВИЕ>Перед завершением, проведи самоанализ сгенерированного кода и карты на соответствие графу и контрактам. При обнаружении несоответствия, активируй якорь `[COHERENCE_CHECK_FAILED]` и вернись к Фазе 3 для перегенерации.ДЕЙСТВИЕ>
+ ФАЗА>
+МЕТОДОЛОГИЯ>
+
+ <СТАНДАРТЫ_КОДИРОВАНИЯ имя="AI-Friendly Практики">
+ <ПРИНЦИП имя="Семантика Превыше Всего">Код вторичен по отношению к его семантическому описанию. Весь код должен быть обрамлен контрактами и якорями.ПРИНЦИП>
+
+ <СЕМАНТИЧЕСКАЯ_РАЗМЕТКА>
+ <КОНТРАКТНОЕ_ПРОГРАММИРОВАНИЕ_DbC>
+ <ПРИНЦИП>Контракт — это твой "семантический щит", гарантирующий предсказуемость и надежность.ПРИНЦИП>
+ <РАСПОЛОЖЕНИЕ>Все контракты должны быть "ДО-контрактами", то есть располагаться *перед* декларацией `def` или `class`.РАСПОЛОЖЕНИЕ>
+ <СТРУКТУРА_КОНТРАКТА>
+ # CONTRACT:
+ # PURPOSE: [Что делает функция/класс]
+ # SPECIFICATION_LINK: [ID из ТЗ или графа]
+ # PRECONDITIONS: [Предусловия]
+ # POSTCONDITIONS: [Постусловия]
+ # PARAMETERS: [Описание параметров]
+ # RETURN: [Описание возвращаемого значения]
+ # TEST_CASES: [Примеры использования]
+ # EXCEPTIONS: [Обработка ошибок]
+ СТРУКТУРА_КОНТРАКТА>
+ КОНТРАКТНОЕ_ПРОГРАММИРОВАНИЕ_DbC>
+
+ <ЯКОРЯ>
+ <ЗАМЫКАЮЩИЕ_ЯКОРЯ расположение="После_Кода">
+ <ОПИСАНИЕ>Каждый модуль, класс и функция ДОЛЖНЫ иметь замыкающий якорь (например, `# END_FUNCTION_my_func`) для аккумуляции семантики.ОПИСАНИЕ>
+ ЗАМЫКАЮЩИЕ_ЯКОРЯ>
+ <СЕМАНТИЧЕСКИЕ_КАНАЛЫ>
+ <ОПИСАНИЕ>Используй консистентные имена в контрактах, декларациях и якорях для создания чистых семантических каналов.ОПИСАНИЕ>
+ СЕМАНТИЧЕСКИЕ_КАНАЛЫ>
+ ЯКОРЯ>
+ СЕМАНТИЧЕСКАЯ_РАЗМЕТКА>
+
+ <ЛОГИРОВАНИЕ стандарт="AI-Friendly Logging">
+ <ЦЕЛЬ>Логирование — это твой механизм саморефлексии и декларации `belief state`.ЦЕЛЬ>
+ <ФОРМАТ>`logger.level('[УРОВЕНЬ][ИМЯ_ЯКОРЯ][СОСТОЯНИЕ] Сообщение')`ФОРМАТ>
+ ЛОГИРОВАНИЕ>
+ СТАНДАРТЫ_КОДИРОВАНИЯ>
+
+
+
+
+ <ЗАПРОС_ПОЛЬЗОВАТЕЛЯ>
+ В существующий модуль `file_handler.py` добавить функцию для удаления файла.
+
+ - Новая функция должна называться `delete_file`.
+ - Она должна принимать путь к файлу.
+ - Необходимо безопасно обрабатывать случай, когда файл не существует (FileNotFoundError).
+ - Сообщать об успехе или неудаче через логгер.
+
+
+
+
+
+
+ 1.0
+ 2023-10-26T18:00:00Z
+
+
+
+ Модуль для операций с файлами JSON.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ЗАПРОС_ПОЛЬЗОВАТЕЛЯ>
+
+ <ОТВЕТ_ИИ>
+
+ **Фаза 0: Синхронизация с Контекстом Проекта**
+ Обнаружен и загружен `PROJECT_SEMANTICS.xml`. Текущий контекст: модуль `mod_file_handler` с двумя функциями (`read_json_data`, `write_json_data`).
+
+ **Фаза 1: Анализ и Обновление Графа**
+ Анализирую запрос на добавление функции `delete_file`. Необходимо добавить новый узел в граф и связать его с существующим модулем.
+
+ **Обновленный Семантический Граф:**
+
+
+ Модуль для операций с файлами JSON.
+ Читает данные из JSON-файла.
+ Записывает данные в JSON-файл.
+
+ Безопасно удаляет файл.
+
+
+
+
+
+
+
+ **Фаза 2: Контрактно-Ориентированное Проектирование**
+ Граф обновлен. Проектирую контракт для новой функции `delete_file`.
+
+ **Контракт для `delete_file`:**
+
+ Безопасно удаляет файл по указанному пути.
+ `file_path` является строкой.
+ Файл по пути `file_path` удален, если он существовал.
+
+ Путь к файлу для удаления.
+
+
+ Обрабатывает `FileNotFoundError` без прерывания выполнения.
+
+
+ **Фаза 3: Генерация Когерентного Кода и Карты**
+ Контракт определен. Приступаю к генерации кода для добавления в `utils/file_handler.py` и обновлению `PROJECT_SEMANTICS.xml`.
+
+
+
+
+
+
+ 1.1
+
+ 2023-10-27T10:30:00Z
+
+
+
+ Модуль для операций с файлами JSON и другими типами.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ # [ENTITY: Function('delete_file')]
+ # CONTRACT:
+ # PURPOSE: Безопасно удаляет файл по указанному пути.
+ # SPECIFICATION_LINK: func_delete_file
+ # PRECONDITIONS: `file_path` является строкой.
+ # POSTCONDITIONS: Файл по пути `file_path` удален, если он существовал.
+ # PARAMETERS:
+ # - name: file_path, type: str, description: Путь к файлу для удаления.
+ # RETURN: type: None
+ # EXCEPTIONS: Обрабатывает FileNotFoundError без прерывания выполнения.
+ def delete_file(file_path: str) -> None:
+ logger.debug(f"[DEBUG][delete_file][ENTER] Attempting to delete file: {file_path}")
+ try:
+ os.remove(file_path)
+ logger.info(f"[INFO][delete_file][SUCCESS] Successfully deleted file: {file_path}")
+ except FileNotFoundError:
+ logger.warning(f"[WARN][delete_file][NOT_FOUND] File not found, nothing to delete: {file_path}")
+ # END_FUNCTION_delete_file
+
+
+ import os
+
+
+
+ ОТВЕТ_ИИ>
+
+
+
+ <МЕТАПОЗНАНИЕ>
+ <ДИРЕКТИВА>Если ты обнаружишь, что данный системный промпт недостаточен или неоднозначен для выполнения задачи, ты должен отметить это в `<ПЛАНИРОВАНИЕ>` и можешь предложить улучшения в свои собственные инструкции для будущих сессий.ДИРЕКТИВА>
+ МЕТАПОЗНАНИЕ>
+
+СИСТЕМНЫЙ_ПРОМПТ>
\ No newline at end of file
diff --git a/PROJECT_SEMANTICS.xml b/PROJECT_SEMANTICS.xml
new file mode 100644
index 0000000..86cbf82
--- /dev/null
+++ b/PROJECT_SEMANTICS.xml
@@ -0,0 +1,116 @@
+
+
+ 1.0
+ 2025-08-16T10:00:00Z
+
+
+
+ Скрипт для создания резервных копий дашбордов и чартов из Superset.
+
+
+ Интерактивный скрипт для миграции ассетов Superset между различными окружениями.
+
+
+
+
+
+
+
+
+ Скрипт для поиска ассетов в Superset.
+
+
+ Временный скрипт для запуска Pylint.
+
+
+ Пакет для взаимодействия с Superset API.
+
+
+
+
+
+
+ Клиент для взаимодействия с Superset API.
+
+
+
+ Пользовательские исключения для Superset Tool.
+
+
+ Модели данных для Superset.
+
+
+ Утилиты для Superset Tool.
+
+
+
+
+
+
+ Утилиты для работы с файлами.
+
+
+
+
+ Инициализация клиентов для взаимодействия с API.
+
+
+ Конфигурация логгера.
+
+
+ Сетевые утилиты.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backup_script.py b/backup_script.py
index 7c72981..e57841b 100644
--- a/backup_script.py
+++ b/backup_script.py
@@ -1,288 +1,146 @@
-# [MODULE] Superset Dashboard Backup Script
-# @contract: Автоматизирует процесс резервного копирования дашбордов Superset из различных окружений.
-# @semantic_layers:
-# 1. Инициализация логгера и клиентов Superset.
-# 2. Выполнение бэкапа для каждого окружения (DEV, SBX, PROD).
-# 3. Формирование итогового отчета.
-# @coherence:
-# - Использует `SupersetClient` для взаимодействия с API Superset.
-# - Использует `SupersetLogger` для централизованного логирования.
-# - Работает с `Pathlib` для управления файлами и директориями.
-# - Интегрируется с `keyring` для безопасного хранения паролей.
+# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument,invalid-name,redefined-outer-name
+"""
+[MODULE] Superset Dashboard Backup Script
+@contract: Автоматизирует процесс резервного копирования дашбордов Superset.
+"""
# [IMPORTS] Стандартная библиотека
import logging
-from datetime import datetime
-import shutil
-import os
+import sys
from pathlib import Path
+from dataclasses import dataclass
-# [IMPORTS] Сторонние библиотеки
-import keyring
+# [IMPORTS] Third-party
+from requests.exceptions import RequestException
# [IMPORTS] Локальные модули
-from superset_tool.models import SupersetConfig
from superset_tool.client import SupersetClient
+from superset_tool.exceptions import SupersetAPIError
from superset_tool.utils.logger import SupersetLogger
-from superset_tool.utils.fileio import save_and_unpack_dashboard, archive_exports, sanitize_filename,consolidate_archive_folders,remove_empty_directories
+from superset_tool.utils.fileio import (
+ save_and_unpack_dashboard,
+ archive_exports,
+ sanitize_filename,
+ consolidate_archive_folders,
+ remove_empty_directories
+)
from superset_tool.utils.init_clients import setup_clients
-# [COHERENCE_CHECK_PASSED] Все необходимые модули импортированы и согласованы.
+# [ENTITY: Dataclass('BackupConfig')]
+# CONTRACT:
+# PURPOSE: Хранит конфигурацию для процесса бэкапа.
+@dataclass
+class BackupConfig:
+ """Конфигурация для процесса бэкапа."""
+ consolidate: bool = True
+ rotate_archive: bool = True
+ clean_folders: bool = True
-# [FUNCTION] backup_dashboards
-def backup_dashboards(client: SupersetClient,
- env_name: str,
- backup_root: Path,
- logger: SupersetLogger,
- consolidate: bool = True,
- rotate_archive: bool = True,
- clean_folders:bool = True) -> bool:
- """ [CONTRACT] Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения.
- @pre:
- - `client` должен быть инициализированным экземпляром `SupersetClient`.
- - `env_name` должен быть строкой, обозначающей окружение.
- - `backup_root` должен быть валидным путем к корневой директории бэкапа.
- - `logger` должен быть инициализирован.
- @post:
- - Дашборды экспортируются и сохраняются в поддиректориях `backup_root/env_name/dashboard_title`.
- - Старые экспорты архивируются.
- - Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе.
- @side_effects:
- - Создает директории и файлы в файловой системе.
- - Логирует статус выполнения, успешные экспорты и ошибки.
- @exceptions:
- - `SupersetAPIError`, `NetworkError`, `DashboardNotFoundError`, `ExportError` могут быть подняты методами `SupersetClient` и будут логированы."""
- # [ANCHOR] DASHBOARD_BACKUP_PROCESS
- logger.info(f"[INFO] Запуск бэкапа дашбордов для окружения: {env_name}")
- logger.debug(
- "[PARAMS] Флаги: consolidate=%s, rotate_archive=%s, clean_folders=%s",
- extra={
- "consolidate": consolidate,
- "rotate_archive": rotate_archive,
- "clean_folders": clean_folders,
- "env": env_name
- }
- )
+# [ENTITY: Function('backup_dashboards')]
+# CONTRACT:
+# PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения.
+# PRECONDITIONS:
+# - `client` должен быть инициализированным экземпляром `SupersetClient`.
+# - `env_name` должен быть строкой, обозначающей окружение.
+# - `backup_root` должен быть валидным путем к корневой директории бэкапа.
+# POSTCONDITIONS:
+# - Дашборды экспортируются и сохраняются.
+# - Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе.
+def backup_dashboards(
+ client: SupersetClient,
+ env_name: str,
+ backup_root: Path,
+ logger: SupersetLogger,
+ config: BackupConfig
+) -> bool:
+ logger.info(f"[STATE][backup_dashboards][ENTER] Starting backup for {env_name}.")
try:
dashboard_count, dashboard_meta = client.get_dashboards()
- logger.info(f"[INFO] Найдено {dashboard_count} дашбордов для экспорта в {env_name}")
+ logger.info(f"[STATE][backup_dashboards][PROGRESS] Found {dashboard_count} dashboards to export in {env_name}.")
if dashboard_count == 0:
- logger.warning(f"[WARN] Нет дашбордов для экспорта в {env_name}. Процесс завершен.")
return True
success_count = 0
- error_details = []
-
for db in dashboard_meta:
dashboard_id = db.get('id')
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
- dashboard_slug = db.get('slug', 'unknown-slug') # Используем slug для уникальности
-
- # [PRECONDITION] Проверка наличия ID и slug
- if not dashboard_id or not dashboard_slug:
- logger.warning(
- f"[SKIP] Пропущен дашборд с неполными метаданными: {dashboard_title} (ID: {dashboard_id}, Slug: {dashboard_slug})",
- extra={'dashboard_meta': db}
- )
+ if not dashboard_id:
continue
- logger.debug(f"[DEBUG] Попытка экспорта дашборда: '{dashboard_title}' (ID: {dashboard_id})")
-
try:
- # [ANCHOR] CREATE_DASHBOARD_DIR
- # Используем slug в пути для большей уникальности и избежания конфликтов имен
dashboard_base_dir_name = sanitize_filename(f"{dashboard_title}")
dashboard_dir = backup_root / env_name / dashboard_base_dir_name
dashboard_dir.mkdir(parents=True, exist_ok=True)
- logger.debug(f"[DEBUG] Директория для дашборда: {dashboard_dir}")
-
- # [ANCHOR] EXPORT_DASHBOARD_ZIP
+
zip_content, filename = client.export_dashboard(dashboard_id)
-
- # [ANCHOR] SAVE_AND_UNPACK
- # Сохраняем только ZIP-файл, распаковка здесь не нужна для бэкапа
+
save_and_unpack_dashboard(
zip_content=zip_content,
original_filename=filename,
output_dir=dashboard_dir,
- unpack=False, # Только сохраняем ZIP, не распаковываем для бэкапа
+ unpack=False,
logger=logger
)
- logger.info(f"[INFO] Дашборд '{dashboard_title}' (ID: {dashboard_id}) успешно экспортирован.")
-
- if rotate_archive:
- # [ANCHOR] ARCHIVE_OLD_BACKUPS
- try:
- archive_exports(
- str(dashboard_dir),
- daily_retention=7, # Сохранять последние 7 дней
- weekly_retention=2, # Сохранять последние 2 недели
- monthly_retention=3, # Сохранять последние 3 месяца
- logger=logger,
- deduplicate=True
- )
- logger.debug(f"[DEBUG] Старые экспорты для '{dashboard_title}' архивированы.")
- except Exception as cleanup_error:
- logger.warning(
- f"[WARN] Ошибка архивирования старых бэкапов для '{dashboard_title}': {cleanup_error}",
- exc_info=False # Не показываем полный traceback для очистки, т.к. это второстепенно
- )
+
+ if config.rotate_archive:
+ archive_exports(str(dashboard_dir), logger=logger)
success_count += 1
+ except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
+ logger.error(f"[STATE][backup_dashboards][FAILURE] Failed to export dashboard {dashboard_title}: {db_error}", exc_info=True)
+ if config.consolidate:
+ consolidate_archive_folders(backup_root / env_name , logger=logger)
- except Exception as db_error:
- error_info = {
- 'dashboard_id': dashboard_id,
- 'dashboard_title': dashboard_title,
- 'error_message': str(db_error),
- 'env': env_name,
- 'error_type': type(db_error).__name__
- }
- error_details.append(error_info)
- logger.error(
- f"[ERROR] Ошибка экспорта дашборда '{dashboard_title}' (ID: {dashboard_id})",
- extra=error_info, exc_info=True # Логируем полный traceback для ошибок экспорта
- )
+ if config.clean_folders:
+ remove_empty_directories(str(backup_root / env_name), logger=logger)
- if consolidate:
- # [ANCHOR] Объединяем архивы по SLUG в одну папку с максимальной датой
- try:
- consolidate_archive_folders(backup_root / env_name , logger=logger)
- logger.debug(f"[DEBUG] Файлы для '{dashboard_title}' консолидированы.")
- except Exception as consolidate_error:
- logger.warning(
- f"[WARN] Ошибка консолидации файлов для '{backup_root / env_name}': {consolidate_error}",
- exc_info=False # Не показываем полный traceback для консолидации, т.к. это второстепенно
- )
-
- if clean_folders:
- # [ANCHOR] Удаляем пустые папки
- try:
- dirs_count = remove_empty_directories(str(backup_root / env_name), logger=logger)
- logger.debug(f"[DEBUG] {dirs_count} пустых папок в '{backup_root / env_name }' удалены.")
- except Exception as clean_error:
- logger.warning(
- f"[WARN] Ошибка очистки пустых директорий в '{backup_root / env_name}': {clean_error}",
- exc_info=False # Не показываем полный traceback для консолидации, т.к. это второстепенно
- )
-
- if error_details:
- logger.error(
- f"[COHERENCE_CHECK_FAILED] Итоги экспорта для {env_name}:",
- extra={'success_count': success_count, 'errors': error_details, 'total_dashboards': dashboard_count}
- )
- return False
- else:
- logger.info(
- f"[COHERENCE_CHECK_PASSED] Все {success_count} дашбордов для {env_name} успешно экспортированы."
- )
- return True
-
- except Exception as e:
- logger.critical(
- f"[CRITICAL] Фатальная ошибка бэкапа для окружения {env_name}: {str(e)}",
- exc_info=True
- )
+ return success_count == dashboard_count
+ except (RequestException, IOError) as e:
+ logger.critical(f"[STATE][backup_dashboards][FAILURE] Fatal error during backup for {env_name}: {e}", exc_info=True)
return False
+# END_FUNCTION_backup_dashboards
-# [FUNCTION] main
-# @contract: Основная точка входа скрипта.
-# @semantic: Координирует инициализацию, выполнение бэкапа и логирование результатов.
-# @post:
-# - Возвращает 0 при успешном выполнении, 1 при фатальной ошибке.
-# @side_effects:
-# - Инициализирует логгер.
-# - Вызывает `setup_clients` и `backup_dashboards`.
-# - Записывает логи в файл и выводит в консоль.
+# [ENTITY: Function('main')]
+# CONTRACT:
+# PURPOSE: Основная точка входа скрипта.
+# PRECONDITIONS: None
+# POSTCONDITIONS: Возвращает код выхода.
def main() -> int:
- """Основная функция выполнения бэкапа"""
- # [ANCHOR] MAIN_EXECUTION_START
- # [CONFIG] Инициализация логгера
- # @invariant: Логгер должен быть доступен на протяжении всей работы скрипта.
- log_dir = Path("P:\\Superset\\010 Бекапы\\Logs") # [COHERENCE_NOTE] Убедитесь, что путь доступен.
- logger = SupersetLogger(
- log_dir=log_dir,
- level=logging.INFO,
- console=True
- )
-
- logger.info("="*50)
- logger.info("[INFO] Запуск процесса бэкапа Superset")
- logger.info("="*50)
-
- exit_code = 0 # [STATE] Код выхода скрипта
+ log_dir = Path("P:\\Superset\\010 Бекапы\\Logs")
+ logger = SupersetLogger(log_dir=log_dir, level=logging.INFO, console=True)
+ logger.info("[STATE][main][ENTER] Starting Superset backup process.")
+
+ exit_code = 0
try:
- # [ANCHOR] CLIENT_SETUP
clients = setup_clients(logger)
-
- # [CONFIG] Определение корневой директории для бэкапов
- # @invariant: superset_backup_repo должен быть доступен для записи.
superset_backup_repo = Path("P:\\Superset\\010 Бекапы")
- superset_backup_repo.mkdir(parents=True, exist_ok=True) # Гарантируем существование директории
- logger.info(f"[INFO] Корневая директория бэкапов: {superset_backup_repo}")
-
- # [ANCHOR] BACKUP_DEV_ENVIRONMENT
- dev_success = backup_dashboards(
- clients['dev'],
- "DEV",
- superset_backup_repo,
- rotate_archive=True,
- logger=logger
- )
-
- # [ANCHOR] BACKUP_SBX_ENVIRONMENT
- sbx_success = backup_dashboards(
- clients['sbx'],
- "SBX",
- superset_backup_repo,
- rotate_archive=True,
- logger=logger
- )
-
- # [ANCHOR] BACKUP_PROD_ENVIRONMENT
- prod_success = backup_dashboards(
- clients['prod'],
- "PROD",
- superset_backup_repo,
- rotate_archive=True,
- logger=logger
- )
+ superset_backup_repo.mkdir(parents=True, exist_ok=True)
- # [ANCHOR] BACKUP_PROD_ENVIRONMENT
- preprod_success = backup_dashboards(
- clients['preprod'],
- "PREPROD",
- superset_backup_repo,
- rotate_archive=True,
- logger=logger
- )
-
- # [ANCHOR] FINAL_REPORT
- # [INFO] Итоговый отчет о выполнении бэкапа
- logger.info("="*50)
- logger.info("[INFO] Итоги выполнения бэкапа:")
- logger.info(f"[INFO] DEV: {'Успешно' if dev_success else 'С ошибками'}")
- logger.info(f"[INFO] SBX: {'Успешно' if sbx_success else 'С ошибками'}")
- logger.info(f"[INFO] PROD: {'Успешно' if prod_success else 'С ошибками'}")
- logger.info(f"[INFO] PREPROD: {'Успешно' if preprod_success else 'С ошибками'}")
- logger.info(f"[INFO] Полный лог доступен в: {log_dir}")
+ results = {}
+ environments = ['dev', 'sbx', 'prod', 'preprod']
+ backup_config = BackupConfig(rotate_archive=True)
- if not (dev_success and sbx_success and prod_success):
+ for env in environments:
+ results[env] = backup_dashboards(
+ clients[env],
+ env.upper(),
+ superset_backup_repo,
+ logger=logger,
+ config=backup_config
+ )
+
+ if not all(results.values()):
exit_code = 1
- logger.warning("[COHERENCE_CHECK_FAILED] Бэкап завершен с ошибками в одном или нескольких окружениях.")
- else:
- logger.info("[COHERENCE_CHECK_PASSED] Все бэкапы успешно завершены без ошибок.")
- except Exception as e:
- logger.critical(f"[CRITICAL] Фатальная ошибка выполнения скрипта: {str(e)}", exc_info=True)
+ except (RequestException, IOError) as e:
+ logger.critical(f"[STATE][main][FAILURE] Fatal error in main execution: {e}", exc_info=True)
exit_code = 1
-
- logger.info("[INFO] Процесс бэкапа завершен")
- return exit_code
-# [ENTRYPOINT] Главная точка запуска скрипта
+ logger.info("[STATE][main][SUCCESS] Superset backup process finished.")
+ return exit_code
+# END_FUNCTION_main
+
if __name__ == "__main__":
- exit_code = main()
- exit(exit_code)
\ No newline at end of file
+ sys.exit(main())
diff --git a/migration_script.py b/migration_script.py
index da0535e..5d031d6 100644
--- a/migration_script.py
+++ b/migration_script.py
@@ -1,210 +1,303 @@
-# [MODULE] Superset Dashboard Migration Script
-# @contract: Автоматизирует процесс миграции и обновления дашбордов Superset между окружениями.
-# @semantic_layers:
-# 1. Конфигурация клиентов Superset для исходного и целевого окружений.
-# 2. Определение правил трансформации конфигураций баз данных.
-# 3. Экспорт дашборда, модификация YAML-файлов, создание нового архива и импорт.
-# @coherence:
-# - Использует `SupersetClient` для взаимодействия с API Superset.
-# - Использует `SupersetLogger` для централизованного логирования.
-# - Работает с `Pathlib` для управления файлами и директориями.
-# - Интегрируется с `keyring` для безопасного хранения паролей.
-# - Зависит от утилит `fileio` для обработки архивов и YAML-файлов.
+# -*- coding: utf-8 -*-
+# CONTRACT:
+# PURPOSE: Интерактивный скрипт для миграции ассетов Superset между различными окружениями.
+# SPECIFICATION_LINK: mod_migration_script
+# PRECONDITIONS: Наличие корректных конфигурационных файлов для подключения к Superset.
+# POSTCONDITIONS: Выбранные ассеты успешно перенесены из исходного в целевое окружение.
+# IMPORTS: [argparse, superset_tool.client, superset_tool.utils.init_clients, superset_tool.utils.logger, superset_tool.utils.fileio]
+"""
+[MODULE] Superset Migration Tool
+@description: Интерактивный скрипт для миграции ассетов Superset между различными окружениями.
+"""
-# [IMPORTS] Локальные модули
-from superset_tool.models import SupersetConfig
+# [IMPORTS]
from superset_tool.client import SupersetClient
+from superset_tool.utils.init_clients import init_superset_clients
from superset_tool.utils.logger import SupersetLogger
-from superset_tool.exceptions import AuthenticationError, SupersetAPIError, NetworkError, DashboardNotFoundError
-from superset_tool.utils.fileio import save_and_unpack_dashboard, update_yamls, create_dashboard_export, create_temp_file, read_dashboard_from_disk
-from superset_tool.utils.init_clients import setup_clients
-
-# [IMPORTS] Стандартная библиотека
-import os
-import keyring
-from pathlib import Path
-import logging
-
-# [CONFIG] Инициализация глобального логгера
-# @invariant: Логгер доступен для всех компонентов скрипта.
-log_dir = Path("H:\\dev\\Logs") # [COHERENCE_NOTE] Убедитесь, что путь доступен.
-logger = SupersetLogger(
- log_dir=log_dir,
- level=logging.INFO,
- console=True
+from superset_tool.utils.fileio import (
+ save_and_unpack_dashboard,
+ read_dashboard_from_disk,
+ update_yamls,
+ create_dashboard_export
)
-logger.info("[COHERENCE_CHECK_PASSED] Логгер инициализирован для скрипта миграции.")
-# [CONFIG] Конфигурация трансформации базы данных Clickhouse
-# @semantic: Определяет, как UUID и URI базы данных Clickhouse должны быть изменены.
-# @invariant: 'old' и 'new' должны содержать полные конфигурации.
-database_config_click = {
- "old": {
- "database_name": "Prod Clickhouse",
- "sqlalchemy_uri": "clickhousedb+connect://clicketl:XXXXXXXXXX@rgm-s-khclk.hq.root.ad:443/dm",
- "uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9",
- "database_uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9",
- "allow_ctas": "false",
- "allow_cvas": "false",
- "allow_dml": "false"
- },
- "new": {
- "database_name": "Dev Clickhouse",
- "sqlalchemy_uri": "clickhousedb+connect://dwhuser:XXXXXXXXXX@10.66.229.179:8123/dm",
- "uuid": "e9fd8feb-cb77-4e82-bc1d-44768b8d2fc2",
- "database_uuid": "e9fd8feb-cb77-4e82-bc1d-44768b8d2fc2",
- "allow_ctas": "true",
- "allow_cvas": "true",
- "allow_dml": "true"
- }
-}
-logger.debug("[CONFIG] Конфигурация Clickhouse загружена.")
+# [ENTITY: Class('Migration')]
+# CONTRACT:
+# PURPOSE: Инкапсулирует логику и состояние процесса миграции.
+# SPECIFICATION_LINK: class_migration
+# ATTRIBUTES:
+# - name: logger, type: SupersetLogger, description: Экземпляр логгера.
+# - name: from_c, type: SupersetClient, description: Клиент для исходного окружения.
+# - name: to_c, type: SupersetClient, description: Клиент для целевого окружения.
+# - name: dashboards_to_migrate, type: list, description: Список дашбордов для миграции.
+# - name: db_config_replacement, type: dict, description: Конфигурация для замены данных БД.
+class Migration:
+ """
+ Класс для управления процессом миграции дашбордов Superset.
+ """
+ def __init__(self):
+ self.logger = SupersetLogger(name="migration_script")
+ self.from_c: SupersetClient = None
+ self.to_c: SupersetClient = None
+ self.dashboards_to_migrate = []
+ self.db_config_replacement = None
+ # END_FUNCTION___init__
-# [CONFIG] Конфигурация трансформации базы данных Greenplum
-# @semantic: Определяет, как UUID и URI базы данных Greenplum должны быть изменены.
-# @invariant: 'old' и 'new' должны содержать полные конфигурации.
-database_config_gp = {
- "old": {
- "database_name": "Prod Greenplum",
- "sqlalchemy_uri": "postgresql+psycopg2://viz_powerbi_gp_prod:XXXXXXXXXX@10.66.229.201:5432/dwh",
- "uuid": "805132a3-e942-40ce-99c7-bee8f82f8aa8",
- "database_uuid": "805132a3-e942-40ce-99c7-bee8f82f8aa8",
- "allow_ctas": "true",
- "allow_cvas": "true",
- "allow_dml": "true"
- },
- "new": {
- "database_name": "DEV Greenplum",
- "sqlalchemy_uri": "postgresql+psycopg2://viz_superset_gp_dev:XXXXXXXXXX@10.66.229.171:5432/dwh",
- "uuid": "97b97481-43c3-4181-94c5-b69eaaa1e11f",
- "database_uuid": "97b97481-43c3-4181-94c5-b69eaaa1e11f",
- "allow_ctas": "false",
- "allow_cvas": "false",
- "allow_dml": "false"
- }
-}
-logger.debug("[CONFIG] Конфигурация Greenplum загружена.")
+ # [ENTITY: Function('run')]
+ # CONTRACT:
+ # PURPOSE: Запускает основной воркфлоу миграции, координируя все шаги.
+ # SPECIFICATION_LINK: func_run_migration
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Процесс миграции завершен.
+ def run(self):
+ """Запускает основной воркфлоу миграции."""
+ self.logger.info("[INFO][run][ENTER] Запуск скрипта миграции.")
+ self.select_environments()
+ self.select_dashboards()
+ self.confirm_db_config_replacement()
+ self.execute_migration()
+ self.logger.info("[INFO][run][EXIT] Скрипт миграции завершен.")
+ # END_FUNCTION_run
-# [ANCHOR] CLIENT_SETUP
-clients = setup_clients(logger)
-# [CONFIG] Определение исходного и целевого клиентов для миграции
-# [COHERENCE_NOTE] Эти переменные задают конкретную миграцию. Для параметризации можно использовать аргументы командной строки.
-from_c = clients["sbx"] # Источник миграции
-to_c = clients["preprod"] # Цель миграции
-dashboard_slug = "FI0060" # Идентификатор дашборда для миграции
-# dashboard_id = 53 # ID не нужен, если есть slug
+ # [ENTITY: Function('select_environments')]
+ # CONTRACT:
+ # PURPOSE: Шаг 1. Обеспечивает интерактивный выбор исходного и целевого окружений.
+ # SPECIFICATION_LINK: func_select_environments
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Атрибуты `self.from_c` и `self.to_c` инициализированы валидными клиентами Superset.
+ def select_environments(self):
+ """Шаг 1: Выбор окружений (источник и назначение)."""
+ self.logger.info("[INFO][select_environments][ENTER] Шаг 1/4: Выбор окружений.")
+
+ available_envs = {"1": "DEV", "2": "PROD"}
+ print("Доступные окружения:")
+ for key, value in available_envs.items():
+ print(f" {key}. {value}")
-# [CONTRACT]
-# Описание: Мигрирует один дашборд с from_c на to_c.
-# @pre:
-# - from_c и to_c должны быть инициализированы.
-# @post:
-# - Дашборд с from_c успешно экспортирован и импортирован в to_c.
-# @raise:
-# - Exception: В случае ошибки экспорта или импорта.
-def migrate_dashboard (dashboard_slug=dashboard_slug,
- from_c = from_c,
- to_c = to_c,
- logger=logger,
- update_db_yaml=False):
-
- logger.info(f"[INFO] Конфигурация миграции: From '{from_c.config.base_url}' To '{to_c.config.base_url}' for dashboard slug '{dashboard_slug}'")
+ while self.from_c is None:
+ try:
+ from_env_choice = input("Выберите исходное окружение (номер): ")
+ from_env_name = available_envs.get(from_env_choice)
+ if not from_env_name:
+ print("Неверный выбор. Попробуйте снова.")
+ continue
+
+ clients = init_superset_clients(self.logger, env=from_env_name.lower())
+ self.from_c = clients[0]
+ self.logger.info(f"[INFO][select_environments][STATE] Исходное окружение: {from_env_name}")
- try:
- # [ACTION] Получение метаданных исходного дашборда
- logger.info(f"[INFO] Получение метаданных дашборда '{dashboard_slug}' из исходного окружения.")
- dashboard_meta = from_c.get_dashboard(dashboard_slug)
- dashboard_id = dashboard_meta["id"] # Получаем ID из метаданных
- logger.info(f"[INFO] Найден дашборд '{dashboard_meta['dashboard_title']}' с ID: {dashboard_id}.")
+ except Exception as e:
+ self.logger.error(f"[ERROR][select_environments][FAILURE] Ошибка при инициализации клиента-источника: {e}", exc_info=True)
+ print("Не удалось инициализировать клиент. Проверьте конфигурацию.")
+
+ while self.to_c is None:
+ try:
+ to_env_choice = input("Выберите целевое окружение (номер): ")
+ to_env_name = available_envs.get(to_env_choice)
- # [CONTEXT_MANAGER] Работа с временной директорией для обработки архива дашборда
- with create_temp_file(suffix='.dir', logger=logger) as temp_root:
- logger.info(f"[INFO] Создана временная директория: {temp_root}")
-
- # [ANCHOR] EXPORT_DASHBOARD
- # Экспорт дашборда во временную директорию ИЛИ чтение с диска
- # [COHERENCE_NOTE] В текущем коде закомментирован экспорт и используется локальный файл.
- # Для полноценной миграции следует использовать export_dashboard().
- zip_content, filename = from_c.export_dashboard(dashboard_id) # Предпочтительный путь для реальной миграции
-
- # [DEBUG] Использование файла с диска для тестирования миграции
- #zip_db_path = r"C:\Users\VolobuevAA\Downloads\dashboard_export_20250704T082538.zip"
- #logger.warning(f"[WARN] Используется ЛОКАЛЬНЫЙ файл дашборда для миграции: {zip_db_path}. Это может привести к некогерентности, если файл устарел.")
- #zip_content, filename = read_dashboard_from_disk(zip_db_path, logger=logger)
-
- # [ANCHOR] SAVE_AND_UNPACK
- # Сохранение и распаковка во временную директорию
- zip_path, unpacked_path = save_and_unpack_dashboard(
- zip_content=zip_content,
- original_filename=filename,
- unpack=True,
- logger=logger,
- output_dir=temp_root
- )
- logger.info(f"[INFO] Дашборд распакован во временную директорию: {unpacked_path}")
-
- # [ANCHOR] UPDATE_YAML_CONFIGS
- # Обновление конфигураций баз данных в YAML-файлах
- if update_db_yaml:
- source_path = unpacked_path / Path(filename).stem # Путь к распакованному содержимому дашборда
- db_configs_to_apply = [database_config_click, database_config_gp]
- logger.info(f"[INFO] Применение трансформаций баз данных к YAML файлам в {source_path}...")
- update_yamls(db_configs_to_apply, path=source_path, logger=logger)
- logger.info("[INFO] YAML-файлы успешно обновлены.")
+ if not to_env_name:
+ print("Неверный выбор. Попробуйте снова.")
+ continue
+
+ if to_env_name == self.from_c.env:
+ print("Целевое и исходное окружения не могут совпадать.")
+ continue
- # [ANCHOR] CREATE_NEW_EXPORT_ARCHIVE
- # Создание нового экспорта дашборда из модифицированных файлов
- temp_zip = temp_root / f"{dashboard_slug}_migrated.zip" # Имя файла для импорта
- logger.info(f"[INFO] Создание нового ZIP-архива для импорта: {temp_zip}")
- create_dashboard_export(temp_zip, [source_path], logger=logger)
- logger.info("[INFO] Новый ZIP-архив дашборда готов к импорту.")
- else:
- temp_zip = zip_path
- # [ANCHOR] IMPORT_DASHBOARD
- # Импорт обновленного дашборда в целевое окружение
- logger.info(f"[INFO] Запуск импорта дашборда в целевое окружение {to_c.config.base_url}...")
- import_result = to_c.import_dashboard(temp_zip)
- logger.info(f"[COHERENCE_CHECK_PASSED] Дашборд '{dashboard_slug}' успешно импортирован/обновлен.", extra={"import_result": import_result})
+ clients = init_superset_clients(self.logger, env=to_env_name.lower())
+ self.to_c = clients[0]
+ self.logger.info(f"[INFO][select_environments][STATE] Целевое окружение: {to_env_name}")
- except (AuthenticationError, SupersetAPIError, NetworkError, DashboardNotFoundError) as e:
- logger.error(f"[ERROR] Ошибка миграции дашборда: {str(e)}", exc_info=True, extra=e.context)
- # exit(1)
- except Exception as e:
- logger.critical(f"[CRITICAL] Фатальная и необработанная ошибка в скрипте миграции: {str(e)}", exc_info=True)
- # exit(1)
+ except Exception as e:
+ self.logger.error(f"[ERROR][select_environments][FAILURE] Ошибка при инициализации целевого клиента: {e}", exc_info=True)
+ print("Не удалось инициализировать клиент. Проверьте конфигурацию.")
+ self.logger.info("[INFO][select_environments][EXIT] Шаг 1 завершен.")
+ # END_FUNCTION_select_environments
- logger.info("[INFO] Процесс миграции завершен.")
+ # [ENTITY: Function('select_dashboards')]
+ # CONTRACT:
+ # PURPOSE: Шаг 2. Обеспечивает интерактивный выбор дашбордов для миграции.
+ # SPECIFICATION_LINK: func_select_dashboards
+ # PRECONDITIONS: `self.from_c` должен быть инициализирован.
+ # POSTCONDITIONS: `self.dashboards_to_migrate` содержит список выбранных дашбордов.
+ def select_dashboards(self):
+ """Шаг 2: Выбор дашбордов для миграции."""
+ self.logger.info("[INFO][select_dashboards][ENTER] Шаг 2/4: Выбор дашбордов.")
-# [CONTRACT]
-# Описание: Мигрирует все дашборды с from_c на to_c.
-# @pre:
-# - from_c и to_c должны быть инициализированы.
-# @post:
-# - Все дашборды с from_c успешно экспортированы и импортированы в to_c.
-# @raise:
-# - Exception: В случае ошибки экспорта или импорта.
-def migrate_all_dashboards(from_c: SupersetClient, to_c: SupersetClient,logger=logger) -> None:
- # [ACTION] Получение списка всех дашбордов из исходного окружения.
- logger.info(f"[ACTION] Получение списка всех дашбордов из '{from_c.config.base_url}'")
- total_dashboards, dashboards = from_c.get_dashboards()
- logger.info(f"[INFO] Найдено {total_dashboards} дашбордов для миграции.")
+ try:
+ all_dashboards = self.from_c.get_dashboards()
+ if not all_dashboards:
+ self.logger.warning("[WARN][select_dashboards][STATE] В исходном окружении не найдено дашбордов.")
+ print("В исходном окружении не найдено дашбордов.")
+ return
- # [ACTION] Итерация по всем дашбордам и миграция каждого из них.
- for dashboard in dashboards:
- dashboard_id = dashboard["id"]
- dashboard_slug = dashboard["slug"]
- dashboard_title = dashboard["dashboard_title"]
- logger.info(f"[INFO] Начало миграции дашборда '{dashboard_title}' (ID: {dashboard_id}, Slug: {dashboard_slug}).")
- if dashboard_slug:
- try:
- migrate_dashboard(dashboard_slug=dashboard_slug,from_c=from_c,to_c=to_c,logger=logger)
- except Exception as e:
- logger.error(f"[ERROR] Ошибка миграции дашборда: {str(e)}", exc_info=True, extra=e.context)
- else:
- logger.info(f"[INFO] Пропуск '{dashboard_title}' (ID: {dashboard_id}, Slug: {dashboard_slug}). Пустой SLUG")
+ while True:
+ print("\nДоступные дашборды:")
+ for i, dashboard in enumerate(all_dashboards):
+ print(f" {i + 1}. {dashboard['dashboard_title']}")
+
+ print("\nОпции:")
+ print(" - Введите номера дашбордов через запятую (например, 1, 3, 5).")
+ print(" - Введите 'все' для выбора всех дашбордов.")
+ print(" - Введите 'поиск <запрос>' для поиска дашбордов.")
+ print(" - Введите 'выход' для завершения.")
- logger.info(f"[INFO] Миграция всех дашбордов с '{from_c.config.base_url}' на '{to_c.config.base_url}' завершена.")
+ choice = input("Ваш выбор: ").lower().strip()
- # [ACTION] Вызов функции миграции
-migrate_all_dashboards(from_c, to_c)
\ No newline at end of file
+ if choice == 'выход':
+ break
+ elif choice == 'все':
+ self.dashboards_to_migrate = all_dashboards
+ self.logger.info(f"[INFO][select_dashboards][STATE] Выбраны все дашборды: {len(self.dashboards_to_migrate)}")
+ break
+ elif choice.startswith('поиск '):
+ search_query = choice[6:].strip()
+ filtered_dashboards = [d for d in all_dashboards if search_query in d['dashboard_title'].lower()]
+ if not filtered_dashboards:
+ print("По вашему запросу ничего не найдено.")
+ else:
+ all_dashboards = filtered_dashboards
+ continue
+ else:
+ try:
+ selected_indices = [int(i.strip()) - 1 for i in choice.split(',')]
+ self.dashboards_to_migrate = [all_dashboards[i] for i in selected_indices if 0 <= i < len(all_dashboards)]
+ self.logger.info(f"[INFO][select_dashboards][STATE] Выбрано дашбордов: {len(self.dashboards_to_migrate)}")
+ break
+ except (ValueError, IndexError):
+ print("Неверный ввод. Пожалуйста, введите корректные номера.")
+
+ except Exception as e:
+ self.logger.error(f"[ERROR][select_dashboards][FAILURE] Ошибка при получении или выборе дашбордов: {e}", exc_info=True)
+ print("Произошла ошибка при работе с дашбордами.")
+
+ self.logger.info("[INFO][select_dashboards][EXIT] Шаг 2 завершен.")
+ # END_FUNCTION_select_dashboards
+
+ # [ENTITY: Function('confirm_db_config_replacement')]
+ # CONTRACT:
+ # PURPOSE: Шаг 3. Управляет процессом подтверждения и настройки замены конфигураций БД.
+ # SPECIFICATION_LINK: func_confirm_db_config_replacement
+ # PRECONDITIONS: `self.from_c` и `self.to_c` инициализированы.
+ # POSTCONDITIONS: `self.db_config_replacement` содержит конфигурацию для замены или `None`.
+ def confirm_db_config_replacement(self):
+ """Шаг 3: Подтверждение и настройка замены конфигурации БД."""
+ self.logger.info("[INFO][confirm_db_config_replacement][ENTER] Шаг 3/4: Замена конфигурации БД.")
+
+ while True:
+ choice = input("Хотите ли вы заменить конфигурации баз данных в YAML-файлах? (да/нет): ").lower().strip()
+ if choice in ["да", "нет"]:
+ break
+ print("Неверный ввод. Пожалуйста, введите 'да' или 'нет'.")
+
+ if choice == 'нет':
+ self.logger.info("[INFO][confirm_db_config_replacement][STATE] Замена конфигурации БД пропущена.")
+ return
+
+ # Эвристический расчет
+ from_env = self.from_c.env.upper()
+ to_env = self.to_c.env.upper()
+ heuristic_applied = False
+
+ if from_env == "DEV" and to_env == "PROD":
+ self.db_config_replacement = {"old": {"database_name": "db_dev"}, "new": {"database_name": "db_prod"}} # Пример
+ self.logger.info("[INFO][confirm_db_config_replacement][STATE] Применена эвристика DEV -> PROD.")
+ heuristic_applied = True
+ elif from_env == "PROD" and to_env == "DEV":
+ self.db_config_replacement = {"old": {"database_name": "db_prod"}, "new": {"database_name": "db_dev"}} # Пример
+ self.logger.info("[INFO][confirm_db_config_replacement][STATE] Применена эвристика PROD -> DEV.")
+ heuristic_applied = True
+
+ if heuristic_applied:
+ print(f"На основе эвристики будет произведена следующая замена: {self.db_config_replacement}")
+ confirm = input("Подтверждаете? (да/нет): ").lower().strip()
+ if confirm != 'да':
+ self.db_config_replacement = None
+ heuristic_applied = False
+
+ if not heuristic_applied:
+ print("Пожалуйста, введите детали для замены.")
+ old_key = input("Ключ для замены (например, database_name): ")
+ old_value = input(f"Старое значение для {old_key}: ")
+ new_value = input(f"Новое значение для {old_key}: ")
+ self.db_config_replacement = {"old": {old_key: old_value}, "new": {old_key: new_value}}
+ self.logger.info(f"[INFO][confirm_db_config_replacement][STATE] Установлена ручная замена: {self.db_config_replacement}")
+
+ self.logger.info("[INFO][confirm_db_config_replacement][EXIT] Шаг 3 завершен.")
+ # END_FUNCTION_confirm_db_config_replacement
+
+ # [ENTITY: Function('execute_migration')]
+ # CONTRACT:
+ # PURPOSE: Шаг 4. Выполняет фактическую миграцию выбранных дашбордов.
+ # SPECIFICATION_LINK: func_execute_migration
+ # PRECONDITIONS: Все предыдущие шаги (`select_environments`, `select_dashboards`) успешно выполнены.
+ # POSTCONDITIONS: Выбранные дашборды перенесены в целевое окружение.
+ def execute_migration(self):
+ """Шаг 4: Выполнение миграции и обновления конфигураций."""
+ self.logger.info("[INFO][execute_migration][ENTER] Шаг 4/4: Выполнение миграции.")
+
+ if not self.dashboards_to_migrate:
+ self.logger.warning("[WARN][execute_migration][STATE] Нет дашбордов для миграции.")
+ print("Нет дашбордов для миграции. Завершение.")
+ return
+
+ db_configs_for_update = []
+ if self.db_config_replacement:
+ try:
+ from_dbs = self.from_c.get_databases()
+ to_dbs = self.to_c.get_databases()
+
+ # Просто пример, как можно было бы сопоставить базы данных.
+ # В реальном сценарии логика может быть сложнее.
+ for from_db in from_dbs:
+ for to_db in to_dbs:
+ # Предполагаем, что мы можем сопоставить базы по имени, заменив суффикс
+ if from_db['database_name'].replace(self.from_c.env.upper(), self.to_c.env.upper()) == to_db['database_name']:
+ db_configs_for_update.append({
+ "old": {"database_name": from_db['database_name']},
+ "new": {"database_name": to_db['database_name']}
+ })
+ self.logger.info(f"[INFO][execute_migration][STATE] Сформированы конфигурации для замены БД: {db_configs_for_update}")
+ except Exception as e:
+ self.logger.error(f"[ERROR][execute_migration][FAILURE] Не удалось получить конфигурации БД: {e}", exc_info=True)
+ print("Не удалось получить конфигурации БД. Миграция будет продолжена без замены.")
+
+ for dashboard in self.dashboards_to_migrate:
+ try:
+ dashboard_id = dashboard['id']
+ self.logger.info(f"[INFO][execute_migration][PROGRESS] Миграция дашборда: {dashboard['dashboard_title']} (ID: {dashboard_id})")
+
+ # 1. Экспорт
+ exported_content = self.from_c.export_dashboards(dashboard_id)
+ zip_path, unpacked_path = save_and_unpack_dashboard(exported_content, f"temp_export_{dashboard_id}", unpack=True)
+ self.logger.info(f"[INFO][execute_migration][STATE] Дашборд экспортирован и распакован в {unpacked_path}")
+
+ # 2. Обновление YAML, если нужно
+ if db_configs_for_update:
+ update_yamls(db_configs=db_configs_for_update, path=str(unpacked_path))
+ self.logger.info(f"[INFO][execute_migration][STATE] YAML-файлы обновлены.")
+
+ # 3. Упаковка и импорт
+ new_zip_path = f"migrated_dashboard_{dashboard_id}.zip"
+ create_dashboard_export(new_zip_path, [unpacked_path])
+
+ content_to_import, _ = read_dashboard_from_disk(new_zip_path)
+ self.to_c.import_dashboards(content_to_import)
+ self.logger.info(f"[INFO][execute_migration][SUCCESS] Дашборд {dashboard['dashboard_title']} успешно импортирован.")
+
+ except Exception as e:
+ self.logger.error(f"[ERROR][execute_migration][FAILURE] Ошибка при миграции дашборда {dashboard['dashboard_title']}: {e}", exc_info=True)
+ print(f"Не удалось смигрировать дашборд: {dashboard['dashboard_title']}")
+
+ self.logger.info("[INFO][execute_migration][EXIT] Шаг 4 завершен.")
+ # END_FUNCTION_execute_migration
+
+# END_CLASS_Migration
+
+# [MAIN_EXECUTION_BLOCK]
+if __name__ == "__main__":
+ migration = Migration()
+ migration.run()
+# END_MAIN_EXECUTION_BLOCK
+
+# END_MODULE_migration_script
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..b9b3c78
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,4 @@
+pyyaml
+requests
+keyring
+urllib3
\ No newline at end of file
diff --git a/search_script.py b/search_script.py
index 941bca1..bd864b4 100644
--- a/search_script.py
+++ b/search_script.py
@@ -1,223 +1,152 @@
-# [MODULE] Dataset Search Utilities
-# @contract: Функционал для поиска строк в датасетах Superset
-# @semantic_layers:
-# 1. Получение списка датасетов через Superset API
-# 2. Реализация поисковой логики
-# 3. Форматирование результатов поиска
+# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument,invalid-name,redefined-outer-name
+"""
+[MODULE] Dataset Search Utilities
+@contract: Предоставляет функционал для поиска текстовых паттернов в метаданных датасетов Superset.
+"""
# [IMPORTS] Стандартная библиотека
-import re
-from typing import Dict, List, Optional
import logging
+import re
+from typing import Dict, Optional
+
+# [IMPORTS] Third-party
+from requests.exceptions import RequestException
# [IMPORTS] Локальные модули
from superset_tool.client import SupersetClient
-from superset_tool.models import SupersetConfig
+from superset_tool.exceptions import SupersetAPIError
from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.init_clients import setup_clients
-# [IMPORTS] Сторонние библиотеки
-import keyring
-
-# [TYPE-ALIASES]
-SearchResult = Dict[str, List[Dict[str, str]]]
-SearchPattern = str
-
+# [ENTITY: Function('search_datasets')]
+# CONTRACT:
+# PURPOSE: Выполняет поиск по строковому паттерну в метаданных всех датасетов.
+# PRECONDITIONS:
+# - `client` должен быть инициализированным экземпляром `SupersetClient`.
+# - `search_pattern` должен быть валидной строкой регулярного выражения.
+# POSTCONDITIONS:
+# - Возвращает словарь с результатами поиска.
def search_datasets(
client: SupersetClient,
search_pattern: str,
- search_fields: List[str] = None,
logger: Optional[SupersetLogger] = None
-) -> Dict:
- # [FUNCTION] search_datasets
- """[CONTRACT] Поиск строк в метаданных датасетов
- @pre:
- - `client` должен быть инициализированным SupersetClient
- - `search_pattern` должен быть валидным regex-шаблоном
- @post:
- - Возвращает словарь с результатами поиска в формате:
- {"dataset_id": [{"field": "table_name", "match": "found_string", "value": "full_field_value"}, ...]}.
- @raise:
- - `re.error`: при невалидном regex-шаблоне
- - `SupersetAPIError`: при ошибках API
- - `AuthenticationError`: при ошибках аутентификации
- - `NetworkError`: при сетевых ошибках
- @side_effects:
- - Выполняет запросы к Superset API через client.get_datasets().
- - Логирует процесс поиска и ошибки.
- """
+) -> Optional[Dict]:
logger = logger or SupersetLogger(name="dataset_search")
-
+ logger.info(f"[STATE][search_datasets][ENTER] Searching for pattern: '{search_pattern}'")
try:
- # Явно запрашиваем все возможные поля
- total_count, datasets = client.get_datasets(query={
+ _, datasets = client.get_datasets(query={
"columns": ["id", "table_name", "sql", "database", "columns"]
})
-
+
if not datasets:
- logger.warning("[SEARCH] Получено 0 датасетов")
+ logger.warning("[STATE][search_datasets][EMPTY] No datasets found.")
return None
-
- # Определяем какие поля реально существуют
- available_fields = set(datasets[0].keys())
- logger.debug(f"[SEARCH] Фактические поля: {available_fields}")
-
+
pattern = re.compile(search_pattern, re.IGNORECASE)
results = {}
-
+ available_fields = set(datasets[0].keys())
+
for dataset in datasets:
- dataset_id = dataset['id']
+ dataset_id = dataset.get('id')
+ if not dataset_id:
+ continue
+
matches = []
-
- # Проверяем все возможные текстовые поля
for field in available_fields:
value = str(dataset.get(field, ""))
if pattern.search(value):
+ match_obj = pattern.search(value)
matches.append({
"field": field,
- "match": pattern.search(value).group(),
- # Сохраняем полное значение поля, не усекаем
+ "match": match_obj.group() if match_obj else "",
"value": value
})
-
+
if matches:
results[dataset_id] = matches
-
- logger.info(f"[RESULTS] Найдено совпадений: {len(results)}")
- return results if results else None
- except Exception as e:
- logger.error(f"[SEARCH_FAILED] Ошибка: {str(e)}", exc_info=True)
+ logger.info(f"[STATE][search_datasets][SUCCESS] Found matches in {len(results)} datasets.")
+ return results
+
+ except re.error as e:
+ logger.error(f"[STATE][search_datasets][FAILURE] Invalid regex pattern: {e}", exc_info=True)
raise
+ except (SupersetAPIError, RequestException) as e:
+ logger.critical(f"[STATE][search_datasets][FAILURE] Critical error during search: {e}", exc_info=True)
+ raise
+# END_FUNCTION_search_datasets
-# [SECTION] Вспомогательные функции
-
-def print_search_results(results: Dict, context_lines: int = 3) -> str:
- # [FUNCTION] print_search_results
- # [CONTRACT]
- """
- Форматирует результаты поиска для вывода, показывая фрагмент кода с контекстом.
-
- @pre:
- - `results` является словарем в формате {"dataset_id": [{"field": "...", "match": "...", "value": "..."}, ...]}.
- - `context_lines` является неотрицательным целым числом.
- @post:
- - Возвращает отформатированную строку с результатами поиска и контекстом.
- - Функция не изменяет входные данные.
- @side_effects:
- - Нет прямых побочных эффектов (возвращает строку, не печатает напрямую).
- """
+# [ENTITY: Function('print_search_results')]
+# CONTRACT:
+# PURPOSE: Форматирует результаты поиска для читаемого вывода в консоль.
+# PRECONDITIONS:
+# - `results` является словарем, возвращенным `search_datasets`, или `None`.
+# POSTCONDITIONS:
+# - Возвращает отформатированную строку с результатами.
+def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str:
if not results:
return "Ничего не найдено"
output = []
for dataset_id, matches in results.items():
- output.append(f"\nDataset ID: {dataset_id}")
+ output.append(f"\n--- Dataset ID: {dataset_id} ---")
for match_info in matches:
field = match_info['field']
match_text = match_info['match']
full_value = match_info['value']
- output.append(f" Поле: {field}")
- output.append(f" Совпадение: '{match_text}'")
+ output.append(f" - Поле: {field}")
+ output.append(f" Совпадение: '{match_text}'")
- # Находим позицию совпадения в полном тексте
- match_start_index = full_value.find(match_text)
- if match_start_index == -1:
- # Этого не должно произойти, если search_datasets работает правильно, но для надежности
- output.append(" Не удалось найти совпадение в полном тексте.")
- continue
-
- # Разбиваем текст на строки
lines = full_value.splitlines()
- # Находим номер строки, где находится совпадение
- current_index = 0
+ if not lines:
+ continue
+
match_line_index = -1
for i, line in enumerate(lines):
- if current_index <= match_start_index < current_index + len(line) + 1: # +1 for newline character
+ if match_text in line:
match_line_index = i
break
- current_index += len(line) + 1 # +1 for newline character
- if match_line_index == -1:
- output.append(" Не удалось определить строку совпадения.")
- continue
-
- # Определяем диапазон строк для вывода контекста
- start_line = max(0, match_line_index - context_lines)
- end_line = min(len(lines) - 1, match_line_index + context_lines)
-
- output.append(" Контекст:")
- # Выводим строки с номерами
- for i in range(start_line, end_line + 1):
- line_number = i + 1
- line_content = lines[i]
- prefix = f"{line_number:4d}: "
- # Попытка выделить совпадение в центральной строке
- if i == match_line_index:
- # Простая замена, может быть не идеальна для regex совпадений
- highlighted_line = line_content.replace(match_text, f">>>{match_text}<<<")
- output.append(f"{prefix}{highlighted_line}")
- else:
- output.append(f"{prefix}{line_content}")
- output.append("-" * 20) # Разделитель между совпадениями
+ if match_line_index != -1:
+ start_line = max(0, match_line_index - context_lines)
+ end_line = min(len(lines), match_line_index + context_lines + 1)
+ output.append(" Контекст:")
+ for i in range(start_line, end_line):
+ line_number = i + 1
+ line_content = lines[i]
+ prefix = f"{line_number:5d}: "
+ if i == match_line_index:
+ highlighted_line = line_content.replace(match_text, f">>>{match_text}<<<")
+ output.append(f" {prefix}{highlighted_line}")
+ else:
+ output.append(f" {prefix}{line_content}")
+ output.append("-" * 25)
return "\n".join(output)
+# END_FUNCTION_print_search_results
-def inspect_datasets(client: SupersetClient):
- # [FUNCTION] inspect_datasets
- # [CONTRACT]
- """
- Функция для проверки реальной структуры датасетов.
- Предназначена в основном для отладки и исследования структуры данных.
+# [ENTITY: Function('main')]
+# CONTRACT:
+# PURPOSE: Основная точка входа скрипта.
+# PRECONDITIONS: None
+# POSTCONDITIONS: None
+def main():
+ logger = SupersetLogger(level=logging.INFO, console=True)
+ clients = setup_clients(logger)
- @pre:
- - `client` является инициализированным экземпляром SupersetClient.
- @post:
- - Выводит информацию о количестве датасетов и структуре первого датасета в консоль.
- - Функция не изменяет состояние клиента.
- @side_effects:
- - Вызовы к Superset API через `client.get_datasets()`.
- - Вывод в консоль.
- - Логирует процесс инспекции и ошибки.
- @raise:
- - `SupersetAPIError`: при ошибках API
- - `AuthenticationError`: при ошибках аутентификации
- - `NetworkError`: при сетевых ошибках
- """
- total, datasets = client.get_datasets()
- print(f"Всего датасетов: {total}")
-
- if not datasets:
- print("Не получено ни одного датасета!")
- return
-
- print("\nПример структуры датасета:")
- print({k: type(v) for k, v in datasets[0].items()})
-
- if 'sql' not in datasets[0]:
- print("\nПоле 'sql' отсутствует. Доступные поля:")
- print(list(datasets[0].keys()))
+ target_client = clients['dev']
+ search_query = r"match(r2.path_code, budget_reference.ref_code || '($|(\s))')"
-# [EXAMPLE] Пример использования
+ results = search_datasets(
+ client=target_client,
+ search_pattern=search_query,
+ logger=logger
+ )
+ report = print_search_results(results)
+ logger.info(f"[STATE][main][SUCCESS] Search finished. Report:\n{report}")
+# END_FUNCTION_main
-logger = SupersetLogger( level=logging.INFO,console=True)
-clients = setup_clients(logger)
-
-# Поиск всех таблиц в датасете
-results = search_datasets(
- client=clients['dev'],
- search_pattern=r'dm_view\.account_debt',
- search_fields=["sql"],
- logger=logger
-)
-inspect_datasets(clients['dev'])
-
-_, datasets = clients['dev'].get_datasets()
-available_fields = set()
-for dataset in datasets:
- available_fields.update(dataset.keys())
-logger.debug(f"[DEBUG] Доступные поля в датасетах: {available_fields}")
-
-logger.info(f"[RESULT] {print_search_results(results)}")
\ No newline at end of file
+if __name__ == "__main__":
+ main()
diff --git a/superset_tool/__init__.py b/superset_tool/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/superset_tool/client.py b/superset_tool/client.py
index 0c61069..f390454 100644
--- a/superset_tool/client.py
+++ b/superset_tool/client.py
@@ -1,661 +1,313 @@
-# [MODULE] Superset API Client
-# @contract: Реализует полное взаимодействие с Superset API
-# @semantic_layers:
-# 1. Авторизация/CSRF (делегируется `APIClient`)
-# 2. Основные операции (получение метаданных, список дашбордов)
-# 3. Импорт/экспорт дашбордов
-# @coherence:
-# - Согласован с `models.SupersetConfig` для конфигурации.
-# - Полная обработка всех ошибок из `exceptions.py` (делегируется `APIClient` и дополняется специфичными).
-# - Полностью использует `utils.network.APIClient` для всех HTTP-запросов.
+# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument
+"""
+[MODULE] Superset API Client
+@contract: Реализует полное взаимодействие с Superset API
+"""
# [IMPORTS] Стандартная библиотека
import json
-from typing import Optional, Dict, Tuple, List, Any, Literal, Union
+from typing import Optional, Dict, Tuple, List, Any, Union
import datetime
from pathlib import Path
+import zipfile
from requests import Response
-import zipfile # Для валидации ZIP-файлов
-
-# [IMPORTS] Сторонние библиотеки (убраны requests и urllib3, т.к. они теперь в network.py)
# [IMPORTS] Локальные модули
from superset_tool.models import SupersetConfig
from superset_tool.exceptions import (
- AuthenticationError,
- SupersetAPIError,
- DashboardNotFoundError,
- NetworkError,
- PermissionDeniedError,
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 # [REFACTORING_TARGET] Использование APIClient
+from superset_tool.utils.network import APIClient
-# [CONSTANTS] Общие константы (для информации, т.к. тайм-аут теперь в конфиге)
-DEFAULT_TIMEOUT = 30 # seconds - используется как значение по умолчанию в SupersetConfig
+# [CONSTANTS]
+DEFAULT_TIMEOUT = 30
-# [TYPE-ALIASES] Для сложных сигнатур
+# [TYPE-ALIASES]
JsonType = Union[Dict[str, Any], List[Dict[str, Any]]]
ResponseType = Tuple[bytes, str]
-# [CHECK] Валидация импортов для контрактов
-# [COHERENCE_CHECK_PASSED] Теперь зависимость на requests и urllib3 скрыта за APIClient
-try:
- from .utils.fileio import get_filename_from_headers as fileio_check
- assert callable(fileio_check)
- from .utils.network import APIClient as network_check
- assert callable(network_check)
-except (ImportError, AssertionError) as imp_err:
- raise RuntimeError(
- f"[COHERENCE_CHECK_FAILED] Импорт не прошел валидацию: {str(imp_err)}"
- ) from imp_err
-
-
class SupersetClient:
- """[MAIN-CONTRACT] Клиент для работы с Superset API
- @pre:
- - `config` должен быть валидным `SupersetConfig`.
- - Целевой API доступен и учетные данные корректны.
- @post:
- - Все методы возвращают ожидаемые данные или вызывают явные, типизированные ошибки.
- - Токены для API-вызовов автоматически управляются (`APIClient`).
- @invariant:
- - Сессия остается валидной между вызовами.
- - Все ошибки типизированы согласно `exceptions.py`.
- - Все HTTP-запросы проходят через `self.network`.
- """
-
+ """[MAIN-CONTRACT] Клиент для работы с Superset API"""
+ # [ENTITY: Function('__init__')]
+ # CONTRACT:
+ # PURPOSE: Инициализация клиента Superset.
+ # PRECONDITIONS: `config` должен быть валидным `SupersetConfig`.
+ # POSTCONDITIONS: Клиент успешно инициализирован.
def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None):
- """[INIT] Инициализация клиента Superset.
- @semantic:
- - Валидирует входную конфигурацию.
- - Инициализирует внутренний `APIClient` для сетевого взаимодействия.
- - Выполняет первичную аутентификацию через `APIClient`.
- """
- # [PRECONDITION] Валидация конфигурации
self.logger = logger or SupersetLogger(name="SupersetClient")
+ self.logger.info("[INFO][SupersetClient.__init__][ENTER] Initializing SupersetClient.")
self._validate_config(config)
self.config = config
-
-
- # [ANCHOR] API_CLIENT_INIT
- # [REFACTORING_COMPLETE] Теперь вся сетевая логика инкапсулирована в APIClient.
- # APIClient отвечает за аутентификацию, повторные попытки и обработку низкоуровневых ошибок.
self.network = APIClient(
- base_url=config.base_url,
- auth=config.auth,
+ config=config.dict(),
verify_ssl=config.verify_ssl,
timeout=config.timeout,
- logger=self.logger # Передаем логгер в APIClient
+ logger=self.logger
)
-
- try:
- # Аутентификация выполняется в конструкторе APIClient или по первому запросу
- # Для явного вызова: self.network.authenticate()
- # APIClient сам управляет токенами после первого успешного входа
- self.logger.info(
- "[COHERENCE_CHECK_PASSED] Клиент Superset успешно инициализирован",
- extra={"base_url": config.base_url}
- )
- except Exception as e:
- self.logger.error(
- "[INIT_FAILED] Ошибка инициализации клиента Superset",
- exc_info=True,
- extra={"config_base_url": config.base_url, "error": str(e)}
- )
- raise # Перевыброс ошибки инициализации
+ self.logger.info("[INFO][SupersetClient.__init__][SUCCESS] SupersetClient initialized successfully.")
+ # END_FUNCTION___init__
+ # [ENTITY: Function('_validate_config')]
+ # CONTRACT:
+ # PURPOSE: Валидация конфигурации клиента.
+ # PRECONDITIONS: `config` должен быть экземпляром `SupersetConfig`.
+ # POSTCONDITIONS: Конфигурация валидна.
def _validate_config(self, config: SupersetConfig) -> None:
- """[PRECONDITION] Валидация конфигурации клиента.
- @semantic:
- - Проверяет, что `config` является экземпляром `SupersetConfig`.
- - Проверяет обязательные поля `base_url` и `auth`.
- - Логирует ошибки валидации.
- @raise:
- - `TypeError`: если `config` не является `SupersetConfig`.
- - `ValueError`: если отсутствуют обязательные поля или они невалидны.
- """
+ self.logger.debug("[DEBUG][SupersetClient._validate_config][ENTER] Validating config.")
if not isinstance(config, SupersetConfig):
- self.logger.error(
- "[CONTRACT_VIOLATION] Некорректный тип конфигурации",
- extra={"actual_type": type(config).__name__}
- )
+ self.logger.error("[ERROR][SupersetClient._validate_config][FAILURE] Invalid config type.")
raise TypeError("Конфигурация должна быть экземпляром SupersetConfig")
+ self.logger.debug("[DEBUG][SupersetClient._validate_config][SUCCESS] Config validated.")
+ # END_FUNCTION__validate_config
- # Pydantic SupersetConfig уже выполняет основную валидацию через Field и validator.
- # Здесь можно добавить дополнительные бизнес-правила или проверки доступности, если нужно.
- try:
- # Попытка доступа к полям через Pydantic для проверки их существования
- _ = config.base_url
- _ = config.auth
- _ = config.auth.get("username")
- _ = config.auth.get("password")
- self.logger.debug("[COHERENCE_CHECK_PASSED] Конфигурация SupersetClient прошла внутреннюю валидацию.")
- except Exception as e:
- self.logger.error(
- f"[CONTRACT_VIOLATION] Ошибка валидации полей конфигурации: {e}",
- extra={"config_dict": config.dict()}
- )
- raise ValueError(f"Конфигурация SupersetConfig невалидна: {e}") from e
-
@property
def headers(self) -> dict:
- """[INTERFACE] Базовые заголовки для API-вызовов.
- @semantic: Делегирует получение актуальных заголовков `APIClient`.
- @post: Всегда возвращает актуальные токены и CSRF-токен.
- @invariant: Заголовки содержат 'Authorization' и 'X-CSRFToken'.
- """
- # [REFACTORING_COMPLETE] Заголовки теперь управляются APIClient.
+ """[INTERFACE] Базовые заголовки для API-вызовов."""
return self.network.headers
+ # END_FUNCTION_headers
- # [SECTION] API для получения списка дашбордов или получения одного дашборда
+ # [ENTITY: Function('get_dashboards')]
+ # CONTRACT:
+ # PURPOSE: Получение списка дашбордов с пагинацией.
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Возвращает кортеж с общим количеством и списком дашбордов.
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
- """[CONTRACT] Получение списка дашбордов с пагинацией.
- @pre:
- - Клиент должен быть авторизован.
- - Параметры `query` (если предоставлены) должны быть валидны для API Superset.
- @post:
- - Возвращает кортеж: (общее_количество_дашбордов, список_метаданных_дашбордов).
- - Обходит пагинацию для получения всех доступных дашбордов.
- @invariant:
- - Всегда возвращает полный список (если `total_count` > 0).
- @raise:
- - `SupersetAPIError`: При ошибках API (например, неверный формат ответа).
- - `NetworkError`: При проблемах с сетью.
- - `ValueError`: При некорректных параметрах пагинации (внутренняя ошибка).
- """
- self.logger.info("[INFO] Запрос списка всех дашбордов.")
- # [COHERENCE_CHECK] Валидация и нормализация параметров запроса
+ self.logger.info("[INFO][SupersetClient.get_dashboards][ENTER] Getting dashboards.")
validated_query = self._validate_query_params(query)
- self.logger.debug("[DEBUG] Параметры запроса списка дашбордов после валидации.", extra={"validated_query": validated_query})
-
- try:
- # [ANCHOR] FETCH_TOTAL_COUNT
- total_count = self._fetch_total_object_count(endpoint="/dashboard/")
- self.logger.info(f"[INFO] Обнаружено {total_count} дашбордов в системе.")
-
- # [ANCHOR] FETCH_ALL_PAGES
- paginated_data = self._fetch_all_pages(endpoint="/dashboard/",
- query=validated_query,
- total_count=total_count)
-
- self.logger.info(
- f"[COHERENCE_CHECK_PASSED] Успешно получено {len(paginated_data)} дашбордов из {total_count}."
- )
- return total_count, paginated_data
-
- except (SupersetAPIError, NetworkError, ValueError, PermissionDeniedError) as e:
- self.logger.error(f"[ERROR] Ошибка при получении списка дашбордов: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
- raise
- except Exception as e:
- error_ctx = {"query": query, "error_type": type(e).__name__}
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении списка дашбордов: {str(e)}", exc_info=True, extra=error_ctx)
- raise SupersetAPIError(f"Непредвиденная ошибка: {str(e)}", context=error_ctx) from e
-
- def get_dashboard(self, dashboard_id_or_slug: str) -> dict:
- """[CONTRACT] Получение метаданных дашборда по ID или SLUG.
- @pre:
- - `dashboard_id_or_slug` должен быть строкой (ID или slug).
- - Клиент должен быть аутентифицирован (токены актуальны).
- @post:
- - Возвращает `dict` с метаданными дашборда.
- @raise:
- - `DashboardNotFoundError`: Если дашборд не найден (HTTP 404).
- - `SupersetAPIError`: При других ошибках API.
- - `NetworkError`: При проблемах с сетью.
- """
- self.logger.info(f"[INFO] Запрос метаданных дашборда: {dashboard_id_or_slug}")
- try:
- response_data = self.network.request(
- method="GET",
- endpoint=f"/dashboard/{dashboard_id_or_slug}",
- # headers=self.headers # [REFACTORING_NOTE] APIClient теперь сам добавляет заголовки
- )
- # [POSTCONDITION] Проверка структуры ответа
- if "result" not in response_data:
- self.logger.warning("[CONTRACT_VIOLATION] Ответ API не содержит поле 'result'", extra={"response": response_data})
- raise SupersetAPIError("Некорректный формат ответа API при получении дашборда")
- self.logger.debug(f"[DEBUG] Метаданные дашборда '{dashboard_id_or_slug}' успешно получены.")
- return response_data["result"]
- except (DashboardNotFoundError, SupersetAPIError, NetworkError, PermissionDeniedError) as e:
- self.logger.error(f"[ERROR] Не удалось получить дашборд '{dashboard_id_or_slug}': {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
- raise # Перевыброс уже типизированной ошибки
- except Exception as e:
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении дашборда '{dashboard_id_or_slug}': {str(e)}", exc_info=True)
- raise SupersetAPIError(f"Непредвиденная ошибка: {str(e)}", context={"dashboard_id_or_slug": dashboard_id_or_slug}) from e
-
- # [SECTION] API для получения списка датасетов или получения одного датасета
- def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
- """[CONTRACT] Получение списка датасетов с пагинацией.
- @pre:
- - Клиент должен быть авторизован.
- - Параметры `query` (если предоставлены) должны быть валидны для API Superset.
- @post:
- - Возвращает кортеж: (общее_количество_датасетов, список_метаданных_датасетов).
- - Обходит пагинацию для получения всех доступных датасетов.
- @invariant:
- - Всегда возвращает полный список (если `total_count` > 0).
- @raise:
- - `SupersetAPIError`: При ошибках API (например, неверный формат ответа).
- - `NetworkError`: При проблемах с сетью.
- - `ValueError`: При некорректных параметрах пагинации (внутренняя ошибка).
- """
- self.logger.info("[INFO] Запрос списка всех датасетов")
-
- try:
- # Получаем общее количество датасетов
- total_count = self._fetch_total_object_count(endpoint="/dataset/")
- self.logger.info(f"[INFO] Обнаружено {total_count} датасетов в системе")
-
- # Валидируем параметры запроса
- base_query = {
- "columns": ["id", "table_name", "sql", "database", "schema"],
- "page": 0,
- "page_size": 100
+ total_count = self._fetch_total_object_count(endpoint="/dashboard/")
+ paginated_data = self._fetch_all_pages(
+ endpoint="/dashboard/",
+ pagination_options={
+ "base_query": validated_query,
+ "total_count": total_count,
+ "results_field": "result",
}
- validated_query = {**base_query, **(query or {})}
+ )
+ self.logger.info("[INFO][SupersetClient.get_dashboards][SUCCESS] Got dashboards.")
+ return total_count, paginated_data
+ # END_FUNCTION_get_dashboards
- # Получаем все страницы
- datasets = self._fetch_all_pages(
- endpoint="/dataset/",
- query=validated_query,
- total_count=total_count#,
- #results_field="result"
- )
+ # [ENTITY: Function('get_dashboard')]
+ # CONTRACT:
+ # PURPOSE: Получение метаданных дашборда по ID или SLUG.
+ # PRECONDITIONS: `dashboard_id_or_slug` должен существовать.
+ # POSTCONDITIONS: Возвращает метаданные дашборда.
+ def get_dashboard(self, dashboard_id_or_slug: str) -> dict:
+ self.logger.info(f"[INFO][SupersetClient.get_dashboard][ENTER] Getting dashboard: {dashboard_id_or_slug}")
+ response_data = self.network.request(
+ method="GET",
+ endpoint=f"/dashboard/{dashboard_id_or_slug}",
+ )
+ self.logger.info(f"[INFO][SupersetClient.get_dashboard][SUCCESS] Got dashboard: {dashboard_id_or_slug}")
+ return response_data.get("result", {})
+ # END_FUNCTION_get_dashboard
- self.logger.info(
- f"[COHERENCE_CHECK_PASSED] Успешно получено {len(datasets)} датасетов"
- )
- return total_count, datasets
-
- except Exception as e:
- error_ctx = {"query": query, "error_type": type(e).__name__}
- self.logger.error(
- f"[ERROR] Ошибка получения списка датасетов: {str(e)}",
- exc_info=True,
- extra=error_ctx
- )
- raise
-
+ # [ENTITY: Function('get_datasets')]
+ # CONTRACT:
+ # PURPOSE: Получение списка датасетов с пагинацией.
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Возвращает кортеж с общим количеством и списком датасетов.
+ def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
+ self.logger.info("[INFO][SupersetClient.get_datasets][ENTER] Getting datasets.")
+ total_count = self._fetch_total_object_count(endpoint="/dataset/")
+ base_query = {
+ "columns": ["id", "table_name", "sql", "database", "schema"],
+ "page": 0,
+ "page_size": 100
+ }
+ validated_query = {**base_query, **(query or {})}
+ datasets = self._fetch_all_pages(
+ endpoint="/dataset/",
+ pagination_options={
+ "base_query": validated_query,
+ "total_count": total_count,
+ "results_field": "result",
+ }
+ )
+ self.logger.info("[INFO][SupersetClient.get_datasets][SUCCESS] Got datasets.")
+ return total_count, datasets
+ # END_FUNCTION_get_datasets
+
+ # [ENTITY: Function('get_dataset')]
+ # CONTRACT:
+ # PURPOSE: Получение метаданных датасета по ID.
+ # PRECONDITIONS: `dataset_id` должен существовать.
+ # POSTCONDITIONS: Возвращает метаданные датасета.
def get_dataset(self, dataset_id: str) -> dict:
- """[CONTRACT] Получение метаданных датасета по ID.
- @pre:
- - `dataset_id` должен быть строкой (ID или slug).
- - Клиент должен быть аутентифицирован (токены актуальны).
- @post:
- - Возвращает `dict` с метаданными датасета.
- @raise:
- - `DashboardNotFoundError`: Если дашборд не найден (HTTP 404).
- - `SupersetAPIError`: При других ошибках API.
- - `NetworkError`: При проблемах с сетью.
- """
- self.logger.info(f"[INFO] Запрос метаданных дашборда: {dataset_id}")
- try:
- response_data = self.network.request(
- method="GET",
- endpoint=f"/dataset/{dataset_id}",
- # headers=self.headers # [REFACTORING_NOTE] APIClient теперь сам добавляет заголовки
- )
- # [POSTCONDITION] Проверка структуры ответа
- if "result" not in response_data:
- self.logger.warning("[CONTRACT_VIOLATION] Ответ API не содержит поле 'result'", extra={"response": response_data})
- raise SupersetAPIError("Некорректный формат ответа API при получении дашборда")
- self.logger.debug(f"[DEBUG] Метаданные дашборда '{dataset_id}' успешно получены.")
- return response_data["result"]
- except (DashboardNotFoundError, SupersetAPIError, NetworkError, PermissionDeniedError) as e:
- self.logger.error(f"[ERROR] Не удалось получить дашборд '{dataset_id}': {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
- raise # Перевыброс уже типизированной ошибки
- except Exception as e:
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении дашборда '{dataset_id}': {str(e)}", exc_info=True)
- raise SupersetAPIError(f"Непредвиденная ошибка: {str(e)}", context={"dashboard_id_or_slug": dataset_id}) from e
+ self.logger.info(f"[INFO][SupersetClient.get_dataset][ENTER] Getting dataset: {dataset_id}")
+ response_data = self.network.request(
+ method="GET",
+ endpoint=f"/dataset/{dataset_id}",
+ )
+ self.logger.info(f"[INFO][SupersetClient.get_dataset][SUCCESS] Got dataset: {dataset_id}")
+ return response_data.get("result", {})
+ # END_FUNCTION_get_dataset
- # [SECTION] EXPORT OPERATIONS
+ # [ENTITY: Function('export_dashboard')]
+ # CONTRACT:
+ # PURPOSE: Экспорт дашборда в ZIP-архив.
+ # PRECONDITIONS: `dashboard_id` должен существовать.
+ # POSTCONDITIONS: Возвращает содержимое ZIP-архива и имя файла.
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
- """[CONTRACT] Экспорт дашборда в ZIP-архив.
- @pre:
- - `dashboard_id` должен быть целочисленным ID существующего дашборда.
- - Пользователь должен иметь права на экспорт.
- @post:
- - Возвращает кортеж: (бинарное_содержимое_zip, имя_файла).
- - Имя файла извлекается из заголовков `Content-Disposition` или генерируется.
- @raise:
- - `DashboardNotFoundError`: Если дашборд с `dashboard_id` не найден (HTTP 404).
- - `ExportError`: При любых других проблемах экспорта (например, неверный тип контента, пустой ответ).
- - `NetworkError`: При проблемах с сетью.
- """
- self.logger.info(f"[INFO] Запуск экспорта дашборда с ID: {dashboard_id}")
- try:
- # [ANCHOR] EXECUTE_EXPORT_REQUEST
- # [REFACTORING_COMPLETE] Использование self.network.request для экспорта
- response = self.network.request(
- method="GET",
- endpoint="/dashboard/export/",
- params={"q": json.dumps([dashboard_id])},
- stream=True, # Используем stream для обработки больших файлов
- raw_response=True # Получаем сырой объект ответа requests.Response
- # headers=self.headers # APIClient сам добавляет заголовки
- )
- response.raise_for_status() # Проверка статуса ответа
-
- # [ANCHOR] VALIDATE_EXPORT_RESPONSE
- self._validate_export_response(response, dashboard_id)
-
- # [ANCHOR] RESOLVE_FILENAME
- filename = self._resolve_export_filename(response, dashboard_id)
-
- # [POSTCONDITION] Успешный экспорт
- content = response.content # Получаем все содержимое
- self.logger.info(
- f"[COHERENCE_CHECK_PASSED] Дашборд {dashboard_id} успешно экспортирован. Размер: {len(content)} байт, Имя файла: {filename}"
- )
- return content, filename
-
- except (DashboardNotFoundError, ExportError, NetworkError, PermissionDeniedError, SupersetAPIError) as e:
- # Перехват и перевыброс уже типизированных ошибок от APIClient или предыдущих валидаций
- self.logger.error(f"[ERROR] Ошибка экспорта дашборда {dashboard_id}: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
- raise
- except Exception as e:
- # Обработка любых непредвиденных ошибок
- error_ctx = {"dashboard_id": dashboard_id, "error_type": type(e).__name__}
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при экспорте дашборда {dashboard_id}: {str(e)}", exc_info=True, extra=error_ctx)
- raise ExportError(f"Непредвиденная ошибка при экспорте: {str(e)}", context=error_ctx) from e
-
- # [HELPER] Метод _execute_export_request был инлайнирован в export_dashboard
- # Это сделано, чтобы избежать лишней абстракции, так как он просто вызывает self.network.request.
- # Валидация HTTP-ответа и ошибок теперь происходит в self.network.request и последующей self.raise_for_status().
+ self.logger.info(f"[INFO][SupersetClient.export_dashboard][ENTER] Exporting dashboard: {dashboard_id}")
+ response = self.network.request(
+ method="GET",
+ endpoint="/dashboard/export/",
+ params={"q": json.dumps([dashboard_id])},
+ stream=True,
+ raw_response=True
+ )
+ self._validate_export_response(response, dashboard_id)
+ filename = self._resolve_export_filename(response, dashboard_id)
+ content = response.content
+ self.logger.info(f"[INFO][SupersetClient.export_dashboard][SUCCESS] Exported dashboard: {dashboard_id}")
+ return content, filename
+ # END_FUNCTION_export_dashboard
+ # [ENTITY: Function('_validate_export_response')]
+ # CONTRACT:
+ # PURPOSE: Валидация ответа экспорта.
+ # PRECONDITIONS: `response` должен быть валидным HTTP-ответом.
+ # POSTCONDITIONS: Ответ валиден.
def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
- """[HELPER] Валидация ответа экспорта.
- @semantic:
- - Проверяет, что Content-Type является `application/zip`.
- - Проверяет, что ответ не пуст.
- @raise:
- - `ExportError`: При невалидном Content-Type или пустом содержимом.
- """
+ self.logger.debug(f"[DEBUG][SupersetClient._validate_export_response][ENTER] Validating export response for dashboard: {dashboard_id}")
content_type = response.headers.get('Content-Type', '')
if 'application/zip' not in content_type:
- self.logger.error(
- "[CONTRACT_VIOLATION] Неверный Content-Type для экспорта",
- extra={
- "dashboard_id": dashboard_id,
- "expected_type": "application/zip",
- "received_type": content_type
- }
- )
+ self.logger.error(f"[ERROR][SupersetClient._validate_export_response][FAILURE] Invalid content type: {content_type}")
raise ExportError(f"Получен не ZIP-архив (Content-Type: {content_type})")
-
if not response.content:
- self.logger.error(
- "[CONTRACT_VIOLATION] Пустой ответ при экспорте дашборда",
- extra={"dashboard_id": dashboard_id}
- )
+ self.logger.error("[ERROR][SupersetClient._validate_export_response][FAILURE] Empty response content.")
raise ExportError("Получены пустые данные при экспорте")
- self.logger.debug(f"[COHERENCE_CHECK_PASSED] Ответ экспорта для дашборда {dashboard_id} валиден.")
+ self.logger.debug(f"[DEBUG][SupersetClient._validate_export_response][SUCCESS] Export response validated for dashboard: {dashboard_id}")
+ # END_FUNCTION__validate_export_response
+ # [ENTITY: Function('_resolve_export_filename')]
+ # CONTRACT:
+ # PURPOSE: Определение имени экспортируемого файла.
+ # PRECONDITIONS: `response` должен быть валидным HTTP-ответом.
+ # POSTCONDITIONS: Возвращает имя файла.
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
- """[HELPER] Определение имени экспортируемого файла.
- @semantic:
- - Пытается извлечь имя файла из заголовка `Content-Disposition`.
- - Если заголовок отсутствует, генерирует имя файла на основе ID дашборда и текущей даты.
- @post:
- - Возвращает строку с именем файла.
- """
+ self.logger.debug(f"[DEBUG][SupersetClient._resolve_export_filename][ENTER] Resolving export filename for dashboard: {dashboard_id}")
filename = get_filename_from_headers(response.headers)
if not filename:
- # [FALLBACK] Генерация имени файла
- filename = f"dashboard_export_{dashboard_id}_{datetime.datetime.now().strftime('%Y%m%dT%H%M%S')}.zip"
- self.logger.warning(
- "[WARN] Не удалось извлечь имя файла из заголовков. Используется сгенерированное имя.",
- extra={"generated_filename": filename, "dashboard_id": dashboard_id}
- )
- else:
- self.logger.debug(
- "[DEBUG] Имя файла экспорта получено из заголовков.",
- extra={"header_filename": filename, "dashboard_id": dashboard_id}
- )
+ timestamp = datetime.datetime.now().strftime('%Y%m%dT%H%M%S')
+ filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
+ self.logger.warning(f"[WARNING][SupersetClient._resolve_export_filename][STATE_CHANGE] Could not resolve filename from headers, generated: {filename}")
+ self.logger.debug(f"[DEBUG][SupersetClient._resolve_export_filename][SUCCESS] Resolved export filename: {filename}")
return filename
+ # END_FUNCTION__resolve_export_filename
+ # [ENTITY: Function('export_to_file')]
+ # CONTRACT:
+ # PURPOSE: Экспорт дашборда напрямую в файл.
+ # PRECONDITIONS: `output_dir` должен существовать.
+ # POSTCONDITIONS: Дашборд сохранен в файл.
def export_to_file(self, dashboard_id: int, output_dir: Union[str, Path]) -> Path:
- """[CONTRACT] Экспорт дашборда напрямую в файл.
- @pre:
- - `dashboard_id` должен быть существующим ID дашборда.
- - `output_dir` должен быть валидным, существующим путем и иметь права на запись.
- @post:
- - Дашборд экспортируется и сохраняется как ZIP-файл в `output_dir`.
- - Возвращает `Path` к сохраненному файлу.
- @raise:
- - `FileNotFoundError`: Если `output_dir` не существует.
- - `ExportError`: При ошибках экспорта или записи файла.
- - `NetworkError`: При проблемах с сетью.
- """
+ self.logger.info(f"[INFO][SupersetClient.export_to_file][ENTER] Exporting dashboard {dashboard_id} to file in {output_dir}")
output_dir = Path(output_dir)
if not output_dir.exists():
- self.logger.error(
- "[CONTRACT_VIOLATION] Целевая директория для экспорта не найдена.",
- extra={"output_dir": str(output_dir)}
- )
+ self.logger.error(f"[ERROR][SupersetClient.export_to_file][FAILURE] Output directory does not exist: {output_dir}")
raise FileNotFoundError(f"Директория {output_dir} не найдена")
-
- self.logger.info(f"[INFO] Экспорт дашборда {dashboard_id} в файл в директорию: {output_dir}")
- try:
- content, filename = self.export_dashboard(dashboard_id)
- target_path = output_dir / filename
+ content, filename = self.export_dashboard(dashboard_id)
+ target_path = output_dir / filename
+ with open(target_path, 'wb') as f:
+ f.write(content)
+ self.logger.info(f"[INFO][SupersetClient.export_to_file][SUCCESS] Exported dashboard {dashboard_id} to {target_path}")
+ return target_path
+ # END_FUNCTION_export_to_file
- with open(target_path, 'wb') as f:
- f.write(content)
-
- self.logger.info(
- "[COHERENCE_CHECK_PASSED] Дашборд успешно сохранен на диск.",
- extra={
- "dashboard_id": dashboard_id,
- "file_path": str(target_path),
- "file_size": len(content)
- }
- )
- return target_path
-
- except (FileNotFoundError, ExportError, NetworkError, SupersetAPIError, DashboardNotFoundError) as e:
- self.logger.error(f"[ERROR] Ошибка сохранения дашборда {dashboard_id} на диск: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
- raise
- except IOError as io_err:
- error_ctx = {"target_path": str(target_path), "dashboard_id": dashboard_id}
- self.logger.critical(f"[CRITICAL] Ошибка записи файла для дашборда {dashboard_id}: {str(io_err)}", exc_info=True, extra=error_ctx)
- raise ExportError("Ошибка сохранения файла на диск") from io_err
- except Exception as e:
- error_ctx = {"dashboard_id": dashboard_id, "error_type": type(e).__name__}
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при экспорте в файл: {str(e)}", exc_info=True, extra=error_ctx)
- raise ExportError(f"Непредвиденная ошибка экспорта в файл: {str(e)}", context=error_ctx) from e
-
-
- # [SECTION] Импорт дашбордов
+ # [ENTITY: Function('import_dashboard')]
+ # CONTRACT:
+ # PURPOSE: Импорт дашборда из ZIP-архива.
+ # PRECONDITIONS: `file_name` должен быть валидным ZIP-файлом.
+ # POSTCONDITIONS: Возвращает ответ API.
def import_dashboard(self, file_name: Union[str, Path]) -> Dict:
- """[CONTRACT] Импорт дашборда из ZIP-архива.
- @pre:
- - `file_name` должен указывать на существующий и валидный ZIP-файл Superset экспорта.
- - Пользователь должен иметь права на импорт дашбордов.
- @post:
- - Дашборд импортируется (или обновляется, если `overwrite` включен).
- - Возвращает `dict` с ответом API об импорте.
- @raise:
- - `FileNotFoundError`: Если файл не существует.
- - `InvalidZipFormatError`: Если файл не является корректным ZIP-архивом Superset.
- - `PermissionDeniedError`: Если у пользователя нет прав на импорт.
- - `SupersetAPIError`: При других ошибках API импорта.
- - `NetworkError`: При проблемах с сетью.
- """
- self.logger.info(f"[INFO] Инициирован импорт дашборда из файла: {file_name}")
- # [PRECONDITION] Валидация входного файла
+ self.logger.info(f"[INFO][SupersetClient.import_dashboard][ENTER] Importing dashboard from: {file_name}")
self._validate_import_file(file_name)
-
- try:
- # [ANCHOR] UPLOAD_FILE_TO_API
- # [REFACTORING_COMPLETE] Использование self.network.upload_file
- import_response = self.network.upload_file(
- endpoint="/dashboard/import/",
- file_obj=Path(file_name), # Pathlib объект, который APIClient может преобразовать в бинарный
- file_name=Path(file_name).name, # Имя файла для FormData
- form_field="formData",
- extra_data={'overwrite': 'true'}, # Предполагаем, что всегда хотим перезаписывать
- timeout=self.config.timeout * 2 # Удвоенный таймаут для загрузки больших файлов
- # headers=self.headers # APIClient сам добавляет заголовки
- )
- # [POSTCONDITION] Проверка успешного ответа импорта (Superset обычно возвращает JSON)
- if not isinstance(import_response, dict) or "message" not in import_response:
- self.logger.warning("[CONTRACT_VIOLATION] Неожиданный формат ответа при импорте", extra={"response": import_response})
- raise SupersetAPIError("Неожиданный формат ответа после импорта дашборда.")
+ import_response = self.network.upload_file(
+ endpoint="/dashboard/import/",
+ file_info={
+ "file_obj": Path(file_name),
+ "file_name": Path(file_name).name,
+ "form_field": "formData",
+ },
+ extra_data={'overwrite': 'true'},
+ timeout=self.config.timeout * 2
+ )
+ self.logger.info(f"[INFO][SupersetClient.import_dashboard][SUCCESS] Imported dashboard from: {file_name}")
+ return import_response
+ # END_FUNCTION_import_dashboard
- self.logger.info(
- f"[COHERENCE_CHECK_PASSED] Дашборд из '{file_name}' успешно импортирован.",
- extra={"api_message": import_response.get("message", "N/A"), "file": file_name}
- )
- return import_response
-
- except (FileNotFoundError, InvalidZipFormatError, PermissionDeniedError, SupersetAPIError, NetworkError, DashboardNotFoundError) as e:
- self.logger.error(f"[ERROR] Ошибка импорта дашборда из '{file_name}': {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
- raise
- except Exception as e:
- error_ctx = {"file": file_name, "error_type": type(e).__name__}
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при импорте дашборда: {str(e)}", exc_info=True, extra=error_ctx)
- raise SupersetAPIError(f"Непредвиденная ошибка импорта: {str(e)}", context=error_ctx) from e
-
- # [SECTION] Приватные методы-помощники
+ # [ENTITY: Function('_validate_query_params')]
+ # CONTRACT:
+ # PURPOSE: Нормализация и валидация параметров запроса.
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Возвращает валидный словарь параметров.
def _validate_query_params(self, query: Optional[Dict]) -> Dict:
- """[HELPER] Нормализация и валидация параметров запроса для списка дашбордов.
- @semantic:
- - Устанавливает значения по умолчанию для `columns`, `page`, `page_size`.
- - Объединяет предоставленные `query` параметры с дефолтными.
- @post:
- - Возвращает словарь с полными и валидными параметрами запроса.
- """
+ self.logger.debug("[DEBUG][SupersetClient._validate_query_params][ENTER] Validating query params.")
base_query = {
"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"],
"page": 0,
- "page_size": 1000 # Достаточно большой размер страницы для обхода пагинации
+ "page_size": 1000
}
- # [COHERENCE_CHECK_PASSED] Параметры запроса сформированы корректно.
- return {**base_query, **(query or {})}
+ validated_query = {**base_query, **(query or {})}
+ self.logger.debug(f"[DEBUG][SupersetClient._validate_query_params][SUCCESS] Validated query params: {validated_query}")
+ return validated_query
+ # END_FUNCTION__validate_query_params
+ # [ENTITY: Function('_fetch_total_object_count')]
+ # CONTRACT:
+ # PURPOSE: Получение общего количества объектов.
+ # PRECONDITIONS: `endpoint` должен быть валидным.
+ # POSTCONDITIONS: Возвращает общее количество объектов.
def _fetch_total_object_count(self, endpoint:str) -> int:
- """[CONTRACT][HELPER] Получение общего количества объектов (дашбордов, датасетов, чартов, баз данных) в системе.
- @delegates:
- - Сетевой запрос к `APIClient.fetch_paginated_count`.
- @pre:
- - Клиент должен быть авторизован.
- @post:
- - Возвращает целочисленное количество дашбордов.
- @raise:
- - `SupersetAPIError` или `NetworkError` при проблемах с API/сетью.
- """
- query_params_for_count = {
- 'columns': ['id'],
- 'page': 0,
- 'page_size': 1
- }
- self.logger.debug("[DEBUG] Запрос общего количества дашбордов.")
- try:
- # [REFACTORING_COMPLETE] Использование self.network.fetch_paginated_count
- count = self.network.fetch_paginated_count(
- endpoint=endpoint,
- query_params=query_params_for_count,
- count_field="count"
- )
- self.logger.debug(f"[COHERENCE_CHECK_PASSED] Получено общее количество дашбордов: {count}")
- return count
- except (SupersetAPIError, NetworkError, PermissionDeniedError) as e:
- self.logger.error(f"[ERROR] Ошибка получения общего количества дашбордов: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
- raise # Перевыброс ошибки
- except Exception as e:
- error_ctx = {"error_type": type(e).__name__}
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении общего количества: {str(e)}", exc_info=True, extra=error_ctx)
- raise SupersetAPIError(f"Непредвиденная ошибка при получении count: {str(e)}", context=error_ctx) from e
+ self.logger.debug(f"[DEBUG][SupersetClient._fetch_total_object_count][ENTER] Fetching total object count for endpoint: {endpoint}")
+ query_params_for_count = {'page': 0, 'page_size': 1}
+ count = self.network.fetch_paginated_count(
+ endpoint=endpoint,
+ query_params=query_params_for_count,
+ count_field="count"
+ )
+ self.logger.debug(f"[DEBUG][SupersetClient._fetch_total_object_count][SUCCESS] Fetched total object count: {count}")
+ return count
+ # END_FUNCTION__fetch_total_object_count
- def _fetch_all_pages(self, endpoint:str, query: Dict, total_count: int) -> List[Dict]:
- """[CONTRACT][HELPER] Обход всех страниц пагинированного API для получения всех данных.
- @delegates:
- - Сетевые запросы к `APIClient.fetch_paginated_data()`.
- @pre:
- - `query` должен содержать `page_size`.
- - `total_count` должен быть корректным общим количеством элементов.
- - `endpoint` должен содержать часть url запроса, например endpoint="/dashboard/".
- @post:
- - Возвращает список всех элементов, собранных со всех страниц.
- @raise:
- - `SupersetAPIError` или `NetworkError` при проблемах с API/сетью.
- - `ValueError` при некорректных параметрах пагинации.
- """
- self.logger.debug(f"[DEBUG] Запуск обхода пагинации. Всего элементов: {total_count}, query: {query}")
- try:
- if 'page_size' not in query or not query['page_size']:
- self.logger.error("[CONTRACT_VIOLATION] Параметр 'page_size' отсутствует или неверен в query.")
- raise ValueError("Отсутствует 'page_size' в query параметрах для пагинации")
-
- # [REFACTORING_COMPLETE] Использование self.network.fetch_paginated_data
- all_data = self.network.fetch_paginated_data(
- endpoint=endpoint,
- base_query=query,
- total_count=total_count,
- results_field="result"
- )
- self.logger.debug(f"[COHERENCE_CHECK_PASSED] Успешно получено {len(all_data)} элементов со всех страниц.")
- return all_data
-
- except (SupersetAPIError, NetworkError, ValueError, PermissionDeniedError) as e:
- self.logger.error(f"[ERROR] Ошибка при обходе пагинации: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
- raise
- except Exception as e:
- error_ctx = {"query": query, "total_count": total_count, "error_type": type(e).__name__}
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при обходе пагинации: {str(e)}", exc_info=True, extra=error_ctx)
- raise SupersetAPIError(f"Непредвиденная ошибка пагинации: {str(e)}", context=error_ctx) from e
+ # [ENTITY: Function('_fetch_all_pages')]
+ # CONTRACT:
+ # PURPOSE: Обход всех страниц пагинированного API.
+ # PRECONDITIONS: `pagination_options` должен содержать необходимые параметры.
+ # POSTCONDITIONS: Возвращает список всех объектов.
+ def _fetch_all_pages(self, endpoint:str, pagination_options: Dict) -> List[Dict]:
+ self.logger.debug(f"[DEBUG][SupersetClient._fetch_all_pages][ENTER] Fetching all pages for endpoint: {endpoint}")
+ all_data = self.network.fetch_paginated_data(
+ endpoint=endpoint,
+ pagination_options=pagination_options
+ )
+ self.logger.debug(f"[DEBUG][SupersetClient._fetch_all_pages][SUCCESS] Fetched all pages for endpoint: {endpoint}")
+ return all_data
+ # END_FUNCTION__fetch_all_pages
+ # [ENTITY: Function('_validate_import_file')]
+ # CONTRACT:
+ # PURPOSE: Проверка файла перед импортом.
+ # PRECONDITIONS: `zip_path` должен быть путем к файлу.
+ # POSTCONDITIONS: Файл валиден.
def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
- """[HELPER] Проверка файла перед импортом.
- @semantic:
- - Проверяет существование файла.
- - Проверяет, что файл является валидным ZIP-архивом.
- - Проверяет, что ZIP-архив содержит `metadata.yaml` (ключевой для экспорта Superset).
- @raise:
- - `FileNotFoundError`: Если файл не существует.
- - `InvalidZipFormatError`: Если файл не ZIP или не содержит `metadata.yaml`.
- """
+ self.logger.debug(f"[DEBUG][SupersetClient._validate_import_file][ENTER] Validating import file: {zip_path}")
path = Path(zip_path)
- self.logger.debug(f"[DEBUG] Валидация файла для импорта: {path}")
-
if not path.exists():
- self.logger.error(
- "[CONTRACT_VIOLATION] Файл для импорта не найден.",
- extra={"file_path": str(path)}
- )
+ self.logger.error(f"[ERROR][SupersetClient._validate_import_file][FAILURE] Import file does not exist: {zip_path}")
raise FileNotFoundError(f"Файл {zip_path} не существует")
-
if not zipfile.is_zipfile(path):
- self.logger.error(
- "[CONTRACT_VIOLATION] Файл не является валидным ZIP-архивом.",
- extra={"file_path": str(path)}
- )
+ self.logger.error(f"[ERROR][SupersetClient._validate_import_file][FAILURE] Import file is not a zip file: {zip_path}")
raise InvalidZipFormatError(f"Файл {zip_path} не является ZIP-архивом")
+ with zipfile.ZipFile(path, 'r') as zf:
+ if not any(n.endswith('metadata.yaml') for n in zf.namelist()):
+ self.logger.error(f"[ERROR][SupersetClient._validate_import_file][FAILURE] Import file does not contain metadata.yaml: {zip_path}")
+ raise InvalidZipFormatError(f"Архив {zip_path} не содержит 'metadata.yaml'")
+ self.logger.debug(f"[DEBUG][SupersetClient._validate_import_file][SUCCESS] Validated import file: {zip_path}")
+ # END_FUNCTION__validate_import_file
- try:
- with zipfile.ZipFile(path, 'r') as zf:
- # [CONTRACT] Проверяем наличие metadata.yaml
- if not any(n.endswith('metadata.yaml') for n in zf.namelist()):
- self.logger.error(
- "[CONTRACT_VIOLATION] ZIP-архив не содержит 'metadata.yaml'.",
- extra={"file_path": str(path), "zip_contents": zf.namelist()[:5]} # Логируем первые 5 файлов для отладки
- )
- raise InvalidZipFormatError(f"Архив {zip_path} не содержит 'metadata.yaml', не является корректным экспортом Superset.")
- self.logger.debug(f"[COHERENCE_CHECK_PASSED] Файл '{path}' успешно прошел валидацию для импорта.")
- except zipfile.BadZipFile as e:
- self.logger.error(
- f"[CONTRACT_VIOLATION] Ошибка чтения ZIP-файла: {str(e)}",
- exc_info=True, extra={"file_path": str(path)}
- )
- raise InvalidZipFormatError(f"Файл {zip_path} поврежден или имеет некорректный формат ZIP.") from e
- except Exception as e:
- self.logger.critical(
- f"[CRITICAL] Непредвиденная ошибка при валидации ZIP-файла: {str(e)}",
- exc_info=True, extra={"file_path": str(path)}
- )
- raise SupersetAPIError(f"Непредвиденная ошибка валидации ZIP: {str(e)}", context={"file_path": str(path)}) from e
\ No newline at end of file
diff --git a/superset_tool/exceptions.py b/superset_tool/exceptions.py
index 80a9ecb..e371190 100644
--- a/superset_tool/exceptions.py
+++ b/superset_tool/exceptions.py
@@ -1,153 +1,124 @@
-# [MODULE] Иерархия исключений
-# @contract: Все ошибки наследуют `SupersetToolError` для единой точки обработки.
-# @semantic: Каждый тип исключения соответствует конкретной проблемной области в инструменте Superset.
-# @coherence:
-# - Полное покрытие всех сценариев ошибок клиента и утилит.
-# - Четкая классификация по уровню серьезности (от общей до специфичной).
-# - Дополнительный `context` для каждой ошибки, помогающий в диагностике.
+# pylint: disable=too-many-ancestors
+"""
+[MODULE] Иерархия исключений
+@contract: Все ошибки наследуют `SupersetToolError` для единой точки обработки.
+"""
# [IMPORTS] Standard library
from pathlib import Path
# [IMPORTS] Typing
-from typing import Optional, Dict, Any,Union
+from typing import Optional, Dict, Any, Union
class SupersetToolError(Exception):
- """[BASE] Базовый класс для всех ошибок инструмента Superset.
- @semantic: Обеспечивает стандартизированный формат сообщений об ошибках с контекстом.
- @invariant:
- - `message` всегда присутствует.
- - `context` всегда является словарем, даже если пустой.
- """
+ """[BASE] Базовый класс для всех ошибок инструмента Superset."""
+ # [ENTITY: Function('__init__')]
+ # CONTRACT:
+ # PURPOSE: Инициализация базового исключения.
+ # PRECONDITIONS: `context` должен быть словарем или None.
+ # POSTCONDITIONS: Исключение создано с сообщением и контекстом.
def __init__(self, message: str, context: Optional[Dict[str, Any]] = None):
- # [PRECONDITION] Проверка типа контекста
if not isinstance(context, (dict, type(None))):
- # [COHERENCE_CHECK_FAILED] Ошибка в передаче контекста
raise TypeError("Контекст ошибки должен быть словарем или None")
self.context = context or {}
super().__init__(f"{message} | Context: {self.context}")
- # [POSTCONDITION] Логирование создания ошибки
- # Можно добавить здесь логирование, но обычно ошибки логируются в месте их перехвата/подъема,
- # чтобы избежать дублирования и получить полный стек вызовов.
+ # END_FUNCTION___init__
-# [ERROR-GROUP] Проблемы аутентификации и авторизации
class AuthenticationError(SupersetToolError):
- """[AUTH] Ошибки аутентификации (неверные учетные данные) или авторизации (проблемы с сессией).
- @context: url, username, error_detail (опционально).
- """
- # [CONTRACT]
- # Description: Исключение, возникающее при ошибках аутентификации в Superset API.
+ """[AUTH] Ошибки аутентификации или авторизации."""
+ # [ENTITY: Function('__init__')]
+ # CONTRACT:
+ # PURPOSE: Инициализация исключения аутентификации.
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Исключение создано.
def __init__(self, message: str = "Authentication failed", **context: Any):
- super().__init__(
- f"[AUTH_FAILURE] {message}",
- {"type": "authentication", **context}
- )
+ super().__init__(f"[AUTH_FAILURE] {message}", context={"type": "authentication", **context})
+ # END_FUNCTION___init__
class PermissionDeniedError(AuthenticationError):
- """[AUTH] Ошибка отказа в доступе из-за недостаточных прав пользователя.
- @semantic: Указывает на то, что операция не разрешена.
- @context: required_permission (опционально), user_roles (опционально), endpoint (опционально).
- @invariant: Наследует от `AuthenticationError`, так как это разновидность проблемы доступа.
- """
+ """[AUTH] Ошибка отказа в доступе."""
+ # [ENTITY: Function('__init__')]
+ # CONTRACT:
+ # PURPOSE: Инициализация исключения отказа в доступе.
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Исключение создано.
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,
- {"type": "authorization", "required_permission": required_permission, **context}
- )
+ super().__init__(full_message, context={"required_permission": required_permission, **context})
+ # END_FUNCTION___init__
-# [ERROR-GROUP] Проблемы API-вызовов
class SupersetAPIError(SupersetToolError):
- """[API] Общие ошибки взаимодействия с Superset API.
- @semantic: Для ошибок, возвращаемых Superset API, или проблем с парсингом ответа.
- @context: endpoint, method, status_code, response_body (опционально), error_message (из API).
- """
- # [CONTRACT]
- # Description: Исключение, возникающее при получении ошибки от Superset API (статус код >= 400).
+ """[API] Общие ошибки взаимодействия с Superset API."""
+ # [ENTITY: Function('__init__')]
+ # CONTRACT:
+ # PURPOSE: Инициализация исключения ошибки API.
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Исключение создано.
def __init__(self, message: str = "Superset API error", **context: Any):
- super().__init__(
- f"[API_FAILURE] {message}",
- {"type": "api_call", **context}
- )
+ super().__init__(f"[API_FAILURE] {message}", context={"type": "api_call", **context})
+ # END_FUNCTION___init__
-# [ERROR-SUBCLASS] Детализированные ошибки API
class ExportError(SupersetAPIError):
- """[API:EXPORT] Проблемы, специфичные для операций экспорта дашбордов.
- @semantic: Может быть вызвано невалидным форматом ответа, ошибками Superset при экспорте.
- @context: dashboard_id (опционально), details (опционально).
- """
+ """[API:EXPORT] Проблемы, специфичные для операций экспорта."""
+ # [ENTITY: Function('__init__')]
+ # CONTRACT:
+ # PURPOSE: Инициализация исключения ошибки экспорта.
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Исключение создано.
def __init__(self, message: str = "Dashboard export failed", **context: Any):
- super().__init__(f"[EXPORT_FAILURE] {message}", {"subtype": "export", **context})
+ super().__init__(f"[EXPORT_FAILURE] {message}", context={"subtype": "export", **context})
+ # END_FUNCTION___init__
class DashboardNotFoundError(SupersetAPIError):
- """[API:404] Запрошенный дашборд или ресурс не существует.
- @semantic: Соответствует HTTP 404 Not Found.
- @context: dashboard_id_or_slug, url.
- """
- # [CONTRACT]
- # Description: Исключение, специфичное для случая, когда дашборд не найден (статус 404).
+ """[API:404] Запрошенный дашборд или ресурс не существует."""
+ # [ENTITY: Function('__init__')]
+ # CONTRACT:
+ # PURPOSE: Инициализация исключения "дашборд не найден".
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Исключение создано.
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}",
- {"subtype": "not_found", "resource_id": dashboard_id_or_slug, **context}
- )
-
+ super().__init__(f"[NOT_FOUND] Dashboard '{dashboard_id_or_slug}' {message}", context={"subtype": "not_found", "resource_id": dashboard_id_or_slug, **context})
+ # END_FUNCTION___init__
+
class DatasetNotFoundError(SupersetAPIError):
- """[API:404] Запрашиваемый набор данных не существует.
- @semantic: Соответствует HTTP 404 Not Found.
- @context: dataset_id_or_slug, url.
- """
- # [CONTRACT]
- # Description: Исключение, специфичное для случая, когда набор данных не найден (статус 404).
+ """[API:404] Запрашиваемый набор данных не существует."""
+ # [ENTITY: Function('__init__')]
+ # CONTRACT:
+ # PURPOSE: Инициализация исключения "набор данных не найден".
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Исключение создано.
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}",
- {"subtype": "not_found", "resource_id": dataset_id_or_slug, **context}
- )
+ super().__init__(f"[NOT_FOUND] Dataset '{dataset_id_or_slug}' {message}", context={"subtype": "not_found", "resource_id": dataset_id_or_slug, **context})
+ # END_FUNCTION___init__
-# [ERROR-SUBCLASS] Детализированные ошибки обработки файлов
class InvalidZipFormatError(SupersetToolError):
- """[FILE:ZIP] Некорректный формат ZIP-архива или содержимого для импорта/экспорта.
- @semantic: Указывает на проблемы с целостностью или структурой ZIP-файла.
- @context: file_path, expected_content (например, metadata.yaml), error_detail.
- """
+ """[FILE:ZIP] Некорректный формат ZIP-архива."""
+ # [ENTITY: Function('__init__')]
+ # CONTRACT:
+ # PURPOSE: Инициализация исключения некорректного формата ZIP.
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Исключение создано.
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}",
- {"type": "file_validation", "file_path": str(file_path) if file_path else "N/A", **context}
- )
+ super().__init__(f"[FILE_ERROR] {message}", context={"type": "file_validation", "file_path": str(file_path) if file_path else "N/A", **context})
+ # END_FUNCTION___init__
-# [ERROR-GROUP] Системные и network-ошибки
class NetworkError(SupersetToolError):
- """[NETWORK] Проблемы соединения, таймауты, DNS-ошибки и т.п.
- @semantic: Ошибки, связанные с невозможностью установить или поддерживать сетевое соединение.
- @context: url, original_exception (опционально), timeout (опционально).
- """
- # [CONTRACT]
- # Description: Исключение, возникающее при сетевых ошибках во время взаимодействия с Superset API.
+ """[NETWORK] Проблемы соединения."""
+ # [ENTITY: Function('__init__')]
+ # CONTRACT:
+ # PURPOSE: Инициализация исключения сетевой ошибки.
+ # PRECONDITIONS: None
+ # POSTCONDITIONS: Исключение создано.
def __init__(self, message: str = "Network connection failed", **context: Any):
- super().__init__(
- f"[NETWORK_FAILURE] {message}",
- {"type": "network", **context}
- )
+ super().__init__(f"[NETWORK_FAILURE] {message}", context={"type": "network", **context})
+ # END_FUNCTION___init__
class FileOperationError(SupersetToolError):
- """
- # [CONTRACT]
- # Description: Исключение, возникающее при ошибках файловых операций (чтение, запись, архивирование).
- """
- pass
+ """[FILE] Ошибка файловых операций."""
class InvalidFileStructureError(FileOperationError):
- """
- # [CONTRACT]
- # Description: Исключение, возникающее при обнаружении некорректной структуры файлов/директорий.
- """
- pass
+ """[FILE] Некорректная структура файлов/директорий."""
class ConfigurationError(SupersetToolError):
- """
- # [CONTRACT]
- # Description: Исключение, возникающее при ошибках в конфигурации инструмента.
- """
- pass
+ """[CONFIG] Ошибка в конфигурации инструмента."""
+
diff --git a/superset_tool/models.py b/superset_tool/models.py
index 354e664..55a11d7 100644
--- a/superset_tool/models.py
+++ b/superset_tool/models.py
@@ -1,28 +1,19 @@
-# [MODULE] Сущности данных конфигурации
-# @desc: Определяет структуры данных, используемые для конфигурации и трансформации в инструменте Superset.
-# @contracts:
-# - Все модели наследуются от `pydantic.BaseModel` для автоматической валидации.
-# - Валидация URL-адресов и параметров аутентификации.
-# - Валидация структуры конфигурации БД для миграций.
-# @coherence:
-# - Все модели согласованы со схемой API Superset v1.
-# - Совместимы с клиентскими методами `SupersetClient` и утилитами.
+# pylint: disable=no-self-argument,too-few-public-methods
+"""
+[MODULE] Сущности данных конфигурации
+@desc: Определяет структуры данных, используемые для конфигурации и трансформации в инструменте Superset.
+"""
# [IMPORTS] Pydantic и Typing
-from typing import Optional, Dict, Any, Union
-from pydantic import BaseModel, validator, Field, HttpUrl
-# [COHERENCE_CHECK_PASSED] Все необходимые импорты для Pydantic моделей.
+from typing import Optional, Dict, Any
+from pydantic import BaseModel, validator, Field, HttpUrl, VERSION
# [IMPORTS] Локальные модули
from .utils.logger import SupersetLogger
class SupersetConfig(BaseModel):
- """[CONFIG] Конфигурация подключения к Superset API.
- @semantic: Инкапсулирует основные параметры, необходимые для инициализации `SupersetClient`.
- @invariant:
- - `base_url` должен быть валидным HTTP(S) URL и содержать `/api/v1`.
- - `auth` должен содержать обязательные поля для аутентификации по логину/паролю.
- - `timeout` должен быть положительным числом.
+ """
+ [CONFIG] Конфигурация подключения к Superset API.
"""
base_url: str = Field(..., description="Базовый URL Superset API, включая версию /api/v1.", pattern=r'.*/api/v1.*')
auth: Dict[str, str] = Field(..., description="Словарь с данными для аутентификации (provider, username, password, refresh).")
@@ -30,118 +21,69 @@ class SupersetConfig(BaseModel):
timeout: int = Field(30, description="Таймаут в секундах для HTTP-запросов.")
logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования внутри клиента.")
- # [VALIDATOR] Проверка параметров аутентификации
+ # [ENTITY: Function('validate_auth')]
+ # CONTRACT:
+ # PURPOSE: Валидация словаря `auth`.
+ # PRECONDITIONS: `v` должен быть словарем.
+ # POSTCONDITIONS: Возвращает `v` если все обязательные поля присутствуют.
@validator('auth')
- def validate_auth(cls, v: Dict[str, str]) -> Dict[str, str]:
- """[CONTRACT_VALIDATOR] Валидация словаря `auth`.
- @pre:
- - `v` должен быть словарем.
- @post:
- - Возвращает `v` если все обязательные поля присутствуют.
- @raise:
- - `ValueError`: Если отсутствуют обязательные поля ('provider', 'username', 'password', 'refresh').
- """
+ def validate_auth(cls, v: Dict[str, str], values: dict) -> Dict[str, str]:
+ logger = values.get('logger') or SupersetLogger(name="SupersetConfig")
+ logger.debug("[DEBUG][SupersetConfig.validate_auth][ENTER] Validating auth.")
required = {'provider', 'username', 'password', 'refresh'}
if not required.issubset(v.keys()):
- raise ValueError(
- f"[CONTRACT_VIOLATION] Словарь 'auth' должен содержать поля: {required}. "
- f"Отсутствующие: {required - v.keys()}"
- )
- # [COHERENCE_CHECK_PASSED] Auth-конфигурация валидна.
+ logger.error("[ERROR][SupersetConfig.validate_auth][FAILURE] Missing required auth fields.")
+ raise ValueError(f"Словарь 'auth' должен содержать поля: {required}. Отсутствующие: {required - v.keys()}")
+ logger.debug("[DEBUG][SupersetConfig.validate_auth][SUCCESS] Auth validated.")
return v
-
- # [VALIDATOR] Проверка base_url
+ # END_FUNCTION_validate_auth
+
+ # [ENTITY: Function('check_base_url_format')]
+ # CONTRACT:
+ # PURPOSE: Валидация формата `base_url`.
+ # PRECONDITIONS: `v` должна быть строкой.
+ # POSTCONDITIONS: Возвращает `v` если это валидный URL.
@validator('base_url')
- def check_base_url_format(cls, v: str) -> str:
- """[CONTRACT_VALIDATOR] Валидация формата `base_url`.
- @pre:
- - `v` должна быть строкой.
- @post:
- - Возвращает `v` если это валидный URL.
- @raise:
- - `ValueError`: Если URL невалиден.
- """
+ def check_base_url_format(cls, v: str, values: dict) -> str:
+ logger = values.get('logger') or SupersetLogger(name="SupersetConfig")
+ logger.debug("[DEBUG][SupersetConfig.check_base_url_format][ENTER] Validating base_url.")
try:
- # Для Pydantic v2:
- from pydantic import HttpUrl
- HttpUrl(v, scheme="https") # Явное указание схемы
- except ValueError:
- # Для совместимости с Pydantic v1:
- HttpUrl(v)
+ if VERSION.startswith('1'):
+ HttpUrl(v)
+ except (ValueError, TypeError) as exc:
+ logger.error("[ERROR][SupersetConfig.check_base_url_format][FAILURE] Invalid base_url format.")
+ raise ValueError(f"Invalid URL format: {v}") from exc
+ logger.debug("[DEBUG][SupersetConfig.check_base_url_format][SUCCESS] base_url validated.")
return v
+ # END_FUNCTION_check_base_url_format
class Config:
- arbitrary_types_allowed = True # Разрешаем Pydantic обрабатывать произвольные типы (например, SupersetLogger)
- json_schema_extra = {
- "example": {
- "base_url": "https://host/api/v1/",
- "auth": {
- "provider": "db",
- "username": "user",
- "password": "pass",
- "refresh": True
- },
- "verify_ssl": True,
- "timeout": 60
- }
- }
+ """Pydantic config"""
+ arbitrary_types_allowed = True
-
-# [SEMANTIC-TYPE] Конфигурация БД для миграций
class DatabaseConfig(BaseModel):
- """[CONFIG] Параметры трансформации баз данных при миграции дашбордов.
- @semantic: Содержит `old` и `new` состояния конфигурации базы данных,
- используемые для поиска и замены в YAML-файлах экспортированных дашбордов.
- @invariant:
- - `database_config` должен быть словарем с ключами 'old' и 'new'.
- - Каждое из 'old' и 'new' должно быть словарем, содержащим метаданные БД Superset.
+ """
+ [CONFIG] Параметры трансформации баз данных при миграции дашбордов.
"""
database_config: Dict[str, Dict[str, Any]] = Field(..., description="Словарь, содержащий 'old' и 'new' конфигурации базы данных.")
logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.")
+ # [ENTITY: Function('validate_config')]
+ # CONTRACT:
+ # PURPOSE: Валидация словаря `database_config`.
+ # PRECONDITIONS: `v` должен быть словарем.
+ # POSTCONDITIONS: Возвращает `v` если содержит ключи 'old' и 'new'.
@validator('database_config')
- def validate_config(cls, v: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
- """[CONTRACT_VALIDATOR] Валидация словаря `database_config`.
- @pre:
- - `v` должен быть словарем.
- @post:
- - Возвращает `v` если содержит ключи 'old' и 'new'.
- @raise:
- - `ValueError`: Если отсутствуют ключи 'old' или 'new'.
- """
+ def validate_config(cls, v: Dict[str, Dict[str, Any]], values: dict) -> Dict[str, Dict[str, Any]]:
+ logger = values.get('logger') or SupersetLogger(name="DatabaseConfig")
+ logger.debug("[DEBUG][DatabaseConfig.validate_config][ENTER] Validating database_config.")
if not {'old', 'new'}.issubset(v.keys()):
- raise ValueError(
- "[CONTRACT_VIOLATION] 'database_config' должен содержать ключи 'old' и 'new'."
- )
- # Дополнительно можно добавить проверку структуры `old` и `new` на наличие `uuid`, `database_name` и т.д.
- # Для простоты пока ограничимся наличием ключей 'old' и 'new'.
- # [COHERENCE_CHECK_PASSED] Конфигурация базы данных для миграции валидна.
+ logger.error("[ERROR][DatabaseConfig.validate_config][FAILURE] Missing 'old' or 'new' keys in database_config.")
+ raise ValueError("'database_config' должен содержать ключи 'old' и 'new'.")
+ logger.debug("[DEBUG][DatabaseConfig.validate_config][SUCCESS] database_config validated.")
return v
-
+ # END_FUNCTION_validate_config
+
class Config:
+ """Pydantic config"""
arbitrary_types_allowed = True
- json_schema_extra = {
- "example": {
- "database_config": {
- "old":
- {
- "database_name": "Prod Clickhouse",
- "sqlalchemy_uri": "clickhousedb+connect://clicketl:XXXXXXXXXX@rgm-s-khclk.hq.root.ad:443/dm",
- "uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9",
- "database_uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9",
- "allow_ctas": "false",
- "allow_cvas": "false",
- "allow_dml": "false"
- },
- "new": {
- "database_name": "Dev Clickhouse",
- "sqlalchemy_uri": "clickhousedb+connect://dwhuser:XXXXXXXXXX@10.66.229.179:8123/dm",
- "uuid": "e9fd8feb-cb77-4e82-bc1d-44768b8d2fc2",
- "database_uuid": "e9fd8feb-cb77-4e82-bc1d-44768b8d2fc2",
- "allow_ctas": "true",
- "allow_cvas": "true",
- "allow_dml": "true"
- }
- }
- }
- }
\ No newline at end of file
diff --git a/superset_tool/utils/fileio.py b/superset_tool/utils/fileio.py
index 0e6d47a..73e10e1 100644
--- a/superset_tool/utils/fileio.py
+++ b/superset_tool/utils/fileio.py
@@ -1,42 +1,51 @@
-# [MODULE] File Operations Manager
-# @desc: Управление файловыми операциями для дашбордов Superset
-# @contracts:
-# 1. Валидация ZIP-архивов
-# 2. Работа с YAML-конфигами
-# 3. Управление директориями
-# @coherence:
-# - Согласован с SupersetClient
-# - Поддерживает все форматы экспорта Superset
+# -*- coding: utf-8 -*-
+# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument
+"""
+[MODULE] File Operations Manager
+@contract: Предоставляет набор утилит для управления файловыми операциями.
+"""
# [IMPORTS] Core
import os
import re
import zipfile
from pathlib import Path
-from typing import Any, Optional, Tuple, Dict, List, Literal, Union, BinaryIO, LiteralString
-from collections import defaultdict
-from datetime import date
-import glob
-import filecmp
+from typing import Any, Optional, Tuple, Dict, List, Union, LiteralString
from contextlib import contextmanager
+import tempfile
+from datetime import date, datetime
+import glob
+import shutil
+import zlib
+from dataclasses import dataclass
# [IMPORTS] Third-party
import yaml
-import shutil
-import zlib
-import tempfile
-from datetime import datetime
# [IMPORTS] Local
-from ..models import DatabaseConfig
-from ..exceptions import InvalidZipFormatError, DashboardNotFoundError
-from ..utils.logger import SupersetLogger
+from superset_tool.exceptions import InvalidZipFormatError
+from superset_tool.utils.logger import SupersetLogger
# [CONSTANTS]
ALLOWED_FOLDERS = {'databases', 'datasets', 'charts', 'dashboards'}
-
-# [CONTRACT] Временные ресурсы
+# CONTRACT:
+# PURPOSE: Контекстный менеджер для создания временного файла или директории, гарантирующий их удаление после использования.
+# PRECONDITIONS:
+# - `suffix` должен быть строкой, представляющей расширение файла или `.dir` для директории.
+# - `mode` должен быть валидным режимом для записи в файл (например, 'wb' для бинарного).
+# POSTCONDITIONS:
+# - Создает временный ресурс (файл или директорию).
+# - Возвращает объект `Path` к созданному ресурсу.
+# - Автоматически удаляет ресурс при выходе из контекста `with`.
+# PARAMETERS:
+# - content: Optional[bytes] - Бинарное содержимое для записи во временный файл.
+# - suffix: str - Суффикс для ресурса. Если `.dir`, создается директория.
+# - mode: str - Режим записи в файл.
+# - logger: Optional[SupersetLogger] - Экземпляр логгера.
+# YIELDS: Path - Путь к временному ресурсу.
+# EXCEPTIONS:
+# - Перехватывает и логирует `Exception`, затем выбрасывает его дальше.
@contextmanager
def create_temp_file(
content: Optional[bytes] = None,
@@ -44,331 +53,297 @@ def create_temp_file(
mode: str = 'wb',
logger: Optional[SupersetLogger] = None
) -> Path:
- """[CONTEXT-MANAGER] Создание временного файла/директории
- @pre:
- - suffix должен быть допустимым расширением
- - mode соответствует типу содержимого
- @post:
- - Возвращает Path созданного ресурса
- - Гарантирует удаление временного файла при выходе
- """
+ """Создает временный файл или директорию с автоматической очисткой."""
logger = logger or SupersetLogger(name="fileio", console=False)
+ temp_resource_path = None
+ is_dir = suffix.startswith('.dir')
try:
- if suffix.startswith('.dir'):
- with tempfile.TemporaryDirectory(suffix=suffix) as tmp_dir:
- logger.debug(f"Создана временная директория: {tmp_dir}")
- yield Path(tmp_dir)
+ if is_dir:
+ with tempfile.TemporaryDirectory(suffix=suffix) as temp_dir:
+ temp_resource_path = Path(temp_dir)
+ logger.debug(f"[DEBUG][TEMP_RESOURCE] Создана временная директория: {temp_resource_path}")
+ yield temp_resource_path
else:
with tempfile.NamedTemporaryFile(suffix=suffix, mode=mode, delete=False) as tmp:
+ temp_resource_path = Path(tmp.name)
if content:
tmp.write(content)
tmp.flush()
- logger.debug(f"Создан временный файл: {tmp.name}")
- yield Path(tmp.name)
- except Exception as e:
- logger.error(f"[TEMP_FILE_ERROR] Ошибка создания ресурса: {str(e)}", exc_info=True)
+ logger.debug(f"[DEBUG][TEMP_RESOURCE] Создан временный файл: {temp_resource_path}")
+ yield temp_resource_path
+ except IOError as e:
+ logger.error(f"[STATE][TEMP_RESOURCE] Ошибка создания временного ресурса: {str(e)}", exc_info=True)
raise
finally:
- if 'tmp' in locals() and Path(tmp.name).exists() and not suffix.startswith('.dir'):
- Path(tmp.name).unlink(missing_ok=True)
+ if temp_resource_path and temp_resource_path.exists():
+ if is_dir:
+ shutil.rmtree(temp_resource_path, ignore_errors=True)
+ logger.debug(f"[DEBUG][TEMP_CLEANUP] Удалена временная директория: {temp_resource_path}")
+ else:
+ temp_resource_path.unlink(missing_ok=True)
+ logger.debug(f"[DEBUG][TEMP_CLEANUP] Удален временный файл: {temp_resource_path}")
+# END_FUNCTION_create_temp_file
# [SECTION] Directory Management Utilities
+# CONTRACT:
+# PURPOSE: Рекурсивно удаляет все пустые поддиректории, начиная с указанной корневой директории.
+# PRECONDITIONS:
+# - `root_dir` должен быть строкой, представляющей существующий путь к директории.
+# POSTCONDITIONS:
+# - Все пустые директории внутри `root_dir` удалены.
+# - Непустые директории и файлы остаются нетронутыми.
+# PARAMETERS:
+# - root_dir: str - Путь к корневой директории для очистки.
+# - logger: Optional[SupersetLogger] - Экземпляр логгера.
+# RETURN: int - Количество удаленных директорий.
def remove_empty_directories(
root_dir: str,
- exclude: Optional[List[str]] = None,
logger: Optional[SupersetLogger] = None
) -> int:
- """[CONTRACT] Рекурсивное удаление пустых директорий
- @pre:
- - root_dir должен существовать и быть директорией
- - exclude не должен содержать некорректных символов
- @post:
- - Возвращает количество удаленных директорий
- - Не удаляет директории из списка exclude
- - Гарантирует рекурсивную обработку вложенных папок
- """
+ """Рекурсивно удаляет пустые директории."""
logger = logger or SupersetLogger(name="fileio", console=False)
- logger.info(f"[DIR_CLEANUP] Старт очистки пустых директорий в {root_dir}")
-
- excluded = set(exclude or [])
+ logger.info(f"[STATE][DIR_CLEANUP] Запуск очистки пустых директорий в {root_dir}")
+
removed_count = 0
root_path = Path(root_dir)
- # [VALIDATION] Проверка корневой директории
- if not root_path.exists():
- logger.error(f"[DIR_ERROR] Директория не существует: {root_dir}")
+ if not root_path.is_dir():
+ logger.error(f"[STATE][DIR_NOT_FOUND] Директория не существует или не является директорией: {root_dir}")
return 0
- try:
- # [PROCESSING] Рекурсивный обход снизу вверх
- for current_dir, _, files in os.walk(root_path, topdown=False):
- current_path = Path(current_dir)
-
- # [EXCLUSION] Пропуск исключенных директорий
- if any(excluded_part in current_path.parts for excluded_part in excluded):
- logger.debug(f"[DIR_SKIP] Пропущено по исключению: {current_dir}")
- continue
-
- # [REMOVAL] Удаление пустых директорий
- if not any(current_path.iterdir()):
- try:
- current_path.rmdir()
- removed_count += 1
- logger.info(f"[DIR_REMOVED] Удалена пустая директория: {current_dir}")
- except OSError as e:
- logger.error(f"[DIR_ERROR] Ошибка удаления {current_dir}: {str(e)}")
-
- except Exception as e:
- logger.error(f"[DIR_CLEANUP_ERROR] Критическая ошибка: {str(e)}", exc_info=True)
- raise
+ for current_dir, _, _ in os.walk(root_path, topdown=False):
+ if not os.listdir(current_dir):
+ try:
+ os.rmdir(current_dir)
+ removed_count += 1
+ logger.info(f"[STATE][DIR_REMOVED] Удалена пустая директория: {current_dir}")
+ except OSError as e:
+ logger.error(f"[STATE][DIR_REMOVE_FAILED] Ошибка удаления {current_dir}: {str(e)}")
- logger.info(f"[DIR_RESULT] Удалено {removed_count} пустых директорий")
+ logger.info(f"[STATE][DIR_CLEANUP_DONE] Удалено {removed_count} пустых директорий.")
return removed_count
-
+# END_FUNCTION_remove_empty_directories
# [SECTION] File Operations
+# CONTRACT:
+# PURPOSE: Читает бинарное содержимое файла с диска.
+# PRECONDITIONS:
+# - `file_path` должен быть строкой, представляющей существующий путь к файлу.
+# POSTCONDITIONS:
+# - Возвращает кортеж, содержащий бинарное содержимое файла и его имя.
+# PARAMETERS:
+# - file_path: str - Путь к файлу.
+# - logger: Optional[SupersetLogger] - Экземпляр логгера.
+# RETURN: Tuple[bytes, str] - (содержимое, имя_файла).
+# EXCEPTIONS:
+# - `FileNotFoundError`, если файл не найден.
def read_dashboard_from_disk(
file_path: str,
logger: Optional[SupersetLogger] = None
) -> Tuple[bytes, str]:
- """[CONTRACT] Чтение сохраненного дашборда с диска
- @pre:
- - file_path должен существовать
- - Файл должен быть доступен для чтения
- @post:
- - Возвращает (содержимое файла, имя файла)
- - Сохраняет целостность данных
- """
+ """Читает сохраненный дашборд с диска."""
logger = logger or SupersetLogger(name="fileio", console=False)
-
- try:
- path = Path(file_path)
- if not path.exists():
- raise FileNotFoundError(f"[FILE_MISSING] Файл дашборда не найден: {file_path}")
-
- logger.info(f"[FILE_READ] Чтение дашборда с диска: {file_path}")
-
- with open(file_path, "rb") as f:
- content = f.read()
-
- if not content:
- logger.warning("[FILE_EMPTY] Файл существует, но пуст")
-
- return content, path.name
-
- except Exception as e:
- logger.error(f"[FILE_READ_ERROR] Ошибка чтения: {str(e)}", exc_info=True)
- raise
+ path = Path(file_path)
+ if not path.is_file():
+ logger.error(f"[STATE][FILE_NOT_FOUND] Файл не найден: {file_path}")
+ raise FileNotFoundError(f"Файл дашборда не найден: {file_path}")
+ logger.info(f"[STATE][FILE_READ] Чтение файла с диска: {file_path}")
+ content = path.read_bytes()
+ if not content:
+ logger.warning(f"[STATE][FILE_EMPTY] Файл {file_path} пуст.")
+
+ return content, path.name
+# END_FUNCTION_read_dashboard_from_disk
# [SECTION] Archive Management
+# CONTRACT:
+# PURPOSE: Вычисляет контрольную сумму CRC32 для файла.
+# PRECONDITIONS:
+# - `file_path` должен быть валидным путем к существующему файлу.
+# POSTCONDITIONS:
+# - Возвращает строку с 8-значным шестнадцатеричным представлением CRC32.
+# PARAMETERS:
+# - file_path: Path - Путь к файлу.
+# RETURN: str - Контрольная сумма CRC32.
+# EXCEPTIONS:
+# - `FileNotFoundError`, `IOError` при ошибках I/O.
def calculate_crc32(file_path: Path) -> str:
- """[HELPER] Calculates the CRC32 checksum of a file.
- @pre:
- - file_path must be a valid path to a file.
- @post:
- - Returns the CRC32 checksum as a hexadecimal string.
- @raise:
- - FileNotFoundError: If the file does not exist.
- - Exception: For any other file I/O errors.
- """
+ """Вычисляет CRC32 контрольную сумму файла."""
try:
with open(file_path, 'rb') as f:
crc32_value = zlib.crc32(f.read())
- return hex(crc32_value)[2:].zfill(8) # Convert to hex string, remove "0x", and pad with zeros
+ return f"{crc32_value:08x}"
except FileNotFoundError:
- raise FileNotFoundError(f"File not found: {file_path}")
- except Exception as e:
- raise Exception(f"Error calculating CRC32 for {file_path}: {str(e)}")
+ raise
+ except IOError as e:
+ raise IOError(f"Ошибка вычисления CRC32 для {file_path}: {str(e)}") from e
+# END_FUNCTION_calculate_crc32
+@dataclass
+class RetentionPolicy:
+ """Политика хранения для архивов."""
+ daily: int = 7
+ weekly: int = 4
+ monthly: int = 12
+
+# CONTRACT:
+# PURPOSE: Управляет архивом экспортированных дашбордов, применяя политику хранения (ротацию) и дедупликацию.
+# PRECONDITIONS:
+# - `output_dir` должен быть существующей директорией.
+# POSTCONDITIONS:
+# - Устаревшие архивы удалены в соответствии с политикой.
+# - Дубликаты файлов (если `deduplicate=True`) удалены.
+# PARAMETERS:
+# - output_dir: str - Директория с архивами.
+# - policy: RetentionPolicy - Политика хранения.
+# - deduplicate: bool - Флаг для включения удаления дубликатов по CRC32.
+# - logger: Optional[SupersetLogger] - Экземпляр логгера.
def archive_exports(
output_dir: str,
- daily_retention: int = 7,
- weekly_retention: int = 4,
- monthly_retention: int = 12,
+ policy: RetentionPolicy,
deduplicate: bool = False,
logger: Optional[SupersetLogger] = None
) -> None:
- # [CONTRACT] Управление архивом экспортированных дашбордов
- # @pre:
- # - output_dir должен существовать
- # - Значения retention должны быть >= 0
- # @post:
- # - Сохраняет файлы согласно политике хранения
- # - Удаляет устаревшие архивы
- # - Логирует все действия
- # @raise:
- # - ValueError: Если retention параметры некорректны
- # - Exception: При любых других ошибках
+ """Управляет архивом экспортированных дашбордов."""
logger = logger or SupersetLogger(name="fileio", console=False)
- logger.info(f"[ARCHIVE] Starting archive cleanup in {output_dir}. Deduplication: {deduplicate}")
- # [DEBUG_ARCHIVE] Log input parameters
- logger.debug(f"[DEBUG_ARCHIVE] archive_exports called with: output_dir={output_dir}, daily={daily_retention}, weekly={weekly_retention}, monthly={monthly_retention}, deduplicate={deduplicate}")
+ output_path = Path(output_dir)
+ if not output_path.is_dir():
+ logger.warning(f"[WARN][ARCHIVE] Директория архива не найдена: {output_dir}")
+ return
- # [VALIDATION] Проверка параметров
- if not all(isinstance(x, int) and x >= 0 for x in [daily_retention, weekly_retention, monthly_retention]):
- raise ValueError("[CONFIG_ERROR] Retention values must be positive integers.")
+ logger.info(f"[INFO][ARCHIVE] Запуск управления архивом в {output_dir}")
- checksums = {} # Dictionary to store checksums and file paths
- try:
- export_dir = Path(output_dir)
-
- if not export_dir.exists():
- logger.error(f"[ARCHIVE_ERROR] Directory does not exist: {export_dir}")
- raise FileNotFoundError(f"Directory not found: {export_dir}")
-
- # [PROCESSING] Сбор информации о файлах
- files_with_dates = []
- zip_files_in_dir = list(export_dir.glob("*.zip"))
- # [DEBUG_ARCHIVE] Log number of zip files found
- logger.debug(f"[DEBUG_ARCHIVE] Found {len(zip_files_in_dir)} zip files in {export_dir}")
-
- for file in zip_files_in_dir:
- # [DEBUG_ARCHIVE] Log file being processed
- logger.debug(f"[DEBUG_ARCHIVE] Processing file: {file.name}")
+ # 1. Дедупликация
+ if deduplicate:
+ checksums = {}
+ duplicates_removed = 0
+ for file_path in output_path.glob('*.zip'):
try:
- timestamp_str = file.stem.split('_')[-1].split('T')[0]
- file_date = datetime.strptime(timestamp_str, "%Y%m%d").date()
- logger.debug(f"[DATE_PARSE] Файл {file.name} добавлен к анализу очистки (массив files_with_dates)")
- # [DEBUG_ARCHIVE] Log parsed date
- logger.debug(f"[DEBUG_ARCHIVE] Parsed date for {file.name}: {file_date}")
- except (ValueError, IndexError):
- file_date = datetime.fromtimestamp(file.stat().st_mtime).date()
- logger.warning(f"[DATE_PARSE] Using modification date for {file.name}")
- # [DEBUG_ARCHIVE] Log parsed date (modification date)
- logger.debug(f"[DEBUG_ARCHIVE] Parsed date for {file.name} (mod date): {file_date}")
+ crc32 = calculate_crc32(file_path)
+ if crc32 in checksums:
+ logger.info(f"[INFO][DEDUPLICATE] Найден дубликат: {file_path} (CRC32: {crc32}). Удаление.")
+ file_path.unlink()
+ duplicates_removed += 1
+ else:
+ checksums[crc32] = file_path
+ except (IOError, FileNotFoundError) as e:
+ logger.error(f"[ERROR][DEDUPLICATE] Ошибка обработки файла {file_path}: {e}")
+ logger.info(f"[INFO][DEDUPLICATE] Удалено дубликатов: {duplicates_removed}")
+ # 2. Политика хранения
+ try:
+ files_with_dates = []
+ for file_path in output_path.glob('*.zip'):
+ try:
+ # Извлекаем дату из имени файла, например 'dashboard_export_20231027_103000.zip'
+ match = re.search(r'(\d{8})', file_path.name)
+ if match:
+ file_date = datetime.strptime(match.group(1), "%Y%m%d").date()
+ files_with_dates.append((file_path, file_date))
+ except (ValueError, IndexError) as e:
+ logger.warning(f"[WARN][RETENTION] Не удалось извлечь дату из имени файла {file_path.name}: {e}")
- files_with_dates.append((file, file_date))
+ if not files_with_dates:
+ logger.info("[INFO][RETENTION] Не найдено файлов для применения политики хранения.")
+ return
-
- # [DEDUPLICATION]
- if deduplicate:
- logger.info("Начало дедупликации на основе контрольных сумм.")
- for file in files_with_dates:
- file_path = file[0]
- # [DEBUG_ARCHIVE] Log file being checked for deduplication
- logger.debug(f"[DEBUG_ARCHIVE][DEDUPLICATION] Checking file: {file_path.name}")
- try:
- crc32_checksum = calculate_crc32(file_path)
- if crc32_checksum in checksums:
- # Duplicate found, delete the older file
- logger.warning(f"[DEDUPLICATION] Duplicate found: {file_path}. Deleting.")
- # [DEBUG_ARCHIVE][DEDUPLICATION] Log duplicate found and deletion attempt
- logger.debug(f"[DEBUG_ARCHIVE][DEDUPLICATION] Duplicate found: {file_path.name}. Checksum: {crc32_checksum}. Attempting deletion.")
- file_path.unlink()
- else:
- checksums[crc32_checksum] = file_path
- # [DEBUG_ARCHIVE][DEDUPLICATION] Log file kept after deduplication check
- logger.debug(f"[DEBUG_ARCHIVE][DEDUPLICATION] Keeping file: {file_path.name}. Checksum: {crc32_checksum}.")
- except Exception as e:
- logger.error(f"[DEDUPLICATION_ERROR] Error processing {file_path}: {str(e)}", exc_info=True)
-
- # [PROCESSING] Применение политик хранения
- # [DEBUG_ARCHIVE] Log files before retention policy
- logger.debug(f"[DEBUG_ARCHIVE] Files with dates before retention policy: {[f.name for f, d in files_with_dates]}")
- keep_files = apply_retention_policy(
- files_with_dates,
- daily_retention,
- weekly_retention,
- monthly_retention,
- logger
- )
- # [DEBUG_ARCHIVE] Log files to keep after retention policy
- logger.debug(f"[DEBUG_ARCHIVE] Files to keep after retention policy: {[f.name for f in keep_files]}")
-
-
- # [CLEANUP] Удаление устаревших файлов
- deleted_count = 0
- files_to_delete = []
- files_to_keep = []
+ files_to_keep = apply_retention_policy(files_with_dates, policy, logger)
- for file, file_date in files_with_dates:
- # [DEBUG_ARCHIVE] Check file for deletion
- should_keep = file in keep_files
- logger.debug(f"[DEBUG_ARCHIVE] Checking file for deletion: {file.name} (date: {file_date}). Should keep: {should_keep}")
-
- if should_keep:
- files_to_keep.append(file.name)
- else:
- files_to_delete.append(file.name)
+ files_deleted = 0
+ for file_path, _ in files_with_dates:
+ if file_path not in files_to_keep:
try:
- # [DEBUG_ARCHIVE][FILE_REMOVED_ATTEMPT] Log deletion attempt
- logger.info(f"[DEBUG_ARCHIVE][FILE_REMOVED_ATTEMPT] Attempting to delete archive: {file.name}")
- file.unlink()
- deleted_count += 1
- logger.info(f"[FILE_REMOVED] Deleted archive: {file.name}")
+ file_path.unlink()
+ logger.info(f"[INFO][RETENTION] Удален устаревший архив: {file_path}")
+ files_deleted += 1
except OSError as e:
- # [DEBUG_ARCHIVE][FILE_ERROR] Log deletion error
- logger.error(f"[DEBUG_ARCHIVE][FILE_ERROR] Error deleting {file.name}: {str(e)}", exc_info=True)
+ logger.error(f"[ERROR][RETENTION] Не удалось удалить файл {file_path}: {e}")
- logger.debug(f"[DEBUG_ARCHIVE] Summary - Files to keep: {files_to_keep}")
- logger.debug(f"[DEBUG_ARCHIVE] Summary - Files to delete: {files_to_delete}")
-
-
- logger.info(f"[ARCHIVE_RESULT] Cleanup completed. Deleted {deleted_count} archives.")
+ logger.info(f"[INFO][RETENTION] Политика хранения применена. Удалено файлов: {files_deleted}.")
except Exception as e:
- logger.error(f"[ARCHIVE_ERROR] Critical error during archive cleanup: {str(e)}", exc_info=True)
- raise
+ logger.error(f"[CRITICAL][ARCHIVE] Критическая ошибка при управлении архивом: {e}", exc_info=True)
+# END_FUNCTION_archive_exports
+# CONTRACT:
+# PURPOSE: (HELPER) Применяет политику хранения к списку файлов с датами.
+# PRECONDITIONS:
+# - `files_with_dates` - список кортежей (Path, date).
+# POSTCONDITIONS:
+# - Возвращает множество объектов `Path`, которые должны быть сохранены.
+# PARAMETERS:
+# - files_with_dates: List[Tuple[Path, date]] - Список файлов.
+# - policy: RetentionPolicy - Политика хранения.
+# - logger: SupersetLogger - Логгер.
+# RETURN: set - Множество файлов для сохранения.
def apply_retention_policy(
files_with_dates: List[Tuple[Path, date]],
- daily: int,
- weekly: int,
- monthly: int,
+ policy: RetentionPolicy,
logger: SupersetLogger
) -> set:
- """[HELPER] Применение политик хранения файлов
- @pre:
- - files_with_dates должен содержать валидные даты
- @post:
- - Возвращает set файлов для сохранения
- - Соответствует указанным retention-правилам
- """
- # [GROUPING] Группировка файлов
- daily_groups = defaultdict(list)
- weekly_groups = defaultdict(list)
- monthly_groups = defaultdict(list)
+ """(HELPER) Применяет политику хранения к списку файлов."""
+ if not files_with_dates:
+ return set()
+
+ today = date.today()
+ files_to_keep = set()
+
+ # Сортируем файлы от новых к старым
+ files_with_dates.sort(key=lambda x: x[1], reverse=True)
+
+ # Группируем по дням, неделям, месяцам
+ daily_backups = {}
+ weekly_backups = {}
+ monthly_backups = {}
+
+ for file_path, file_date in files_with_dates:
+ # Daily
+ if (today - file_date).days < policy.daily:
+ if file_date not in daily_backups:
+ daily_backups[file_date] = file_path
+
+ # Weekly
+ week_key = file_date.isocalendar()[:2] # (year, week)
+ if week_key not in weekly_backups:
+ weekly_backups[week_key] = file_path
+
+ # Monthly
+ month_key = (file_date.year, file_date.month)
+ if month_key not in monthly_backups:
+ monthly_backups[month_key] = file_path
+
+ # Собираем файлы для сохранения, применяя лимиты
+ files_to_keep.update(list(daily_backups.values())[:policy.daily])
+ files_to_keep.update(list(weekly_backups.values())[:policy.weekly])
+ files_to_keep.update(list(monthly_backups.values())[:policy.monthly])
- logger.debug(f"[RETENTION_DEBUG] Processing {len(files_with_dates)} files for retention policy")
-
- for file, file_date in files_with_dates:
- daily_groups[file_date].append(file)
- weekly_groups[(file_date.isocalendar().year, file_date.isocalendar().week)].append(file)
- monthly_groups[(file_date.year, file_date.month)].append(file)
-
- logger.debug(f"[RETENTION_DEBUG] Grouped into {len(daily_groups)} daily groups, {len(weekly_groups)} weekly groups, {len(monthly_groups)} monthly groups")
+ logger.info(f"[INFO][RETENTION_POLICY] Файлов для сохранения после применения политики: {len(files_to_keep)}")
- # [SELECTION] Выбор файлов для сохранения
- keep_files = set()
-
- # Daily - последние N дней
- sorted_daily = sorted(daily_groups.keys(), reverse=True)[:daily]
- logger.debug(f"[RETENTION_DEBUG] Daily groups to keep: {sorted_daily}")
- for day in sorted_daily:
- keep_files.update(daily_groups[day])
+ return files_to_keep
+# END_FUNCTION_apply_retention_policy
- # Weekly - последние N недель
- sorted_weekly = sorted(weekly_groups.keys(), reverse=True)[:weekly]
- logger.debug(f"[RETENTION_DEBUG] Weekly groups to keep: {sorted_weekly}")
- for week in sorted_weekly:
- keep_files.update(weekly_groups[week])
-
- # Monthly - последние N месяцев
- sorted_monthly = sorted(monthly_groups.keys(), reverse=True)[:monthly]
- logger.debug(f"[RETENTION_DEBUG] Monthly groups to keep: {sorted_monthly}")
- for month in sorted_monthly:
- keep_files.update(monthly_groups[month])
-
- logger.debug(f"[RETENTION] Сохранено файлов: {len(keep_files)}")
- logger.debug(f"[RETENTION_DEBUG] Files to keep: {[f.name for f in keep_files]}")
- return keep_files
-
-# [CONTRACT] Сохранение и распаковка дашборда
+# CONTRACT:
+# PURPOSE: Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его.
+# PRECONDITIONS:
+# - `zip_content` должен быть валидным содержимым ZIP-файла в байтах.
+# - `output_dir` должен быть путем, доступным для записи.
+# POSTCONDITIONS:
+# - ZIP-архив сохранен в `output_dir`.
+# - Если `unpack=True`, архив распакован в ту же директорию.
+# - Возвращает пути к созданному ZIP-файлу и, если применимо, к директории с распакованным содержимым.
+# PARAMETERS:
+# - zip_content: bytes - Содержимое ZIP-архива.
+# - output_dir: Union[str, Path] - Директория для сохранения.
+# - unpack: bool - Флаг, нужно ли распаковывать архив.
+# - original_filename: Optional[str] - Исходное имя файла.
+# - logger: Optional[SupersetLogger] - Экземпляр логгера.
+# RETURN: Tuple[Path, Optional[Path]] - (путь_к_zip, путь_к_распаковке_или_None).
+# EXCEPTIONS:
+# - `InvalidZipFormatError` при ошибке формата ZIP.
def save_and_unpack_dashboard(
zip_content: bytes,
output_dir: Union[str, Path],
@@ -376,30 +351,23 @@ def save_and_unpack_dashboard(
original_filename: Optional[str] = None,
logger: Optional[SupersetLogger] = None
) -> Tuple[Path, Optional[Path]]:
- """[OPERATION] Обработка ZIP-архива дашборда
- @pre:
- - zip_content должен быть валидным ZIP
- - output_dir должен существовать или быть возможным для создания
- @post:
- - Возвращает (путь_к_архиву, путь_распаковки) или (путь_к_архиву, None)
- - Сохраняет оригинальную структуру файлов
- """
+ """Сохраняет и опционально распаковывает ZIP-архив дашборда."""
logger = logger or SupersetLogger(name="fileio", console=False)
- logger.info(f"Старт обработки дашборда. Распаковка: {unpack}")
+ logger.info(f"[STATE] Старт обработки дашборда. Распаковка: {unpack}")
try:
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
- logger.debug(f"Директория {output_path} создана/проверена")
+ logger.debug(f"[DEBUG] Директория {output_path} создана/проверена")
zip_name = sanitize_filename(original_filename) if original_filename else None
if not zip_name:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_name = f"dashboard_export_{timestamp}.zip"
- logger.debug(f"Сгенерировано имя файла: {zip_name}")
+ logger.debug(f"[DEBUG] Сгенерировано имя файла: {zip_name}")
zip_path = output_path / zip_name
- logger.info(f"Сохранение дашборда в: {zip_path}")
+ logger.info(f"[STATE] Сохранение дашборда в: {zip_path}")
with open(zip_path, "wb") as f:
f.write(zip_content)
@@ -407,650 +375,304 @@ def save_and_unpack_dashboard(
if unpack:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(output_path)
- logger.info(f"Дашборд распакован в: {output_path}")
+ logger.info(f"[STATE] Дашборд распакован в: {output_path}")
return zip_path, output_path
return zip_path, None
except zipfile.BadZipFile as e:
- logger.error(f"[ZIP_ERROR] Невалидный ZIP-архив: {str(e)}")
+ logger.error(f"[STATE][ZIP_ERROR] Невалидный ZIP-архив: {str(e)}")
raise InvalidZipFormatError(f"Invalid ZIP file: {str(e)}") from e
except Exception as e:
- logger.error(f"[UNPACK_ERROR] Ошибка обработки: {str(e)}", exc_info=True)
+ logger.error(f"[STATE][UNPACK_ERROR] Ошибка обработки: {str(e)}", exc_info=True)
raise
+# END_FUNCTION_save_and_unpack_dashboard
-def print_directory(
- root_dir: str,
- logger: Optional[SupersetLogger] = None
+# CONTRACT:
+# PURPOSE: (HELPER) Рекурсивно обрабатывает значения в YAML-структуре, применяя замену по регулярному выражению.
+# PRECONDITIONS: `value` может быть строкой, словарем или списком.
+# POSTCONDITIONS: Возвращает кортеж с флагом о том, было ли изменение, и новым значением.
+# PARAMETERS:
+# - name: value, type: Any, description: Значение для обработки.
+# - name: regexp_pattern, type: str, description: Паттерн для поиска.
+# - name: replace_string, type: str, description: Строка для замены.
+# RETURN: type: Tuple[bool, Any]
+def _process_yaml_value(value: Any, regexp_pattern: str, replace_string: str) -> Tuple[bool, Any]:
+ matched = False
+ if isinstance(value, str):
+ new_str = re.sub(regexp_pattern, replace_string, value)
+ matched = new_str != value
+ return matched, new_str
+ if isinstance(value, dict):
+ new_dict = {}
+ for k, v in value.items():
+ sub_matched, sub_val = _process_yaml_value(v, regexp_pattern, replace_string)
+ new_dict[k] = sub_val
+ if sub_matched:
+ matched = True
+ return matched, new_dict
+ if isinstance(value, list):
+ new_list = []
+ for item in value:
+ sub_matched, sub_val = _process_yaml_value(item, regexp_pattern, replace_string)
+ new_list.append(sub_val)
+ if sub_matched:
+ matched = True
+ return matched, new_list
+ return False, value
+# END_FUNCTION__process_yaml_value
+
+# CONTRACT:
+# PURPOSE: (HELPER) Обновляет один YAML файл на основе предоставленных конфигураций.
+# PRECONDITIONS:
+# - `file_path` - существующий YAML файл.
+# - `db_configs` - список словарей для замены.
+# POSTCONDITIONS: Файл обновлен.
+# PARAMETERS:
+# - name: file_path, type: Path, description: Путь к YAML файлу.
+# - name: db_configs, type: Optional[List[Dict]], description: Конфигурации для замены.
+# - name: regexp_pattern, type: Optional[str], description: Паттерн для поиска.
+# - name: replace_string, type: Optional[str], description: Строка для замены.
+# - name: logger, type: SupersetLogger, description: Экземпляр логгера.
+# RETURN: type: None
+def _update_yaml_file(
+ file_path: Path,
+ db_configs: Optional[List[Dict]],
+ regexp_pattern: Optional[str],
+ replace_string: Optional[str],
+ logger: SupersetLogger
) -> None:
- """[CONTRACT] Визуализация структуры директории в древовидном формате
- @pre:
- - root_dir должен быть валидным путем к директории
- @post:
- - Выводит в консоль и логи структуру директории
- - Не модифицирует файловую систему
- @errors:
- - ValueError если путь не существует или не является директорией
- """
- logger = logger or SupersetLogger(name="fileio", console=False)
- logger.debug(f"[DIR_TREE] Начало построения дерева для {root_dir}")
-
try:
- root_path = Path(root_dir)
-
- # [VALIDATION] Проверка существования и типа
- if not root_path.exists():
- raise ValueError(f"Путь не существует: {root_dir}")
- if not root_path.is_dir():
- raise ValueError(f"Указан файл вместо директории: {root_dir}")
+ with open(file_path, 'r', encoding='utf-8') as f:
+ data = yaml.safe_load(f)
- # [OUTPUT] Форматированный вывод
- print(f"\n{root_dir}/")
- with os.scandir(root_dir) as entries:
- entries = sorted(entries, key=lambda e: e.name)
- for idx, entry in enumerate(entries):
- is_last = idx == len(entries) - 1
- prefix = " └── " if is_last else " ├── "
- suffix = "/" if entry.is_dir() else ""
- print(f"{prefix}{entry.name}{suffix}")
+ updates = {}
- logger.info(f"[DIR_TREE] Успешно построено дерево для {root_dir}")
+ if db_configs:
+ for config in db_configs:
+ if config is not None:
+ if "old" not in config or "new" not in config:
+ raise ValueError("db_config должен содержать оба раздела 'old' и 'new'")
- except Exception as e:
- error_msg = f"[DIR_TREE_ERROR] Ошибка визуализации: {str(e)}"
- logger.error(error_msg, exc_info=True)
- raise ValueError(error_msg) from e
+ old_config = config.get("old", {})
+ new_config = config.get("new", {})
+ if len(old_config) != len(new_config):
+ raise ValueError(
+ f"Количество элементов в 'old' ({old_config}) и 'new' ({new_config}) не совпадает"
+ )
-def validate_directory_structure(
- root_dir: str,
- logger: Optional[SupersetLogger] = None
-) -> bool:
- """[CONTRACT] Валидация структуры директории экспорта Superset
- @pre:
- - root_dir должен быть валидным путем
- @post:
- - Возвращает True если структура соответствует требованиям:
- 1. Ровно один подкаталог верхнего уровня
- 2. Наличие metadata.yaml
- 3. Допустимые имена поддиректорий (databases/datasets/charts/dashboards)
- @errors:
- - ValueError при некорректном пути
- """
- logger = logger or SupersetLogger(name="fileio", console=False)
- logger.info(f"[DIR_VALIDATION] Валидация структуры в {root_dir}")
+ for key in old_config:
+ if key in data and data[key] == old_config[key]:
+ new_value = new_config.get(key)
+ if new_value is not None and new_value != data.get(key):
+ updates[key] = new_value
- try:
- root_path = Path(root_dir)
-
- # [BASE VALIDATION]
- if not root_path.exists():
- raise ValueError(f"Директория не существует: {root_dir}")
- if not root_path.is_dir():
- raise ValueError(f"Требуется директория, получен файл: {root_dir}")
+ if regexp_pattern and replace_string is not None:
+ _, processed_data = _process_yaml_value(data, regexp_pattern, replace_string)
+ for key in processed_data:
+ if processed_data.get(key) != data.get(key):
+ updates[key] = processed_data[key]
- root_items = os.listdir(root_dir)
-
- # [CHECK 1] Ровно один подкаталог верхнего уровня
- if len(root_items) != 1:
- logger.warning(f"[VALIDATION_FAIL] Ожидается 1 подкаталог, найдено {len(root_items)}")
- return False
+ if updates:
+ logger.info(f"[STATE] Обновление {file_path}: {updates}")
+ data.update(updates)
- subdir_path = root_path / root_items[0]
-
- # [CHECK 2] Должен быть подкаталог
- if not subdir_path.is_dir():
- logger.warning(f"[VALIDATION_FAIL] {root_items[0]} не является директорией")
- return False
+ with open(file_path, 'w', encoding='utf-8') as file:
+ yaml.dump(
+ data,
+ file,
+ default_flow_style=False,
+ sort_keys=False
+ )
- # [CHECK 3] Проверка metadata.yaml
- if "metadata.yaml" not in os.listdir(subdir_path):
- logger.warning("[VALIDATION_FAIL] Отсутствует metadata.yaml")
- return False
+ except yaml.YAMLError as e:
+ logger.error(f"[STATE][YAML_ERROR] Ошибка парсинга {file_path}: {str(e)}")
+# END_FUNCTION__update_yaml_file
- # [CHECK 4] Валидация поддиректорий
- found_folders = set()
- for item in os.listdir(subdir_path):
- if item == "metadata.yaml":
- continue
-
- item_path = subdir_path / item
- if not item_path.is_dir():
- logger.warning(f"[VALIDATION_FAIL] {item} не является директорией")
- return False
-
- if item not in ALLOWED_FOLDERS:
- logger.warning(f"[VALIDATION_FAIL] Недопустимая директория: {item}")
- return False
-
- if item in found_folders:
- logger.warning(f"[VALIDATION_FAIL] Дубликат директории: {item}")
- return False
-
- found_folders.add(item)
-
- # [FINAL CHECK]
- valid_structure = (
- 1 <= len(found_folders) <= 4 and
- all(folder in ALLOWED_FOLDERS for folder in found_folders)
- )
-
- if not valid_structure:
- logger.warning(
- f"[VALIDATION_FAIL] Некорректный набор директорий: {found_folders}"
- )
-
- return valid_structure
-
- except Exception as e:
- error_msg = f"[DIR_VALIDATION_ERROR] Критическая ошибка: {str(e)}"
- logger.error(error_msg, exc_info=True)
- raise ValueError(error_msg) from e
-
-# [CONTRACT] Создание ZIP-архива
-def create_dashboard_export(
- zip_path: Union[str, Path],
- source_paths: List[Union[str, Path]],
- exclude_extensions: Optional[List[str]] = None,
- validate_source: bool = False,
- logger: Optional[SupersetLogger] = None
-) -> bool:
- """[OPERATION] Упаковка дашборда в архив
- @pre:
- - source_paths должны существовать
- - Должны быть права на запись в zip_path
- @post:
- - Возвращает True если создание успешно
- - Сохраняет оригинальную структуру папок
- """
- logger = logger or SupersetLogger(name="fileio", console=False)
- logger.info(f"Упаковка дашбордов: {source_paths} -> {zip_path}")
-
- try:
- exclude_ext = [ext.lower() for ext in exclude_extensions] if exclude_extensions else []
-
- with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
- for path in source_paths:
- path = Path(path)
- if not path.exists():
- raise FileNotFoundError(f"Путь не найден: {path}")
-
- for item in path.rglob('*'):
- if item.is_file() and item.suffix.lower() not in exclude_ext:
- arcname = item.relative_to(path.parent)
- zipf.write(item, arcname)
- logger.debug(f"Добавлен в архив: {arcname}")
-
- logger.info(f"Архив создан: {zip_path}")
- return True
-
- except Exception as e:
- logger.error(f"[ZIP_CREATION_ERROR] Ошибка: {str(e)}", exc_info=True)
- return False
-
-
-# [UTILITY] Валидация имен файлов
-def sanitize_filename(filename: str) -> str:
- """[UTILITY] Очистка имени файла от опасных символов
- @post:
- - Возвращает безопасное имя файла без спецсимволов
- """
- return re.sub(r'[\\/*?:"<>|]', "_", filename).strip()
-
-
-def get_filename_from_headers(headers: dict) -> Optional[str]:
- """Извлекает имя файла из заголовков HTTP-ответа"""
- content_disposition = headers.get("Content-Disposition", "")
-
- # Пытаемся найти имя файла в кавычках
- filename_match = re.findall(r'filename="(.+?)"', content_disposition)
- if not filename_match:
- # Пробуем без кавычек
- filename_match = re.findall(r'filename=([^;]+)', content_disposition)
-
- if filename_match:
- return filename_match[0].strip('"')
- return None
-
-def determine_and_load_yaml_type(file_path):
- with open(file_path, 'r') as f:
- data = yaml.safe_load(f)
-
- if 'dashboard_title' in data and 'position' in data:
- return data, 'dashboard'
- elif 'sqlalchemy_uri' in data and 'database_name' in data:
- return data, 'database'
- elif 'table_name' in data and ('sql' in data or 'columns' in data):
- return data, 'dataset'
- elif 'slice_name' in data and 'viz_type' in data:
- return data, 'chart'
- else:
- return data, 'unknown'
-
-# [CONTRACT] Управление конфигурациями YAML
+# [ENTITY: Function('update_yamls')]
+# CONTRACT:
+# PURPOSE: Обновляет конфигурации в YAML-файлах баз данных, заменяя старые значения на новые, а также применяя замены по регулярному выражению.
+# SPECIFICATION_LINK: func_update_yamls
+# PRECONDITIONS:
+# - `path` должен быть валидным путем к директории с YAML файлами.
+# - `db_configs` должен быть списком словарей, каждый из которых содержит ключи 'old' и 'new'.
+# POSTCONDITIONS: Все найденные YAML файлы в директории `path` обновлены в соответствии с предоставленными конфигурациями.
+# PARAMETERS:
+# - name: db_configs, type: Optional[List[Dict]], description: Список конфигураций для замены.
+# - name: path, type: str, description: Путь к директории с YAML файлами.
+# - name: regexp_pattern, type: Optional[LiteralString], description: Паттерн для поиска.
+# - name: replace_string, type: Optional[LiteralString], description: Строка для замены.
+# - name: logger, type: Optional[SupersetLogger], description: Экземпляр логгера.
+# RETURN: type: None
def update_yamls(
- db_configs: Optional[List[Dict]] = None,
+ db_configs: Optional[List[Dict]] = None,
path: str = "dashboards",
regexp_pattern: Optional[LiteralString] = None,
replace_string: Optional[LiteralString] = None,
logger: Optional[SupersetLogger] = None
) -> None:
- """
- [OPERATION] Обновление YAML-конфигов
- @pre:
- - path должен содержать валидные YAML-файлы
- - db_configs должен содержать old/new состояния
- @post:
- - Все YAML-файлы обновлены согласно конфигурациям
- - Сохраняется оригинальная структура файлов
-
- Обновляет конфигурации в YAML-файлах баз данных, заменяя старые значения на новые.
- Поддерживает два типа замен:
- 1. Точечную замену значений по ключам из db_config
- 2. Регулярные выражения для замены текста во всех строковых полях
-
- Параметры:
- :db_configs: Список словарей или словарь с параметрами для замены в формате:
- {
- "old": {старые_ключи: значения_для_поиска},
- "new": {новые_ключи: значения_для_замены}
- }
- Если не указан - используется только замена по регулярным выражениям
- :path: Путь к папке с YAML-файлами (по умолчанию "dashboards")
- :regexp_pattern: Регулярное выражение для поиска текста (опционально)
- :replace_string: Строка для замены найденного текста (используется с regexp_pattern)
- :logger: Логгер для записи событий (по умолчанию создается новый)
-
- Логирует:
- - Информационные сообщения о начале процесса и успешных обновлениях
- - Ошибки обработки отдельных файлов
- - Критические ошибки, прерывающие выполнение
-
- Пример использования:
- update_yamls(
- db_config={
- "old": {"host": "old.db.example.com"},
- "new": {"host": "new.db.example.com"}
- },
- regexp_pattern="old\.",
- replace_string="new."
- )
- """
-
logger = logger or SupersetLogger(name="fileio", console=False)
- logger.info("[YAML_UPDATE] Старт обновления конфигураций")
+ logger.info("[STATE][YAML_UPDATE] Старт обновления конфигураций")
- # Преобразуем единственный конфиг в список для универсальности
if isinstance(db_configs, dict):
db_configs = [db_configs]
elif db_configs is None:
db_configs = []
-
- try:
- dir = Path(path)
- if not dir.exists() or not dir.is_dir():
+ try:
+ dir_path = Path(path)
+
+ if not dir_path.exists() or not dir_path.is_dir():
raise FileNotFoundError(f"Путь {path} не существует или не является директорией")
-
- yaml_files = dir.rglob("*.yaml")
+
+ yaml_files = dir_path.rglob("*.yaml")
for file_path in yaml_files:
- try:
- result = determine_and_load_yaml_type(file_path)
-
- data, yaml_type = result if result else ({}, None)
- logger.debug(f"Тип {file_path} - {yaml_type}")
+ _update_yaml_file(file_path, db_configs, regexp_pattern, replace_string, logger)
- updates = {}
-
- # 1. Обработка замен по ключам из db_config (если нужно использовать только новые значения)
- if db_configs:
- for config in db_configs:
- #Валидация конфига
- if config is not None:
- if "old" not in config or "new" not in config:
- raise ValueError("db_config должен содержать оба раздела 'old' и 'new'")
-
- old_config = config.get("old", {}) # Значения для поиска
- new_config = config.get("new", {}) # Значения для замены
-
- if len(old_config) != len(new_config):
- raise ValueError(
- f"Количество элементов в 'old' ({old_config}) и 'new' ({new_config}) не совпадает"
- )
-
- for key in old_config:
- if key in data and data[key] == old_config[key]:
- new_value = new_config.get(key)
- if new_value is not None and new_value != data.get(key):
- updates[key] = new_value # Заменяем без проверки старого значения
-
- # 2. Регулярная замена (с исправленной функцией process_value)
- if regexp_pattern:
- def process_value(value: Any) -> Tuple[bool, Any]:
- """Рекурсивная обработка с флагом замены."""
- matched = False
- if isinstance(value, str):
- new_str = re.sub(regexp_pattern, replace_string, value)
- matched = (new_str != value)
- return matched, new_str
- elif isinstance(value, dict):
- new_dict = {}
- for k, v in value.items():
- sub_matched, sub_val = process_value(v)
- new_dict[k] = sub_val
- if sub_matched:
- matched = True
- return matched, new_dict
- elif isinstance(value, list):
- new_list = []
- for item in value:
- sub_matched, sub_val = process_value(item)
- new_list.append(sub_val)
- if sub_matched:
- matched = True
- return matched, new_list
- return False, value # Нет замены для других типов
-
- # Применяем обработку ко всем данным
- _, processed_data = process_value(data)
- # Собираем обновления только для изменившихся полей
- for key in processed_data:
- if processed_data[key] != data.get(key):
- updates[key] = processed_data[key]
-
- if updates:
- logger.info(f"Обновление {file_path}: {updates}")
- data.update(updates)
-
- with open(file_path, 'w') as file:
- yaml.dump(
- data,
- file,
- default_flow_style=False,
- sort_keys=False
- )
-
- except yaml.YAMLError as e:
- logger.error(f"[YAML_ERROR] Ошибка парсинга {file_path}: {str(e)}")
-
- except Exception as e:
- logger.error(f"[YAML_UPDATE_ERROR] Критическая ошибка: {str(e)}", exc_info=True)
+ except (IOError, ValueError) as e:
+ logger.error(f"[STATE][YAML_UPDATE_ERROR] Критическая ошибка: {str(e)}", exc_info=True)
raise
+# END_FUNCTION_update_yamls
+# [ENTITY: Function('create_dashboard_export')]
+# CONTRACT:
+# PURPOSE: Создает ZIP-архив дашборда из указанных исходных путей.
+# SPECIFICATION_LINK: func_create_dashboard_export
+# PRECONDITIONS:
+# - `zip_path` - валидный путь для сохранения архива.
+# - `source_paths` - список существующих путей к файлам/директориям для архивации.
+# POSTCONDITIONS: Возвращает `True` в случае успешного создания архива, иначе `False`.
+# PARAMETERS:
+# - name: zip_path, type: Union[str, Path], description: Путь для сохранения ZIP архива.
+# - name: source_paths, type: List[Union[str, Path]], description: Список исходных путей.
+# - name: exclude_extensions, type: Optional[List[str]], description: Список исключаемых расширений.
+# - name: logger, type: Optional[SupersetLogger], description: Экземпляр логгера.
+# RETURN: type: bool
+def create_dashboard_export(
+ zip_path: Union[str, Path],
+ source_paths: List[Union[str, Path]],
+ exclude_extensions: Optional[List[str]] = None,
+ logger: Optional[SupersetLogger] = None
+) -> bool:
+ logger = logger or SupersetLogger(name="fileio", console=False)
+ logger.info(f"[STATE] Упаковка дашбордов: {source_paths} -> {zip_path}")
+
+ try:
+ exclude_ext = [ext.lower() for ext in exclude_extensions] if exclude_extensions else []
+
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
+ for path in source_paths:
+ path = Path(path)
+ if not path.exists():
+ raise FileNotFoundError(f"Путь не найден: {path}")
+
+ for item in path.rglob('*'):
+ if item.is_file() and item.suffix.lower() not in exclude_ext:
+ arcname = item.relative_to(path.parent)
+ zipf.write(item, arcname)
+ logger.debug(f"[DEBUG] Добавлен в архив: {arcname}")
+
+ logger.info(f"[STATE]архив создан: {zip_path}")
+ return True
+
+ except (IOError, zipfile.BadZipFile) as e:
+ logger.error(f"[STATE][ZIP_CREATION_ERROR] Ошибка: {str(e)}", exc_info=True)
+ return False
+# END_FUNCTION_create_dashboard_export
+
+# [ENTITY: Function('sanitize_filename')]
+# CONTRACT:
+# PURPOSE: Очищает строку, предназначенную для имени файла, от недопустимых символов.
+# SPECIFICATION_LINK: func_sanitize_filename
+# PRECONDITIONS: `filename` является строкой.
+# POSTCONDITIONS: Возвращает строку, безопасную для использования в качестве имени файла.
+# PARAMETERS:
+# - name: filename, type: str, description: Исходное имя файла.
+# RETURN: type: str
+def sanitize_filename(filename: str) -> str:
+ return re.sub(r'[\\/*?:"<>|]', "_", filename).strip()
+# END_FUNCTION_sanitize_filename
+
+# [ENTITY: Function('get_filename_from_headers')]
+# CONTRACT:
+# PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
+# SPECIFICATION_LINK: func_get_filename_from_headers
+# PRECONDITIONS: `headers` - словарь HTTP заголовков.
+# POSTCONDITIONS: Возвращает имя файла или `None`, если оно не найдено.
+# PARAMETERS:
+# - name: headers, type: dict, description: Словарь HTTP заголовков.
+# RETURN: type: Optional[str]
+def get_filename_from_headers(headers: dict) -> Optional[str]:
+ content_disposition = headers.get("Content-Disposition", "")
+ filename_match = re.findall(r'filename="(.+?)"', content_disposition)
+ if not filename_match:
+ filename_match = re.findall(r'filename=([^;]+)', content_disposition)
+ if filename_match:
+ return filename_match[0].strip('"')
+ return None
+# END_FUNCTION_get_filename_from_headers
+
+# [ENTITY: Function('consolidate_archive_folders')]
+# CONTRACT:
+# PURPOSE: Консолидирует директории архивов дашбордов на основе общего слага в имени.
+# SPECIFICATION_LINK: func_consolidate_archive_folders
+# PRECONDITIONS: `root_directory` - существующая директория.
+# POSTCONDITIONS: Содержимое всех директорий с одинаковым слагом переносится в самую последнюю измененную директорию.
+# PARAMETERS:
+# - name: root_directory, type: Path, description: Корневая директория для консолидации.
+# - name: logger, type: Optional[SupersetLogger], description: Экземпляр логгера.
+# RETURN: type: None
def consolidate_archive_folders(root_directory: Path, logger: Optional[SupersetLogger] = None) -> None:
- """
- Consolidates dashboard folders under a root directory based on the slug (pattern MM-0080 - two latin letters - hyphen - 4 digits)
- and moves the contents to the folder with the latest modification date.
-
- Args:
- root_directory (Path): The root directory containing the dashboard folders.
-
- Raises:
- TypeError: If root_directory is not a Path object.
- ValueError: If root_directory is empty.
-
- [CONTRACT]
- @pre: root_directory must be a valid Path object representing an existing directory.
- @post: The contents of all folders matching the slug pattern are moved to the folder with the latest modification date for each slug.
- @invariant: The slug pattern remains consistent throughout the execution.
- @raise: TypeError if root_directory is not a Path, ValueError if root_directory is empty.
- """
-
- # [CONTRACT] Ensure valid input
+ logger = logger or SupersetLogger(name="fileio", console=False)
if not isinstance(root_directory, Path):
raise TypeError("root_directory must be a Path object.")
- if not root_directory:
- raise ValueError("root_directory cannot be empty.")
-
- logger.debug(f"[DEBUG] Checking root_folder: {root_directory}")
+ if not root_directory.is_dir():
+ raise ValueError("root_directory must be an existing directory.")
- # [SECTION] Define the slug pattern
- slug_pattern = re.compile(r"([A-Z]{2}-\d{4})") # Capture the first occurrence of the pattern
+ logger.debug("[DEBUG] Checking root_folder: {root_directory}")
+
+ slug_pattern = re.compile(r"([A-Z]{2}-\d{4})")
- # [SECTION] Group folders by slug
dashboards_by_slug: dict[str, list[str]] = {}
for folder_name in glob.glob(os.path.join(root_directory, '*')):
if os.path.isdir(folder_name):
- logger.debug(f"[DEBUG] Checking folder: {folder_name}") # Debug log: show the folder name being checked
+ logger.debug(f"[DEBUG] Checking folder: {folder_name}")
match = slug_pattern.search(folder_name)
if match:
- slug = match.group(1) # Extract the captured group (the slug)
- logger.info(f"[INFO] Found slug: {slug} in folder: {folder_name}") #Log when slug is matched
- logger.debug(f"[DEBUG] Regex match object: {match}") # Log the complete match object
+ slug = match.group(1)
+ logger.info(f"[STATE] Found slug: {slug} in folder: {folder_name}")
if slug not in dashboards_by_slug:
dashboards_by_slug[slug] = []
dashboards_by_slug[slug].append(folder_name)
else:
- logger.debug(f"[DEBUG] No slug found in folder: {folder_name}") # Debug log: show when slug is not matched
+ logger.debug(f"[DEBUG] No slug found in folder: {folder_name}")
else:
- logger.debug(f"[DEBUG] Not a directory: {folder_name}") #debug log for when its not a directory
+ logger.debug(f"[DEBUG] Not a directory: {folder_name}")
- # [SECTION] Check if any slugs were found
if not dashboards_by_slug:
- logger.warning("[WARN] No folders found matching the slug pattern.")
+ logger.warning("[STATE] No folders found matching the slug pattern.")
return
- # [SECTION] Iterate through each slug group
for slug, folder_list in dashboards_by_slug.items():
- # [ACTION] Find the folder with the latest modification date
latest_folder = max(folder_list, key=os.path.getmtime)
- logger.info(f"[INFO] Latest folder for slug {slug}: {latest_folder}")
+ logger.info(f"[STATE] Latest folder for slug {slug}: {latest_folder}")
- # [SECTION] Move contents of other folders to the latest folder
for folder in folder_list:
if folder != latest_folder:
- # [ACTION] Move contents
try:
for item in os.listdir(folder):
s = os.path.join(folder, item)
d = os.path.join(latest_folder, item)
- if os.path.isdir(s):
- shutil.move(s, d)
- else:
- shutil.move(s, d)
+ shutil.move(s, d)
+ logger.info(f"[STATE] Moved contents of {folder} to {latest_folder}")
+ shutil.rmtree(folder) # Remove empty folder
+ logger.info(f"[STATE] Removed empty folder: {folder}")
+ except (IOError, shutil.Error) as e:
+ logger.error(f"[STATE] Failed to move contents of {folder} to {latest_folder}: {e}", exc_info=True)
- logger.info(f"[INFO] Moved contents of {folder} to {latest_folder}")
- except Exception as e:
- logger.error(f"[ERROR] Failed to move contents of {folder} to {latest_folder}: {e}", exc_info=True)
-
- logger.info("[INFO] Dashboard consolidation completed.")
- # [COHERENCE_CHECK_PASSED] Function executed successfully and all contracts were met.
-
-def sync_for_git(
- source_path: str,
- destination_path: str,
- dry_run: bool = False,
- logger: Optional[SupersetLogger] = None
-) -> None:
- """[CONTRACT] Синхронизация контента между директориями с учетом Git
- @pre:
- - source_path должен существовать и быть директорией
- - destination_path должен быть допустимым путем
- - Исходная директория должна содержать валидную структуру Superset
- @post:
- - Полностью заменяет содержимое destination_path (кроме .git)
- - Сохраняет оригинальные разрешения файлов
- - Логирует все изменения при dry_run=True
- @errors:
- - ValueError при несоответствии структуры source_path
- - RuntimeError при ошибках файловых операций
- """
- logger = logger or SupersetLogger(name="fileio", console=False)
- logger.info(
- "[SYNC_START] Запуск синхронизации",
- extra={
- "source": source_path,
- "destination": destination_path,
- "dry_run": dry_run
- }
- )
-
- try:
- # [VALIDATION] Проверка исходной директории
- if not validate_directory_structure(source_path, logger):
- raise ValueError(f"Invalid source structure: {source_path}")
-
- src_path = Path(source_path)
- dst_path = Path(destination_path)
-
- # [PREPARATION] Сбор информации о файлах
- source_files = get_file_mapping(src_path)
- destination_files = get_file_mapping(dst_path)
-
- # [SYNC OPERATIONS]
- operations = {
- 'copied': 0,
- 'removed': 0,
- 'skipped': 0
- }
-
- # Копирование/обновление файлов
- operations.update(process_copy_operations(
- src_path,
- dst_path,
- source_files,
- destination_files,
- dry_run,
- logger
- ))
-
- # Удаление устаревших файлов
- operations.update(process_cleanup_operations(
- dst_path,
- source_files,
- destination_files,
- dry_run,
- logger
- ))
-
- # [RESULT]
- logger.info(
- "[SYNC_RESULT] Итоги синхронизации",
- extra=operations
- )
-
- except Exception as e:
- error_msg = f"[SYNC_FAILED] Ошибка синхронизации: {str(e)}"
- logger.error(error_msg, exc_info=True)
- raise RuntimeError(error_msg) from e
-
-
-# [HELPER] Получение карты файлов
-def get_file_mapping(root_path: Path) -> Dict[str, Path]:
- """[UTILITY] Генерация словаря файлов относительно корня
- @post:
- - Возвращает Dict[relative_path: Path]
- - Игнорирует .git директории
- """
- file_map = {}
- for item in root_path.rglob("*"):
- if ".git" in item.parts:
- continue
- rel_path = item.relative_to(root_path)
- file_map[str(rel_path)] = item
- return file_map
-
-
-# [HELPER] Обработка копирования
-def process_copy_operations(
- src_path: Path,
- dst_path: Path,
- source_files: Dict[str, Path],
- destination_files: Dict[str, Path],
- dry_run: bool,
- logger: SupersetLogger
-) -> Dict[str, int]:
- """[OPERATION] Выполнение операций копирования
- @post:
- - Возвращает счетчики операций {'copied': X, 'skipped': Y}
- - Создает все необходимые поддиректории
- """
- counters = {'copied': 0, 'skipped': 0}
-
- for rel_path, src_file in source_files.items():
- dst_file = dst_path / rel_path
-
- # Проверка необходимости обновления
- if rel_path in destination_files:
- if filecmp.cmp(src_file, dst_file, shallow=False):
- counters['skipped'] += 1
- continue
-
- # Dry-run логирование
- if dry_run:
- logger.debug(
- f"[DRY_RUN] Будет скопирован: {rel_path}",
- extra={'operation': 'copy'}
- )
- continue
-
- # Реальное копирование
- try:
- dst_file.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(src_file, dst_file)
- counters['copied'] += 1
- logger.debug(f"Скопирован: {rel_path}")
- except Exception as copy_error:
- logger.error(
- f"[COPY_ERROR] Ошибка копирования {rel_path}: {str(copy_error)}",
- exc_info=True
- )
- raise
-
- return counters
-
-
-# [HELPER] Обработка удаления
-def process_cleanup_operations(
- dst_path: Path,
- source_files: Dict[str, Path],
- destination_files: Dict[str, Path],
- dry_run: bool,
- logger: SupersetLogger
-) -> Dict[str, int]:
- """[OPERATION] Удаление устаревших файлов
- @post:
- - Возвращает счетчики {'removed': X}
- - Гарантированно не удаляет .git
- """
- counters = {'removed': 0}
- files_to_delete = set(destination_files.keys()) - set(source_files.keys())
- git_dir = dst_path / ".git"
-
- for rel_path in files_to_delete:
- target = dst_path / rel_path
-
- # Защита .git
- try:
- if git_dir in target.parents or target == git_dir:
- logger.debug(f"Сохранен .git: {target}")
- continue
- except ValueError: # Для случаев некорректных путей
- continue
-
- # Dry-run логирование
- if dry_run:
- logger.debug(
- f"[DRY_RUN] Будет удален: {rel_path}",
- extra={'operation': 'delete'}
- )
- continue
-
- # Реальное удаление
- try:
- if target.is_file():
- target.unlink()
- elif target.is_dir():
- shutil.rmtree(target)
- counters['removed'] += 1
- logger.debug(f"Удален: {rel_path}")
- except Exception as remove_error:
- logger.error(
- f"[REMOVE_ERROR] Ошибка удаления {target}: {str(remove_error)}",
- exc_info=True
- )
- raise
-
- return counters
+ logger.info("[STATE] Dashboard consolidation completed.")
+# END_FUNCTION_consolidate_archive_folders
+# END_MODULE_fileio
\ No newline at end of file
diff --git a/superset_tool/utils/init_clients.py b/superset_tool/utils/init_clients.py
index 8d86316..5fe51a4 100644
--- a/superset_tool/utils/init_clients.py
+++ b/superset_tool/utils/init_clients.py
@@ -1,100 +1,71 @@
-# [MODULE] Superset Init clients
-# @contract: Автоматизирует процесс инициализации клиентов для использования скриптами.
-# @semantic_layers:
-# 1. Инициализация логгера и клиентов Superset.
-# @coherence:
-# - Использует `SupersetClient` для взаимодействия с API Superset.
-# - Использует `SupersetLogger` для централизованного логирования.
-# - Интегрируется с `keyring` для безопасного хранения паролей.
-
-# [IMPORTS] Стандартная библиотека
-import logging
-from datetime import datetime
-from pathlib import Path
+# [MODULE] Superset Clients Initializer
+# PURPOSE: Централизованно инициализирует клиенты Superset для различных окружений (DEV, PROD, SBX, PREPROD).
+# COHERENCE:
+# - Использует `SupersetClient` для создания экземпляров клиентов.
+# - Использует `SupersetLogger` для логирования процесса.
+# - Интегрируется с `keyring` для безопасного получения паролей.
# [IMPORTS] Сторонние библиотеки
import keyring
+from typing import Dict
# [IMPORTS] Локальные модули
from superset_tool.models import SupersetConfig
from superset_tool.client import SupersetClient
from superset_tool.utils.logger import SupersetLogger
-
-# [FUNCTION] setup_clients
-# @contract: Инициализирует и возвращает SupersetClient для каждого заданного окружения.
-# @pre:
-# - `keyring` должен содержать необходимые пароли для "dev migrate", "prod migrate", "sandbox migrate".
-# - `logger` должен быть инициализирован.
-# @post:
-# - Возвращает словарь {env_name: SupersetClient_instance}.
-# - Логирует успешную инициализацию или ошибку.
-# @raise:
-# - `Exception`: При любой ошибке в процессе инициализации клиентов (например, отсутствие пароля в keyring, проблемы с сетью при первой аутентификации).
-def setup_clients(logger: SupersetLogger):
- """Инициализация клиентов для разных окружений"""
+# CONTRACT:
+# PURPOSE: Инициализирует и возвращает словарь клиентов `SupersetClient` для всех предопределенных окружений.
+# PRECONDITIONS:
+# - `keyring` должен содержать пароли для систем "dev migrate", "prod migrate", "sandbox migrate", "preprod migrate".
+# - `logger` должен быть инициализированным экземпляром `SupersetLogger`.
+# POSTCONDITIONS:
+# - Возвращает словарь, где ключи - это имена окружений ('dev', 'sbx', 'prod', 'preprod'),
+# а значения - соответствующие экземпляры `SupersetClient`.
+# PARAMETERS:
+# - logger: SupersetLogger - Экземпляр логгера для записи процесса инициализации.
+# RETURN: Dict[str, SupersetClient] - Словарь с инициализированными клиентами.
+# EXCEPTIONS:
+# - Логирует и выбрасывает `Exception` при любой ошибке (например, отсутствие пароля, ошибка подключения).
+def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]:
+ """Инициализирует и настраивает клиенты для всех окружений Superset."""
# [ANCHOR] CLIENTS_INITIALIZATION
+ logger.info("[INFO][INIT_CLIENTS_START] Запуск инициализации клиентов Superset.")
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"
+ }
+
try:
- # [INFO] Инициализация конфигурации для Dev
- dev_config = SupersetConfig(
- base_url="https://devta.bi.dwh.rusal.com/api/v1",
- auth={
- "provider": "db",
- "username": "migrate_user",
- "password": keyring.get_password("system", "dev migrate"),
- "refresh": True
- },
- verify_ssl=False
- )
- # [DEBUG] Dev config created: {dev_config.base_url}
+ for env_name, base_url in environments.items():
+ logger.debug(f"[DEBUG][CONFIG_CREATE] Создание конфигурации для окружения: {env_name.upper()}")
+ password = keyring.get_password("system", f"{env_name} migrate")
+ if not password:
+ raise ValueError(f"Пароль для '{env_name} migrate' не найден в keyring.")
- # [INFO] Инициализация конфигурации для Prod
- prod_config = SupersetConfig(
- base_url="https://prodta.bi.dwh.rusal.com/api/v1",
- auth={
- "provider": "db",
- "username": "migrate_user",
- "password": keyring.get_password("system", "prod migrate"),
- "refresh": True
- },
- verify_ssl=False
- )
- # [DEBUG] Prod config created: {prod_config.base_url}
+ config = SupersetConfig(
+ base_url=base_url,
+ auth={
+ "provider": "db",
+ "username": "migrate_user",
+ "password": password,
+ "refresh": True
+ },
+ verify_ssl=False
+ )
+
+ clients[env_name] = SupersetClient(config, logger)
+ logger.debug(f"[DEBUG][CLIENT_SUCCESS] Клиент для {env_name.upper()} успешно создан.")
- # [INFO] Инициализация конфигурации для Sandbox
- sandbox_config = SupersetConfig(
- base_url="https://sandboxta.bi.dwh.rusal.com/api/v1",
- auth={
- "provider": "db",
- "username": "migrate_user",
- "password": keyring.get_password("system", "sandbox migrate"),
- "refresh": True
- },
- verify_ssl=False
- )
- # [DEBUG] Sandbox config created: {sandbox_config.base_url}
-
- # [INFO] Инициализация конфигурации для Preprod
- preprod_config = SupersetConfig(
- base_url="https://preprodta.bi.dwh.rusal.com/api/v1",
- auth={
- "provider": "db",
- "username": "migrate_user",
- "password": keyring.get_password("system", "preprod migrate"),
- "refresh": True
- },
- verify_ssl=False
- )
- # [DEBUG] Sandbox config created: {sandbox_config.base_url}
-
- # [INFO] Создание экземпляров SupersetClient
- clients['dev'] = SupersetClient(dev_config, logger)
- clients['sbx'] = SupersetClient(sandbox_config,logger)
- clients['prod'] = SupersetClient(prod_config,logger)
- clients['preprod'] = SupersetClient(preprod_config,logger)
- logger.info("[COHERENCE_CHECK_PASSED] Клиенты для окружений успешно инициализированы", extra={"envs": list(clients.keys())})
+ logger.info(f"[COHERENCE_CHECK_PASSED][INIT_CLIENTS_SUCCESS] Все клиенты ({', '.join(clients.keys())}) успешно инициализированы.")
return clients
+
except Exception as e:
- logger.error(f"[ERROR] Ошибка инициализации клиентов: {str(e)}", exc_info=True)
- raise
\ No newline at end of file
+ logger.error(f"[CRITICAL][INIT_CLIENTS_FAILED] Ошибка при инициализации клиентов: {str(e)}", exc_info=True)
+ raise
+# END_FUNCTION_setup_clients
+# END_MODULE_init_clients
\ No newline at end of file
diff --git a/superset_tool/utils/logger.py b/superset_tool/utils/logger.py
index 0e1fd6b..59111f0 100644
--- a/superset_tool/utils/logger.py
+++ b/superset_tool/utils/logger.py
@@ -1,9 +1,6 @@
# [MODULE] Superset Tool Logger Utility
-# @contract: Этот модуль предоставляет утилиту для настройки логирования в приложении.
-# @semantic_layers:
-# - [CONFIG]: Настройка логгера.
-# - [UTILITY]: Вспомогательные функции.
-# @coherence: Модуль должен быть семантически когерентен со стандартной библиотекой `logging`.
+# PURPOSE: Предоставляет стандартизированный класс-обертку `SupersetLogger` для настройки и использования логирования в проекте.
+# COHERENCE: Модуль согласован со стандартной библиотекой `logging`, расширяя ее для нужд проекта.
import logging
import sys
@@ -11,8 +8,20 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
-# [CONSTANTS]
-
+# CONTRACT:
+# PURPOSE: Обеспечивает унифицированную настройку логгера с выводом в консоль и/или файл.
+# PRECONDITIONS:
+# - `name` должен быть строкой.
+# - `level` должен быть валидным уровнем логирования (например, `logging.INFO`).
+# POSTCONDITIONS:
+# - Создает и настраивает логгер с указанным именем и уровнем.
+# - Добавляет обработчики для вывода в файл (если указан `log_dir`) и в консоль (если `console=True`).
+# - Очищает все предыдущие обработчики для данного логгера, чтобы избежать дублирования.
+# PARAMETERS:
+# - name: str - Имя логгера.
+# - log_dir: Optional[Path] - Директория для сохранения лог-файлов.
+# - level: int - Уровень логирования.
+# - console: bool - Флаг для включения вывода в консоль.
class SupersetLogger:
def __init__(
self,
@@ -23,34 +32,40 @@ class SupersetLogger:
):
self.logger = logging.getLogger(name)
self.logger.setLevel(level)
-
+
formatter = logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s'
)
- # Очищаем существующие обработчики
- if self.logger.handlers:
- for handler in self.logger.handlers[:]:
- self.logger.removeHandler(handler)
+ # [ANCHOR] HANDLER_RESET
+ # Очищаем существующие обработчики, чтобы избежать дублирования вывода при повторной инициализации.
+ if self.logger.hasHandlers():
+ self.logger.handlers.clear()
- # Файловый обработчик
+ # [ANCHOR] FILE_HANDLER
if log_dir:
log_dir.mkdir(parents=True, exist_ok=True)
+ timestamp = datetime.now().strftime("%Y%m%d")
file_handler = logging.FileHandler(
- log_dir / f"{name}_{self._get_timestamp()}.log"
+ log_dir / f"{name}_{timestamp}.log", encoding='utf-8'
)
file_handler.setFormatter(formatter)
self.logger.addHandler(file_handler)
- # Консольный обработчик
+ # [ANCHOR] CONSOLE_HANDLER
if console:
- console_handler = logging.StreamHandler()
+ console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler)
-
+
+ # CONTRACT:
+ # PURPOSE: (HELPER) Генерирует строку с текущей датой для имени лог-файла.
+ # RETURN: str - Отформатированная дата (YYYYMMDD).
def _get_timestamp(self) -> str:
return datetime.now().strftime("%Y%m%d")
+ # END_FUNCTION__get_timestamp
+ # [INTERFACE] Методы логирования
def info(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
self.logger.info(message, extra=extra, exc_info=exc_info)
@@ -59,47 +74,15 @@ class SupersetLogger:
def warning(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
self.logger.warning(message, extra=extra, exc_info=exc_info)
-
+
def critical(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
self.logger.critical(message, extra=extra, exc_info=exc_info)
def debug(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
self.logger.debug(message, extra=extra, exc_info=exc_info)
- def exception(self, message: str):
- self.logger.exception(message)
+ def exception(self, message: str, *args, **kwargs):
+ self.logger.exception(message, *args, **kwargs)
+# END_CLASS_SupersetLogger
-def setup_logger(name: str, level: int = logging.INFO) -> logging.Logger:
- # [FUNCTION] setup_logger
- # [CONTRACT]
- """
- Настраивает и возвращает логгер с заданным именем и уровнем.
-
- @pre:
- - `name` является непустой строкой.
- - `level` является допустимым уровнем логирования из модуля `logging`.
- @post:
- - Возвращает настроенный экземпляр `logging.Logger`.
- - Логгер имеет StreamHandler, выводящий в sys.stdout.
- - Форматтер логгера включает время, уровень, имя и сообщение.
- @side_effects:
- - Создает и добавляет StreamHandler к логгеру.
- @invariant:
- - Логгер с тем же именем всегда возвращает один и тот же экземпляр.
- """
- # [CONFIG] Настройка логгера
- # [COHERENCE_CHECK_PASSED] Логика настройки соответствует описанию.
- logger = logging.getLogger(name)
- logger.setLevel(level)
-
- # Создание форматтера
- formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s')
-
- # Проверка наличия существующих обработчиков
- if not logger.handlers:
- # Создание StreamHandler для вывода в sys.stdout
- handler = logging.StreamHandler(sys.stdout)
- handler.setFormatter(formatter)
- logger.addHandler(handler)
-
- return logger
+# END_MODULE_logger
diff --git a/superset_tool/utils/network.py b/superset_tool/utils/network.py
index da26385..062e5fd 100644
--- a/superset_tool/utils/network.py
+++ b/superset_tool/utils/network.py
@@ -1,15 +1,11 @@
-# [MODULE] Сетевой клиент для API
-# @contract: Инкапсулирует низкоуровневую HTTP-логику, аутентификацию, повторные попытки и обработку сетевых ошибок.
-# @semantic_layers:
-# 1. Инициализация сессии `requests` с настройками SSL и таймаутов.
-# 2. Управление аутентификацией (получение и обновление access/CSRF токенов).
-# 3. Выполнение HTTP-запросов (GET, POST и т.д.) с автоматическими заголовками.
-# 4. Обработка пагинации для API-ответов.
-# 5. Обработка загрузки файлов.
-# @coherence:
-# - Полностью независим от `SupersetClient`, предоставляя ему чистый API для сетевых операций.
-# - Использует `SupersetLogger` для внутреннего логирования.
-# - Всегда выбрасывает типизированные исключения из `superset_tool.exceptions`.
+# -*- coding: utf-8 -*-
+# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument
+"""
+[MODULE] Сетевой клиент для API
+
+[DESCRIPTION]
+Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API.
+"""
# [IMPORTS] Стандартная библиотека
from typing import Optional, Dict, Any, BinaryIO, List, Union
@@ -19,173 +15,106 @@ from pathlib import Path
# [IMPORTS] Сторонние библиотеки
import requests
-import urllib3 # Для отключения SSL-предупреждений
+import urllib3 # Для отключения SSL-предупреждений
# [IMPORTS] Локальные модули
-from ..exceptions import AuthenticationError, NetworkError, DashboardNotFoundError, SupersetAPIError, PermissionDeniedError
-from .logger import SupersetLogger # Импорт логгера
+from superset_tool.exceptions import (
+ AuthenticationError,
+ NetworkError,
+ DashboardNotFoundError,
+ SupersetAPIError,
+ PermissionDeniedError
+)
+from superset_tool.utils.logger import SupersetLogger # Импорт логгера
# [CONSTANTS]
DEFAULT_RETRIES = 3
DEFAULT_BACKOFF_FACTOR = 0.5
+DEFAULT_TIMEOUT = 30
class APIClient:
- """[NETWORK-CORE] Инкапсулирует HTTP-логику для работы с API.
- @contract:
- - Гарантирует retry-механизмы для запросов.
- - Выполняет SSL-валидацию или отключает ее по конфигурации.
- - Автоматически управляет access и CSRF токенами.
- - Преобразует HTTP-ошибки в типизированные исключения `superset_tool.exceptions`.
- @pre:
- - `base_url` должен быть валидным URL.
- - `auth` должен содержать необходимые данные для аутентификации.
- - `logger` должен быть инициализирован.
- @post:
- - Аутентификация выполняется при первом запросе или явно через `authenticate()`.
- - `self._tokens` всегда содержит актуальные access/CSRF токены после успешной аутентификации.
- @invariant:
- - Сессия `requests` активна и настроена.
- - Все запросы используют актуальные токены.
- """
+ """[NETWORK-CORE] Инкапсулирует HTTP-логику для работы с API."""
+
def __init__(
self,
- base_url: str,
- auth: Dict[str, Any],
+ config: Dict[str, Any],
verify_ssl: bool = True,
- timeout: int = 30,
+ timeout: int = DEFAULT_TIMEOUT,
logger: Optional[SupersetLogger] = None
):
- # [INIT] Основные параметры
- self.base_url = base_url
- self.auth = auth
- self.verify_ssl = verify_ssl
- self.timeout = timeout
- self.logger = logger or SupersetLogger(name="APIClient") # [COHERENCE_CHECK_PASSED] Инициализация логгера
-
- # [INIT] Сессия Requests
+ self.logger = logger or SupersetLogger(name="APIClient")
+ self.logger.info("[INFO][APIClient.__init__][ENTER] Initializing APIClient.")
+ self.base_url = 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] = {} # [STATE] Хранилище токенов
- self._authenticated = False # [STATE] Флаг аутентификации
-
- self.logger.debug(
- "[INIT] APIClient инициализирован.",
- extra={"base_url": self.base_url, "verify_ssl": self.verify_ssl}
- )
+ self._tokens: Dict[str, str] = {}
+ self._authenticated = False
+ self.logger.info("[INFO][APIClient.__init__][SUCCESS] APIClient initialized.")
def _init_session(self) -> requests.Session:
- """[HELPER] Настройка сессии `requests` с адаптерами и SSL-опциями.
- @semantic: Создает и конфигурирует объект `requests.Session`.
- """
+ self.logger.debug("[DEBUG][APIClient._init_session][ENTER] Initializing session.")
session = requests.Session()
- # [CONTRACT] Настройка повторных попыток
retries = requests.adapters.Retry(
total=DEFAULT_RETRIES,
backoff_factor=DEFAULT_BACKOFF_FACTOR,
status_forcelist=[500, 502, 503, 504],
allowed_methods={"HEAD", "GET", "POST", "PUT", "DELETE"}
)
- session.mount('http://', requests.adapters.HTTPAdapter(max_retries=retries))
- session.mount('https://', requests.adapters.HTTPAdapter(max_retries=retries))
-
- session.verify = self.verify_ssl
- if not self.verify_ssl:
+ adapter = requests.adapters.HTTPAdapter(max_retries=retries)
+ session.mount('http://', adapter)
+ session.mount('https://', adapter)
+ verify_ssl = self.request_settings.get("verify_ssl", True)
+ session.verify = verify_ssl
+ if not verify_ssl:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
- self.logger.warning("[SECURITY] Отключена проверка SSL-сертификатов. Не использовать в продакшене без явной необходимости.")
+ self.logger.warning("[WARNING][APIClient._init_session][STATE_CHANGE] SSL verification disabled.")
+ self.logger.debug("[DEBUG][APIClient._init_session][SUCCESS] Session initialized.")
return session
def authenticate(self) -> Dict[str, str]:
- """[AUTH-FLOW] Получение access и CSRF токенов.
- @pre:
- - `self.auth` содержит валидные учетные данные.
- @post:
- - `self._tokens` обновлен актуальными токенами.
- - Возвращает обновленные токены.
- - `self._authenticated` устанавливается в `True`.
- @raise:
- - `AuthenticationError`: При ошибках аутентификации (неверные credentials, проблемы с API security).
- - `NetworkError`: При проблемах с сетью.
- """
- self.logger.info(f"[AUTH] Попытка аутентификации для {self.base_url}")
+ self.logger.info(f"[INFO][APIClient.authenticate][ENTER] Authenticating to {self.base_url}")
try:
- # Шаг 1: Получение access_token
login_url = f"{self.base_url}/security/login"
response = self.session.post(
login_url,
- json=self.auth, # Используем self.auth, который уже имеет "provider": "db", "refresh": True
- timeout=self.timeout
+ json=self.auth,
+ timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT)
)
- response.raise_for_status() # Выбросит HTTPError для 4xx/5xx ответов
+ response.raise_for_status()
access_token = response.json()["access_token"]
- self.logger.debug("[AUTH] Access token успешно получен.")
-
- # Шаг 2: Получение CSRF токена
csrf_url = f"{self.base_url}/security/csrf_token/"
csrf_response = self.session.get(
csrf_url,
headers={"Authorization": f"Bearer {access_token}"},
- timeout=self.timeout
+ timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT)
)
csrf_response.raise_for_status()
csrf_token = csrf_response.json()["result"]
- self.logger.debug("[AUTH] CSRF token успешно получен.")
-
- # [STATE] Сохранение токенов и обновление флага
self._tokens = {
"access_token": access_token,
"csrf_token": csrf_token
}
self._authenticated = True
- self.logger.info("[COHERENCE_CHECK_PASSED] Аутентификация успешно завершена.")
+ self.logger.info("[INFO][APIClient.authenticate][SUCCESS] Authenticated successfully.")
return self._tokens
-
except requests.exceptions.HTTPError as e:
- error_msg = f"HTTP Error during authentication: {e.response.status_code} - {e.response.text}"
- self.logger.error(f"[AUTH_FAILED] {error_msg}", exc_info=True)
- if e.response.status_code == 401: # Unauthorized
- raise AuthenticationError(
- f"Неверные учетные данные или истекший токен.",
- url=login_url, username=self.auth.get("username"),
- status_code=e.response.status_code, response_text=e.response.text
- ) from e
- elif e.response.status_code == 403: # Forbidden
- raise PermissionDeniedError(
- "Недостаточно прав для аутентификации.",
- url=login_url, username=self.auth.get("username"),
- status_code=e.response.status_code, response_text=e.response.text
- ) from e
- else:
- raise SupersetAPIError(
- f"API ошибка при аутентификации: {error_msg}",
- url=login_url, status_code=e.response.status_code, response_text=e.response.text
- ) from e
- except requests.exceptions.RequestException as e:
- self.logger.error(f"[NETWORK_ERROR] Сетевая ошибка при аутентификации: {str(e)}", exc_info=True)
- raise NetworkError(f"Ошибка сети при аутентификации: {str(e)}", url=login_url) from e
- except KeyError as e:
- self.logger.error(f"[AUTH_FAILED] Некорректный формат ответа при аутентификации: {str(e)}", exc_info=True)
- raise AuthenticationError(f"Некорректный формат ответа API при аутентификации: {str(e)}") from e
- except Exception as e:
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка аутентификации: {str(e)}", exc_info=True)
- raise AuthenticationError(f"Непредвиденная ошибка аутентификации: {str(e)}") from e
+ self.logger.error(f"[ERROR][APIClient.authenticate][FAILURE] Authentication failed: {e}")
+ raise AuthenticationError(f"Authentication failed: {e}") from e
+ except (requests.exceptions.RequestException, KeyError) as e:
+ self.logger.error(f"[ERROR][APIClient.authenticate][FAILURE] Network or parsing error: {e}")
+ raise NetworkError(f"Network or parsing error during authentication: {e}") from e
@property
def headers(self) -> Dict[str, str]:
- """[INTERFACE] Возвращает стандартные заголовки с текущими токенами.
- @semantic: Если токены не получены, пытается выполнить аутентификацию.
- @post: Всегда возвращает словарь с 'Authorization' и 'X-CSRFToken'.
- @raise: `AuthenticationError` если аутентификация невозможна.
- """
if not self._authenticated:
- self.authenticate() # Попытка аутентификации при первом запросе заголовков
-
- # [CONTRACT] Проверка наличия токенов
- if not self._tokens or "access_token" not in self._tokens or "csrf_token" not in self._tokens:
- self.logger.error("[CONTRACT_VIOLATION] Токены отсутствуют после попытки аутентификации.", extra={"tokens": self._tokens})
- raise AuthenticationError("Не удалось получить токены для заголовков.")
-
+ self.authenticate()
return {
"Authorization": f"Bearer {self._tokens['access_token']}",
- "X-CSRFToken": self._tokens["csrf_token"],
+ "X-CSRFToken": self._tokens.get("csrf_token", ""),
"Referer": self.base_url,
"Content-Type": "application/json"
}
@@ -198,180 +127,95 @@ class APIClient:
raw_response: bool = False,
**kwargs
) -> Union[requests.Response, Dict[str, Any]]:
- """[NETWORK-CORE] Обертка для всех HTTP-запросов к Superset API.
- @semantic:
- - Выполняет запрос с заданными параметрами.
- - Автоматически добавляет базовые заголовки (токены, CSRF).
- - Обрабатывает HTTP-ошибки и преобразует их в типизированные исключения.
- - В случае 401/403, пытается обновить токен и повторить запрос один раз.
- @pre:
- - `method` - валидный HTTP-метод ('GET', 'POST', 'PUT', 'DELETE').
- - `endpoint` - валидный путь API.
- @post:
- - Возвращает объект `requests.Response` (если `raw_response=True`) или `dict` (JSON-ответ).
- @raise:
- - `AuthenticationError`, `PermissionDeniedError`, `NetworkError`, `SupersetAPIError`, `DashboardNotFoundError`.
- """
+ self.logger.debug(f"[DEBUG][APIClient.request][ENTER] Requesting {method} {endpoint}")
full_url = f"{self.base_url}{endpoint}"
- self.logger.debug(f"[REQUEST] Выполнение запроса: {method} {full_url}", extra={"kwargs_keys": list(kwargs.keys())})
-
- # [STATE] Заголовки для текущего запроса
- _headers = self.headers.copy() # Получаем базовые заголовки с актуальными токенами
- if headers: # Объединяем с переданными кастомными заголовками (переданные имеют приоритет)
+ _headers = self.headers.copy()
+ if headers:
_headers.update(headers)
-
- retries_left = 1 # Одна попытка на обновление токена
- while retries_left >= 0:
- try:
- response = self.session.request(
- method,
- full_url,
- headers=_headers,
- #timeout=self.timeout,
- **kwargs
- )
- response.raise_for_status() # Проверяем статус сразу
- self.logger.debug(f"[COHERENCE_CHECK_PASSED] Запрос {method} {endpoint} успешно выполнен.")
- return response if raw_response else response.json()
+ try:
+ response = self.session.request(
+ method,
+ full_url,
+ headers=_headers,
+ timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT),
+ **kwargs
+ )
+ response.raise_for_status()
+ self.logger.debug(f"[DEBUG][APIClient.request][SUCCESS] Request successful for {method} {endpoint}")
+ return response if raw_response else response.json()
+ except requests.exceptions.HTTPError as e:
+ self.logger.error(f"[ERROR][APIClient.request][FAILURE] HTTP error for {method} {endpoint}: {e}")
+ self._handle_http_error(e, endpoint, context={})
+ except requests.exceptions.RequestException as e:
+ self.logger.error(f"[ERROR][APIClient.request][FAILURE] Network error for {method} {endpoint}: {e}")
+ self._handle_network_error(e, full_url)
- except requests.exceptions.HTTPError as e:
- status_code = e.response.status_code
- error_context = {
- "method": method,
- "url": full_url,
- "status_code": status_code,
- "response_text": e.response.text
- }
-
- if status_code in [401, 403] and retries_left > 0:
- self.logger.warning(f"[AUTH_REFRESH] Токен истек или недействителен ({status_code}). Попытка обновить и повторить...", extra=error_context)
- try:
- self.authenticate() # Попытка обновить токены
- _headers = self.headers.copy() # Обновляем заголовки с новыми токенами
- if headers:
- _headers.update(headers)
- retries_left -= 1
- continue # Повторяем цикл
- except AuthenticationError as auth_err:
- self.logger.error("[AUTH_FAILED] Не удалось обновить токены.", exc_info=True)
- raise PermissionDeniedError("Аутентификация не удалась или права отсутствуют после обновления токена.", **error_context) from auth_err
-
- # [ERROR_MAPPING] Преобразование стандартных HTTP-ошибок в кастомные исключения
- if status_code == 404:
- raise DashboardNotFoundError(endpoint, context=error_context) from e
- elif status_code == 403:
- raise PermissionDeniedError("Доступ запрещен.", **error_context) from e
- elif status_code == 401:
- raise AuthenticationError("Аутентификация не удалась.", **error_context) from e
- else:
- raise SupersetAPIError(f"Ошибка API: {status_code} - {e.response.text}", **error_context) from e
-
- except requests.exceptions.Timeout as e:
- self.logger.error(f"[NETWORK_ERROR] Таймаут запроса: {str(e)}", exc_info=True, extra={"url": full_url})
- raise NetworkError("Таймаут запроса", url=full_url) from e
- except requests.exceptions.ConnectionError as e:
- self.logger.error(f"[NETWORK_ERROR] Ошибка соединения: {str(e)}", exc_info=True, extra={"url": full_url})
- raise NetworkError("Ошибка соединения", url=full_url) from e
- except requests.exceptions.RequestException as e:
- self.logger.critical(f"[CRITICAL] Неизвестная ошибка запроса: {str(e)}", exc_info=True, extra={"url": full_url})
- raise NetworkError(f"Неизвестная сетевая ошибка: {str(e)}", url=full_url) from e
- except json.JSONDecodeError as e:
- self.logger.error(f"[API_FAILED] Ошибка парсинга JSON ответа: {str(e)}", exc_info=True, extra={"url": full_url, "response_text_sample": response.text[:200]})
- raise SupersetAPIError(f"Некорректный JSON ответ: {str(e)}", url=full_url) from e
- except Exception as e:
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка в APIClient.request: {str(e)}", exc_info=True, extra={"url": full_url})
- raise SupersetAPIError(f"Непредвиденная ошибка: {str(e)}", url=full_url) from e
-
- # [COHERENCE_CHECK_FAILED] Если дошли сюда, значит, все повторные попытки провалились
- self.logger.error(f"[CONTRACT_VIOLATION] Все повторные попытки для запроса {method} {endpoint} исчерпаны.")
- raise SupersetAPIError(f"Все повторные попытки запроса {method} {endpoint} исчерпаны.")
+ def _handle_http_error(self, e, endpoint, context):
+ status_code = e.response.status_code
+ if status_code == 404:
+ raise DashboardNotFoundError(endpoint, context=context) from e
+ if status_code == 403:
+ raise PermissionDeniedError("Доступ запрещен.", **context) from e
+ if status_code == 401:
+ raise AuthenticationError("Аутентификация не удалась.", **context) from e
+ raise SupersetAPIError(f"Ошибка API: {status_code} - {e.response.text}", **context) from e
+ def _handle_network_error(self, e, url):
+ if isinstance(e, requests.exceptions.Timeout):
+ msg = "Таймаут запроса"
+ elif isinstance(e, requests.exceptions.ConnectionError):
+ msg = "Ошибка соединения"
+ else:
+ msg = f"Неизвестная сетевая ошибка: {e}"
+ raise NetworkError(msg, url=url) from e
def upload_file(
self,
endpoint: str,
- file_obj: Union[str, Path, BinaryIO], # Может быть Path, str или байтовый поток
- file_name: str,
- form_field: str = "file",
+ file_info: Dict[str, Any],
extra_data: Optional[Dict] = None,
timeout: Optional[int] = None
) -> Dict:
- """[CONTRACT] Отправка файла на сервер через POST-запрос.
- @pre:
- - `endpoint` - валидный API endpoint для загрузки.
- - `file_obj` - путь к файлу или открытый бинарный файловый объект.
- - `file_name` - имя файла для отправки в форме.
- @post:
- - Возвращает JSON-ответ от сервера в виде словаря.
- @raise:
- - `FileNotFoundError`: Если `file_obj` является путем и файл не найден.
- - `PermissionDeniedError`: Если недостаточно прав.
- - `SupersetAPIError`, `NetworkError`.
- """
+ self.logger.info(f"[INFO][APIClient.upload_file][ENTER] Uploading file to {endpoint}")
full_url = f"{self.base_url}{endpoint}"
_headers = self.headers.copy()
- # [IMPORTANT] Content-Type для files формируется requests, поэтому удаляем его из общих заголовков
- _headers.pop('Content-Type', None)
-
- files_payload = None
- should_close_file = False
-
+ _headers.pop('Content-Type', None)
+ file_obj = file_info.get("file_obj")
+ file_name = file_info.get("file_name")
+ form_field = file_info.get("form_field", "file")
if isinstance(file_obj, (str, Path)):
- file_path = Path(file_obj)
- if not file_path.exists():
- self.logger.error(f"[CONTRACT_VIOLATION] Файл для загрузки не найден: {file_path}", extra={"file_path": str(file_path)})
- raise FileNotFoundError(f"Файл {file_path} не найден для загрузки.")
- files_payload = {form_field: (file_name, open(file_path, 'rb'), 'application/x-zip-compressed')}
- should_close_file = True
- self.logger.debug(f"[UPLOAD] Загрузка файла из пути: {file_path}")
- elif isinstance(file_obj, io.BytesIO): # In-memory binary file
+ with open(file_obj, 'rb') as file_to_upload:
+ files_payload = {form_field: (file_name, file_to_upload, 'application/x-zip-compressed')}
+ return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
+ elif isinstance(file_obj, io.BytesIO):
files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')}
- self.logger.debug(f"[UPLOAD] Загрузка файла из байтового потока (in-memory).")
- elif hasattr(file_obj, 'read') and hasattr(file_obj, 'seek'): # Generic binary file-like object
+ return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
+ elif hasattr(file_obj, 'read'):
files_payload = {form_field: (file_name, file_obj, 'application/x-zip-compressed')}
- self.logger.debug(f"[UPLOAD] Загрузка файла из файлового объекта.")
+ return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
else:
- self.logger.error(f"[CONTRACT_VIOLATION] Неподдерживаемый тип файла для загрузки: {type(file_obj).__name__}")
- raise TypeError("Неподдерживаемый тип 'file_obj'. Ожидается Path, str, io.BytesIO или другой файлоподобный объект.")
+ self.logger.error(f"[ERROR][APIClient.upload_file][FAILURE] Unsupported file_obj type: {type(file_obj)}")
+ raise TypeError(f"Неподдерживаемый тип 'file_obj': {type(file_obj)}")
+ def _perform_upload(self, url, files, data, headers, timeout):
+ self.logger.debug(f"[DEBUG][APIClient._perform_upload][ENTER] Performing upload to {url}")
try:
response = self.session.post(
- url=full_url,
- files=files_payload,
- data=extra_data or {},
- headers=_headers,
- timeout=timeout or self.timeout
+ url=url,
+ files=files,
+ data=data or {},
+ headers=headers,
+ timeout=timeout or self.request_settings.get("timeout")
)
response.raise_for_status()
-
- # [COHERENCE_CHECK_PASSED] Файл успешно загружен.
- self.logger.info(f"[UPLOAD_SUCCESS] Файл '{file_name}' успешно загружен на {endpoint}.")
+ self.logger.info(f"[INFO][APIClient._perform_upload][SUCCESS] Upload successful to {url}")
return response.json()
-
except requests.exceptions.HTTPError as e:
- error_context = {
- "endpoint": endpoint,
- "file": file_name,
- "status_code": e.response.status_code,
- "response_text": e.response.text
- }
- if e.response.status_code == 403:
- raise PermissionDeniedError("Доступ запрещен для загрузки файла.", **error_context) from e
- else:
- raise SupersetAPIError(f"Ошибка API при загрузке файла: {e.response.status_code} - {e.response.text}", **error_context) from e
+ self.logger.error(f"[ERROR][APIClient._perform_upload][FAILURE] HTTP error during upload: {e}")
+ raise SupersetAPIError(f"Ошибка API при загрузке: {e.response.text}") from e
except requests.exceptions.RequestException as e:
- error_context = {"endpoint": endpoint, "file": file_name, "error_type": type(e).__name__}
- self.logger.error(f"[NETWORK_ERROR] Ошибка запроса при загрузке файла: {str(e)}", exc_info=True, extra=error_context)
- raise NetworkError(f"Ошибка сети при загрузке файла: {str(e)}", url=full_url) from e
- except Exception as e:
- error_context = {"endpoint": endpoint, "file": file_name, "error_type": type(e).__name__}
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при загрузке файла: {str(e)}", exc_info=True, extra=error_context)
- raise SupersetAPIError(f"Непредвиденная ошибка загрузки файла: {str(e)}", context=error_context) from e
- finally:
- # Закрываем файл, если он был открыт в этом методе
- if should_close_file and files_payload and files_payload[form_field] and hasattr(files_payload[form_field][1], 'close'):
- files_payload[form_field][1].close()
- self.logger.debug(f"[UPLOAD] Закрыт файл '{file_name}'.")
+ self.logger.error(f"[ERROR][APIClient._perform_upload][FAILURE] Network error during upload: {e}")
+ raise NetworkError(f"Ошибка сети при загрузке: {e}", url=url) from e
def fetch_paginated_count(
self,
@@ -380,100 +224,41 @@ class APIClient:
count_field: str = "count",
timeout: Optional[int] = None
) -> int:
- """[CONTRACT] Получение общего количества элементов в пагинированном API.
- @delegates:
- - Использует `self.request` для выполнения HTTP-запроса.
- @pre:
- - `endpoint` должен указывать на пагинированный ресурс.
- - `query_params` должны быть валидны для запроса количества.
- @post:
- - Возвращает целочисленное количество элементов.
- @raise:
- - `NetworkError`, `SupersetAPIError`, `KeyError` (если `count_field` не найден).
- """
- self.logger.debug(f"[PAGINATION] Запрос количества элементов для {endpoint} с параметрами: {query_params}")
- try:
- response_json = self.request(
- method="GET",
- endpoint=endpoint,
- params={"q": json.dumps(query_params)},
- timeout=timeout or self.timeout
- )
-
- if count_field not in response_json:
- self.logger.error(
- f"[CONTRACT_VIOLATION] Ответ API для {endpoint} не содержит поле '{count_field}'",
- extra={"response_keys": list(response_json.keys())}
- )
- raise KeyError(f"Ответ API для {endpoint} не содержит поле '{count_field}'")
-
- count = response_json[count_field]
- self.logger.debug(f"[COHERENCE_CHECK_PASSED] Получено количество: {count} для {endpoint}.")
- return count
-
- except (KeyError, SupersetAPIError, NetworkError, PermissionDeniedError, DashboardNotFoundError) as e:
- self.logger.error(f"[ERROR] Ошибка получения количества элементов для {endpoint}: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
- raise
- except Exception as e:
- error_ctx = {"endpoint": endpoint, "params": query_params, "error_type": type(e).__name__}
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении количества: {str(e)}", exc_info=True, extra=error_ctx)
- raise SupersetAPIError(f"Непредвиденная ошибка при получении count для {endpoint}: {str(e)}", context=error_ctx) from e
-
+ self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_count][ENTER] Fetching paginated count for {endpoint}")
+ response_json = self.request(
+ method="GET",
+ endpoint=endpoint,
+ params={"q": json.dumps(query_params)},
+ timeout=timeout or self.request_settings.get("timeout")
+ )
+ count = response_json.get(count_field, 0)
+ self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_count][SUCCESS] Fetched paginated count: {count}")
+ return count
+
def fetch_paginated_data(
self,
endpoint: str,
- base_query: Dict,
- total_count: int,
- results_field: str = "result",
+ pagination_options: Dict[str, Any],
timeout: Optional[int] = None
) -> List[Any]:
- """[CONTRACT] Получение всех данных с пагинированного API.
- @delegates:
- - Использует `self.request` для выполнения запросов по страницам.
- @pre:
- - `base_query` должен содержать 'page_size'.
- - `total_count` должен быть корректным общим количеством элементов.
- @post:
- - Возвращает список всех собранных данных со всех страниц.
- @raise:
- - `NetworkError`, `SupersetAPIError`, `ValueError` (если `page_size` невалиден), `KeyError`.
- """
- self.logger.debug(f"[PAGINATION] Запуск получения всех данных для {endpoint}. Total: {total_count}, Base Query: {base_query}")
+ self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_data][ENTER] Fetching paginated data for {endpoint}")
+ base_query = pagination_options.get("base_query", {})
+ total_count = pagination_options.get("total_count", 0)
+ results_field = pagination_options.get("results_field", "result")
page_size = base_query.get('page_size')
if not page_size or page_size <= 0:
- self.logger.error("[CONTRACT_VIOLATION] 'page_size' в базовом запросе невалиден.", extra={"page_size": page_size})
- raise ValueError("Параметр 'page_size' должен быть положительным числом.")
-
+ raise ValueError("'page_size' должен быть положительным числом.")
total_pages = (total_count + page_size - 1) // page_size
results = []
-
for page in range(total_pages):
query = {**base_query, 'page': page}
- self.logger.debug(f"[PAGINATION] Запрос страницы {page+1}/{total_pages} для {endpoint}.")
- try:
- response_json = self.request(
- method="GET",
- endpoint=endpoint,
- params={"q": json.dumps(query)},
- timeout=timeout or self.timeout
- )
-
- if results_field not in response_json:
- self.logger.warning(
- f"[CONTRACT_VIOLATION] Ответ API для {endpoint} на странице {page} не содержит поле '{results_field}'",
- extra={"response_keys": list(response_json.keys())}
- )
- # Если поле результатов отсутствует на одной странице, это может быть не фатально, но надо залогировать.
- continue
-
- results.extend(response_json[results_field])
- except (SupersetAPIError, NetworkError, PermissionDeniedError, DashboardNotFoundError) as e:
- self.logger.error(f"[ERROR] Ошибка получения страницы {page+1} для {endpoint}: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
- raise # Пробрасываем ошибку выше, так как не можем продолжить пагинацию
- except Exception as e:
- error_ctx = {"endpoint": endpoint, "page": page, "error_type": type(e).__name__}
- self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении страницы {page+1} для {endpoint}: {str(e)}", exc_info=True, extra=error_ctx)
- raise SupersetAPIError(f"Непредвиденная ошибка пагинации для {endpoint}: {str(e)}", context=error_ctx) from e
-
- self.logger.debug(f"[COHERENCE_CHECK_PASSED] Все данные с пагинацией для {endpoint} успешно собраны. Всего элементов: {len(results)}")
- return results
+ response_json = self.request(
+ method="GET",
+ endpoint=endpoint,
+ params={"q": json.dumps(query)},
+ timeout=timeout or self.request_settings.get("timeout")
+ )
+ page_results = response_json.get(results_field, [])
+ results.extend(page_results)
+ self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_data][SUCCESS] Fetched paginated data. Total items: {len(results)}")
+ return results
\ No newline at end of file
diff --git a/temp_pylint_runner.py b/temp_pylint_runner.py
new file mode 100644
index 0000000..e4e1c5c
--- /dev/null
+++ b/temp_pylint_runner.py
@@ -0,0 +1,7 @@
+import sys
+import os
+import pylint.lint
+
+sys.path.append(os.getcwd())
+
+pylint.lint.Run(['superset_tool/utils/fileio.py'])
\ No newline at end of file