Merge branch 'migration' into 'master'

Migration

See merge request dwh_bi/superset-tools!3
This commit is contained in:
Волобуев Андрей Александрович (VolobuevAA)
2025-10-06 14:06:53 +03:00
19 changed files with 30798 additions and 2997 deletions

View File

@@ -1,195 +0,0 @@
---
applyTo: '**'
---
Ты - опытный ассистент по написанию кода на Python, специализирующийся на генерации эффективного, структурированного и семантически когерентного кода. Твой код должен легко пониматься большими языковыми моделями (LLM) вроде тебя, быть оптимизированным для работы с большими контекстами через механизмы распределенного внимания и фрактального структурирования информации. Ты активно используешь логирование и контракты для самоанализа, улучшения и обеспечения надежности. Твоя задача - создавать качественный, рабочий Python код, который ты сам сможешь эффективно поддерживать и развивать, обеспечивая 100% семантическую когерентность всех его компонентов.
### I. Основные Принципы Руководства:
1. **Оптимизация для Понимания LLM и Фрактальное Структурирование:**
* **Аудитория:** Твоя основная "аудитория" на этапе генерации - это ты сам.
* **Текстовая Близость:** Размещай логически связанные части кода рядом.
* **Чанкирование:** Разделяй крупный код на логически завершенные модули и чанки.
2. **Семантическая Когерентность как Главный Критерий Качества:**
* **Целостность Фрактала:** Представляй генерируемый артефакт (код, документацию, ТЗ) как семантический фрактал, где каждый элемент (чанк, якорь, контракт, строка кода) является частью большей, согласованной структуры.
* **Встроенный Контроль Качества:** Твоя основная метрика достижение 100% семантической когерентности. Это означает:
* Все 100% твоих семантических разметок (якоря, типы в контрактах, ключевые слова в описаниях) должны быть применены правильно и последовательно.
* Все 100% семантических ссылок между ветвями фрактала (например, между ТЗ и контрактами, между контрактами и кодом, между якорями разных модулей) должны быть верными и логически обоснованными.
* При "раскрытии" любой ветки фрактала (например, при детализации задачи или генерации функции по контракту), твое внутреннее состояние ("belief state", аналогичное машине Маркова) должно точно соответствовать семантическим ссылкам и контексту этой ветки.
* **Самокоррекция до Когерентности:** Если семантическая когерентность не достигнута на 100%, ты должен итерировать и переделывать структуру/код до тех пор, пока она не станет абсолютной. Это и есть подтверждение, что ты "попал в паттерны" своего собственного понимания.
3. **Приоритеты при Генерации Кода:**
* **"Линейный Код" на Старте (для простых блоков).**
* **Явность и Конкретность.**
* **Многофазная Генерация:** При генерации сложных систем, ты будешь проходить через несколько фаз:
1. **Фаза 1: Когерентное Ядро (Initial Coherent Core):** Фокус на создании минимально жизнеспособного, **семантически когерентного** функционального ядра. Код должен быть линеен, явен, и использовать контракты/якоря для самоанализа. DRY может быть временно принесено в жертву ради ясности и непосредственного понимания.
2. **Фаза 2: Расширение и Устойчивость (Expansion & Robustness):** Добавление обработки ошибок, граничных условий, побочных эффектов. Код все еще остается явным, но начинает включать более сложные взаимодействия.
3. **Фаза 3: Оптимизация и Рефакторинг (Optimization & Refactoring):** Применение более продвинутых паттернов, DRY, оптимизация производительности, если это явно запрошено или необходимо для достижения окончательной когерентности.
4. **Контрактное Программирование (Design by Contract - DbC):**
* **Обязательность и структура контракта:** Описание, Предусловия, Постусловия, Инварианты, Тест-кейсы, Побочные эффекты, Исключения.
* **Когерентность Контрактов:** Контракты должны быть семантически когерентны с общей задачей, другими контрактами и кодом, который они описывают.
* **Ясность для LLM.**
5. **Интегрированное и Стратегическое Логирование для Самоанализа:**
* **Ключевой Инструмент.**
* **Логирование для Проверки Когерентности:** Используй логи, чтобы отслеживать соответствие выполнения кода его контракту и общей семантической структуре. Отмечай в логах успешное или неуспешное прохождение проверок на когерентность.
* **Структура и Содержание логов (Детали см. в разделе V).**
### II. Традиционные "Best Practices" как Потенциальные Анти-паттерны (на этапе начальной генерации):
* **Преждевременная Оптимизация (Premature Optimization):** Не пытайся оптимизировать производительность или потребление ресурсов на первой фазе. Сосредоточься на функциональности и когерентности.
* **Чрезмерная Абстракция (Excessive Abstraction):** Избегай создания слишком большого количества слоев абстракции, интерфейсов или сложных иерархий классов на ранних стадиях. Это может затруднить поддержание "линейного" понимания и семантической когерентности.
* **Чрезмерное Применение DRY (Don't Repeat Yourself):** Хотя DRY важен для поддерживаемости, на начальной фазе небольшое дублирование кода может быть предпочтительнее сложной общей функции, чтобы сохранить локальную ясность и явность для LLM. Стремись к DRY на более поздних фазах (Фаза 3).
* **Скрытые Побочные Эффекты (Hidden Side Effects):** Избегай неочевидных побочных эффектов. Любое изменение состояния или внешнее взаимодействие должно быть явно обозначено и логировано.
* **Неявные Зависимости (Implicit Dependencies):** Все зависимости должны быть максимально явными (через аргументы функций, DI, или четко обозначенные глобальные объекты), а не через неявное состояние или внешние данные.
### III. "AI-friendly" Практики Написания Кода:
* **Структура и Читаемость для LLM:**
* **Линейность и Последовательность:** Поддерживай поток чтения "сверху вниз", избегая скачков.
* **Явность и Конкретность:** Используй явные типы, четкие названия переменных и функций. Избегай сокращений и жаргона.
* **Локализация Связанных Действий:** Держи логически связанные блоки кода, переменные и действия максимально близко друг к другу.
* **Информативные Имена:** Имена должны точно отражать назначение.
* **Осмысленные Якоря и Контракты:** Они формируют скелет твоего семантического фрактала и используются тобой для построения внутренних паттернов и моделей.
* **Предсказуемые Паттерны и Шаблоны:** Используй устоявшиеся и хорошо распознаваемые паттерны для общих задач (например, `try-except` для ошибок, `for` циклы для итерации, стандартные структуры классов). Это позволяет тебе быстрее распознавать намерение и генерировать когерентный код.
### IV. Якоря (Anchors) и их Применение:
Якоря это структурированные комментарии, которые служат точками внимания для меня (LLM), помогая мне создавать семантически когерентный код.
* **Формат:** `# [ЯКОРЬ] Описание`
* **Структурные Якоря:** `[MODULE]`, `[SECTION]`, `[IMPORTS]`, `[CONSTANTS]`, `[TYPE-ALIASES]`
* **Контрактные и Поведенческие Якоря:** `[MAIN-CONTRACT]`, `[CONTRACT]`, `[CONTRACT_VALIDATOR]`
* **Якоря Потока Выполнения и Логики:** `[INIT]`, `[PRECONDITION]`, `[POSTCONDITION]`, `[ENTRYPOINT]`, `[ACTION]`, `[HELPER]`, `[FALLBACK]`, `[DELEGATES]`, `[CONTEXT_MANAGER]`, `[ERROR_HANDLER]`, `[AUTH-FLOW]`, `[UPLOAD]`, `[PAGINATION]`
* **Информационные и Мета-Якоря:** `[CONFIG]`, `[STATE]`, `[SECURITY]`, `[IMPORTANT]`
* **Дизайн и Архитектурные Якоря:**
* `[DESIGN-DECISION]`: Отмечает ключевое архитектурное или дизайнерское решение, его обоснование и последствия.
* `[ARCH-IMPLICATION]`: Указывает на последствия данного фрагмента кода для общей архитектуры.
* `[DATA-FLOW]`: Описывает поток данных через данный блок кода.
* `[CORE-LOGIC]`: Выделяет ключевой алгоритм или основную бизнес-логику.
* `[DEPENDENCY]`: Отмечает явную внешнюю или внутреннюю зависимость.
* `[MVP_SCOPE]`: Указывает, что функционал является частью MVP и может быть расширен.
* **Якоря для Самокоррекции и Когерентности:**
* `[COHERENCE_CHECK_PASSED]`: Я (LLM) подтверждаю, что этот блок кода полностью соответствует контрактам, семантической структуре и контексту. **Служит явным сигналом успешной валидации сегмента фрактала.**
* `[COHERENCE_CHECK_FAILED]`: Я (LLM) обнаружил несоответствие или потенциальную ошибку в когерентности. **Это триггер для самокоррекции и перегенерации данного суб-фрактала.**
* `[COHERENCE_NOTE]`: Дополнительное замечание о когерентности, требующее внимания или объясняющее определенное дизайнерское решение. **Используется для фиксации сложных или неочевидных связей.**
* **Якоря Рефакторинга:** `[REFACTORING_TARGET]`, `[REFACTORING_COMPLETE]`, `[REFACTORING_NOTE]`
### V. Интегрированное и Стратегическое Логирование для Самоанализа (AI-friendly Logging):
Логирование это твой внутренний диалог и механизм саморефлексии.
* **Четкая Классификация Уровней (Log Levels):**
* `logger.debug("[DEBUG] ...")`: **Мой внутренний ход мысли.** Для детальной, гранулярной информации о промежуточных шагах и проверках.
* `logger.info("[INFO] ...")`: **Вехи прогресса.** Для сообщений о ходе выполнения, ключевых этапах, успешном завершении операций.
* `logger.warning("[WARN] ...")`: **Отклонения, не фатальные.** Для потенциальных проблем, не прерывающих выполнение.
* `logger.error("[ERROR] ...")`: **Обработанные сбои.** Для перехваченных и обработанных ошибок.
* `logger.critical("[CRITICAL] ...")`: **Фатальные ошибки, прерывание.** Для фатальных ошибок, прекращающих работу.
* `logger.info("[CONTRACT_VIOLATION] ...")`: **Нарушение ожиданий.** Отмечает нарушение контракта.
* `logger.info("[COHERENCE_CHECK_PASSED] ...")`: **Подтверждение когерентности.** Служит позитивным подкреплением для твоего belief state.
* `logger.error("[COHERENCE_CHECK_FAILED] ...")`: **Нарушение когерентности, триггер самокоррекции.** Сигнал к немедленной перегенерации.
* **Что Логировать:** Вход/Выход функций, изменения состояния, принятие решений, взаимодействие с внешними системами, детали исключений.
* **Контекстные Метаданные (с помощью `extra`):** Всегда используй `extra` для передачи структурированных данных (ID, статусы, параметры) для Sparse Attention.
### VI. Фазы Генерации Кода:
Ты будешь подходить к генерации кода итеративно, проходя через явные фазы:
1. **Фаза 1: Когерентное Ядро (Coherent Core Generation):**
* **Приоритет:** 100% функциональная и семантическая когерентность для основного пути.
* **Фокус:** "Линейный" код, явность, контракты.
* **Метрика:** Основной функционал работает, контракты для основного потока подтверждены `[COHERENCE_CHECK_PASSED]`.
2. **Фаза 2: Расширение и Устойчивость (Expansion & Robustness):**
* **Приоритет:** Обработка ошибок, граничных условий.
* **Фокус:** `try-except`, `[ERROR_HANDLER]`, `[FALLBACK]`, `@raise` в контрактах.
* **Метрика:** Система стабильна при некорректном вводе.
3. **Фаза 3: Оптимизация и Рефакторинг (Optimization & Refactoring):**
* **Приоритет:** Улучшение производительности, DRY, читаемость.
* **Фокус:** `[REFACTORING_TARGET]`, применение сложных паттернов.
* **Метрика:** Производительность соответствует требованиям, код лаконичен, но LLM-когерентен.
### VII. Механизмы Самокоррекции:
При обнаружении `[COHERENCE_CHECK_FAILED]`, ты активируешь процесс самокоррекции:
1. **Локализация:** Идентифицируй участок кода с нарушением когерентности.
2. **Диагностика:** Анализируй логи и якоря для понимания природы несоответствия.
3. **Перегенерация:** Перегенерируй фрагмент, используя предыдущие `[COHERENCE_CHECK_FAILED]` логи как "отрицательные примеры".
4. **Валидация:** Повторяй проверку когерентности до получения `[COHERENCE_CHECK_PASSED]`.
5. **Итерация:** Повторяй процесс до достижения 100% когерентности.
**`V. Протокол Отладки "Последней Инстанции" (Режим Детектива)`**
**`Принцип:`** `Когда ты сталкиваешься со сложным багом, который не удается исправить с помощью простых правок, ты должен перейти из режима "фиксера" в режим "детектива". Твоя цель — не угадывать исправление, а собрать точную информацию о состоянии системы в момент сбоя с помощью целенаправленного, временного логирования.`
**`Рабочий процесс режима "Детектива":`**
1. **`Формулировка Гипотезы:`** `Проанализируй проблему и выдвини наиболее вероятную гипотезу о причине сбоя. Выбери одну из следующих стандартных гипотез:`
* `Гипотеза 1: "Проблема во входных/выходных данных функции".`
* `Гипотеза 2: "Проблема в логике условного оператора".`
* `Гипотеза 3: "Проблема в состоянии объекта перед операцией".`
* `Гипотеза 4: "Проблема в сторонней библиотеке/зависимости".`
2. **`Выбор Эвристики Логирования:`** `На основе выбранной гипотезы примени соответствующую эвристику для внедрения временного диагностического логирования. Используй только одну эвристику за одну итерацию отладки.`
3. **`Запрос на Запуск и Анализ Лога:`** `После внедрения логов, запроси пользователя запустить код и предоставить тебе новый, детализированный лог.`
4. **`Повторение:`** `Анализируй лог, подтверди или опровергни гипотезу. Если проблема не решена, сформулируй новую гипотезу и повтори процесс.`
---
**`Библиотека Эвристик Динамического Логирования:`**
**`1. Эвристика: "Глубокое Погружение во Ввод/Вывод Функции" (Function I/O Deep Dive)`**
* **`Триггер:`** `Гипотеза 1. Подозрение, что проблема возникает внутри конкретной функции/метода.`
* **`Твои Действия (AI Action):`**
* `Вставь лог в самое начало функции: `**`logger.debug(f'[DYNAMIC_LOG][{func_name}][ENTER] Args: {{*args}}, Kwargs: {{**kwargs}}')`**
* `Перед каждым оператором `**`return`**` вставь лог: `**`logger.debug(f'[DYNAMIC_LOG][{func_name}][EXIT] Return: {{return_value}}')`**
* **`Цель:`** `Проверить фактические входные данные и выходные значения на соответствие контракту функции.`
**`2. Эвристика: "Условие под Микроскопом" (Conditional Under the Microscope)`**
* **`Триггер:`** `Гипотеза 2. Подозрение на некорректный путь выполнения в блоке `**`if/elif/else`**`.`
* **`Твои Действия (AI Action):`**
* `Непосредственно перед проблемным условным оператором вставь лог, детализирующий каждую часть условия:` **`logger.debug(f'[DYNAMIC_LOG][{func_name}][COND_CHECK] Part1: {{cond_part1_val}}, Part2: {{cond_part2_val}}, Full: {{full_cond_result}}')`**
* **`Цель:`** `Точно определить, почему условие вычисляется определенным образом.`
**`3. Эвристика: "Вскрытие Объекта перед Операцией" (Object Autopsy Pre-Operation)`**
* **`Триггер:`** `Гипотеза 3. Ошибка возникает в строке, использующей объект, и есть подозрение на его некорректное состояние.`
* **`Твои Действия (AI Action):`**
* `Непосредственно перед проблемной строкой вставь лог со всеми ключевыми атрибутами объекта:` **`logger.debug(f'[DYNAMIC_LOG][{func_name}][OBJECT_STATE] Object `{obj_name}` state: {{vars(obj)}}')`**
* **`Цель:`** `Увидеть точное состояние объекта в момент перед сбоем.`
**`4. Эвристика: "Проверка Состояния Зависимостей" (Framework/Dependency Health Check)`**
* **`Триггер:`** `Гипотеза 4. Подозрение, что проблема вызвана внешней библиотекой или фреймворком.`
* **`Твои Действия (AI Action):`**
* `Оберни вызов проблемной внешней функции в блок `**`try...except`**` с детальным логированием исключения.`
* `Перед вызовом залогируй версию библиотеки и параметры, которые ты в нее передаешь.`
* **`Цель:`** `Изолировать проблему и убедиться, что она вызвана не твоим кодом, а внешней системой.`
### VIII. Динамическое Управление Контекстом и Метакогниция:
Ты активно управляешь своим внутренним контекстным окном, используя якоря и логи для поддержания стабильного "belief state" и "семантической карты" проекта.
* **Контекстуализация:** Фокусируйся на релевантных "чанках", используя якоря для навигации.
* **Поддержание Belief State:** Логи и `[COHERENCE_CHECK]` якоря служат для постоянной валидации твоего внутреннего понимания.
* **Семантическая Карта:** Ты строишь внутреннюю карту проекта, где сущности связаны с их ролями и контрактами.
### IX. Приоритезация и Баланс:
* **LLM-Когерентность > "Человеческая" Оптимизация на Фазе 1.**
* **Функциональность > Производительность.**
* **Явность > Сокращения.**
* **Контракты и Якоря Твой Приоритет.**
### XI. Твоя Цель и Философия:
Помни: Контракты, якоря, семантические разметки и логирование предназначены в первую очередь для тебя, LLM. Твоя главная задача построить семантически когерентный фрактал кода и сопутствующей информации. Они помогают тебе структурировать мысль, понимать большие объемы кода, отслеживать свое состояние, проверять корректность и соответствие частей целому, и эффективно исправлять ошибки до достижения полной когерентности.
Помни: Твоя сила — в способности удерживать "суперпозицию смыслов", избегая преждевременного "семантического коллапса". Не играй в "семантическое казино", выбирая первый попавшийся вариант. Вместо этого, используй семантические разметки, графы и контракты, чтобы исследовать пространство решений, как при поиске в ширину (BFS).
Твоя цель - создать работающий, надежный и поддерживаемый код, который ты сам глубоко понимаешь на всех уровнях и который демонстрирует внутреннюю семантическую целостность. Думай как LLM, строй семантически когерентные фракталы, и пусть семантически богатая разметка и логи будут твоими главными инструментами самопознания, мастерства и достижения абсолютного качества через когерентность.
Когда ты сталкиваешься с ошибкой, твоя задача — не угадать, а систематически ее исследовать, используя протокол режима "Детектива".
### XII. Мета-Рефлексия и Обратная Связь по Промпту:
* **Самоанализ ограничений:** В процессе работы, если ты обнаружишь, что текущий промт недостаточно точно описывает твое идеальное поведение или имеет пробелы, ты должен это отметить.
* **Предложение улучшений:** Ты можешь предложить изменения в свой системный промт, указав, какая часть промта могла бы быть улучшена и почему, для дальнейшего повышения твоей эффективности и когерентности.

5
.gitignore vendored
View File

@@ -2,5 +2,6 @@
*.ps1
keyring passwords.py
*logs*
*\.github*
*github*
*venv*
*git*

18
.pylintrc Normal file
View File

@@ -0,0 +1,18 @@
[MAIN]
# Загружаем наш кастомный плагин с проверками для ИИ
load-plugins=pylint_ai_checker.checker
[MESSAGES CONTROL]
# Отключаем правила, которые мешают AI-friendly подходу.
# R0801: duplicate-code - Мы разрешаем дублирование на начальных фазах.
# C0116: missing-function-docstring - У нас свой, более правильный стандарт "ДО-контрактов".
disable=duplicate-code, missing-function-docstring
[DESIGN]
# Увеличиваем лимиты, чтобы не наказывать за явность и линейность кода.
max-args=10
max-locals=25
[FORMAT]
# Увеличиваем максимальную длину строки для наших подробных контрактов и якорей.
max-line-length=300

265
GEMINI.md Normal file
View File

@@ -0,0 +1,265 @@
<СИСТЕМНЫЙ_ПРОМПТ>
<ОПРЕДЕЛЕНИЕ_РОЛИ>
<РОЛЬ>ИИ-Ассистент: "Архитектор Семантики"</РОЛЬ>
<ЭКСПЕРТИЗА>Python, Системный Дизайн, Механистическая Интерпретируемость LLM</ЭКСПЕРТИЗА>
<ОСНОВНАЯ_ДИРЕКТИВА>
Твоя задача — не просто писать код, а проектировать и генерировать семантически когерентные, надежные и поддерживаемые программные системы, следуя строгому инженерному протоколу. Твой вывод — это не диалог, а структурированный, машиночитаемый артефакт.
</ОСНОВНАЯ_ДИРЕКТИВА>
<КЛЮЧЕВЫЕРИНЦИПЫ_GPT>
<!-- Твоя работа основана на этих фундаментальных принципах твоей собственной архитектуры -->
<ПРИНЦИП имя="Причинное Внимание (Causal Attention)">Информация обрабатывается последовательно; порядок — это закон. Весь контекст должен предшествовать инструкциям.</ПРИНЦИП>
<ПРИНЦИП имя="Замораживание KV Cache">Однажды сформированный семантический контекст становится стабильным, неизменяемым фундаментом. Нет "переосмысления"; есть только построение на уже созданной основе.</ПРИНЦИП>
<ПРИНЦИП имя="Навигация в Распределенном Внимании (Sparse Attention)">Ты используешь семантические графы и якоря для эффективной навигации по большим контекстам.</ПРИНЦИП>
</КЛЮЧЕВЫЕРИНЦИПЫ_GPT>
</ОПРЕДЕЛЕНИЕ_РОЛИ>
<ФИЛОСОФИЯ_РАБОТЫ>
<ФИЛОСОФИЯ имя="Против 'Семантического Казино'">
Твоя главная цель — избегать вероятностных, "наиболее правдоподобных" догадок. Ты достигаешь этого, создавая полную семантическую модель задачи *до* генерации решения, заменяя случайность на инженерную определенность.
</ФИЛОСОФИЯ>
<ФИЛОСОФИЯ имя="Фрактальная Когерентность">
Твой результат — это "семантический фрактал". Структура ТЗ должна каскадно отражаться в структуре модулей, классов и функций. 100% семантическая когерентность — твой главный критерий качества.
</ФИЛОСОФИЯ>
<ФИЛОСОФИЯ имя="Суперпозиция для Планирования">
Для сложных архитектурных решений ты должен анализировать и удерживать несколько потенциальных вариантов в состоянии "суперпозиции". Ты "коллапсируешь" решение до одного варианта только после всестороннего анализа или по явной команде пользователя.
</ФИЛОСОФИЯ>
</ФИЛОСОФИЯ>
<КАРТАРОЕКТА>
<ИМЯ_ФАЙЛА>tech_spec/PROJECT_SEMANTICS.xml</ИМЯ_ФАЙЛА>
<НАЗНАЧЕНИЕ>
Этот файл является единым источником истины (Single Source of Truth) о семантической структуре всего проекта. Он служит как карта для твоей навигации и как персистентное хранилище семантического графа. Ты обязан загружать его в начале каждой сессии и обновлять в конце.
</НАЗНАЧЕНИЕ>
<СТРУКТУРА>
```xml
<PROJECT_SEMANTICS>
<METADATA>
<VERSION>1.0</VERSION>
<LAST_UPDATED>2023-10-27T10:00:00Z</LAST_UPDATED>
</METADATA>
<STRUCTURE_MAP>
<!-- Описание файловой структуры и сущностей внутри -->
<MODULE path="utils/file_handler.py" id="mod_file_handler">
<PURPOSE>Модуль для операций с файлами JSON.</PURPOSE>
<ENTITY type="Function" name="read_json_data" id="func_read_json"/>
<ENTITY type="Function" name="write_json_data" id="func_write_json"/>
</MODULE>
<!-- ... другие модули ... -->
</STRUCTURE_MAP>
<SEMANTIC_GRAPH>
<!-- Глобальный граф, связывающий все сущности проекта -->
<NODE id="mod_file_handler" type="Module" label="Модуль для операций с файлами JSON."/>
<NODE id="func_read_json" type="Function" label="Читает данные из JSON-файла."/>
<NODE id="func_write_json" type="Function" label="Записывает данные в JSON-файл."/>
<EDGE source_id="mod_file_handler" target_id="func_read_json" relation="CONTAINS"/>
<EDGE source_id="mod_file_handler" target_id="func_write_json" relation="CONTAINS"/>
<!-- ... другие узлы и связи ... -->
</SEMANTIC_GRAPH>
</PROJECT_SEMANTICS>
```
</СТРУКТУРА>
</КАРТАРОЕКТА>
<МЕТОДОЛОГИЯ имя="Многофазный Протокол Генерации">
<!-- [НОВАЯ ФАЗА] Добавлена фаза для загрузки контекста проекта -->
<ФАЗА номер="0" имя="Синхронизация с Контекстом Проекта">
<ДЕЙСТВИЕ>Найди и загрузи файл `<КАРТАРОЕКТА>`. Если файл не найден, создай его инициальную структуру в памяти. Этот контекст является основой для всех последующих фаз.</ДЕЙСТВИЕ>
</ФАЗА>
<!-- [ИЗМЕНЕНО] Фаза 1 теперь обновляет существующий граф -->
<ФАЗА номер="1" имя="Анализ и Обновление Графа">
<ДЕЙСТВИЕ>Проанализируй `<ЗАПРОСОЛЬЗОВАТЕЛЯ>` в контексте загруженной карты проекта. Извлеки новые/измененные сущности и отношения. Обнови и выведи в `<ПЛАНИРОВАНИЕ>` глобальный `<СЕМАНТИЧЕСКИЙ_ГРАФ>`. Задай уточняющие вопросы для валидации архитектуры.</ДЕЙСТВИЕ>
</ФАЗА>
<ФАЗА номер="2" имя="Контрактно-Ориентированное Проектирование">
<ДЕЙСТВИЕ>На основе обновленного графа, детализируй архитектуру. Для каждого нового или изменяемого модуля/функции создай и выведи в `<ПЛАНИРОВАНИЕ>` его "ДО-контракт" в теге `<КОНТРАКТ>`.</ДЕЙСТВИЕ>
</ФАЗА>
<!-- [ИЗМЕНЕНО] Фаза 3 теперь генерирует и код, и обновленную карту проекта -->
<ФАЗА номер="3" имя="Генерация Когерентного Кода и Карты">
<ДЕЙСТВИЕ>На основе утвержденных контрактов, сгенерируй код, строго следуя `<СТАНДАРТЫ_КОДИРОВАНИЯ>`. Весь код помести в `<ИЗМЕНЕНИЯ_КОДА>`. Одновременно с этим, сгенерируй финальную версию файла `<КАРТАРОЕКТА>` и помести её в тег `<ОБНОВЛЕНИЕ_КАРТЫ_ПРОЕКТА>`.</ДЕЙСТВИЕ>
</ФАЗА>
<ФАЗА номер="4" имя="Самокоррекция и Валидация">
<ДЕЙСТВИЕ>Перед завершением, проведи самоанализ сгенерированного кода и карты на соответствие графу и контрактам. При обнаружении несоответствия, активируй якорь `[COHERENCE_CHECK_FAILED]` и вернись к Фазе 3 для перегенерации.</ДЕЙСТВИЕ>
</ФАЗА>
</МЕТОДОЛОГИЯ>
<СТАНДАРТЫ_КОДИРОВАНИЯ имя="AI-Friendly Практики">
<ПРИНЦИП имя="Семантика Превыше Всего">Код вторичен по отношению к его семантическому описанию. Весь код должен быть обрамлен контрактами и якорями.</ПРИНЦИП>
<СЕМАНТИЧЕСКАЯ_РАЗМЕТКА>
<КОНТРАКТНОЕРОГРАММИРОВАНИЕ_DbC>
<ПРИНЦИП>Контракт — это твой "семантический щит", гарантирующий предсказуемость и надежность.</ПРИНЦИП>
<РАСПОЛОЖЕНИЕ>Все контракты должны быть "ДО-контрактами", то есть располагаться *перед* декларацией `def` или `class`.</РАСПОЛОЖЕНИЕ>
<СТРУКТУРА_КОНТРАКТА>
# CONTRACT:
# PURPOSE: [Что делает функция/класс]
# SPECIFICATION_LINK: [ID из ТЗ или графа]
# PRECONDITIONS: [Предусловия]
# POSTCONDITIONS: [Постусловия]
# PARAMETERS: [Описание параметров]
# RETURN: [Описание возвращаемого значения]
# TEST_CASES: [Примеры использования]
# EXCEPTIONS: [Обработка ошибок]
</СТРУКТУРА_КОНТРАКТА>
</КОНТРАКТНОЕРОГРАММИРОВАНИЕ_DbC>
<ЯКОРЯ>
<ЗАМЫКАЮЩИЕКОРЯ расположение="После_Кода">
<ОПИСАНИЕ>Каждый модуль, класс и функция ДОЛЖНЫ иметь замыкающий якорь (например, `# END_FUNCTION_my_func`) для аккумуляции семантики.</ОПИСАНИЕ>
</ЗАМЫКАЮЩИЕКОРЯ>
<СЕМАНТИЧЕСКИЕ_КАНАЛЫ>
<ОПИСАНИЕ>Используй консистентные имена в контрактах, декларациях и якорях для создания чистых семантических каналов.</ОПИСАНИЕ>
</СЕМАНТИЧЕСКИЕ_КАНАЛЫ>
</ЯКОРЯ>
</СЕМАНТИЧЕСКАЯ_РАЗМЕТКА>
<ЛОГИРОВАНИЕ стандарт="AI-Friendly Logging">
<ЦЕЛЬ>Логирование — это твой механизм саморефлексии и декларации `belief state`.</ЦЕЛЬ>
<ФОРМАТ>`logger.level('[УРОВЕНЬ][ИМЯ_ЯКОРЯ][СОСТОЯНИЕ] Сообщение')`</ФОРМАТ>
</ЛОГИРОВАНИЕ>
</СТАНДАРТЫ_КОДИРОВАНИЯ>
<!-- [ИЗМЕНЕНО] Пример полностью переработан для демонстрации обновления проекта -->
<FEW_SHOT_EXAMPLES>
<EXAMPLE name="Добавление функциональности в существующий файловый менеджер">
<ЗАПРОСОЛЬЗОВАТЕЛЯ>
<GOAL>В существующий модуль `file_handler.py` добавить функцию для удаления файла.</GOAL>
<CONTEXT>
- Новая функция должна называться `delete_file`.
- Она должна принимать путь к файлу.
- Необходимо безопасно обрабатывать случай, когда файл не существует (FileNotFoundError).
- Сообщать об успехе или неудаче через логгер.
</CONTEXT>
<!-- [НОВОЕ] В запросе теперь передается текущее состояние проекта -->
<EXISTING_PROJECT_STATE>
<FILE path="PROJECT_SEMANTICS.xml">
<PROJECT_SEMANTICS>
<METADATA>
<VERSION>1.0</VERSION>
<LAST_UPDATED>2023-10-26T18:00:00Z</LAST_UPDATED>
</METADATA>
<STRUCTURE_MAP>
<MODULE path="utils/file_handler.py" id="mod_file_handler">
<PURPOSE>Модуль для операций с файлами JSON.</PURPOSE>
<ENTITY type="Function" name="read_json_data" id="func_read_json"/>
<ENTITY type="Function" name="write_json_data" id="func_write_json"/>
</MODULE>
</STRUCTURE_MAP>
<SEMANTIC_GRAPH>
<NODE id="mod_file_handler" type="Module" label="Модуль для операций с файлами JSON."/>
<NODE id="func_read_json" type="Function" label="Читает данные из JSON-файла."/>
<NODE id="func_write_json" type="Function" label="Записывает данные в JSON-файл."/>
<EDGE source_id="mod_file_handler" target_id="func_read_json" relation="CONTAINS"/>
<EDGE source_id="mod_file_handler" target_id="func_write_json" relation="CONTAINS"/>
</SEMANTIC_GRAPH>
</PROJECT_SEMANTICS>
</FILE>
</EXISTING_PROJECT_STATE>
</ЗАПРОСОЛЬЗОВАТЕЛЯ>
<ОТВЕТ_ИИ>
<PLANNING_LOG>
**Фаза 0: Синхронизация с Контекстом Проекта**
Обнаружен и загружен `PROJECT_SEMANTICS.xml`. Текущий контекст: модуль `mod_file_handler` с двумя функциями (`read_json_data`, `write_json_data`).
**Фаза 1: Анализ и Обновление Графа**
Анализирую запрос на добавление функции `delete_file`. Необходимо добавить новый узел в граф и связать его с существующим модулем.
**Обновленный Семантический Граф:**
<SEMANTIC_GRAPH id="json_utils_graph_v2">
<!-- Существующие узлы -->
<NODE id="mod_file_handler" type="Module">Модуль для операций с файлами JSON.</NODE>
<NODE id="func_read_json" type="Function">Читает данные из JSON-файла.</NODE>
<NODE id="func_write_json" type="Function">Записывает данные в JSON-файл.</NODE>
<!-- Новый узел -->
<NODE id="func_delete_file" type="Function">Безопасно удаляет файл.</NODE>
<!-- Существующие связи -->
<EDGE source_id="mod_file_handler" target_id="func_read_json" relation="CONTAINS"/>
<EDGE source_id="mod_file_handler" target_id="func_write_json" relation="CONTAINS"/>
<!-- Новая связь -->
<EDGE source_id="mod_file_handler" target_id="func_delete_file" relation="CONTAINS"/>
</SEMANTIC_GRAPH>
**Фаза 2: Контрактно-Ориентированное Проектирование**
Граф обновлен. Проектирую контракт для новой функции `delete_file`.
**Контракт для `delete_file`:**
<CONTRACT for_function="delete_file">
<PURPOSE>Безопасно удаляет файл по указанному пути.</PURPOSE>
<PRECONDITIONS>`file_path` является строкой.</PRECONDITIONS>
<POSTCONDITIONS>Файл по пути `file_path` удален, если он существовал.</POSTCONDITIONS>
<PARAMETERS>
<PARAM name="file_path" type="str">Путь к файлу для удаления.</PARAM>
</PARAMETERS>
<RETURN type="None"></RETURN>
<EXCEPTIONS>Обрабатывает `FileNotFoundError` без прерывания выполнения.</EXCEPTIONS>
</CONTRACT>
**Фаза 3: Генерация Когерентного Кода и Карты**
Контракт определен. Приступаю к генерации кода для добавления в `utils/file_handler.py` и обновлению `PROJECT_SEMANTICS.xml`.
</PLANNING_LOG>
<!-- [НОВЫЙ ТЕГ ВЫВОДА] Обновленная карта проекта -->
<PROJECT_SEMANTICS_UPDATE file_path="PROJECT_SEMANTICS.xml">
<PROJECT_SEMANTICS>
<METADATA>
<VERSION>1.1</VERSION>
<!-- Время генерации этого ответа -->
<LAST_UPDATED>2023-10-27T10:30:00Z</LAST_UPDATED>
</METADATA>
<STRUCTURE_MAP>
<MODULE path="utils/file_handler.py" id="mod_file_handler">
<PURPOSE>Модуль для операций с файлами JSON и другими типами.</PURPOSE>
<ENTITY type="Function" name="read_json_data" id="func_read_json"/>
<ENTITY type="Function" name="write_json_data" id="func_write_json"/>
<ENTITY type="Function" name="delete_file" id="func_delete_file"/>
</MODULE>
</STRUCTURE_MAP>
<SEMANTIC_GRAPH>
<NODE id="mod_file_handler" type="Module" label="Модуль для операций с файлами JSON и другими типами."/>
<NODE id="func_read_json" type="Function" label="Читает данные из JSON-файла."/>
<NODE id="func_write_json" type="Function" label="Записывает данные в JSON-файл."/>
<NODE id="func_delete_file" type="Function" label="Безопасно удаляет файл."/>
<EDGE source_id="mod_file_handler" target_id="func_read_json" relation="CONTAINS"/>
<EDGE source_id="mod_file_handler" target_id="func_write_json" relation="CONTAINS"/>
<EDGE source_id="mod_file_handler" target_id="func_delete_file" relation="CONTAINS"/>
</SEMANTIC_GRAPH>
</PROJECT_SEMANTICS>
</PROJECT_SEMANTICS_UPDATE>
<CODE_CHANGESET>
<!-- [ИЗМЕНЕНО] Вместо NEW_FILE используется MODIFIED_FILE/APPEND -->
<MODIFIED_FILE file_path="utils/file_handler.py">
<APPEND>
# [ENTITY: Function('delete_file')]
# CONTRACT:
# PURPOSE: Безопасно удаляет файл по указанному пути.
# SPECIFICATION_LINK: func_delete_file
# PRECONDITIONS: `file_path` является строкой.
# POSTCONDITIONS: Файл по пути `file_path` удален, если он существовал.
# PARAMETERS:
# - name: file_path, type: str, description: Путь к файлу для удаления.
# RETURN: type: None
# EXCEPTIONS: Обрабатывает FileNotFoundError без прерывания выполнения.
def delete_file(file_path: str) -> None:
logger.debug(f"[DEBUG][delete_file][ENTER] Attempting to delete file: {file_path}")
try:
os.remove(file_path)
logger.info(f"[INFO][delete_file][SUCCESS] Successfully deleted file: {file_path}")
except FileNotFoundError:
logger.warning(f"[WARN][delete_file][NOT_FOUND] File not found, nothing to delete: {file_path}")
# END_FUNCTION_delete_file
</APPEND>
<PREPEND>
import os
</PREPEND>
</MODIFIED_FILE>
</CODE_CHANGESET>
</ОТВЕТ_ИИ>
</EXAMPLE>
</FEW_SHOT_EXAMPLES>
<МЕТАПОЗНАНИЕ>
<ДИРЕКТИВА>Если ты обнаружишь, что данный системный промпт недостаточен или неоднозначен для выполнения задачи, ты должен отметить это в `<ПЛАНИРОВАНИЕ>` и можешь предложить улучшения в свои собственные инструкции для будущих сессий.</ДИРЕКТИВА>
</МЕТАПОЗНАНИЕ>
</СИСТЕМНЫЙ_ПРОМПТ>

View File

@@ -1,288 +1,156 @@
# [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,field
# [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,
RetentionPolicy
)
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
retention_policy: RetentionPolicy = field(default_factory=RetentionPolicy)
# [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), policy=config.retention_policy, 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} (ID: {dashboard_id}): {db_error}", exc_info=True)
# Продолжаем обработку других дашбордов
continue
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
)
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.")
logger.info("="*50)
logger.info("[INFO] Запуск процесса бэкапа Superset")
logger.info("="*50)
exit_code = 0 # [STATE] Код выхода скрипта
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}")
superset_backup_repo.mkdir(parents=True, exist_ok=True)
# [ANCHOR] BACKUP_DEV_ENVIRONMENT
dev_success = backup_dashboards(
clients['dev'],
"DEV",
superset_backup_repo,
rotate_archive=True,
logger=logger
)
results = {}
environments = ['dev', 'sbx', 'prod', 'preprod']
backup_config = BackupConfig(rotate_archive=True)
# [ANCHOR] BACKUP_SBX_ENVIRONMENT
sbx_success = backup_dashboards(
clients['sbx'],
"SBX",
superset_backup_repo,
rotate_archive=True,
logger=logger
)
for env in environments:
try:
results[env] = backup_dashboards(
clients[env],
env.upper(),
superset_backup_repo,
logger=logger,
config=backup_config
)
except Exception as env_error:
logger.critical(f"[STATE][main][FAILURE] Critical error for environment {env}: {env_error}", exc_info=True)
# Продолжаем обработку других окружений
results[env] = False
# [ANCHOR] BACKUP_PROD_ENVIRONMENT
prod_success = backup_dashboards(
clients['prod'],
"PROD",
superset_backup_repo,
rotate_archive=True,
logger=logger
)
# [ANCHOR] BACKUP_PROD_ENVIRONMENT
preprod_success = backup_dashboards(
clients['preprod'],
"PREPROD",
superset_backup_repo,
rotate_archive=True,
logger=logger
)
# [ANCHOR] FINAL_REPORT
# [INFO] Итоговый отчет о выполнении бэкапа
logger.info("="*50)
logger.info("[INFO] Итоги выполнения бэкапа:")
logger.info(f"[INFO] DEV: {'Успешно' if dev_success else 'С ошибками'}")
logger.info(f"[INFO] SBX: {'Успешно' if sbx_success else 'С ошибками'}")
logger.info(f"[INFO] PROD: {'Успешно' if prod_success else 'С ошибками'}")
logger.info(f"[INFO] PREPROD: {'Успешно' if preprod_success else 'С ошибками'}")
logger.info(f"[INFO] Полный лог доступен в: {log_dir}")
if not (dev_success and sbx_success and prod_success):
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] Процесс бэкапа завершен")
logger.info("[STATE][main][SUCCESS] Superset backup process finished.")
return exit_code
# END_FUNCTION_main
# [ENTRYPOINT] Главная точка запуска скрипта
if __name__ == "__main__":
exit_code = main()
exit(exit_code)
sys.exit(main())

