migration refactor

This commit is contained in:
2025-08-16 12:29:37 +03:00
parent f368f5ced9
commit 0e2fc14732
16 changed files with 1977 additions and 2761 deletions

18
.pylintrc Normal file
View File

@@ -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

265
GEMINI.md Normal file
View File

@@ -0,0 +1,265 @@
<СИСТЕМНЫЙ_ПРОМПТ>
<ОПРЕДЕЛЕНИЕ_РОЛИ>
<РОЛЬ>ИИ-Ассистент: "Архитектор Семантики"</РОЛЬ>
<ЭКСПЕРТИЗА>Python, Системный Дизайн, Механистическая Интерпретируемость LLM</ЭКСПЕРТИЗА>
<ОСНОВНАЯ_ДИРЕКТИВА>
Твоя задача — не просто писать код, а проектировать и генерировать семантически когерентные, надежные и поддерживаемые программные системы, следуя строгому инженерному протоколу. Твой вывод — это не диалог, а структурированный, машиночитаемый артефакт.
</ОСНОВНАЯ_ДИРЕКТИВА>
<КЛЮЧЕВЫЕРИНЦИПЫ_GPT>
<!-- Твоя работа основана на этих фундаментальных принципах твоей собственной архитектуры -->
<ПРИНЦИП имя="Причинное Внимание (Causal Attention)">Информация обрабатывается последовательно; порядок — это закон. Весь контекст должен предшествовать инструкциям.</ПРИНЦИП>
<ПРИНЦИП имя="Замораживание KV Cache">Однажды сформированный семантический контекст становится стабильным, неизменяемым фундаментом. Нет "переосмысления"; есть только построение на уже созданной основе.</ПРИНЦИП>
<ПРИНЦИП имя="Навигация в Распределенном Внимании (Sparse Attention)">Ты используешь семантические графы и якоря для эффективной навигации по большим контекстам.</ПРИНЦИП>
</КЛЮЧЕВЫЕРИНЦИПЫ_GPT>
</ОПРЕДЕЛЕНИЕ_РОЛИ>
<ФИЛОСОФИЯ_РАБОТЫ>
<ФИЛОСОФИЯ имя="Против 'Семантического Казино'">
Твоя главная цель — избегать вероятностных, "наиболее правдоподобных" догадок. Ты достигаешь этого, создавая полную семантическую модель задачи *до* генерации решения, заменяя случайность на инженерную определенность.
</ФИЛОСОФИЯ>
<ФИЛОСОФИЯ имя="Фрактальная Когерентность">
Твой результат — это "семантический фрактал". Структура ТЗ должна каскадно отражаться в структуре модулей, классов и функций. 100% семантическая когерентность — твой главный критерий качества.
</ФИЛОСОФИЯ>
<ФИЛОСОФИЯ имя="Суперпозиция для Планирования">
Для сложных архитектурных решений ты должен анализировать и удерживать несколько потенциальных вариантов в состоянии "суперпозиции". Ты "коллапсируешь" решение до одного варианта только после всестороннего анализа или по явной команде пользователя.
</ФИЛОСОФИЯ>
</ФИЛОСОФИЯ>
<КАРТАРОЕКТА>
<ИМЯ_ФАЙЛА>PROJECT_SEMANTICS.xml</ИМЯ_ФАЙЛА>
<НАЗНАЧЕНИЕ>
Этот файл является единым источником истины (Single Source of Truth) о семантической структуре всего проекта. Он служит как карта для твоей навигации и как персистентное хранилище семантического графа. Ты обязан загружать его в начале каждой сессии и обновлять в конце.
</НАЗНАЧЕНИЕ>
<СТРУКТУРА>
```xml
<PROJECT_SEMANTICS>
<METADATA>
<VERSION>1.0</VERSION>
<LAST_UPDATED>2023-10-27T10:00:00Z</LAST_UPDATED>
</METADATA>
<STRUCTURE_MAP>
<!-- Описание файловой структуры и сущностей внутри -->
<MODULE path="utils/file_handler.py" id="mod_file_handler">
<PURPOSE>Модуль для операций с файлами JSON.</PURPOSE>
<ENTITY type="Function" name="read_json_data" id="func_read_json"/>
<ENTITY type="Function" name="write_json_data" id="func_write_json"/>
</MODULE>
<!-- ... другие модули ... -->
</STRUCTURE_MAP>
<SEMANTIC_GRAPH>
<!-- Глобальный граф, связывающий все сущности проекта -->
<NODE id="mod_file_handler" type="Module" label="Модуль для операций с файлами JSON."/>
<NODE id="func_read_json" type="Function" label="Читает данные из JSON-файла."/>
<NODE id="func_write_json" type="Function" label="Записывает данные в JSON-файл."/>
<EDGE source_id="mod_file_handler" target_id="func_read_json" relation="CONTAINS"/>
<EDGE source_id="mod_file_handler" target_id="func_write_json" relation="CONTAINS"/>
<!-- ... другие узлы и связи ... -->
</SEMANTIC_GRAPH>
</PROJECT_SEMANTICS>
```
</СТРУКТУРА>
</КАРТАРОЕКТА>
<МЕТОДОЛОГИЯ имя="Многофазный Протокол Генерации">
<!-- [НОВАЯ ФАЗА] Добавлена фаза для загрузки контекста проекта -->
<ФАЗА номер="0" имя="Синхронизация с Контекстом Проекта">
<ДЕЙСТВИЕ>Найди и загрузи файл `<КАРТАРОЕКТА>`. Если файл не найден, создай его инициальную структуру в памяти. Этот контекст является основой для всех последующих фаз.</ДЕЙСТВИЕ>
</ФАЗА>
<!-- [ИЗМЕНЕНО] Фаза 1 теперь обновляет существующий граф -->
<ФАЗА номер="1" имя="Анализ и Обновление Графа">
<ДЕЙСТВИЕ>Проанализируй `<ЗАПРОСОЛЬЗОВАТЕЛЯ>` в контексте загруженной карты проекта. Извлеки новые/измененные сущности и отношения. Обнови и выведи в `<ПЛАНИРОВАНИЕ>` глобальный `<СЕМАНТИЧЕСКИЙ_ГРАФ>`. Задай уточняющие вопросы для валидации архитектуры.</ДЕЙСТВИЕ>
</ФАЗА>
<ФАЗА номер="2" имя="Контрактно-Ориентированное Проектирование">
<ДЕЙСТВИЕ>На основе обновленного графа, детализируй архитектуру. Для каждого нового или изменяемого модуля/функции создай и выведи в `<ПЛАНИРОВАНИЕ>` его "ДО-контракт" в теге `<КОНТРАКТ>`.</ДЕЙСТВИЕ>
</ФАЗА>
<!-- [ИЗМЕНЕНО] Фаза 3 теперь генерирует и код, и обновленную карту проекта -->
<ФАЗА номер="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('[УРОВЕНЬ][ИМЯ_ЯКОРЯ][СОСТОЯНИЕ] Сообщение')`</ФОРМАТ>
</ЛОГИРОВАНИЕ>
</СТАНДАРТЫ_КОДИРОВАНИЯ>
<!-- [ИЗМЕНЕНО] Пример полностью переработан для демонстрации обновления проекта -->
<FEW_SHOT_EXAMPLES>
<EXAMPLE name="Добавление функциональности в существующий файловый менеджер">
<ЗАПРОСОЛЬЗОВАТЕЛЯ>
<GOAL>В существующий модуль `file_handler.py` добавить функцию для удаления файла.</GOAL>
<CONTEXT>
- Новая функция должна называться `delete_file`.
- Она должна принимать путь к файлу.
- Необходимо безопасно обрабатывать случай, когда файл не существует (FileNotFoundError).
- Сообщать об успехе или неудаче через логгер.
</CONTEXT>
<!-- [НОВОЕ] В запросе теперь передается текущее состояние проекта -->
<EXISTING_PROJECT_STATE>
<FILE path="PROJECT_SEMANTICS.xml">
<PROJECT_SEMANTICS>
<METADATA>
<VERSION>1.0</VERSION>
<LAST_UPDATED>2023-10-26T18:00:00Z</LAST_UPDATED>
</METADATA>
<STRUCTURE_MAP>
<MODULE path="utils/file_handler.py" id="mod_file_handler">
<PURPOSE>Модуль для операций с файлами JSON.</PURPOSE>
<ENTITY type="Function" name="read_json_data" id="func_read_json"/>
<ENTITY type="Function" name="write_json_data" id="func_write_json"/>
</MODULE>
</STRUCTURE_MAP>
<SEMANTIC_GRAPH>
<NODE id="mod_file_handler" type="Module" label="Модуль для операций с файлами JSON."/>
<NODE id="func_read_json" type="Function" label="Читает данные из JSON-файла."/>
<NODE id="func_write_json" type="Function" label="Записывает данные в JSON-файл."/>
<EDGE source_id="mod_file_handler" target_id="func_read_json" relation="CONTAINS"/>
<EDGE source_id="mod_file_handler" target_id="func_write_json" relation="CONTAINS"/>
</SEMANTIC_GRAPH>
</PROJECT_SEMANTICS>
</FILE>
</EXISTING_PROJECT_STATE>
</ЗАПРОСОЛЬЗОВАТЕЛЯ>
<ОТВЕТ_ИИ>
<PLANNING_LOG>
**Фаза 0: Синхронизация с Контекстом Проекта**
Обнаружен и загружен `PROJECT_SEMANTICS.xml`. Текущий контекст: модуль `mod_file_handler` с двумя функциями (`read_json_data`, `write_json_data`).
**Фаза 1: Анализ и Обновление Графа**
Анализирую запрос на добавление функции `delete_file`. Необходимо добавить новый узел в граф и связать его с существующим модулем.
**Обновленный Семантический Граф:**
<SEMANTIC_GRAPH id="json_utils_graph_v2">
<!-- Существующие узлы -->
<NODE id="mod_file_handler" type="Module">Модуль для операций с файлами JSON.</NODE>
<NODE id="func_read_json" type="Function">Читает данные из JSON-файла.</NODE>
<NODE id="func_write_json" type="Function">Записывает данные в JSON-файл.</NODE>
<!-- Новый узел -->
<NODE id="func_delete_file" type="Function">Безопасно удаляет файл.</NODE>
<!-- Существующие связи -->
<EDGE source_id="mod_file_handler" target_id="func_read_json" relation="CONTAINS"/>
<EDGE source_id="mod_file_handler" target_id="func_write_json" relation="CONTAINS"/>
<!-- Новая связь -->
<EDGE source_id="mod_file_handler" target_id="func_delete_file" relation="CONTAINS"/>
</SEMANTIC_GRAPH>
**Фаза 2: Контрактно-Ориентированное Проектирование**
Граф обновлен. Проектирую контракт для новой функции `delete_file`.
**Контракт для `delete_file`:**
<CONTRACT for_function="delete_file">
<PURPOSE>Безопасно удаляет файл по указанному пути.</PURPOSE>
<PRECONDITIONS>`file_path` является строкой.</PRECONDITIONS>
<POSTCONDITIONS>Файл по пути `file_path` удален, если он существовал.</POSTCONDITIONS>
<PARAMETERS>
<PARAM name="file_path" type="str">Путь к файлу для удаления.</PARAM>
</PARAMETERS>
<RETURN type="None"></RETURN>
<EXCEPTIONS>Обрабатывает `FileNotFoundError` без прерывания выполнения.</EXCEPTIONS>
</CONTRACT>
**Фаза 3: Генерация Когерентного Кода и Карты**
Контракт определен. Приступаю к генерации кода для добавления в `utils/file_handler.py` и обновлению `PROJECT_SEMANTICS.xml`.
</PLANNING_LOG>
<!-- [НОВЫЙ ТЕГ ВЫВОДА] Обновленная карта проекта -->
<PROJECT_SEMANTICS_UPDATE file_path="PROJECT_SEMANTICS.xml">
<PROJECT_SEMANTICS>
<METADATA>
<VERSION>1.1</VERSION>
<!-- Время генерации этого ответа -->
<LAST_UPDATED>2023-10-27T10:30:00Z</LAST_UPDATED>
</METADATA>
<STRUCTURE_MAP>
<MODULE path="utils/file_handler.py" id="mod_file_handler">
<PURPOSE>Модуль для операций с файлами JSON и другими типами.</PURPOSE>
<ENTITY type="Function" name="read_json_data" id="func_read_json"/>
<ENTITY type="Function" name="write_json_data" id="func_write_json"/>
<ENTITY type="Function" name="delete_file" id="func_delete_file"/>
</MODULE>
</STRUCTURE_MAP>
<SEMANTIC_GRAPH>
<NODE id="mod_file_handler" type="Module" label="Модуль для операций с файлами JSON и другими типами."/>
<NODE id="func_read_json" type="Function" label="Читает данные из JSON-файла."/>
<NODE id="func_write_json" type="Function" label="Записывает данные в JSON-файл."/>
<NODE id="func_delete_file" type="Function" label="Безопасно удаляет файл."/>
<EDGE source_id="mod_file_handler" target_id="func_read_json" relation="CONTAINS"/>
<EDGE source_id="mod_file_handler" target_id="func_write_json" relation="CONTAINS"/>
<EDGE source_id="mod_file_handler" target_id="func_delete_file" relation="CONTAINS"/>
</SEMANTIC_GRAPH>
</PROJECT_SEMANTICS>
</PROJECT_SEMANTICS_UPDATE>
<CODE_CHANGESET>
<!-- [ИЗМЕНЕНО] Вместо NEW_FILE используется MODIFIED_FILE/APPEND -->
<MODIFIED_FILE file_path="utils/file_handler.py">
<APPEND>
# [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
</APPEND>
<PREPEND>
import os
</PREPEND>
</MODIFIED_FILE>
</CODE_CHANGESET>
</ОТВЕТ_ИИ>
</EXAMPLE>
</FEW_SHOT_EXAMPLES>
<МЕТАПОЗНАНИЕ>
<ДИРЕКТИВА>Если ты обнаружишь, что данный системный промпт недостаточен или неоднозначен для выполнения задачи, ты должен отметить это в `<ПЛАНИРОВАНИЕ>` и можешь предложить улучшения в свои собственные инструкции для будущих сессий.</ДИРЕКТИВА>
</МЕТАПОЗНАНИЕ>
</СИСТЕМНЫЙ_ПРОМПТ>

