Merge branch 'migration' into 'master'
Migration See merge request dwh_bi/superset-tools!3
This commit is contained in:
195
.github/instructions/fractal_promt.instructions.md
vendored
195
.github/instructions/fractal_promt.instructions.md
vendored
@@ -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
5
.gitignore
vendored
@@ -2,5 +2,6 @@
|
||||
*.ps1
|
||||
keyring passwords.py
|
||||
*logs*
|
||||
*\.github*
|
||||
|
||||
*github*
|
||||
*venv*
|
||||
*git*
|
||||
|
||||
18
.pylintrc
Normal file
18
.pylintrc
Normal 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
265
GEMINI.md
Normal 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>
|
||||
|
||||
<МЕТАПОЗНАНИЕ>
|
||||
<ДИРЕКТИВА>Если ты обнаружишь, что данный системный промпт недостаточен или неоднозначен для выполнения задачи, ты должен отметить это в `<ПЛАНИРОВАНИЕ>` и можешь предложить улучшения в свои собственные инструкции для будущих сессий.</ДИРЕКТИВА>
|
||||
</МЕТАПОЗНАНИЕ>
|
||||
|
||||
</СИСТЕМНЫЙ_ПРОМПТ>
|
||||
318
backup_script.py
318
backup_script.py
@@ -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,
|
||||
# [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,
|
||||
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
|
||||
}
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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 consolidate:
|
||||
# [ANCHOR] Объединяем архивы по SLUG в одну папку с максимальной датой
|
||||
try:
|
||||
if config.consolidate:
|
||||
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 config.clean_folders:
|
||||
remove_empty_directories(str(backup_root / env_name), logger=logger)
|
||||
|
||||
if error_details:
|
||||
logger.error(
|
||||
f"[COHERENCE_CHECK_FAILED] Итоги экспорта для {env_name}:",
|
||||
extra={'success_count': success_count, 'errors': error_details, 'total_dashboards': dashboard_count}
|
||||
)
|
||||
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
|
||||
else:
|
||||
logger.info(
|
||||
f"[COHERENCE_CHECK_PASSED] Все {success_count} дашбордов для {env_name} успешно экспортированы."
|
||||
)
|
||||
return True
|
||||
# END_FUNCTION_backup_dashboards
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(
|
||||
f"[CRITICAL] Фатальная ошибка бэкапа для окружения {env_name}: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
# [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",
|
||||
results = {}
|
||||
environments = ['dev', 'sbx', 'prod', 'preprod']
|
||||
backup_config = BackupConfig(rotate_archive=True)
|
||||
|
||||
for env in environments:
|
||||
try:
|
||||
results[env] = backup_dashboards(
|
||||
clients[env],
|
||||
env.upper(),
|
||||
superset_backup_repo,
|
||||
rotate_archive=True,
|
||||
logger=logger
|
||||
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_SBX_ENVIRONMENT
|
||||
sbx_success = backup_dashboards(
|
||||
clients['sbx'],
|
||||
"SBX",
|
||||
superset_backup_repo,
|
||||
rotate_archive=True,
|
||||
logger=logger
|
||||
)
|
||||
|
||||
# [ANCHOR] BACKUP_PROD_ENVIRONMENT
|
||||
prod_success = backup_dashboards(
|
||||
clients['prod'],
|
||||
"PROD",
|
||||
superset_backup_repo,
|
||||
rotate_archive=True,
|
||||
logger=logger
|
||||
)
|
||||
|
||||
# [ANCHOR] BACKUP_PROD_ENVIRONMENT
|
||||
preprod_success = backup_dashboards(
|
||||
clients['preprod'],
|
||||
"PREPROD",
|
||||
superset_backup_repo,
|
||||
rotate_archive=True,
|
||||
logger=logger
|
||||
)
|
||||
|
||||
# [ANCHOR] FINAL_REPORT
|
||||
# [INFO] Итоговый отчет о выполнении бэкапа
|
||||
logger.info("="*50)
|
||||
logger.info("[INFO] Итоги выполнения бэкапа:")
|
||||
logger.info(f"[INFO] DEV: {'Успешно' if dev_success else 'С ошибками'}")
|
||||
logger.info(f"[INFO] SBX: {'Успешно' if sbx_success else 'С ошибками'}")
|
||||
logger.info(f"[INFO] PROD: {'Успешно' if prod_success else 'С ошибками'}")
|
||||
logger.info(f"[INFO] PREPROD: {'Успешно' if preprod_success else 'С ошибками'}")
|
||||
logger.info(f"[INFO] Полный лог доступен в: {log_dir}")
|
||||
|
||||
if not (dev_success and sbx_success and prod_success):
|
||||
exit_code = 1
|
||||
logger.warning("[COHERENCE_CHECK_FAILED] Бэкап завершен с ошибками в одном или нескольких окружениях.")
|
||||
else:
|
||||
logger.info("[COHERENCE_CHECK_PASSED] Все бэкапы успешно завершены без ошибок.")
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"[CRITICAL] Фатальная ошибка выполнения скрипта: {str(e)}", exc_info=True)
|
||||
if not all(results.values()):
|
||||
exit_code = 1
|
||||
|
||||
logger.info("[INFO] Процесс бэкапа завершен")
|
||||
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("[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())
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
)
|
||||
|
||||
from superset_tool.utils.logger import SupersetLogger # type: ignore
|
||||
# [END_IMPORTS]
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Service('Migration')]
|
||||
# [RELATION: Service('Migration')] -> [DEPENDS_ON] -> [PythonModule('superset_tool.client')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Интерактивный процесс миграции дашбордов с возможностью
|
||||
«удалить‑и‑перезаписать» при ошибке импорта.
|
||||
:preconditions:
|
||||
- Конфигурация Superset‑клиентов доступна,
|
||||
- Пользователь может взаимодействовать через консольный UI.
|
||||
:postconditions:
|
||||
- Выбранные дашборды импортированы в целевое окружение.
|
||||
:sideeffect: Записывает журнал в каталог ``logs/`` текущего рабочего каталога.
|
||||
"""
|
||||
|
||||
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
|
||||
console=True,
|
||||
)
|
||||
logger.info("[COHERENCE_CHECK_PASSED] Логгер инициализирован для скрипта миграции.")
|
||||
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]
|
||||
|
||||
# [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"
|
||||
# --------------------------------------------------------------
|
||||
# [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]
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [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] Delete‑on‑failure = %s",
|
||||
self.enable_delete_on_failure,
|
||||
)
|
||||
# [END_ENTITY]
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [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
|
||||
|
||||
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)
|
||||
|
||||
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]
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [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
|
||||
|
||||
options = [("ALL", "Все дашборды")] + [
|
||||
(str(d["id"]), d["dashboard_title"]) for d in all_dashboards
|
||||
]
|
||||
|
||||
rc, selected = checklist(
|
||||
title="Выбор дашбордов",
|
||||
prompt="Отметьте нужные дашборды (введите номера):",
|
||||
options=options,
|
||||
)
|
||||
if rc != 0:
|
||||
return
|
||||
|
||||
if "ALL" in selected:
|
||||
self.dashboards_to_migrate = list(all_dashboards)
|
||||
self.logger.info(
|
||||
"[INFO][select_dashboards] Выбраны все дашборды (%d).",
|
||||
len(self.dashboards_to_migrate),
|
||||
)
|
||||
return
|
||||
|
||||
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]
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [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},
|
||||
}
|
||||
}
|
||||
logger.debug("[CONFIG] Конфигурация Clickhouse загружена.")
|
||||
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]
|
||||
|
||||
# [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: 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
|
||||
|
||||
# [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
|
||||
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
|
||||
|
||||
# [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):
|
||||
total = len(self.dashboards_to_migrate)
|
||||
self.logger.info("[INFO][execute_migration] Starting migration of %d dashboards.", total)
|
||||
|
||||
logger.info(f"[INFO] Конфигурация миграции: From '{from_c.config.base_url}' To '{to_c.config.base_url}' for dashboard slug '{dashboard_slug}'")
|
||||
# Передаём режим клиенту‑назначению
|
||||
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)
|
||||
|
||||
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}.")
|
||||
# ------------------- Экспорт -------------------
|
||||
exported_content, _ = self.from_c.export_dashboard(dash_id) # type: ignore[attr-defined]
|
||||
|
||||
# [CONTEXT_MANAGER] Работа с временной директорией для обработки архива дашборда
|
||||
with create_temp_file(suffix='.dir', logger=logger) as temp_root:
|
||||
logger.info(f"[INFO] Создана временная директория: {temp_root}")
|
||||
# ------------------- Временный 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)
|
||||
|
||||
# [ANCHOR] EXPORT_DASHBOARD
|
||||
# Экспорт дашборда во временную директорию ИЛИ чтение с диска
|
||||
# [COHERENCE_NOTE] В текущем коде закомментирован экспорт и используется локальный файл.
|
||||
# Для полноценной миграции следует использовать export_dashboard().
|
||||
zip_content, filename = from_c.export_dashboard(dashboard_id) # Предпочтительный путь для реальной миграции
|
||||
# ------------------- Распаковка во временный каталог -------------------
|
||||
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)
|
||||
|
||||
# [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)
|
||||
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)
|
||||
|
||||
# [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
|
||||
# ------------------- YAML‑обновление (если нужно) -------------------
|
||||
if self.db_config_replacement:
|
||||
update_yamls(
|
||||
db_configs=[self.db_config_replacement],
|
||||
path=str(tmp_unpack_dir),
|
||||
)
|
||||
logger.info(f"[INFO] Дашборд распакован во временную директорию: {unpacked_path}")
|
||||
self.logger.info("[INFO][execute_migration] YAML‑files updated.")
|
||||
|
||||
# [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-файлы успешно обновлены.")
|
||||
# ------------------- Сборка нового 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] Re‑packed to %s", tmp_new_zip)
|
||||
|
||||
# [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-архив дашборда готов к импорту.")
|
||||
# ------------------- Импорт -------------------
|
||||
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:
|
||||
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.logger.warning(
|
||||
"[WARN][execute_migration] Unable to map slug '%s' to ID on target.",
|
||||
slug,
|
||||
)
|
||||
|
||||
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)
|
||||
# ------------------- Batch‑удаление -------------------
|
||||
self._batch_delete_by_ids(ids_to_delete)
|
||||
|
||||
logger.info("[INFO] Процесс миграции завершен.")
|
||||
# ------------------- Повторный импорт только для проблемных дашбордов -------------------
|
||||
for fail in self._failed_imports:
|
||||
dash_slug = fail["slug"]
|
||||
dash_id = fail["dash_id"]
|
||||
zip_content = fail["zip_content"]
|
||||
|
||||
# [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} дашбордов для миграции.")
|
||||
# Один раз создаём временный 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)
|
||||
|
||||
# [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")
|
||||
# Пере‑импортируем – **slug** передаётся, но клиент будет использовать ID
|
||||
self.to_c.import_dashboard(
|
||||
file_name=retry_zip_path,
|
||||
dash_id=dash_id,
|
||||
dash_slug=dash_slug,
|
||||
) # type: ignore[attr-defined]
|
||||
|
||||
logger.info(f"[INFO] Миграция всех дашбордов с '{from_c.config.base_url}' на '{to_c.config.base_url}' завершена.")
|
||||
self.logger.info("[INFO][execute_migration][RECOVERED] Dashboard slug '%s' re‑imported.", dash_slug)
|
||||
|
||||
# [ACTION] Вызов функции миграции
|
||||
migrate_all_dashboards(from_c, to_c)
|
||||
# -----------------------------------------------------------------
|
||||
# 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
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
pyyaml
|
||||
requests
|
||||
keyring
|
||||
urllib3
|
||||
pydantic
|
||||
whiptail-dialogs
|
||||
217
search_script.py
217
search_script.py
@@ -1,223 +1,152 @@
|
||||
# [MODULE] Dataset Search Utilities
|
||||
# @contract: Функционал для поиска строк в датасетах Superset
|
||||
# @semantic_layers:
|
||||
# 1. Получение списка датасетов через Superset API
|
||||
# 2. Реализация поисковой логики
|
||||
# 3. Форматирование результатов поиска
|
||||
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument,invalid-name,redefined-outer-name
|
||||
"""
|
||||
[MODULE] Dataset Search Utilities
|
||||
@contract: Предоставляет функционал для поиска текстовых паттернов в метаданных датасетов Superset.
|
||||
"""
|
||||
|
||||
# [IMPORTS] Стандартная библиотека
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, Optional
|
||||
|
||||
# [IMPORTS] Third-party
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
# [IMPORTS] Локальные модули
|
||||
from superset_tool.client import SupersetClient
|
||||
from superset_tool.models import SupersetConfig
|
||||
from superset_tool.exceptions import SupersetAPIError
|
||||
from superset_tool.utils.logger import SupersetLogger
|
||||
from superset_tool.utils.init_clients import setup_clients
|
||||
|
||||
# [IMPORTS] Сторонние библиотеки
|
||||
import keyring
|
||||
|
||||
# [TYPE-ALIASES]
|
||||
SearchResult = Dict[str, List[Dict[str, str]]]
|
||||
SearchPattern = str
|
||||
|
||||
# [ENTITY: Function('search_datasets')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Выполняет поиск по строковому паттерну в метаданных всех датасетов.
|
||||
# PRECONDITIONS:
|
||||
# - `client` должен быть инициализированным экземпляром `SupersetClient`.
|
||||
# - `search_pattern` должен быть валидной строкой регулярного выражения.
|
||||
# POSTCONDITIONS:
|
||||
# - Возвращает словарь с результатами поиска.
|
||||
def search_datasets(
|
||||
client: SupersetClient,
|
||||
search_pattern: str,
|
||||
search_fields: List[str] = None,
|
||||
logger: Optional[SupersetLogger] = None
|
||||
) -> Dict:
|
||||
# [FUNCTION] search_datasets
|
||||
"""[CONTRACT] Поиск строк в метаданных датасетов
|
||||
@pre:
|
||||
- `client` должен быть инициализированным SupersetClient
|
||||
- `search_pattern` должен быть валидным regex-шаблоном
|
||||
@post:
|
||||
- Возвращает словарь с результатами поиска в формате:
|
||||
{"dataset_id": [{"field": "table_name", "match": "found_string", "value": "full_field_value"}, ...]}.
|
||||
@raise:
|
||||
- `re.error`: при невалидном regex-шаблоне
|
||||
- `SupersetAPIError`: при ошибках API
|
||||
- `AuthenticationError`: при ошибках аутентификации
|
||||
- `NetworkError`: при сетевых ошибках
|
||||
@side_effects:
|
||||
- Выполняет запросы к Superset API через client.get_datasets().
|
||||
- Логирует процесс поиска и ошибки.
|
||||
"""
|
||||
) -> Optional[Dict]:
|
||||
logger = logger or SupersetLogger(name="dataset_search")
|
||||
|
||||
logger.info(f"[STATE][search_datasets][ENTER] Searching for pattern: '{search_pattern}'")
|
||||
try:
|
||||
# Явно запрашиваем все возможные поля
|
||||
total_count, datasets = client.get_datasets(query={
|
||||
_, datasets = client.get_datasets(query={
|
||||
"columns": ["id", "table_name", "sql", "database", "columns"]
|
||||
})
|
||||
|
||||
if not datasets:
|
||||
logger.warning("[SEARCH] Получено 0 датасетов")
|
||||
logger.warning("[STATE][search_datasets][EMPTY] No datasets found.")
|
||||
return None
|
||||
|
||||
# Определяем какие поля реально существуют
|
||||
available_fields = set(datasets[0].keys())
|
||||
logger.debug(f"[SEARCH] Фактические поля: {available_fields}")
|
||||
|
||||
pattern = re.compile(search_pattern, re.IGNORECASE)
|
||||
results = {}
|
||||
available_fields = set(datasets[0].keys())
|
||||
|
||||
for dataset in datasets:
|
||||
dataset_id = dataset['id']
|
||||
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" - Поле: {field}")
|
||||
output.append(f" Совпадение: '{match_text}'")
|
||||
|
||||
# Находим позицию совпадения в полном тексте
|
||||
match_start_index = full_value.find(match_text)
|
||||
if match_start_index == -1:
|
||||
# Этого не должно произойти, если search_datasets работает правильно, но для надежности
|
||||
output.append(" Не удалось найти совпадение в полном тексте.")
|
||||
lines = full_value.splitlines()
|
||||
if not lines:
|
||||
continue
|
||||
|
||||
# Разбиваем текст на строки
|
||||
lines = full_value.splitlines()
|
||||
# Находим номер строки, где находится совпадение
|
||||
current_index = 0
|
||||
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
|
||||
|
||||
# Определяем диапазон строк для вывода контекста
|
||||
if match_line_index != -1:
|
||||
start_line = max(0, match_line_index - context_lines)
|
||||
end_line = min(len(lines) - 1, 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 + 1):
|
||||
for i in range(start_line, end_line):
|
||||
line_number = i + 1
|
||||
line_content = lines[i]
|
||||
prefix = f"{line_number:4d}: "
|
||||
# Попытка выделить совпадение в центральной строке
|
||||
prefix = f"{line_number:5d}: "
|
||||
if i == match_line_index:
|
||||
# Простая замена, может быть не идеальна для regex совпадений
|
||||
highlighted_line = line_content.replace(match_text, f">>>{match_text}<<<")
|
||||
output.append(f" {prefix}{highlighted_line}")
|
||||
else:
|
||||
output.append(f" {prefix}{line_content}")
|
||||
output.append("-" * 20) # Разделитель между совпадениями
|
||||
|
||||
output.append("-" * 25)
|
||||
return "\n".join(output)
|
||||
# END_FUNCTION_print_search_results
|
||||
|
||||
def inspect_datasets(client: SupersetClient):
|
||||
# [FUNCTION] inspect_datasets
|
||||
# [CONTRACT]
|
||||
"""
|
||||
Функция для проверки реальной структуры датасетов.
|
||||
Предназначена в основном для отладки и исследования структуры данных.
|
||||
|
||||
@pre:
|
||||
- `client` является инициализированным экземпляром SupersetClient.
|
||||
@post:
|
||||
- Выводит информацию о количестве датасетов и структуре первого датасета в консоль.
|
||||
- Функция не изменяет состояние клиента.
|
||||
@side_effects:
|
||||
- Вызовы к Superset API через `client.get_datasets()`.
|
||||
- Вывод в консоль.
|
||||
- Логирует процесс инспекции и ошибки.
|
||||
@raise:
|
||||
- `SupersetAPIError`: при ошибках API
|
||||
- `AuthenticationError`: при ошибках аутентификации
|
||||
- `NetworkError`: при сетевых ошибках
|
||||
"""
|
||||
total, datasets = client.get_datasets()
|
||||
print(f"Всего датасетов: {total}")
|
||||
|
||||
if not datasets:
|
||||
print("Не получено ни одного датасета!")
|
||||
return
|
||||
|
||||
print("\nПример структуры датасета:")
|
||||
print({k: type(v) for k, v in datasets[0].items()})
|
||||
|
||||
if 'sql' not in datasets[0]:
|
||||
print("\nПоле 'sql' отсутствует. Доступные поля:")
|
||||
print(list(datasets[0].keys()))
|
||||
|
||||
# [EXAMPLE] Пример использования
|
||||
|
||||
|
||||
# [ENTITY: Function('main')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Основная точка входа скрипта.
|
||||
# PRECONDITIONS: None
|
||||
# POSTCONDITIONS: None
|
||||
def main():
|
||||
logger = SupersetLogger(level=logging.INFO, console=True)
|
||||
clients = setup_clients(logger)
|
||||
|
||||
# Поиск всех таблиц в датасете
|
||||
target_client = clients['dev']
|
||||
search_query = r"match(r2.path_code, budget_reference.ref_code || '($|(\s))')"
|
||||
|
||||
results = search_datasets(
|
||||
client=clients['dev'],
|
||||
search_pattern=r'dm_view\.account_debt',
|
||||
search_fields=["sql"],
|
||||
client=target_client,
|
||||
search_pattern=search_query,
|
||||
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}")
|
||||
report = print_search_results(results)
|
||||
logger.info(f"[STATE][main][SUCCESS] Search finished. Report:\n{report}")
|
||||
# END_FUNCTION_main
|
||||
|
||||
logger.info(f"[RESULT] {print_search_results(results)}")
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
0
superset_tool/__init__.py
Normal file
0
superset_tool/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,8 @@
|
||||
# [MODULE] Иерархия исключений
|
||||
# @contract: Все ошибки наследуют `SupersetToolError` для единой точки обработки.
|
||||
# @semantic: Каждый тип исключения соответствует конкретной проблемной области в инструменте Superset.
|
||||
# @coherence:
|
||||
# - Полное покрытие всех сценариев ошибок клиента и утилит.
|
||||
# - Четкая классификация по уровню серьезности (от общей до специфичной).
|
||||
# - Дополнительный `context` для каждой ошибки, помогающий в диагностике.
|
||||
# pylint: disable=too-many-ancestors
|
||||
"""
|
||||
[MODULE] Иерархия исключений
|
||||
@contract: Все ошибки наследуют `SupersetToolError` для единой точки обработки.
|
||||
"""
|
||||
|
||||
# [IMPORTS] Standard library
|
||||
from pathlib import Path
|
||||
@@ -13,141 +11,114 @@ from pathlib import Path
|
||||
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] Ошибка в конфигурации инструмента."""
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
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.")
|
||||
|
||||
config = SupersetConfig(
|
||||
env=env_name,
|
||||
base_url=base_url,
|
||||
auth={
|
||||
"provider": "db",
|
||||
"username": "migrate_user",
|
||||
"password": keyring.get_password("system", "dev migrate"),
|
||||
"password": password,
|
||||
"refresh": True
|
||||
},
|
||||
verify_ssl=False
|
||||
)
|
||||
# [DEBUG] Dev config created: {dev_config.base_url}
|
||||
|
||||
# [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}
|
||||
clients[env_name] = SupersetClient(config, logger)
|
||||
logger.debug(f"[DEBUG][CLIENT_SUCCESS] Клиент для {env_name.upper()} успешно создан.")
|
||||
|
||||
# [INFO] Инициализация конфигурации для Sandbox
|
||||
sandbox_config = SupersetConfig(
|
||||
base_url="https://sandboxta.bi.dwh.rusal.com/api/v1",
|
||||
auth={
|
||||
"provider": "db",
|
||||
"username": "migrate_user",
|
||||
"password": keyring.get_password("system", "sandbox migrate"),
|
||||
"refresh": True
|
||||
},
|
||||
verify_ssl=False
|
||||
)
|
||||
# [DEBUG] Sandbox config created: {sandbox_config.base_url}
|
||||
|
||||
# [INFO] Инициализация конфигурации для Preprod
|
||||
preprod_config = SupersetConfig(
|
||||
base_url="https://preprodta.bi.dwh.rusal.com/api/v1",
|
||||
auth={
|
||||
"provider": "db",
|
||||
"username": "migrate_user",
|
||||
"password": keyring.get_password("system", "preprod migrate"),
|
||||
"refresh": True
|
||||
},
|
||||
verify_ssl=False
|
||||
)
|
||||
# [DEBUG] Sandbox config created: {sandbox_config.base_url}
|
||||
|
||||
# [INFO] Создание экземпляров SupersetClient
|
||||
clients['dev'] = SupersetClient(dev_config, logger)
|
||||
clients['sbx'] = SupersetClient(sandbox_config,logger)
|
||||
clients['prod'] = SupersetClient(prod_config,logger)
|
||||
clients['preprod'] = SupersetClient(preprod_config,logger)
|
||||
logger.info("[COHERENCE_CHECK_PASSED] Клиенты для окружений успешно инициализированы", extra={"envs": list(clients.keys())})
|
||||
logger.info(f"[COHERENCE_CHECK_PASSED][INIT_CLIENTS_SUCCESS] Все клиенты ({', '.join(clients.keys())}) успешно инициализированы.")
|
||||
return clients
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] Ошибка инициализации клиентов: {str(e)}", exc_info=True)
|
||||
logger.error(f"[CRITICAL][INIT_CLIENTS_FAILED] Ошибка при инициализации клиентов: {str(e)}", exc_info=True)
|
||||
raise
|
||||
# END_FUNCTION_setup_clients
|
||||
# END_MODULE_init_clients
|
||||
@@ -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, ai‑friendly, 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]
|
||||
# --------------------------------------------------------------
|
||||
@@ -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
|
||||
@@ -22,170 +18,103 @@ import requests
|
||||
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)
|
||||
|
||||
retries_left = 1 # Одна попытка на обновление токена
|
||||
while retries_left >= 0:
|
||||
timeout = kwargs.pop('timeout', self.request_settings.get("timeout", DEFAULT_TIMEOUT))
|
||||
try:
|
||||
response = self.session.request(
|
||||
method,
|
||||
full_url,
|
||||
headers=_headers,
|
||||
#timeout=self.timeout,
|
||||
timeout=timeout,
|
||||
**kwargs
|
||||
)
|
||||
response.raise_for_status() # Проверяем статус сразу
|
||||
self.logger.debug(f"[COHERENCE_CHECK_PASSED] Запрос {method} {endpoint} успешно выполнен.")
|
||||
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:
|
||||
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
|
||||
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.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
|
||||
self.logger.error(f"[ERROR][APIClient.request][FAILURE] Network error for {method} {endpoint}: {e}")
|
||||
self._handle_network_error(e, full_url)
|
||||
|
||||
# [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:
|
||||
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.timeout
|
||||
timeout=timeout or self.request_settings.get("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}.")
|
||||
count = response_json.get(count_field, 0)
|
||||
self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_count][SUCCESS] Fetched paginated count: {count}")
|
||||
return count
|
||||
|
||||
except (KeyError, SupersetAPIError, NetworkError, PermissionDeniedError, DashboardNotFoundError) as e:
|
||||
self.logger.error(f"[ERROR] Ошибка получения количества элементов для {endpoint}: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
|
||||
raise
|
||||
except Exception as e:
|
||||
error_ctx = {"endpoint": endpoint, "params": query_params, "error_type": type(e).__name__}
|
||||
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении количества: {str(e)}", exc_info=True, extra=error_ctx)
|
||||
raise SupersetAPIError(f"Непредвиденная ошибка при получении count для {endpoint}: {str(e)}", context=error_ctx) from e
|
||||
|
||||
def fetch_paginated_data(
|
||||
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
|
||||
timeout=timeout or self.request_settings.get("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)}")
|
||||
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
|
||||
148
superset_tool/utils/whiptail_fallback.py
Normal file
148
superset_tool/utils/whiptail_fallback.py
Normal file
@@ -0,0 +1,148 @@
|
||||
# [MODULE_PATH] superset_tool.utils.whiptail_fallback
|
||||
# [FILE] whiptail_fallback.py
|
||||
# [SEMANTICS] ui, fallback, console, utils, non‑interactive
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [IMPORTS]
|
||||
# --------------------------------------------------------------
|
||||
import sys
|
||||
from typing import List, Tuple, Optional, Any
|
||||
# [END_IMPORTS]
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Service('ConsoleUI')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Плотный консольный UI‑fallback для всех функций,
|
||||
которые в оригинальном проекте использовали ``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]
|
||||
# --------------------------------------------------------------
|
||||
119
tech_spec/PROJECT_SEMANTICS.xml
Normal file
119
tech_spec/PROJECT_SEMANTICS.xml
Normal 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
28200
tech_spec/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user