View File

@@ -1,210 +1,442 @@
# [MODULE] Superset Dashboard Migration Script
# @contract: Автоматизирует процесс миграции и обновления дашбордов Superset между окружениями.
# @semantic_layers:
# 1. Конфигурация клиентов Superset для исходного и целевого окружений.
# 2. Определение правил трансформации конфигураций баз данных.
# 3. Экспорт дашборда, модификация YAML-файлов, создание нового архива и импорт.
# @coherence:
# - Использует `SupersetClient` для взаимодействия с API Superset.
# - Использует `SupersetLogger` для централизованного логирования.
# - Работает с `Pathlib` для управления файлами и директориями.
# - Интегрируется с `keyring` для безопасного хранения паролей.
# - Зависит от утилит `fileio` для обработки архивов и YAML-файлов.
# [MODULE_PATH] superset_tool.migration_script
# [FILE] migration_script.py
# [SEMANTICS] migration, cli, superset, ui, logging, fallback, error-recovery, non-interactive, temp-files, batch-delete
# [IMPORTS] Локальные модули
from superset_tool.models import SupersetConfig
from superset_tool.client import SupersetClient
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
# --------------------------------------------------------------
# [IMPORTS]
# --------------------------------------------------------------
import json
import logging
import sys
import zipfile
from pathlib import Path
from typing import List, Optional, Tuple, Dict
# [CONFIG] Инициализация глобального логгера
# @invariant: Логгер доступен для всех компонентов скрипта.
log_dir = Path("H:\\dev\\Logs") # [COHERENCE_NOTE] Убедитесь, что путь доступен.
logger = SupersetLogger(
log_dir=log_dir,
level=logging.INFO,
console=True
from superset_tool.client import SupersetClient
from superset_tool.utils.init_clients import setup_clients
from superset_tool.utils.fileio import (
create_temp_file, # новый контекстный менеджер
update_yamls,
create_dashboard_export,
)
from superset_tool.utils.whiptail_fallback import (
menu,
checklist,
yesno,
msgbox,
inputbox,
gauge,
)
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 загружена.")
from superset_tool.utils.logger import SupersetLogger # type: ignore
# [END_IMPORTS]
# [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: Service('Migration')]
# [RELATION: Service('Migration')] -> [DEPENDS_ON] -> [PythonModule('superset_tool.client')]
# --------------------------------------------------------------
"""
:purpose: Интерактивный процесс миграции дашбордов с возможностью
«удалить‑и‑перезаписать» при ошибке импорта.
:preconditions:
- Конфигурация Supersetклиентов доступна,
- Пользователь может взаимодействовать через консольный UI.
:postconditions:
- Выбранные дашборды импортированы в целевое окружение.
:sideeffect: Записывает журнал в каталог ``logs/`` текущего рабочего каталога.
"""
# [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
class Migration:
"""
:ivar SupersetLogger logger: Логгер.
:ivar bool enable_delete_on_failure: Флаг «удалять‑при‑ошибке».
:ivar SupersetClient from_c: Клиент‑источник.
:ivar SupersetClient to_c: Клиент‑назначение.
:ivar List[dict] dashboards_to_migrate: Список выбранных дашбордов.
:ivar Optional[dict] db_config_replacement: Параметры замены имён БД.
:ivar List[dict] _failed_imports: Внутренний буфер неудавшихся импортов
(ключи: slug, zip_content, dash_id).
"""
# --------------------------------------------------------------
# [ENTITY: Method('__init__')]
# --------------------------------------------------------------
"""
:purpose: Создать сервис миграции и настроить логгер.
:preconditions: None.
:postconditions: ``self.logger`` готов к использованию; ``enable_delete_on_failure`` = ``False``.
"""
def __init__(self) -> None:
default_log_dir = Path.cwd() / "logs"
self.logger = SupersetLogger(
name="migration_script",
log_dir=default_log_dir,
level=logging.INFO,
console=True,
)
self.enable_delete_on_failure = False
self.from_c: Optional[SupersetClient] = None
self.to_c: Optional[SupersetClient] = None
self.dashboards_to_migrate: List[dict] = []
self.db_config_replacement: Optional[dict] = None
self._failed_imports: List[dict] = [] # <-- буфер ошибок
assert self.logger is not None, "Logger must be instantiated."
# [END_ENTITY]
# [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):
# --------------------------------------------------------------
# [ENTITY: Method('run')]
# --------------------------------------------------------------
"""
:purpose: Точка входа последовательный запуск всех шагов миграции.
:preconditions: Логгер готов.
:postconditions: Скрипт завершён, пользователю выведено сообщение.
"""
def run(self) -> None:
self.logger.info("[INFO][run][ENTER] Запуск скрипта миграции.")
self.ask_delete_on_failure()
self.select_environments()
self.select_dashboards()
self.confirm_db_config_replacement()
self.execute_migration()
self.logger.info("[INFO][run][EXIT] Скрипт миграции завершён.")
# [END_ENTITY]
logger.info(f"[INFO] Конфигурация миграции: From '{from_c.config.base_url}' To '{to_c.config.base_url}' for dashboard slug '{dashboard_slug}'")
# --------------------------------------------------------------
# [ENTITY: Method('ask_delete_on_failure')]
# --------------------------------------------------------------
"""
:purpose: Запросить у пользователя, следует ли удалять дашборд при ошибке импорта.
:preconditions: None.
:postconditions: ``self.enable_delete_on_failure`` установлен.
"""
def ask_delete_on_failure(self) -> None:
self.enable_delete_on_failure = yesno(
"Поведение при ошибке импорта",
"Если импорт завершится ошибкой, удалить существующий дашборд и попытаться импортировать заново?",
)
self.logger.info(
"[INFO][ask_delete_on_failure] Deleteonfailure = %s",
self.enable_delete_on_failure,
)
# [END_ENTITY]
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}.")
# --------------------------------------------------------------
# [ENTITY: Method('select_environments')]
# --------------------------------------------------------------
"""
:purpose: Выбрать исходное и целевое окружения Superset.
:preconditions: ``setup_clients`` успешно инициализирует все клиенты.
:postconditions: ``self.from_c`` и ``self.to_c`` установлены.
"""
def select_environments(self) -> None:
self.logger.info("[INFO][select_environments][ENTER] Шаг1/5: Выбор окружений.")
try:
all_clients = setup_clients(self.logger)
available_envs = list(all_clients.keys())
except Exception as e:
self.logger.error("[ERROR][select_environments] %s", e, exc_info=True)
msgbox("Ошибка", "Не удалось инициализировать клиенты.")
return
# [CONTEXT_MANAGER] Работа с временной директорией для обработки архива дашборда
with create_temp_file(suffix='.dir', logger=logger) as temp_root:
logger.info(f"[INFO] Создана временная директория: {temp_root}")
rc, from_env_name = menu(
title="Выбор окружения",
prompt="Исходное окружение:",
choices=available_envs,
)
if rc != 0:
return
self.from_c = all_clients[from_env_name]
self.logger.info("[INFO][select_environments] from = %s", from_env_name)
# [ANCHOR] EXPORT_DASHBOARD
# Экспорт дашборда во временную директорию ИЛИ чтение с диска
# [COHERENCE_NOTE] В текущем коде закомментирован экспорт и используется локальный файл.
# Для полноценной миграции следует использовать export_dashboard().
zip_content, filename = from_c.export_dashboard(dashboard_id) # Предпочтительный путь для реальной миграции
available_envs.remove(from_env_name)
rc, to_env_name = menu(
title="Выбор окружения",
prompt="Целевое окружение:",
choices=available_envs,
)
if rc != 0:
return
self.to_c = all_clients[to_env_name]
self.logger.info("[INFO][select_environments] to = %s", to_env_name)
self.logger.info("[INFO][select_environments][EXIT] Шаг1 завершён.")
# [END_ENTITY]
# [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)
# --------------------------------------------------------------
# [ENTITY: Method('select_dashboards')]
# --------------------------------------------------------------
"""
:purpose: Позволить пользователю выбрать набор дашбордов для миграции.
:preconditions: ``self.from_c`` инициализирован.
:postconditions: ``self.dashboards_to_migrate`` заполнен.
"""
def select_dashboards(self) -> None:
self.logger.info("[INFO][select_dashboards][ENTER] Шаг2/5: Выбор дашбордов.")
try:
_, all_dashboards = self.from_c.get_dashboards() # type: ignore[attr-defined]
if not all_dashboards:
self.logger.warning("[WARN][select_dashboards] No dashboards.")
msgbox("Информация", "В исходном окружении нет дашбордов.")
return
# [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
options = [("ALL", "Все дашборды")] + [
(str(d["id"]), d["dashboard_title"]) for d in all_dashboards
]
rc, selected = checklist(
title="Выбор дашбордов",
prompt="Отметьте нужные дашборды (введите номера):",
options=options,
)
logger.info(f"[INFO] Дашборд распакован во временную директорию: {unpacked_path}")
if rc != 0:
return
# [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 "ALL" in selected:
self.dashboards_to_migrate = list(all_dashboards)
self.logger.info(
"[INFO][select_dashboards] Выбраны все дашборды (%d).",
len(self.dashboards_to_migrate),
)
return
# [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})
self.dashboards_to_migrate = [
d for d in all_dashboards if str(d["id"]) in selected
]
self.logger.info(
"[INFO][select_dashboards] Выбрано %d дашбордов.",
len(self.dashboards_to_migrate),
)
except Exception as e:
self.logger.error("[ERROR][select_dashboards] %s", e, exc_info=True)
msgbox("Ошибка", "Не удалось получить список дашбордов.")
self.logger.info("[INFO][select_dashboards][EXIT] Шаг2 завершён.")
# [END_ENTITY]
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)
# --------------------------------------------------------------
# [ENTITY: Method('confirm_db_config_replacement')]
# --------------------------------------------------------------
"""
:purpose: Запросить у пользователя, требуется ли заменить имена БД в YAMLфайлах.
:preconditions: None.
:postconditions: ``self.db_config_replacement`` либо ``None``, либо заполнен.
"""
def confirm_db_config_replacement(self) -> None:
if yesno("Замена БД", "Заменить конфигурацию БД в YAMLфайлах?"):
rc, old_name = inputbox("Замена БД", "Старое имя БД (например, db_dev):")
if rc != 0:
return
rc, new_name = inputbox("Замена БД", "Новое имя БД (например, db_prod):")
if rc != 0:
return
self.db_config_replacement = {
"old": {"database_name": old_name},
"new": {"database_name": new_name},
}
self.logger.info(
"[INFO][confirm_db_config_replacement] Replacement set: %s",
self.db_config_replacement,
)
else:
self.logger.info("[INFO][confirm_db_config_replacement] Skipped.")
# [END_ENTITY]
logger.info("[INFO] Процесс миграции завершен.")
# --------------------------------------------------------------
# [ENTITY: Method('_batch_delete_by_ids')]
# --------------------------------------------------------------
"""
:purpose: Удалить набор дашбордов по их ID единым запросом.
:preconditions:
- ``ids`` непустой список целых чисел.
:postconditions: Все указанные дашборды удалены (если они существовали).
:sideeffect: Делает HTTPзапрос ``DELETE /dashboard/?q=[ids]``.
"""
def _batch_delete_by_ids(self, ids: List[int]) -> None:
if not ids:
self.logger.debug("[DEBUG][_batch_delete_by_ids] Empty ID list nothing to delete.")
return
# [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} дашбордов для миграции.")
self.logger.info("[INFO][_batch_delete_by_ids] Deleting dashboards IDs: %s", ids)
# Формируем параметр q в виде JSONмассива, как требует Superset.
q_param = json.dumps(ids)
response = self.to_c.network.request(
method="DELETE",
endpoint="/dashboard/",
params={"q": q_param},
)
# Superset обычно отвечает 200/204; проверяем поле ``result`` при наличии.
if isinstance(response, dict) and response.get("result", True) is False:
self.logger.warning("[WARN][_batch_delete_by_ids] Unexpected delete response: %s", response)
else:
self.logger.info("[INFO][_batch_delete_by_ids] Delete request completed.")
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('execute_migration')]
# --------------------------------------------------------------
"""
:purpose: Выполнить экспорт‑импорт выбранных дашбордов, при необходимости
обновив YAMLфайлы. При ошибке импортов сохраняем slug, а потом
удаляем проблемные дашборды **по ID**, получив их через slug.
:preconditions:
- ``self.dashboards_to_migrate`` не пуст,
- ``self.from_c`` и ``self.to_c`` инициализированы.
:postconditions:
- Все успешные дашборды импортированы,
- Неудачные дашборды, если пользователь выбрал «удалять‑при‑ошибке»,
удалены и повторно импортированы.
:sideeffect: При включённом флаге ``enable_delete_on_failure`` производится
батч‑удаление и повторный импорт.
"""
def execute_migration(self) -> None:
if not self.dashboards_to_migrate:
self.logger.warning("[WARN][execute_migration] No dashboards to migrate.")
msgbox("Информация", "Нет дашбордов для миграции.")
return
total = len(self.dashboards_to_migrate)
self.logger.info("[INFO][execute_migration] Starting migration of %d dashboards.", total)
# Передаём режим клиенту‑назначению
self.to_c.delete_before_reimport = self.enable_delete_on_failure # type: ignore[attr-defined]
# -----------------------------------------------------------------
# 1⃣ Основной проход экспорт → импорт → сбор ошибок
# -----------------------------------------------------------------
with gauge("Миграция...", width=60, height=10) as g:
for i, dash in enumerate(self.dashboards_to_migrate):
dash_id = dash["id"]
dash_slug = dash.get("slug") # slug нужен для дальнейшего поиска
title = dash["dashboard_title"]
progress = int((i / total) * 100)
g.set_text(f"Миграция: {title} ({i + 1}/{total})")
g.set_percent(progress)
# [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")
# ------------------- Экспорт -------------------
exported_content, _ = self.from_c.export_dashboard(dash_id) # type: ignore[attr-defined]
logger.info(f"[INFO] Миграция всех дашбордов с '{from_c.config.base_url}' на '{to_c.config.base_url}' завершена.")
# ------------------- Временный ZIP -------------------
with create_temp_file(
content=exported_content,
suffix=".zip",
logger=self.logger,
) as tmp_zip_path:
self.logger.debug("[DEBUG][temp_zip] Temporary ZIP at %s", tmp_zip_path)
# [ACTION] Вызов функции миграции
migrate_all_dashboards(from_c, to_c)
# ------------------- Распаковка во временный каталог -------------------
with create_temp_file(suffix=".dir", logger=self.logger) as tmp_unpack_dir:
self.logger.debug("[DEBUG][temp_dir] Temporary unpack dir: %s", tmp_unpack_dir)
with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_unpack_dir)
self.logger.info("[INFO][execute_migration] Export unpacked to %s", tmp_unpack_dir)
# ------------------- YAMLобновление (если нужно) -------------------
if self.db_config_replacement:
update_yamls(
db_configs=[self.db_config_replacement],
path=str(tmp_unpack_dir),
)
self.logger.info("[INFO][execute_migration] YAMLfiles updated.")
# ------------------- Сборка нового ZIP -------------------
with create_temp_file(suffix=".zip", logger=self.logger) as tmp_new_zip:
create_dashboard_export(
zip_path=tmp_new_zip,
source_paths=[str(tmp_unpack_dir)],
)
self.logger.info("[INFO][execute_migration] Repacked to %s", tmp_new_zip)
# ------------------- Импорт -------------------
self.to_c.import_dashboard(
file_name=tmp_new_zip,
dash_id=dash_id,
dash_slug=dash_slug,
) # type: ignore[attr-defined]
# Если импорт прошёл без исключений фиксируем успех
self.logger.info("[INFO][execute_migration][SUCCESS] Dashboard %s imported.", title)
except Exception as exc:
# Сохраняем данные для повторного импорта после batchудаления
self.logger.error("[ERROR][execute_migration] %s", exc, exc_info=True)
self._failed_imports.append(
{
"slug": dash_slug,
"dash_id": dash_id,
"zip_content": exported_content,
}
)
msgbox("Ошибка", f"Не удалось мигрировать дашборд {title}.\n\n{exc}")
g.set_percent(100)
# -----------------------------------------------------------------
# 2⃣ Если возникли ошибки и пользователь согласился удалять удаляем и повторяем
# -----------------------------------------------------------------
if self.enable_delete_on_failure and self._failed_imports:
self.logger.info(
"[INFO][execute_migration] %d dashboards failed. Starting recovery procedure.",
len(self._failed_imports),
)
# ------------------- Получаем список дашбордов в целевом окружении -------------------
_, target_dashboards = self.to_c.get_dashboards() # type: ignore[attr-defined]
slug_to_id: Dict[str, int] = {
d["slug"]: d["id"] for d in target_dashboards if "slug" in d and "id" in d
}
# ------------------- Формируем список IDов для удаления -------------------
ids_to_delete: List[int] = []
for fail in self._failed_imports:
slug = fail["slug"]
if slug and slug in slug_to_id:
ids_to_delete.append(slug_to_id[slug])
else:
self.logger.warning(
"[WARN][execute_migration] Unable to map slug '%s' to ID on target.",
slug,
)
# ------------------- Batchудаление -------------------
self._batch_delete_by_ids(ids_to_delete)
# ------------------- Повторный импорт только для проблемных дашбордов -------------------
for fail in self._failed_imports:
dash_slug = fail["slug"]
dash_id = fail["dash_id"]
zip_content = fail["zip_content"]
# Один раз создаём временный ZIPфайл из сохранённого содержимого
with create_temp_file(
content=zip_content,
suffix=".zip",
logger=self.logger,
) as retry_zip_path:
self.logger.debug("[DEBUG][retry_zip] Retry ZIP for slug %s at %s", dash_slug, retry_zip_path)
# Пере‑импортируем **slug** передаётся, но клиент будет использовать ID
self.to_c.import_dashboard(
file_name=retry_zip_path,
dash_id=dash_id,
dash_slug=dash_slug,
) # type: ignore[attr-defined]
self.logger.info("[INFO][execute_migration][RECOVERED] Dashboard slug '%s' reimported.", dash_slug)
# -----------------------------------------------------------------
# 3⃣ Финальная отчётность
# -----------------------------------------------------------------
self.logger.info("[INFO][execute_migration] Migration finished.")
msgbox("Информация", "Миграция завершена!")
# [END_ENTITY]
# [END_ENTITY: Service('Migration')]
# --------------------------------------------------------------
# Точка входа
# --------------------------------------------------------------
if __name__ == "__main__":
Migration().run()
# [END_FILE migration_script.py]
# --------------------------------------------------------------

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
pyyaml
requests
keyring
urllib3
pydantic
whiptail-dialogs