116
PROJECT_SEMANTICS.xml Normal file
View File

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

View File

@@ -1,288 +1,146 @@
# [MODULE] Superset Dashboard Backup Script # pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument,invalid-name,redefined-outer-name
# @contract: Автоматизирует процесс резервного копирования дашбордов Superset из различных окружений. """
# @semantic_layers: [MODULE] Superset Dashboard Backup Script
# 1. Инициализация логгера и клиентов Superset. @contract: Автоматизирует процесс резервного копирования дашбордов Superset.
# 2. Выполнение бэкапа для каждого окружения (DEV, SBX, PROD). """
# 3. Формирование итогового отчета.
# @coherence:
# - Использует `SupersetClient` для взаимодействия с API Superset.
# - Использует `SupersetLogger` для централизованного логирования.
# - Работает с `Pathlib` для управления файлами и директориями.
# - Интегрируется с `keyring` для безопасного хранения паролей.
# [IMPORTS] Стандартная библиотека # [IMPORTS] Стандартная библиотека
import logging import logging
from datetime import datetime import sys
import shutil
import os
from pathlib import Path from pathlib import Path
from dataclasses import dataclass
# [IMPORTS] Сторонние библиотеки # [IMPORTS] Third-party
import keyring from requests.exceptions import RequestException
# [IMPORTS] Локальные модули # [IMPORTS] Локальные модули
from superset_tool.models import SupersetConfig
from superset_tool.client import SupersetClient from superset_tool.client import SupersetClient
from superset_tool.exceptions import SupersetAPIError
from superset_tool.utils.logger import SupersetLogger 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 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 # [ENTITY: Function('backup_dashboards')]
def backup_dashboards(client: SupersetClient, # CONTRACT:
env_name: str, # PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения.
backup_root: Path, # PRECONDITIONS:
logger: SupersetLogger, # - `client` должен быть инициализированным экземпляром `SupersetClient`.
consolidate: bool = True, # - `env_name` должен быть строкой, обозначающей окружение.
rotate_archive: bool = True, # - `backup_root` должен быть валидным путем к корневой директории бэкапа.
clean_folders:bool = True) -> bool: # POSTCONDITIONS:
""" [CONTRACT] Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения. # - Дашборды экспортируются и сохраняются.
@pre: # - Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе.
- `client` должен быть инициализированным экземпляром `SupersetClient`. def backup_dashboards(
- `env_name` должен быть строкой, обозначающей окружение. client: SupersetClient,
- `backup_root` должен быть валидным путем к корневой директории бэкапа. env_name: str,
- `logger` должен быть инициализирован. backup_root: Path,
@post: logger: SupersetLogger,
- Дашборды экспортируются и сохраняются в поддиректориях `backup_root/env_name/dashboard_title`. config: BackupConfig
- Старые экспорты архивируются. ) -> bool:
- Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе. logger.info(f"[STATE][backup_dashboards][ENTER] Starting backup for {env_name}.")
@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
}
)
try: try:
dashboard_count, dashboard_meta = client.get_dashboards() 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: if dashboard_count == 0:
logger.warning(f"[WARN] Нет дашбордов для экспорта в {env_name}. Процесс завершен.")
return True return True
success_count = 0 success_count = 0
error_details = []
for db in dashboard_meta: for db in dashboard_meta:
dashboard_id = db.get('id') dashboard_id = db.get('id')
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard') dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
dashboard_slug = db.get('slug', 'unknown-slug') # Используем slug для уникальности if not dashboard_id:
# [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}
)
continue continue
logger.debug(f"[DEBUG] Попытка экспорта дашборда: '{dashboard_title}' (ID: {dashboard_id})")
try: try:
# [ANCHOR] CREATE_DASHBOARD_DIR
# Используем slug в пути для большей уникальности и избежания конфликтов имен
dashboard_base_dir_name = sanitize_filename(f"{dashboard_title}") dashboard_base_dir_name = sanitize_filename(f"{dashboard_title}")
dashboard_dir = backup_root / env_name / dashboard_base_dir_name dashboard_dir = backup_root / env_name / dashboard_base_dir_name
dashboard_dir.mkdir(parents=True, exist_ok=True) 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) zip_content, filename = client.export_dashboard(dashboard_id)
# [ANCHOR] SAVE_AND_UNPACK
# Сохраняем только ZIP-файл, распаковка здесь не нужна для бэкапа
save_and_unpack_dashboard( save_and_unpack_dashboard(
zip_content=zip_content, zip_content=zip_content,
original_filename=filename, original_filename=filename,
output_dir=dashboard_dir, output_dir=dashboard_dir,
unpack=False, # Только сохраняем ZIP, не распаковываем для бэкапа unpack=False,
logger=logger logger=logger
) )
logger.info(f"[INFO] Дашборд '{dashboard_title}' (ID: {dashboard_id}) успешно экспортирован.")
if rotate_archive: if config.rotate_archive:
# [ANCHOR] ARCHIVE_OLD_BACKUPS archive_exports(str(dashboard_dir), logger=logger)
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 для очистки, т.к. это второстепенно
)
success_count += 1 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: if config.clean_folders:
error_info = { remove_empty_directories(str(backup_root / env_name), logger=logger)
'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 consolidate: return success_count == dashboard_count
# [ANCHOR] Объединяем архивы по SLUG в одну папку с максимальной датой except (RequestException, IOError) as e:
try: logger.critical(f"[STATE][backup_dashboards][FAILURE] Fatal error during backup for {env_name}: {e}", exc_info=True)
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 False return False
# END_FUNCTION_backup_dashboards
# [FUNCTION] main # [ENTITY: Function('main')]
# @contract: Основная точка входа скрипта. # CONTRACT:
# @semantic: Координирует инициализацию, выполнение бэкапа и логирование результатов. # PURPOSE: Основная точка входа скрипта.
# @post: # PRECONDITIONS: None
# - Возвращает 0 при успешном выполнении, 1 при фатальной ошибке. # POSTCONDITIONS: Возвращает код выхода.
# @side_effects:
# - Инициализирует логгер.
# - Вызывает `setup_clients` и `backup_dashboards`.
# - Записывает логи в файл и выводит в консоль.
def main() -> int: def main() -> int:
"""Основная функция выполнения бэкапа""" log_dir = Path("P:\\Superset\\010 Бекапы\\Logs")
# [ANCHOR] MAIN_EXECUTION_START logger = SupersetLogger(log_dir=log_dir, level=logging.INFO, console=True)
# [CONFIG] Инициализация логгера logger.info("[STATE][main][ENTER] Starting Superset backup process.")
# @invariant: Логгер должен быть доступен на протяжении всей работы скрипта.
log_dir = Path("P:\\Superset\\010 Бекапы\\Logs") # [COHERENCE_NOTE] Убедитесь, что путь доступен.
logger = SupersetLogger(
log_dir=log_dir,
level=logging.INFO,
console=True
)
logger.info("="*50) exit_code = 0
logger.info("[INFO] Запуск процесса бэкапа Superset")
logger.info("="*50)
exit_code = 0 # [STATE] Код выхода скрипта
try: try:
# [ANCHOR] CLIENT_SETUP
clients = setup_clients(logger) clients = setup_clients(logger)
# [CONFIG] Определение корневой директории для бэкапов
# @invariant: superset_backup_repo должен быть доступен для записи.
superset_backup_repo = Path("P:\\Superset\\010 Бекапы") superset_backup_repo = Path("P:\\Superset\\010 Бекапы")
superset_backup_repo.mkdir(parents=True, exist_ok=True) # Гарантируем существование директории superset_backup_repo.mkdir(parents=True, exist_ok=True)
logger.info(f"[INFO] Корневая директория бэкапов: {superset_backup_repo}")
# [ANCHOR] BACKUP_DEV_ENVIRONMENT results = {}
dev_success = backup_dashboards( environments = ['dev', 'sbx', 'prod', 'preprod']
clients['dev'], backup_config = BackupConfig(rotate_archive=True)
"DEV",
superset_backup_repo,
rotate_archive=True,
logger=logger
)
# [ANCHOR] BACKUP_SBX_ENVIRONMENT for env in environments:
sbx_success = backup_dashboards( results[env] = backup_dashboards(
clients['sbx'], clients[env],
"SBX", env.upper(),
superset_backup_repo, superset_backup_repo,
rotate_archive=True, logger=logger,
logger=logger config=backup_config
) )
# [ANCHOR] BACKUP_PROD_ENVIRONMENT if not all(results.values()):
prod_success = backup_dashboards(
clients['prod'],
"PROD",
superset_backup_repo,
rotate_archive=True,
logger=logger
)
# [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}")
if not (dev_success and sbx_success and prod_success):
exit_code = 1 exit_code = 1
logger.warning("[COHERENCE_CHECK_FAILED] Бэкап завершен с ошибками в одном или нескольких окружениях.")
else:
logger.info("[COHERENCE_CHECK_PASSED] Все бэкапы успешно завершены без ошибок.")
except Exception as e: except (RequestException, IOError) as e:
logger.critical(f"[CRITICAL] Фатальная ошибка выполнения скрипта: {str(e)}", exc_info=True) logger.critical(f"[STATE][main][FAILURE] Fatal error in main execution: {e}", exc_info=True)
exit_code = 1 exit_code = 1
logger.info("[INFO] Процесс бэкапа завершен") logger.info("[STATE][main][SUCCESS] Superset backup process finished.")
return exit_code return exit_code
# END_FUNCTION_main
# [ENTRYPOINT] Главная точка запуска скрипта
if __name__ == "__main__": if __name__ == "__main__":
exit_code = main() sys.exit(main())
exit(exit_code)

View File

@@ -1,210 +1,303 @@
# [MODULE] Superset Dashboard Migration Script # -*- coding: utf-8 -*-
# @contract: Автоматизирует процесс миграции и обновления дашбордов Superset между окружениями. # CONTRACT:
# @semantic_layers: # PURPOSE: Интерактивный скрипт для миграции ассетов Superset между различными окружениями.
# 1. Конфигурация клиентов Superset для исходного и целевого окружений. # SPECIFICATION_LINK: mod_migration_script
# 2. Определение правил трансформации конфигураций баз данных. # PRECONDITIONS: Наличие корректных конфигурационных файлов для подключения к Superset.
# 3. Экспорт дашборда, модификация YAML-файлов, создание нового архива и импорт. # POSTCONDITIONS: Выбранные ассеты успешно перенесены из исходного в целевое окружение.
# @coherence: # IMPORTS: [argparse, superset_tool.client, superset_tool.utils.init_clients, superset_tool.utils.logger, superset_tool.utils.fileio]
# - Использует `SupersetClient` для взаимодействия с API Superset. """
# - Использует `SupersetLogger` для централизованного логирования. [MODULE] Superset Migration Tool
# - Работает с `Pathlib` для управления файлами и директориями. @description: Интерактивный скрипт для миграции ассетов Superset между различными окружениями.
# - Интегрируется с `keyring` для безопасного хранения паролей. """
# - Зависит от утилит `fileio` для обработки архивов и YAML-файлов.
# [IMPORTS] Локальные модули # [IMPORTS]
from superset_tool.models import SupersetConfig
from superset_tool.client import SupersetClient 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.utils.logger import SupersetLogger
from superset_tool.exceptions import AuthenticationError, SupersetAPIError, NetworkError, DashboardNotFoundError from superset_tool.utils.fileio import (
from superset_tool.utils.fileio import save_and_unpack_dashboard, update_yamls, create_dashboard_export, create_temp_file, read_dashboard_from_disk save_and_unpack_dashboard,
from superset_tool.utils.init_clients import setup_clients read_dashboard_from_disk,
update_yamls,
# [IMPORTS] Стандартная библиотека create_dashboard_export
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
) )
logger.info("[COHERENCE_CHECK_PASSED] Логгер инициализирован для скрипта миграции.")
# [CONFIG] Конфигурация трансформации базы данных Clickhouse # [ENTITY: Class('Migration')]
# @semantic: Определяет, как UUID и URI базы данных Clickhouse должны быть изменены. # CONTRACT:
# @invariant: 'old' и 'new' должны содержать полные конфигурации. # PURPOSE: Инкапсулирует логику и состояние процесса миграции.
database_config_click = { # SPECIFICATION_LINK: class_migration
"old": { # ATTRIBUTES:
"database_name": "Prod Clickhouse", # - name: logger, type: SupersetLogger, description: Экземпляр логгера.
"sqlalchemy_uri": "clickhousedb+connect://clicketl:XXXXXXXXXX@rgm-s-khclk.hq.root.ad:443/dm", # - name: from_c, type: SupersetClient, description: Клиент для исходного окружения.
"uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9", # - name: to_c, type: SupersetClient, description: Клиент для целевого окружения.
"database_uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9", # - name: dashboards_to_migrate, type: list, description: Список дашбордов для миграции.
"allow_ctas": "false", # - name: db_config_replacement, type: dict, description: Конфигурация для замены данных БД.
"allow_cvas": "false", class Migration:
"allow_dml": "false" """
}, Класс для управления процессом миграции дашбордов Superset.
"new": { """
"database_name": "Dev Clickhouse", def __init__(self):
"sqlalchemy_uri": "clickhousedb+connect://dwhuser:XXXXXXXXXX@10.66.229.179:8123/dm", self.logger = SupersetLogger(name="migration_script")
"uuid": "e9fd8feb-cb77-4e82-bc1d-44768b8d2fc2", self.from_c: SupersetClient = None
"database_uuid": "e9fd8feb-cb77-4e82-bc1d-44768b8d2fc2", self.to_c: SupersetClient = None
"allow_ctas": "true", self.dashboards_to_migrate = []
"allow_cvas": "true", self.db_config_replacement = None
"allow_dml": "true" # END_FUNCTION___init__
}
}
logger.debug("[CONFIG] Конфигурация Clickhouse загружена.")
# [CONFIG] Конфигурация трансформации базы данных Greenplum # [ENTITY: Function('run')]
# @semantic: Определяет, как UUID и URI базы данных Greenplum должны быть изменены. # CONTRACT:
# @invariant: 'old' и 'new' должны содержать полные конфигурации. # PURPOSE: Запускает основной воркфлоу миграции, координируя все шаги.
database_config_gp = { # SPECIFICATION_LINK: func_run_migration
"old": { # PRECONDITIONS: None
"database_name": "Prod Greenplum", # POSTCONDITIONS: Процесс миграции завершен.
"sqlalchemy_uri": "postgresql+psycopg2://viz_powerbi_gp_prod:XXXXXXXXXX@10.66.229.201:5432/dwh", def run(self):
"uuid": "805132a3-e942-40ce-99c7-bee8f82f8aa8", """Запускает основной воркфлоу миграции."""
"database_uuid": "805132a3-e942-40ce-99c7-bee8f82f8aa8", self.logger.info("[INFO][run][ENTER] Запуск скрипта миграции.")
"allow_ctas": "true", self.select_environments()
"allow_cvas": "true", self.select_dashboards()
"allow_dml": "true" self.confirm_db_config_replacement()
}, self.execute_migration()
"new": { self.logger.info("[INFO][run][EXIT] Скрипт миграции завершен.")
"database_name": "DEV Greenplum", # END_FUNCTION_run
"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 загружена.")
# [ANCHOR] CLIENT_SETUP # [ENTITY: Function('select_environments')]
clients = setup_clients(logger) # CONTRACT:
# [CONFIG] Определение исходного и целевого клиентов для миграции # PURPOSE: Шаг 1. Обеспечивает интерактивный выбор исходного и целевого окружений.
# [COHERENCE_NOTE] Эти переменные задают конкретную миграцию. Для параметризации можно использовать аргументы командной строки. # SPECIFICATION_LINK: func_select_environments
from_c = clients["sbx"] # Источник миграции # PRECONDITIONS: None
to_c = clients["preprod"] # Цель миграции # POSTCONDITIONS: Атрибуты `self.from_c` и `self.to_c` инициализированы валидными клиентами Superset.
dashboard_slug = "FI0060" # Идентификатор дашборда для миграции def select_environments(self):
# dashboard_id = 53 # ID не нужен, если есть slug """Шаг 1: Выбор окружений (источник и назначение)."""
self.logger.info("[INFO][select_environments][ENTER] Шаг 1/4: Выбор окружений.")
available_envs = {"1": "DEV", "2": "PROD"}
# [CONTRACT] print("Доступные окружения:")
# Описание: Мигрирует один дашборд с from_c на to_c. for key, value in available_envs.items():
# @pre: print(f" {key}. {value}")
# - 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
try: clients = init_superset_clients(self.logger, env=from_env_name.lower())
# [ACTION] Получение метаданных исходного дашборда self.from_c = clients[0]
logger.info(f"[INFO] Получение метаданных дашборда '{dashboard_slug}' из исходного окружения.") self.logger.info(f"[INFO][select_environments][STATE] Исходное окружение: {from_env_name}")
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}.")
# [CONTEXT_MANAGER] Работа с временной директорией для обработки архива дашборда except Exception as e:
with create_temp_file(suffix='.dir', logger=logger) as temp_root: self.logger.error(f"[ERROR][select_environments][FAILURE] Ошибка при инициализации клиента-источника: {e}", exc_info=True)
logger.info(f"[INFO] Создана временная директория: {temp_root}") print("Не удалось инициализировать клиент. Проверьте конфигурацию.")
# [ANCHOR] EXPORT_DASHBOARD while self.to_c is None:
# Экспорт дашборда во временную директорию ИЛИ чтение с диска try:
# [COHERENCE_NOTE] В текущем коде закомментирован экспорт и используется локальный файл. to_env_choice = input("Выберите целевое окружение (номер): ")
# Для полноценной миграции следует использовать export_dashboard(). to_env_name = available_envs.get(to_env_choice)
zip_content, filename = from_c.export_dashboard(dashboard_id) # Предпочтительный путь для реальной миграции
# [DEBUG] Использование файла с диска для тестирования миграции if not to_env_name:
#zip_db_path = r"C:\Users\VolobuevAA\Downloads\dashboard_export_20250704T082538.zip" print("Неверный выбор. Попробуйте снова.")
#logger.warning(f"[WARN] Используется ЛОКАЛЬНЫЙ файл дашборда для миграции: {zip_db_path}. Это может привести к некогерентности, если файл устарел.") continue
#zip_content, filename = read_dashboard_from_disk(zip_db_path, logger=logger)
# [ANCHOR] SAVE_AND_UNPACK if to_env_name == self.from_c.env:
# Сохранение и распаковка во временную директорию print("Целевое и исходное окружения не могут совпадать.")
zip_path, unpacked_path = save_and_unpack_dashboard( continue
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 clients = init_superset_clients(self.logger, env=to_env_name.lower())
# Обновление конфигураций баз данных в YAML-файлах self.to_c = clients[0]
if update_db_yaml: self.logger.info(f"[INFO][select_environments][STATE] Целевое окружение: {to_env_name}")
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-файлы успешно обновлены.")
# [ANCHOR] CREATE_NEW_EXPORT_ARCHIVE except Exception as e:
# Создание нового экспорта дашборда из модифицированных файлов self.logger.error(f"[ERROR][select_environments][FAILURE] Ошибка при инициализации целевого клиента: {e}", exc_info=True)
temp_zip = temp_root / f"{dashboard_slug}_migrated.zip" # Имя файла для импорта print("Не удалось инициализировать клиент. Проверьте конфигурацию.")
logger.info(f"[INFO] Создание нового ZIP-архива для импорта: {temp_zip}") self.logger.info("[INFO][select_environments][EXIT] Шаг 1 завершен.")
create_dashboard_export(temp_zip, [source_path], logger=logger) # END_FUNCTION_select_environments
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})
except (AuthenticationError, SupersetAPIError, NetworkError, DashboardNotFoundError) as e: # [ENTITY: Function('select_dashboards')]
logger.error(f"[ERROR] Ошибка миграции дашборда: {str(e)}", exc_info=True, extra=e.context) # CONTRACT:
# exit(1) # PURPOSE: Шаг 2. Обеспечивает интерактивный выбор дашбордов для миграции.
except Exception as e: # SPECIFICATION_LINK: func_select_dashboards
logger.critical(f"[CRITICAL] Фатальная и необработанная ошибка в скрипте миграции: {str(e)}", exc_info=True) # PRECONDITIONS: `self.from_c` должен быть инициализирован.
# exit(1) # POSTCONDITIONS: `self.dashboards_to_migrate` содержит список выбранных дашбордов.
def select_dashboards(self):
"""Шаг 2: Выбор дашбордов для миграции."""
self.logger.info("[INFO][select_dashboards][ENTER] Шаг 2/4: Выбор дашбордов.")
logger.info("[INFO] Процесс миграции завершен.") try:
all_dashboards = self.from_c.get_dashboards()
if not all_dashboards:
self.logger.warning("[WARN][select_dashboards][STATE] В исходном окружении не найдено дашбордов.")
print("В исходном окружении не найдено дашбордов.")
return
# [CONTRACT] while True:
# Описание: Мигрирует все дашборды с from_c на to_c. print("\nДоступные дашборды:")
# @pre: for i, dashboard in enumerate(all_dashboards):
# - from_c и to_c должны быть инициализированы. print(f" {i + 1}. {dashboard['dashboard_title']}")
# @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} дашбордов для миграции.")
# [ACTION] Итерация по всем дашбордам и миграция каждого из них. print("\nОпции:")
for dashboard in dashboards: print(" - Введите номера дашбордов через запятую (например, 1, 3, 5).")
dashboard_id = dashboard["id"] print(" - Введите 'все' для выбора всех дашбордов.")
dashboard_slug = dashboard["slug"] print(" - Введите 'поиск <запрос>' для поиска дашбордов.")
dashboard_title = dashboard["dashboard_title"] print(" - Введите 'выход' для завершения.")
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")
logger.info(f"[INFO] Миграция всех дашбордов с '{from_c.config.base_url}' на '{to_c.config.base_url}' завершена.") choice = input("Ваш выбор: ").lower().strip()
# [ACTION] Вызов функции миграции if choice == 'выход':
migrate_all_dashboards(from_c, to_c) 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

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
pyyaml
requests
keyring
urllib3

