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