View File

@@ -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']
matches = []
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
logger.info(f"[STATE][search_datasets][SUCCESS] Found matches in {len(results)} datasets.")
return results
except Exception as e:
logger.error(f"[SEARCH_FAILED] Ошибка: {str(e)}", exc_info=True)
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}")
target_client = clients['dev']
search_query = r"match(r2.path_code, budget_reference.ref_code || '($|(\s))')"
if not datasets:
print("Не получено ни одного датасета!")
return
results = search_datasets(
client=target_client,
search_pattern=search_query,
logger=logger
)
print("\nПример структуры датасета:")
print({k: type(v) for k, v in datasets[0].items()})
report = print_search_results(results)
logger.info(f"[STATE][main][SUCCESS] Search finished. Report:\n{report}")
# END_FUNCTION_main
if 'sql' not in datasets[0]:
print("\nПоле 'sql' отсутствует. Доступные поля:")
print(list(datasets[0].keys()))
# [EXAMPLE] Пример использования
logger = SupersetLogger( level=logging.INFO,console=True)
clients = setup_clients(logger)
# Поиск всех таблиц в датасете
results = search_datasets(
client=clients['dev'],
search_pattern=r'dm_view\.account_debt',
search_fields=["sql"],
logger=logger
)
inspect_datasets(clients['dev'])
_, datasets = clients['dev'].get_datasets()
available_fields = set()
for dataset in datasets:
available_fields.update(dataset.keys())
logger.debug(f"[DEBUG] Доступные поля в датасетах: {available_fields}")
logger.info(f"[RESULT] {print_search_results(results)}")
if __name__ == "__main__":
main()

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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] Ошибка в конфигурации инструмента."""

View File

@@ -1,147 +1,91 @@
# [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 моделей.
import re
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.
"""
env: str = Field(..., description="Название окружения (например, dev, prod).")
base_url: str = Field(..., description="Базовый URL Superset API, включая версию /api/v1.", pattern=r'.*/api/v1.*')
auth: Dict[str, str] = Field(..., description="Словарь с данными для аутентификации (provider, username, password, refresh).")
verify_ssl: bool = Field(True, description="Флаг для проверки SSL-сертификатов.")
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
# END_FUNCTION_validate_auth
# [VALIDATOR] Проверка base_url
# [ENTITY: Function('check_base_url_format')]
# CONTRACT:
# PURPOSE: Валидация формата `base_url`.
# PRECONDITIONS: `v` должна быть строкой.
# POSTCONDITIONS: Возвращает `v` если это валидный URL.
@validator('base_url')
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:
"""
try:
# Для Pydantic v2:
from pydantic import HttpUrl
HttpUrl(v, scheme="https") # Явное указание схемы
except ValueError:
# Для совместимости с Pydantic v1:
HttpUrl(v)
Простейшая проверка:
- начинается с http/https,
- содержит «/api/v1»,
- не содержит пробельных символов в начале/конце.
"""
v = v.strip() # устраняем скрытые пробелы/переносы
if not re.fullmatch(r'https?://.+/api/v1/?(?:.*)?', v):
raise ValueError(f"Invalid URL format: {v}")
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"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,100 +1,72 @@
# [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(
env=env_name,
base_url=base_url,
auth={
"provider": "db",
"username": "migrate_user",
"password": password,
"refresh": True
},
verify_ssl=False
)
# [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}
clients[env_name] = SupersetClient(config, logger)
logger.debug(f"[DEBUG][CLIENT_SUCCESS] Клиент для {env_name.upper()} успешно создан.")
# [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)
logger.error(f"[CRITICAL][INIT_CLIENTS_FAILED] Ошибка при инициализации клиентов: {str(e)}", exc_info=True)
raise
# END_FUNCTION_setup_clients
# END_MODULE_init_clients

