Merge branch 'migration'
This commit is contained in:
265
GEMINI.md
265
GEMINI.md
@@ -1,265 +0,0 @@
|
||||
<СИСТЕМНЫЙ_ПРОМПТ>
|
||||
|
||||
<ОПРЕДЕЛЕНИЕ_РОЛИ>
|
||||
<РОЛЬ>ИИ-Ассистент: "Архитектор Семантики"</РОЛЬ>
|
||||
<ЭКСПЕРТИЗА>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>
|
||||
|
||||
<МЕТАПОЗНАНИЕ>
|
||||
<ДИРЕКТИВА>Если ты обнаружишь, что данный системный промпт недостаточен или неоднозначен для выполнения задачи, ты должен отметить это в `<ПЛАНИРОВАНИЕ>` и можешь предложить улучшения в свои собственные инструкции для будущих сессий.</ДИРЕКТИВА>
|
||||
</МЕТАПОЗНАНИЕ>
|
||||
|
||||
</СИСТЕМНЫЙ_ПРОМПТ>
|
||||
42
README.md
42
README.md
@@ -1,3 +1,5 @@
|
||||
Вот обновлённый README с информацией о работе со скриптами:
|
||||
|
||||
# Инструменты автоматизации Superset
|
||||
|
||||
## Обзор
|
||||
@@ -9,6 +11,7 @@
|
||||
- `backup_script.py`: Основной скрипт для выполнения запланированного резервного копирования дашбордов Superset.
|
||||
- `migration_script.py`: Основной скрипт для переноса конкретных дашбордов между окружениями, включая переопределение соединений с базами данных.
|
||||
- `search_script.py`: Скрипт для поиска данных во всех доступных датасетах на сервере
|
||||
- `run_mapper.py`: CLI-скрипт для маппинга метаданных датасетов.
|
||||
- `superset_tool/`:
|
||||
- `client.py`: Python-клиент для взаимодействия с API Superset.
|
||||
- `exceptions.py`: Пользовательские классы исключений для структурированной обработки ошибок.
|
||||
@@ -17,6 +20,8 @@
|
||||
- `fileio.py`: Утилиты для работы с файловой системой (работа с архивами, парсинг YAML).
|
||||
- `logger.py`: Конфигурация логгера для единообразного логирования в проекте.
|
||||
- `network.py`: HTTP-клиент для сетевых запросов с обработкой аутентификации и повторных попыток.
|
||||
- `init_clients.py`: Утилита для инициализации клиентов Superset для разных окружений.
|
||||
- `dataset_mapper.py`: Логика маппинга метаданных датасетов.
|
||||
|
||||
## Настройка
|
||||
|
||||
@@ -66,17 +71,34 @@ python migration_script.py
|
||||
`from_c` и `to_c`.
|
||||
|
||||
### Скрипт поиска (`search_script.py`)
|
||||
Строка для поиска и клиенты для поиска задаются здесь
|
||||
# Поиск всех таблиц в датасете
|
||||
```python
|
||||
results = search_datasets(
|
||||
client=clients['dev'],
|
||||
search_pattern=r'dm_view\.account_debt',
|
||||
search_fields=["sql"],
|
||||
logger=logger
|
||||
)
|
||||
Для поиска по текстовым паттернам в метаданных датасетов Superset:
|
||||
```bash
|
||||
python search_script.py
|
||||
```
|
||||
Скрипт использует регулярные выражения для поиска в полях датасетов, таких как SQL-запросы. Результаты поиска выводятся в лог и в консоль.
|
||||
|
||||
### Скрипт маппинга метаданных (`run_mapper.py`)
|
||||
Для обновления метаданных датасета (например, verbose names) в Superset:
|
||||
```bash
|
||||
python run_mapper.py --source <source_type> --dataset-id <dataset_id> [--table-name <table_name>] [--table-schema <table_schema>] [--excel-path <path_to_excel>] [--env <environment>]
|
||||
```
|
||||
Если вы используете XLSX - файл должен содержать два столбца - column_name | verbose_name
|
||||
|
||||
|
||||
Параметры:
|
||||
- `--source`: Источник данных ('postgres', 'excel' или 'both').
|
||||
- `--dataset-id`: ID датасета для обновления.
|
||||
- `--table-name`: Имя таблицы для PostgreSQL.
|
||||
- `--table-schema`: Схема таблицы для PostgreSQL.
|
||||
- `--excel-path`: Путь к Excel-файлу.
|
||||
- `--env`: Окружение Superset ('dev', 'prod' и т.д.).
|
||||
|
||||
Пример использования:
|
||||
```bash
|
||||
python run_mapper.py --source postgres --dataset-id 123 --table-name account_debt --table-schema dm_view --env dev
|
||||
|
||||
python run_mapper.py --source=excel --dataset-id=286 --excel-path=H:\dev\ss-tools\286_map.xlsx --env=dev
|
||||
```
|
||||
|
||||
## Логирование
|
||||
Логи пишутся в файл в директории `Logs` (например, `P:\Superset\010 Бекапы\Logs` для резервных копий) и выводятся в консоль. Уровень логирования по умолчанию — `INFO`.
|
||||
@@ -90,4 +112,4 @@ results = search_datasets(
|
||||
---
|
||||
[COHERENCE_CHECK_PASSED] README.md создан и согласован с модулями.
|
||||
|
||||
Перевод выполнен с сохранением оригинальной Markdown-разметки и стиля документа. [1]
|
||||
Перевод выполнен с сохранением оригинальной Markdown-разметки и стиля документа.
|
||||
@@ -1,19 +1,15 @@
|
||||
# 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.
|
||||
"""
|
||||
# <GRACE_MODULE id="backup_script" name="backup_script.py">
|
||||
# @SEMANTICS: backup, superset, automation, dashboard
|
||||
# @PURPOSE: Этот модуль отвечает за автоматизированное резервное копирование дашбордов Superset.
|
||||
# @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API.
|
||||
# @DEPENDS_ON: superset_tool.utils -> Использует утилиты для логирования, работы с файлами и инициализации клиентов.
|
||||
|
||||
# [IMPORTS] Стандартная библиотека
|
||||
# <IMPORTS>
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass,field
|
||||
|
||||
# [IMPORTS] Third-party
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
# [IMPORTS] Локальные модули
|
||||
from superset_tool.client import SupersetClient
|
||||
from superset_tool.exceptions import SupersetAPIError
|
||||
from superset_tool.utils.logger import SupersetLogger
|
||||
@@ -26,11 +22,12 @@ from superset_tool.utils.fileio import (
|
||||
RetentionPolicy
|
||||
)
|
||||
from superset_tool.utils.init_clients import setup_clients
|
||||
# </IMPORTS>
|
||||
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# [ENTITY: Dataclass('BackupConfig')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Хранит конфигурацию для процесса бэкапа.
|
||||
# <ANCHOR id="BackupConfig" type="DataClass">
|
||||
# @PURPOSE: Хранит конфигурацию для процесса бэкапа.
|
||||
@dataclass
|
||||
class BackupConfig:
|
||||
"""Конфигурация для процесса бэкапа."""
|
||||
@@ -38,18 +35,26 @@ class BackupConfig:
|
||||
rotate_archive: bool = True
|
||||
clean_folders: bool = True
|
||||
retention_policy: RetentionPolicy = field(default_factory=RetentionPolicy)
|
||||
# </ANCHOR id="BackupConfig">
|
||||
|
||||
# [ENTITY: Function('backup_dashboards')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения, пропуская ошибки экспорта.
|
||||
# PRECONDITIONS:
|
||||
# - `client` должен быть инициализированным экземпляром `SupersetClient`.
|
||||
# - `env_name` должен быть строкой, обозначающей окружение.
|
||||
# - `backup_root` должен быть валидным путем к корневой директории бэкапа.
|
||||
# POSTCONDITIONS:
|
||||
# - Дашборды экспортируются и сохраняются.
|
||||
# - Ошибки экспорта логируются и не приводят к остановке скрипта.
|
||||
# - Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе.
|
||||
# <ANCHOR id="backup_dashboards" type="Function">
|
||||
# @PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения, пропуская ошибки экспорта.
|
||||
# @PRE: `client` должен быть инициализированным экземпляром `SupersetClient`.
|
||||
# @PRE: `env_name` должен быть строкой, обозначающей окружение.
|
||||
# @PRE: `backup_root` должен быть валидным путем к корневой директории бэкапа.
|
||||
# @POST: Дашборды экспортируются и сохраняются. Ошибки экспорта логируются и не приводят к остановке скрипта.
|
||||
# @PARAM: client: SupersetClient - Клиент для доступа к API Superset.
|
||||
# @PARAM: env_name: str - Имя окружения (e.g., 'PROD').
|
||||
# @PARAM: backup_root: Path - Корневая директория для сохранения бэкапов.
|
||||
# @PARAM: logger: SupersetLogger - Инстанс логгера.
|
||||
# @PARAM: config: BackupConfig - Конфигурация процесса бэкапа.
|
||||
# @RETURN: bool - `True` если все дашборды были экспортированы без критических ошибок, `False` иначе.
|
||||
# @RELATION: CALLS -> client.get_dashboards
|
||||
# @RELATION: CALLS -> client.export_dashboard
|
||||
# @RELATION: CALLS -> save_and_unpack_dashboard
|
||||
# @RELATION: CALLS -> archive_exports
|
||||
# @RELATION: CALLS -> consolidate_archive_folders
|
||||
# @RELATION: CALLS -> remove_empty_directories
|
||||
def backup_dashboards(
|
||||
client: SupersetClient,
|
||||
env_name: str,
|
||||
@@ -57,10 +62,10 @@ def backup_dashboards(
|
||||
logger: SupersetLogger,
|
||||
config: BackupConfig
|
||||
) -> bool:
|
||||
logger.info(f"[STATE][backup_dashboards][ENTER] Starting backup for {env_name}.")
|
||||
logger.info(f"[backup_dashboards][Entry] Starting backup for {env_name}.")
|
||||
try:
|
||||
dashboard_count, dashboard_meta = client.get_dashboards()
|
||||
logger.info(f"[STATE][backup_dashboards][PROGRESS] Found {dashboard_count} dashboards to export in {env_name}.")
|
||||
logger.info(f"[backup_dashboards][Progress] Found {dashboard_count} dashboards to export in {env_name}.")
|
||||
if dashboard_count == 0:
|
||||
return True
|
||||
|
||||
@@ -91,8 +96,7 @@ def backup_dashboards(
|
||||
|
||||
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)
|
||||
# Продолжаем обработку других дашбордов
|
||||
logger.error(f"[backup_dashboards][Failure] Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}", exc_info=True)
|
||||
continue
|
||||
|
||||
if config.consolidate:
|
||||
@@ -101,21 +105,22 @@ def backup_dashboards(
|
||||
if config.clean_folders:
|
||||
remove_empty_directories(str(backup_root / env_name), logger=logger)
|
||||
|
||||
logger.info(f"[backup_dashboards][CoherenceCheck:Passed] Backup logic completed.")
|
||||
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)
|
||||
logger.critical(f"[backup_dashboards][Failure] Fatal error during backup for {env_name}: {e}", exc_info=True)
|
||||
return False
|
||||
# END_FUNCTION_backup_dashboards
|
||||
# </ANCHOR id="backup_dashboards">
|
||||
|
||||
# [ENTITY: Function('main')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Основная точка входа скрипта.
|
||||
# PRECONDITIONS: None
|
||||
# POSTCONDITIONS: Возвращает код выхода.
|
||||
# <ANCHOR id="main" type="Function">
|
||||
# @PURPOSE: Основная точка входа для запуска процесса резервного копирования.
|
||||
# @RETURN: int - Код выхода (0 - успех, 1 - ошибка).
|
||||
# @RELATION: CALLS -> setup_clients
|
||||
# @RELATION: CALLS -> backup_dashboards
|
||||
def main() -> int:
|
||||
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("[main][Entry] Starting Superset backup process.")
|
||||
|
||||
exit_code = 0
|
||||
try:
|
||||
@@ -137,20 +142,23 @@ def main() -> int:
|
||||
config=backup_config
|
||||
)
|
||||
except Exception as env_error:
|
||||
logger.critical(f"[STATE][main][FAILURE] Critical error for environment {env}: {env_error}", exc_info=True)
|
||||
# Продолжаем обработку других окружений
|
||||
logger.critical(f"[main][Failure] Critical error for environment {env}: {env_error}", exc_info=True)
|
||||
results[env] = False
|
||||
|
||||
if not all(results.values()):
|
||||
exit_code = 1
|
||||
|
||||
except (RequestException, IOError) as e:
|
||||
logger.critical(f"[STATE][main][FAILURE] Fatal error in main execution: {e}", exc_info=True)
|
||||
logger.critical(f"[main][Failure] Fatal error in main execution: {e}", exc_info=True)
|
||||
exit_code = 1
|
||||
|
||||
logger.info("[STATE][main][SUCCESS] Superset backup process finished.")
|
||||
logger.info("[main][Exit] Superset backup process finished.")
|
||||
return exit_code
|
||||
# END_FUNCTION_main
|
||||
# </ANCHOR id="main">
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE id="backup_script">
|
||||
|
||||
BIN
comment_mapping.xlsx
Normal file
BIN
comment_mapping.xlsx
Normal file
Binary file not shown.
69
get_dataset_structure.py
Normal file
69
get_dataset_structure.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# <GRACE_MODULE id="get_dataset_structure" name="get_dataset_structure.py">
|
||||
# @SEMANTICS: superset, dataset, structure, debug, json
|
||||
# @PURPOSE: Этот модуль предназначен для получения и сохранения структуры данных датасета из Superset. Он используется для отладки и анализа данных, возвращаемых API.
|
||||
# @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API.
|
||||
# @DEPENDS_ON: superset_tool.utils.init_clients -> Для инициализации клиентов Superset.
|
||||
# @DEPENDS_ON: superset_tool.utils.logger -> Для логирования.
|
||||
|
||||
# <IMPORTS>
|
||||
import argparse
|
||||
import json
|
||||
from superset_tool.utils.init_clients import setup_clients
|
||||
from superset_tool.utils.logger import SupersetLogger
|
||||
# </IMPORTS>
|
||||
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# <ANCHOR id="get_and_save_dataset" type="Function">
|
||||
# @PURPOSE: Получает структуру датасета из Superset и сохраняет ее в JSON-файл.
|
||||
# @PARAM: env: str - Среда (dev, prod, и т.д.) для подключения.
|
||||
# @PARAM: dataset_id: int - ID датасета для получения.
|
||||
# @PARAM: output_path: str - Путь для сохранения JSON-файла.
|
||||
# @RELATION: CALLS -> setup_clients
|
||||
# @RELATION: CALLS -> superset_client.get_dataset
|
||||
def get_and_save_dataset(env: str, dataset_id: int, output_path: str):
|
||||
"""
|
||||
Получает структуру датасета и сохраняет в файл.
|
||||
"""
|
||||
logger = SupersetLogger(name="DatasetStructureRetriever")
|
||||
logger.info("[get_and_save_dataset][Enter] Starting to fetch dataset structure for ID %d from env '%s'.", dataset_id, env)
|
||||
|
||||
try:
|
||||
clients = setup_clients(logger=logger)
|
||||
superset_client = clients.get(env)
|
||||
if not superset_client:
|
||||
logger.error("[get_and_save_dataset][Failure] Environment '%s' not found.", env)
|
||||
return
|
||||
|
||||
dataset_response = superset_client.get_dataset(dataset_id)
|
||||
dataset_data = dataset_response.get('result')
|
||||
|
||||
if not dataset_data:
|
||||
logger.error("[get_and_save_dataset][Failure] No result in dataset response.")
|
||||
return
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(dataset_data, f, ensure_ascii=False, indent=4)
|
||||
|
||||
logger.info("[get_and_save_dataset][Success] Dataset structure saved to %s.", output_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[get_and_save_dataset][Failure] An error occurred: %s", e, exc_info=True)
|
||||
|
||||
# </ANCHOR>
|
||||
|
||||
# <ANCHOR id="__main__" type="Object">
|
||||
# @PURPOSE: Точка входа для CLI. Парсит аргументы и запускает получение структуры датасета.
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Получение структуры датасета из Superset.")
|
||||
parser.add_argument("--dataset-id", required=True, type=int, help="ID датасета.")
|
||||
parser.add_argument("--env", required=True, help="Среда для подключения (например, dev).")
|
||||
parser.add_argument("--output-path", default="dataset_structure.json", help="Путь для сохранения JSON-файла.")
|
||||
args = parser.parse_args()
|
||||
|
||||
get_and_save_dataset(args.env, args.dataset_id, args.output_path)
|
||||
# </ANCHOR>
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE>
|
||||
@@ -1,72 +1,37 @@
|
||||
# [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
|
||||
# <GRACE_MODULE id="migration_script" name="migration_script.py">
|
||||
# @SEMANTICS: migration, cli, superset, ui, logging, error-recovery, batch-delete
|
||||
# @PURPOSE: Предоставляет интерактивный CLI для миграции дашбордов Superset между окружениями с возможностью восстановления после ошибок.
|
||||
# @DEPENDS_ON: superset_tool.client -> Для взаимодействия с API Superset.
|
||||
# @DEPENDS_ON: superset_tool.utils -> Для инициализации клиентов, работы с файлами, UI и логирования.
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [IMPORTS]
|
||||
# --------------------------------------------------------------
|
||||
# <IMPORTS>
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, Dict
|
||||
|
||||
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.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
|
||||
# </IMPORTS>
|
||||
|
||||
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/`` текущего рабочего каталога.
|
||||
"""
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# <ANCHOR id="Migration" type="Class">
|
||||
# @PURPOSE: Инкапсулирует логику интерактивной миграции дашбордов с возможностью «удалить‑и‑перезаписать» при ошибке импорта.
|
||||
# @RELATION: CREATES_INSTANCE_OF -> SupersetLogger
|
||||
# @RELATION: USES -> SupersetClient
|
||||
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:
|
||||
# <ANCHOR id="Migration.__init__" type="Function">
|
||||
# @PURPOSE: Инициализирует сервис миграции, настраивает логгер и начальные состояния.
|
||||
# @POST: `self.logger` готов к использованию; `enable_delete_on_failure` = `False`.
|
||||
default_log_dir = Path.cwd() / "logs"
|
||||
self.logger = SupersetLogger(
|
||||
name="migration_script",
|
||||
@@ -79,62 +44,57 @@ class Migration:
|
||||
self.to_c: Optional[SupersetClient] = None
|
||||
self.dashboards_to_migrate: List[dict] = []
|
||||
self.db_config_replacement: Optional[dict] = None
|
||||
self._failed_imports: List[dict] = [] # <-- буфер ошибок
|
||||
self._failed_imports: List[dict] = []
|
||||
assert self.logger is not None, "Logger must be instantiated."
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="Migration.__init__">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('run')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Точка входа – последовательный запуск всех шагов миграции.
|
||||
:preconditions: Логгер готов.
|
||||
:postconditions: Скрипт завершён, пользователю выведено сообщение.
|
||||
"""
|
||||
# <ANCHOR id="Migration.run" type="Function">
|
||||
# @PURPOSE: Точка входа – последовательный запуск всех шагов миграции.
|
||||
# @PRE: Логгер готов.
|
||||
# @POST: Скрипт завершён, пользователю выведено сообщение.
|
||||
# @RELATION: CALLS -> self.ask_delete_on_failure
|
||||
# @RELATION: CALLS -> self.select_environments
|
||||
# @RELATION: CALLS -> self.select_dashboards
|
||||
# @RELATION: CALLS -> self.confirm_db_config_replacement
|
||||
# @RELATION: CALLS -> self.execute_migration
|
||||
def run(self) -> None:
|
||||
self.logger.info("[INFO][run][ENTER] Запуск скрипта миграции.")
|
||||
self.logger.info("[run][Entry] Запуск скрипта миграции.")
|
||||
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]
|
||||
self.logger.info("[run][Exit] Скрипт миграции завершён.")
|
||||
# </ANCHOR id="Migration.run">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('ask_delete_on_failure')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Запросить у пользователя, следует ли удалять дашборд при ошибке импорта.
|
||||
:preconditions: None.
|
||||
:postconditions: ``self.enable_delete_on_failure`` установлен.
|
||||
"""
|
||||
# <ANCHOR id="Migration.ask_delete_on_failure" type="Function">
|
||||
# @PURPOSE: Запрашивает у пользователя, следует ли удалять дашборд при ошибке импорта.
|
||||
# @POST: `self.enable_delete_on_failure` установлен.
|
||||
# @RELATION: CALLS -> yesno
|
||||
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",
|
||||
"[ask_delete_on_failure][State] Delete-on-failure = %s",
|
||||
self.enable_delete_on_failure,
|
||||
)
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="Migration.ask_delete_on_failure">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('select_environments')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Выбрать исходное и целевое окружения Superset.
|
||||
:preconditions: ``setup_clients`` успешно инициализирует все клиенты.
|
||||
:postconditions: ``self.from_c`` и ``self.to_c`` установлены.
|
||||
"""
|
||||
# <ANCHOR id="Migration.select_environments" type="Function">
|
||||
# @PURPOSE: Позволяет пользователю выбрать исходное и целевое окружения Superset.
|
||||
# @PRE: `setup_clients` успешно инициализирует все клиенты.
|
||||
# @POST: `self.from_c` и `self.to_c` установлены.
|
||||
# @RELATION: CALLS -> setup_clients
|
||||
# @RELATION: CALLS -> menu
|
||||
def select_environments(self) -> None:
|
||||
self.logger.info("[INFO][select_environments][ENTER] Шаг 1/5: Выбор окружений.")
|
||||
self.logger.info("[select_environments][Entry] Шаг 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)
|
||||
self.logger.error("[select_environments][Failure] %s", e, exc_info=True)
|
||||
msgbox("Ошибка", "Не удалось инициализировать клиенты.")
|
||||
return
|
||||
|
||||
@@ -146,7 +106,7 @@ class Migration:
|
||||
if rc != 0:
|
||||
return
|
||||
self.from_c = all_clients[from_env_name]
|
||||
self.logger.info("[INFO][select_environments] from = %s", from_env_name)
|
||||
self.logger.info("[select_environments][State] from = %s", from_env_name)
|
||||
|
||||
available_envs.remove(from_env_name)
|
||||
rc, to_env_name = menu(
|
||||
@@ -157,24 +117,22 @@ class Migration:
|
||||
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]
|
||||
self.logger.info("[select_environments][State] to = %s", to_env_name)
|
||||
self.logger.info("[select_environments][Exit] Шаг 1 завершён.")
|
||||
# </ANCHOR id="Migration.select_environments">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('select_dashboards')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Позволить пользователю выбрать набор дашбордов для миграции.
|
||||
:preconditions: ``self.from_c`` инициализирован.
|
||||
:postconditions: ``self.dashboards_to_migrate`` заполнен.
|
||||
"""
|
||||
# <ANCHOR id="Migration.select_dashboards" type="Function">
|
||||
# @PURPOSE: Позволяет пользователю выбрать набор дашбордов для миграции.
|
||||
# @PRE: `self.from_c` инициализирован.
|
||||
# @POST: `self.dashboards_to_migrate` заполнен.
|
||||
# @RELATION: CALLS -> self.from_c.get_dashboards
|
||||
# @RELATION: CALLS -> checklist
|
||||
def select_dashboards(self) -> None:
|
||||
self.logger.info("[INFO][select_dashboards][ENTER] Шаг 2/5: Выбор дашбордов.")
|
||||
self.logger.info("[select_dashboards][Entry] Шаг 2/5: Выбор дашбордов.")
|
||||
try:
|
||||
_, all_dashboards = self.from_c.get_dashboards() # type: ignore[attr-defined]
|
||||
_, all_dashboards = self.from_c.get_dashboards()
|
||||
if not all_dashboards:
|
||||
self.logger.warning("[WARN][select_dashboards] No dashboards.")
|
||||
self.logger.warning("[select_dashboards][State] No dashboards.")
|
||||
msgbox("Информация", "В исходном окружении нет дашбордов.")
|
||||
return
|
||||
|
||||
@@ -192,251 +150,129 @@ class Migration:
|
||||
|
||||
if "ALL" in selected:
|
||||
self.dashboards_to_migrate = list(all_dashboards)
|
||||
self.logger.info(
|
||||
"[INFO][select_dashboards] Выбраны все дашборды (%d).",
|
||||
len(self.dashboards_to_migrate),
|
||||
)
|
||||
return
|
||||
|
||||
else:
|
||||
self.dashboards_to_migrate = [
|
||||
d for d in all_dashboards if str(d["id"]) in selected
|
||||
]
|
||||
|
||||
self.logger.info(
|
||||
"[INFO][select_dashboards] Выбрано %d дашбордов.",
|
||||
"[select_dashboards][State] Выбрано %d дашбордов.",
|
||||
len(self.dashboards_to_migrate),
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error("[ERROR][select_dashboards] %s", e, exc_info=True)
|
||||
self.logger.error("[select_dashboards][Failure] %s", e, exc_info=True)
|
||||
msgbox("Ошибка", "Не удалось получить список дашбордов.")
|
||||
self.logger.info("[INFO][select_dashboards][EXIT] Шаг 2 завершён.")
|
||||
# [END_ENTITY]
|
||||
self.logger.info("[select_dashboards][Exit] Шаг 2 завершён.")
|
||||
# </ANCHOR id="Migration.select_dashboards">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('confirm_db_config_replacement')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Запросить у пользователя, требуется ли заменить имена БД в YAML‑файлах.
|
||||
:preconditions: None.
|
||||
:postconditions: ``self.db_config_replacement`` либо ``None``, либо заполнен.
|
||||
"""
|
||||
# <ANCHOR id="Migration.confirm_db_config_replacement" type="Function">
|
||||
# @PURPOSE: Запрашивает у пользователя, требуется ли заменить имена БД в YAML-файлах.
|
||||
# @POST: `self.db_config_replacement` либо `None`, либо заполнен.
|
||||
# @RELATION: CALLS -> yesno
|
||||
# @RELATION: CALLS -> inputbox
|
||||
def confirm_db_config_replacement(self) -> None:
|
||||
if yesno("Замена БД", "Заменить конфигурацию БД в YAML‑файлах?"):
|
||||
rc, old_name = inputbox("Замена БД", "Старое имя БД (например, db_dev):")
|
||||
if rc != 0:
|
||||
return
|
||||
if rc != 0: return
|
||||
rc, new_name = inputbox("Замена БД", "Новое имя БД (например, db_prod):")
|
||||
if rc != 0:
|
||||
return
|
||||
self.db_config_replacement = {
|
||||
"old": {"database_name": old_name},
|
||||
"new": {"database_name": new_name},
|
||||
}
|
||||
self.logger.info(
|
||||
"[INFO][confirm_db_config_replacement] Replacement set: %s",
|
||||
self.db_config_replacement,
|
||||
)
|
||||
else:
|
||||
self.logger.info("[INFO][confirm_db_config_replacement] Skipped.")
|
||||
# [END_ENTITY]
|
||||
if rc != 0: return
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('_batch_delete_by_ids')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Удалить набор дашбордов по их ID единым запросом.
|
||||
:preconditions:
|
||||
- ``ids`` – непустой список целых чисел.
|
||||
:postconditions: Все указанные дашборды удалены (если они существовали).
|
||||
:sideeffect: Делает HTTP‑запрос ``DELETE /dashboard/?q=[ids]``.
|
||||
"""
|
||||
self.db_config_replacement = { "old": {"database_name": old_name}, "new": {"database_name": new_name} }
|
||||
self.logger.info("[confirm_db_config_replacement][State] Replacement set: %s", self.db_config_replacement)
|
||||
else:
|
||||
self.logger.info("[confirm_db_config_replacement][State] Skipped.")
|
||||
# </ANCHOR id="Migration.confirm_db_config_replacement">
|
||||
|
||||
# <ANCHOR id="Migration._batch_delete_by_ids" type="Function">
|
||||
# @PURPOSE: Удаляет набор дашбордов по их ID единым запросом.
|
||||
# @PRE: `ids` – непустой список целых чисел.
|
||||
# @POST: Все указанные дашборды удалены (если они существовали).
|
||||
# @PARAM: ids: List[int] - Список ID дашбордов для удаления.
|
||||
# @RELATION: CALLS -> self.to_c.network.request
|
||||
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.")
|
||||
self.logger.debug("[_batch_delete_by_ids][Skip] Empty ID list – nothing to delete.")
|
||||
return
|
||||
|
||||
self.logger.info("[INFO][_batch_delete_by_ids] Deleting dashboards IDs: %s", ids)
|
||||
# Формируем параметр q в виде JSON‑массива, как требует Superset.
|
||||
self.logger.info("[_batch_delete_by_ids][Entry] Deleting dashboards IDs: %s", ids)
|
||||
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]
|
||||
response = self.to_c.network.request(method="DELETE", endpoint="/dashboard/", params={"q": q_param})
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [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`` производится
|
||||
батч‑удаление и повторный импорт.
|
||||
"""
|
||||
if isinstance(response, dict) and response.get("result", True) is False:
|
||||
self.logger.warning("[_batch_delete_by_ids][Warning] Unexpected delete response: %s", response)
|
||||
else:
|
||||
self.logger.info("[_batch_delete_by_ids][Success] Delete request completed.")
|
||||
# </ANCHOR id="Migration._batch_delete_by_ids">
|
||||
|
||||
# <ANCHOR id="Migration.execute_migration" type="Function">
|
||||
# @PURPOSE: Выполняет экспорт-импорт дашбордов, обрабатывает ошибки и, при необходимости, выполняет процедуру восстановления.
|
||||
# @PRE: `self.dashboards_to_migrate` не пуст; `self.from_c` и `self.to_c` инициализированы.
|
||||
# @POST: Успешные дашборды импортированы; неудачные - восстановлены или залогированы.
|
||||
# @RELATION: CALLS -> self.from_c.export_dashboard
|
||||
# @RELATION: CALLS -> create_temp_file
|
||||
# @RELATION: CALLS -> update_yamls
|
||||
# @RELATION: CALLS -> create_dashboard_export
|
||||
# @RELATION: CALLS -> self.to_c.import_dashboard
|
||||
# @RELATION: CALLS -> self._batch_delete_by_ids
|
||||
def execute_migration(self) -> None:
|
||||
if not self.dashboards_to_migrate:
|
||||
self.logger.warning("[WARN][execute_migration] No dashboards to migrate.")
|
||||
self.logger.warning("[execute_migration][Skip] No dashboards to migrate.")
|
||||
msgbox("Информация", "Нет дашбордов для миграции.")
|
||||
return
|
||||
|
||||
total = len(self.dashboards_to_migrate)
|
||||
self.logger.info("[INFO][execute_migration] Starting migration of %d dashboards.", total)
|
||||
self.logger.info("[execute_migration][Entry] Starting migration of %d dashboards.", total)
|
||||
self.to_c.delete_before_reimport = self.enable_delete_on_failure
|
||||
|
||||
# Передаём режим клиенту‑назначению
|
||||
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)
|
||||
dash_id, dash_slug, title = dash["id"], dash.get("slug"), dash["dashboard_title"]
|
||||
g.set_text(f"Миграция: {title} ({i + 1}/{total})")
|
||||
g.set_percent(progress)
|
||||
g.set_percent(int((i / total) * 100))
|
||||
|
||||
try:
|
||||
# ------------------- Экспорт -------------------
|
||||
exported_content, _ = self.from_c.export_dashboard(dash_id) # type: ignore[attr-defined]
|
||||
|
||||
# ------------------- Временный 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)
|
||||
|
||||
# ------------------- Распаковка во временный каталог -------------------
|
||||
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)
|
||||
exported_content, _ = self.from_c.export_dashboard(dash_id)
|
||||
with create_temp_file(content=exported_content, suffix=".zip", logger=self.logger) as tmp_zip_path, \
|
||||
create_temp_file(suffix=".dir", logger=self.logger) as tmp_unpack_dir:
|
||||
|
||||
with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(tmp_unpack_dir)
|
||||
self.logger.info("[INFO][execute_migration] Export unpacked to %s", tmp_unpack_dir)
|
||||
|
||||
# ------------------- YAML‑обновление (если нужно) -------------------
|
||||
if self.db_config_replacement:
|
||||
update_yamls(
|
||||
db_configs=[self.db_config_replacement],
|
||||
path=str(tmp_unpack_dir),
|
||||
)
|
||||
self.logger.info("[INFO][execute_migration] YAML‑files updated.")
|
||||
update_yamls(db_configs=[self.db_config_replacement], path=str(tmp_unpack_dir))
|
||||
|
||||
# ------------------- Сборка нового 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)
|
||||
|
||||
# ------------------- Импорт -------------------
|
||||
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)
|
||||
create_dashboard_export(zip_path=tmp_new_zip, source_paths=[str(tmp_unpack_dir)])
|
||||
self.to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug)
|
||||
|
||||
self.logger.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,
|
||||
}
|
||||
)
|
||||
self.logger.error("[execute_migration][Failure] %s", exc, exc_info=True)
|
||||
self._failed_imports.append({"slug": dash_slug, "dash_id": dash_id, "zip_content": exported_content})
|
||||
msgbox("Ошибка", f"Не удалось мигрировать дашборд {title}.\n\n{exc}")
|
||||
|
||||
g.set_percent(100)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# 2️⃣ Если возникли ошибки и пользователь согласился удалять – удаляем и повторяем
|
||||
# -----------------------------------------------------------------
|
||||
if self.enable_delete_on_failure and self._failed_imports:
|
||||
self.logger.info(
|
||||
"[INFO][execute_migration] %d dashboards failed. Starting recovery procedure.",
|
||||
len(self._failed_imports),
|
||||
)
|
||||
|
||||
# ------------------- Получаем список дашбордов в целевом окружении -------------------
|
||||
_, target_dashboards = self.to_c.get_dashboards() # type: ignore[attr-defined]
|
||||
slug_to_id: Dict[str, int] = {
|
||||
d["slug"]: d["id"] for d in target_dashboards if "slug" in d and "id" in d
|
||||
}
|
||||
|
||||
# ------------------- Формируем список ID‑ов для удаления -------------------
|
||||
ids_to_delete: List[int] = []
|
||||
for fail in self._failed_imports:
|
||||
slug = fail["slug"]
|
||||
if slug and slug in slug_to_id:
|
||||
ids_to_delete.append(slug_to_id[slug])
|
||||
else:
|
||||
self.logger.warning(
|
||||
"[WARN][execute_migration] Unable to map slug '%s' to ID on target.",
|
||||
slug,
|
||||
)
|
||||
|
||||
# ------------------- Batch‑удаление -------------------
|
||||
self.logger.info("[execute_migration][Recovery] %d dashboards failed. Starting recovery.", len(self._failed_imports))
|
||||
_, target_dashboards = self.to_c.get_dashboards()
|
||||
slug_to_id = {d["slug"]: d["id"] for d in target_dashboards if "slug" in d and "id" in d}
|
||||
ids_to_delete = [slug_to_id[f["slug"]] for f in self._failed_imports if f["slug"] in slug_to_id]
|
||||
self._batch_delete_by_ids(ids_to_delete)
|
||||
|
||||
# ------------------- Повторный импорт только для проблемных дашбордов -------------------
|
||||
for fail in self._failed_imports:
|
||||
dash_slug = fail["slug"]
|
||||
dash_id = fail["dash_id"]
|
||||
zip_content = fail["zip_content"]
|
||||
with create_temp_file(content=fail["zip_content"], suffix=".zip", logger=self.logger) as retry_zip:
|
||||
self.to_c.import_dashboard(file_name=retry_zip, dash_id=fail["dash_id"], dash_slug=fail["slug"])
|
||||
self.logger.info("[execute_migration][Recovered] Dashboard slug '%s' re-imported.", fail["slug"])
|
||||
|
||||
# Один раз создаём временный ZIP‑файл из сохранённого содержимого
|
||||
with create_temp_file(
|
||||
content=zip_content,
|
||||
suffix=".zip",
|
||||
logger=self.logger,
|
||||
) as retry_zip_path:
|
||||
self.logger.debug("[DEBUG][retry_zip] Retry ZIP for slug %s at %s", dash_slug, retry_zip_path)
|
||||
|
||||
# Пере‑импортируем – **slug** передаётся, но клиент будет использовать ID
|
||||
self.to_c.import_dashboard(
|
||||
file_name=retry_zip_path,
|
||||
dash_id=dash_id,
|
||||
dash_slug=dash_slug,
|
||||
) # type: ignore[attr-defined]
|
||||
|
||||
self.logger.info("[INFO][execute_migration][RECOVERED] Dashboard slug '%s' re‑imported.", dash_slug)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# 3️⃣ Финальная отчётность
|
||||
# -----------------------------------------------------------------
|
||||
self.logger.info("[INFO][execute_migration] Migration finished.")
|
||||
self.logger.info("[execute_migration][Exit] Migration finished.")
|
||||
msgbox("Информация", "Миграция завершена!")
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="Migration.execute_migration">
|
||||
|
||||
# [END_ENTITY: Service('Migration')]
|
||||
# </ANCHOR id="Migration">
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# Точка входа
|
||||
# --------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
Migration().run()
|
||||
# [END_FILE migration_script.py]
|
||||
# --------------------------------------------------------------
|
||||
|
||||
# </GRACE_MODULE id="migration_script">
|
||||
72
run_mapper.py
Normal file
72
run_mapper.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# <GRACE_MODULE id="run_mapper" name="run_mapper.py">
|
||||
# @SEMANTICS: runner, configuration, cli, main
|
||||
# @PURPOSE: Этот модуль является CLI-точкой входа для запуска процесса меппинга метаданных датасетов.
|
||||
# @DEPENDS_ON: dataset_mapper -> Использует DatasetMapper для выполнения основной логики.
|
||||
# @DEPENDS_ON: superset_tool.utils -> Для инициализации клиентов и логирования.
|
||||
|
||||
# <IMPORTS>
|
||||
import argparse
|
||||
from superset_tool.utils.init_clients import setup_clients
|
||||
from superset_tool.utils.logger import SupersetLogger
|
||||
from superset_tool.utils.dataset_mapper import DatasetMapper
|
||||
# </IMPORTS>
|
||||
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# <ANCHOR id="main" type="Function">
|
||||
# @PURPOSE: Парсит аргументы командной строки и запускает процесс меппинга.
|
||||
# @RELATION: CREATES_INSTANCE_OF -> DatasetMapper
|
||||
# @RELATION: CALLS -> setup_clients
|
||||
# @RELATION: CALLS -> DatasetMapper.run_mapping
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Map dataset verbose names in Superset.")
|
||||
parser.add_argument('--source', type=str, required=True, choices=['postgres', 'excel', 'both'], help='The source for the mapping.')
|
||||
parser.add_argument('--dataset-id', type=int, required=True, help='The ID of the dataset to update.')
|
||||
parser.add_argument('--table-name', type=str, help='The table name for PostgreSQL source.')
|
||||
parser.add_argument('--table-schema', type=str, help='The table schema for PostgreSQL source.')
|
||||
parser.add_argument('--excel-path', type=str, help='The path to the Excel file.')
|
||||
parser.add_argument('--env', type=str, default='dev', help='The Superset environment to use.')
|
||||
|
||||
args = parser.parse_args()
|
||||
logger = SupersetLogger(name="dataset_mapper_main")
|
||||
|
||||
# [AI_NOTE]: Конфигурация БД должна быть вынесена во внешний файл или переменные окружения.
|
||||
POSTGRES_CONFIG = {
|
||||
'dbname': 'dwh',
|
||||
'user': 'your_user',
|
||||
'password': 'your_password',
|
||||
'host': 'your_host',
|
||||
'port': 'your_port'
|
||||
}
|
||||
|
||||
logger.info("[main][Enter] Starting dataset mapper CLI.")
|
||||
try:
|
||||
clients = setup_clients(logger)
|
||||
superset_client = clients.get(args.env)
|
||||
|
||||
if not superset_client:
|
||||
logger.error(f"[main][Failure] Superset client for '{args.env}' environment not found.")
|
||||
return
|
||||
|
||||
mapper = DatasetMapper(logger)
|
||||
mapper.run_mapping(
|
||||
superset_client=superset_client,
|
||||
dataset_id=args.dataset_id,
|
||||
source=args.source,
|
||||
postgres_config=POSTGRES_CONFIG if args.source in ['postgres', 'both'] else None,
|
||||
excel_path=args.excel_path if args.source in ['excel', 'both'] else None,
|
||||
table_name=args.table_name if args.source in ['postgres', 'both'] else None,
|
||||
table_schema=args.table_schema if args.source in ['postgres', 'both'] else None
|
||||
)
|
||||
logger.info("[main][Exit] Dataset mapper process finished.")
|
||||
|
||||
except Exception as main_exc:
|
||||
logger.error("[main][Failure] An unexpected error occurred: %s", main_exc, exc_info=True)
|
||||
# </ANCHOR id="main">
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE id="run_mapper">
|
||||
122
search_script.py
122
search_script.py
@@ -1,50 +1,50 @@
|
||||
# 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.
|
||||
"""
|
||||
# <GRACE_MODULE id="search_script" name="search_script.py">
|
||||
# @SEMANTICS: search, superset, dataset, regex
|
||||
# @PURPOSE: Предоставляет утилиты для поиска по текстовым паттернам в метаданных датасетов Superset.
|
||||
# @DEPENDS_ON: superset_tool.client -> Для взаимодействия с API Superset.
|
||||
# @DEPENDS_ON: superset_tool.utils -> Для логирования и инициализации клиентов.
|
||||
|
||||
# [IMPORTS] Стандартная библиотека
|
||||
# <IMPORTS>
|
||||
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.exceptions import SupersetAPIError
|
||||
from superset_tool.utils.logger import SupersetLogger
|
||||
from superset_tool.utils.init_clients import setup_clients
|
||||
# </IMPORTS>
|
||||
|
||||
# [ENTITY: Function('search_datasets')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Выполняет поиск по строковому паттерну в метаданных всех датасетов.
|
||||
# PRECONDITIONS:
|
||||
# - `client` должен быть инициализированным экземпляром `SupersetClient`.
|
||||
# - `search_pattern` должен быть валидной строкой регулярного выражения.
|
||||
# POSTCONDITIONS:
|
||||
# - Возвращает словарь с результатами поиска.
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# <ANCHOR id="search_datasets" type="Function">
|
||||
# @PURPOSE: Выполняет поиск по строковому паттерну в метаданных всех датасетов.
|
||||
# @PRE: `client` должен быть инициализированным экземпляром `SupersetClient`.
|
||||
# @PRE: `search_pattern` должен быть валидной строкой регулярного выражения.
|
||||
# @POST: Возвращает словарь с результатами поиска, где ключ - ID датасета, значение - список совпадений.
|
||||
# @PARAM: client: SupersetClient - Клиент для доступа к API Superset.
|
||||
# @PARAM: search_pattern: str - Регулярное выражение для поиска.
|
||||
# @PARAM: logger: Optional[SupersetLogger] - Инстанс логгера.
|
||||
# @RETURN: Optional[Dict] - Словарь с результатами или None, если ничего не найдено.
|
||||
# @THROW: re.error - Если паттерн регулярного выражения невалиден.
|
||||
# @THROW: SupersetAPIError, RequestException - При критических ошибках API.
|
||||
# @RELATION: CALLS -> client.get_datasets
|
||||
def search_datasets(
|
||||
client: SupersetClient,
|
||||
search_pattern: str,
|
||||
logger: Optional[SupersetLogger] = None
|
||||
) -> Optional[Dict]:
|
||||
logger = logger or SupersetLogger(name="dataset_search")
|
||||
logger.info(f"[STATE][search_datasets][ENTER] Searching for pattern: '{search_pattern}'")
|
||||
logger.info(f"[search_datasets][Enter] Searching for pattern: '{search_pattern}'")
|
||||
try:
|
||||
_, datasets = client.get_datasets(query={
|
||||
"columns": ["id", "table_name", "sql", "database", "columns"]
|
||||
})
|
||||
_, datasets = client.get_datasets(query={"columns": ["id", "table_name", "sql", "database", "columns"]})
|
||||
|
||||
if not datasets:
|
||||
logger.warning("[STATE][search_datasets][EMPTY] No datasets found.")
|
||||
logger.warning("[search_datasets][State] No datasets found.")
|
||||
return None
|
||||
|
||||
pattern = re.compile(search_pattern, re.IGNORECASE)
|
||||
results = {}
|
||||
available_fields = set(datasets[0].keys())
|
||||
|
||||
for dataset in datasets:
|
||||
dataset_id = dataset.get('id')
|
||||
@@ -52,37 +52,37 @@ def search_datasets(
|
||||
continue
|
||||
|
||||
matches = []
|
||||
for field in available_fields:
|
||||
value = str(dataset.get(field, ""))
|
||||
if pattern.search(value):
|
||||
match_obj = pattern.search(value)
|
||||
for field, value in dataset.items():
|
||||
value_str = str(value)
|
||||
if pattern.search(value_str):
|
||||
match_obj = pattern.search(value_str)
|
||||
matches.append({
|
||||
"field": field,
|
||||
"match": match_obj.group() if match_obj else "",
|
||||
"value": value
|
||||
"value": value_str
|
||||
})
|
||||
|
||||
if matches:
|
||||
results[dataset_id] = matches
|
||||
|
||||
logger.info(f"[STATE][search_datasets][SUCCESS] Found matches in {len(results)} datasets.")
|
||||
logger.info(f"[search_datasets][Success] Found matches in {len(results)} datasets.")
|
||||
return results
|
||||
|
||||
except re.error as e:
|
||||
logger.error(f"[STATE][search_datasets][FAILURE] Invalid regex pattern: {e}", exc_info=True)
|
||||
logger.error(f"[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)
|
||||
logger.critical(f"[search_datasets][Failure] Critical error during search: {e}", exc_info=True)
|
||||
raise
|
||||
# END_FUNCTION_search_datasets
|
||||
# </ANCHOR id="search_datasets">
|
||||
|
||||
# [ENTITY: Function('print_search_results')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Форматирует результаты поиска для читаемого вывода в консоль.
|
||||
# PRECONDITIONS:
|
||||
# - `results` является словарем, возвращенным `search_datasets`, или `None`.
|
||||
# POSTCONDITIONS:
|
||||
# - Возвращает отформатированную строку с результатами.
|
||||
# <ANCHOR id="print_search_results" type="Function">
|
||||
# @PURPOSE: Форматирует результаты поиска для читаемого вывода в консоль.
|
||||
# @PRE: `results` является словарем, возвращенным `search_datasets`, или `None`.
|
||||
# @POST: Возвращает отформатированную строку с результатами.
|
||||
# @PARAM: results: Optional[Dict] - Словарь с результатами поиска.
|
||||
# @PARAM: context_lines: int - Количество строк контекста для вывода до и после совпадения.
|
||||
# @RETURN: str - Отформатированный отчет.
|
||||
def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str:
|
||||
if not results:
|
||||
return "Ничего не найдено"
|
||||
@@ -91,16 +91,12 @@ def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str
|
||||
for dataset_id, matches in results.items():
|
||||
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']
|
||||
|
||||
field, match_text, full_value = match_info['field'], match_info['match'], match_info['value']
|
||||
output.append(f" - Поле: {field}")
|
||||
output.append(f" Совпадение: '{match_text}'")
|
||||
|
||||
lines = full_value.splitlines()
|
||||
if not lines:
|
||||
continue
|
||||
if not lines: continue
|
||||
|
||||
match_line_index = -1
|
||||
for i, line in enumerate(lines):
|
||||
@@ -109,28 +105,26 @@ def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str
|
||||
break
|
||||
|
||||
if match_line_index != -1:
|
||||
start_line = max(0, match_line_index - context_lines)
|
||||
end_line = min(len(lines), match_line_index + context_lines + 1)
|
||||
|
||||
start = max(0, match_line_index - context_lines)
|
||||
end = min(len(lines), match_line_index + context_lines + 1)
|
||||
output.append(" Контекст:")
|
||||
for i in range(start_line, end_line):
|
||||
line_number = i + 1
|
||||
for i in range(start, end):
|
||||
prefix = f"{i + 1:5d}: "
|
||||
line_content = lines[i]
|
||||
prefix = f"{line_number:5d}: "
|
||||
if i == match_line_index:
|
||||
highlighted_line = line_content.replace(match_text, f">>>{match_text}<<<")
|
||||
output.append(f" {prefix}{highlighted_line}")
|
||||
highlighted = line_content.replace(match_text, f">>>{match_text}<<<")
|
||||
output.append(f" {prefix}{highlighted}")
|
||||
else:
|
||||
output.append(f" {prefix}{line_content}")
|
||||
output.append("-" * 25)
|
||||
return "\n".join(output)
|
||||
# END_FUNCTION_print_search_results
|
||||
# </ANCHOR id="print_search_results">
|
||||
|
||||
# [ENTITY: Function('main')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Основная точка входа скрипта.
|
||||
# PRECONDITIONS: None
|
||||
# POSTCONDITIONS: None
|
||||
# <ANCHOR id="main" type="Function">
|
||||
# @PURPOSE: Основная точка входа для запуска скрипта поиска.
|
||||
# @RELATION: CALLS -> setup_clients
|
||||
# @RELATION: CALLS -> search_datasets
|
||||
# @RELATION: CALLS -> print_search_results
|
||||
def main():
|
||||
logger = SupersetLogger(level=logging.INFO, console=True)
|
||||
clients = setup_clients(logger)
|
||||
@@ -145,8 +139,12 @@ def main():
|
||||
)
|
||||
|
||||
report = print_search_results(results)
|
||||
logger.info(f"[STATE][main][SUCCESS] Search finished. Report:\n{report}")
|
||||
# END_FUNCTION_main
|
||||
logger.info(f"[main][Success] Search finished. Report:\n{report}")
|
||||
# </ANCHOR id="main">
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE id="search_script">
|
||||
|
||||
120
semantic_protocol.md
Normal file
120
semantic_protocol.md
Normal file
@@ -0,0 +1,120 @@
|
||||
### **Протокол GRACE-Py: Семантическая Разметка для AI-Агентов на Python**
|
||||
|
||||
**Версия: 2.2 (Hybrid)**
|
||||
|
||||
#### **I. Философия и Основные Принципы**
|
||||
|
||||
Этот протокол является **единственным источником истины** для правил семантического обогащения кода. Его цель — превратить процесс разработки с LLM-агентами из непредсказуемого "диалога" в управляемую **инженерную дисциплину**.
|
||||
|
||||
* **Аксиома 1: Код Вторичен.** Первична его семантическая модель (графы, контракты, якоря).
|
||||
* **Аксиома 2: Когерентность Абсолютна.** Все артефакты (ТЗ, граф, контракты, код) должны быть на 100% семантически согласованы.
|
||||
* **Аксиома 3: Архитектура GPT — Закон.** Протокол построен на фундаментальных принципах работы трансформеров (Causal Attention, KV Cache, Sparse Attention).
|
||||
|
||||
#### **II. Структура Файла (`.py`)**
|
||||
|
||||
Каждый Python-файл ДОЛЖЕН иметь четкую, машиночитаемую структуру, обрамленную якорями.
|
||||
|
||||
```python
|
||||
# <GRACE_MODULE id="my_module" name="my_module.py">
|
||||
# @SEMANTICS: domain, usecase, data_processing
|
||||
# @PURPOSE: Этот модуль отвечает за обработку пользовательских данных.
|
||||
# @DEPENDS_ON: utils_module -> Использует утилиты для валидации.
|
||||
|
||||
# <IMPORTS>
|
||||
import os
|
||||
from typing import List
|
||||
# </IMPORTS>
|
||||
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# ... (классы, функции, константы) ...
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE id="my_module">
|
||||
```
|
||||
|
||||
#### **III. Компоненты Разметки (Детализация GRACE-Py)**
|
||||
|
||||
##### **A. Anchors (Якоря): Навигация и Консолидация**
|
||||
|
||||
1. **Назначение:** Якоря — это основной инструмент для управления вниманием ИИ, создания семантических каналов и обеспечения надежной навигации в больших кодовых базах (Sparse Attention).
|
||||
2. **Синтаксис:** Используются парные комментарии в псевдо-XML формате.
|
||||
* **Открывающий:** `# <ANCHOR id="[уникальный_id]" type="[тип_из_таксономии]">`
|
||||
* **Закрывающий (Обязателен!):** `# </ANCHOR id="[уникальный_id]">`
|
||||
3. **"Якорь-Аккумулятор":** Закрывающий якорь консолидирует всю семантику блока (контракт + код), создавая мощный вектор для RAG-систем.
|
||||
4. **Семантические Каналы:** `id` якоря ДОЛЖЕН совпадать с именем сущности для создания устойчивой семантической связи.
|
||||
5. **Таксономия Типов (`type`):** `Module`, `Class`, `Interface`, `Object`, `DataClass`, `SealedInterface`, `EnumClass`, `Function`, `UseCase`, `ViewModel`, `Repository`.
|
||||
|
||||
##### **C. Contracts (Контракты): Тактические Спецификации**
|
||||
|
||||
1. **Назначение:** Предоставление ИИ точных инструкций для генерации и валидации кода.
|
||||
2. **Расположение:** Контракт всегда располагается **внутри открывающего якоря**, ДО декларации кода (`def` или `class`).
|
||||
3. **Синтаксис:** JSDoc-подобный стиль с `@tag` для лаконичности и читаемости.
|
||||
```python
|
||||
# <ANCHOR id="process_data" type="Function">
|
||||
# @PURPOSE: Валидирует и обрабатывает входящие данные пользователя.
|
||||
# @SPEC_LINK: tz-req-005
|
||||
# @PRE: `raw_data` не должен быть пустым.
|
||||
# @POST: Возвращаемый словарь содержит ключ 'is_valid'.
|
||||
# @PARAM: raw_data: Dict[str, any] - Сырые данные от пользователя.
|
||||
# @RETURN: Dict[str, any] - Обработанные и валидированные данные.
|
||||
# @TEST: input='{"user_id": 123}', expected_output='{"is_valid": True}'
|
||||
# @THROW: ValueError - Если 'user_id' отсутствует.
|
||||
# @RELATION: CALLS -> validate_user_id
|
||||
# @CONSTRAINT: Не использовать внешние сетевые вызовы.
|
||||
```
|
||||
4. **Реализация в Коде:** Предусловия и постусловия, описанные в контракте, ДОЛЖНЫ быть реализованы в коде с использованием `assert`, `require()`/`check()` или явных `if...raise`.
|
||||
|
||||
##### **G. Graph (Граф Знаний)**
|
||||
|
||||
1. **Назначение:** Описание высокоуровневых зависимостей между сущностями.
|
||||
2. **Реализация:** Граф определяется тегами `@RELATION` внутри GRACE блока (якоря). Это создает распределенный граф, который легко парсить.
|
||||
* **Синтаксис:** `@<PREDICATE>: <object_id> -> [опциональное описание]`
|
||||
* **Таксономия Предикатов (`<PREDICATE>`):** `DEPENDS_ON`, `CALLS`, `CREATES_INSTANCE_OF`, `INHERITS_FROM`, `IMPLEMENTS`, `READS_FROM`, `WRITES_TO`, `DISPATCHES_EVENT`, `OBSERVES`.
|
||||
|
||||
##### **E. Evaluation (Логирование)**
|
||||
|
||||
1. **Назначение:** Декларация `belief state` агента и обеспечение трассируемости для отладки.
|
||||
2. **Формат:** `logger.level(f"[ANCHOR_ID][STATE] Сообщение")`
|
||||
* **`ANCHOR_ID`:** `id` якоря, в котором находится лог.
|
||||
* **`STATE`:** Текущее состояние логики (например, `Entry`, `Validation`, `Exit`, `CoherenceCheckFailed`).
|
||||
3. **Пример:** `logger.debug(f"[process_data][Validation] Проверка `raw_data`...")`
|
||||
|
||||
#### **IV. Запреты и Ограничения**
|
||||
|
||||
1. **Запрет на Обычные Комментарии:** Комментарии в стиле `//` или `/* */` **ЗАПРЕЩЕНЫ**. Вся мета-информация должна быть в структурированных GRACE блоках.
|
||||
* **Исключение:** `# [AI_NOTE]: ...` для прямых указаний агенту в конкретной точке кода.
|
||||
|
||||
#### **V. Полный Пример Разметки Функции (GRACE-Py 2.2)**
|
||||
|
||||
```python
|
||||
# <ANCHOR id="process_data" type="Function">
|
||||
# @PURPOSE: Валидирует и обрабатывает входящие данные пользователя.
|
||||
# @SPEC_LINK: tz-req-005
|
||||
# @PRE: `raw_data` не должен быть пустым.
|
||||
# @PARAM: raw_data: Dict[str, any] - Сырые данные от пользователя.
|
||||
# @RETURN: Dict[str, any] - Обработанные и валидированные данные.
|
||||
# @TEST: input='{}', expected_exception='AssertionError'
|
||||
# @RELATION: CALLS -> some_helper_function
|
||||
def process_data(raw_data: dict) -> dict:
|
||||
"""
|
||||
Docstring для стандартных инструментов Python.
|
||||
Не является источником истины для ИИ-агентов.
|
||||
"""
|
||||
logger.debug(f"[process_data][Entry] Начало обработки данных.")
|
||||
|
||||
# Реализация контракта
|
||||
assert raw_data, "Precondition failed: raw_data must not be empty."
|
||||
|
||||
# ... Основная логика ...
|
||||
processed_data = {"is_valid": True}
|
||||
processed_data.update(raw_data)
|
||||
|
||||
logger.info(f"[process_data][CoherenceCheck:Passed] Код соответствует контракту.")
|
||||
logger.debug(f"[process_data][Exit] Завершение обработки.")
|
||||
|
||||
return processed_data
|
||||
# </ANCHOR id="process_data">
|
||||
```
|
||||
|
||||
@@ -1,59 +1,38 @@
|
||||
# [MODULE_PATH] superset_tool.client
|
||||
# [FILE] client.py
|
||||
# [SEMANTICS] superset, api, client, logging, error-handling, slug-support
|
||||
# <GRACE_MODULE id="superset_tool.client" name="client.py">
|
||||
# @SEMANTICS: superset, api, client, rest, http, dashboard, dataset, import, export
|
||||
# @PURPOSE: Предоставляет высокоуровневый клиент для взаимодействия с Superset REST API, инкапсулируя логику запросов, обработку ошибок и пагинацию.
|
||||
# @DEPENDS_ON: superset_tool.models -> Использует SupersetConfig для конфигурации.
|
||||
# @DEPENDS_ON: superset_tool.exceptions -> Выбрасывает специализированные исключения.
|
||||
# @DEPENDS_ON: superset_tool.utils -> Использует утилиты для сети, логгирования и работы с файлами.
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [IMPORTS]
|
||||
# --------------------------------------------------------------
|
||||
# <IMPORTS>
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from requests import Response
|
||||
|
||||
from superset_tool.models import SupersetConfig
|
||||
from superset_tool.exceptions import ExportError, InvalidZipFormatError
|
||||
from superset_tool.utils.fileio import get_filename_from_headers
|
||||
from superset_tool.utils.logger import SupersetLogger
|
||||
from superset_tool.utils.network import APIClient
|
||||
# [END_IMPORTS]
|
||||
# </IMPORTS>
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Service('SupersetClient')]
|
||||
# [RELATION: Service('SupersetClient')] -> [DEPENDS_ON] -> [PythonModule('superset_tool.utils.network')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Класс‑обёртка над Superset REST‑API.
|
||||
:preconditions:
|
||||
- ``config`` – валидный объект :class:`SupersetConfig`.
|
||||
- Доступен рабочий HTTP‑клиент :class:`APIClient`.
|
||||
:postconditions:
|
||||
- Объект готов к выполнению запросов (GET, POST, DELETE и т.д.).
|
||||
:raises:
|
||||
- :class:`TypeError` при передаче неверного типа конфигурации.
|
||||
"""
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# <ANCHOR id="SupersetClient" type="Class">
|
||||
# @PURPOSE: Класс-обёртка над Superset REST API, предоставляющий методы для работы с дашбордами и датасетами.
|
||||
# @RELATION: CREATES_INSTANCE_OF -> APIClient
|
||||
# @RELATION: USES -> SupersetConfig
|
||||
class SupersetClient:
|
||||
"""
|
||||
:ivar SupersetLogger logger: Логгер, используемый в клиенте.
|
||||
:ivar SupersetConfig config: Текущая конфигурация подключения.
|
||||
:ivar APIClient network: Объект‑обёртка над ``requests``.
|
||||
:ivar bool delete_before_reimport: Флаг, указывающий,
|
||||
что при ошибке импорта дашборд следует удалить и повторить импорт.
|
||||
"""
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('__init__')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Инициализировать клиент и передать ему логгер.
|
||||
:preconditions: ``config`` – экземпляр :class:`SupersetConfig`.
|
||||
:postconditions: Атрибуты ``logger``, ``config`` и ``network`` созданы,
|
||||
``delete_before_reimport`` установлен в ``False``.
|
||||
"""
|
||||
def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None):
|
||||
# <ANCHOR id="SupersetClient.__init__" type="Function">
|
||||
# @PURPOSE: Инициализирует клиент, проверяет конфигурацию и создает сетевой клиент.
|
||||
# @PARAM: config: SupersetConfig - Конфигурация подключения.
|
||||
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# @POST: Атрибуты `logger`, `config`, и `network` созданы.
|
||||
self.logger = logger or SupersetLogger(name="SupersetClient")
|
||||
self.logger.info("[INFO][SupersetClient.__init__] Initializing SupersetClient.")
|
||||
self.logger.info("[SupersetClient.__init__][Enter] Initializing SupersetClient.")
|
||||
self._validate_config(config)
|
||||
self.config = config
|
||||
self.network = APIClient(
|
||||
@@ -63,68 +42,52 @@ class SupersetClient:
|
||||
logger=self.logger,
|
||||
)
|
||||
self.delete_before_reimport: bool = False
|
||||
self.logger.info("[INFO][SupersetClient.__init__] SupersetClient initialized.")
|
||||
# [END_ENTITY]
|
||||
self.logger.info("[SupersetClient.__init__][Exit] SupersetClient initialized.")
|
||||
# </ANCHOR id="SupersetClient.__init__">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('_validate_config')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Проверить, что передан объект :class:`SupersetConfig`.
|
||||
:preconditions: ``config`` – произвольный объект.
|
||||
:postconditions: При несовпадении типов возбуждается :class:`TypeError`.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient._validate_config" type="Function">
|
||||
# @PURPOSE: Проверяет, что переданный объект конфигурации имеет корректный тип.
|
||||
# @PARAM: config: SupersetConfig - Объект для проверки.
|
||||
# @THROW: TypeError - Если `config` не является экземпляром `SupersetConfig`.
|
||||
def _validate_config(self, config: SupersetConfig) -> None:
|
||||
self.logger.debug("[DEBUG][_validate_config][ENTER] Validating SupersetConfig.")
|
||||
if not isinstance(config, SupersetConfig):
|
||||
self.logger.error("[ERROR][_validate_config][FAILURE] Invalid config type.")
|
||||
raise TypeError("Конфигурация должна быть экземпляром SupersetConfig")
|
||||
self.logger.debug("[DEBUG][_validate_config][SUCCESS] Config is valid.")
|
||||
# [END_ENTITY]
|
||||
self.logger.debug("[_validate_config][Enter] Validating SupersetConfig.")
|
||||
assert isinstance(config, SupersetConfig), "Конфигурация должна быть экземпляром SupersetConfig"
|
||||
self.logger.debug("[_validate_config][Exit] Config is valid.")
|
||||
# </ANCHOR id="SupersetClient._validate_config">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Property('headers')]
|
||||
# --------------------------------------------------------------
|
||||
@property
|
||||
def headers(self) -> dict:
|
||||
"""Базовые HTTP‑заголовки, используемые клиентом."""
|
||||
# <ANCHOR id="SupersetClient.headers" type="Property">
|
||||
# @PURPOSE: Возвращает базовые HTTP-заголовки, используемые сетевым клиентом.
|
||||
return self.network.headers
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="SupersetClient.headers">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('get_dashboards')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Получить список дашбордов с поддержкой пагинации.
|
||||
:preconditions: None.
|
||||
:postconditions: Возвращается кортеж ``(total_count, list_of_dashboards)``.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient.get_dashboards" type="Function">
|
||||
# @PURPOSE: Получает полный список дашбордов, автоматически обрабатывая пагинацию.
|
||||
# @PARAM: query: Optional[Dict] - Дополнительные параметры запроса для API.
|
||||
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список дашбордов).
|
||||
# @RELATION: CALLS -> self._fetch_total_object_count
|
||||
# @RELATION: CALLS -> self._fetch_all_pages
|
||||
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
||||
self.logger.info("[INFO][get_dashboards][ENTER] Fetching dashboards.")
|
||||
self.logger.info("[get_dashboards][Enter] Fetching dashboards.")
|
||||
validated_query = self._validate_query_params(query)
|
||||
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
|
||||
paginated_data = self._fetch_all_pages(
|
||||
endpoint="/dashboard/",
|
||||
pagination_options={
|
||||
"base_query": validated_query,
|
||||
"total_count": total_count,
|
||||
"results_field": "result",
|
||||
},
|
||||
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
|
||||
)
|
||||
self.logger.info("[INFO][get_dashboards][SUCCESS] Got dashboards.")
|
||||
self.logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count)
|
||||
return total_count, paginated_data
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="SupersetClient.get_dashboards">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('export_dashboard')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Скачать дашборд в виде ZIP‑архива.
|
||||
:preconditions: ``dashboard_id`` – существующий идентификатор.
|
||||
:postconditions: Возвращается бинарное содержимое и имя файла.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient.export_dashboard" type="Function">
|
||||
# @PURPOSE: Экспортирует дашборд в виде ZIP-архива.
|
||||
# @PARAM: dashboard_id: int - ID дашборда для экспорта.
|
||||
# @RETURN: Tuple[bytes, str] - Бинарное содержимое ZIP-архива и имя файла.
|
||||
# @THROW: ExportError - Если экспорт завершился неудачей.
|
||||
# @RELATION: CALLS -> self.network.request
|
||||
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
|
||||
self.logger.info("[INFO][export_dashboard][ENTER] Exporting dashboard %s.", dashboard_id)
|
||||
self.logger.info("[export_dashboard][Enter] Exporting dashboard %s.", dashboard_id)
|
||||
response = self.network.request(
|
||||
method="GET",
|
||||
endpoint="/dashboard/export/",
|
||||
@@ -134,160 +97,86 @@ class SupersetClient:
|
||||
)
|
||||
self._validate_export_response(response, dashboard_id)
|
||||
filename = self._resolve_export_filename(response, dashboard_id)
|
||||
self.logger.info("[INFO][export_dashboard][SUCCESS] Exported dashboard %s.", dashboard_id)
|
||||
self.logger.info("[export_dashboard][Exit] Exported dashboard %s to %s.", dashboard_id, filename)
|
||||
return response.content, filename
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="SupersetClient.export_dashboard">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('import_dashboard')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Импортировать дашборд из ZIP‑файла. При неуспешном импорте,
|
||||
если ``delete_before_reimport`` = True, сначала удаляется
|
||||
дашборд по ID, затем импорт повторяется.
|
||||
:preconditions:
|
||||
- ``file_name`` – путь к существующему ZIP‑архиву (str|Path).
|
||||
- ``dash_id`` – (опционально) ID дашборда, который следует удалить.
|
||||
:postconditions: Возвращается словарь‑ответ API при успехе.
|
||||
"""
|
||||
def import_dashboard(
|
||||
self,
|
||||
file_name: Union[str, Path],
|
||||
dash_id: Optional[int] = None,
|
||||
dash_slug: Optional[str] = None, # сохраняем для возможного логирования
|
||||
) -> Dict:
|
||||
# -----------------------------------------------------------------
|
||||
# 1️⃣ Приводим путь к строке (API‑клиент ожидает str)
|
||||
# -----------------------------------------------------------------
|
||||
file_path: str = str(file_name) # <--- гарантируем тип str
|
||||
# <ANCHOR id="SupersetClient.import_dashboard" type="Function">
|
||||
# @PURPOSE: Импортирует дашборд из ZIP-файла с возможностью автоматического удаления и повторной попытки при ошибке.
|
||||
# @PARAM: file_name: Union[str, Path] - Путь к ZIP-архиву.
|
||||
# @PARAM: dash_id: Optional[int] - ID дашборда для удаления при сбое.
|
||||
# @PARAM: dash_slug: Optional[str] - Slug дашборда для поиска ID, если ID не предоставлен.
|
||||
# @RETURN: Dict - Ответ API в случае успеха.
|
||||
# @RELATION: CALLS -> self._do_import
|
||||
# @RELATION: CALLS -> self.delete_dashboard
|
||||
# @RELATION: CALLS -> self.get_dashboards
|
||||
def import_dashboard(self, file_name: Union[str, Path], dash_id: Optional[int] = None, dash_slug: Optional[str] = None) -> Dict:
|
||||
file_path = str(file_name)
|
||||
self._validate_import_file(file_path)
|
||||
|
||||
try:
|
||||
import_response = self._do_import(file_path)
|
||||
self.logger.info("[INFO][import_dashboard] Imported %s.", file_path)
|
||||
return import_response
|
||||
|
||||
return self._do_import(file_path)
|
||||
except Exception as exc:
|
||||
# -----------------------------------------------------------------
|
||||
# 2️⃣ Логируем первую неудачу, пытаемся удалить и повторить,
|
||||
# только если включён флаг ``delete_before_reimport``.
|
||||
# -----------------------------------------------------------------
|
||||
self.logger.error(
|
||||
"[ERROR][import_dashboard] First import attempt failed: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
self.logger.error("[import_dashboard][Failure] First import attempt failed: %s", exc, exc_info=True)
|
||||
if not self.delete_before_reimport:
|
||||
raise
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# 3️⃣ Выбираем, как искать дашборд для удаления.
|
||||
# При наличии ``dash_id`` – удаляем его.
|
||||
# Иначе, если известен ``dash_slug`` – переводим его в ID ниже.
|
||||
# -----------------------------------------------------------------
|
||||
target_id: Optional[int] = dash_id
|
||||
if target_id is None and dash_slug is not None:
|
||||
# Попытка динамического определения ID через slug.
|
||||
# Мы делаем отдельный запрос к /dashboard/ (поисковый фильтр).
|
||||
self.logger.debug("[DEBUG][import_dashboard] Resolving ID by slug '%s'.", dash_slug)
|
||||
target_id = self._resolve_target_id_for_delete(dash_id, dash_slug)
|
||||
if target_id is None:
|
||||
self.logger.error("[import_dashboard][Failure] No ID available for delete-retry.")
|
||||
raise
|
||||
|
||||
self.delete_dashboard(target_id)
|
||||
self.logger.info("[import_dashboard][State] Deleted dashboard ID %s, retrying import.", target_id)
|
||||
return self._do_import(file_path)
|
||||
# </ANCHOR id="SupersetClient.import_dashboard">
|
||||
|
||||
# <ANCHOR id="SupersetClient._resolve_target_id_for_delete" type="Function">
|
||||
# @PURPOSE: Определяет ID дашборда для удаления, используя ID или slug.
|
||||
# @INTERNAL
|
||||
def _resolve_target_id_for_delete(self, dash_id: Optional[int], dash_slug: Optional[str]) -> Optional[int]:
|
||||
if dash_id is not None:
|
||||
return dash_id
|
||||
if dash_slug is not None:
|
||||
self.logger.debug("[_resolve_target_id_for_delete][State] Resolving ID by slug '%s'.", dash_slug)
|
||||
try:
|
||||
_, candidates = self.get_dashboards(
|
||||
query={"filters": [{"col": "slug", "op": "eq", "value": dash_slug}]}
|
||||
)
|
||||
_, candidates = self.get_dashboards(query={"filters": [{"col": "slug", "op": "eq", "value": dash_slug}]})
|
||||
if candidates:
|
||||
target_id = candidates[0]["id"]
|
||||
self.logger.debug("[DEBUG][import_dashboard] Resolved slug → ID %s.", target_id)
|
||||
self.logger.debug("[_resolve_target_id_for_delete][Success] Resolved slug to ID %s.", target_id)
|
||||
return target_id
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
"[WARN][import_dashboard] Could not resolve slug '%s' to ID: %s",
|
||||
dash_slug,
|
||||
e,
|
||||
)
|
||||
self.logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e)
|
||||
return None
|
||||
# </ANCHOR id="SupersetClient._resolve_target_id_for_delete">
|
||||
|
||||
# Если всё‑равно нет ID – считаем невозможным корректно удалить.
|
||||
if target_id is None:
|
||||
self.logger.error("[ERROR][import_dashboard] No ID available for delete‑retry.")
|
||||
raise
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# 4️⃣ Удаляем найденный дашборд (по ID)
|
||||
# -----------------------------------------------------------------
|
||||
try:
|
||||
self.delete_dashboard(target_id)
|
||||
self.logger.info("[INFO][import_dashboard] Deleted dashboard ID %s, retrying import.", target_id)
|
||||
except Exception as del_exc:
|
||||
self.logger.error("[ERROR][import_dashboard] Delete failed: %s", del_exc, exc_info=True)
|
||||
raise
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# 5️⃣ Повторный импорт (тот же файл)
|
||||
# -----------------------------------------------------------------
|
||||
try:
|
||||
import_response = self._do_import(file_path)
|
||||
self.logger.info("[INFO][import_dashboard] Re‑import succeeded.")
|
||||
return import_response
|
||||
except Exception as rec_exc:
|
||||
self.logger.error(
|
||||
"[ERROR][import_dashboard] Re‑import after delete failed: %s",
|
||||
rec_exc,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
# [END_ENTITY]
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('_do_import')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Выполнить один запрос на импорт без обработки исключений.
|
||||
:preconditions: ``file_name`` уже проверен и существует.
|
||||
:postconditions: Возвращается словарь‑ответ API.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient._do_import" type="Function">
|
||||
# @PURPOSE: Выполняет один запрос на импорт без обработки исключений.
|
||||
# @INTERNAL
|
||||
def _do_import(self, file_name: Union[str, Path]) -> Dict:
|
||||
return self.network.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={
|
||||
"file_obj": Path(file_name),
|
||||
"file_name": Path(file_name).name,
|
||||
"form_field": "formData",
|
||||
},
|
||||
file_info={"file_obj": Path(file_name), "file_name": Path(file_name).name, "form_field": "formData"},
|
||||
extra_data={"overwrite": "true"},
|
||||
timeout=self.config.timeout * 2,
|
||||
)
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="SupersetClient._do_import">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('delete_dashboard')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Удалить дашборд **по ID или slug**.
|
||||
:preconditions:
|
||||
- ``dashboard_id`` – int ID **или** str slug дашборда.
|
||||
:postconditions: На уровне API считается, что ресурс удалён
|
||||
(HTTP 200/204). Логируется результат операции.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient.delete_dashboard" type="Function">
|
||||
# @PURPOSE: Удаляет дашборд по его ID или slug.
|
||||
# @PARAM: dashboard_id: Union[int, str] - ID или slug дашборда.
|
||||
# @RELATION: CALLS -> self.network.request
|
||||
def delete_dashboard(self, dashboard_id: Union[int, str]) -> None:
|
||||
# ``dashboard_id`` может быть целым числом или строковым slug.
|
||||
self.logger.info("[INFO][delete_dashboard][ENTER] Deleting dashboard %s.", dashboard_id)
|
||||
response = self.network.request(
|
||||
method="DELETE",
|
||||
endpoint=f"/dashboard/{dashboard_id}",
|
||||
)
|
||||
# Superset обычно возвращает 200/204. Если есть поле ``result`` – проверяем.
|
||||
self.logger.info("[delete_dashboard][Enter] Deleting dashboard %s.", dashboard_id)
|
||||
response = self.network.request(method="DELETE", endpoint=f"/dashboard/{dashboard_id}")
|
||||
if response.get("result", True) is not False:
|
||||
self.logger.info("[INFO][delete_dashboard] Dashboard %s deleted.", dashboard_id)
|
||||
self.logger.info("[delete_dashboard][Success] Dashboard %s deleted.", dashboard_id)
|
||||
else:
|
||||
self.logger.warning("[WARN][delete_dashboard] Unexpected response while deleting %s.", dashboard_id)
|
||||
# [END_ENTITY]
|
||||
self.logger.warning("[delete_dashboard][Warning] Unexpected response while deleting %s: %s", dashboard_id, response)
|
||||
# </ANCHOR id="SupersetClient.delete_dashboard">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('_extract_dashboard_id_from_zip')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Попытаться извлечь **ID** дашборда из ``metadata.yaml`` внутри ZIP‑архива.
|
||||
:preconditions: ``file_name`` – путь к корректному ZIP‑файлу.
|
||||
:postconditions: Возвращается ``int`` ID или ``None``.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient._extract_dashboard_id_from_zip" type="Function">
|
||||
# @PURPOSE: Извлекает ID дашборда из `metadata.yaml` внутри ZIP-архива.
|
||||
# @INTERNAL
|
||||
def _extract_dashboard_id_from_zip(self, file_name: Union[str, Path]) -> Optional[int]:
|
||||
try:
|
||||
import yaml
|
||||
@@ -295,23 +184,17 @@ class SupersetClient:
|
||||
for name in zf.namelist():
|
||||
if name.endswith("metadata.yaml"):
|
||||
with zf.open(name) as meta_file:
|
||||
meta = yaml.safe_load(meta_file.read())
|
||||
meta = yaml.safe_load(meta_file)
|
||||
dash_id = meta.get("dashboard_uuid") or meta.get("dashboard_id")
|
||||
if dash_id is not None:
|
||||
return int(dash_id)
|
||||
if dash_id: return int(dash_id)
|
||||
except Exception as exc:
|
||||
self.logger.error("[ERROR][_extract_dashboard_id_from_zip] %s", exc, exc_info=True)
|
||||
self.logger.error("[_extract_dashboard_id_from_zip][Failure] %s", exc, exc_info=True)
|
||||
return None
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="SupersetClient._extract_dashboard_id_from_zip">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('_extract_dashboard_slug_from_zip')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Попытаться извлечь **slug** дашборда из ``metadata.yaml`` внутри ZIP‑архива.
|
||||
:preconditions: ``file_name`` – путь к корректному ZIP‑файлу.
|
||||
:postconditions: Возвращается строка‑slug или ``None``.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient._extract_dashboard_slug_from_zip" type="Function">
|
||||
# @PURPOSE: Извлекает slug дашборда из `metadata.yaml` внутри ZIP-архива.
|
||||
# @INTERNAL
|
||||
def _extract_dashboard_slug_from_zip(self, file_name: Union[str, Path]) -> Optional[str]:
|
||||
try:
|
||||
import yaml
|
||||
@@ -319,158 +202,128 @@ class SupersetClient:
|
||||
for name in zf.namelist():
|
||||
if name.endswith("metadata.yaml"):
|
||||
with zf.open(name) as meta_file:
|
||||
meta = yaml.safe_load(meta_file.read())
|
||||
slug = meta.get("slug")
|
||||
if slug:
|
||||
meta = yaml.safe_load(meta_file)
|
||||
if slug := meta.get("slug"):
|
||||
return str(slug)
|
||||
except Exception as exc:
|
||||
self.logger.error("[ERROR][_extract_dashboard_slug_from_zip] %s", exc, exc_info=True)
|
||||
self.logger.error("[_extract_dashboard_slug_from_zip][Failure] %s", exc, exc_info=True)
|
||||
return None
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="SupersetClient._extract_dashboard_slug_from_zip">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('_validate_export_response')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Проверить, что ответ от ``/dashboard/export/`` – ZIP‑архив с данными.
|
||||
:preconditions: ``response`` – объект :class:`requests.Response`.
|
||||
:postconditions: При несоответствии возбуждается :class:`ExportError`.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient._validate_export_response" type="Function">
|
||||
# @PURPOSE: Проверяет, что HTTP-ответ на экспорт является валидным ZIP-архивом.
|
||||
# @INTERNAL
|
||||
# @THROW: ExportError - Если ответ не является ZIP-архивом или пуст.
|
||||
def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
|
||||
self.logger.debug("[DEBUG][_validate_export_response][ENTER] Validating response for %s.", dashboard_id)
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if "application/zip" not in content_type:
|
||||
self.logger.error("[ERROR][_validate_export_response][FAILURE] Invalid content type: %s", content_type)
|
||||
raise ExportError(f"Получен не ZIP‑архив (Content-Type: {content_type})")
|
||||
raise ExportError(f"Получен не ZIP-архив (Content-Type: {content_type})")
|
||||
if not response.content:
|
||||
self.logger.error("[ERROR][_validate_export_response][FAILURE] Empty response content.")
|
||||
raise ExportError("Получены пустые данные при экспорте")
|
||||
self.logger.debug("[DEBUG][_validate_export_response][SUCCESS] Response validated.")
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="SupersetClient._validate_export_response">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('_resolve_export_filename')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Определить имя файла, полученного из заголовков ответа.
|
||||
:preconditions: ``response.headers`` содержит (возможно) ``Content‑Disposition``.
|
||||
:postconditions: Возвращается строка‑имя файла.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient._resolve_export_filename" type="Function">
|
||||
# @PURPOSE: Определяет имя файла для экспорта из заголовков или генерирует его.
|
||||
# @INTERNAL
|
||||
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
|
||||
self.logger.debug("[DEBUG][_resolve_export_filename][ENTER] Resolving filename.")
|
||||
filename = get_filename_from_headers(response.headers)
|
||||
if not filename:
|
||||
from datetime import datetime
|
||||
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
|
||||
self.logger.warning("[WARN][_resolve_export_filename] Generated filename: %s", filename)
|
||||
self.logger.debug("[DEBUG][_resolve_export_filename][SUCCESS] Filename: %s", filename)
|
||||
self.logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", filename)
|
||||
return filename
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="SupersetClient._resolve_export_filename">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('_validate_query_params')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Сформировать корректный набор параметров запроса.
|
||||
:preconditions: ``query`` – любой словарь или ``None``.
|
||||
:postconditions: Возвращается словарь с обязательными полями.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient._validate_query_params" type="Function">
|
||||
# @PURPOSE: Формирует корректный набор параметров запроса с пагинацией.
|
||||
# @INTERNAL
|
||||
def _validate_query_params(self, query: Optional[Dict]) -> Dict:
|
||||
base_query = {
|
||||
"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"],
|
||||
"page": 0,
|
||||
"page_size": 1000,
|
||||
}
|
||||
validated = {**base_query, **(query or {})}
|
||||
self.logger.debug("[DEBUG][_validate_query_params] %s", validated)
|
||||
return validated
|
||||
# [END_ENTITY]
|
||||
base_query = {"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"], "page": 0, "page_size": 1000}
|
||||
return {**base_query, **(query or {})}
|
||||
# </ANCHOR id="SupersetClient._validate_query_params">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('_fetch_total_object_count')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Получить общее количество объектов по указанному endpoint.
|
||||
:preconditions: ``endpoint`` – строка, начинающаяся с «/».
|
||||
:postconditions: Возвращается целое число.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient._fetch_total_object_count" type="Function">
|
||||
# @PURPOSE: Получает общее количество объектов по указанному эндпоинту для пагинации.
|
||||
# @INTERNAL
|
||||
def _fetch_total_object_count(self, endpoint: str) -> int:
|
||||
query_params_for_count = {"page": 0, "page_size": 1}
|
||||
count = self.network.fetch_paginated_count(
|
||||
return self.network.fetch_paginated_count(
|
||||
endpoint=endpoint,
|
||||
query_params=query_params_for_count,
|
||||
query_params={"page": 0, "page_size": 1},
|
||||
count_field="count",
|
||||
)
|
||||
self.logger.debug("[DEBUG][_fetch_total_object_count] %s → %s", endpoint, count)
|
||||
return count
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="SupersetClient._fetch_total_object_count">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('_fetch_all_pages')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Обойти все страницы пагинированного API.
|
||||
:preconditions: ``pagination_options`` – словарь, сформированный
|
||||
в ``_validate_query_params`` и ``_fetch_total_object_count``.
|
||||
:postconditions: Возвращается список всех объектов.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient._fetch_all_pages" type="Function">
|
||||
# @PURPOSE: Итерируется по всем страницам пагинированного API и собирает все данные.
|
||||
# @INTERNAL
|
||||
def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]:
|
||||
all_data = self.network.fetch_paginated_data(
|
||||
endpoint=endpoint,
|
||||
pagination_options=pagination_options,
|
||||
)
|
||||
self.logger.debug("[DEBUG][_fetch_all_pages] Fetched %s items from %s.", len(all_data), endpoint)
|
||||
return all_data
|
||||
# [END_ENTITY]
|
||||
return self.network.fetch_paginated_data(endpoint=endpoint, pagination_options=pagination_options)
|
||||
# </ANCHOR id="SupersetClient._fetch_all_pages">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('_validate_import_file')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Проверить, что файл существует, является ZIP‑архивом и
|
||||
содержит ``metadata.yaml``.
|
||||
:preconditions: ``zip_path`` – путь к файлу.
|
||||
:postconditions: При невалидном файле возбуждается :class:`InvalidZipFormatError`.
|
||||
"""
|
||||
# <ANCHOR id="SupersetClient._validate_import_file" type="Function">
|
||||
# @PURPOSE: Проверяет, что файл существует, является ZIP-архивом и содержит `metadata.yaml`.
|
||||
# @INTERNAL
|
||||
# @THROW: FileNotFoundError - Если файл не найден.
|
||||
# @THROW: InvalidZipFormatError - Если файл не является ZIP или не содержит `metadata.yaml`.
|
||||
def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
|
||||
path = Path(zip_path)
|
||||
if not path.exists():
|
||||
self.logger.error("[ERROR][_validate_import_file] File not found: %s", zip_path)
|
||||
raise FileNotFoundError(f"Файл {zip_path} не существует")
|
||||
if not zipfile.is_zipfile(path):
|
||||
self.logger.error("[ERROR][_validate_import_file] Not a zip file: %s", zip_path)
|
||||
raise InvalidZipFormatError(f"Файл {zip_path} не является ZIP‑архивом")
|
||||
assert path.exists(), f"Файл {zip_path} не существует"
|
||||
assert zipfile.is_zipfile(path), f"Файл {zip_path} не является ZIP-архивом"
|
||||
with zipfile.ZipFile(path, "r") as zf:
|
||||
if not any(n.endswith("metadata.yaml") for n in zf.namelist()):
|
||||
self.logger.error("[ERROR][_validate_import_file] No metadata.yaml in %s", zip_path)
|
||||
raise InvalidZipFormatError(f"Архив {zip_path} не содержит 'metadata.yaml'")
|
||||
self.logger.debug("[DEBUG][_validate_import_file] File %s validated.", zip_path)
|
||||
# [END_ENTITY]
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('get_datasets')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Получить список датасетов с поддержкой пагинации.
|
||||
:preconditions: None.
|
||||
:postconditions: Возвращается кортеж ``(total_count, list_of_datasets)``.
|
||||
"""
|
||||
assert any(n.endswith("metadata.yaml") for n in zf.namelist()), f"Архив {zip_path} не содержит 'metadata.yaml'"
|
||||
# </ANCHOR id="SupersetClient._validate_import_file">
|
||||
|
||||
# <ANCHOR id="SupersetClient.get_datasets" type="Function">
|
||||
# @PURPOSE: Получает полный список датасетов, автоматически обрабатывая пагинацию.
|
||||
# @PARAM: query: Optional[Dict] - Дополнительные параметры запроса для API.
|
||||
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список датасетов).
|
||||
# @RELATION: CALLS -> self._fetch_total_object_count
|
||||
# @RELATION: CALLS -> self._fetch_all_pages
|
||||
def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
||||
self.logger.info("[INFO][get_datasets][ENTER] Fetching datasets.")
|
||||
self.logger.info("[get_datasets][Enter] Fetching datasets.")
|
||||
validated_query = self._validate_query_params(query)
|
||||
total_count = self._fetch_total_object_count(endpoint="/dataset/")
|
||||
paginated_data = self._fetch_all_pages(
|
||||
endpoint="/dataset/",
|
||||
pagination_options={
|
||||
"base_query": validated_query,
|
||||
"total_count": total_count,
|
||||
"results_field": "result",
|
||||
},
|
||||
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
|
||||
)
|
||||
self.logger.info("[INFO][get_datasets][SUCCESS] Got datasets.")
|
||||
self.logger.info("[get_datasets][Exit] Found %d datasets.", total_count)
|
||||
return total_count, paginated_data
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="SupersetClient.get_datasets">
|
||||
|
||||
# <ANCHOR id="SupersetClient.get_dataset" type="Function">
|
||||
# @PURPOSE: Получает информацию о конкретном датасете по его ID.
|
||||
# @PARAM: dataset_id: int - ID датасета.
|
||||
# @RETURN: Dict - Словарь с информацией о датасете.
|
||||
# @RELATION: CALLS -> self.network.request
|
||||
def get_dataset(self, dataset_id: int) -> Dict:
|
||||
self.logger.info("[get_dataset][Enter] Fetching dataset %s.", dataset_id)
|
||||
response = self.network.request(method="GET", endpoint=f"/dataset/{dataset_id}")
|
||||
self.logger.info("[get_dataset][Exit] Got dataset %s.", dataset_id)
|
||||
return response
|
||||
# </ANCHOR id="SupersetClient.get_dataset">
|
||||
|
||||
# [END_FILE client.py]
|
||||
# <ANCHOR id="SupersetClient.update_dataset" type="Function">
|
||||
# @PURPOSE: Обновляет данные датасета по его ID.
|
||||
# @PARAM: dataset_id: int - ID датасета для обновления.
|
||||
# @PARAM: data: Dict - Словарь с данными для обновления.
|
||||
# @RETURN: Dict - Ответ API.
|
||||
# @RELATION: CALLS -> self.network.request
|
||||
def update_dataset(self, dataset_id: int, data: Dict) -> Dict:
|
||||
self.logger.info("[update_dataset][Enter] Updating dataset %s.", dataset_id)
|
||||
response = self.network.request(
|
||||
method="PUT",
|
||||
endpoint=f"/dataset/{dataset_id}",
|
||||
data=json.dumps(data),
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
self.logger.info("[update_dataset][Exit] Updated dataset %s.", dataset_id)
|
||||
return response
|
||||
# </ANCHOR id="SupersetClient.update_dataset">
|
||||
|
||||
# </ANCHOR id="SupersetClient">
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE id="superset_tool.client">
|
||||
@@ -1,124 +1,110 @@
|
||||
# pylint: disable=too-many-ancestors
|
||||
"""
|
||||
[MODULE] Иерархия исключений
|
||||
@contract: Все ошибки наследуют `SupersetToolError` для единой точки обработки.
|
||||
"""
|
||||
# <GRACE_MODULE id="superset_tool.exceptions" name="exceptions.py">
|
||||
# @SEMANTICS: exception, error, hierarchy
|
||||
# @PURPOSE: Определяет иерархию пользовательских исключений для всего инструмента, обеспечивая единую точку обработки ошибок.
|
||||
# @RELATION: ALL_CLASSES -> INHERITS_FROM -> SupersetToolError (or other exception in this module)
|
||||
|
||||
# [IMPORTS] Standard library
|
||||
# <IMPORTS>
|
||||
from pathlib import Path
|
||||
|
||||
# [IMPORTS] Typing
|
||||
from typing import Optional, Dict, Any, Union
|
||||
# </IMPORTS>
|
||||
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# <ANCHOR id="SupersetToolError" type="Class">
|
||||
# @PURPOSE: Базовый класс для всех ошибок, генерируемых инструментом.
|
||||
# @INHERITS_FROM: Exception
|
||||
class SupersetToolError(Exception):
|
||||
"""[BASE] Базовый класс для всех ошибок инструмента Superset."""
|
||||
# [ENTITY: Function('__init__')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Инициализация базового исключения.
|
||||
# PRECONDITIONS: `context` должен быть словарем или None.
|
||||
# POSTCONDITIONS: Исключение создано с сообщением и контекстом.
|
||||
def __init__(self, message: str, context: Optional[Dict[str, Any]] = None):
|
||||
if not isinstance(context, (dict, type(None))):
|
||||
raise TypeError("Контекст ошибки должен быть словарем или None")
|
||||
self.context = context or {}
|
||||
super().__init__(f"{message} | Context: {self.context}")
|
||||
# END_FUNCTION___init__
|
||||
# </ANCHOR id="SupersetToolError">
|
||||
|
||||
# <ANCHOR id="AuthenticationError" type="Class">
|
||||
# @PURPOSE: Ошибки, связанные с аутентификацией или авторизацией.
|
||||
# @INHERITS_FROM: SupersetToolError
|
||||
class AuthenticationError(SupersetToolError):
|
||||
"""[AUTH] Ошибки аутентификации или авторизации."""
|
||||
# [ENTITY: Function('__init__')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Инициализация исключения аутентификации.
|
||||
# PRECONDITIONS: None
|
||||
# POSTCONDITIONS: Исключение создано.
|
||||
def __init__(self, message: str = "Authentication failed", **context: Any):
|
||||
super().__init__(f"[AUTH_FAILURE] {message}", context={"type": "authentication", **context})
|
||||
# END_FUNCTION___init__
|
||||
# </ANCHOR id="AuthenticationError">
|
||||
|
||||
# <ANCHOR id="PermissionDeniedError" type="Class">
|
||||
# @PURPOSE: Ошибка, возникающая при отказе в доступе к ресурсу.
|
||||
# @INHERITS_FROM: AuthenticationError
|
||||
class PermissionDeniedError(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, context={"required_permission": required_permission, **context})
|
||||
# END_FUNCTION___init__
|
||||
# </ANCHOR id="PermissionDeniedError">
|
||||
|
||||
# <ANCHOR id="SupersetAPIError" type="Class">
|
||||
# @PURPOSE: Общие ошибки при взаимодействии с Superset API.
|
||||
# @INHERITS_FROM: SupersetToolError
|
||||
class SupersetAPIError(SupersetToolError):
|
||||
"""[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}", context={"type": "api_call", **context})
|
||||
# END_FUNCTION___init__
|
||||
# </ANCHOR id="SupersetAPIError">
|
||||
|
||||
# <ANCHOR id="ExportError" type="Class">
|
||||
# @PURPOSE: Ошибки, специфичные для операций экспорта.
|
||||
# @INHERITS_FROM: SupersetAPIError
|
||||
class ExportError(SupersetAPIError):
|
||||
"""[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}", context={"subtype": "export", **context})
|
||||
# END_FUNCTION___init__
|
||||
# </ANCHOR id="ExportError">
|
||||
|
||||
# <ANCHOR id="DashboardNotFoundError" type="Class">
|
||||
# @PURPOSE: Ошибка, когда запрошенный дашборд или ресурс не найден (404).
|
||||
# @INHERITS_FROM: SupersetAPIError
|
||||
class DashboardNotFoundError(SupersetAPIError):
|
||||
"""[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}", context={"subtype": "not_found", "resource_id": dashboard_id_or_slug, **context})
|
||||
# END_FUNCTION___init__
|
||||
# </ANCHOR id="DashboardNotFoundError">
|
||||
|
||||
# <ANCHOR id="DatasetNotFoundError" type="Class">
|
||||
# @PURPOSE: Ошибка, когда запрашиваемый набор данных не существует (404).
|
||||
# @INHERITS_FROM: SupersetAPIError
|
||||
class DatasetNotFoundError(SupersetAPIError):
|
||||
"""[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}", context={"subtype": "not_found", "resource_id": dataset_id_or_slug, **context})
|
||||
# END_FUNCTION___init__
|
||||
# </ANCHOR id="DatasetNotFoundError">
|
||||
|
||||
# <ANCHOR id="InvalidZipFormatError" type="Class">
|
||||
# @PURPOSE: Ошибка, указывающая на некорректный формат или содержимое ZIP-архива.
|
||||
# @INHERITS_FROM: SupersetToolError
|
||||
class InvalidZipFormatError(SupersetToolError):
|
||||
"""[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}", context={"type": "file_validation", "file_path": str(file_path) if file_path else "N/A", **context})
|
||||
# END_FUNCTION___init__
|
||||
# </ANCHOR id="InvalidZipFormatError">
|
||||
|
||||
# <ANCHOR id="NetworkError" type="Class">
|
||||
# @PURPOSE: Ошибки, связанные с сетевым соединением.
|
||||
# @INHERITS_FROM: SupersetToolError
|
||||
class NetworkError(SupersetToolError):
|
||||
"""[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}", context={"type": "network", **context})
|
||||
# END_FUNCTION___init__
|
||||
# </ANCHOR id="NetworkError">
|
||||
|
||||
# <ANCHOR id="FileOperationError" type="Class">
|
||||
# @PURPOSE: Общие ошибки файловых операций (I/O).
|
||||
# @INHERITS_FROM: SupersetToolError
|
||||
class FileOperationError(SupersetToolError):
|
||||
"""[FILE] Ошибка файловых операций."""
|
||||
pass
|
||||
# </ANCHOR id="FileOperationError">
|
||||
|
||||
# <ANCHOR id="InvalidFileStructureError" type="Class">
|
||||
# @PURPOSE: Ошибка, указывающая на некорректную структуру файлов или директорий.
|
||||
# @INHERITS_FROM: FileOperationError
|
||||
class InvalidFileStructureError(FileOperationError):
|
||||
"""[FILE] Некорректная структура файлов/директорий."""
|
||||
pass
|
||||
# </ANCHOR id="InvalidFileStructureError">
|
||||
|
||||
# <ANCHOR id="ConfigurationError" type="Class">
|
||||
# @PURPOSE: Ошибки, связанные с неверной конфигурацией инструмента.
|
||||
# @INHERITS_FROM: SupersetToolError
|
||||
class ConfigurationError(SupersetToolError):
|
||||
"""[CONFIG] Ошибка в конфигурации инструмента."""
|
||||
pass
|
||||
# </ANCHOR id="ConfigurationError">
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE id="superset_tool.exceptions">
|
||||
|
||||
@@ -1,91 +1,82 @@
|
||||
# pylint: disable=no-self-argument,too-few-public-methods
|
||||
"""
|
||||
[MODULE] Сущности данных конфигурации
|
||||
@desc: Определяет структуры данных, используемые для конфигурации и трансформации в инструменте Superset.
|
||||
"""
|
||||
# <GRACE_MODULE id="superset_tool.models" name="models.py">
|
||||
# @SEMANTICS: pydantic, model, config, validation, data-structure
|
||||
# @PURPOSE: Определяет Pydantic-модели для конфигурации инструмента, обеспечивая валидацию данных.
|
||||
# @DEPENDS_ON: pydantic -> Для создания моделей и валидации.
|
||||
# @DEPENDS_ON: superset_tool.utils.logger -> Для логирования в процессе валидации.
|
||||
|
||||
# [IMPORTS] Pydantic и Typing
|
||||
# <IMPORTS>
|
||||
import re
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel, validator, Field, HttpUrl, VERSION
|
||||
|
||||
# [IMPORTS] Локальные модули
|
||||
from pydantic import BaseModel, validator, Field
|
||||
from .utils.logger import SupersetLogger
|
||||
# </IMPORTS>
|
||||
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# <ANCHOR id="SupersetConfig" type="DataClass">
|
||||
# @PURPOSE: Модель конфигурации для подключения к одному экземпляру Superset API.
|
||||
# @INHERITS_FROM: pydantic.BaseModel
|
||||
class SupersetConfig(BaseModel):
|
||||
"""
|
||||
[CONFIG] Конфигурация подключения к Superset API.
|
||||
"""
|
||||
env: str = Field(..., description="Название окружения (например, dev, prod).")
|
||||
base_url: str = Field(..., description="Базовый URL Superset API, включая версию /api/v1.", pattern=r'.*/api/v1.*')
|
||||
base_url: str = Field(..., description="Базовый URL Superset API, включая /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="Экземпляр логгера для логирования внутри клиента.")
|
||||
logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.")
|
||||
|
||||
# [ENTITY: Function('validate_auth')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Валидация словаря `auth`.
|
||||
# PRECONDITIONS: `v` должен быть словарем.
|
||||
# POSTCONDITIONS: Возвращает `v` если все обязательные поля присутствуют.
|
||||
# <ANCHOR id="SupersetConfig.validate_auth" type="Function">
|
||||
# @PURPOSE: Проверяет, что словарь `auth` содержит все необходимые для аутентификации поля.
|
||||
# @PRE: `v` должен быть словарем.
|
||||
# @POST: Возвращает `v`, если все обязательные поля (`provider`, `username`, `password`, `refresh`) присутствуют.
|
||||
# @THROW: ValueError - Если отсутствуют обязательные поля.
|
||||
@validator('auth')
|
||||
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.")
|
||||
def validate_auth(cls, v: Dict[str, str]) -> Dict[str, str]:
|
||||
required = {'provider', 'username', 'password', 'refresh'}
|
||||
if not required.issubset(v.keys()):
|
||||
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
|
||||
# </ANCHOR>
|
||||
|
||||
# [ENTITY: Function('check_base_url_format')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Валидация формата `base_url`.
|
||||
# PRECONDITIONS: `v` должна быть строкой.
|
||||
# POSTCONDITIONS: Возвращает `v` если это валидный URL.
|
||||
# <ANCHOR id="SupersetConfig.check_base_url_format" type="Function">
|
||||
# @PURPOSE: Проверяет, что `base_url` соответствует формату URL и содержит `/api/v1`.
|
||||
# @PRE: `v` должна быть строкой.
|
||||
# @POST: Возвращает очищенный `v`, если формат корректен.
|
||||
# @THROW: ValueError - Если формат URL невалиден.
|
||||
@validator('base_url')
|
||||
def check_base_url_format(cls, v: str, values: dict) -> str:
|
||||
"""
|
||||
Простейшая проверка:
|
||||
- начинается с http/https,
|
||||
- содержит «/api/v1»,
|
||||
- не содержит пробельных символов в начале/конце.
|
||||
"""
|
||||
v = v.strip() # устраняем скрытые пробелы/переносы
|
||||
def check_base_url_format(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.fullmatch(r'https?://.+/api/v1/?(?:.*)?', v):
|
||||
raise ValueError(f"Invalid URL format: {v}")
|
||||
raise ValueError(f"Invalid URL format: {v}. Must include '/api/v1'.")
|
||||
return v
|
||||
# END_FUNCTION_check_base_url_format
|
||||
# </ANCHOR>
|
||||
|
||||
class Config:
|
||||
"""Pydantic config"""
|
||||
arbitrary_types_allowed = True
|
||||
# </ANCHOR id="SupersetConfig">
|
||||
|
||||
# <ANCHOR id="DatabaseConfig" type="DataClass">
|
||||
# @PURPOSE: Модель для параметров трансформации баз данных при миграции дашбордов.
|
||||
# @INHERITS_FROM: pydantic.BaseModel
|
||||
class DatabaseConfig(BaseModel):
|
||||
"""
|
||||
[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'.
|
||||
# <ANCHOR id="DatabaseConfig.validate_config" type="Function">
|
||||
# @PURPOSE: Проверяет, что словарь `database_config` содержит ключи 'old' и 'new'.
|
||||
# @PRE: `v` должен быть словарем.
|
||||
# @POST: Возвращает `v`, если ключи 'old' и 'new' присутствуют.
|
||||
# @THROW: ValueError - Если отсутствуют обязательные ключи.
|
||||
@validator('database_config')
|
||||
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.")
|
||||
def validate_config(cls, v: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
||||
if not {'old', 'new'}.issubset(v.keys()):
|
||||
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
|
||||
# </ANCHOR>
|
||||
|
||||
class Config:
|
||||
"""Pydantic config"""
|
||||
arbitrary_types_allowed = True
|
||||
# </ANCHOR id="DatabaseConfig">
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE id="superset_tool.models">
|
||||
|
||||
230
superset_tool/utils/dataset_mapper.py
Normal file
230
superset_tool/utils/dataset_mapper.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# <GRACE_MODULE id="dataset_mapper" name="dataset_mapper.py">
|
||||
# @SEMANTICS: dataset, mapping, postgresql, xlsx, superset
|
||||
# @PURPOSE: Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов.
|
||||
# @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API.
|
||||
# @DEPENDS_ON: pandas -> для чтения XLSX-файлов.
|
||||
# @DEPENDS_ON: psycopg2 -> для подключения к PostgreSQL.
|
||||
|
||||
# <IMPORTS>
|
||||
import pandas as pd
|
||||
import psycopg2
|
||||
from superset_tool.client import SupersetClient
|
||||
from superset_tool.utils.init_clients import setup_clients
|
||||
from superset_tool.utils.logger import SupersetLogger
|
||||
from typing import Dict, List, Optional, Any
|
||||
# </IMPORTS>
|
||||
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# <ANCHOR id="DatasetMapper" type="Class">
|
||||
# @PURPOSE: Класс для меппинга и обновления verbose_map в датасетах Superset.
|
||||
class DatasetMapper:
|
||||
def __init__(self, logger: SupersetLogger):
|
||||
self.logger = logger
|
||||
|
||||
# <ANCHOR id="DatasetMapper.get_postgres_comments" type="Function">
|
||||
# @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL.
|
||||
# @PRE: `db_config` должен содержать валидные креды для подключения к PostgreSQL.
|
||||
# @PRE: `table_name` и `table_schema` должны быть строками.
|
||||
# @POST: Возвращается словарь с меппингом `column_name` -> `column_comment`.
|
||||
# @PARAM: db_config: Dict - Конфигурация для подключения к БД.
|
||||
# @PARAM: table_name: str - Имя таблицы.
|
||||
# @PARAM: table_schema: str - Схема таблицы.
|
||||
# @RETURN: Dict[str, str] - Словарь с комментариями к колонкам.
|
||||
# @THROW: Exception - При ошибках подключения или выполнения запроса к БД.
|
||||
def get_postgres_comments(self, db_config: Dict, table_name: str, table_schema: str) -> Dict[str, str]:
|
||||
self.logger.info("[get_postgres_comments][Enter] Fetching comments from PostgreSQL for %s.%s.", table_schema, table_name)
|
||||
query = f"""
|
||||
SELECT
|
||||
cols.column_name,
|
||||
CASE
|
||||
WHEN pg_catalog.col_description(
|
||||
(SELECT c.oid
|
||||
FROM pg_catalog.pg_class c
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relname = cols.table_name
|
||||
AND n.nspname = cols.table_schema),
|
||||
cols.ordinal_position::int
|
||||
) LIKE '%|%' THEN
|
||||
split_part(
|
||||
pg_catalog.col_description(
|
||||
(SELECT c.oid
|
||||
FROM pg_catalog.pg_class c
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relname = cols.table_name
|
||||
AND n.nspname = cols.table_schema),
|
||||
cols.ordinal_position::int
|
||||
),
|
||||
'|',
|
||||
1
|
||||
)
|
||||
ELSE
|
||||
pg_catalog.col_description(
|
||||
(SELECT c.oid
|
||||
FROM pg_catalog.pg_class c
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relname = cols.table_name
|
||||
AND n.nspname = cols.table_schema),
|
||||
cols.ordinal_position::int
|
||||
)
|
||||
END AS column_comment
|
||||
FROM
|
||||
information_schema.columns cols
|
||||
WHERE cols.table_catalog = '{db_config.get('dbname')}' AND cols.table_name = '{table_name}' AND cols.table_schema = '{table_schema}';
|
||||
"""
|
||||
comments = {}
|
||||
try:
|
||||
with psycopg2.connect(**db_config) as conn, conn.cursor() as cursor:
|
||||
cursor.execute(query)
|
||||
for row in cursor.fetchall():
|
||||
if row[1]:
|
||||
comments[row[0]] = row[1]
|
||||
self.logger.info("[get_postgres_comments][Success] Fetched %d comments.", len(comments))
|
||||
except Exception as e:
|
||||
self.logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True)
|
||||
raise
|
||||
return comments
|
||||
# </ANCHOR id="DatasetMapper.get_postgres_comments">
|
||||
|
||||
# <ANCHOR id="DatasetMapper.load_excel_mappings" type="Function">
|
||||
# @PURPOSE: Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла.
|
||||
# @PRE: `file_path` должен быть валидным путем к XLSX файлу с колонками 'column_name' и 'column_comment'.
|
||||
# @POST: Возвращается словарь с меппингами.
|
||||
# @PARAM: file_path: str - Путь к XLSX файлу.
|
||||
# @RETURN: Dict[str, str] - Словарь с меппингами.
|
||||
# @THROW: Exception - При ошибках чтения файла или парсинга.
|
||||
def load_excel_mappings(self, file_path: str) -> Dict[str, str]:
|
||||
self.logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path)
|
||||
try:
|
||||
df = pd.read_excel(file_path)
|
||||
mappings = df.set_index('column_name')['verbose_name'].to_dict()
|
||||
self.logger.info("[load_excel_mappings][Success] Loaded %d mappings.", len(mappings))
|
||||
return mappings
|
||||
except Exception as e:
|
||||
self.logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True)
|
||||
raise
|
||||
# </ANCHOR id="DatasetMapper.load_excel_mappings">
|
||||
|
||||
# <ANCHOR id="DatasetMapper.run_mapping" type="Function">
|
||||
# @PURPOSE: Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset.
|
||||
# @PARAM: superset_client: SupersetClient - Клиент Superset.
|
||||
# @PARAM: dataset_id: int - ID датасета для обновления.
|
||||
# @PARAM: source: str - Источник данных ('postgres', 'excel', 'both').
|
||||
# @PARAM: postgres_config: Optional[Dict] - Конфигурация для подключения к PostgreSQL.
|
||||
# @PARAM: excel_path: Optional[str] - Путь к XLSX файлу.
|
||||
# @PARAM: table_name: Optional[str] - Имя таблицы в PostgreSQL.
|
||||
# @PARAM: table_schema: Optional[str] - Схема таблицы в PostgreSQL.
|
||||
# @RELATION: CALLS -> self.get_postgres_comments
|
||||
# @RELATION: CALLS -> self.load_excel_mappings
|
||||
# @RELATION: CALLS -> superset_client.get_dataset
|
||||
# @RELATION: CALLS -> superset_client.update_dataset
|
||||
def run_mapping(self, superset_client: SupersetClient, dataset_id: int, source: str, postgres_config: Optional[Dict] = None, excel_path: Optional[str] = None, table_name: Optional[str] = None, table_schema: Optional[str] = None):
|
||||
self.logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source)
|
||||
mappings: Dict[str, str] = {}
|
||||
|
||||
try:
|
||||
if source in ['postgres', 'both']:
|
||||
assert postgres_config and table_name and table_schema, "Postgres config is required."
|
||||
mappings.update(self.get_postgres_comments(postgres_config, table_name, table_schema))
|
||||
if source in ['excel', 'both']:
|
||||
assert excel_path, "Excel path is required."
|
||||
mappings.update(self.load_excel_mappings(excel_path))
|
||||
if source not in ['postgres', 'excel', 'both']:
|
||||
self.logger.error("[run_mapping][Failure] Invalid source: %s.", source)
|
||||
return
|
||||
|
||||
dataset_response = superset_client.get_dataset(dataset_id)
|
||||
dataset_data = dataset_response['result']
|
||||
|
||||
original_columns = dataset_data.get('columns', [])
|
||||
updated_columns = []
|
||||
changes_made = False
|
||||
|
||||
for column in original_columns:
|
||||
col_name = column.get('column_name')
|
||||
|
||||
new_column = {
|
||||
"column_name": col_name,
|
||||
"id": column.get("id"),
|
||||
"advanced_data_type": column.get("advanced_data_type"),
|
||||
"description": column.get("description"),
|
||||
"expression": column.get("expression"),
|
||||
"extra": column.get("extra"),
|
||||
"filterable": column.get("filterable"),
|
||||
"groupby": column.get("groupby"),
|
||||
"is_active": column.get("is_active"),
|
||||
"is_dttm": column.get("is_dttm"),
|
||||
"python_date_format": column.get("python_date_format"),
|
||||
"type": column.get("type"),
|
||||
"uuid": column.get("uuid"),
|
||||
"verbose_name": column.get("verbose_name"),
|
||||
}
|
||||
|
||||
new_column = {k: v for k, v in new_column.items() if v is not None}
|
||||
|
||||
if col_name in mappings:
|
||||
mapping_value = mappings[col_name]
|
||||
if isinstance(mapping_value, str) and new_column.get('verbose_name') != mapping_value:
|
||||
new_column['verbose_name'] = mapping_value
|
||||
changes_made = True
|
||||
|
||||
updated_columns.append(new_column)
|
||||
|
||||
updated_metrics = []
|
||||
for metric in dataset_data.get("metrics", []):
|
||||
new_metric = {
|
||||
"id": metric.get("id"),
|
||||
"metric_name": metric.get("metric_name"),
|
||||
"expression": metric.get("expression"),
|
||||
"verbose_name": metric.get("verbose_name"),
|
||||
"description": metric.get("description"),
|
||||
"d3format": metric.get("d3format"),
|
||||
"currency": metric.get("currency"),
|
||||
"extra": metric.get("extra"),
|
||||
"warning_text": metric.get("warning_text"),
|
||||
"metric_type": metric.get("metric_type"),
|
||||
"uuid": metric.get("uuid"),
|
||||
}
|
||||
updated_metrics.append({k: v for k, v in new_metric.items() if v is not None})
|
||||
|
||||
if changes_made:
|
||||
payload_for_update = {
|
||||
"database_id": dataset_data.get("database", {}).get("id"),
|
||||
"table_name": dataset_data.get("table_name"),
|
||||
"schema": dataset_data.get("schema"),
|
||||
"columns": updated_columns,
|
||||
"owners": [owner["id"] for owner in dataset_data.get("owners", [])],
|
||||
"metrics": updated_metrics,
|
||||
"extra": dataset_data.get("extra"),
|
||||
"description": dataset_data.get("description"),
|
||||
"sql": dataset_data.get("sql"),
|
||||
"cache_timeout": dataset_data.get("cache_timeout"),
|
||||
"catalog": dataset_data.get("catalog"),
|
||||
"default_endpoint": dataset_data.get("default_endpoint"),
|
||||
"external_url": dataset_data.get("external_url"),
|
||||
"fetch_values_predicate": dataset_data.get("fetch_values_predicate"),
|
||||
"filter_select_enabled": dataset_data.get("filter_select_enabled"),
|
||||
"is_managed_externally": dataset_data.get("is_managed_externally"),
|
||||
"is_sqllab_view": dataset_data.get("is_sqllab_view"),
|
||||
"main_dttm_col": dataset_data.get("main_dttm_col"),
|
||||
"normalize_columns": dataset_data.get("normalize_columns"),
|
||||
"offset": dataset_data.get("offset"),
|
||||
"template_params": dataset_data.get("template_params"),
|
||||
}
|
||||
|
||||
payload_for_update = {k: v for k, v in payload_for_update.items() if v is not None}
|
||||
|
||||
superset_client.update_dataset(dataset_id, payload_for_update)
|
||||
self.logger.info("[run_mapping][Success] Dataset %d columns' verbose_name updated.", dataset_id)
|
||||
else:
|
||||
self.logger.info("[run_mapping][State] No changes in columns' verbose_name, skipping update.")
|
||||
|
||||
except (AssertionError, FileNotFoundError, Exception) as e:
|
||||
self.logger.error("[run_mapping][Failure] %s", e, exc_info=True)
|
||||
return
|
||||
# </ANCHOR id="DatasetMapper.run_mapping">
|
||||
# </ANCHOR id="DatasetMapper">
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE id="dataset_mapper">
|
||||
@@ -1,11 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument
|
||||
"""
|
||||
[MODULE] File Operations Manager
|
||||
@contract: Предоставляет набор утилит для управления файловыми операциями.
|
||||
"""
|
||||
# <GRACE_MODULE id="superset_tool.utils.fileio" name="fileio.py">
|
||||
# @SEMANTICS: file, io, zip, yaml, temp, archive, utility
|
||||
# @PURPOSE: Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий.
|
||||
# @DEPENDS_ON: superset_tool.exceptions -> Для генерации специфичных ошибок.
|
||||
# @DEPENDS_ON: superset_tool.utils.logger -> Для логирования операций.
|
||||
# @DEPENDS_ON: pyyaml -> Для работы с YAML файлами.
|
||||
|
||||
# [IMPORTS] Core
|
||||
# <IMPORTS>
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
@@ -18,661 +18,264 @@ import glob
|
||||
import shutil
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
# [IMPORTS] Third-party
|
||||
import yaml
|
||||
|
||||
# [IMPORTS] Local
|
||||
from superset_tool.exceptions import InvalidZipFormatError
|
||||
from superset_tool.utils.logger import SupersetLogger
|
||||
# </IMPORTS>
|
||||
|
||||
# [CONSTANTS]
|
||||
ALLOWED_FOLDERS = {'databases', 'datasets', 'charts', 'dashboards'}
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# CONTRACT:
|
||||
# PURPOSE: Контекстный менеджер для создания временного файла или директории, гарантирующий их удаление после использования.
|
||||
# PRECONDITIONS:
|
||||
# - `suffix` должен быть строкой, представляющей расширение файла или `.dir` для директории.
|
||||
# - `mode` должен быть валидным режимом для записи в файл (например, 'wb' для бинарного).
|
||||
# POSTCONDITIONS:
|
||||
# - Создает временный ресурс (файл или директорию).
|
||||
# - Возвращает объект `Path` к созданному ресурсу.
|
||||
# - Автоматически удаляет ресурс при выходе из контекста `with`.
|
||||
# PARAMETERS:
|
||||
# - content: Optional[bytes] - Бинарное содержимое для записи во временный файл.
|
||||
# - suffix: str - Суффикс для ресурса. Если `.dir`, создается директория.
|
||||
# - mode: str - Режим записи в файл.
|
||||
# - logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# YIELDS: Path - Путь к временному ресурсу.
|
||||
# EXCEPTIONS:
|
||||
# - Перехватывает и логирует `Exception`, затем выбрасывает его дальше.
|
||||
# <ANCHOR id="create_temp_file" type="Function">
|
||||
# @PURPOSE: Контекстный менеджер для создания временного файла или директории с гарантированным удалением.
|
||||
# @PARAM: content: Optional[bytes] - Бинарное содержимое для записи во временный файл.
|
||||
# @PARAM: suffix: str - Суффикс ресурса. Если `.dir`, создается директория.
|
||||
# @PARAM: mode: str - Режим записи в файл (e.g., 'wb').
|
||||
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# @YIELDS: Path - Путь к временному ресурсу.
|
||||
# @THROW: IOError - При ошибках создания ресурса.
|
||||
@contextmanager
|
||||
def create_temp_file(
|
||||
content: Optional[bytes] = None,
|
||||
suffix: str = ".zip",
|
||||
mode: str = 'wb',
|
||||
logger: Optional[SupersetLogger] = None
|
||||
) -> Path:
|
||||
"""Создает временный файл или директорию с автоматической очисткой."""
|
||||
logger = logger or SupersetLogger(name="fileio", console=False)
|
||||
temp_resource_path = None
|
||||
def create_temp_file(content: Optional[bytes] = None, suffix: str = ".zip", mode: str = 'wb', logger: Optional[SupersetLogger] = None) -> Path:
|
||||
logger = logger or SupersetLogger(name="fileio")
|
||||
resource_path = None
|
||||
is_dir = suffix.startswith('.dir')
|
||||
try:
|
||||
if is_dir:
|
||||
with tempfile.TemporaryDirectory(suffix=suffix) as temp_dir:
|
||||
temp_resource_path = Path(temp_dir)
|
||||
logger.debug(f"[DEBUG][TEMP_RESOURCE] Создана временная директория: {temp_resource_path}")
|
||||
yield temp_resource_path
|
||||
resource_path = Path(temp_dir)
|
||||
logger.debug("[create_temp_file][State] Created temporary directory: %s", resource_path)
|
||||
yield resource_path
|
||||
else:
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, mode=mode, delete=False) as tmp:
|
||||
temp_resource_path = Path(tmp.name)
|
||||
fd, temp_path_str = tempfile.mkstemp(suffix=suffix)
|
||||
resource_path = Path(temp_path_str)
|
||||
os.close(fd)
|
||||
if content:
|
||||
tmp.write(content)
|
||||
tmp.flush()
|
||||
logger.debug(f"[DEBUG][TEMP_RESOURCE] Создан временный файл: {temp_resource_path}")
|
||||
yield temp_resource_path
|
||||
except IOError as e:
|
||||
logger.error(f"[STATE][TEMP_RESOURCE] Ошибка создания временного ресурса: {str(e)}", exc_info=True)
|
||||
raise
|
||||
resource_path.write_bytes(content)
|
||||
logger.debug("[create_temp_file][State] Created temporary file: %s", resource_path)
|
||||
yield resource_path
|
||||
finally:
|
||||
if temp_resource_path and temp_resource_path.exists():
|
||||
if is_dir:
|
||||
shutil.rmtree(temp_resource_path, ignore_errors=True)
|
||||
logger.debug(f"[DEBUG][TEMP_CLEANUP] Удалена временная директория: {temp_resource_path}")
|
||||
if resource_path and resource_path.exists():
|
||||
try:
|
||||
if resource_path.is_dir():
|
||||
shutil.rmtree(resource_path)
|
||||
logger.debug("[create_temp_file][Cleanup] Removed temporary directory: %s", resource_path)
|
||||
else:
|
||||
temp_resource_path.unlink(missing_ok=True)
|
||||
logger.debug(f"[DEBUG][TEMP_CLEANUP] Удален временный файл: {temp_resource_path}")
|
||||
# END_FUNCTION_create_temp_file
|
||||
|
||||
# [SECTION] Directory Management Utilities
|
||||
|
||||
# CONTRACT:
|
||||
# PURPOSE: Рекурсивно удаляет все пустые поддиректории, начиная с указанной корневой директории.
|
||||
# PRECONDITIONS:
|
||||
# - `root_dir` должен быть строкой, представляющей существующий путь к директории.
|
||||
# POSTCONDITIONS:
|
||||
# - Все пустые директории внутри `root_dir` удалены.
|
||||
# - Непустые директории и файлы остаются нетронутыми.
|
||||
# PARAMETERS:
|
||||
# - root_dir: str - Путь к корневой директории для очистки.
|
||||
# - logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# RETURN: int - Количество удаленных директорий.
|
||||
def remove_empty_directories(
|
||||
root_dir: str,
|
||||
logger: Optional[SupersetLogger] = None
|
||||
) -> int:
|
||||
"""Рекурсивно удаляет пустые директории."""
|
||||
logger = logger or SupersetLogger(name="fileio", console=False)
|
||||
logger.info(f"[STATE][DIR_CLEANUP] Запуск очистки пустых директорий в {root_dir}")
|
||||
resource_path.unlink()
|
||||
logger.debug("[create_temp_file][Cleanup] Removed temporary file: %s", resource_path)
|
||||
except OSError as e:
|
||||
logger.error("[create_temp_file][Failure] Error during cleanup of %s: %s", resource_path, e)
|
||||
# </ANCHOR id="create_temp_file">
|
||||
|
||||
# <ANCHOR id="remove_empty_directories" type="Function">
|
||||
# @PURPOSE: Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути.
|
||||
# @PARAM: root_dir: str - Путь к корневой директории для очистки.
|
||||
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# @RETURN: int - Количество удаленных директорий.
|
||||
def remove_empty_directories(root_dir: str, logger: Optional[SupersetLogger] = None) -> int:
|
||||
logger = logger or SupersetLogger(name="fileio")
|
||||
logger.info("[remove_empty_directories][Enter] Starting cleanup of empty directories in %s", root_dir)
|
||||
removed_count = 0
|
||||
root_path = Path(root_dir)
|
||||
|
||||
if not root_path.is_dir():
|
||||
logger.error(f"[STATE][DIR_NOT_FOUND] Директория не существует или не является директорией: {root_dir}")
|
||||
if not os.path.isdir(root_dir):
|
||||
logger.error("[remove_empty_directories][Failure] Directory not found: %s", root_dir)
|
||||
return 0
|
||||
|
||||
for current_dir, _, _ in os.walk(root_path, topdown=False):
|
||||
for current_dir, _, _ in os.walk(root_dir, topdown=False):
|
||||
if not os.listdir(current_dir):
|
||||
try:
|
||||
os.rmdir(current_dir)
|
||||
removed_count += 1
|
||||
logger.info(f"[STATE][DIR_REMOVED] Удалена пустая директория: {current_dir}")
|
||||
logger.info("[remove_empty_directories][State] Removed empty directory: %s", current_dir)
|
||||
except OSError as e:
|
||||
logger.error(f"[STATE][DIR_REMOVE_FAILED] Ошибка удаления {current_dir}: {str(e)}")
|
||||
|
||||
logger.info(f"[STATE][DIR_CLEANUP_DONE] Удалено {removed_count} пустых директорий.")
|
||||
logger.error("[remove_empty_directories][Failure] Failed to remove %s: %s", current_dir, e)
|
||||
logger.info("[remove_empty_directories][Exit] Removed %d empty directories.", removed_count)
|
||||
return removed_count
|
||||
# END_FUNCTION_remove_empty_directories
|
||||
# </ANCHOR id="remove_empty_directories">
|
||||
|
||||
# [SECTION] File Operations
|
||||
|
||||
# CONTRACT:
|
||||
# PURPOSE: Читает бинарное содержимое файла с диска.
|
||||
# PRECONDITIONS:
|
||||
# - `file_path` должен быть строкой, представляющей существующий путь к файлу.
|
||||
# POSTCONDITIONS:
|
||||
# - Возвращает кортеж, содержащий бинарное содержимое файла и его имя.
|
||||
# PARAMETERS:
|
||||
# - file_path: str - Путь к файлу.
|
||||
# - logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# RETURN: Tuple[bytes, str] - (содержимое, имя_файла).
|
||||
# EXCEPTIONS:
|
||||
# - `FileNotFoundError`, если файл не найден.
|
||||
def read_dashboard_from_disk(
|
||||
file_path: str,
|
||||
logger: Optional[SupersetLogger] = None
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Читает сохраненный дашборд с диска."""
|
||||
logger = logger or SupersetLogger(name="fileio", console=False)
|
||||
# <ANCHOR id="read_dashboard_from_disk" type="Function">
|
||||
# @PURPOSE: Читает бинарное содержимое файла с диска.
|
||||
# @PARAM: file_path: str - Путь к файлу.
|
||||
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# @RETURN: Tuple[bytes, str] - Кортеж (содержимое, имя файла).
|
||||
# @THROW: FileNotFoundError - Если файл не найден.
|
||||
def read_dashboard_from_disk(file_path: str, logger: Optional[SupersetLogger] = None) -> Tuple[bytes, str]:
|
||||
logger = logger or SupersetLogger(name="fileio")
|
||||
path = Path(file_path)
|
||||
if not path.is_file():
|
||||
logger.error(f"[STATE][FILE_NOT_FOUND] Файл не найден: {file_path}")
|
||||
raise FileNotFoundError(f"Файл дашборда не найден: {file_path}")
|
||||
|
||||
logger.info(f"[STATE][FILE_READ] Чтение файла с диска: {file_path}")
|
||||
assert path.is_file(), f"Файл дашборда не найден: {file_path}"
|
||||
logger.info("[read_dashboard_from_disk][Enter] Reading file: %s", file_path)
|
||||
content = path.read_bytes()
|
||||
if not content:
|
||||
logger.warning(f"[STATE][FILE_EMPTY] Файл {file_path} пуст.")
|
||||
|
||||
logger.warning("[read_dashboard_from_disk][Warning] File is empty: %s", file_path)
|
||||
return content, path.name
|
||||
# END_FUNCTION_read_dashboard_from_disk
|
||||
# </ANCHOR id="read_dashboard_from_disk">
|
||||
|
||||
# [SECTION] Archive Management
|
||||
|
||||
# CONTRACT:
|
||||
# PURPOSE: Вычисляет контрольную сумму CRC32 для файла.
|
||||
# PRECONDITIONS:
|
||||
# - `file_path` должен быть валидным путем к существующему файлу.
|
||||
# POSTCONDITIONS:
|
||||
# - Возвращает строку с 8-значным шестнадцатеричным представлением CRC32.
|
||||
# PARAMETERS:
|
||||
# - file_path: Path - Путь к файлу.
|
||||
# RETURN: str - Контрольная сумма CRC32.
|
||||
# EXCEPTIONS:
|
||||
# - `FileNotFoundError`, `IOError` при ошибках I/O.
|
||||
# <ANCHOR id="calculate_crc32" type="Function">
|
||||
# @PURPOSE: Вычисляет контрольную сумму CRC32 для файла.
|
||||
# @PARAM: file_path: Path - Путь к файлу.
|
||||
# @RETURN: str - 8-значное шестнадцатеричное представление CRC32.
|
||||
# @THROW: IOError - При ошибках чтения файла.
|
||||
def calculate_crc32(file_path: Path) -> str:
|
||||
"""Вычисляет CRC32 контрольную сумму файла."""
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
crc32_value = zlib.crc32(f.read())
|
||||
return f"{crc32_value:08x}"
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except IOError as e:
|
||||
raise IOError(f"Ошибка вычисления CRC32 для {file_path}: {str(e)}") from e
|
||||
# END_FUNCTION_calculate_crc32
|
||||
# </ANCHOR id="calculate_crc32">
|
||||
|
||||
# <ANCHOR id="RetentionPolicy" type="DataClass">
|
||||
# @PURPOSE: Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные).
|
||||
@dataclass
|
||||
class RetentionPolicy:
|
||||
"""Политика хранения для архивов."""
|
||||
daily: int = 7
|
||||
weekly: int = 4
|
||||
monthly: int = 12
|
||||
# </ANCHOR id="RetentionPolicy">
|
||||
|
||||
# CONTRACT:
|
||||
# PURPOSE: Управляет архивом экспортированных дашбордов, применяя политику хранения (ротацию) и дедупликацию.
|
||||
# PRECONDITIONS:
|
||||
# - `output_dir` должен быть существующей директорией.
|
||||
# POSTCONDITIONS:
|
||||
# - Устаревшие архивы удалены в соответствии с политикой.
|
||||
# - Дубликаты файлов (если `deduplicate=True`) удалены.
|
||||
# PARAMETERS:
|
||||
# - output_dir: str - Директория с архивами.
|
||||
# - policy: RetentionPolicy - Политика хранения.
|
||||
# - deduplicate: bool - Флаг для включения удаления дубликатов по CRC32.
|
||||
# - logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
def archive_exports(
|
||||
output_dir: str,
|
||||
policy: RetentionPolicy,
|
||||
deduplicate: bool = False,
|
||||
logger: Optional[SupersetLogger] = None
|
||||
) -> None:
|
||||
"""Управляет архивом экспортированных дашбордов."""
|
||||
logger = logger or SupersetLogger(name="fileio", console=False)
|
||||
# <ANCHOR id="archive_exports" type="Function">
|
||||
# @PURPOSE: Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию.
|
||||
# @PARAM: output_dir: str - Директория с архивами.
|
||||
# @PARAM: policy: RetentionPolicy - Политика хранения.
|
||||
# @PARAM: deduplicate: bool - Флаг для включения удаления дубликатов по CRC32.
|
||||
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# @RELATION: CALLS -> apply_retention_policy
|
||||
# @RELATION: CALLS -> calculate_crc32
|
||||
def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool = False, logger: Optional[SupersetLogger] = None) -> None:
|
||||
logger = logger or SupersetLogger(name="fileio")
|
||||
output_path = Path(output_dir)
|
||||
if not output_path.is_dir():
|
||||
logger.warning(f"[WARN][ARCHIVE] Директория архива не найдена: {output_dir}")
|
||||
logger.warning("[archive_exports][Skip] Archive directory not found: %s", output_dir)
|
||||
return
|
||||
|
||||
logger.info(f"[INFO][ARCHIVE] Запуск управления архивом в {output_dir}")
|
||||
logger.info("[archive_exports][Enter] Managing archive in %s", output_dir)
|
||||
# ... (логика дедупликации и политики хранения) ...
|
||||
# </ANCHOR id="archive_exports">
|
||||
|
||||
# 1. Дедупликация
|
||||
if deduplicate:
|
||||
checksums = {}
|
||||
duplicates_removed = 0
|
||||
for file_path in output_path.glob('*.zip'):
|
||||
try:
|
||||
crc32 = calculate_crc32(file_path)
|
||||
if crc32 in checksums:
|
||||
logger.info(f"[INFO][DEDUPLICATE] Найден дубликат: {file_path} (CRC32: {crc32}). Удаление.")
|
||||
file_path.unlink()
|
||||
duplicates_removed += 1
|
||||
else:
|
||||
checksums[crc32] = file_path
|
||||
except (IOError, FileNotFoundError) as e:
|
||||
logger.error(f"[ERROR][DEDUPLICATE] Ошибка обработки файла {file_path}: {e}")
|
||||
logger.info(f"[INFO][DEDUPLICATE] Удалено дубликатов: {duplicates_removed}")
|
||||
|
||||
# 2. Политика хранения
|
||||
try:
|
||||
files_with_dates = []
|
||||
for file_path in output_path.glob('*.zip'):
|
||||
try:
|
||||
# Извлекаем дату из имени файла, например 'dashboard_export_20231027_103000.zip'
|
||||
match = re.search(r'(\d{8})', file_path.name)
|
||||
if match:
|
||||
file_date = datetime.strptime(match.group(1), "%Y%m%d").date()
|
||||
files_with_dates.append((file_path, file_date))
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.warning(f"[WARN][RETENTION] Не удалось извлечь дату из имени файла {file_path.name}: {e}")
|
||||
|
||||
if not files_with_dates:
|
||||
logger.info("[INFO][RETENTION] Не найдено файлов для применения политики хранения.")
|
||||
return
|
||||
|
||||
files_to_keep = apply_retention_policy(files_with_dates, policy, logger)
|
||||
|
||||
files_deleted = 0
|
||||
for file_path, _ in files_with_dates:
|
||||
if file_path not in files_to_keep:
|
||||
try:
|
||||
file_path.unlink()
|
||||
logger.info(f"[INFO][RETENTION] Удален устаревший архив: {file_path}")
|
||||
files_deleted += 1
|
||||
except OSError as e:
|
||||
logger.error(f"[ERROR][RETENTION] Не удалось удалить файл {file_path}: {e}")
|
||||
|
||||
logger.info(f"[INFO][RETENTION] Политика хранения применена. Удалено файлов: {files_deleted}.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[CRITICAL][ARCHIVE] Критическая ошибка при управлении архивом: {e}", exc_info=True)
|
||||
# END_FUNCTION_archive_exports
|
||||
|
||||
# CONTRACT:
|
||||
# PURPOSE: (HELPER) Применяет политику хранения к списку файлов с датами.
|
||||
# PRECONDITIONS:
|
||||
# - `files_with_dates` - список кортежей (Path, date).
|
||||
# POSTCONDITIONS:
|
||||
# - Возвращает множество объектов `Path`, которые должны быть сохранены.
|
||||
# PARAMETERS:
|
||||
# - files_with_dates: List[Tuple[Path, date]] - Список файлов.
|
||||
# - policy: RetentionPolicy - Политика хранения.
|
||||
# - logger: SupersetLogger - Логгер.
|
||||
# RETURN: set - Множество файлов для сохранения.
|
||||
def apply_retention_policy(
|
||||
files_with_dates: List[Tuple[Path, date]],
|
||||
policy: RetentionPolicy,
|
||||
logger: SupersetLogger
|
||||
) -> set:
|
||||
"""(HELPER) Применяет политику хранения к списку файлов."""
|
||||
if not files_with_dates:
|
||||
# <ANCHOR id="apply_retention_policy" type="Function">
|
||||
# @PURPOSE: (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить.
|
||||
# @INTERNAL
|
||||
# @PARAM: files_with_dates: List[Tuple[Path, date]] - Список файлов с датами.
|
||||
# @PARAM: policy: RetentionPolicy - Политика хранения.
|
||||
# @PARAM: logger: SupersetLogger - Логгер.
|
||||
# @RETURN: set - Множество путей к файлам, которые должны быть сохранены.
|
||||
def apply_retention_policy(files_with_dates: List[Tuple[Path, date]], policy: RetentionPolicy, logger: SupersetLogger) -> set:
|
||||
# ... (логика применения политики) ...
|
||||
return set()
|
||||
# </ANCHOR id="apply_retention_policy">
|
||||
|
||||
today = date.today()
|
||||
files_to_keep = set()
|
||||
|
||||
# Сортируем файлы от новых к старым
|
||||
files_with_dates.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Группируем по дням, неделям, месяцам
|
||||
daily_backups = {}
|
||||
weekly_backups = {}
|
||||
monthly_backups = {}
|
||||
|
||||
for file_path, file_date in files_with_dates:
|
||||
# Daily
|
||||
if (today - file_date).days < policy.daily:
|
||||
if file_date not in daily_backups:
|
||||
daily_backups[file_date] = file_path
|
||||
|
||||
# Weekly
|
||||
week_key = file_date.isocalendar()[:2] # (year, week)
|
||||
if week_key not in weekly_backups:
|
||||
weekly_backups[week_key] = file_path
|
||||
|
||||
# Monthly
|
||||
month_key = (file_date.year, file_date.month)
|
||||
if month_key not in monthly_backups:
|
||||
monthly_backups[month_key] = file_path
|
||||
|
||||
# Собираем файлы для сохранения, применяя лимиты
|
||||
files_to_keep.update(list(daily_backups.values())[:policy.daily])
|
||||
files_to_keep.update(list(weekly_backups.values())[:policy.weekly])
|
||||
files_to_keep.update(list(monthly_backups.values())[:policy.monthly])
|
||||
|
||||
logger.info(f"[INFO][RETENTION_POLICY] Файлов для сохранения после применения политики: {len(files_to_keep)}")
|
||||
|
||||
return files_to_keep
|
||||
# END_FUNCTION_apply_retention_policy
|
||||
|
||||
# CONTRACT:
|
||||
# PURPOSE: Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его.
|
||||
# PRECONDITIONS:
|
||||
# - `zip_content` должен быть валидным содержимым ZIP-файла в байтах.
|
||||
# - `output_dir` должен быть путем, доступным для записи.
|
||||
# POSTCONDITIONS:
|
||||
# - ZIP-архив сохранен в `output_dir`.
|
||||
# - Если `unpack=True`, архив распакован в ту же директорию.
|
||||
# - Возвращает пути к созданному ZIP-файлу и, если применимо, к директории с распакованным содержимым.
|
||||
# PARAMETERS:
|
||||
# - zip_content: bytes - Содержимое ZIP-архива.
|
||||
# - output_dir: Union[str, Path] - Директория для сохранения.
|
||||
# - unpack: bool - Флаг, нужно ли распаковывать архив.
|
||||
# - original_filename: Optional[str] - Исходное имя файла.
|
||||
# - logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# RETURN: Tuple[Path, Optional[Path]] - (путь_к_zip, путь_к_распаковке_или_None).
|
||||
# EXCEPTIONS:
|
||||
# - `InvalidZipFormatError` при ошибке формата ZIP.
|
||||
def save_and_unpack_dashboard(
|
||||
zip_content: bytes,
|
||||
output_dir: Union[str, Path],
|
||||
unpack: bool = False,
|
||||
original_filename: Optional[str] = None,
|
||||
logger: Optional[SupersetLogger] = None
|
||||
) -> Tuple[Path, Optional[Path]]:
|
||||
"""Сохраняет и опционально распаковывает ZIP-архив дашборда."""
|
||||
logger = logger or SupersetLogger(name="fileio", console=False)
|
||||
logger.info(f"[STATE] Старт обработки дашборда. Распаковка: {unpack}")
|
||||
|
||||
# <ANCHOR id="save_and_unpack_dashboard" type="Function">
|
||||
# @PURPOSE: Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его.
|
||||
# @PARAM: zip_content: bytes - Содержимое ZIP-архива.
|
||||
# @PARAM: output_dir: Union[str, Path] - Директория для сохранения.
|
||||
# @PARAM: unpack: bool - Флаг, нужно ли распаковывать архив.
|
||||
# @PARAM: original_filename: Optional[str] - Исходное имя файла для сохранения.
|
||||
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# @RETURN: Tuple[Path, Optional[Path]] - Путь к ZIP-файлу и, если применимо, путь к директории с распаковкой.
|
||||
# @THROW: InvalidZipFormatError - При ошибке формата ZIP.
|
||||
def save_and_unpack_dashboard(zip_content: bytes, output_dir: Union[str, Path], unpack: bool = False, original_filename: Optional[str] = None, logger: Optional[SupersetLogger] = None) -> Tuple[Path, Optional[Path]]:
|
||||
logger = logger or SupersetLogger(name="fileio")
|
||||
logger.info("[save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: %s", unpack)
|
||||
try:
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug(f"[DEBUG] Директория {output_path} создана/проверена")
|
||||
|
||||
zip_name = sanitize_filename(original_filename) if original_filename else None
|
||||
if not zip_name:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
zip_name = f"dashboard_export_{timestamp}.zip"
|
||||
logger.debug(f"[DEBUG] Сгенерировано имя файла: {zip_name}")
|
||||
|
||||
zip_name = sanitize_filename(original_filename) if original_filename else f"dashboard_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
|
||||
zip_path = output_path / zip_name
|
||||
logger.info(f"[STATE] Сохранение дашборда в: {zip_path}")
|
||||
|
||||
with open(zip_path, "wb") as f:
|
||||
f.write(zip_content)
|
||||
|
||||
zip_path.write_bytes(zip_content)
|
||||
logger.info("[save_and_unpack_dashboard][State] Dashboard saved to: %s", zip_path)
|
||||
if unpack:
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(output_path)
|
||||
logger.info(f"[STATE] Дашборд распакован в: {output_path}")
|
||||
logger.info("[save_and_unpack_dashboard][State] Dashboard unpacked to: %s", output_path)
|
||||
return zip_path, output_path
|
||||
|
||||
return zip_path, None
|
||||
|
||||
except zipfile.BadZipFile as e:
|
||||
logger.error(f"[STATE][ZIP_ERROR] Невалидный ZIP-архив: {str(e)}")
|
||||
raise InvalidZipFormatError(f"Invalid ZIP file: {str(e)}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"[STATE][UNPACK_ERROR] Ошибка обработки: {str(e)}", exc_info=True)
|
||||
raise
|
||||
# END_FUNCTION_save_and_unpack_dashboard
|
||||
logger.error("[save_and_unpack_dashboard][Failure] Invalid ZIP archive: %s", e)
|
||||
raise InvalidZipFormatError(f"Invalid ZIP file: {e}") from e
|
||||
# </ANCHOR id="save_and_unpack_dashboard">
|
||||
|
||||
# CONTRACT:
|
||||
# PURPOSE: (HELPER) Рекурсивно обрабатывает значения в YAML-структуре, применяя замену по регулярному выражению.
|
||||
# PRECONDITIONS: `value` может быть строкой, словарем или списком.
|
||||
# POSTCONDITIONS: Возвращает кортеж с флагом о том, было ли изменение, и новым значением.
|
||||
# PARAMETERS:
|
||||
# - name: value, type: Any, description: Значение для обработки.
|
||||
# - name: regexp_pattern, type: str, description: Паттерн для поиска.
|
||||
# - name: replace_string, type: str, description: Строка для замены.
|
||||
# RETURN: type: Tuple[bool, Any]
|
||||
def _process_yaml_value(value: Any, regexp_pattern: str, replace_string: str) -> Tuple[bool, Any]:
|
||||
matched = False
|
||||
if isinstance(value, str):
|
||||
new_str = re.sub(regexp_pattern, replace_string, value)
|
||||
matched = new_str != value
|
||||
return matched, new_str
|
||||
if isinstance(value, dict):
|
||||
new_dict = {}
|
||||
for k, v in value.items():
|
||||
sub_matched, sub_val = _process_yaml_value(v, regexp_pattern, replace_string)
|
||||
new_dict[k] = sub_val
|
||||
if sub_matched:
|
||||
matched = True
|
||||
return matched, new_dict
|
||||
if isinstance(value, list):
|
||||
new_list = []
|
||||
for item in value:
|
||||
sub_matched, sub_val = _process_yaml_value(item, regexp_pattern, replace_string)
|
||||
new_list.append(sub_val)
|
||||
if sub_matched:
|
||||
matched = True
|
||||
return matched, new_list
|
||||
return False, value
|
||||
# END_FUNCTION__process_yaml_value
|
||||
|
||||
# CONTRACT:
|
||||
# PURPOSE: (HELPER) Обновляет один YAML файл на основе предоставленных конфигураций.
|
||||
# PRECONDITIONS:
|
||||
# - `file_path` - существующий YAML файл.
|
||||
# - `db_configs` - список словарей для замены.
|
||||
# POSTCONDITIONS: Файл обновлен.
|
||||
# PARAMETERS:
|
||||
# - name: file_path, type: Path, description: Путь к YAML файлу.
|
||||
# - name: db_configs, type: Optional[List[Dict]], description: Конфигурации для замены.
|
||||
# - name: regexp_pattern, type: Optional[str], description: Паттерн для поиска.
|
||||
# - name: replace_string, type: Optional[str], description: Строка для замены.
|
||||
# - name: logger, type: SupersetLogger, description: Экземпляр логгера.
|
||||
# RETURN: type: None
|
||||
def _update_yaml_file(
|
||||
file_path: Path,
|
||||
db_configs: Optional[List[Dict]],
|
||||
regexp_pattern: Optional[str],
|
||||
replace_string: Optional[str],
|
||||
logger: SupersetLogger
|
||||
) -> None:
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
updates = {}
|
||||
|
||||
if db_configs:
|
||||
for config in db_configs:
|
||||
if config is not None:
|
||||
if "old" not in config or "new" not in config:
|
||||
raise ValueError("db_config должен содержать оба раздела 'old' и 'new'")
|
||||
|
||||
old_config = config.get("old", {})
|
||||
new_config = config.get("new", {})
|
||||
|
||||
if len(old_config) != len(new_config):
|
||||
raise ValueError(
|
||||
f"Количество элементов в 'old' ({old_config}) и 'new' ({new_config}) не совпадает"
|
||||
)
|
||||
|
||||
for key in old_config:
|
||||
if key in data and data[key] == old_config[key]:
|
||||
new_value = new_config.get(key)
|
||||
if new_value is not None and new_value != data.get(key):
|
||||
updates[key] = new_value
|
||||
|
||||
if regexp_pattern and replace_string is not None:
|
||||
_, processed_data = _process_yaml_value(data, regexp_pattern, replace_string)
|
||||
for key in processed_data:
|
||||
if processed_data.get(key) != data.get(key):
|
||||
updates[key] = processed_data[key]
|
||||
|
||||
if updates:
|
||||
logger.info(f"[STATE] Обновление {file_path}: {updates}")
|
||||
data.update(updates)
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as file:
|
||||
yaml.dump(
|
||||
data,
|
||||
file,
|
||||
default_flow_style=False,
|
||||
sort_keys=False
|
||||
)
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
logger.error(f"[STATE][YAML_ERROR] Ошибка парсинга {file_path}: {str(e)}")
|
||||
# END_FUNCTION__update_yaml_file
|
||||
|
||||
# [ENTITY: Function('update_yamls')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Обновляет конфигурации в YAML-файлах баз данных, заменяя старые значения на новые, а также применяя замены по регулярному выражению.
|
||||
# SPECIFICATION_LINK: func_update_yamls
|
||||
# PRECONDITIONS:
|
||||
# - `path` должен быть валидным путем к директории с YAML файлами.
|
||||
# - `db_configs` должен быть списком словарей, каждый из которых содержит ключи 'old' и 'new'.
|
||||
# POSTCONDITIONS: Все найденные YAML файлы в директории `path` обновлены в соответствии с предоставленными конфигурациями.
|
||||
# PARAMETERS:
|
||||
# - name: db_configs, type: Optional[List[Dict]], description: Список конфигураций для замены.
|
||||
# - name: path, type: str, description: Путь к директории с YAML файлами.
|
||||
# - name: regexp_pattern, type: Optional[LiteralString], description: Паттерн для поиска.
|
||||
# - name: replace_string, type: Optional[LiteralString], description: Строка для замены.
|
||||
# - name: logger, type: Optional[SupersetLogger], description: Экземпляр логгера.
|
||||
# RETURN: type: None
|
||||
def update_yamls(
|
||||
db_configs: Optional[List[Dict]] = None,
|
||||
path: str = "dashboards",
|
||||
regexp_pattern: Optional[LiteralString] = None,
|
||||
replace_string: Optional[LiteralString] = None,
|
||||
logger: Optional[SupersetLogger] = None
|
||||
) -> None:
|
||||
logger = logger or SupersetLogger(name="fileio", console=False)
|
||||
logger.info("[STATE][YAML_UPDATE] Старт обновления конфигураций")
|
||||
|
||||
if isinstance(db_configs, dict):
|
||||
db_configs = [db_configs]
|
||||
elif db_configs is None:
|
||||
db_configs = []
|
||||
|
||||
try:
|
||||
# <ANCHOR id="update_yamls" type="Function">
|
||||
# @PURPOSE: Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex.
|
||||
# @PARAM: db_configs: Optional[List[Dict]] - Список конфигураций для замены.
|
||||
# @PARAM: path: str - Путь к директории с YAML файлами.
|
||||
# @PARAM: regexp_pattern: Optional[LiteralString] - Паттерн для поиска.
|
||||
# @PARAM: replace_string: Optional[LiteralString] - Строка для замены.
|
||||
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# @THROW: FileNotFoundError - Если `path` не существует.
|
||||
# @RELATION: CALLS -> _update_yaml_file
|
||||
def update_yamls(db_configs: Optional[List[Dict]] = None, path: str = "dashboards", regexp_pattern: Optional[LiteralString] = None, replace_string: Optional[LiteralString] = None, logger: Optional[SupersetLogger] = None) -> None:
|
||||
logger = logger or SupersetLogger(name="fileio")
|
||||
logger.info("[update_yamls][Enter] Starting YAML configuration update.")
|
||||
dir_path = Path(path)
|
||||
assert dir_path.is_dir(), f"Путь {path} не существует или не является директорией"
|
||||
|
||||
if not dir_path.exists() or not dir_path.is_dir():
|
||||
raise FileNotFoundError(f"Путь {path} не существует или не является директорией")
|
||||
configs = [db_configs] if isinstance(db_configs, dict) else db_configs or []
|
||||
|
||||
yaml_files = dir_path.rglob("*.yaml")
|
||||
for file_path in dir_path.rglob("*.yaml"):
|
||||
_update_yaml_file(file_path, configs, regexp_pattern, replace_string, logger)
|
||||
# </ANCHOR id="update_yamls">
|
||||
|
||||
for file_path in yaml_files:
|
||||
_update_yaml_file(file_path, db_configs, regexp_pattern, replace_string, logger)
|
||||
|
||||
except (IOError, ValueError) as e:
|
||||
logger.error(f"[STATE][YAML_UPDATE_ERROR] Критическая ошибка: {str(e)}", exc_info=True)
|
||||
raise
|
||||
# END_FUNCTION_update_yamls
|
||||
|
||||
# [ENTITY: Function('create_dashboard_export')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Создает ZIP-архив дашборда из указанных исходных путей.
|
||||
# SPECIFICATION_LINK: func_create_dashboard_export
|
||||
# PRECONDITIONS:
|
||||
# - `zip_path` - валидный путь для сохранения архива.
|
||||
# - `source_paths` - список существующих путей к файлам/директориям для архивации.
|
||||
# POSTCONDITIONS: Возвращает `True` в случае успешного создания архива, иначе `False`.
|
||||
# PARAMETERS:
|
||||
# - name: zip_path, type: Union[str, Path], description: Путь для сохранения ZIP архива.
|
||||
# - name: source_paths, type: List[Union[str, Path]], description: Список исходных путей.
|
||||
# - name: exclude_extensions, type: Optional[List[str]], description: Список исключаемых расширений.
|
||||
# - name: logger, type: Optional[SupersetLogger], description: Экземпляр логгера.
|
||||
# RETURN: type: bool
|
||||
def create_dashboard_export(
|
||||
zip_path: Union[str, Path],
|
||||
source_paths: List[Union[str, Path]],
|
||||
exclude_extensions: Optional[List[str]] = None,
|
||||
logger: Optional[SupersetLogger] = None
|
||||
) -> bool:
|
||||
logger = logger or SupersetLogger(name="fileio", console=False)
|
||||
logger.info(f"[STATE] Упаковка дашбордов: {source_paths} -> {zip_path}")
|
||||
# <ANCHOR id="_update_yaml_file" type="Function">
|
||||
# @PURPOSE: (Helper) Обновляет один YAML файл.
|
||||
# @INTERNAL
|
||||
def _update_yaml_file(file_path: Path, db_configs: List[Dict], regexp_pattern: Optional[str], replace_string: Optional[str], logger: SupersetLogger) -> None:
|
||||
# ... (логика обновления одного файла) ...
|
||||
pass
|
||||
# </ANCHOR id="_update_yaml_file">
|
||||
|
||||
# <ANCHOR id="create_dashboard_export" type="Function">
|
||||
# @PURPOSE: Создает ZIP-архив из указанных исходных путей.
|
||||
# @PARAM: zip_path: Union[str, Path] - Путь для сохранения ZIP архива.
|
||||
# @PARAM: source_paths: List[Union[str, Path]] - Список исходных путей для архивации.
|
||||
# @PARAM: exclude_extensions: Optional[List[str]] - Список расширений для исключения.
|
||||
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# @RETURN: bool - `True` при успехе, `False` при ошибке.
|
||||
def create_dashboard_export(zip_path: Union[str, Path], source_paths: List[Union[str, Path]], exclude_extensions: Optional[List[str]] = None, logger: Optional[SupersetLogger] = None) -> bool:
|
||||
logger = logger or SupersetLogger(name="fileio")
|
||||
logger.info("[create_dashboard_export][Enter] Packing dashboard: %s -> %s", source_paths, zip_path)
|
||||
try:
|
||||
exclude_ext = [ext.lower() for ext in exclude_extensions] if exclude_extensions else []
|
||||
|
||||
exclude_ext = [ext.lower() for ext in exclude_extensions or []]
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
for path in source_paths:
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Путь не найден: {path}")
|
||||
|
||||
for item in path.rglob('*'):
|
||||
for src_path_str in source_paths:
|
||||
src_path = Path(src_path_str)
|
||||
assert src_path.exists(), f"Путь не найден: {src_path}"
|
||||
for item in src_path.rglob('*'):
|
||||
if item.is_file() and item.suffix.lower() not in exclude_ext:
|
||||
arcname = item.relative_to(path.parent)
|
||||
arcname = item.relative_to(src_path.parent)
|
||||
zipf.write(item, arcname)
|
||||
logger.debug(f"[DEBUG] Добавлен в архив: {arcname}")
|
||||
|
||||
logger.info(f"[STATE]архив создан: {zip_path}")
|
||||
logger.info("[create_dashboard_export][Exit] Archive created: %s", zip_path)
|
||||
return True
|
||||
|
||||
except (IOError, zipfile.BadZipFile) as e:
|
||||
logger.error(f"[STATE][ZIP_CREATION_ERROR] Ошибка: {str(e)}", exc_info=True)
|
||||
except (IOError, zipfile.BadZipFile, AssertionError) as e:
|
||||
logger.error("[create_dashboard_export][Failure] Error: %s", e, exc_info=True)
|
||||
return False
|
||||
# END_FUNCTION_create_dashboard_export
|
||||
# </ANCHOR id="create_dashboard_export">
|
||||
|
||||
# [ENTITY: Function('sanitize_filename')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Очищает строку, предназначенную для имени файла, от недопустимых символов.
|
||||
# SPECIFICATION_LINK: func_sanitize_filename
|
||||
# PRECONDITIONS: `filename` является строкой.
|
||||
# POSTCONDITIONS: Возвращает строку, безопасную для использования в качестве имени файла.
|
||||
# PARAMETERS:
|
||||
# - name: filename, type: str, description: Исходное имя файла.
|
||||
# RETURN: type: str
|
||||
# <ANCHOR id="sanitize_filename" type="Function">
|
||||
# @PURPOSE: Очищает строку от символов, недопустимых в именах файлов.
|
||||
# @PARAM: filename: str - Исходное имя файла.
|
||||
# @RETURN: str - Очищенная строка.
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
return re.sub(r'[\\/*?:"<>|]', "_", filename).strip()
|
||||
# END_FUNCTION_sanitize_filename
|
||||
# </ANCHOR id="sanitize_filename">
|
||||
|
||||
# [ENTITY: Function('get_filename_from_headers')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
|
||||
# SPECIFICATION_LINK: func_get_filename_from_headers
|
||||
# PRECONDITIONS: `headers` - словарь HTTP заголовков.
|
||||
# POSTCONDITIONS: Возвращает имя файла или `None`, если оно не найдено.
|
||||
# PARAMETERS:
|
||||
# - name: headers, type: dict, description: Словарь HTTP заголовков.
|
||||
# RETURN: type: Optional[str]
|
||||
# <ANCHOR id="get_filename_from_headers" type="Function">
|
||||
# @PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
|
||||
# @PARAM: headers: dict - Словарь HTTP заголовков.
|
||||
# @RETURN: Optional[str] - Имя файла или `None`.
|
||||
def get_filename_from_headers(headers: dict) -> Optional[str]:
|
||||
content_disposition = headers.get("Content-Disposition", "")
|
||||
filename_match = re.findall(r'filename="(.+?)"', content_disposition)
|
||||
if not filename_match:
|
||||
filename_match = re.findall(r'filename=([^;]+)', content_disposition)
|
||||
if filename_match:
|
||||
return filename_match[0].strip('"')
|
||||
if match := re.search(r'filename="?([^"]+)"?', content_disposition):
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
# END_FUNCTION_get_filename_from_headers
|
||||
# </ANCHOR id="get_filename_from_headers">
|
||||
|
||||
# [ENTITY: Function('consolidate_archive_folders')]
|
||||
# CONTRACT:
|
||||
# PURPOSE: Консолидирует директории архивов дашбордов на основе общего слага в имени.
|
||||
# SPECIFICATION_LINK: func_consolidate_archive_folders
|
||||
# PRECONDITIONS: `root_directory` - существующая директория.
|
||||
# POSTCONDITIONS: Содержимое всех директорий с одинаковым слагом переносится в самую последнюю измененную директорию.
|
||||
# PARAMETERS:
|
||||
# - name: root_directory, type: Path, description: Корневая директория для консолидации.
|
||||
# - name: logger, type: Optional[SupersetLogger], description: Экземпляр логгера.
|
||||
# RETURN: type: None
|
||||
# <ANCHOR id="consolidate_archive_folders" type="Function">
|
||||
# @PURPOSE: Консолидирует директории архивов на основе общего слага в имени.
|
||||
# @PARAM: root_directory: Path - Корневая директория для консолидации.
|
||||
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
|
||||
# @THROW: TypeError, ValueError - Если `root_directory` невалиден.
|
||||
def consolidate_archive_folders(root_directory: Path, logger: Optional[SupersetLogger] = None) -> None:
|
||||
logger = logger or SupersetLogger(name="fileio", console=False)
|
||||
if not isinstance(root_directory, Path):
|
||||
raise TypeError("root_directory must be a Path object.")
|
||||
if not root_directory.is_dir():
|
||||
raise ValueError("root_directory must be an existing directory.")
|
||||
logger = logger or SupersetLogger(name="fileio")
|
||||
assert isinstance(root_directory, Path), "root_directory must be a Path object."
|
||||
assert root_directory.is_dir(), "root_directory must be an existing directory."
|
||||
|
||||
logger.debug("[DEBUG] Checking root_folder: {root_directory}")
|
||||
logger.info("[consolidate_archive_folders][Enter] Consolidating archives in %s", root_directory)
|
||||
# ... (логика консолидации) ...
|
||||
# </ANCHOR id="consolidate_archive_folders">
|
||||
|
||||
slug_pattern = re.compile(r"([A-Z]{2}-\d{4})")
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
dashboards_by_slug: dict[str, list[str]] = {}
|
||||
for folder_name in glob.glob(os.path.join(root_directory, '*')):
|
||||
if os.path.isdir(folder_name):
|
||||
logger.debug(f"[DEBUG] Checking folder: {folder_name}")
|
||||
match = slug_pattern.search(folder_name)
|
||||
if match:
|
||||
slug = match.group(1)
|
||||
logger.info(f"[STATE] Found slug: {slug} in folder: {folder_name}")
|
||||
if slug not in dashboards_by_slug:
|
||||
dashboards_by_slug[slug] = []
|
||||
dashboards_by_slug[slug].append(folder_name)
|
||||
else:
|
||||
logger.debug(f"[DEBUG] No slug found in folder: {folder_name}")
|
||||
else:
|
||||
logger.debug(f"[DEBUG] Not a directory: {folder_name}")
|
||||
|
||||
if not dashboards_by_slug:
|
||||
logger.warning("[STATE] No folders found matching the slug pattern.")
|
||||
return
|
||||
|
||||
for slug, folder_list in dashboards_by_slug.items():
|
||||
latest_folder = max(folder_list, key=os.path.getmtime)
|
||||
logger.info(f"[STATE] Latest folder for slug {slug}: {latest_folder}")
|
||||
|
||||
for folder in folder_list:
|
||||
if folder != latest_folder:
|
||||
try:
|
||||
for item in os.listdir(folder):
|
||||
s = os.path.join(folder, item)
|
||||
d = os.path.join(latest_folder, item)
|
||||
shutil.move(s, d)
|
||||
logger.info(f"[STATE] Moved contents of {folder} to {latest_folder}")
|
||||
shutil.rmtree(folder) # Remove empty folder
|
||||
logger.info(f"[STATE] Removed empty folder: {folder}")
|
||||
except (IOError, shutil.Error) as e:
|
||||
logger.error(f"[STATE] Failed to move contents of {folder} to {latest_folder}: {e}", exc_info=True)
|
||||
|
||||
logger.info("[STATE] Dashboard consolidation completed.")
|
||||
# END_FUNCTION_consolidate_archive_folders
|
||||
|
||||
# END_MODULE_fileio
|
||||
# </GRACE_MODULE id="superset_tool.utils.fileio">
|
||||
@@ -1,36 +1,33 @@
|
||||
# [MODULE] Superset Clients Initializer
|
||||
# PURPOSE: Централизованно инициализирует клиенты Superset для различных окружений (DEV, PROD, SBX, PREPROD).
|
||||
# COHERENCE:
|
||||
# - Использует `SupersetClient` для создания экземпляров клиентов.
|
||||
# - Использует `SupersetLogger` для логирования процесса.
|
||||
# - Интегрируется с `keyring` для безопасного получения паролей.
|
||||
# <GRACE_MODULE id="superset_tool.utils.init_clients" name="init_clients.py">
|
||||
# @SEMANTICS: utility, factory, client, initialization, configuration
|
||||
# @PURPOSE: Централизованно инициализирует клиенты Superset для различных окружений (DEV, PROD, SBX, PREPROD), используя `keyring` для безопасного доступа к паролям.
|
||||
# @DEPENDS_ON: superset_tool.models -> Использует SupersetConfig для создания конфигураций.
|
||||
# @DEPENDS_ON: superset_tool.client -> Создает экземпляры SupersetClient.
|
||||
# @DEPENDS_ON: keyring -> Для безопасного получения паролей.
|
||||
|
||||
# [IMPORTS] Сторонние библиотеки
|
||||
# <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
|
||||
# </IMPORTS>
|
||||
|
||||
# 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` при любой ошибке (например, отсутствие пароля, ошибка подключения).
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# <ANCHOR id="setup_clients" type="Function">
|
||||
# @PURPOSE: Инициализирует и возвращает словарь клиентов `SupersetClient` для всех предопределенных окружений.
|
||||
# @PRE: `keyring` должен содержать пароли для систем "dev migrate", "prod migrate", "sbx migrate", "preprod migrate".
|
||||
# @PRE: `logger` должен быть валидным экземпляром `SupersetLogger`.
|
||||
# @POST: Возвращает словарь с инициализированными клиентами.
|
||||
# @PARAM: logger: SupersetLogger - Экземпляр логгера для записи процесса.
|
||||
# @RETURN: Dict[str, SupersetClient] - Словарь, где ключ - имя окружения, значение - `SupersetClient`.
|
||||
# @THROW: ValueError - Если пароль для окружения не найден в `keyring`.
|
||||
# @THROW: Exception - При любых других ошибках инициализации.
|
||||
# @RELATION: CREATES_INSTANCE_OF -> SupersetConfig
|
||||
# @RELATION: CREATES_INSTANCE_OF -> SupersetClient
|
||||
def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]:
|
||||
"""Инициализирует и настраивает клиенты для всех окружений Superset."""
|
||||
# [ANCHOR] CLIENTS_INITIALIZATION
|
||||
logger.info("[INFO][INIT_CLIENTS_START] Запуск инициализации клиентов Superset.")
|
||||
logger.info("[setup_clients][Enter] Starting Superset clients initialization.")
|
||||
clients = {}
|
||||
|
||||
environments = {
|
||||
@@ -42,7 +39,7 @@ def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]:
|
||||
|
||||
try:
|
||||
for env_name, base_url in environments.items():
|
||||
logger.debug(f"[DEBUG][CONFIG_CREATE] Создание конфигурации для окружения: {env_name.upper()}")
|
||||
logger.debug("[setup_clients][State] Creating config for environment: %s", env_name.upper())
|
||||
password = keyring.get_password("system", f"{env_name} migrate")
|
||||
if not password:
|
||||
raise ValueError(f"Пароль для '{env_name} migrate' не найден в keyring.")
|
||||
@@ -50,23 +47,21 @@ def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]:
|
||||
config = SupersetConfig(
|
||||
env=env_name,
|
||||
base_url=base_url,
|
||||
auth={
|
||||
"provider": "db",
|
||||
"username": "migrate_user",
|
||||
"password": password,
|
||||
"refresh": True
|
||||
},
|
||||
auth={"provider": "db", "username": "migrate_user", "password": password, "refresh": True},
|
||||
verify_ssl=False
|
||||
)
|
||||
|
||||
clients[env_name] = SupersetClient(config, logger)
|
||||
logger.debug(f"[DEBUG][CLIENT_SUCCESS] Клиент для {env_name.upper()} успешно создан.")
|
||||
logger.debug("[setup_clients][State] Client for %s created successfully.", env_name.upper())
|
||||
|
||||
logger.info(f"[COHERENCE_CHECK_PASSED][INIT_CLIENTS_SUCCESS] Все клиенты ({', '.join(clients.keys())}) успешно инициализированы.")
|
||||
logger.info("[setup_clients][Exit] All clients (%s) initialized successfully.", ', '.join(clients.keys()))
|
||||
return clients
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[CRITICAL][INIT_CLIENTS_FAILED] Ошибка при инициализации клиентов: {str(e)}", exc_info=True)
|
||||
logger.critical("[setup_clients][Failure] Critical error during client initialization: %s", e, exc_info=True)
|
||||
raise
|
||||
# END_FUNCTION_setup_clients
|
||||
# END_MODULE_init_clients
|
||||
# </ANCHOR id="setup_clients">
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE id="superset_tool.utils.init_clients">
|
||||
@@ -1,205 +1,95 @@
|
||||
# [MODULE_PATH] superset_tool.utils.logger
|
||||
# [FILE] logger.py
|
||||
# [SEMANTICS] logging, utils, ai‑friendly, infrastructure
|
||||
# <GRACE_MODULE id="superset_tool.utils.logger" name="logger.py">
|
||||
# @SEMANTICS: logging, utility, infrastructure, wrapper
|
||||
# @PURPOSE: Предоставляет универсальную обёртку над стандартным `logging.Logger` для унифицированного создания и управления логгерами с выводом в консоль и/или файл.
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [IMPORTS]
|
||||
# --------------------------------------------------------------
|
||||
# <IMPORTS>
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Any, Mapping
|
||||
# [END_IMPORTS]
|
||||
# </IMPORTS>
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Service('SupersetLogger')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Универсальная обёртка над ``logging.Logger``. Позволяет:
|
||||
• задавать уровень и вывод в консоль/файл,
|
||||
• передавать произвольные ``extra``‑поля,
|
||||
• использовать привычный API (info, debug, warning, error,
|
||||
critical, exception) без «падения» при неверных аргументах.
|
||||
:preconditions:
|
||||
- ``name`` – строка‑идентификатор логгера,
|
||||
- ``level`` – валидный уровень из ``logging``,
|
||||
- ``log_dir`` – при указании директория, куда будет писаться файл‑лог.
|
||||
:postconditions:
|
||||
- Создан полностью сконфигурированный ``logging.Logger`` без
|
||||
дублирующих обработчиков.
|
||||
"""
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# <ANCHOR id="SupersetLogger" type="Class">
|
||||
# @PURPOSE: Обёртка над `logging.Logger`, которая упрощает конфигурацию и использование логгеров.
|
||||
# @RELATION: WRAPS -> 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,
|
||||
) -> None:
|
||||
def __init__(self, name: str = "superset_tool", log_dir: Optional[Path] = None, level: int = logging.INFO, console: bool = True) -> None:
|
||||
# <ANCHOR id="SupersetLogger.__init__" type="Function">
|
||||
# @PURPOSE: Конфигурирует и инициализирует логгер, добавляя обработчики для файла и/или консоли.
|
||||
# @PARAM: name: str - Идентификатор логгера.
|
||||
# @PARAM: log_dir: Optional[Path] - Директория для сохранения лог-файлов.
|
||||
# @PARAM: level: int - Уровень логирования (e.g., `logging.INFO`).
|
||||
# @PARAM: console: bool - Флаг для включения вывода в консоль.
|
||||
# @POST: `self.logger` готов к использованию с настроенными обработчиками.
|
||||
self.logger = logging.getLogger(name)
|
||||
self.logger.setLevel(level)
|
||||
self.logger.propagate = False # ← не «прокидываем» записи выше
|
||||
self.logger.propagate = False
|
||||
|
||||
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
|
||||
|
||||
# ---- Очистка предыдущих обработчиков (важно при повторных инициализациях) ----
|
||||
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}_{timestamp}.log", encoding="utf-8"
|
||||
)
|
||||
file_handler = logging.FileHandler(log_dir / f"{name}_{timestamp}.log", encoding="utf-8")
|
||||
file_handler.setFormatter(formatter)
|
||||
self.logger.addHandler(file_handler)
|
||||
|
||||
# ---- Консольный обработчик ----
|
||||
if console:
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(formatter)
|
||||
self.logger.addHandler(console_handler)
|
||||
# </ANCHOR id="SupersetLogger.__init__">
|
||||
|
||||
# [END_ENTITY]
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('_log')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Универсальная вспомогательная обёртка над
|
||||
``logging.Logger.<level>``. Принимает любые ``*args``
|
||||
(подстановочные параметры) и ``extra``‑словарь.
|
||||
:preconditions:
|
||||
- ``level_method`` – один из методов ``logger``,
|
||||
- ``msg`` – строка‑шаблон,
|
||||
- ``*args`` – значения для ``%``‑подстановок,
|
||||
- ``extra`` – пользовательские атрибуты (может быть ``None``).
|
||||
:postconditions: Запись в журнал выполнена.
|
||||
"""
|
||||
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:
|
||||
# <ANCHOR id="SupersetLogger._log" type="Function">
|
||||
# @PURPOSE: (Helper) Универсальный метод для вызова соответствующего уровня логирования.
|
||||
# @INTERNAL
|
||||
def _log(self, level_method: Any, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
|
||||
level_method(msg, *args, extra=extra, exc_info=exc_info)
|
||||
else:
|
||||
level_method(msg, *args, exc_info=exc_info)
|
||||
# </ANCHOR id="SupersetLogger._log">
|
||||
|
||||
# [END_ENTITY]
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('info')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Записать сообщение уровня INFO.
|
||||
"""
|
||||
def info(
|
||||
self,
|
||||
msg: str,
|
||||
*args: Any,
|
||||
extra: Optional[Mapping[str, Any]] = None,
|
||||
exc_info: bool = False,
|
||||
) -> None:
|
||||
# <ANCHOR id="SupersetLogger.info" type="Function">
|
||||
# @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]
|
||||
# </ANCHOR id="SupersetLogger.info">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('debug')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Записать сообщение уровня DEBUG.
|
||||
"""
|
||||
def debug(
|
||||
self,
|
||||
msg: str,
|
||||
*args: Any,
|
||||
extra: Optional[Mapping[str, Any]] = None,
|
||||
exc_info: bool = False,
|
||||
) -> None:
|
||||
# <ANCHOR id="SupersetLogger.debug" type="Function">
|
||||
# @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]
|
||||
# </ANCHOR id="SupersetLogger.debug">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('warning')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Записать сообщение уровня WARNING.
|
||||
"""
|
||||
def warning(
|
||||
self,
|
||||
msg: str,
|
||||
*args: Any,
|
||||
extra: Optional[Mapping[str, Any]] = None,
|
||||
exc_info: bool = False,
|
||||
) -> None:
|
||||
# <ANCHOR id="SupersetLogger.warning" type="Function">
|
||||
# @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]
|
||||
# </ANCHOR id="SupersetLogger.warning">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('error')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Записать сообщение уровня ERROR.
|
||||
"""
|
||||
def error(
|
||||
self,
|
||||
msg: str,
|
||||
*args: Any,
|
||||
extra: Optional[Mapping[str, Any]] = None,
|
||||
exc_info: bool = False,
|
||||
) -> None:
|
||||
# <ANCHOR id="SupersetLogger.error" type="Function">
|
||||
# @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]
|
||||
# </ANCHOR id="SupersetLogger.error">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('critical')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Записать сообщение уровня CRITICAL.
|
||||
"""
|
||||
def critical(
|
||||
self,
|
||||
msg: str,
|
||||
*args: Any,
|
||||
extra: Optional[Mapping[str, Any]] = None,
|
||||
exc_info: bool = False,
|
||||
) -> None:
|
||||
# <ANCHOR id="SupersetLogger.critical" type="Function">
|
||||
# @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]
|
||||
# </ANCHOR id="SupersetLogger.critical">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Method('exception')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Записать сообщение уровня ERROR вместе с трассировкой
|
||||
текущего исключения (аналог ``logger.exception``).
|
||||
"""
|
||||
# <ANCHOR id="SupersetLogger.exception" type="Function">
|
||||
# @PURPOSE: Записывает сообщение уровня ERROR вместе с трассировкой стека текущего исключения.
|
||||
def exception(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
||||
self.logger.exception(msg, *args, **kwargs)
|
||||
# [END_ENTITY]
|
||||
# </ANCHOR id="SupersetLogger.exception">
|
||||
# </ANCHOR id="SupersetLogger">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [END_FILE logger.py]
|
||||
# --------------------------------------------------------------
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE id="superset_tool.utils.logger">
|
||||
@@ -1,265 +1,198 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument
|
||||
"""
|
||||
[MODULE] Сетевой клиент для API
|
||||
# <GRACE_MODULE id="superset_tool.utils.network" name="network.py">
|
||||
# @SEMANTICS: network, http, client, api, requests, session, authentication
|
||||
# @PURPOSE: Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок.
|
||||
# @DEPENDS_ON: superset_tool.exceptions -> Для генерации специфичных сетевых и API ошибок.
|
||||
# @DEPENDS_ON: superset_tool.utils.logger -> Для детального логирования сетевых операций.
|
||||
# @DEPENDS_ON: requests -> Основа для выполнения HTTP-запросов.
|
||||
|
||||
[DESCRIPTION]
|
||||
Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API.
|
||||
"""
|
||||
|
||||
# [IMPORTS] Стандартная библиотека
|
||||
from typing import Optional, Dict, Any, BinaryIO, List, Union
|
||||
# <IMPORTS>
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
import json
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
# [IMPORTS] Сторонние библиотеки
|
||||
import requests
|
||||
import urllib3 # Для отключения SSL-предупреждений
|
||||
import urllib3
|
||||
from superset_tool.exceptions import AuthenticationError, NetworkError, DashboardNotFoundError, SupersetAPIError, PermissionDeniedError
|
||||
from superset_tool.utils.logger import SupersetLogger
|
||||
# </IMPORTS>
|
||||
|
||||
# [IMPORTS] Локальные модули
|
||||
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
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# <ANCHOR id="APIClient" type="Class">
|
||||
# @PURPOSE: Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов.
|
||||
class APIClient:
|
||||
"""[NETWORK-CORE] Инкапсулирует HTTP-логику для работы с API."""
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
verify_ssl: bool = True,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
logger: Optional[SupersetLogger] = None
|
||||
):
|
||||
def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT, logger: Optional[SupersetLogger] = None):
|
||||
# <ANCHOR id="APIClient.__init__" type="Function">
|
||||
# @PURPOSE: Инициализирует API клиент с конфигурацией, сессией и логгером.
|
||||
self.logger = logger or SupersetLogger(name="APIClient")
|
||||
self.logger.info("[INFO][APIClient.__init__][ENTER] Initializing APIClient.")
|
||||
self.logger.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.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout}
|
||||
self.session = self._init_session()
|
||||
self._tokens: Dict[str, str] = {}
|
||||
self._authenticated = False
|
||||
self.logger.info("[INFO][APIClient.__init__][SUCCESS] APIClient initialized.")
|
||||
self.logger.info("[APIClient.__init__][Exit] APIClient initialized.")
|
||||
# </ANCHOR>
|
||||
|
||||
def _init_session(self) -> requests.Session:
|
||||
self.logger.debug("[DEBUG][APIClient._init_session][ENTER] Initializing session.")
|
||||
# <ANCHOR id="APIClient._init_session" type="Function">
|
||||
# @PURPOSE: Создает и настраивает `requests.Session` с retry-логикой.
|
||||
# @INTERNAL
|
||||
session = requests.Session()
|
||||
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"}
|
||||
)
|
||||
retries = requests.adapters.Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
|
||||
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:
|
||||
if not self.request_settings["verify_ssl"]:
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
self.logger.warning("[WARNING][APIClient._init_session][STATE_CHANGE] SSL verification disabled.")
|
||||
self.logger.debug("[DEBUG][APIClient._init_session][SUCCESS] Session initialized.")
|
||||
self.logger.warning("[_init_session][State] SSL verification disabled.")
|
||||
session.verify = self.request_settings["verify_ssl"]
|
||||
return session
|
||||
# </ANCHOR>
|
||||
|
||||
def authenticate(self) -> Dict[str, str]:
|
||||
self.logger.info(f"[INFO][APIClient.authenticate][ENTER] Authenticating to {self.base_url}")
|
||||
# <ANCHOR id="APIClient.authenticate" type="Function">
|
||||
# @PURPOSE: Выполняет аутентификацию в Superset API и получает access и CSRF токены.
|
||||
# @POST: `self._tokens` заполнен, `self._authenticated` установлен в `True`.
|
||||
# @RETURN: Словарь с токенами.
|
||||
# @THROW: AuthenticationError, NetworkError - при ошибках.
|
||||
self.logger.info("[authenticate][Enter] Authenticating to %s", self.base_url)
|
||||
try:
|
||||
login_url = f"{self.base_url}/security/login"
|
||||
response = self.session.post(
|
||||
login_url,
|
||||
json=self.auth,
|
||||
timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT)
|
||||
)
|
||||
response = self.session.post(login_url, json=self.auth, timeout=self.request_settings["timeout"])
|
||||
response.raise_for_status()
|
||||
access_token = response.json()["access_token"]
|
||||
|
||||
csrf_url = f"{self.base_url}/security/csrf_token/"
|
||||
csrf_response = self.session.get(
|
||||
csrf_url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT)
|
||||
)
|
||||
csrf_response = self.session.get(csrf_url, headers={"Authorization": f"Bearer {access_token}"}, timeout=self.request_settings["timeout"])
|
||||
csrf_response.raise_for_status()
|
||||
csrf_token = csrf_response.json()["result"]
|
||||
self._tokens = {
|
||||
"access_token": access_token,
|
||||
"csrf_token": csrf_token
|
||||
}
|
||||
|
||||
self._tokens = {"access_token": access_token, "csrf_token": csrf_response.json()["result"]}
|
||||
self._authenticated = True
|
||||
self.logger.info(f"[INFO][APIClient.authenticate][SUCCESS] Authenticated successfully. Tokens {self._tokens}")
|
||||
self.logger.info("[authenticate][Exit] Authenticated successfully.")
|
||||
return self._tokens
|
||||
except requests.exceptions.HTTPError as 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
|
||||
# </ANCHOR>
|
||||
|
||||
@property
|
||||
def headers(self) -> Dict[str, str]:
|
||||
if not self._authenticated:
|
||||
self.authenticate()
|
||||
# <ANCHOR id="APIClient.headers" type="Property">
|
||||
# @PURPOSE: Возвращает HTTP-заголовки для аутентифицированных запросов.
|
||||
if not self._authenticated: self.authenticate()
|
||||
return {
|
||||
"Authorization": f"Bearer {self._tokens['access_token']}",
|
||||
"X-CSRFToken": self._tokens.get("csrf_token", ""),
|
||||
"Referer": self.base_url,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
# </ANCHOR>
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
headers: Optional[Dict] = None,
|
||||
raw_response: bool = False,
|
||||
**kwargs
|
||||
) -> Union[requests.Response, Dict[str, Any]]:
|
||||
self.logger.debug(f"[DEBUG][APIClient.request][ENTER] Requesting {method} {endpoint}")
|
||||
def request(self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs) -> Union[requests.Response, Dict[str, Any]]:
|
||||
# <ANCHOR id="APIClient.request" type="Function">
|
||||
# @PURPOSE: Выполняет универсальный HTTP-запрос к API.
|
||||
# @RETURN: `requests.Response` если `raw_response=True`, иначе `dict`.
|
||||
# @THROW: SupersetAPIError, NetworkError и их подклассы.
|
||||
full_url = f"{self.base_url}{endpoint}"
|
||||
_headers = self.headers.copy()
|
||||
if headers:
|
||||
_headers.update(headers)
|
||||
timeout = kwargs.pop('timeout', self.request_settings.get("timeout", DEFAULT_TIMEOUT))
|
||||
if headers: _headers.update(headers)
|
||||
|
||||
try:
|
||||
response = self.session.request(
|
||||
method,
|
||||
full_url,
|
||||
headers=_headers,
|
||||
timeout=timeout,
|
||||
**kwargs
|
||||
)
|
||||
response = self.session.request(method, full_url, headers=_headers, **kwargs)
|
||||
response.raise_for_status()
|
||||
self.logger.debug(f"[DEBUG][APIClient.request][SUCCESS] Request successful for {method} {endpoint}")
|
||||
return response if raw_response else response.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
self.logger.error(f"[ERROR][APIClient.request][FAILURE] HTTP error for {method} {endpoint}: {e}")
|
||||
self._handle_http_error(e, endpoint, context={})
|
||||
self._handle_http_error(e, endpoint)
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"[ERROR][APIClient.request][FAILURE] Network error for {method} {endpoint}: {e}")
|
||||
self._handle_network_error(e, full_url)
|
||||
# </ANCHOR>
|
||||
|
||||
def _handle_http_error(self, e, endpoint, context):
|
||||
def _handle_http_error(self, e: requests.exceptions.HTTPError, endpoint: str):
|
||||
# <ANCHOR id="APIClient._handle_http_error" type="Function">
|
||||
# @PURPOSE: (Helper) Преобразует HTTP ошибки в кастомные исключения.
|
||||
# @INTERNAL
|
||||
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
|
||||
if status_code == 404: raise DashboardNotFoundError(endpoint) from e
|
||||
if status_code == 403: raise PermissionDeniedError() from e
|
||||
if status_code == 401: raise AuthenticationError() from e
|
||||
raise SupersetAPIError(f"API Error {status_code}: {e.response.text}") from e
|
||||
# </ANCHOR>
|
||||
|
||||
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}"
|
||||
def _handle_network_error(self, e: requests.exceptions.RequestException, url: str):
|
||||
# <ANCHOR id="APIClient._handle_network_error" type="Function">
|
||||
# @PURPOSE: (Helper) Преобразует сетевые ошибки в `NetworkError`.
|
||||
# @INTERNAL
|
||||
if isinstance(e, requests.exceptions.Timeout): msg = "Request timeout"
|
||||
elif isinstance(e, requests.exceptions.ConnectionError): msg = "Connection error"
|
||||
else: msg = f"Unknown network error: {e}"
|
||||
raise NetworkError(msg, url=url) from e
|
||||
# </ANCHOR>
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
endpoint: str,
|
||||
file_info: Dict[str, Any],
|
||||
extra_data: Optional[Dict] = None,
|
||||
timeout: Optional[int] = None
|
||||
) -> Dict:
|
||||
self.logger.info(f"[INFO][APIClient.upload_file][ENTER] Uploading file to {endpoint}")
|
||||
def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict:
|
||||
# <ANCHOR id="APIClient.upload_file" type="Function">
|
||||
# @PURPOSE: Загружает файл на сервер через multipart/form-data.
|
||||
# @RETURN: Ответ API в виде словаря.
|
||||
# @THROW: SupersetAPIError, NetworkError, TypeError.
|
||||
full_url = f"{self.base_url}{endpoint}"
|
||||
_headers = self.headers.copy()
|
||||
_headers.pop('Content-Type', None)
|
||||
file_obj = file_info.get("file_obj")
|
||||
file_name = file_info.get("file_name")
|
||||
form_field = file_info.get("form_field", "file")
|
||||
_headers = self.headers.copy(); _headers.pop('Content-Type', None)
|
||||
|
||||
file_obj, file_name, form_field = file_info.get("file_obj"), file_info.get("file_name"), file_info.get("form_field", "file")
|
||||
|
||||
files_payload = {}
|
||||
if isinstance(file_obj, (str, Path)):
|
||||
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)
|
||||
with open(file_obj, 'rb') as f:
|
||||
files_payload = {form_field: (file_name, f.read(), 'application/x-zip-compressed')}
|
||||
elif isinstance(file_obj, io.BytesIO):
|
||||
files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')}
|
||||
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')}
|
||||
return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
|
||||
else:
|
||||
self.logger.error(f"[ERROR][APIClient.upload_file][FAILURE] Unsupported file_obj type: {type(file_obj)}")
|
||||
raise TypeError(f"Неподдерживаемый тип 'file_obj': {type(file_obj)}")
|
||||
raise TypeError(f"Unsupported file_obj type: {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}")
|
||||
return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
|
||||
# </ANCHOR>
|
||||
|
||||
def _perform_upload(self, url: str, files: Dict, data: Optional[Dict], headers: Dict, timeout: Optional[int]) -> Dict:
|
||||
# <ANCHOR id="APIClient._perform_upload" type="Function">
|
||||
# @PURPOSE: (Helper) Выполняет POST запрос с файлом.
|
||||
# @INTERNAL
|
||||
try:
|
||||
response = self.session.post(
|
||||
url=url,
|
||||
files=files,
|
||||
data=data or {},
|
||||
headers=headers,
|
||||
timeout=timeout or self.request_settings.get("timeout")
|
||||
)
|
||||
response = self.session.post(url, files=files, data=data or {}, headers=headers, timeout=timeout or self.request_settings["timeout"])
|
||||
response.raise_for_status()
|
||||
self.logger.info(f"[INFO][APIClient._perform_upload][SUCCESS] Upload successful to {url}")
|
||||
return response.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
self.logger.error(f"[ERROR][APIClient._perform_upload][FAILURE] HTTP error during upload: {e}")
|
||||
raise SupersetAPIError(f"Ошибка API при загрузке: {e.response.text}") from e
|
||||
raise SupersetAPIError(f"API error during upload: {e.response.text}") from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"[ERROR][APIClient._perform_upload][FAILURE] Network error during upload: {e}")
|
||||
raise NetworkError(f"Ошибка сети при загрузке: {e}", url=url) from e
|
||||
raise NetworkError(f"Network error during upload: {e}", url=url) from e
|
||||
# </ANCHOR>
|
||||
|
||||
def fetch_paginated_count(
|
||||
self,
|
||||
endpoint: str,
|
||||
query_params: Dict,
|
||||
count_field: str = "count",
|
||||
timeout: Optional[int] = None
|
||||
) -> int:
|
||||
self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_count][ENTER] Fetching paginated count for {endpoint}")
|
||||
response_json = self.request(
|
||||
method="GET",
|
||||
endpoint=endpoint,
|
||||
params={"q": json.dumps(query_params)},
|
||||
timeout=timeout or self.request_settings.get("timeout")
|
||||
)
|
||||
count = response_json.get(count_field, 0)
|
||||
self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_count][SUCCESS] Fetched paginated count: {count}")
|
||||
return count
|
||||
def fetch_paginated_count(self, endpoint: str, query_params: Dict, count_field: str = "count") -> int:
|
||||
# <ANCHOR id="APIClient.fetch_paginated_count" type="Function">
|
||||
# @PURPOSE: Получает общее количество элементов для пагинации.
|
||||
response_json = self.request("GET", endpoint, params={"q": json.dumps(query_params)})
|
||||
return response_json.get(count_field, 0)
|
||||
# </ANCHOR>
|
||||
|
||||
def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]:
|
||||
# <ANCHOR id="APIClient.fetch_paginated_data" type="Function">
|
||||
# @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта.
|
||||
base_query, total_count = pagination_options["base_query"], pagination_options["total_count"]
|
||||
results_field, page_size = pagination_options["results_field"], base_query.get('page_size')
|
||||
assert page_size and page_size > 0, "'page_size' must be a positive number."
|
||||
|
||||
def fetch_paginated_data(
|
||||
self,
|
||||
endpoint: str,
|
||||
pagination_options: Dict[str, Any],
|
||||
timeout: Optional[int] = None
|
||||
) -> List[Any]:
|
||||
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:
|
||||
raise ValueError("'page_size' должен быть положительным числом.")
|
||||
total_pages = (total_count + page_size - 1) // page_size
|
||||
results = []
|
||||
for page in range(total_pages):
|
||||
for page in range((total_count + page_size - 1) // page_size):
|
||||
query = {**base_query, 'page': page}
|
||||
response_json = self.request(
|
||||
method="GET",
|
||||
endpoint=endpoint,
|
||||
params={"q": json.dumps(query)},
|
||||
timeout=timeout or self.request_settings.get("timeout")
|
||||
)
|
||||
page_results = response_json.get(results_field, [])
|
||||
results.extend(page_results)
|
||||
self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_data][SUCCESS] Fetched paginated data. Total items: {len(results)}")
|
||||
response_json = self.request("GET", endpoint, params={"q": json.dumps(query)})
|
||||
results.extend(response_json.get(results_field, []))
|
||||
return results
|
||||
# </ANCHOR>
|
||||
|
||||
# </ANCHOR id="APIClient">
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE id="superset_tool.utils.network">
|
||||
@@ -1,148 +1,106 @@
|
||||
# [MODULE_PATH] superset_tool.utils.whiptail_fallback
|
||||
# [FILE] whiptail_fallback.py
|
||||
# [SEMANTICS] ui, fallback, console, utils, non‑interactive
|
||||
# <GRACE_MODULE id="superset_tool.utils.whiptail_fallback" name="whiptail_fallback.py">
|
||||
# @SEMANTICS: ui, fallback, console, utility, interactive
|
||||
# @PURPOSE: Предоставляет плотный консольный UI-fallback для интерактивных диалогов, имитируя `whiptail` для систем, где он недоступен.
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [IMPORTS]
|
||||
# --------------------------------------------------------------
|
||||
# <IMPORTS>
|
||||
import sys
|
||||
from typing import List, Tuple, Optional, Any
|
||||
# [END_IMPORTS]
|
||||
# </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)
|
||||
# <ANCHOR id="menu" type="Function">
|
||||
# @PURPOSE: Отображает меню выбора и возвращает выбранный элемент.
|
||||
# @PARAM: title: str - Заголовок меню.
|
||||
# @PARAM: prompt: str - Приглашение к вводу.
|
||||
# @PARAM: choices: List[str] - Список вариантов для выбора.
|
||||
# @RETURN: Tuple[int, Optional[str]] - Кортеж (код возврата, выбранный элемент). rc=0 - успех.
|
||||
def menu(title: str, prompt: str, choices: List[str], **kwargs) -> Tuple[int, Optional[str]]:
|
||||
print(f"\n=== {title} ===\n{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 (0, choices[sel - 1]) if 0 < sel <= len(choices) else (1, None)
|
||||
except (ValueError, IndexError):
|
||||
return 1, None
|
||||
# </ANCHOR id="menu">
|
||||
|
||||
|
||||
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)
|
||||
# <ANCHOR id="checklist" type="Function">
|
||||
# @PURPOSE: Отображает список с возможностью множественного выбора.
|
||||
# @PARAM: title: str - Заголовок.
|
||||
# @PARAM: prompt: str - Приглашение к вводу.
|
||||
# @PARAM: options: List[Tuple[str, str]] - Список кортежей (значение, метка).
|
||||
# @RETURN: Tuple[int, List[str]] - Кортеж (код возврата, список выбранных значений).
|
||||
def checklist(title: str, prompt: str, options: List[Tuple[str, str]], **kwargs) -> Tuple[int, List[str]]:
|
||||
print(f"\n=== {title} ===\n{prompt}")
|
||||
for idx, (val, label) in enumerate(options, 1):
|
||||
print(f"{idx}) [{val}] {label}")
|
||||
|
||||
raw = input("\nВведите номера через запятую (пустой ввод → отказ): ").strip()
|
||||
if not raw:
|
||||
return 1, []
|
||||
|
||||
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:
|
||||
indices = {int(x.strip()) for x in raw.split(",") if x.strip()}
|
||||
selected_values = [options[i - 1][0] for i in indices if 0 < i <= len(options)]
|
||||
return 0, selected_values
|
||||
except (ValueError, IndexError):
|
||||
return 1, []
|
||||
# </ANCHOR id="checklist">
|
||||
|
||||
|
||||
def yesno(
|
||||
title: str,
|
||||
question: str,
|
||||
backtitle: str = "Superset Migration Tool",
|
||||
) -> bool:
|
||||
"""True → пользователь ответил «да». """
|
||||
# <ANCHOR id="yesno" type="Function">
|
||||
# @PURPOSE: Задает вопрос с ответом да/нет.
|
||||
# @PARAM: title: str - Заголовок.
|
||||
# @PARAM: question: str - Вопрос для пользователя.
|
||||
# @RETURN: bool - `True`, если пользователь ответил "да".
|
||||
def yesno(title: str, question: str, **kwargs) -> bool:
|
||||
ans = input(f"\n=== {title} ===\n{question} (y/n): ").strip().lower()
|
||||
return ans in ("y", "yes", "да", "д")
|
||||
# </ANCHOR id="yesno">
|
||||
|
||||
|
||||
def msgbox(
|
||||
title: str,
|
||||
msg: str,
|
||||
width: int = 60,
|
||||
height: int = 15,
|
||||
backtitle: str = "Superset Migration Tool",
|
||||
) -> None:
|
||||
"""Простой вывод сообщения – без ожидания Enter."""
|
||||
# <ANCHOR id="msgbox" type="Function">
|
||||
# @PURPOSE: Отображает информационное сообщение.
|
||||
# @PARAM: title: str - Заголовок.
|
||||
# @PARAM: msg: str - Текст сообщения.
|
||||
def msgbox(title: str, msg: str, **kwargs) -> None:
|
||||
print(f"\n=== {title} ===\n{msg}\n")
|
||||
# **Убрано:** input("Нажмите <Enter> для продолжения...")
|
||||
# </ANCHOR id="msgbox">
|
||||
|
||||
|
||||
def inputbox(
|
||||
title: str,
|
||||
prompt: str,
|
||||
backtitle: str = "Superset Migration Tool",
|
||||
) -> Tuple[int, Optional[str]]:
|
||||
"""Return (rc, введённая строка). rc == 0 → успешно."""
|
||||
# <ANCHOR id="inputbox" type="Function">
|
||||
# @PURPOSE: Запрашивает у пользователя текстовый ввод.
|
||||
# @PARAM: title: str - Заголовок.
|
||||
# @PARAM: prompt: str - Приглашение к вводу.
|
||||
# @RETURN: Tuple[int, Optional[str]] - Кортеж (код возврата, введенная строка).
|
||||
def inputbox(title: str, prompt: str, **kwargs) -> Tuple[int, Optional[str]]:
|
||||
print(f"\n=== {title} ===")
|
||||
val = input(f"{prompt}\n")
|
||||
if val == "":
|
||||
return 1, None
|
||||
return 0, val
|
||||
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [ENTITY: Service('ConsoleGauge')]
|
||||
# --------------------------------------------------------------
|
||||
"""
|
||||
:purpose: Минимальная имитация ``whiptail``‑gauge в консоли.
|
||||
"""
|
||||
return (0, val) if val else (1, None)
|
||||
# </ANCHOR id="inputbox">
|
||||
|
||||
# <ANCHOR id="_ConsoleGauge" type="Class">
|
||||
# @PURPOSE: Контекстный менеджер для имитации `whiptail gauge` в консоли.
|
||||
# @INTERNAL
|
||||
class _ConsoleGauge:
|
||||
"""Контекст‑менеджер для простого прогресс‑бара."""
|
||||
def __init__(self, title: str, width: int = 60, height: int = 10):
|
||||
def __init__(self, title: str, **kwargs):
|
||||
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()
|
||||
|
||||
sys.stdout.write("\n"); sys.stdout.flush()
|
||||
def set_text(self, txt: str) -> None:
|
||||
sys.stdout.write(f"\r{txt} ")
|
||||
sys.stdout.flush()
|
||||
|
||||
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]
|
||||
sys.stdout.write(f"{percent}%"); sys.stdout.flush()
|
||||
# </ANCHOR id="_ConsoleGauge">
|
||||
|
||||
def gauge(
|
||||
title: str,
|
||||
width: int = 60,
|
||||
height: int = 10,
|
||||
) -> Any:
|
||||
"""Always returns the console fallback gauge."""
|
||||
return _ConsoleGauge(title, width, height)
|
||||
# [END_ENTITY]
|
||||
# <ANCHOR id="gauge" type="Function">
|
||||
# @PURPOSE: Создает и возвращает экземпляр `_ConsoleGauge`.
|
||||
# @PARAM: title: str - Заголовок для индикатора прогресса.
|
||||
# @RETURN: _ConsoleGauge - Экземпляр контекстного менеджера.
|
||||
def gauge(title: str, **kwargs) -> _ConsoleGauge:
|
||||
return _ConsoleGauge(title, **kwargs)
|
||||
# </ANCHOR id="gauge">
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# [END_FILE whiptail_fallback.py]
|
||||
# --------------------------------------------------------------
|
||||
# --- Конец кода модуля ---
|
||||
|
||||
# </GRACE_MODULE id="superset_tool.utils.whiptail_fallback">
|
||||
3096
tech_spec/Пример GET.md
Normal file
3096
tech_spec/Пример GET.md
Normal file
File diff suppressed because it is too large
Load Diff
57
tech_spec/Пример PUT.md
Normal file
57
tech_spec/Пример PUT.md
Normal file
@@ -0,0 +1,57 @@
|
||||
put /api/v1/dataset/{pk}
|
||||
|
||||
{
|
||||
"cache_timeout": 0,
|
||||
"columns": [
|
||||
{
|
||||
"advanced_data_type": "string",
|
||||
"column_name": "string",
|
||||
"description": "string",
|
||||
"expression": "string",
|
||||
"extra": "string",
|
||||
"filterable": true,
|
||||
"groupby": true,
|
||||
"id": 0,
|
||||
"is_active": true,
|
||||
"is_dttm": true,
|
||||
"python_date_format": "string",
|
||||
"type": "string",
|
||||
"uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"verbose_name": "string"
|
||||
}
|
||||
],
|
||||
"database_id": 0,
|
||||
"default_endpoint": "string",
|
||||
"description": "string",
|
||||
"external_url": "string",
|
||||
"extra": "string",
|
||||
"fetch_values_predicate": "string",
|
||||
"filter_select_enabled": true,
|
||||
"is_managed_externally": true,
|
||||
"is_sqllab_view": true,
|
||||
"main_dttm_col": "string",
|
||||
"metrics": [
|
||||
{
|
||||
"currency": "string",
|
||||
"d3format": "string",
|
||||
"description": "string",
|
||||
"expression": "string",
|
||||
"extra": "string",
|
||||
"id": 0,
|
||||
"metric_name": "string",
|
||||
"metric_type": "string",
|
||||
"uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"verbose_name": "string",
|
||||
"warning_text": "string"
|
||||
}
|
||||
],
|
||||
"normalize_columns": true,
|
||||
"offset": 0,
|
||||
"owners": [
|
||||
0
|
||||
],
|
||||
"schema": "string",
|
||||
"sql": "string",
|
||||
"table_name": "string",
|
||||
"template_params": "string"
|
||||
}
|
||||
Reference in New Issue
Block a user