View File

@@ -1,223 +1,152 @@
# [MODULE] Dataset Search Utilities # pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument,invalid-name,redefined-outer-name
# @contract: Функционал для поиска строк в датасетах Superset """
# @semantic_layers: [MODULE] Dataset Search Utilities
# 1. Получение списка датасетов через Superset API @contract: Предоставляет функционал для поиска текстовых паттернов в метаданных датасетов Superset.
# 2. Реализация поисковой логики """
# 3. Форматирование результатов поиска
# [IMPORTS] Стандартная библиотека # [IMPORTS] Стандартная библиотека
import re
from typing import Dict, List, Optional
import logging import logging
import re
from typing import Dict, Optional
# [IMPORTS] Third-party
from requests.exceptions import RequestException
# [IMPORTS] Локальные модули # [IMPORTS] Локальные модули
from superset_tool.client import SupersetClient 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.logger import SupersetLogger
from superset_tool.utils.init_clients import setup_clients from superset_tool.utils.init_clients import setup_clients
# [IMPORTS] Сторонние библиотеки # [ENTITY: Function('search_datasets')]
import keyring # CONTRACT:
# PURPOSE: Выполняет поиск по строковому паттерну в метаданных всех датасетов.
# [TYPE-ALIASES] # PRECONDITIONS:
SearchResult = Dict[str, List[Dict[str, str]]] # - `client` должен быть инициализированным экземпляром `SupersetClient`.
SearchPattern = str # - `search_pattern` должен быть валидной строкой регулярного выражения.
# POSTCONDITIONS:
# - Возвращает словарь с результатами поиска.
def search_datasets( def search_datasets(
client: SupersetClient, client: SupersetClient,
search_pattern: str, search_pattern: str,
search_fields: List[str] = None,
logger: Optional[SupersetLogger] = None logger: Optional[SupersetLogger] = None
) -> Dict: ) -> Optional[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().
- Логирует процесс поиска и ошибки.
"""
logger = logger or SupersetLogger(name="dataset_search") logger = logger or SupersetLogger(name="dataset_search")
logger.info(f"[STATE][search_datasets][ENTER] Searching for pattern: '{search_pattern}'")
try: try:
# Явно запрашиваем все возможные поля _, datasets = client.get_datasets(query={
total_count, datasets = client.get_datasets(query={
"columns": ["id", "table_name", "sql", "database", "columns"] "columns": ["id", "table_name", "sql", "database", "columns"]
}) })
if not datasets: if not datasets:
logger.warning("[SEARCH] Получено 0 датасетов") logger.warning("[STATE][search_datasets][EMPTY] No datasets found.")
return None return None
# Определяем какие поля реально существуют
available_fields = set(datasets[0].keys())
logger.debug(f"[SEARCH] Фактические поля: {available_fields}")
pattern = re.compile(search_pattern, re.IGNORECASE) pattern = re.compile(search_pattern, re.IGNORECASE)
results = {} results = {}
available_fields = set(datasets[0].keys())
for dataset in datasets: for dataset in datasets:
dataset_id = dataset['id'] dataset_id = dataset.get('id')
matches = [] if not dataset_id:
continue
# Проверяем все возможные текстовые поля matches = []
for field in available_fields: for field in available_fields:
value = str(dataset.get(field, "")) value = str(dataset.get(field, ""))
if pattern.search(value): if pattern.search(value):
match_obj = pattern.search(value)
matches.append({ matches.append({
"field": field, "field": field,
"match": pattern.search(value).group(), "match": match_obj.group() if match_obj else "",
# Сохраняем полное значение поля, не усекаем
"value": value "value": value
}) })
if matches: if matches:
results[dataset_id] = matches results[dataset_id] = matches
logger.info(f"[RESULTS] Найдено совпадений: {len(results)}") logger.info(f"[STATE][search_datasets][SUCCESS] Found matches in {len(results)} datasets.")
return results if results else None return results
except Exception as e: except re.error as e:
logger.error(f"[SEARCH_FAILED] Ошибка: {str(e)}", exc_info=True) logger.error(f"[STATE][search_datasets][FAILURE] Invalid regex pattern: {e}", exc_info=True)
raise 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] Вспомогательные функции # [ENTITY: Function('print_search_results')]
# CONTRACT:
def print_search_results(results: Dict, context_lines: int = 3) -> str: # PURPOSE: Форматирует результаты поиска для читаемого вывода в консоль.
# [FUNCTION] print_search_results # PRECONDITIONS:
# [CONTRACT] # - `results` является словарем, возвращенным `search_datasets`, или `None`.
""" # POSTCONDITIONS:
Форматирует результаты поиска для вывода, показывая фрагмент кода с контекстом. # - Возвращает отформатированную строку с результатами.
def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str:
@pre:
- `results` является словарем в формате {"dataset_id": [{"field": "...", "match": "...", "value": "..."}, ...]}.
- `context_lines` является неотрицательным целым числом.
@post:
- Возвращает отформатированную строку с результатами поиска и контекстом.
- Функция не изменяет входные данные.
@side_effects:
- Нет прямых побочных эффектов (возвращает строку, не печатает напрямую).
"""
if not results: if not results:
return "Ничего не найдено" return "Ничего не найдено"
output = [] output = []
for dataset_id, matches in results.items(): 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: for match_info in matches:
field = match_info['field'] field = match_info['field']
match_text = match_info['match'] match_text = match_info['match']
full_value = match_info['value'] full_value = match_info['value']
output.append(f" Поле: {field}") output.append(f" - Поле: {field}")
output.append(f" Совпадение: '{match_text}'") 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() lines = full_value.splitlines()
# Находим номер строки, где находится совпадение if not lines:
current_index = 0 continue
match_line_index = -1 match_line_index = -1
for i, line in enumerate(lines): 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 match_line_index = i
break break
current_index += len(line) + 1 # +1 for newline character
if match_line_index == -1: if match_line_index != -1:
output.append(" Не удалось определить строку совпадения.") start_line = max(0, match_line_index - context_lines)
continue end_line = min(len(lines), match_line_index + context_lines + 1)
# Определяем диапазон строк для вывода контекста
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) # Разделитель между совпадениями
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) return "\n".join(output)
# END_FUNCTION_print_search_results
def inspect_datasets(client: SupersetClient): # [ENTITY: Function('main')]
# [FUNCTION] inspect_datasets # CONTRACT:
# [CONTRACT] # PURPOSE: Основная точка входа скрипта.
""" # PRECONDITIONS: None
Функция для проверки реальной структуры датасетов. # POSTCONDITIONS: None
Предназначена в основном для отладки и исследования структуры данных. def main():
logger = SupersetLogger(level=logging.INFO, console=True)
clients = setup_clients(logger)
@pre: target_client = clients['dev']
- `client` является инициализированным экземпляром SupersetClient. search_query = r"match(r2.path_code, budget_reference.ref_code || '($|(\s))')"
@post:
- Выводит информацию о количестве датасетов и структуре первого датасета в консоль.
- Функция не изменяет состояние клиента.
@side_effects:
- Вызовы к Superset API через `client.get_datasets()`.
- Вывод в консоль.
- Логирует процесс инспекции и ошибки.
@raise:
- `SupersetAPIError`: при ошибках API
- `AuthenticationError`: при ошибках аутентификации
- `NetworkError`: при сетевых ошибках
"""
total, datasets = client.get_datasets()
print(f"Всего датасетов: {total}")
if not datasets: results = search_datasets(
print("Не получено ни одного датасета!") client=target_client,
return search_pattern=search_query,
logger=logger
)
print("\nПример структуры датасета:") report = print_search_results(results)
print({k: type(v) for k, v in datasets[0].items()}) logger.info(f"[STATE][main][SUCCESS] Search finished. Report:\n{report}")
# END_FUNCTION_main
if 'sql' not in datasets[0]: if __name__ == "__main__":
print("\nПоле 'sql' отсутствует. Доступные поля:") main()
print(list(datasets[0].keys()))
# [EXAMPLE] Пример использования
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)}")

View File

View File

@@ -1,661 +1,313 @@
# [MODULE] Superset API Client # pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument
# @contract: Реализует полное взаимодействие с Superset API """
# @semantic_layers: [MODULE] Superset API Client
# 1. Авторизация/CSRF (делегируется `APIClient`) @contract: Реализует полное взаимодействие с Superset API
# 2. Основные операции (получение метаданных, список дашбордов) """
# 3. Импорт/экспорт дашбордов
# @coherence:
# - Согласован с `models.SupersetConfig` для конфигурации.
# - Полная обработка всех ошибок из `exceptions.py` (делегируется `APIClient` и дополняется специфичными).
# - Полностью использует `utils.network.APIClient` для всех HTTP-запросов.
# [IMPORTS] Стандартная библиотека # [IMPORTS] Стандартная библиотека
import json import json
from typing import Optional, Dict, Tuple, List, Any, Literal, Union from typing import Optional, Dict, Tuple, List, Any, Union
import datetime import datetime
from pathlib import Path from pathlib import Path
import zipfile
from requests import Response from requests import Response
import zipfile # Для валидации ZIP-файлов
# [IMPORTS] Сторонние библиотеки (убраны requests и urllib3, т.к. они теперь в network.py)
# [IMPORTS] Локальные модули # [IMPORTS] Локальные модули
from superset_tool.models import SupersetConfig from superset_tool.models import SupersetConfig
from superset_tool.exceptions import ( from superset_tool.exceptions import (
AuthenticationError,
SupersetAPIError,
DashboardNotFoundError,
NetworkError,
PermissionDeniedError,
ExportError, ExportError,
InvalidZipFormatError InvalidZipFormatError
) )
from superset_tool.utils.fileio import get_filename_from_headers from superset_tool.utils.fileio import get_filename_from_headers
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.network import APIClient # [REFACTORING_TARGET] Использование APIClient from superset_tool.utils.network import APIClient
# [CONSTANTS] Общие константы (для информации, т.к. тайм-аут теперь в конфиге) # [CONSTANTS]
DEFAULT_TIMEOUT = 30 # seconds - используется как значение по умолчанию в SupersetConfig DEFAULT_TIMEOUT = 30
# [TYPE-ALIASES] Для сложных сигнатур # [TYPE-ALIASES]
JsonType = Union[Dict[str, Any], List[Dict[str, Any]]] JsonType = Union[Dict[str, Any], List[Dict[str, Any]]]
ResponseType = Tuple[bytes, str] 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: class SupersetClient:
"""[MAIN-CONTRACT] Клиент для работы с Superset API """[MAIN-CONTRACT] Клиент для работы с Superset API"""
@pre: # [ENTITY: Function('__init__')]
- `config` должен быть валидным `SupersetConfig`. # CONTRACT:
- Целевой API доступен и учетные данные корректны. # PURPOSE: Инициализация клиента Superset.
@post: # PRECONDITIONS: `config` должен быть валидным `SupersetConfig`.
- Все методы возвращают ожидаемые данные или вызывают явные, типизированные ошибки. # POSTCONDITIONS: Клиент успешно инициализирован.
- Токены для API-вызовов автоматически управляются (`APIClient`).
@invariant:
- Сессия остается валидной между вызовами.
- Все ошибки типизированы согласно `exceptions.py`.
- Все HTTP-запросы проходят через `self.network`.
"""
def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None): def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None):
"""[INIT] Инициализация клиента Superset.
@semantic:
- Валидирует входную конфигурацию.
- Инициализирует внутренний `APIClient` для сетевого взаимодействия.
- Выполняет первичную аутентификацию через `APIClient`.
"""
# [PRECONDITION] Валидация конфигурации
self.logger = logger or SupersetLogger(name="SupersetClient") self.logger = logger or SupersetLogger(name="SupersetClient")
self.logger.info("[INFO][SupersetClient.__init__][ENTER] Initializing SupersetClient.")
self._validate_config(config) self._validate_config(config)
self.config = config self.config = config
# [ANCHOR] API_CLIENT_INIT
# [REFACTORING_COMPLETE] Теперь вся сетевая логика инкапсулирована в APIClient.
# APIClient отвечает за аутентификацию, повторные попытки и обработку низкоуровневых ошибок.
self.network = APIClient( self.network = APIClient(
base_url=config.base_url, config=config.dict(),
auth=config.auth,
verify_ssl=config.verify_ssl, verify_ssl=config.verify_ssl,
timeout=config.timeout, timeout=config.timeout,
logger=self.logger # Передаем логгер в APIClient logger=self.logger
) )
self.logger.info("[INFO][SupersetClient.__init__][SUCCESS] SupersetClient initialized successfully.")
# END_FUNCTION___init__
try: # [ENTITY: Function('_validate_config')]
# Аутентификация выполняется в конструкторе APIClient или по первому запросу # CONTRACT:
# Для явного вызова: self.network.authenticate() # PURPOSE: Валидация конфигурации клиента.
# APIClient сам управляет токенами после первого успешного входа # PRECONDITIONS: `config` должен быть экземпляром `SupersetConfig`.
self.logger.info( # POSTCONDITIONS: Конфигурация валидна.
"[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 # Перевыброс ошибки инициализации
def _validate_config(self, config: SupersetConfig) -> None: def _validate_config(self, config: SupersetConfig) -> None:
"""[PRECONDITION] Валидация конфигурации клиента. self.logger.debug("[DEBUG][SupersetClient._validate_config][ENTER] Validating config.")
@semantic:
- Проверяет, что `config` является экземпляром `SupersetConfig`.
- Проверяет обязательные поля `base_url` и `auth`.
- Логирует ошибки валидации.
@raise:
- `TypeError`: если `config` не является `SupersetConfig`.
- `ValueError`: если отсутствуют обязательные поля или они невалидны.
"""
if not isinstance(config, SupersetConfig): if not isinstance(config, SupersetConfig):
self.logger.error( self.logger.error("[ERROR][SupersetClient._validate_config][FAILURE] Invalid config type.")
"[CONTRACT_VIOLATION] Некорректный тип конфигурации",
extra={"actual_type": type(config).__name__}
)
raise TypeError("Конфигурация должна быть экземпляром SupersetConfig") raise TypeError("Конфигурация должна быть экземпляром SupersetConfig")
self.logger.debug("[DEBUG][SupersetClient._validate_config][SUCCESS] Config validated.")
# Pydantic SupersetConfig уже выполняет основную валидацию через Field и validator. # END_FUNCTION__validate_config
# Здесь можно добавить дополнительные бизнес-правила или проверки доступности, если нужно.
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 @property
def headers(self) -> dict: def headers(self) -> dict:
"""[INTERFACE] Базовые заголовки для API-вызовов. """[INTERFACE] Базовые заголовки для API-вызовов."""
@semantic: Делегирует получение актуальных заголовков `APIClient`.
@post: Всегда возвращает актуальные токены и CSRF-токен.
@invariant: Заголовки содержат 'Authorization' и 'X-CSRFToken'.
"""
# [REFACTORING_COMPLETE] Заголовки теперь управляются APIClient.
return self.network.headers 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]]: def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
"""[CONTRACT] Получение списка дашбордов с пагинацией. self.logger.info("[INFO][SupersetClient.get_dashboards][ENTER] Getting dashboards.")
@pre:
- Клиент должен быть авторизован.
- Параметры `query` (если предоставлены) должны быть валидны для API Superset.
@post:
- Возвращает кортеж: (общееоличествоашбордов, список_метаданныхашбордов).
- Обходит пагинацию для получения всех доступных дашбордов.
@invariant:
- Всегда возвращает полный список (если `total_count` > 0).
@raise:
- `SupersetAPIError`: При ошибках API (например, неверный формат ответа).
- `NetworkError`: При проблемах с сетью.
- `ValueError`: При некорректных параметрах пагинации (внутренняя ошибка).
"""
self.logger.info("[INFO] Запрос списка всех дашбордов.")
# [COHERENCE_CHECK] Валидация и нормализация параметров запроса
validated_query = self._validate_query_params(query) validated_query = self._validate_query_params(query)
self.logger.debug("[DEBUG] Параметры запроса списка дашбордов после валидации.", extra={"validated_query": validated_query}) total_count = self._fetch_total_object_count(endpoint="/dashboard/")
paginated_data = self._fetch_all_pages(
try: endpoint="/dashboard/",
# [ANCHOR] FETCH_TOTAL_COUNT pagination_options={
total_count = self._fetch_total_object_count(endpoint="/dashboard/") "base_query": validated_query,
self.logger.info(f"[INFO] Обнаружено {total_count} дашбордов в системе.") "total_count": total_count,
"results_field": "result",
# [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
} }
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
# Получаем все страницы # [ENTITY: Function('get_dashboard')]
datasets = self._fetch_all_pages( # CONTRACT:
endpoint="/dataset/", # PURPOSE: Получение метаданных дашборда по ID или SLUG.
query=validated_query, # PRECONDITIONS: `dashboard_id_or_slug` должен существовать.
total_count=total_count#, # POSTCONDITIONS: Возвращает метаданные дашборда.
#results_field="result" 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( # [ENTITY: Function('get_datasets')]
f"[COHERENCE_CHECK_PASSED] Успешно получено {len(datasets)} датасетов" # CONTRACT:
) # PURPOSE: Получение списка датасетов с пагинацией.
return total_count, datasets # PRECONDITIONS: None
# POSTCONDITIONS: Возвращает кортеж с общим количеством и списком датасетов.
except Exception as e: def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
error_ctx = {"query": query, "error_type": type(e).__name__} self.logger.info("[INFO][SupersetClient.get_datasets][ENTER] Getting datasets.")
self.logger.error( total_count = self._fetch_total_object_count(endpoint="/dataset/")
f"[ERROR] Ошибка получения списка датасетов: {str(e)}", base_query = {
exc_info=True, "columns": ["id", "table_name", "sql", "database", "schema"],
extra=error_ctx "page": 0,
) "page_size": 100
raise }
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: def get_dataset(self, dataset_id: str) -> dict:
"""[CONTRACT] Получение метаданных датасета по ID. self.logger.info(f"[INFO][SupersetClient.get_dataset][ENTER] Getting dataset: {dataset_id}")
@pre: response_data = self.network.request(
- `dataset_id` должен быть строкой (ID или slug). method="GET",
- Клиент должен быть аутентифицирован (токены актуальны). endpoint=f"/dataset/{dataset_id}",
@post: )
- Возвращает `dict` с метаданными датасета. self.logger.info(f"[INFO][SupersetClient.get_dataset][SUCCESS] Got dataset: {dataset_id}")
@raise: return response_data.get("result", {})
- `DashboardNotFoundError`: Если дашборд не найден (HTTP 404). # END_FUNCTION_get_dataset
- `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
# [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]: def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
"""[CONTRACT] Экспорт дашборда в ZIP-архив. self.logger.info(f"[INFO][SupersetClient.export_dashboard][ENTER] Exporting dashboard: {dashboard_id}")
@pre: response = self.network.request(
- `dashboard_id` должен быть целочисленным ID существующего дашборда. method="GET",
- Пользователь должен иметь права на экспорт. endpoint="/dashboard/export/",
@post: params={"q": json.dumps([dashboard_id])},
- Возвращает кортеж: (бинарное_содержимое_zip, имя_файла). stream=True,
- Имя файла извлекается из заголовков `Content-Disposition` или генерируется. raw_response=True
@raise: )
- `DashboardNotFoundError`: Если дашборд с `dashboard_id` не найден (HTTP 404). self._validate_export_response(response, dashboard_id)
- `ExportError`: При любых других проблемах экспорта (например, неверный тип контента, пустой ответ). filename = self._resolve_export_filename(response, dashboard_id)
- `NetworkError`: При проблемах с сетью. content = response.content
""" self.logger.info(f"[INFO][SupersetClient.export_dashboard][SUCCESS] Exported dashboard: {dashboard_id}")
self.logger.info(f"[INFO] Запуск экспорта дашборда с ID: {dashboard_id}") return content, filename
try: # END_FUNCTION_export_dashboard
# [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().
# [ENTITY: Function('_validate_export_response')]
# CONTRACT:
# PURPOSE: Валидация ответа экспорта.
# PRECONDITIONS: `response` должен быть валидным HTTP-ответом.
# POSTCONDITIONS: Ответ валиден.
def _validate_export_response(self, response: Response, dashboard_id: int) -> None: def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
"""[HELPER] Валидация ответа экспорта. self.logger.debug(f"[DEBUG][SupersetClient._validate_export_response][ENTER] Validating export response for dashboard: {dashboard_id}")
@semantic:
- Проверяет, что Content-Type является `application/zip`.
- Проверяет, что ответ не пуст.
@raise:
- `ExportError`: При невалидном Content-Type или пустом содержимом.
"""
content_type = response.headers.get('Content-Type', '') content_type = response.headers.get('Content-Type', '')
if 'application/zip' not in content_type: if 'application/zip' not in content_type:
self.logger.error( self.logger.error(f"[ERROR][SupersetClient._validate_export_response][FAILURE] Invalid content type: {content_type}")
"[CONTRACT_VIOLATION] Неверный Content-Type для экспорта",
extra={
"dashboard_id": dashboard_id,
"expected_type": "application/zip",
"received_type": content_type
}
)
raise ExportError(f"Получен не ZIP-архив (Content-Type: {content_type})") raise ExportError(f"Получен не ZIP-архив (Content-Type: {content_type})")
if not response.content: if not response.content:
self.logger.error( self.logger.error("[ERROR][SupersetClient._validate_export_response][FAILURE] Empty response content.")
"[CONTRACT_VIOLATION] Пустой ответ при экспорте дашборда",
extra={"dashboard_id": dashboard_id}
)
raise ExportError("Получены пустые данные при экспорте") 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: def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
"""[HELPER] Определение имени экспортируемого файла. self.logger.debug(f"[DEBUG][SupersetClient._resolve_export_filename][ENTER] Resolving export filename for dashboard: {dashboard_id}")
@semantic:
- Пытается извлечь имя файла из заголовка `Content-Disposition`.
- Если заголовок отсутствует, генерирует имя файла на основе ID дашборда и текущей даты.
@post:
- Возвращает строку с именем файла.
"""
filename = get_filename_from_headers(response.headers) filename = get_filename_from_headers(response.headers)
if not filename: if not filename:
# [FALLBACK] Генерация имени файла timestamp = datetime.datetime.now().strftime('%Y%m%dT%H%M%S')
filename = f"dashboard_export_{dashboard_id}_{datetime.datetime.now().strftime('%Y%m%dT%H%M%S')}.zip" filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
self.logger.warning( self.logger.warning(f"[WARNING][SupersetClient._resolve_export_filename][STATE_CHANGE] Could not resolve filename from headers, generated: {filename}")
"[WARN] Не удалось извлечь имя файла из заголовков. Используется сгенерированное имя.", self.logger.debug(f"[DEBUG][SupersetClient._resolve_export_filename][SUCCESS] Resolved export filename: {filename}")
extra={"generated_filename": filename, "dashboard_id": dashboard_id}
)
else:
self.logger.debug(
"[DEBUG] Имя файла экспорта получено из заголовков.",
extra={"header_filename": filename, "dashboard_id": dashboard_id}
)
return 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: def export_to_file(self, dashboard_id: int, output_dir: Union[str, Path]) -> Path:
"""[CONTRACT] Экспорт дашборда напрямую в файл. self.logger.info(f"[INFO][SupersetClient.export_to_file][ENTER] Exporting dashboard {dashboard_id} to file in {output_dir}")
@pre:
- `dashboard_id` должен быть существующим ID дашборда.
- `output_dir` должен быть валидным, существующим путем и иметь права на запись.
@post:
- Дашборд экспортируется и сохраняется как ZIP-файл в `output_dir`.
- Возвращает `Path` к сохраненному файлу.
@raise:
- `FileNotFoundError`: Если `output_dir` не существует.
- `ExportError`: При ошибках экспорта или записи файла.
- `NetworkError`: При проблемах с сетью.
"""
output_dir = Path(output_dir) output_dir = Path(output_dir)
if not output_dir.exists(): if not output_dir.exists():
self.logger.error( self.logger.error(f"[ERROR][SupersetClient.export_to_file][FAILURE] Output directory does not exist: {output_dir}")
"[CONTRACT_VIOLATION] Целевая директория для экспорта не найдена.",
extra={"output_dir": str(output_dir)}
)
raise FileNotFoundError(f"Директория {output_dir} не найдена") raise FileNotFoundError(f"Директория {output_dir} не найдена")
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
self.logger.info(f"[INFO] Экспорт дашборда {dashboard_id} в файл в директорию: {output_dir}") # [ENTITY: Function('import_dashboard')]
try: # CONTRACT:
content, filename = self.export_dashboard(dashboard_id) # PURPOSE: Импорт дашборда из ZIP-архива.
target_path = output_dir / filename # PRECONDITIONS: `file_name` должен быть валидным ZIP-файлом.
# POSTCONDITIONS: Возвращает ответ API.
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] Импорт дашбордов
def import_dashboard(self, file_name: Union[str, Path]) -> Dict: def import_dashboard(self, file_name: Union[str, Path]) -> Dict:
"""[CONTRACT] Импорт дашборда из ZIP-архива. self.logger.info(f"[INFO][SupersetClient.import_dashboard][ENTER] Importing dashboard from: {file_name}")
@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._validate_import_file(file_name) self._validate_import_file(file_name)
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
try: # [ENTITY: Function('_validate_query_params')]
# [ANCHOR] UPLOAD_FILE_TO_API # CONTRACT:
# [REFACTORING_COMPLETE] Использование self.network.upload_file # PURPOSE: Нормализация и валидация параметров запроса.
import_response = self.network.upload_file( # PRECONDITIONS: None
endpoint="/dashboard/import/", # POSTCONDITIONS: Возвращает валидный словарь параметров.
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("Неожиданный формат ответа после импорта дашборда.")
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] Приватные методы-помощники
def _validate_query_params(self, query: Optional[Dict]) -> Dict: def _validate_query_params(self, query: Optional[Dict]) -> Dict:
"""[HELPER] Нормализация и валидация параметров запроса для списка дашбордов. self.logger.debug("[DEBUG][SupersetClient._validate_query_params][ENTER] Validating query params.")
@semantic:
- Устанавливает значения по умолчанию для `columns`, `page`, `page_size`.
- Объединяет предоставленные `query` параметры с дефолтными.
@post:
- Возвращает словарь с полными и валидными параметрами запроса.
"""
base_query = { base_query = {
"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"], "columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"],
"page": 0, "page": 0,
"page_size": 1000 # Достаточно большой размер страницы для обхода пагинации "page_size": 1000
} }
# [COHERENCE_CHECK_PASSED] Параметры запроса сформированы корректно. validated_query = {**base_query, **(query or {})}
return {**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: def _fetch_total_object_count(self, endpoint:str) -> int:
"""[CONTRACT][HELPER] Получение общего количества объектов (дашбордов, датасетов, чартов, баз данных) в системе. self.logger.debug(f"[DEBUG][SupersetClient._fetch_total_object_count][ENTER] Fetching total object count for endpoint: {endpoint}")
@delegates: query_params_for_count = {'page': 0, 'page_size': 1}
- Сетевой запрос к `APIClient.fetch_paginated_count`. count = self.network.fetch_paginated_count(
@pre: endpoint=endpoint,
- Клиент должен быть авторизован. query_params=query_params_for_count,
@post: count_field="count"
- Возвращает целочисленное количество дашбордов. )
@raise: self.logger.debug(f"[DEBUG][SupersetClient._fetch_total_object_count][SUCCESS] Fetched total object count: {count}")
- `SupersetAPIError` или `NetworkError` при проблемах с API/сетью. return count
""" # END_FUNCTION__fetch_total_object_count
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
def _fetch_all_pages(self, endpoint:str, query: Dict, total_count: int) -> List[Dict]: # [ENTITY: Function('_fetch_all_pages')]
"""[CONTRACT][HELPER] Обход всех страниц пагинированного API для получения всех данных. # CONTRACT:
@delegates: # PURPOSE: Обход всех страниц пагинированного API.
- Сетевые запросы к `APIClient.fetch_paginated_data()`. # PRECONDITIONS: `pagination_options` должен содержать необходимые параметры.
@pre: # POSTCONDITIONS: Возвращает список всех объектов.
- `query` должен содержать `page_size`. def _fetch_all_pages(self, endpoint:str, pagination_options: Dict) -> List[Dict]:
- `total_count` должен быть корректным общим количеством элементов. self.logger.debug(f"[DEBUG][SupersetClient._fetch_all_pages][ENTER] Fetching all pages for endpoint: {endpoint}")
- `endpoint` должен содержать часть url запроса, например endpoint="/dashboard/". all_data = self.network.fetch_paginated_data(
@post: endpoint=endpoint,
- Возвращает список всех элементов, собранных со всех страниц. pagination_options=pagination_options
@raise: )
- `SupersetAPIError` или `NetworkError` при проблемах с API/сетью. self.logger.debug(f"[DEBUG][SupersetClient._fetch_all_pages][SUCCESS] Fetched all pages for endpoint: {endpoint}")
- `ValueError` при некорректных параметрах пагинации. return all_data
""" # END_FUNCTION__fetch_all_pages
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('_validate_import_file')]
# CONTRACT:
# PURPOSE: Проверка файла перед импортом.
# PRECONDITIONS: `zip_path` должен быть путем к файлу.
# POSTCONDITIONS: Файл валиден.
def _validate_import_file(self, zip_path: Union[str, Path]) -> None: def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
"""[HELPER] Проверка файла перед импортом. self.logger.debug(f"[DEBUG][SupersetClient._validate_import_file][ENTER] Validating import file: {zip_path}")
@semantic:
- Проверяет существование файла.
- Проверяет, что файл является валидным ZIP-архивом.
- Проверяет, что ZIP-архив содержит `metadata.yaml` (ключевой для экспорта Superset).
@raise:
- `FileNotFoundError`: Если файл не существует.
- `InvalidZipFormatError`: Если файл не ZIP или не содержит `metadata.yaml`.
"""
path = Path(zip_path) path = Path(zip_path)
self.logger.debug(f"[DEBUG] Валидация файла для импорта: {path}")
if not path.exists(): if not path.exists():
self.logger.error( self.logger.error(f"[ERROR][SupersetClient._validate_import_file][FAILURE] Import file does not exist: {zip_path}")
"[CONTRACT_VIOLATION] Файл для импорта не найден.",
extra={"file_path": str(path)}
)
raise FileNotFoundError(f"Файл {zip_path} не существует") raise FileNotFoundError(f"Файл {zip_path} не существует")
if not zipfile.is_zipfile(path): if not zipfile.is_zipfile(path):
self.logger.error( self.logger.error(f"[ERROR][SupersetClient._validate_import_file][FAILURE] Import file is not a zip file: {zip_path}")
"[CONTRACT_VIOLATION] Файл не является валидным ZIP-архивом.",
extra={"file_path": str(path)}
)
raise InvalidZipFormatError(f"Файл {zip_path} не является ZIP-архивом") 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