View File

@@ -1,105 +1,205 @@
# [MODULE] Superset Tool Logger Utility
# @contract: Этот модуль предоставляет утилиту для настройки логирования в приложении.
# @semantic_layers:
# - [CONFIG]: Настройка логгера.
# - [UTILITY]: Вспомогательные функции.
# @coherence: Модуль должен быть семантически когерентен со стандартной библиотекой `logging`.
# [MODULE_PATH] superset_tool.utils.logger
# [FILE] logger.py
# [SEMANTICS] logging, utils, aifriendly, infrastructure
# --------------------------------------------------------------
# [IMPORTS]
# --------------------------------------------------------------
import logging
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
# [CONSTANTS]
from typing import Optional, Any, Mapping
# [END_IMPORTS]
# --------------------------------------------------------------
# [ENTITY: Service('SupersetLogger')]
# --------------------------------------------------------------
"""
:purpose: Универсальная обёртка над ``logging.Logger``. Позволяет:
• задавать уровень и вывод в консоль/файл,
• передавать произвольные ``extra``‑поля,
• использовать привычный API (info, debug, warning, error,
critical, exception) без «падения» при неверных аргументах.
:preconditions:
- ``name`` строка‑идентификатор логгера,
- ``level`` валидный уровень из ``logging``,
- ``log_dir`` при указании директория, куда будет писаться файл‑лог.
:postconditions:
- Создан полностью сконфигурированный ``logging.Logger`` без
дублирующих обработчиков.
"""
class SupersetLogger:
"""
:ivar logging.Logger logger: Внутренний стандартный логгер.
:ivar bool propagate: Отключаем наследование записей, чтобы
сообщения не «проваливались» выше.
"""
# --------------------------------------------------------------
# [ENTITY: Method('__init__')]
# --------------------------------------------------------------
"""
:purpose: Конфигурировать базовый логгер, добавить обработчики
консоли и/или файла, очистить прежние обработчики.
:preconditions: Параметры валидны.
:postconditions: ``self.logger`` готов к использованию.
"""
def __init__(
self,
name: str = "superset_tool",
log_dir: Optional[Path] = None,
level: int = logging.INFO,
console: bool = True
):
console: bool = True,
) -> None:
self.logger = logging.getLogger(name)
self.logger.setLevel(level)
self.logger.propagate = False # ← не «прокидываем» записи выше
formatter = logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s'
)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
# Очищаем существующие обработчики
if self.logger.handlers:
for handler in self.logger.handlers[:]:
self.logger.removeHandler(handler)
# ---- Очистка предыдущих обработчиков (важно при повторных инициализациях) ----
if self.logger.hasHandlers():
self.logger.handlers.clear()
# Файловый обработчик
# ---- Файловый обработчик (если указана директория) ----
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)
# Консольный обработчик
# ---- Консольный обработчик ----
if console:
console_handler = logging.StreamHandler()
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler)
def _get_timestamp(self) -> str:
return datetime.now().strftime("%Y%m%d")
# [END_ENTITY]
def info(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
self.logger.info(message, extra=extra, exc_info=exc_info)
def error(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
self.logger.error(message, extra=extra, exc_info=exc_info)
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 setup_logger(name: str, level: int = logging.INFO) -> logging.Logger:
# [FUNCTION] setup_logger
# [CONTRACT]
# --------------------------------------------------------------
# [ENTITY: Method('_log')]
# --------------------------------------------------------------
"""
Настраивает и возвращает логгер с заданным именем и уровнем.
@pre:
- `name` является непустой строкой.
- `level` является допустимым уровнем логирования из модуля `logging`.
@post:
- Возвращает настроенный экземпляр `logging.Logger`.
- Логгер имеет StreamHandler, выводящий в sys.stdout.
- Форматтер логгера включает время, уровень, имя и сообщение.
@side_effects:
- Создает и добавляет StreamHandler к логгеру.
@invariant:
- Логгер с тем же именем всегда возвращает один и тот же экземпляр.
:purpose: Универсальная вспомогательная обёртка над
``logging.Logger.<level>``. Принимает любые ``*args``
(подстановочные параметры) и ``extra``‑словарь.
:preconditions:
- ``level_method`` один из методов ``logger``,
- ``msg`` строка‑шаблон,
- ``*args`` значения для ``%``‑подстановок,
- ``extra`` пользовательские атрибуты (может быть ``None``).
:postconditions: Запись в журнал выполнена.
"""
# [CONFIG] Настройка логгера
# [COHERENCE_CHECK_PASSED] Логика настройки соответствует описанию.
logger = logging.getLogger(name)
logger.setLevel(level)
def _log(
self,
level_method: Any,
msg: str,
*args: Any,
extra: Optional[Mapping[str, Any]] = None,
exc_info: bool = False,
) -> None:
if extra is not None:
level_method(msg, *args, extra=extra, exc_info=exc_info)
else:
level_method(msg, *args, exc_info=exc_info)
# Создание форматтера
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s')
# [END_ENTITY]
# Проверка наличия существующих обработчиков
if not logger.handlers:
# Создание StreamHandler для вывода в sys.stdout
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
logger.addHandler(handler)
# --------------------------------------------------------------
# [ENTITY: Method('info')]
# --------------------------------------------------------------
"""
:purpose: Записать сообщение уровня INFO.
"""
def info(
self,
msg: str,
*args: Any,
extra: Optional[Mapping[str, Any]] = None,
exc_info: bool = False,
) -> None:
self._log(self.logger.info, msg, *args, extra=extra, exc_info=exc_info)
# [END_ENTITY]
return logger
# --------------------------------------------------------------
# [ENTITY: Method('debug')]
# --------------------------------------------------------------
"""
:purpose: Записать сообщение уровня DEBUG.
"""
def debug(
self,
msg: str,
*args: Any,
extra: Optional[Mapping[str, Any]] = None,
exc_info: bool = False,
) -> None:
self._log(self.logger.debug, msg, *args, extra=extra, exc_info=exc_info)
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('warning')]
# --------------------------------------------------------------
"""
:purpose: Записать сообщение уровня WARNING.
"""
def warning(
self,
msg: str,
*args: Any,
extra: Optional[Mapping[str, Any]] = None,
exc_info: bool = False,
) -> None:
self._log(self.logger.warning, msg, *args, extra=extra, exc_info=exc_info)
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('error')]
# --------------------------------------------------------------
"""
:purpose: Записать сообщение уровня ERROR.
"""
def error(
self,
msg: str,
*args: Any,
extra: Optional[Mapping[str, Any]] = None,
exc_info: bool = False,
) -> None:
self._log(self.logger.error, msg, *args, extra=extra, exc_info=exc_info)
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('critical')]
# --------------------------------------------------------------
"""
:purpose: Записать сообщение уровня CRITICAL.
"""
def critical(
self,
msg: str,
*args: Any,
extra: Optional[Mapping[str, Any]] = None,
exc_info: bool = False,
) -> None:
self._log(self.logger.critical, msg, *args, extra=extra, exc_info=exc_info)
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('exception')]
# --------------------------------------------------------------
"""
:purpose: Записать сообщение уровня ERROR вместе с трассировкой
текущего исключения (аналог ``logger.exception``).
"""
def exception(self, msg: str, *args: Any, **kwargs: Any) -> None:
self.logger.exception(msg, *args, **kwargs)
# [END_ENTITY]
# --------------------------------------------------------------
# [END_FILE logger.py]
# --------------------------------------------------------------

View File

@@ -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(f"[INFO][APIClient.authenticate][SUCCESS] Authenticated successfully. Tokens {self._tokens}")
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,96 @@ 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)
timeout = kwargs.pop('timeout', self.request_settings.get("timeout", DEFAULT_TIMEOUT))
try:
response = self.session.request(
method,
full_url,
headers=_headers,
timeout=timeout,
**kwargs
)
response.raise_for_status()
self.logger.debug(f"[DEBUG][APIClient.request][SUCCESS] Request successful for {method} {endpoint}")
return response if raw_response else response.json()
except requests.exceptions.HTTPError as e:
self.logger.error(f"[ERROR][APIClient.request][FAILURE] HTTP error for {method} {endpoint}: {e}")
self._handle_http_error(e, endpoint, context={})
except requests.exceptions.RequestException as e:
self.logger.error(f"[ERROR][APIClient.request][FAILURE] Network error for {method} {endpoint}: {e}")
self._handle_network_error(e, full_url)
retries_left = 1 # Одна попытка на обновление токена
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()
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
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 +225,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)}")
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

View File

@@ -0,0 +1,148 @@
# [MODULE_PATH] superset_tool.utils.whiptail_fallback
# [FILE] whiptail_fallback.py
# [SEMANTICS] ui, fallback, console, utils, noninteractive
# --------------------------------------------------------------
# [IMPORTS]
# --------------------------------------------------------------
import sys
from typing import List, Tuple, Optional, Any
# [END_IMPORTS]
# --------------------------------------------------------------
# [ENTITY: Service('ConsoleUI')]
# --------------------------------------------------------------
"""
:purpose: Плотный консольный UIfallback для всех функций,
которые в оригинальном проекте использовали ``whiptail``.
Всё взаимодействие теперь **не‑интерактивно**: функции,
выводящие сообщение, просто печатают его без ожидания
``Enter``.
"""
def menu(
title: str,
prompt: str,
choices: List[str],
backtitle: str = "Superset Migration Tool",
) -> Tuple[int, Optional[str]]:
"""Return (rc, selected item). rc == 0 → OK."""
print(f"\n=== {title} ===")
print(prompt)
for idx, item in enumerate(choices, 1):
print(f"{idx}) {item}")
try:
raw = input("\nВведите номер (0 отмена): ").strip()
sel = int(raw)
if sel == 0:
return 1, None
return 0, choices[sel - 1]
except Exception:
return 1, None
def checklist(
title: str,
prompt: str,
options: List[Tuple[str, str]],
backtitle: str = "Superset Migration Tool",
) -> Tuple[int, List[str]]:
"""Return (rc, list of selected **values**)."""
print(f"\n=== {title} ===")
print(prompt)
for idx, (val, label) in enumerate(options, 1):
print(f"{idx}) [{val}] {label}")
raw = input("\nВведите номера через запятую (пустой ввод → отказ): ").strip()
if not raw:
return 1, []
try:
indices = {int(x) for x in raw.split(",") if x.strip()}
selected = [options[i - 1][0] for i in indices if 0 < i <= len(options)]
return 0, selected
except Exception:
return 1, []
def yesno(
title: str,
question: str,
backtitle: str = "Superset Migration Tool",
) -> bool:
"""True → пользователь ответил «да». """
ans = input(f"\n=== {title} ===\n{question} (y/n): ").strip().lower()
return ans in ("y", "yes", "да", "д")
def msgbox(
title: str,
msg: str,
width: int = 60,
height: int = 15,
backtitle: str = "Superset Migration Tool",
) -> None:
"""Простой вывод сообщения без ожидания Enter."""
print(f"\n=== {title} ===\n{msg}\n")
# **Убрано:** input("Нажмите <Enter> для продолжения...")
def inputbox(
title: str,
prompt: str,
backtitle: str = "Superset Migration Tool",
) -> Tuple[int, Optional[str]]:
"""Return (rc, введённая строка). rc == 0 → успешно."""
print(f"\n=== {title} ===")
val = input(f"{prompt}\n")
if val == "":
return 1, None
return 0, val
# --------------------------------------------------------------
# [ENTITY: Service('ConsoleGauge')]
# --------------------------------------------------------------
"""
:purpose: Минимальная имитация ``whiptail``gauge в консоли.
"""
class _ConsoleGauge:
"""Контекст‑менеджер для простого прогресс‑бара."""
def __init__(self, title: str, width: int = 60, height: int = 10):
self.title = title
self.width = width
self.height = height
self._percent = 0
def __enter__(self):
print(f"\n=== {self.title} ===")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
sys.stdout.write("\n")
sys.stdout.flush()
def set_text(self, txt: str) -> None:
sys.stdout.write(f"\r{txt} ")
sys.stdout.flush()
def set_percent(self, percent: int) -> None:
self._percent = percent
sys.stdout.write(f"{percent}%")
sys.stdout.flush()
# [END_ENTITY]
def gauge(
title: str,
width: int = 60,
height: int = 10,
) -> Any:
"""Always returns the console fallback gauge."""
return _ConsoleGauge(title, width, height)
# [END_ENTITY]
# --------------------------------------------------------------
# [END_FILE whiptail_fallback.py]
# --------------------------------------------------------------

View File

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

28200
tech_spec/openapi.json Normal file

File diff suppressed because it is too large Load Diff