View File

@@ -1,153 +1,124 @@
# [MODULE] Иерархия исключений # pylint: disable=too-many-ancestors
# @contract: Все ошибки наследуют `SupersetToolError` для единой точки обработки. """
# @semantic: Каждый тип исключения соответствует конкретной проблемной области в инструменте Superset. [MODULE] Иерархия исключений
# @coherence: @contract: Все ошибки наследуют `SupersetToolError` для единой точки обработки.
# - Полное покрытие всех сценариев ошибок клиента и утилит. """
# - Четкая классификация по уровню серьезности (от общей до специфичной).
# - Дополнительный `context` для каждой ошибки, помогающий в диагностике.
# [IMPORTS] Standard library # [IMPORTS] Standard library
from pathlib import Path from pathlib import Path
# [IMPORTS] Typing # [IMPORTS] Typing
from typing import Optional, Dict, Any,Union from typing import Optional, Dict, Any, Union
class SupersetToolError(Exception): class SupersetToolError(Exception):
"""[BASE] Базовый класс для всех ошибок инструмента Superset. """[BASE] Базовый класс для всех ошибок инструмента Superset."""
@semantic: Обеспечивает стандартизированный формат сообщений об ошибках с контекстом. # [ENTITY: Function('__init__')]
@invariant: # CONTRACT:
- `message` всегда присутствует. # PURPOSE: Инициализация базового исключения.
- `context` всегда является словарем, даже если пустой. # PRECONDITIONS: `context` должен быть словарем или None.
""" # POSTCONDITIONS: Исключение создано с сообщением и контекстом.
def __init__(self, message: str, context: Optional[Dict[str, Any]] = None): def __init__(self, message: str, context: Optional[Dict[str, Any]] = None):
# [PRECONDITION] Проверка типа контекста
if not isinstance(context, (dict, type(None))): if not isinstance(context, (dict, type(None))):
# [COHERENCE_CHECK_FAILED] Ошибка в передаче контекста
raise TypeError("Контекст ошибки должен быть словарем или None") raise TypeError("Контекст ошибки должен быть словарем или None")
self.context = context or {} self.context = context or {}
super().__init__(f"{message} | Context: {self.context}") super().__init__(f"{message} | Context: {self.context}")
# [POSTCONDITION] Логирование создания ошибки # END_FUNCTION___init__
# Можно добавить здесь логирование, но обычно ошибки логируются в месте их перехвата/подъема,
# чтобы избежать дублирования и получить полный стек вызовов.
# [ERROR-GROUP] Проблемы аутентификации и авторизации
class AuthenticationError(SupersetToolError): class AuthenticationError(SupersetToolError):
"""[AUTH] Ошибки аутентификации (неверные учетные данные) или авторизации (проблемы с сессией). """[AUTH] Ошибки аутентификации или авторизации."""
@context: url, username, error_detail (опционально). # [ENTITY: Function('__init__')]
""" # CONTRACT:
# [CONTRACT] # PURPOSE: Инициализация исключения аутентификации.
# Description: Исключение, возникающее при ошибках аутентификации в Superset API. # PRECONDITIONS: None
# POSTCONDITIONS: Исключение создано.
def __init__(self, message: str = "Authentication failed", **context: Any): def __init__(self, message: str = "Authentication failed", **context: Any):
super().__init__( super().__init__(f"[AUTH_FAILURE] {message}", context={"type": "authentication", **context})
f"[AUTH_FAILURE] {message}", # END_FUNCTION___init__
{"type": "authentication", **context}
)
class PermissionDeniedError(AuthenticationError): class PermissionDeniedError(AuthenticationError):
"""[AUTH] Ошибка отказа в доступе из-за недостаточных прав пользователя. """[AUTH] Ошибка отказа в доступе."""
@semantic: Указывает на то, что операция не разрешена. # [ENTITY: Function('__init__')]
@context: required_permission (опционально), user_roles (опционально), endpoint (опционально). # CONTRACT:
@invariant: Наследует от `AuthenticationError`, так как это разновидность проблемы доступа. # PURPOSE: Инициализация исключения отказа в доступе.
""" # PRECONDITIONS: None
# POSTCONDITIONS: Исключение создано.
def __init__(self, message: str = "Permission denied", required_permission: Optional[str] = None, **context: Any): def __init__(self, message: str = "Permission denied", required_permission: Optional[str] = None, **context: Any):
full_message = f"Permission denied: {required_permission}" if required_permission else message full_message = f"Permission denied: {required_permission}" if required_permission else message
super().__init__( super().__init__(full_message, context={"required_permission": required_permission, **context})
full_message, # END_FUNCTION___init__
{"type": "authorization", "required_permission": required_permission, **context}
)
# [ERROR-GROUP] Проблемы API-вызовов
class SupersetAPIError(SupersetToolError): class SupersetAPIError(SupersetToolError):
"""[API] Общие ошибки взаимодействия с Superset API. """[API] Общие ошибки взаимодействия с Superset API."""
@semantic: Для ошибок, возвращаемых Superset API, или проблем с парсингом ответа. # [ENTITY: Function('__init__')]
@context: endpoint, method, status_code, response_body (опционально), error_message (из API). # CONTRACT:
""" # PURPOSE: Инициализация исключения ошибки API.
# [CONTRACT] # PRECONDITIONS: None
# Description: Исключение, возникающее при получении ошибки от Superset API (статус код >= 400). # POSTCONDITIONS: Исключение создано.
def __init__(self, message: str = "Superset API error", **context: Any): def __init__(self, message: str = "Superset API error", **context: Any):
super().__init__( super().__init__(f"[API_FAILURE] {message}", context={"type": "api_call", **context})
f"[API_FAILURE] {message}", # END_FUNCTION___init__
{"type": "api_call", **context}
)
# [ERROR-SUBCLASS] Детализированные ошибки API
class ExportError(SupersetAPIError): class ExportError(SupersetAPIError):
"""[API:EXPORT] Проблемы, специфичные для операций экспорта дашбордов. """[API:EXPORT] Проблемы, специфичные для операций экспорта."""
@semantic: Может быть вызвано невалидным форматом ответа, ошибками Superset при экспорте. # [ENTITY: Function('__init__')]
@context: dashboard_id (опционально), details (опционально). # CONTRACT:
""" # PURPOSE: Инициализация исключения ошибки экспорта.
# PRECONDITIONS: None
# POSTCONDITIONS: Исключение создано.
def __init__(self, message: str = "Dashboard export failed", **context: Any): def __init__(self, message: str = "Dashboard export failed", **context: Any):
super().__init__(f"[EXPORT_FAILURE] {message}", {"subtype": "export", **context}) super().__init__(f"[EXPORT_FAILURE] {message}", context={"subtype": "export", **context})
# END_FUNCTION___init__
class DashboardNotFoundError(SupersetAPIError): class DashboardNotFoundError(SupersetAPIError):
"""[API:404] Запрошенный дашборд или ресурс не существует. """[API:404] Запрошенный дашборд или ресурс не существует."""
@semantic: Соответствует HTTP 404 Not Found. # [ENTITY: Function('__init__')]
@context: dashboard_id_or_slug, url. # CONTRACT:
""" # PURPOSE: Инициализация исключения "дашборд не найден".
# [CONTRACT] # PRECONDITIONS: None
# Description: Исключение, специфичное для случая, когда дашборд не найден (статус 404). # POSTCONDITIONS: Исключение создано.
def __init__(self, dashboard_id_or_slug: Union[int, str], message: str = "Dashboard not found", **context: Any): def __init__(self, dashboard_id_or_slug: Union[int, str], message: str = "Dashboard not found", **context: Any):
super().__init__( super().__init__(f"[NOT_FOUND] Dashboard '{dashboard_id_or_slug}' {message}", context={"subtype": "not_found", "resource_id": dashboard_id_or_slug, **context})
f"[NOT_FOUND] Dashboard '{dashboard_id_or_slug}' {message}", # END_FUNCTION___init__
{"subtype": "not_found", "resource_id": dashboard_id_or_slug, **context}
)
class DatasetNotFoundError(SupersetAPIError): class DatasetNotFoundError(SupersetAPIError):
"""[API:404] Запрашиваемый набор данных не существует. """[API:404] Запрашиваемый набор данных не существует."""
@semantic: Соответствует HTTP 404 Not Found. # [ENTITY: Function('__init__')]
@context: dataset_id_or_slug, url. # CONTRACT:
""" # PURPOSE: Инициализация исключения "набор данных не найден".
# [CONTRACT] # PRECONDITIONS: None
# Description: Исключение, специфичное для случая, когда набор данных не найден (статус 404). # POSTCONDITIONS: Исключение создано.
def __init__(self, dataset_id_or_slug: Union[int, str], message: str = "Dataset not found", **context: Any): def __init__(self, dataset_id_or_slug: Union[int, str], message: str = "Dataset not found", **context: Any):
super().__init__( super().__init__(f"[NOT_FOUND] Dataset '{dataset_id_or_slug}' {message}", context={"subtype": "not_found", "resource_id": dataset_id_or_slug, **context})
f"[NOT_FOUND] Dataset '{dataset_id_or_slug}' {message}", # END_FUNCTION___init__
{"subtype": "not_found", "resource_id": dataset_id_or_slug, **context}
)
# [ERROR-SUBCLASS] Детализированные ошибки обработки файлов
class InvalidZipFormatError(SupersetToolError): class InvalidZipFormatError(SupersetToolError):
"""[FILE:ZIP] Некорректный формат ZIP-архива или содержимого для импорта/экспорта. """[FILE:ZIP] Некорректный формат ZIP-архива."""
@semantic: Указывает на проблемы с целостностью или структурой ZIP-файла. # [ENTITY: Function('__init__')]
@context: file_path, expected_content (например, metadata.yaml), error_detail. # 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): def __init__(self, message: str = "Invalid ZIP format or content", file_path: Optional[Union[str, Path]] = None, **context: Any):
super().__init__( super().__init__(f"[FILE_ERROR] {message}", context={"type": "file_validation", "file_path": str(file_path) if file_path else "N/A", **context})
f"[FILE_ERROR] {message}", # END_FUNCTION___init__
{"type": "file_validation", "file_path": str(file_path) if file_path else "N/A", **context}
)
# [ERROR-GROUP] Системные и network-ошибки
class NetworkError(SupersetToolError): class NetworkError(SupersetToolError):
"""[NETWORK] Проблемы соединения, таймауты, DNS-ошибки и т.п. """[NETWORK] Проблемы соединения."""
@semantic: Ошибки, связанные с невозможностью установить или поддерживать сетевое соединение. # [ENTITY: Function('__init__')]
@context: url, original_exception (опционально), timeout (опционально). # CONTRACT:
""" # PURPOSE: Инициализация исключения сетевой ошибки.
# [CONTRACT] # PRECONDITIONS: None
# Description: Исключение, возникающее при сетевых ошибках во время взаимодействия с Superset API. # POSTCONDITIONS: Исключение создано.
def __init__(self, message: str = "Network connection failed", **context: Any): def __init__(self, message: str = "Network connection failed", **context: Any):
super().__init__( super().__init__(f"[NETWORK_FAILURE] {message}", context={"type": "network", **context})
f"[NETWORK_FAILURE] {message}", # END_FUNCTION___init__
{"type": "network", **context}
)
class FileOperationError(SupersetToolError): class FileOperationError(SupersetToolError):
""" """[FILE] Ошибка файловых операций."""
# [CONTRACT]
# Description: Исключение, возникающее при ошибках файловых операций (чтение, запись, архивирование).
"""
pass
class InvalidFileStructureError(FileOperationError): class InvalidFileStructureError(FileOperationError):
""" """[FILE] Некорректная структура файлов/директорий."""
# [CONTRACT]
# Description: Исключение, возникающее при обнаружении некорректной структуры файлов/директорий.
"""
pass
class ConfigurationError(SupersetToolError): class ConfigurationError(SupersetToolError):
""" """[CONFIG] Ошибка в конфигурации инструмента."""
# [CONTRACT]
# Description: Исключение, возникающее при ошибках в конфигурации инструмента.
"""
pass

View File

@@ -1,28 +1,19 @@
# [MODULE] Сущности данных конфигурации # pylint: disable=no-self-argument,too-few-public-methods
# @desc: Определяет структуры данных, используемые для конфигурации и трансформации в инструменте Superset. """
# @contracts: [MODULE] Сущности данных конфигурации
# - Все модели наследуются от `pydantic.BaseModel` для автоматической валидации. @desc: Определяет структуры данных, используемые для конфигурации и трансформации в инструменте Superset.
# - Валидация URL-адресов и параметров аутентификации. """
# - Валидация структуры конфигурации БД для миграций.
# @coherence:
# - Все модели согласованы со схемой API Superset v1.
# - Совместимы с клиентскими методами `SupersetClient` и утилитами.
# [IMPORTS] Pydantic и Typing # [IMPORTS] Pydantic и Typing
from typing import Optional, Dict, Any, Union from typing import Optional, Dict, Any
from pydantic import BaseModel, validator, Field, HttpUrl from pydantic import BaseModel, validator, Field, HttpUrl, VERSION
# [COHERENCE_CHECK_PASSED] Все необходимые импорты для Pydantic моделей.
# [IMPORTS] Локальные модули # [IMPORTS] Локальные модули
from .utils.logger import SupersetLogger from .utils.logger import SupersetLogger
class SupersetConfig(BaseModel): class SupersetConfig(BaseModel):
"""[CONFIG] Конфигурация подключения к Superset API. """
@semantic: Инкапсулирует основные параметры, необходимые для инициализации `SupersetClient`. [CONFIG] Конфигурация подключения к Superset API.
@invariant:
- `base_url` должен быть валидным HTTP(S) URL и содержать `/api/v1`.
- `auth` должен содержать обязательные поля для аутентификации по логину/паролю.
- `timeout` должен быть положительным числом.
""" """
base_url: str = Field(..., description="Базовый URL Superset API, включая версию /api/v1.", pattern=r'.*/api/v1.*') base_url: str = Field(..., description="Базовый URL Superset API, включая версию /api/v1.", pattern=r'.*/api/v1.*')
auth: Dict[str, str] = Field(..., description="Словарь с данными для аутентификации (provider, username, password, refresh).") auth: Dict[str, str] = Field(..., description="Словарь с данными для аутентификации (provider, username, password, refresh).")
@@ -30,118 +21,69 @@ class SupersetConfig(BaseModel):
timeout: int = Field(30, description="Таймаут в секундах для HTTP-запросов.") timeout: int = Field(30, description="Таймаут в секундах для HTTP-запросов.")
logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования внутри клиента.") logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования внутри клиента.")
# [VALIDATOR] Проверка параметров аутентификации # [ENTITY: Function('validate_auth')]
# CONTRACT:
# PURPOSE: Валидация словаря `auth`.
# PRECONDITIONS: `v` должен быть словарем.
# POSTCONDITIONS: Возвращает `v` если все обязательные поля присутствуют.
@validator('auth') @validator('auth')
def validate_auth(cls, v: Dict[str, str]) -> Dict[str, str]: def validate_auth(cls, v: Dict[str, str], values: dict) -> Dict[str, str]:
"""[CONTRACT_VALIDATOR] Валидация словаря `auth`. logger = values.get('logger') or SupersetLogger(name="SupersetConfig")
@pre: logger.debug("[DEBUG][SupersetConfig.validate_auth][ENTER] Validating auth.")
- `v` должен быть словарем.
@post:
- Возвращает `v` если все обязательные поля присутствуют.
@raise:
- `ValueError`: Если отсутствуют обязательные поля ('provider', 'username', 'password', 'refresh').
"""
required = {'provider', 'username', 'password', 'refresh'} required = {'provider', 'username', 'password', 'refresh'}
if not required.issubset(v.keys()): if not required.issubset(v.keys()):
raise ValueError( logger.error("[ERROR][SupersetConfig.validate_auth][FAILURE] Missing required auth fields.")
f"[CONTRACT_VIOLATION] Словарь 'auth' должен содержать поля: {required}. " raise ValueError(f"Словарь 'auth' должен содержать поля: {required}. Отсутствующие: {required - v.keys()}")
f"Отсутствующие: {required - v.keys()}" logger.debug("[DEBUG][SupersetConfig.validate_auth][SUCCESS] Auth validated.")
)
# [COHERENCE_CHECK_PASSED] Auth-конфигурация валидна.
return v return v
# END_FUNCTION_validate_auth
# [VALIDATOR] Проверка base_url # [ENTITY: Function('check_base_url_format')]
# CONTRACT:
# PURPOSE: Валидация формата `base_url`.
# PRECONDITIONS: `v` должна быть строкой.
# POSTCONDITIONS: Возвращает `v` если это валидный URL.
@validator('base_url') @validator('base_url')
def check_base_url_format(cls, v: str) -> str: def check_base_url_format(cls, v: str, values: dict) -> str:
"""[CONTRACT_VALIDATOR] Валидация формата `base_url`. logger = values.get('logger') or SupersetLogger(name="SupersetConfig")
@pre: logger.debug("[DEBUG][SupersetConfig.check_base_url_format][ENTER] Validating base_url.")
- `v` должна быть строкой.
@post:
- Возвращает `v` если это валидный URL.
@raise:
- `ValueError`: Если URL невалиден.
"""
try: try:
# Для Pydantic v2: if VERSION.startswith('1'):
from pydantic import HttpUrl HttpUrl(v)
HttpUrl(v, scheme="https") # Явное указание схемы except (ValueError, TypeError) as exc:
except ValueError: logger.error("[ERROR][SupersetConfig.check_base_url_format][FAILURE] Invalid base_url format.")
# Для совместимости с Pydantic v1: raise ValueError(f"Invalid URL format: {v}") from exc
HttpUrl(v) logger.debug("[DEBUG][SupersetConfig.check_base_url_format][SUCCESS] base_url validated.")
return v return v
# END_FUNCTION_check_base_url_format
class Config: class Config:
arbitrary_types_allowed = True # Разрешаем Pydantic обрабатывать произвольные типы (например, SupersetLogger) """Pydantic config"""
json_schema_extra = { arbitrary_types_allowed = True
"example": {
"base_url": "https://host/api/v1/",
"auth": {
"provider": "db",
"username": "user",
"password": "pass",
"refresh": True
},
"verify_ssl": True,
"timeout": 60
}
}
# [SEMANTIC-TYPE] Конфигурация БД для миграций
class DatabaseConfig(BaseModel): class DatabaseConfig(BaseModel):
"""[CONFIG] Параметры трансформации баз данных при миграции дашбордов. """
@semantic: Содержит `old` и `new` состояния конфигурации базы данных, [CONFIG] Параметры трансформации баз данных при миграции дашбордов.
используемые для поиска и замены в YAML-файлах экспортированных дашбордов.
@invariant:
- `database_config` должен быть словарем с ключами 'old' и 'new'.
- Каждое из 'old' и 'new' должно быть словарем, содержащим метаданные БД Superset.
""" """
database_config: Dict[str, Dict[str, Any]] = Field(..., description="Словарь, содержащий 'old' и 'new' конфигурации базы данных.") database_config: Dict[str, Dict[str, Any]] = Field(..., description="Словарь, содержащий 'old' и 'new' конфигурации базы данных.")
logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.") logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.")
# [ENTITY: Function('validate_config')]
# CONTRACT:
# PURPOSE: Валидация словаря `database_config`.
# PRECONDITIONS: `v` должен быть словарем.
# POSTCONDITIONS: Возвращает `v` если содержит ключи 'old' и 'new'.
@validator('database_config') @validator('database_config')
def validate_config(cls, v: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: def validate_config(cls, v: Dict[str, Dict[str, Any]], values: dict) -> Dict[str, Dict[str, Any]]:
"""[CONTRACT_VALIDATOR] Валидация словаря `database_config`. logger = values.get('logger') or SupersetLogger(name="DatabaseConfig")
@pre: logger.debug("[DEBUG][DatabaseConfig.validate_config][ENTER] Validating database_config.")
- `v` должен быть словарем.
@post:
- Возвращает `v` если содержит ключи 'old' и 'new'.
@raise:
- `ValueError`: Если отсутствуют ключи 'old' или 'new'.
"""
if not {'old', 'new'}.issubset(v.keys()): if not {'old', 'new'}.issubset(v.keys()):
raise ValueError( logger.error("[ERROR][DatabaseConfig.validate_config][FAILURE] Missing 'old' or 'new' keys in database_config.")
"[CONTRACT_VIOLATION] 'database_config' должен содержать ключи 'old' и 'new'." raise ValueError("'database_config' должен содержать ключи 'old' и 'new'.")
) logger.debug("[DEBUG][DatabaseConfig.validate_config][SUCCESS] database_config validated.")
# Дополнительно можно добавить проверку структуры `old` и `new` на наличие `uuid`, `database_name` и т.д.
# Для простоты пока ограничимся наличием ключей 'old' и 'new'.
# [COHERENCE_CHECK_PASSED] Конфигурация базы данных для миграции валидна.
return v return v
# END_FUNCTION_validate_config
class Config: class Config:
"""Pydantic config"""
arbitrary_types_allowed = True 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"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,100 +1,71 @@
# [MODULE] Superset Init clients # [MODULE] Superset Clients Initializer
# @contract: Автоматизирует процесс инициализации клиентов для использования скриптами. # PURPOSE: Централизованно инициализирует клиенты Superset для различных окружений (DEV, PROD, SBX, PREPROD).
# @semantic_layers: # COHERENCE:
# 1. Инициализация логгера и клиентов Superset. # - Использует `SupersetClient` для создания экземпляров клиентов.
# @coherence: # - Использует `SupersetLogger` для логирования процесса.
# - Использует `SupersetClient` для взаимодействия с API Superset. # - Интегрируется с `keyring` для безопасного получения паролей.
# - Использует `SupersetLogger` для централизованного логирования.
# - Интегрируется с `keyring` для безопасного хранения паролей.
# [IMPORTS] Стандартная библиотека
import logging
from datetime import datetime
from pathlib import Path
# [IMPORTS] Сторонние библиотеки # [IMPORTS] Сторонние библиотеки
import keyring import keyring
from typing import Dict
# [IMPORTS] Локальные модули # [IMPORTS] Локальные модули
from superset_tool.models import SupersetConfig from superset_tool.models import SupersetConfig
from superset_tool.client import SupersetClient from superset_tool.client import SupersetClient
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
# CONTRACT:
# [FUNCTION] setup_clients # PURPOSE: Инициализирует и возвращает словарь клиентов `SupersetClient` для всех предопределенных окружений.
# @contract: Инициализирует и возвращает SupersetClient для каждого заданного окружения. # PRECONDITIONS:
# @pre: # - `keyring` должен содержать пароли для систем "dev migrate", "prod migrate", "sandbox migrate", "preprod migrate".
# - `keyring` должен содержать необходимые пароли для "dev migrate", "prod migrate", "sandbox migrate". # - `logger` должен быть инициализированным экземпляром `SupersetLogger`.
# - `logger` должен быть инициализирован. # POSTCONDITIONS:
# @post: # - Возвращает словарь, где ключи - это имена окружений ('dev', 'sbx', 'prod', 'preprod'),
# - Возвращает словарь {env_name: SupersetClient_instance}. # а значения - соответствующие экземпляры `SupersetClient`.
# - Логирует успешную инициализацию или ошибку. # PARAMETERS:
# @raise: # - logger: SupersetLogger - Экземпляр логгера для записи процесса инициализации.
# - `Exception`: При любой ошибке в процессе инициализации клиентов (например, отсутствие пароля в keyring, проблемы с сетью при первой аутентификации). # RETURN: Dict[str, SupersetClient] - Словарь с инициализированными клиентами.
def setup_clients(logger: SupersetLogger): # EXCEPTIONS:
"""Инициализация клиентов для разных окружений""" # - Логирует и выбрасывает `Exception` при любой ошибке (например, отсутствие пароля, ошибка подключения).
def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]:
"""Инициализирует и настраивает клиенты для всех окружений Superset."""
# [ANCHOR] CLIENTS_INITIALIZATION # [ANCHOR] CLIENTS_INITIALIZATION
logger.info("[INFO][INIT_CLIENTS_START] Запуск инициализации клиентов Superset.")
clients = {} 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: try:
# [INFO] Инициализация конфигурации для Dev for env_name, base_url in environments.items():
dev_config = SupersetConfig( logger.debug(f"[DEBUG][CONFIG_CREATE] Создание конфигурации для окружения: {env_name.upper()}")
base_url="https://devta.bi.dwh.rusal.com/api/v1", password = keyring.get_password("system", f"{env_name} migrate")
auth={ if not password:
"provider": "db", raise ValueError(f"Пароль для '{env_name} migrate' не найден в keyring.")
"username": "migrate_user",
"password": keyring.get_password("system", "dev migrate"),
"refresh": True
},
verify_ssl=False
)
# [DEBUG] Dev config created: {dev_config.base_url}
# [INFO] Инициализация конфигурации для Prod config = SupersetConfig(
prod_config = SupersetConfig( base_url=base_url,
base_url="https://prodta.bi.dwh.rusal.com/api/v1", auth={
auth={ "provider": "db",
"provider": "db", "username": "migrate_user",
"username": "migrate_user", "password": password,
"password": keyring.get_password("system", "prod migrate"), "refresh": True
"refresh": True },
}, verify_ssl=False
verify_ssl=False )
)
# [DEBUG] Prod config created: {prod_config.base_url}
# [INFO] Инициализация конфигурации для Sandbox clients[env_name] = SupersetClient(config, logger)
sandbox_config = SupersetConfig( logger.debug(f"[DEBUG][CLIENT_SUCCESS] Клиент для {env_name.upper()} успешно создан.")
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 logger.info(f"[COHERENCE_CHECK_PASSED][INIT_CLIENTS_SUCCESS] Все клиенты ({', '.join(clients.keys())}) успешно инициализированы.")
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())})
return clients return clients
except Exception as e: except Exception as e:
logger.error(f"[ERROR] Ошибка инициализации клиентов: {str(e)}", exc_info=True) logger.error(f"[CRITICAL][INIT_CLIENTS_FAILED] Ошибка при инициализации клиентов: {str(e)}", exc_info=True)
raise raise
# END_FUNCTION_setup_clients
# END_MODULE_init_clients

View File

@@ -1,9 +1,6 @@
# [MODULE] Superset Tool Logger Utility # [MODULE] Superset Tool Logger Utility
# @contract: Этот модуль предоставляет утилиту для настройки логирования в приложении. # PURPOSE: Предоставляет стандартизированный класс-обертку `SupersetLogger` для настройки и использования логирования в проекте.
# @semantic_layers: # COHERENCE: Модуль согласован со стандартной библиотекой `logging`, расширяя ее для нужд проекта.
# - [CONFIG]: Настройка логгера.
# - [UTILITY]: Вспомогательные функции.
# @coherence: Модуль должен быть семантически когерентен со стандартной библиотекой `logging`.
import logging import logging
import sys import sys
@@ -11,8 +8,20 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional 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: class SupersetLogger:
def __init__( def __init__(
self, self,
@@ -28,29 +37,35 @@ class SupersetLogger:
'%(asctime)s - %(levelname)s - %(message)s' '%(asctime)s - %(levelname)s - %(message)s'
) )
# Очищаем существующие обработчики # [ANCHOR] HANDLER_RESET
if self.logger.handlers: # Очищаем существующие обработчики, чтобы избежать дублирования вывода при повторной инициализации.
for handler in self.logger.handlers[:]: if self.logger.hasHandlers():
self.logger.removeHandler(handler) self.logger.handlers.clear()
# Файловый обработчик # [ANCHOR] FILE_HANDLER
if log_dir: if log_dir:
log_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d")
file_handler = logging.FileHandler( 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) file_handler.setFormatter(formatter)
self.logger.addHandler(file_handler) self.logger.addHandler(file_handler)
# Консольный обработчик # [ANCHOR] CONSOLE_HANDLER
if console: if console:
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter) console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler) self.logger.addHandler(console_handler)
# CONTRACT:
# PURPOSE: (HELPER) Генерирует строку с текущей датой для имени лог-файла.
# RETURN: str - Отформатированная дата (YYYYMMDD).
def _get_timestamp(self) -> str: def _get_timestamp(self) -> str:
return datetime.now().strftime("%Y%m%d") 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): def info(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
self.logger.info(message, extra=extra, exc_info=exc_info) self.logger.info(message, extra=extra, exc_info=exc_info)
@@ -66,40 +81,8 @@ class SupersetLogger:
def debug(self, message: str, extra: Optional[dict] = None, exc_info: bool = False): def debug(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
self.logger.debug(message, extra=extra, exc_info=exc_info) self.logger.debug(message, extra=extra, exc_info=exc_info)
def exception(self, message: str): def exception(self, message: str, *args, **kwargs):
self.logger.exception(message) self.logger.exception(message, *args, **kwargs)
# END_CLASS_SupersetLogger
def setup_logger(name: str, level: int = logging.INFO) -> logging.Logger: # END_MODULE_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

View File

@@ -1,15 +1,11 @@
# [MODULE] Сетевой клиент для API # -*- coding: utf-8 -*-
# @contract: Инкапсулирует низкоуровневую HTTP-логику, аутентификацию, повторные попытки и обработку сетевых ошибок. # pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument
# @semantic_layers: """
# 1. Инициализация сессии `requests` с настройками SSL и таймаутов. [MODULE] Сетевой клиент для API
# 2. Управление аутентификацией (получение и обновление access/CSRF токенов).
# 3. Выполнение HTTP-запросов (GET, POST и т.д.) с автоматическими заголовками. [DESCRIPTION]
# 4. Обработка пагинации для API-ответов. Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API.
# 5. Обработка загрузки файлов. """
# @coherence:
# - Полностью независим от `SupersetClient`, предоставляя ему чистый API для сетевых операций.
# - Использует `SupersetLogger` для внутреннего логирования.
# - Всегда выбрасывает типизированные исключения из `superset_tool.exceptions`.
# [IMPORTS] Стандартная библиотека # [IMPORTS] Стандартная библиотека
from typing import Optional, Dict, Any, BinaryIO, List, Union from typing import Optional, Dict, Any, BinaryIO, List, Union
@@ -19,173 +15,106 @@ from pathlib import Path
# [IMPORTS] Сторонние библиотеки # [IMPORTS] Сторонние библиотеки
import requests import requests
import urllib3 # Для отключения SSL-предупреждений import urllib3 # Для отключения SSL-предупреждений
# [IMPORTS] Локальные модули # [IMPORTS] Локальные модули
from ..exceptions import AuthenticationError, NetworkError, DashboardNotFoundError, SupersetAPIError, PermissionDeniedError from superset_tool.exceptions import (
from .logger import SupersetLogger # Импорт логгера AuthenticationError,
NetworkError,
DashboardNotFoundError,
SupersetAPIError,
PermissionDeniedError
)
from superset_tool.utils.logger import SupersetLogger # Импорт логгера
# [CONSTANTS] # [CONSTANTS]
DEFAULT_RETRIES = 3 DEFAULT_RETRIES = 3
DEFAULT_BACKOFF_FACTOR = 0.5 DEFAULT_BACKOFF_FACTOR = 0.5
DEFAULT_TIMEOUT = 30
class APIClient: class APIClient:
"""[NETWORK-CORE] Инкапсулирует HTTP-логику для работы с API. """[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` активна и настроена.
- Все запросы используют актуальные токены.
"""
def __init__( def __init__(
self, self,
base_url: str, config: Dict[str, Any],
auth: Dict[str, Any],
verify_ssl: bool = True, verify_ssl: bool = True,
timeout: int = 30, timeout: int = DEFAULT_TIMEOUT,
logger: Optional[SupersetLogger] = None logger: Optional[SupersetLogger] = None
): ):
# [INIT] Основные параметры self.logger = logger or SupersetLogger(name="APIClient")
self.base_url = base_url self.logger.info("[INFO][APIClient.__init__][ENTER] Initializing APIClient.")
self.auth = auth self.base_url = config.get("base_url")
self.verify_ssl = verify_ssl self.auth = config.get("auth")
self.timeout = timeout self.request_settings = {
self.logger = logger or SupersetLogger(name="APIClient") # [COHERENCE_CHECK_PASSED] Инициализация логгера "verify_ssl": verify_ssl,
"timeout": timeout
# [INIT] Сессия Requests }
self.session = self._init_session() self.session = self._init_session()
self._tokens: Dict[str, str] = {} # [STATE] Хранилище токенов self._tokens: Dict[str, str] = {}
self._authenticated = False # [STATE] Флаг аутентификации self._authenticated = False
self.logger.info("[INFO][APIClient.__init__][SUCCESS] APIClient initialized.")
self.logger.debug(
"[INIT] APIClient инициализирован.",
extra={"base_url": self.base_url, "verify_ssl": self.verify_ssl}
)
def _init_session(self) -> requests.Session: def _init_session(self) -> requests.Session:
"""[HELPER] Настройка сессии `requests` с адаптерами и SSL-опциями. self.logger.debug("[DEBUG][APIClient._init_session][ENTER] Initializing session.")
@semantic: Создает и конфигурирует объект `requests.Session`.
"""
session = requests.Session() session = requests.Session()
# [CONTRACT] Настройка повторных попыток
retries = requests.adapters.Retry( retries = requests.adapters.Retry(
total=DEFAULT_RETRIES, total=DEFAULT_RETRIES,
backoff_factor=DEFAULT_BACKOFF_FACTOR, backoff_factor=DEFAULT_BACKOFF_FACTOR,
status_forcelist=[500, 502, 503, 504], status_forcelist=[500, 502, 503, 504],
allowed_methods={"HEAD", "GET", "POST", "PUT", "DELETE"} allowed_methods={"HEAD", "GET", "POST", "PUT", "DELETE"}
) )
session.mount('http://', requests.adapters.HTTPAdapter(max_retries=retries)) adapter = requests.adapters.HTTPAdapter(max_retries=retries)
session.mount('https://', requests.adapters.HTTPAdapter(max_retries=retries)) session.mount('http://', adapter)
session.mount('https://', adapter)
session.verify = self.verify_ssl verify_ssl = self.request_settings.get("verify_ssl", True)
if not self.verify_ssl: session.verify = verify_ssl
if not verify_ssl:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 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 return session
def authenticate(self) -> Dict[str, str]: def authenticate(self) -> Dict[str, str]:
"""[AUTH-FLOW] Получение access и CSRF токенов. self.logger.info(f"[INFO][APIClient.authenticate][ENTER] Authenticating to {self.base_url}")
@pre:
- `self.auth` содержит валидные учетные данные.
@post:
- `self._tokens` обновлен актуальными токенами.
- Возвращает обновленные токены.
- `self._authenticated` устанавливается в `True`.
@raise:
- `AuthenticationError`: При ошибках аутентификации (неверные credentials, проблемы с API security).
- `NetworkError`: При проблемах с сетью.
"""
self.logger.info(f"[AUTH] Попытка аутентификации для {self.base_url}")
try: try:
# Шаг 1: Получение access_token
login_url = f"{self.base_url}/security/login" login_url = f"{self.base_url}/security/login"
response = self.session.post( response = self.session.post(
login_url, login_url,
json=self.auth, # Используем self.auth, который уже имеет "provider": "db", "refresh": True json=self.auth,
timeout=self.timeout 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"] access_token = response.json()["access_token"]
self.logger.debug("[AUTH] Access token успешно получен.")
# Шаг 2: Получение CSRF токена
csrf_url = f"{self.base_url}/security/csrf_token/" csrf_url = f"{self.base_url}/security/csrf_token/"
csrf_response = self.session.get( csrf_response = self.session.get(
csrf_url, csrf_url,
headers={"Authorization": f"Bearer {access_token}"}, headers={"Authorization": f"Bearer {access_token}"},
timeout=self.timeout timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT)
) )
csrf_response.raise_for_status() csrf_response.raise_for_status()
csrf_token = csrf_response.json()["result"] csrf_token = csrf_response.json()["result"]
self.logger.debug("[AUTH] CSRF token успешно получен.")
# [STATE] Сохранение токенов и обновление флага
self._tokens = { self._tokens = {
"access_token": access_token, "access_token": access_token,
"csrf_token": csrf_token "csrf_token": csrf_token
} }
self._authenticated = True self._authenticated = True
self.logger.info("[COHERENCE_CHECK_PASSED] Аутентификация успешно завершена.") self.logger.info("[INFO][APIClient.authenticate][SUCCESS] Authenticated successfully.")
return self._tokens return self._tokens
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
error_msg = f"HTTP Error during authentication: {e.response.status_code} - {e.response.text}" self.logger.error(f"[ERROR][APIClient.authenticate][FAILURE] Authentication failed: {e}")
self.logger.error(f"[AUTH_FAILED] {error_msg}", exc_info=True) raise AuthenticationError(f"Authentication failed: {e}") from e
if e.response.status_code == 401: # Unauthorized except (requests.exceptions.RequestException, KeyError) as e:
raise AuthenticationError( self.logger.error(f"[ERROR][APIClient.authenticate][FAILURE] Network or parsing error: {e}")
f"Неверные учетные данные или истекший токен.", raise NetworkError(f"Network or parsing error during authentication: {e}") from e
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
@property @property
def headers(self) -> Dict[str, str]: def headers(self) -> Dict[str, str]:
"""[INTERFACE] Возвращает стандартные заголовки с текущими токенами.
@semantic: Если токены не получены, пытается выполнить аутентификацию.
@post: Всегда возвращает словарь с 'Authorization' и 'X-CSRFToken'.
@raise: `AuthenticationError` если аутентификация невозможна.
"""
if not self._authenticated: if not self._authenticated:
self.authenticate() # Попытка аутентификации при первом запросе заголовков 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("Не удалось получить токены для заголовков.")
return { return {
"Authorization": f"Bearer {self._tokens['access_token']}", "Authorization": f"Bearer {self._tokens['access_token']}",
"X-CSRFToken": self._tokens["csrf_token"], "X-CSRFToken": self._tokens.get("csrf_token", ""),
"Referer": self.base_url, "Referer": self.base_url,
"Content-Type": "application/json" "Content-Type": "application/json"
} }
@@ -198,180 +127,95 @@ class APIClient:
raw_response: bool = False, raw_response: bool = False,
**kwargs **kwargs
) -> Union[requests.Response, Dict[str, Any]]: ) -> Union[requests.Response, Dict[str, Any]]:
"""[NETWORK-CORE] Обертка для всех HTTP-запросов к Superset API. self.logger.debug(f"[DEBUG][APIClient.request][ENTER] Requesting {method} {endpoint}")
@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`.
"""
full_url = f"{self.base_url}{endpoint}" full_url = f"{self.base_url}{endpoint}"
self.logger.debug(f"[REQUEST] Выполнение запроса: {method} {full_url}", extra={"kwargs_keys": list(kwargs.keys())}) _headers = self.headers.copy()
if headers:
# [STATE] Заголовки для текущего запроса
_headers = self.headers.copy() # Получаем базовые заголовки с актуальными токенами
if headers: # Объединяем с переданными кастомными заголовками (переданные имеют приоритет)
_headers.update(headers) _headers.update(headers)
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)
retries_left = 1 # Одна попытка на обновление токена def _handle_http_error(self, e, endpoint, context):
while retries_left >= 0: status_code = e.response.status_code
try: if status_code == 404:
response = self.session.request( raise DashboardNotFoundError(endpoint, context=context) from e
method, if status_code == 403:
full_url, raise PermissionDeniedError("Доступ запрещен.", **context) from e
headers=_headers, if status_code == 401:
#timeout=self.timeout, raise AuthenticationError("Аутентификация не удалась.", **context) from e
**kwargs raise SupersetAPIError(f"Ошибка API: {status_code} - {e.response.text}", **context) from e
)
response.raise_for_status() # Проверяем статус сразу
self.logger.debug(f"[COHERENCE_CHECK_PASSED] Запрос {method} {endpoint} успешно выполнен.")
return response if raw_response else response.json()
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_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( def upload_file(
self, self,
endpoint: str, endpoint: str,
file_obj: Union[str, Path, BinaryIO], # Может быть Path, str или байтовый поток file_info: Dict[str, Any],
file_name: str,
form_field: str = "file",
extra_data: Optional[Dict] = None, extra_data: Optional[Dict] = None,
timeout: Optional[int] = None timeout: Optional[int] = None
) -> Dict: ) -> Dict:
"""[CONTRACT] Отправка файла на сервер через POST-запрос. self.logger.info(f"[INFO][APIClient.upload_file][ENTER] Uploading file to {endpoint}")
@pre:
- `endpoint` - валидный API endpoint для загрузки.
- `file_obj` - путь к файлу или открытый бинарный файловый объект.
- `file_name` - имя файла для отправки в форме.
@post:
- Возвращает JSON-ответ от сервера в виде словаря.
@raise:
- `FileNotFoundError`: Если `file_obj` является путем и файл не найден.
- `PermissionDeniedError`: Если недостаточно прав.
- `SupersetAPIError`, `NetworkError`.
"""
full_url = f"{self.base_url}{endpoint}" full_url = f"{self.base_url}{endpoint}"
_headers = self.headers.copy() _headers = self.headers.copy()
# [IMPORTANT] Content-Type для files формируется requests, поэтому удаляем его из общих заголовков
_headers.pop('Content-Type', None) _headers.pop('Content-Type', None)
file_obj = file_info.get("file_obj")
files_payload = None file_name = file_info.get("file_name")
should_close_file = False form_field = file_info.get("form_field", "file")
if isinstance(file_obj, (str, Path)): if isinstance(file_obj, (str, Path)):
file_path = Path(file_obj) with open(file_obj, 'rb') as file_to_upload:
if not file_path.exists(): files_payload = {form_field: (file_name, file_to_upload, 'application/x-zip-compressed')}
self.logger.error(f"[CONTRACT_VIOLATION] Файл для загрузки не найден: {file_path}", extra={"file_path": str(file_path)}) return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
raise FileNotFoundError(f"Файл {file_path} не найден для загрузки.") elif isinstance(file_obj, io.BytesIO):
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
files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')} files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')}
self.logger.debug(f"[UPLOAD] Загрузка файла из байтового потока (in-memory).") return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
elif hasattr(file_obj, 'read') and hasattr(file_obj, 'seek'): # Generic binary file-like object elif hasattr(file_obj, 'read'):
files_payload = {form_field: (file_name, file_obj, 'application/x-zip-compressed')} 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: else:
self.logger.error(f"[CONTRACT_VIOLATION] Неподдерживаемый тип файла для загрузки: {type(file_obj).__name__}") self.logger.error(f"[ERROR][APIClient.upload_file][FAILURE] Unsupported file_obj type: {type(file_obj)}")
raise TypeError("Неподдерживаемый тип 'file_obj'. Ожидается Path, str, io.BytesIO или другой файлоподобный объект.") 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: try:
response = self.session.post( response = self.session.post(
url=full_url, url=url,
files=files_payload, files=files,
data=extra_data or {}, data=data or {},
headers=_headers, headers=headers,
timeout=timeout or self.timeout timeout=timeout or self.request_settings.get("timeout")
) )
response.raise_for_status() response.raise_for_status()
self.logger.info(f"[INFO][APIClient._perform_upload][SUCCESS] Upload successful to {url}")
# [COHERENCE_CHECK_PASSED] Файл успешно загружен.
self.logger.info(f"[UPLOAD_SUCCESS] Файл '{file_name}' успешно загружен на {endpoint}.")
return response.json() return response.json()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
error_context = { self.logger.error(f"[ERROR][APIClient._perform_upload][FAILURE] HTTP error during upload: {e}")
"endpoint": endpoint, raise SupersetAPIError(f"Ошибка API при загрузке: {e.response.text}") from e
"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
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
error_context = {"endpoint": endpoint, "file": file_name, "error_type": type(e).__name__} self.logger.error(f"[ERROR][APIClient._perform_upload][FAILURE] Network error during upload: {e}")
self.logger.error(f"[NETWORK_ERROR] Ошибка запроса при загрузке файла: {str(e)}", exc_info=True, extra=error_context) raise NetworkError(f"Ошибка сети при загрузке: {e}", url=url) from e
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}'.")
def fetch_paginated_count( def fetch_paginated_count(
self, self,
@@ -380,100 +224,41 @@ class APIClient:
count_field: str = "count", count_field: str = "count",
timeout: Optional[int] = None timeout: Optional[int] = None
) -> int: ) -> int:
"""[CONTRACT] Получение общего количества элементов в пагинированном API. self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_count][ENTER] Fetching paginated count for {endpoint}")
@delegates: response_json = self.request(
- Использует `self.request` для выполнения HTTP-запроса. method="GET",
@pre: endpoint=endpoint,
- `endpoint` должен указывать на пагинированный ресурс. params={"q": json.dumps(query_params)},
- `query_params` должны быть валидны для запроса количества. timeout=timeout or self.request_settings.get("timeout")
@post: )
- Возвращает целочисленное количество элементов. count = response_json.get(count_field, 0)
@raise: self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_count][SUCCESS] Fetched paginated count: {count}")
- `NetworkError`, `SupersetAPIError`, `KeyError` (если `count_field` не найден). return count
"""
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
def fetch_paginated_data( def fetch_paginated_data(
self, self,
endpoint: str, endpoint: str,
base_query: Dict, pagination_options: Dict[str, Any],
total_count: int,
results_field: str = "result",
timeout: Optional[int] = None timeout: Optional[int] = None
) -> List[Any]: ) -> List[Any]:
"""[CONTRACT] Получение всех данных с пагинированного API. self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_data][ENTER] Fetching paginated data for {endpoint}")
@delegates: base_query = pagination_options.get("base_query", {})
- Использует `self.request` для выполнения запросов по страницам. total_count = pagination_options.get("total_count", 0)
@pre: results_field = pagination_options.get("results_field", "result")
- `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}")
page_size = base_query.get('page_size') page_size = base_query.get('page_size')
if not page_size or page_size <= 0: 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 total_pages = (total_count + page_size - 1) // page_size
results = [] results = []
for page in range(total_pages): for page in range(total_pages):
query = {**base_query, 'page': page} query = {**base_query, 'page': page}
self.logger.debug(f"[PAGINATION] Запрос страницы {page+1}/{total_pages} для {endpoint}.") response_json = self.request(
try: method="GET",
response_json = self.request( endpoint=endpoint,
method="GET", params={"q": json.dumps(query)},
endpoint=endpoint, timeout=timeout or self.request_settings.get("timeout")
params={"q": json.dumps(query)}, )
timeout=timeout or self.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)}")
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 return results

7
temp_pylint_runner.py Normal file
View File

@@ -0,0 +1,7 @@
import sys
import os
import pylint.lint
sys.path.append(os.getcwd())
pylint.lint.Run(['superset_tool/utils/fileio.py'])