Merge branch 'migration'

This commit is contained in:
Volobuev Andrey
2025-10-07 17:59:58 +03:00
20 changed files with 4712 additions and 2260 deletions

265
GEMINI.md
View File

@@ -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>
<МЕТАПОЗНАНИЕ>
<ДИРЕКТИВА>Если ты обнаружишь, что данный системный промпт недостаточен или неоднозначен для выполнения задачи, ты должен отметить это в `<ПЛАНИРОВАНИЕ>` и можешь предложить улучшения в свои собственные инструкции для будущих сессий.</ДИРЕКТИВА>
</МЕТАПОЗНАНИЕ>
</СИСТЕМНЫЙ_ПРОМПТ>

View File

@@ -1,3 +1,5 @@
Вот обновлённый README с информацией о работе со скриптами:
# Инструменты автоматизации Superset # Инструменты автоматизации Superset
## Обзор ## Обзор
@@ -9,6 +11,7 @@
- `backup_script.py`: Основной скрипт для выполнения запланированного резервного копирования дашбордов Superset. - `backup_script.py`: Основной скрипт для выполнения запланированного резервного копирования дашбордов Superset.
- `migration_script.py`: Основной скрипт для переноса конкретных дашбордов между окружениями, включая переопределение соединений с базами данных. - `migration_script.py`: Основной скрипт для переноса конкретных дашбордов между окружениями, включая переопределение соединений с базами данных.
- `search_script.py`: Скрипт для поиска данных во всех доступных датасетах на сервере - `search_script.py`: Скрипт для поиска данных во всех доступных датасетах на сервере
- `run_mapper.py`: CLI-скрипт для маппинга метаданных датасетов.
- `superset_tool/`: - `superset_tool/`:
- `client.py`: Python-клиент для взаимодействия с API Superset. - `client.py`: Python-клиент для взаимодействия с API Superset.
- `exceptions.py`: Пользовательские классы исключений для структурированной обработки ошибок. - `exceptions.py`: Пользовательские классы исключений для структурированной обработки ошибок.
@@ -17,6 +20,8 @@
- `fileio.py`: Утилиты для работы с файловой системой (работа с архивами, парсинг YAML). - `fileio.py`: Утилиты для работы с файловой системой (работа с архивами, парсинг YAML).
- `logger.py`: Конфигурация логгера для единообразного логирования в проекте. - `logger.py`: Конфигурация логгера для единообразного логирования в проекте.
- `network.py`: HTTP-клиент для сетевых запросов с обработкой аутентификации и повторных попыток. - `network.py`: HTTP-клиент для сетевых запросов с обработкой аутентификации и повторных попыток.
- `init_clients.py`: Утилита для инициализации клиентов Superset для разных окружений.
- `dataset_mapper.py`: Логика маппинга метаданных датасетов.
## Настройка ## Настройка
@@ -66,17 +71,34 @@ python migration_script.py
`from_c` и `to_c`. `from_c` и `to_c`.
### Скрипт поиска (`search_script.py`) ### Скрипт поиска (`search_script.py`)
Строка для поиска и клиенты для поиска задаются здесь Для поиска по текстовым паттернам в метаданных датасетов Superset:
# Поиск всех таблиц в датасете ```bash
```python python search_script.py
results = search_datasets(
client=clients['dev'],
search_pattern=r'dm_view\.account_debt',
search_fields=["sql"],
logger=logger
)
``` ```
Скрипт использует регулярные выражения для поиска в полях датасетов, таких как 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`. Логи пишутся в файл в директории `Logs` (например, `P:\Superset\010 Бекапы\Logs` для резервных копий) и выводятся в консоль. Уровень логирования по умолчанию — `INFO`.
@@ -90,4 +112,4 @@ results = search_datasets(
--- ---
[COHERENCE_CHECK_PASSED] README.md создан и согласован с модулями. [COHERENCE_CHECK_PASSED] README.md создан и согласован с модулями.
Перевод выполнен с сохранением оригинальной Markdown-разметки и стиля документа. [1] Перевод выполнен с сохранением оригинальной Markdown-разметки и стиля документа.

View File

@@ -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 # <GRACE_MODULE id="backup_script" name="backup_script.py">
""" # @SEMANTICS: backup, superset, automation, dashboard
[MODULE] Superset Dashboard Backup Script # @PURPOSE: Этот модуль отвечает за автоматизированное резервное копирование дашбордов Superset.
@contract: Автоматизирует процесс резервного копирования дашбордов Superset. # @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API.
""" # @DEPENDS_ON: superset_tool.utils -> Использует утилиты для логирования, работы с файлами и инициализации клиентов.
# [IMPORTS] Стандартная библиотека # <IMPORTS>
import logging import logging
import sys import sys
from pathlib import Path from pathlib import Path
from dataclasses import dataclass,field from dataclasses import dataclass,field
# [IMPORTS] Third-party
from requests.exceptions import RequestException from requests.exceptions import RequestException
# [IMPORTS] Локальные модули
from superset_tool.client import SupersetClient from superset_tool.client import SupersetClient
from superset_tool.exceptions import SupersetAPIError from superset_tool.exceptions import SupersetAPIError
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
@@ -26,11 +22,12 @@ from superset_tool.utils.fileio import (
RetentionPolicy RetentionPolicy
) )
from superset_tool.utils.init_clients import setup_clients from superset_tool.utils.init_clients import setup_clients
# </IMPORTS>
# --- Начало кода модуля ---
# [ENTITY: Dataclass('BackupConfig')] # <ANCHOR id="BackupConfig" type="DataClass">
# CONTRACT: # @PURPOSE: Хранит конфигурацию для процесса бэкапа.
# PURPOSE: Хранит конфигурацию для процесса бэкапа.
@dataclass @dataclass
class BackupConfig: class BackupConfig:
"""Конфигурация для процесса бэкапа.""" """Конфигурация для процесса бэкапа."""
@@ -38,18 +35,26 @@ class BackupConfig:
rotate_archive: bool = True rotate_archive: bool = True
clean_folders: bool = True clean_folders: bool = True
retention_policy: RetentionPolicy = field(default_factory=RetentionPolicy) retention_policy: RetentionPolicy = field(default_factory=RetentionPolicy)
# </ANCHOR id="BackupConfig">
# [ENTITY: Function('backup_dashboards')] # <ANCHOR id="backup_dashboards" type="Function">
# CONTRACT: # @PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения, пропуская ошибки экспорта.
# PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения, пропуская ошибки экспорта. # @PRE: `client` должен быть инициализированным экземпляром `SupersetClient`.
# PRECONDITIONS: # @PRE: `env_name` должен быть строкой, обозначающей окружение.
# - `client` должен быть инициализированным экземпляром `SupersetClient`. # @PRE: `backup_root` должен быть валидным путем к корневой директории бэкапа.
# - `env_name` должен быть строкой, обозначающей окружение. # @POST: Дашборды экспортируются и сохраняются. Ошибки экспорта логируются и не приводят к остановке скрипта.
# - `backup_root` должен быть валидным путем к корневой директории бэкапа. # @PARAM: client: SupersetClient - Клиент для доступа к API Superset.
# POSTCONDITIONS: # @PARAM: env_name: str - Имя окружения (e.g., 'PROD').
# - Дашборды экспортируются и сохраняются. # @PARAM: backup_root: Path - Корневая директория для сохранения бэкапов.
# - Ошибки экспорта логируются и не приводят к остановке скрипта. # @PARAM: logger: SupersetLogger - Инстанс логгера.
# - Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе. # @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( def backup_dashboards(
client: SupersetClient, client: SupersetClient,
env_name: str, env_name: str,
@@ -57,10 +62,10 @@ def backup_dashboards(
logger: SupersetLogger, logger: SupersetLogger,
config: BackupConfig config: BackupConfig
) -> bool: ) -> 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: try:
dashboard_count, dashboard_meta = client.get_dashboards() 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: if dashboard_count == 0:
return True return True
@@ -91,8 +96,7 @@ def backup_dashboards(
success_count += 1 success_count += 1
except (SupersetAPIError, RequestException, IOError, OSError) as db_error: 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 continue
if config.consolidate: if config.consolidate:
@@ -101,21 +105,22 @@ def backup_dashboards(
if config.clean_folders: if config.clean_folders:
remove_empty_directories(str(backup_root / env_name), logger=logger) 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 return success_count == dashboard_count
except (RequestException, IOError) as e: 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 return False
# END_FUNCTION_backup_dashboards # </ANCHOR id="backup_dashboards">
# [ENTITY: Function('main')] # <ANCHOR id="main" type="Function">
# CONTRACT: # @PURPOSE: Основная точка входа для запуска процесса резервного копирования.
# PURPOSE: Основная точка входа скрипта. # @RETURN: int - Код выхода (0 - успех, 1 - ошибка).
# PRECONDITIONS: None # @RELATION: CALLS -> setup_clients
# POSTCONDITIONS: Возвращает код выхода. # @RELATION: CALLS -> backup_dashboards
def main() -> int: def main() -> int:
log_dir = Path("P:\\Superset\\010 Бекапы\\Logs") log_dir = Path("P:\\Superset\\010 Бекапы\\Logs")
logger = SupersetLogger(log_dir=log_dir, level=logging.INFO, console=True) 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 exit_code = 0
try: try:
@@ -137,20 +142,23 @@ def main() -> int:
config=backup_config config=backup_config
) )
except Exception as env_error: 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 results[env] = False
if not all(results.values()): if not all(results.values()):
exit_code = 1 exit_code = 1
except (RequestException, IOError) as e: 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 exit_code = 1
logger.info("[STATE][main][SUCCESS] Superset backup process finished.") logger.info("[main][Exit] Superset backup process finished.")
return exit_code return exit_code
# END_FUNCTION_main # </ANCHOR id="main">
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
# --- Конец кода модуля ---
# </GRACE_MODULE id="backup_script">

BIN
comment_mapping.xlsx Normal file

Binary file not shown.

69
get_dataset_structure.py Normal file
View 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>

View File

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

72
run_mapper.py Normal file
View 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">

View File

@@ -1,88 +1,88 @@
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument,invalid-name,redefined-outer-name # <GRACE_MODULE id="search_script" name="search_script.py">
""" # @SEMANTICS: search, superset, dataset, regex
[MODULE] Dataset Search Utilities # @PURPOSE: Предоставляет утилиты для поиска по текстовым паттернам в метаданных датасетов Superset.
@contract: Предоставляет функционал для поиска текстовых паттернов в метаданных датасетов Superset. # @DEPENDS_ON: superset_tool.client -> Для взаимодействия с API Superset.
""" # @DEPENDS_ON: superset_tool.utils -> Для логирования и инициализации клиентов.
# [IMPORTS] Стандартная библиотека # <IMPORTS>
import logging import logging
import re import re
from typing import Dict, Optional from typing import Dict, Optional
# [IMPORTS] Third-party
from requests.exceptions import RequestException from requests.exceptions import RequestException
# [IMPORTS] Локальные модули
from superset_tool.client import SupersetClient from superset_tool.client import SupersetClient
from superset_tool.exceptions import SupersetAPIError from superset_tool.exceptions import SupersetAPIError
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.init_clients import setup_clients from superset_tool.utils.init_clients import setup_clients
# </IMPORTS>
# [ENTITY: Function('search_datasets')] # --- Начало кода модуля ---
# CONTRACT:
# PURPOSE: Выполняет поиск по строковому паттерну в метаданных всех датасетов. # <ANCHOR id="search_datasets" type="Function">
# PRECONDITIONS: # @PURPOSE: Выполняет поиск по строковому паттерну в метаданных всех датасетов.
# - `client` должен быть инициализированным экземпляром `SupersetClient`. # @PRE: `client` должен быть инициализированным экземпляром `SupersetClient`.
# - `search_pattern` должен быть валидной строкой регулярного выражения. # @PRE: `search_pattern` должен быть валидной строкой регулярного выражения.
# POSTCONDITIONS: # @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( def search_datasets(
client: SupersetClient, client: SupersetClient,
search_pattern: str, search_pattern: str,
logger: Optional[SupersetLogger] = None logger: Optional[SupersetLogger] = None
) -> Optional[Dict]: ) -> Optional[Dict]:
logger = logger or SupersetLogger(name="dataset_search") logger = logger or SupersetLogger(name="dataset_search")
logger.info(f"[STATE][search_datasets][ENTER] Searching for pattern: '{search_pattern}'") logger.info(f"[search_datasets][Enter] Searching for pattern: '{search_pattern}'")
try: try:
_, datasets = client.get_datasets(query={ _, datasets = client.get_datasets(query={"columns": ["id", "table_name", "sql", "database", "columns"]})
"columns": ["id", "table_name", "sql", "database", "columns"]
})
if not datasets: if not datasets:
logger.warning("[STATE][search_datasets][EMPTY] No datasets found.") logger.warning("[search_datasets][State] No datasets found.")
return None return None
pattern = re.compile(search_pattern, re.IGNORECASE) pattern = re.compile(search_pattern, re.IGNORECASE)
results = {} results = {}
available_fields = set(datasets[0].keys())
for dataset in datasets: for dataset in datasets:
dataset_id = dataset.get('id') dataset_id = dataset.get('id')
if not dataset_id: if not dataset_id:
continue continue
matches = [] matches = []
for field in available_fields: for field, value in dataset.items():
value = str(dataset.get(field, "")) value_str = str(value)
if pattern.search(value): if pattern.search(value_str):
match_obj = pattern.search(value) match_obj = pattern.search(value_str)
matches.append({ matches.append({
"field": field, "field": field,
"match": match_obj.group() if match_obj else "", "match": match_obj.group() if match_obj else "",
"value": value "value": value_str
}) })
if matches: if matches:
results[dataset_id] = 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 return results
except re.error as e: 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 raise
except (SupersetAPIError, RequestException) as e: 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 raise
# END_FUNCTION_search_datasets # </ANCHOR id="search_datasets">
# [ENTITY: Function('print_search_results')] # <ANCHOR id="print_search_results" type="Function">
# CONTRACT: # @PURPOSE: Форматирует результаты поиска для читаемого вывода в консоль.
# PURPOSE: Форматирует результаты поиска для читаемого вывода в консоль. # @PRE: `results` является словарем, возвращенным `search_datasets`, или `None`.
# PRECONDITIONS: # @POST: Возвращает отформатированную строку с результатами.
# - `results` является словарем, возвращенным `search_datasets`, или `None`. # @PARAM: results: Optional[Dict] - Словарь с результатами поиска.
# POSTCONDITIONS: # @PARAM: context_lines: int - Количество строк контекста для вывода до и после совпадения.
# - Возвращает отформатированную строку с результатами. # @RETURN: str - Отформатированный отчет.
def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str: def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str:
if not results: if not results:
return "Ничего не найдено" return "Ничего не найдено"
@@ -91,46 +91,40 @@ def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str
for dataset_id, matches in results.items(): for dataset_id, matches in results.items():
output.append(f"\n--- Dataset ID: {dataset_id} ---") output.append(f"\n--- Dataset ID: {dataset_id} ---")
for match_info in matches: for match_info in matches:
field = match_info['field'] field, match_text, full_value = match_info['field'], match_info['match'], match_info['value']
match_text = match_info['match']
full_value = match_info['value']
output.append(f" - Поле: {field}") output.append(f" - Поле: {field}")
output.append(f" Совпадение: '{match_text}'") output.append(f" Совпадение: '{match_text}'")
lines = full_value.splitlines() lines = full_value.splitlines()
if not lines: if not lines: continue
continue
match_line_index = -1 match_line_index = -1
for i, line in enumerate(lines): for i, line in enumerate(lines):
if match_text in line: if match_text in line:
match_line_index = i match_line_index = i
break break
if match_line_index != -1: if match_line_index != -1:
start_line = max(0, match_line_index - context_lines) start = max(0, match_line_index - context_lines)
end_line = min(len(lines), match_line_index + context_lines + 1) end = min(len(lines), match_line_index + context_lines + 1)
output.append(" Контекст:") output.append(" Контекст:")
for i in range(start_line, end_line): for i in range(start, end):
line_number = i + 1 prefix = f"{i + 1:5d}: "
line_content = lines[i] line_content = lines[i]
prefix = f"{line_number:5d}: "
if i == match_line_index: if i == match_line_index:
highlighted_line = line_content.replace(match_text, f">>>{match_text}<<<") highlighted = line_content.replace(match_text, f">>>{match_text}<<<")
output.append(f" {prefix}{highlighted_line}") output.append(f" {prefix}{highlighted}")
else: else:
output.append(f" {prefix}{line_content}") output.append(f" {prefix}{line_content}")
output.append("-" * 25) output.append("-" * 25)
return "\n".join(output) return "\n".join(output)
# END_FUNCTION_print_search_results # </ANCHOR id="print_search_results">
# [ENTITY: Function('main')] # <ANCHOR id="main" type="Function">
# CONTRACT: # @PURPOSE: Основная точка входа для запуска скрипта поиска.
# PURPOSE: Основная точка входа скрипта. # @RELATION: CALLS -> setup_clients
# PRECONDITIONS: None # @RELATION: CALLS -> search_datasets
# POSTCONDITIONS: None # @RELATION: CALLS -> print_search_results
def main(): def main():
logger = SupersetLogger(level=logging.INFO, console=True) logger = SupersetLogger(level=logging.INFO, console=True)
clients = setup_clients(logger) clients = setup_clients(logger)
@@ -145,8 +139,12 @@ def main():
) )
report = print_search_results(results) report = print_search_results(results)
logger.info(f"[STATE][main][SUCCESS] Search finished. Report:\n{report}") logger.info(f"[main][Success] Search finished. Report:\n{report}")
# END_FUNCTION_main # </ANCHOR id="main">
if __name__ == "__main__": if __name__ == "__main__":
main() main()
# --- Конец кода модуля ---
# </GRACE_MODULE id="search_script">

120
semantic_protocol.md Normal file
View 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">
```

View File

@@ -1,59 +1,38 @@
# [MODULE_PATH] superset_tool.client # <GRACE_MODULE id="superset_tool.client" name="client.py">
# [FILE] client.py # @SEMANTICS: superset, api, client, rest, http, dashboard, dataset, import, export
# [SEMANTICS] superset, api, client, logging, error-handling, slug-support # @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 json
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
from requests import Response from requests import Response
from superset_tool.models import SupersetConfig from superset_tool.models import SupersetConfig
from superset_tool.exceptions import ExportError, InvalidZipFormatError from superset_tool.exceptions import ExportError, InvalidZipFormatError
from superset_tool.utils.fileio import get_filename_from_headers from superset_tool.utils.fileio import get_filename_from_headers
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.network import APIClient from superset_tool.utils.network import APIClient
# [END_IMPORTS] # </IMPORTS>
# -------------------------------------------------------------- # --- Начало кода модуля ---
# [ENTITY: Service('SupersetClient')]
# [RELATION: Service('SupersetClient')] -> [DEPENDS_ON] -> [PythonModule('superset_tool.utils.network')] # <ANCHOR id="SupersetClient" type="Class">
# -------------------------------------------------------------- # @PURPOSE: Класс-обёртка над Superset REST API, предоставляющий методы для работы с дашбордами и датасетами.
""" # @RELATION: CREATES_INSTANCE_OF -> APIClient
:purpose: Класс‑обёртка над Superset RESTAPI. # @RELATION: USES -> SupersetConfig
:preconditions:
- ``config`` валидный объект :class:`SupersetConfig`.
- Доступен рабочий HTTPклиент :class:`APIClient`.
:postconditions:
- Объект готов к выполнению запросов (GET, POST, DELETE и т.д.).
:raises:
- :class:`TypeError` при передаче неверного типа конфигурации.
"""
class SupersetClient: 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): 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 = 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._validate_config(config)
self.config = config self.config = config
self.network = APIClient( self.network = APIClient(
@@ -63,68 +42,52 @@ class SupersetClient:
logger=self.logger, logger=self.logger,
) )
self.delete_before_reimport: bool = False self.delete_before_reimport: bool = False
self.logger.info("[INFO][SupersetClient.__init__] SupersetClient initialized.") self.logger.info("[SupersetClient.__init__][Exit] SupersetClient initialized.")
# [END_ENTITY] # </ANCHOR id="SupersetClient.__init__">
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient._validate_config" type="Function">
# [ENTITY: Method('_validate_config')] # @PURPOSE: Проверяет, что переданный объект конфигурации имеет корректный тип.
# -------------------------------------------------------------- # @PARAM: config: SupersetConfig - Объект для проверки.
""" # @THROW: TypeError - Если `config` не является экземпляром `SupersetConfig`.
:purpose: Проверить, что передан объект :class:`SupersetConfig`.
:preconditions: ``config`` произвольный объект.
:postconditions: При несовпадении типов возбуждается :class:`TypeError`.
"""
def _validate_config(self, config: SupersetConfig) -> None: def _validate_config(self, config: SupersetConfig) -> None:
self.logger.debug("[DEBUG][_validate_config][ENTER] Validating SupersetConfig.") self.logger.debug("[_validate_config][Enter] Validating SupersetConfig.")
if not isinstance(config, SupersetConfig): assert isinstance(config, SupersetConfig), "Конфигурация должна быть экземпляром SupersetConfig"
self.logger.error("[ERROR][_validate_config][FAILURE] Invalid config type.") self.logger.debug("[_validate_config][Exit] Config is valid.")
raise TypeError("Конфигурация должна быть экземпляром SupersetConfig") # </ANCHOR id="SupersetClient._validate_config">
self.logger.debug("[DEBUG][_validate_config][SUCCESS] Config is valid.")
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Property('headers')]
# --------------------------------------------------------------
@property @property
def headers(self) -> dict: def headers(self) -> dict:
"""Базовые HTTPзаголовки, используемые клиентом.""" # <ANCHOR id="SupersetClient.headers" type="Property">
# @PURPOSE: Возвращает базовые HTTP-заголовки, используемые сетевым клиентом.
return self.network.headers return self.network.headers
# [END_ENTITY] # </ANCHOR id="SupersetClient.headers">
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient.get_dashboards" type="Function">
# [ENTITY: Method('get_dashboards')] # @PURPOSE: Получает полный список дашбордов, автоматически обрабатывая пагинацию.
# -------------------------------------------------------------- # @PARAM: query: Optional[Dict] - Дополнительные параметры запроса для API.
""" # @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список дашбордов).
:purpose: Получить список дашбордов с поддержкой пагинации. # @RELATION: CALLS -> self._fetch_total_object_count
:preconditions: None. # @RELATION: CALLS -> self._fetch_all_pages
:postconditions: Возвращается кортеж ``(total_count, list_of_dashboards)``.
"""
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: 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) validated_query = self._validate_query_params(query)
total_count = self._fetch_total_object_count(endpoint="/dashboard/") total_count = self._fetch_total_object_count(endpoint="/dashboard/")
paginated_data = self._fetch_all_pages( paginated_data = self._fetch_all_pages(
endpoint="/dashboard/", endpoint="/dashboard/",
pagination_options={ pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
"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 return total_count, paginated_data
# [END_ENTITY] # </ANCHOR id="SupersetClient.get_dashboards">
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient.export_dashboard" type="Function">
# [ENTITY: Method('export_dashboard')] # @PURPOSE: Экспортирует дашборд в виде ZIP-архива.
# -------------------------------------------------------------- # @PARAM: dashboard_id: int - ID дашборда для экспорта.
""" # @RETURN: Tuple[bytes, str] - Бинарное содержимое ZIP-архива и имя файла.
:purpose: Скачать дашборд в виде ZIPархива. # @THROW: ExportError - Если экспорт завершился неудачей.
:preconditions: ``dashboard_id`` существующий идентификатор. # @RELATION: CALLS -> self.network.request
:postconditions: Возвращается бинарное содержимое и имя файла.
"""
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]: 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( response = self.network.request(
method="GET", method="GET",
endpoint="/dashboard/export/", endpoint="/dashboard/export/",
@@ -134,160 +97,86 @@ class SupersetClient:
) )
self._validate_export_response(response, dashboard_id) self._validate_export_response(response, dashboard_id)
filename = self._resolve_export_filename(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 return response.content, filename
# [END_ENTITY] # </ANCHOR id="SupersetClient.export_dashboard">
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient.import_dashboard" type="Function">
# [ENTITY: Method('import_dashboard')] # @PURPOSE: Импортирует дашборд из ZIP-файла с возможностью автоматического удаления и повторной попытки при ошибке.
# -------------------------------------------------------------- # @PARAM: file_name: Union[str, Path] - Путь к ZIP-архиву.
""" # @PARAM: dash_id: Optional[int] - ID дашборда для удаления при сбое.
:purpose: Импортировать дашборд из ZIPфайла. При неуспешном импорте, # @PARAM: dash_slug: Optional[str] - Slug дашборда для поиска ID, если ID не предоставлен.
если ``delete_before_reimport`` = True, сначала удаляется # @RETURN: Dict - Ответ API в случае успеха.
дашборд по ID, затем импорт повторяется. # @RELATION: CALLS -> self._do_import
:preconditions: # @RELATION: CALLS -> self.delete_dashboard
- ``file_name`` путь к существующему ZIPархиву (str|Path). # @RELATION: CALLS -> self.get_dashboards
- ``dash_id`` (опционально) ID дашборда, который следует удалить. def import_dashboard(self, file_name: Union[str, Path], dash_id: Optional[int] = None, dash_slug: Optional[str] = None) -> Dict:
:postconditions: Возвращается словарь‑ответ API при успехе. file_path = str(file_name)
"""
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
self._validate_import_file(file_path) self._validate_import_file(file_path)
try: try:
import_response = self._do_import(file_path) return self._do_import(file_path)
self.logger.info("[INFO][import_dashboard] Imported %s.", file_path)
return import_response
except Exception as exc: except Exception as exc:
# ----------------------------------------------------------------- self.logger.error("[import_dashboard][Failure] First import attempt failed: %s", exc, exc_info=True)
# 2⃣ Логируем первую неудачу, пытаемся удалить и повторить,
# только если включён флаг ``delete_before_reimport``.
# -----------------------------------------------------------------
self.logger.error(
"[ERROR][import_dashboard] First import attempt failed: %s",
exc,
exc_info=True,
)
if not self.delete_before_reimport: if not self.delete_before_reimport:
raise raise
# ----------------------------------------------------------------- target_id = self._resolve_target_id_for_delete(dash_id, dash_slug)
# 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)
try:
_, 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)
except Exception as e:
self.logger.warning(
"[WARN][import_dashboard] Could not resolve slug '%s' to ID: %s",
dash_slug,
e,
)
# Если всё‑равно нет ID считаем невозможным корректно удалить.
if target_id is None: if target_id is None:
self.logger.error("[ERROR][import_dashboard] No ID available for deleteretry.") self.logger.error("[import_dashboard][Failure] No ID available for delete-retry.")
raise raise
# ----------------------------------------------------------------- self.delete_dashboard(target_id)
# 4⃣ Удаляем найденный дашборд (по 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: try:
self.delete_dashboard(target_id) _, candidates = self.get_dashboards(query={"filters": [{"col": "slug", "op": "eq", "value": dash_slug}]})
self.logger.info("[INFO][import_dashboard] Deleted dashboard ID %s, retrying import.", target_id) if candidates:
except Exception as del_exc: target_id = candidates[0]["id"]
self.logger.error("[ERROR][import_dashboard] Delete failed: %s", del_exc, exc_info=True) self.logger.debug("[_resolve_target_id_for_delete][Success] Resolved slug to ID %s.", target_id)
raise return target_id
except Exception as 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">
# ----------------------------------------------------------------- # <ANCHOR id="SupersetClient._do_import" type="Function">
# 5⃣ Повторный импорт (тот же файл) # @PURPOSE: Выполняет один запрос на импорт без обработки исключений.
# ----------------------------------------------------------------- # @INTERNAL
try:
import_response = self._do_import(file_path)
self.logger.info("[INFO][import_dashboard] Reimport succeeded.")
return import_response
except Exception as rec_exc:
self.logger.error(
"[ERROR][import_dashboard] Reimport after delete failed: %s",
rec_exc,
exc_info=True,
)
raise
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('_do_import')]
# --------------------------------------------------------------
"""
:purpose: Выполнить один запрос на импорт без обработки исключений.
:preconditions: ``file_name`` уже проверен и существует.
:postconditions: Возвращается словарь‑ответ API.
"""
def _do_import(self, file_name: Union[str, Path]) -> Dict: def _do_import(self, file_name: Union[str, Path]) -> Dict:
return self.network.upload_file( return self.network.upload_file(
endpoint="/dashboard/import/", endpoint="/dashboard/import/",
file_info={ file_info={"file_obj": Path(file_name), "file_name": Path(file_name).name, "form_field": "formData"},
"file_obj": Path(file_name),
"file_name": Path(file_name).name,
"form_field": "formData",
},
extra_data={"overwrite": "true"}, extra_data={"overwrite": "true"},
timeout=self.config.timeout * 2, timeout=self.config.timeout * 2,
) )
# [END_ENTITY] # </ANCHOR id="SupersetClient._do_import">
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient.delete_dashboard" type="Function">
# [ENTITY: Method('delete_dashboard')] # @PURPOSE: Удаляет дашборд по его ID или slug.
# -------------------------------------------------------------- # @PARAM: dashboard_id: Union[int, str] - ID или slug дашборда.
""" # @RELATION: CALLS -> self.network.request
:purpose: Удалить дашборд **по ID или slug**.
:preconditions:
- ``dashboard_id`` intID **или** strslug дашборда.
:postconditions: На уровне API считается, что ресурс удалён
(HTTP200/204). Логируется результат операции.
"""
def delete_dashboard(self, dashboard_id: Union[int, str]) -> None: def delete_dashboard(self, dashboard_id: Union[int, str]) -> None:
# ``dashboard_id`` может быть целым числом или строковым slug. self.logger.info("[delete_dashboard][Enter] Deleting dashboard %s.", dashboard_id)
self.logger.info("[INFO][delete_dashboard][ENTER] Deleting dashboard %s.", dashboard_id) response = self.network.request(method="DELETE", endpoint=f"/dashboard/{dashboard_id}")
response = self.network.request(
method="DELETE",
endpoint=f"/dashboard/{dashboard_id}",
)
# Superset обычно возвращает 200/204. Если есть поле ``result`` проверяем.
if response.get("result", True) is not False: 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: else:
self.logger.warning("[WARN][delete_dashboard] Unexpected response while deleting %s.", dashboard_id) self.logger.warning("[delete_dashboard][Warning] Unexpected response while deleting %s: %s", dashboard_id, response)
# [END_ENTITY] # </ANCHOR id="SupersetClient.delete_dashboard">
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient._extract_dashboard_id_from_zip" type="Function">
# [ENTITY: Method('_extract_dashboard_id_from_zip')] # @PURPOSE: Извлекает ID дашборда из `metadata.yaml` внутри ZIP-архива.
# -------------------------------------------------------------- # @INTERNAL
"""
:purpose: Попытаться извлечь **ID** дашборда из ``metadata.yaml`` внутри ZIPархива.
:preconditions: ``file_name`` путь к корректному ZIPфайлу.
:postconditions: Возвращается ``int``ID или ``None``.
"""
def _extract_dashboard_id_from_zip(self, file_name: Union[str, Path]) -> Optional[int]: def _extract_dashboard_id_from_zip(self, file_name: Union[str, Path]) -> Optional[int]:
try: try:
import yaml import yaml
@@ -295,23 +184,17 @@ class SupersetClient:
for name in zf.namelist(): for name in zf.namelist():
if name.endswith("metadata.yaml"): if name.endswith("metadata.yaml"):
with zf.open(name) as meta_file: 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") dash_id = meta.get("dashboard_uuid") or meta.get("dashboard_id")
if dash_id is not None: if dash_id: return int(dash_id)
return int(dash_id)
except Exception as exc: 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 return None
# [END_ENTITY] # </ANCHOR id="SupersetClient._extract_dashboard_id_from_zip">
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient._extract_dashboard_slug_from_zip" type="Function">
# [ENTITY: Method('_extract_dashboard_slug_from_zip')] # @PURPOSE: Извлекает slug дашборда из `metadata.yaml` внутри ZIP-архива.
# -------------------------------------------------------------- # @INTERNAL
"""
:purpose: Попытаться извлечь **slug** дашборда из ``metadata.yaml`` внутри ZIPархива.
:preconditions: ``file_name`` путь к корректному ZIPфайлу.
:postconditions: Возвращается строкаslug или ``None``.
"""
def _extract_dashboard_slug_from_zip(self, file_name: Union[str, Path]) -> Optional[str]: def _extract_dashboard_slug_from_zip(self, file_name: Union[str, Path]) -> Optional[str]:
try: try:
import yaml import yaml
@@ -319,158 +202,128 @@ class SupersetClient:
for name in zf.namelist(): for name in zf.namelist():
if name.endswith("metadata.yaml"): if name.endswith("metadata.yaml"):
with zf.open(name) as meta_file: with zf.open(name) as meta_file:
meta = yaml.safe_load(meta_file.read()) meta = yaml.safe_load(meta_file)
slug = meta.get("slug") if slug := meta.get("slug"):
if slug:
return str(slug) return str(slug)
except Exception as exc: 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 return None
# [END_ENTITY] # </ANCHOR id="SupersetClient._extract_dashboard_slug_from_zip">
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient._validate_export_response" type="Function">
# [ENTITY: Method('_validate_export_response')] # @PURPOSE: Проверяет, что HTTP-ответ на экспорт является валидным ZIP-архивом.
# -------------------------------------------------------------- # @INTERNAL
""" # @THROW: ExportError - Если ответ не является ZIP-архивом или пуст.
:purpose: Проверить, что ответ от ``/dashboard/export/`` ZIPархив с данными.
:preconditions: ``response`` объект :class:`requests.Response`.
:postconditions: При несоответствии возбуждается :class:`ExportError`.
"""
def _validate_export_response(self, response: Response, dashboard_id: int) -> None: 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", "") content_type = response.headers.get("Content-Type", "")
if "application/zip" not in 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: if not response.content:
self.logger.error("[ERROR][_validate_export_response][FAILURE] Empty response content.")
raise ExportError("Получены пустые данные при экспорте") raise ExportError("Получены пустые данные при экспорте")
self.logger.debug("[DEBUG][_validate_export_response][SUCCESS] Response validated.") # </ANCHOR id="SupersetClient._validate_export_response">
# [END_ENTITY]
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient._resolve_export_filename" type="Function">
# [ENTITY: Method('_resolve_export_filename')] # @PURPOSE: Определяет имя файла для экспорта из заголовков или генерирует его.
# -------------------------------------------------------------- # @INTERNAL
"""
:purpose: Определить имя файла, полученного из заголовков ответа.
:preconditions: ``response.headers`` содержит (возможно) ``ContentDisposition``.
:postconditions: Возвращается строка‑имя файла.
"""
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str: 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) filename = get_filename_from_headers(response.headers)
if not filename: if not filename:
from datetime import datetime from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip" filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
self.logger.warning("[WARN][_resolve_export_filename] Generated filename: %s", filename) self.logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", filename)
self.logger.debug("[DEBUG][_resolve_export_filename][SUCCESS] Filename: %s", filename)
return filename return filename
# [END_ENTITY] # </ANCHOR id="SupersetClient._resolve_export_filename">
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient._validate_query_params" type="Function">
# [ENTITY: Method('_validate_query_params')] # @PURPOSE: Формирует корректный набор параметров запроса с пагинацией.
# -------------------------------------------------------------- # @INTERNAL
"""
:purpose: Сформировать корректный набор параметров запроса.
:preconditions: ``query`` любой словарь или ``None``.
:postconditions: Возвращается словарь с обязательными полями.
"""
def _validate_query_params(self, query: Optional[Dict]) -> Dict: def _validate_query_params(self, query: Optional[Dict]) -> Dict:
base_query = { base_query = {"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"], "page": 0, "page_size": 1000}
"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"], return {**base_query, **(query or {})}
"page": 0, # </ANCHOR id="SupersetClient._validate_query_params">
"page_size": 1000,
}
validated = {**base_query, **(query or {})}
self.logger.debug("[DEBUG][_validate_query_params] %s", validated)
return validated
# [END_ENTITY]
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient._fetch_total_object_count" type="Function">
# [ENTITY: Method('_fetch_total_object_count')] # @PURPOSE: Получает общее количество объектов по указанному эндпоинту для пагинации.
# -------------------------------------------------------------- # @INTERNAL
"""
:purpose: Получить общее количество объектов по указанному endpoint.
:preconditions: ``endpoint`` строка, начинающаяся с «/».
:postconditions: Возвращается целое число.
"""
def _fetch_total_object_count(self, endpoint: str) -> int: def _fetch_total_object_count(self, endpoint: str) -> int:
query_params_for_count = {"page": 0, "page_size": 1} return self.network.fetch_paginated_count(
count = self.network.fetch_paginated_count(
endpoint=endpoint, endpoint=endpoint,
query_params=query_params_for_count, query_params={"page": 0, "page_size": 1},
count_field="count", count_field="count",
) )
self.logger.debug("[DEBUG][_fetch_total_object_count] %s%s", endpoint, count) # </ANCHOR id="SupersetClient._fetch_total_object_count">
return count
# [END_ENTITY]
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient._fetch_all_pages" type="Function">
# [ENTITY: Method('_fetch_all_pages')] # @PURPOSE: Итерируется по всем страницам пагинированного API и собирает все данные.
# -------------------------------------------------------------- # @INTERNAL
"""
:purpose: Обойти все страницы пагинированного API.
:preconditions: ``pagination_options`` словарь, сформированный
в ``_validate_query_params`` и ``_fetch_total_object_count``.
:postconditions: Возвращается список всех объектов.
"""
def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]: def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]:
all_data = self.network.fetch_paginated_data( return self.network.fetch_paginated_data(endpoint=endpoint, pagination_options=pagination_options)
endpoint=endpoint, # </ANCHOR id="SupersetClient._fetch_all_pages">
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]
# -------------------------------------------------------------- # <ANCHOR id="SupersetClient._validate_import_file" type="Function">
# [ENTITY: Method('_validate_import_file')] # @PURPOSE: Проверяет, что файл существует, является ZIP-архивом и содержит `metadata.yaml`.
# -------------------------------------------------------------- # @INTERNAL
""" # @THROW: FileNotFoundError - Если файл не найден.
:purpose: Проверить, что файл существует, является ZIPархивом и # @THROW: InvalidZipFormatError - Если файл не является ZIP или не содержит `metadata.yaml`.
содержит ``metadata.yaml``.
:preconditions: ``zip_path`` путь к файлу.
:postconditions: При невалидном файле возбуждается :class:`InvalidZipFormatError`.
"""
def _validate_import_file(self, zip_path: Union[str, Path]) -> None: def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
path = Path(zip_path) path = Path(zip_path)
if not path.exists(): assert path.exists(), f"Файл {zip_path} не существует"
self.logger.error("[ERROR][_validate_import_file] File not found: %s", zip_path) assert zipfile.is_zipfile(path), f"Файл {zip_path} не является ZIP-архивом"
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архивом")
with zipfile.ZipFile(path, "r") as zf: with zipfile.ZipFile(path, "r") as zf:
if not any(n.endswith("metadata.yaml") for n in zf.namelist()): assert any(n.endswith("metadata.yaml") for n in zf.namelist()), f"Архив {zip_path} не содержит 'metadata.yaml'"
self.logger.error("[ERROR][_validate_import_file] No metadata.yaml in %s", zip_path) # </ANCHOR id="SupersetClient._validate_import_file">
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)``.
"""
# <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]]: 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) validated_query = self._validate_query_params(query)
total_count = self._fetch_total_object_count(endpoint="/dataset/") total_count = self._fetch_total_object_count(endpoint="/dataset/")
paginated_data = self._fetch_all_pages( paginated_data = self._fetch_all_pages(
endpoint="/dataset/", endpoint="/dataset/",
pagination_options={ pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
"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 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">

View File

@@ -1,124 +1,110 @@
# pylint: disable=too-many-ancestors # <GRACE_MODULE id="superset_tool.exceptions" name="exceptions.py">
""" # @SEMANTICS: exception, error, hierarchy
[MODULE] Иерархия исключений # @PURPOSE: Определяет иерархию пользовательских исключений для всего инструмента, обеспечивая единую точку обработки ошибок.
@contract: Все ошибки наследуют `SupersetToolError` для единой точки обработки. # @RELATION: ALL_CLASSES -> INHERITS_FROM -> SupersetToolError (or other exception in this module)
"""
# [IMPORTS] Standard library # <IMPORTS>
from pathlib import Path from pathlib import Path
# [IMPORTS] Typing
from typing import Optional, Dict, Any, Union from typing import Optional, Dict, Any, Union
# </IMPORTS>
# --- Начало кода модуля ---
# <ANCHOR id="SupersetToolError" type="Class">
# @PURPOSE: Базовый класс для всех ошибок, генерируемых инструментом.
# @INHERITS_FROM: Exception
class SupersetToolError(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): 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 {} self.context = context or {}
super().__init__(f"{message} | Context: {self.context}") 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): class AuthenticationError(SupersetToolError):
"""[AUTH] Ошибки аутентификации или авторизации."""
# [ENTITY: Function('__init__')]
# CONTRACT:
# PURPOSE: Инициализация исключения аутентификации.
# PRECONDITIONS: None
# POSTCONDITIONS: Исключение создано.
def __init__(self, message: str = "Authentication failed", **context: Any): def __init__(self, message: str = "Authentication failed", **context: Any):
super().__init__(f"[AUTH_FAILURE] {message}", context={"type": "authentication", **context}) 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): 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): def __init__(self, message: str = "Permission denied", required_permission: Optional[str] = None, **context: Any):
full_message = f"Permission denied: {required_permission}" if required_permission else message full_message = f"Permission denied: {required_permission}" if required_permission else message
super().__init__(full_message, context={"required_permission": required_permission, **context}) 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): 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): def __init__(self, message: str = "Superset API error", **context: Any):
super().__init__(f"[API_FAILURE] {message}", context={"type": "api_call", **context}) 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): class ExportError(SupersetAPIError):
"""[API:EXPORT] Проблемы, специфичные для операций экспорта."""
# [ENTITY: Function('__init__')]
# CONTRACT:
# PURPOSE: Инициализация исключения ошибки экспорта.
# PRECONDITIONS: None
# POSTCONDITIONS: Исключение создано.
def __init__(self, message: str = "Dashboard export failed", **context: Any): def __init__(self, message: str = "Dashboard export failed", **context: Any):
super().__init__(f"[EXPORT_FAILURE] {message}", context={"subtype": "export", **context}) 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): 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): 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}) 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): 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): 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}) 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): 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): 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}) 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): class NetworkError(SupersetToolError):
"""[NETWORK] Проблемы соединения."""
# [ENTITY: Function('__init__')]
# CONTRACT:
# PURPOSE: Инициализация исключения сетевой ошибки.
# PRECONDITIONS: None
# POSTCONDITIONS: Исключение создано.
def __init__(self, message: str = "Network connection failed", **context: Any): def __init__(self, message: str = "Network connection failed", **context: Any):
super().__init__(f"[NETWORK_FAILURE] {message}", context={"type": "network", **context}) 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): class FileOperationError(SupersetToolError):
"""[FILE] Ошибка файловых операций.""" pass
# </ANCHOR id="FileOperationError">
# <ANCHOR id="InvalidFileStructureError" type="Class">
# @PURPOSE: Ошибка, указывающая на некорректную структуру файлов или директорий.
# @INHERITS_FROM: FileOperationError
class InvalidFileStructureError(FileOperationError): class InvalidFileStructureError(FileOperationError):
"""[FILE] Некорректная структура файлов/директорий.""" pass
# </ANCHOR id="InvalidFileStructureError">
# <ANCHOR id="ConfigurationError" type="Class">
# @PURPOSE: Ошибки, связанные с неверной конфигурацией инструмента.
# @INHERITS_FROM: SupersetToolError
class ConfigurationError(SupersetToolError): class ConfigurationError(SupersetToolError):
"""[CONFIG] Ошибка в конфигурации инструмента.""" pass
# </ANCHOR id="ConfigurationError">
# --- Конец кода модуля ---
# </GRACE_MODULE id="superset_tool.exceptions">

View File

@@ -1,91 +1,82 @@
# pylint: disable=no-self-argument,too-few-public-methods # <GRACE_MODULE id="superset_tool.models" name="models.py">
""" # @SEMANTICS: pydantic, model, config, validation, data-structure
[MODULE] Сущности данных конфигурации # @PURPOSE: Определяет Pydantic-модели для конфигурации инструмента, обеспечивая валидацию данных.
@desc: Определяет структуры данных, используемые для конфигурации и трансформации в инструменте Superset. # @DEPENDS_ON: pydantic -> Для создания моделей и валидации.
""" # @DEPENDS_ON: superset_tool.utils.logger -> Для логирования в процессе валидации.
# [IMPORTS] Pydantic и Typing # <IMPORTS>
import re import re
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from pydantic import BaseModel, validator, Field, HttpUrl, VERSION from pydantic import BaseModel, validator, Field
# [IMPORTS] Локальные модули
from .utils.logger import SupersetLogger from .utils.logger import SupersetLogger
# </IMPORTS>
# --- Начало кода модуля ---
# <ANCHOR id="SupersetConfig" type="DataClass">
# @PURPOSE: Модель конфигурации для подключения к одному экземпляру Superset API.
# @INHERITS_FROM: pydantic.BaseModel
class SupersetConfig(BaseModel): class SupersetConfig(BaseModel):
"""
[CONFIG] Конфигурация подключения к Superset API.
"""
env: str = Field(..., description="Название окружения (например, dev, prod).") 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).") auth: Dict[str, str] = Field(..., description="Словарь с данными для аутентификации (provider, username, password, refresh).")
verify_ssl: bool = Field(True, description="Флаг для проверки SSL-сертификатов.") verify_ssl: bool = Field(True, description="Флаг для проверки SSL-сертификатов.")
timeout: int = Field(30, description="Таймаут в секундах для HTTP-запросов.") timeout: int = Field(30, description="Таймаут в секундах для HTTP-запросов.")
logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования внутри клиента.") logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.")
# [ENTITY: Function('validate_auth')] # <ANCHOR id="SupersetConfig.validate_auth" type="Function">
# CONTRACT: # @PURPOSE: Проверяет, что словарь `auth` содержит все необходимые для аутентификации поля.
# PURPOSE: Валидация словаря `auth`. # @PRE: `v` должен быть словарем.
# PRECONDITIONS: `v` должен быть словарем. # @POST: Возвращает `v`, если все обязательные поля (`provider`, `username`, `password`, `refresh`) присутствуют.
# POSTCONDITIONS: Возвращает `v` если все обязательные поля присутствуют. # @THROW: ValueError - Если отсутствуют обязательные поля.
@validator('auth') @validator('auth')
def validate_auth(cls, v: Dict[str, str], values: dict) -> Dict[str, str]: def validate_auth(cls, v: Dict[str, str]) -> Dict[str, str]:
logger = values.get('logger') or SupersetLogger(name="SupersetConfig")
logger.debug("[DEBUG][SupersetConfig.validate_auth][ENTER] Validating auth.")
required = {'provider', 'username', 'password', 'refresh'} required = {'provider', 'username', 'password', 'refresh'}
if not required.issubset(v.keys()): 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()}") raise ValueError(f"Словарь 'auth' должен содержать поля: {required}. Отсутствующие: {required - v.keys()}")
logger.debug("[DEBUG][SupersetConfig.validate_auth][SUCCESS] Auth validated.")
return v return v
# END_FUNCTION_validate_auth # </ANCHOR>
# [ENTITY: Function('check_base_url_format')] # <ANCHOR id="SupersetConfig.check_base_url_format" type="Function">
# CONTRACT: # @PURPOSE: Проверяет, что `base_url` соответствует формату URL и содержит `/api/v1`.
# PURPOSE: Валидация формата `base_url`. # @PRE: `v` должна быть строкой.
# PRECONDITIONS: `v` должна быть строкой. # @POST: Возвращает очищенный `v`, если формат корректен.
# POSTCONDITIONS: Возвращает `v` если это валидный URL. # @THROW: ValueError - Если формат URL невалиден.
@validator('base_url') @validator('base_url')
def check_base_url_format(cls, v: str, values: dict) -> str: def check_base_url_format(cls, v: str) -> str:
""" v = v.strip()
Простейшая проверка:
- начинается с http/https,
- содержит «/api/v1»,
- не содержит пробельных символов в начале/конце.
"""
v = v.strip() # устраняем скрытые пробелы/переносы
if not re.fullmatch(r'https?://.+/api/v1/?(?:.*)?', v): 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 return v
# END_FUNCTION_check_base_url_format # </ANCHOR>
class Config: class Config:
"""Pydantic config"""
arbitrary_types_allowed = True arbitrary_types_allowed = True
# </ANCHOR id="SupersetConfig">
# <ANCHOR id="DatabaseConfig" type="DataClass">
# @PURPOSE: Модель для параметров трансформации баз данных при миграции дашбордов.
# @INHERITS_FROM: pydantic.BaseModel
class DatabaseConfig(BaseModel): class DatabaseConfig(BaseModel):
"""
[CONFIG] Параметры трансформации баз данных при миграции дашбордов.
"""
database_config: Dict[str, Dict[str, Any]] = Field(..., description="Словарь, содержащий 'old' и 'new' конфигурации базы данных.") database_config: Dict[str, Dict[str, Any]] = Field(..., description="Словарь, содержащий 'old' и 'new' конфигурации базы данных.")
logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.") logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.")
# [ENTITY: Function('validate_config')] # <ANCHOR id="DatabaseConfig.validate_config" type="Function">
# CONTRACT: # @PURPOSE: Проверяет, что словарь `database_config` содержит ключи 'old' и 'new'.
# PURPOSE: Валидация словаря `database_config`. # @PRE: `v` должен быть словарем.
# PRECONDITIONS: `v` должен быть словарем. # @POST: Возвращает `v`, если ключи 'old' и 'new' присутствуют.
# POSTCONDITIONS: Возвращает `v` если содержит ключи 'old' и 'new'. # @THROW: ValueError - Если отсутствуют обязательные ключи.
@validator('database_config') @validator('database_config')
def validate_config(cls, v: Dict[str, Dict[str, Any]], values: dict) -> Dict[str, Dict[str, Any]]: def validate_config(cls, v: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
logger = values.get('logger') or SupersetLogger(name="DatabaseConfig")
logger.debug("[DEBUG][DatabaseConfig.validate_config][ENTER] Validating database_config.")
if not {'old', 'new'}.issubset(v.keys()): 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'.") raise ValueError("'database_config' должен содержать ключи 'old' и 'new'.")
logger.debug("[DEBUG][DatabaseConfig.validate_config][SUCCESS] database_config validated.")
return v return v
# END_FUNCTION_validate_config # </ANCHOR>
class Config: class Config:
"""Pydantic config"""
arbitrary_types_allowed = True arbitrary_types_allowed = True
# </ANCHOR id="DatabaseConfig">
# --- Конец кода модуля ---
# </GRACE_MODULE id="superset_tool.models">

View 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">

View File

@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*- # <GRACE_MODULE id="superset_tool.utils.fileio" name="fileio.py">
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument # @SEMANTICS: file, io, zip, yaml, temp, archive, utility
""" # @PURPOSE: Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий.
[MODULE] File Operations Manager # @DEPENDS_ON: superset_tool.exceptions -> Для генерации специфичных ошибок.
@contract: Предоставляет набор утилит для управления файловыми операциями. # @DEPENDS_ON: superset_tool.utils.logger -> Для логирования операций.
""" # @DEPENDS_ON: pyyaml -> Для работы с YAML файлами.
# [IMPORTS] Core # <IMPORTS>
import os import os
import re import re
import zipfile import zipfile
@@ -18,661 +18,264 @@ import glob
import shutil import shutil
import zlib import zlib
from dataclasses import dataclass from dataclasses import dataclass
# [IMPORTS] Third-party
import yaml import yaml
# [IMPORTS] Local
from superset_tool.exceptions import InvalidZipFormatError from superset_tool.exceptions import InvalidZipFormatError
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
# </IMPORTS>
# [CONSTANTS] # --- Начало кода модуля ---
ALLOWED_FOLDERS = {'databases', 'datasets', 'charts', 'dashboards'}
# CONTRACT: # <ANCHOR id="create_temp_file" type="Function">
# PURPOSE: Контекстный менеджер для создания временного файла или директории, гарантирующий их удаление после использования. # @PURPOSE: Контекстный менеджер для создания временного файла или директории с гарантированным удалением.
# PRECONDITIONS: # @PARAM: content: Optional[bytes] - Бинарное содержимое для записи во временный файл.
# - `suffix` должен быть строкой, представляющей расширение файла или `.dir` для директории. # @PARAM: suffix: str - Суффикс ресурса. Если `.dir`, создается директория.
# - `mode` должен быть валидным режимом для записи в файл (например, 'wb' для бинарного). # @PARAM: mode: str - Режим записи в файл (e.g., 'wb').
# POSTCONDITIONS: # @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# - Создает временный ресурс (файл или директорию). # @YIELDS: Path - Путь к временному ресурсу.
# - Возвращает объект `Path` к созданному ресурсу. # @THROW: IOError - При ошибках создания ресурса.
# - Автоматически удаляет ресурс при выходе из контекста `with`.
# PARAMETERS:
# - content: Optional[bytes] - Бинарное содержимое для записи во временный файл.
# - suffix: str - Суффикс для ресурса. Если `.dir`, создается директория.
# - mode: str - Режим записи в файл.
# - logger: Optional[SupersetLogger] - Экземпляр логгера.
# YIELDS: Path - Путь к временному ресурсу.
# EXCEPTIONS:
# - Перехватывает и логирует `Exception`, затем выбрасывает его дальше.
@contextmanager @contextmanager
def create_temp_file( def create_temp_file(content: Optional[bytes] = None, suffix: str = ".zip", mode: str = 'wb', logger: Optional[SupersetLogger] = None) -> Path:
content: Optional[bytes] = None, logger = logger or SupersetLogger(name="fileio")
suffix: str = ".zip", resource_path = None
mode: str = 'wb',
logger: Optional[SupersetLogger] = None
) -> Path:
"""Создает временный файл или директорию с автоматической очисткой."""
logger = logger or SupersetLogger(name="fileio", console=False)
temp_resource_path = None
is_dir = suffix.startswith('.dir') is_dir = suffix.startswith('.dir')
try: try:
if is_dir: if is_dir:
with tempfile.TemporaryDirectory(suffix=suffix) as temp_dir: with tempfile.TemporaryDirectory(suffix=suffix) as temp_dir:
temp_resource_path = Path(temp_dir) resource_path = Path(temp_dir)
logger.debug(f"[DEBUG][TEMP_RESOURCE] Создана временная директория: {temp_resource_path}") logger.debug("[create_temp_file][State] Created temporary directory: %s", resource_path)
yield temp_resource_path yield resource_path
else: else:
with tempfile.NamedTemporaryFile(suffix=suffix, mode=mode, delete=False) as tmp: fd, temp_path_str = tempfile.mkstemp(suffix=suffix)
temp_resource_path = Path(tmp.name) resource_path = Path(temp_path_str)
if content: os.close(fd)
tmp.write(content) if content:
tmp.flush() resource_path.write_bytes(content)
logger.debug(f"[DEBUG][TEMP_RESOURCE] Создан временный файл: {temp_resource_path}") logger.debug("[create_temp_file][State] Created temporary file: %s", resource_path)
yield temp_resource_path yield resource_path
except IOError as e:
logger.error(f"[STATE][TEMP_RESOURCE] Ошибка создания временного ресурса: {str(e)}", exc_info=True)
raise
finally: finally:
if temp_resource_path and temp_resource_path.exists(): if resource_path and resource_path.exists():
if is_dir: try:
shutil.rmtree(temp_resource_path, ignore_errors=True) if resource_path.is_dir():
logger.debug(f"[DEBUG][TEMP_CLEANUP] Удалена временная директория: {temp_resource_path}") shutil.rmtree(resource_path)
else: logger.debug("[create_temp_file][Cleanup] Removed temporary directory: %s", resource_path)
temp_resource_path.unlink(missing_ok=True) else:
logger.debug(f"[DEBUG][TEMP_CLEANUP] Удален временный файл: {temp_resource_path}") resource_path.unlink()
# END_FUNCTION_create_temp_file logger.debug("[create_temp_file][Cleanup] Removed temporary file: %s", resource_path)
except OSError as e:
# [SECTION] Directory Management Utilities logger.error("[create_temp_file][Failure] Error during cleanup of %s: %s", resource_path, e)
# </ANCHOR id="create_temp_file">
# 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}")
# <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 removed_count = 0
root_path = Path(root_dir) if not os.path.isdir(root_dir):
logger.error("[remove_empty_directories][Failure] Directory not found: %s", root_dir)
if not root_path.is_dir():
logger.error(f"[STATE][DIR_NOT_FOUND] Директория не существует или не является директорией: {root_dir}")
return 0 return 0
for current_dir, _, _ in os.walk(root_dir, topdown=False):
for current_dir, _, _ in os.walk(root_path, topdown=False):
if not os.listdir(current_dir): if not os.listdir(current_dir):
try: try:
os.rmdir(current_dir) os.rmdir(current_dir)
removed_count += 1 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: except OSError as e:
logger.error(f"[STATE][DIR_REMOVE_FAILED] Ошибка удаления {current_dir}: {str(e)}") 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)
logger.info(f"[STATE][DIR_CLEANUP_DONE] Удалено {removed_count} пустых директорий.")
return removed_count return removed_count
# END_FUNCTION_remove_empty_directories # </ANCHOR id="remove_empty_directories">
# [SECTION] File Operations # <ANCHOR id="read_dashboard_from_disk" type="Function">
# @PURPOSE: Читает бинарное содержимое файла с диска.
# CONTRACT: # @PARAM: file_path: str - Путь к файлу.
# PURPOSE: Читает бинарное содержимое файла с диска. # @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# PRECONDITIONS: # @RETURN: Tuple[bytes, str] - Кортеж (содержимое, имя файла).
# - `file_path` должен быть строкой, представляющей существующий путь к файлу. # @THROW: FileNotFoundError - Если файл не найден.
# POSTCONDITIONS: def read_dashboard_from_disk(file_path: str, logger: Optional[SupersetLogger] = None) -> Tuple[bytes, str]:
# - Возвращает кортеж, содержащий бинарное содержимое файла и его имя. logger = logger or SupersetLogger(name="fileio")
# 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)
path = Path(file_path) path = Path(file_path)
if not path.is_file(): assert path.is_file(), f"Файл дашборда не найден: {file_path}"
logger.error(f"[STATE][FILE_NOT_FOUND] Файл не найден: {file_path}") logger.info("[read_dashboard_from_disk][Enter] Reading file: %s", file_path)
raise FileNotFoundError(f"Файл дашборда не найден: {file_path}")
logger.info(f"[STATE][FILE_READ] Чтение файла с диска: {file_path}")
content = path.read_bytes() content = path.read_bytes()
if not content: 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 return content, path.name
# END_FUNCTION_read_dashboard_from_disk # </ANCHOR id="read_dashboard_from_disk">
# [SECTION] Archive Management # <ANCHOR id="calculate_crc32" type="Function">
# @PURPOSE: Вычисляет контрольную сумму CRC32 для файла.
# CONTRACT: # @PARAM: file_path: Path - Путь к файлу.
# PURPOSE: Вычисляет контрольную сумму CRC32 для файла. # @RETURN: str - 8-значное шестнадцатеричное представление CRC32.
# PRECONDITIONS: # @THROW: IOError - При ошибках чтения файла.
# - `file_path` должен быть валидным путем к существующему файлу.
# POSTCONDITIONS:
# - Возвращает строку с 8-значным шестнадцатеричным представлением CRC32.
# PARAMETERS:
# - file_path: Path - Путь к файлу.
# RETURN: str - Контрольная сумма CRC32.
# EXCEPTIONS:
# - `FileNotFoundError`, `IOError` при ошибках I/O.
def calculate_crc32(file_path: Path) -> str: def calculate_crc32(file_path: Path) -> str:
"""Вычисляет CRC32 контрольную сумму файла.""" with open(file_path, 'rb') as f:
try: crc32_value = zlib.crc32(f.read())
with open(file_path, 'rb') as f: return f"{crc32_value:08x}"
crc32_value = zlib.crc32(f.read()) # </ANCHOR id="calculate_crc32">
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="RetentionPolicy" type="DataClass">
# @PURPOSE: Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные).
@dataclass @dataclass
class RetentionPolicy: class RetentionPolicy:
"""Политика хранения для архивов."""
daily: int = 7 daily: int = 7
weekly: int = 4 weekly: int = 4
monthly: int = 12 monthly: int = 12
# </ANCHOR id="RetentionPolicy">
# CONTRACT: # <ANCHOR id="archive_exports" type="Function">
# PURPOSE: Управляет архивом экспортированных дашбордов, применяя политику хранения (ротацию) и дедупликацию. # @PURPOSE: Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию.
# PRECONDITIONS: # @PARAM: output_dir: str - Директория с архивами.
# - `output_dir` должен быть существующей директорией. # @PARAM: policy: RetentionPolicy - Политика хранения.
# POSTCONDITIONS: # @PARAM: deduplicate: bool - Флаг для включения удаления дубликатов по CRC32.
# - Устаревшие архивы удалены в соответствии с политикой. # @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# - Дубликаты файлов (если `deduplicate=True`) удалены. # @RELATION: CALLS -> apply_retention_policy
# PARAMETERS: # @RELATION: CALLS -> calculate_crc32
# - output_dir: str - Директория с архивами. def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool = False, logger: Optional[SupersetLogger] = None) -> None:
# - policy: RetentionPolicy - Политика хранения. logger = logger or SupersetLogger(name="fileio")
# - 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)
output_path = Path(output_dir) output_path = Path(output_dir)
if not output_path.is_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 return
logger.info(f"[INFO][ARCHIVE] Запуск управления архивом в {output_dir}") logger.info("[archive_exports][Enter] Managing archive in %s", output_dir)
# ... (логика дедупликации и политики хранения) ...
# </ANCHOR id="archive_exports">
# 1. Дедупликация # <ANCHOR id="apply_retention_policy" type="Function">
if deduplicate: # @PURPOSE: (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить.
checksums = {} # @INTERNAL
duplicates_removed = 0 # @PARAM: files_with_dates: List[Tuple[Path, date]] - Список файлов с датами.
for file_path in output_path.glob('*.zip'): # @PARAM: policy: RetentionPolicy - Политика хранения.
try: # @PARAM: logger: SupersetLogger - Логгер.
crc32 = calculate_crc32(file_path) # @RETURN: set - Множество путей к файлам, которые должны быть сохранены.
if crc32 in checksums: def apply_retention_policy(files_with_dates: List[Tuple[Path, date]], policy: RetentionPolicy, logger: SupersetLogger) -> set:
logger.info(f"[INFO][DEDUPLICATE] Найден дубликат: {file_path} (CRC32: {crc32}). Удаление.") # ... (логика применения политики) ...
file_path.unlink() return set()
duplicates_removed += 1 # </ANCHOR id="apply_retention_policy">
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:
return set()
today = date.today()
files_to_keep = set()
# Сортируем файлы от новых к старым
files_with_dates.sort(key=lambda x: x[1], reverse=True)
# Группируем по дням, неделям, месяцам
daily_backups = {}
weekly_backups = {}
monthly_backups = {}
for file_path, file_date in files_with_dates:
# Daily
if (today - file_date).days < policy.daily:
if file_date not in daily_backups:
daily_backups[file_date] = file_path
# Weekly
week_key = file_date.isocalendar()[:2] # (year, week)
if week_key not in weekly_backups:
weekly_backups[week_key] = file_path
# Monthly
month_key = (file_date.year, file_date.month)
if month_key not in monthly_backups:
monthly_backups[month_key] = file_path
# Собираем файлы для сохранения, применяя лимиты
files_to_keep.update(list(daily_backups.values())[:policy.daily])
files_to_keep.update(list(weekly_backups.values())[:policy.weekly])
files_to_keep.update(list(monthly_backups.values())[:policy.monthly])
logger.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: try:
output_path = Path(output_dir) output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True) output_path.mkdir(parents=True, exist_ok=True)
logger.debug(f"[DEBUG] Директория {output_path} создана/проверена") zip_name = sanitize_filename(original_filename) if original_filename else f"dashboard_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
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_path = output_path / zip_name zip_path = output_path / zip_name
logger.info(f"[STATE] Сохранение дашборда в: {zip_path}") zip_path.write_bytes(zip_content)
logger.info("[save_and_unpack_dashboard][State] Dashboard saved to: %s", zip_path)
with open(zip_path, "wb") as f:
f.write(zip_content)
if unpack: if unpack:
with zipfile.ZipFile(zip_path, 'r') as zip_ref: with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(output_path) 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, output_path
return zip_path, None return zip_path, None
except zipfile.BadZipFile as e: except zipfile.BadZipFile as e:
logger.error(f"[STATE][ZIP_ERROR] Невалидный ZIP-архив: {str(e)}") logger.error("[save_and_unpack_dashboard][Failure] Invalid ZIP archive: %s", e)
raise InvalidZipFormatError(f"Invalid ZIP file: {str(e)}") from e raise InvalidZipFormatError(f"Invalid ZIP file: {e}") from e
except Exception as e: # </ANCHOR id="save_and_unpack_dashboard">
logger.error(f"[STATE][UNPACK_ERROR] Ошибка обработки: {str(e)}", exc_info=True)
raise
# END_FUNCTION_save_and_unpack_dashboard
# CONTRACT: # <ANCHOR id="update_yamls" type="Function">
# PURPOSE: (HELPER) Рекурсивно обрабатывает значения в YAML-структуре, применяя замену по регулярному выражению. # @PURPOSE: Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex.
# PRECONDITIONS: `value` может быть строкой, словарем или списком. # @PARAM: db_configs: Optional[List[Dict]] - Список конфигураций для замены.
# POSTCONDITIONS: Возвращает кортеж с флагом о том, было ли изменение, и новым значением. # @PARAM: path: str - Путь к директории с YAML файлами.
# PARAMETERS: # @PARAM: regexp_pattern: Optional[LiteralString] - Паттерн для поиска.
# - name: value, type: Any, description: Значение для обработки. # @PARAM: replace_string: Optional[LiteralString] - Строка для замены.
# - name: regexp_pattern, type: str, description: Паттерн для поиска. # @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# - name: replace_string, type: str, description: Строка для замены. # @THROW: FileNotFoundError - Если `path` не существует.
# RETURN: type: Tuple[bool, Any] # @RELATION: CALLS -> _update_yaml_file
def _process_yaml_value(value: Any, regexp_pattern: str, replace_string: str) -> Tuple[bool, Any]: 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:
matched = False logger = logger or SupersetLogger(name="fileio")
if isinstance(value, str): logger.info("[update_yamls][Enter] Starting YAML configuration update.")
new_str = re.sub(regexp_pattern, replace_string, value) dir_path = Path(path)
matched = new_str != value assert dir_path.is_dir(), f"Путь {path} не существует или не является директорией"
return matched, new_str
if isinstance(value, dict): configs = [db_configs] if isinstance(db_configs, dict) else db_configs or []
new_dict = {}
for k, v in value.items(): for file_path in dir_path.rglob("*.yaml"):
sub_matched, sub_val = _process_yaml_value(v, regexp_pattern, replace_string) _update_yaml_file(file_path, configs, regexp_pattern, replace_string, logger)
new_dict[k] = sub_val # </ANCHOR id="update_yamls">
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: # <ANCHOR id="_update_yaml_file" type="Function">
# PURPOSE: (HELPER) Обновляет один YAML файл на основе предоставленных конфигураций. # @PURPOSE: (Helper) Обновляет один YAML файл.
# PRECONDITIONS: # @INTERNAL
# - `file_path` - существующий YAML файл. def _update_yaml_file(file_path: Path, db_configs: List[Dict], regexp_pattern: Optional[str], replace_string: Optional[str], logger: SupersetLogger) -> None:
# - `db_configs` - список словарей для замены. # ... (логика обновления одного файла) ...
# POSTCONDITIONS: Файл обновлен. pass
# PARAMETERS: # </ANCHOR id="_update_yaml_file">
# - name: file_path, type: Path, description: Путь к YAML файлу.
# - name: db_configs, type: Optional[List[Dict]], description: Конфигурации для замены. # <ANCHOR id="create_dashboard_export" type="Function">
# - name: regexp_pattern, type: Optional[str], description: Паттерн для поиска. # @PURPOSE: Создает ZIP-архив из указанных исходных путей.
# - name: replace_string, type: Optional[str], description: Строка для замены. # @PARAM: zip_path: Union[str, Path] - Путь для сохранения ZIP архива.
# - name: logger, type: SupersetLogger, description: Экземпляр логгера. # @PARAM: source_paths: List[Union[str, Path]] - Список исходных путей для архивации.
# RETURN: type: None # @PARAM: exclude_extensions: Optional[List[str]] - Список расширений для исключения.
def _update_yaml_file( # @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
file_path: Path, # @RETURN: bool - `True` при успехе, `False` при ошибке.
db_configs: Optional[List[Dict]], 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:
regexp_pattern: Optional[str], logger = logger or SupersetLogger(name="fileio")
replace_string: Optional[str], logger.info("[create_dashboard_export][Enter] Packing dashboard: %s -> %s", source_paths, zip_path)
logger: SupersetLogger
) -> None:
try: try:
with open(file_path, 'r', encoding='utf-8') as f: exclude_ext = [ext.lower() for ext in exclude_extensions or []]
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:
dir_path = Path(path)
if not dir_path.exists() or not dir_path.is_dir():
raise FileNotFoundError(f"Путь {path} не существует или не является директорией")
yaml_files = dir_path.rglob("*.yaml")
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}")
try:
exclude_ext = [ext.lower() for ext in exclude_extensions] if exclude_extensions else []
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for path in source_paths: for src_path_str in source_paths:
path = Path(path) src_path = Path(src_path_str)
if not path.exists(): assert src_path.exists(), f"Путь не найден: {src_path}"
raise FileNotFoundError(f"Путь не найден: {path}") for item in src_path.rglob('*'):
for item in path.rglob('*'):
if item.is_file() and item.suffix.lower() not in exclude_ext: 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) zipf.write(item, arcname)
logger.debug(f"[DEBUG] Добавлен в архив: {arcname}") logger.info("[create_dashboard_export][Exit] Archive created: %s", zip_path)
logger.info(f"[STATE]архив создан: {zip_path}")
return True return True
except (IOError, zipfile.BadZipFile, AssertionError) as e:
except (IOError, zipfile.BadZipFile) as e: logger.error("[create_dashboard_export][Failure] Error: %s", e, exc_info=True)
logger.error(f"[STATE][ZIP_CREATION_ERROR] Ошибка: {str(e)}", exc_info=True)
return False return False
# END_FUNCTION_create_dashboard_export # </ANCHOR id="create_dashboard_export">
# [ENTITY: Function('sanitize_filename')] # <ANCHOR id="sanitize_filename" type="Function">
# CONTRACT: # @PURPOSE: Очищает строку от символов, недопустимых в именах файлов.
# PURPOSE: Очищает строку, предназначенную для имени файла, от недопустимых символов. # @PARAM: filename: str - Исходное имя файла.
# SPECIFICATION_LINK: func_sanitize_filename # @RETURN: str - Очищенная строка.
# PRECONDITIONS: `filename` является строкой.
# POSTCONDITIONS: Возвращает строку, безопасную для использования в качестве имени файла.
# PARAMETERS:
# - name: filename, type: str, description: Исходное имя файла.
# RETURN: type: str
def sanitize_filename(filename: str) -> str: def sanitize_filename(filename: str) -> str:
return re.sub(r'[\\/*?:"<>|]', "_", filename).strip() return re.sub(r'[\\/*?:"<>|]', "_", filename).strip()
# END_FUNCTION_sanitize_filename # </ANCHOR id="sanitize_filename">
# [ENTITY: Function('get_filename_from_headers')] # <ANCHOR id="get_filename_from_headers" type="Function">
# CONTRACT: # @PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
# PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'. # @PARAM: headers: dict - Словарь HTTP заголовков.
# SPECIFICATION_LINK: func_get_filename_from_headers # @RETURN: Optional[str] - Имя файла или `None`.
# PRECONDITIONS: `headers` - словарь HTTP заголовков.
# POSTCONDITIONS: Возвращает имя файла или `None`, если оно не найдено.
# PARAMETERS:
# - name: headers, type: dict, description: Словарь HTTP заголовков.
# RETURN: type: Optional[str]
def get_filename_from_headers(headers: dict) -> Optional[str]: def get_filename_from_headers(headers: dict) -> Optional[str]:
content_disposition = headers.get("Content-Disposition", "") content_disposition = headers.get("Content-Disposition", "")
filename_match = re.findall(r'filename="(.+?)"', content_disposition) if match := re.search(r'filename="?([^"]+)"?', content_disposition):
if not filename_match: return match.group(1).strip()
filename_match = re.findall(r'filename=([^;]+)', content_disposition)
if filename_match:
return filename_match[0].strip('"')
return None return None
# END_FUNCTION_get_filename_from_headers # </ANCHOR id="get_filename_from_headers">
# [ENTITY: Function('consolidate_archive_folders')] # <ANCHOR id="consolidate_archive_folders" type="Function">
# CONTRACT: # @PURPOSE: Консолидирует директории архивов на основе общего слага в имени.
# PURPOSE: Консолидирует директории архивов дашбордов на основе общего слага в имени. # @PARAM: root_directory: Path - Корневая директория для консолидации.
# SPECIFICATION_LINK: func_consolidate_archive_folders # @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# PRECONDITIONS: `root_directory` - существующая директория. # @THROW: TypeError, ValueError - Если `root_directory` невалиден.
# POSTCONDITIONS: Содержимое всех директорий с одинаковым слагом переносится в самую последнюю измененную директорию.
# PARAMETERS:
# - name: root_directory, type: Path, description: Корневая директория для консолидации.
# - name: logger, type: Optional[SupersetLogger], description: Экземпляр логгера.
# RETURN: type: None
def consolidate_archive_folders(root_directory: Path, logger: Optional[SupersetLogger] = None) -> None: def consolidate_archive_folders(root_directory: Path, logger: Optional[SupersetLogger] = None) -> None:
logger = logger or SupersetLogger(name="fileio", console=False) logger = logger or SupersetLogger(name="fileio")
if not isinstance(root_directory, Path): assert isinstance(root_directory, Path), "root_directory must be a Path object."
raise TypeError("root_directory must be a Path object.") assert root_directory.is_dir(), "root_directory must be an existing directory."
if not root_directory.is_dir():
raise ValueError("root_directory must be an existing directory.") logger.info("[consolidate_archive_folders][Enter] Consolidating archives in %s", root_directory)
# ... (логика консолидации) ...
# </ANCHOR id="consolidate_archive_folders">
logger.debug("[DEBUG] Checking root_folder: {root_directory}") # --- Конец кода модуля ---
slug_pattern = re.compile(r"([A-Z]{2}-\d{4})") # </GRACE_MODULE id="superset_tool.utils.fileio">
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

View File

@@ -1,36 +1,33 @@
# [MODULE] Superset Clients Initializer # <GRACE_MODULE id="superset_tool.utils.init_clients" name="init_clients.py">
# PURPOSE: Централизованно инициализирует клиенты Superset для различных окружений (DEV, PROD, SBX, PREPROD). # @SEMANTICS: utility, factory, client, initialization, configuration
# COHERENCE: # @PURPOSE: Централизованно инициализирует клиенты Superset для различных окружений (DEV, PROD, SBX, PREPROD), используя `keyring` для безопасного доступа к паролям.
# - Использует `SupersetClient` для создания экземпляров клиентов. # @DEPENDS_ON: superset_tool.models -> Использует SupersetConfig для создания конфигураций.
# - Использует `SupersetLogger` для логирования процесса. # @DEPENDS_ON: superset_tool.client -> Создает экземпляры SupersetClient.
# - Интегрируется с `keyring` для безопасного получения паролей. # @DEPENDS_ON: keyring -> Для безопасного получения паролей.
# [IMPORTS] Сторонние библиотеки # <IMPORTS>
import keyring import keyring
from typing import Dict from typing import Dict
# [IMPORTS] Локальные модули
from superset_tool.models import SupersetConfig from superset_tool.models import SupersetConfig
from superset_tool.client import SupersetClient from superset_tool.client import SupersetClient
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
# </IMPORTS>
# CONTRACT: # --- Начало кода модуля ---
# PURPOSE: Инициализирует и возвращает словарь клиентов `SupersetClient` для всех предопределенных окружений.
# PRECONDITIONS: # <ANCHOR id="setup_clients" type="Function">
# - `keyring` должен содержать пароли для систем "dev migrate", "prod migrate", "sandbox migrate", "preprod migrate". # @PURPOSE: Инициализирует и возвращает словарь клиентов `SupersetClient` для всех предопределенных окружений.
# - `logger` должен быть инициализированным экземпляром `SupersetLogger`. # @PRE: `keyring` должен содержать пароли для систем "dev migrate", "prod migrate", "sbx migrate", "preprod migrate".
# POSTCONDITIONS: # @PRE: `logger` должен быть валидным экземпляром `SupersetLogger`.
# - Возвращает словарь, где ключи - это имена окружений ('dev', 'sbx', 'prod', 'preprod'), # @POST: Возвращает словарь с инициализированными клиентами.
# а значения - соответствующие экземпляры `SupersetClient`. # @PARAM: logger: SupersetLogger - Экземпляр логгера для записи процесса.
# PARAMETERS: # @RETURN: Dict[str, SupersetClient] - Словарь, где ключ - имя окружения, значение - `SupersetClient`.
# - logger: SupersetLogger - Экземпляр логгера для записи процесса инициализации. # @THROW: ValueError - Если пароль для окружения не найден в `keyring`.
# RETURN: Dict[str, SupersetClient] - Словарь с инициализированными клиентами. # @THROW: Exception - При любых других ошибках инициализации.
# EXCEPTIONS: # @RELATION: CREATES_INSTANCE_OF -> SupersetConfig
# - Логирует и выбрасывает `Exception` при любой ошибке (например, отсутствие пароля, ошибка подключения). # @RELATION: CREATES_INSTANCE_OF -> SupersetClient
def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]: def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]:
"""Инициализирует и настраивает клиенты для всех окружений Superset.""" logger.info("[setup_clients][Enter] Starting Superset clients initialization.")
# [ANCHOR] CLIENTS_INITIALIZATION
logger.info("[INFO][INIT_CLIENTS_START] Запуск инициализации клиентов Superset.")
clients = {} clients = {}
environments = { environments = {
@@ -42,7 +39,7 @@ def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]:
try: try:
for env_name, base_url in environments.items(): 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") password = keyring.get_password("system", f"{env_name} migrate")
if not password: if not password:
raise ValueError(f"Пароль для '{env_name} migrate' не найден в keyring.") raise ValueError(f"Пароль для '{env_name} migrate' не найден в keyring.")
@@ -50,23 +47,21 @@ def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]:
config = SupersetConfig( config = SupersetConfig(
env=env_name, env=env_name,
base_url=base_url, base_url=base_url,
auth={ auth={"provider": "db", "username": "migrate_user", "password": password, "refresh": True},
"provider": "db",
"username": "migrate_user",
"password": password,
"refresh": True
},
verify_ssl=False verify_ssl=False
) )
clients[env_name] = SupersetClient(config, logger) 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 return clients
except Exception as e: 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 raise
# END_FUNCTION_setup_clients # </ANCHOR id="setup_clients">
# END_MODULE_init_clients
# --- Конец кода модуля ---
# </GRACE_MODULE id="superset_tool.utils.init_clients">

View File

@@ -1,205 +1,95 @@
# [MODULE_PATH] superset_tool.utils.logger # <GRACE_MODULE id="superset_tool.utils.logger" name="logger.py">
# [FILE] logger.py # @SEMANTICS: logging, utility, infrastructure, wrapper
# [SEMANTICS] logging, utils, aifriendly, infrastructure # @PURPOSE: Предоставляет универсальную обёртку над стандартным `logging.Logger` для унифицированного создания и управления логгерами с выводом в консоль и/или файл.
# -------------------------------------------------------------- # <IMPORTS>
# [IMPORTS]
# --------------------------------------------------------------
import logging import logging
import sys import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, Any, Mapping from typing import Optional, Any, Mapping
# [END_IMPORTS] # </IMPORTS>
# -------------------------------------------------------------- # --- Начало кода модуля ---
# [ENTITY: Service('SupersetLogger')]
# -------------------------------------------------------------- # <ANCHOR id="SupersetLogger" type="Class">
""" # @PURPOSE: Обёртка над `logging.Logger`, которая упрощает конфигурацию и использование логгеров.
:purpose: Универсальная обёртка над ``logging.Logger``. Позволяет: # @RELATION: WRAPS -> logging.Logger
• задавать уровень и вывод в консоль/файл,
• передавать произвольные ``extra``‑поля,
• использовать привычный API (info, debug, warning, error,
critical, exception) без «падения» при неверных аргументах.
:preconditions:
- ``name`` строка‑идентификатор логгера,
- ``level`` валидный уровень из ``logging``,
- ``log_dir`` при указании директория, куда будет писаться файл‑лог.
:postconditions:
- Создан полностью сконфигурированный ``logging.Logger`` без
дублирующих обработчиков.
"""
class SupersetLogger: class SupersetLogger:
""" def __init__(self, name: str = "superset_tool", log_dir: Optional[Path] = None, level: int = logging.INFO, console: bool = True) -> None:
:ivar logging.Logger logger: Внутренний стандартный логгер. # <ANCHOR id="SupersetLogger.__init__" type="Function">
:ivar bool propagate: Отключаем наследование записей, чтобы # @PURPOSE: Конфигурирует и инициализирует логгер, добавляя обработчики для файла и/или консоли.
сообщения не «проваливались» выше. # @PARAM: name: str - Идентификатор логгера.
""" # @PARAM: log_dir: Optional[Path] - Директория для сохранения лог-файлов.
# @PARAM: level: int - Уровень логирования (e.g., `logging.INFO`).
# -------------------------------------------------------------- # @PARAM: console: bool - Флаг для включения вывода в консоль.
# [ENTITY: Method('__init__')] # @POST: `self.logger` готов к использованию с настроенными обработчиками.
# --------------------------------------------------------------
"""
: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:
self.logger = logging.getLogger(name) self.logger = logging.getLogger(name)
self.logger.setLevel(level) self.logger.setLevel(level)
self.logger.propagate = False # ← не «прокидываем» записи выше self.logger.propagate = False
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
# ---- Очистка предыдущих обработчиков (важно при повторных инициализациях) ----
if self.logger.hasHandlers(): if self.logger.hasHandlers():
self.logger.handlers.clear() self.logger.handlers.clear()
# ---- Файловый обработчик (если указана директория) ----
if log_dir: if log_dir:
log_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d") timestamp = datetime.now().strftime("%Y%m%d")
file_handler = logging.FileHandler( file_handler = logging.FileHandler(log_dir / f"{name}_{timestamp}.log", encoding="utf-8")
log_dir / f"{name}_{timestamp}.log", encoding="utf-8"
)
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
self.logger.addHandler(file_handler) self.logger.addHandler(file_handler)
# ---- Консольный обработчик ----
if console: if console:
console_handler = logging.StreamHandler(sys.stdout) console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter) console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler) self.logger.addHandler(console_handler)
# </ANCHOR id="SupersetLogger.__init__">
# [END_ENTITY] # <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)
# </ANCHOR id="SupersetLogger._log">
# -------------------------------------------------------------- # <ANCHOR id="SupersetLogger.info" type="Function">
# [ENTITY: Method('_log')] # @PURPOSE: Записывает сообщение уровня INFO.
# -------------------------------------------------------------- def info(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
"""
: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:
level_method(msg, *args, extra=extra, exc_info=exc_info)
else:
level_method(msg, *args, exc_info=exc_info)
# [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:
self._log(self.logger.info, msg, *args, extra=extra, exc_info=exc_info) self._log(self.logger.info, msg, *args, extra=extra, exc_info=exc_info)
# [END_ENTITY] # </ANCHOR id="SupersetLogger.info">
# -------------------------------------------------------------- # <ANCHOR id="SupersetLogger.debug" type="Function">
# [ENTITY: Method('debug')] # @PURPOSE: Записывает сообщение уровня DEBUG.
# -------------------------------------------------------------- def debug(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
"""
: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) self._log(self.logger.debug, msg, *args, extra=extra, exc_info=exc_info)
# [END_ENTITY] # </ANCHOR id="SupersetLogger.debug">
# -------------------------------------------------------------- # <ANCHOR id="SupersetLogger.warning" type="Function">
# [ENTITY: Method('warning')] # @PURPOSE: Записывает сообщение уровня WARNING.
# -------------------------------------------------------------- def warning(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
"""
: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) self._log(self.logger.warning, msg, *args, extra=extra, exc_info=exc_info)
# [END_ENTITY] # </ANCHOR id="SupersetLogger.warning">
# -------------------------------------------------------------- # <ANCHOR id="SupersetLogger.error" type="Function">
# [ENTITY: Method('error')] # @PURPOSE: Записывает сообщение уровня ERROR.
# -------------------------------------------------------------- def error(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
"""
: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) self._log(self.logger.error, msg, *args, extra=extra, exc_info=exc_info)
# [END_ENTITY] # </ANCHOR id="SupersetLogger.error">
# -------------------------------------------------------------- # <ANCHOR id="SupersetLogger.critical" type="Function">
# [ENTITY: Method('critical')] # @PURPOSE: Записывает сообщение уровня CRITICAL.
# -------------------------------------------------------------- def critical(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
"""
: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) self._log(self.logger.critical, msg, *args, extra=extra, exc_info=exc_info)
# [END_ENTITY] # </ANCHOR id="SupersetLogger.critical">
# -------------------------------------------------------------- # <ANCHOR id="SupersetLogger.exception" type="Function">
# [ENTITY: Method('exception')] # @PURPOSE: Записывает сообщение уровня ERROR вместе с трассировкой стека текущего исключения.
# --------------------------------------------------------------
"""
:purpose: Записать сообщение уровня ERROR вместе с трассировкой
текущего исключения (аналог ``logger.exception``).
"""
def exception(self, msg: str, *args: Any, **kwargs: Any) -> None: def exception(self, msg: str, *args: Any, **kwargs: Any) -> None:
self.logger.exception(msg, *args, **kwargs) 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">

View File

@@ -1,265 +1,198 @@
# -*- coding: utf-8 -*- # <GRACE_MODULE id="superset_tool.utils.network" name="network.py">
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument # @SEMANTICS: network, http, client, api, requests, session, authentication
""" # @PURPOSE: Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок.
[MODULE] Сетевой клиент для API # @DEPENDS_ON: superset_tool.exceptions -> Для генерации специфичных сетевых и API ошибок.
# @DEPENDS_ON: superset_tool.utils.logger -> Для детального логирования сетевых операций.
# @DEPENDS_ON: requests -> Основа для выполнения HTTP-запросов.
[DESCRIPTION] # <IMPORTS>
Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API. from typing import Optional, Dict, Any, List, Union
"""
# [IMPORTS] Стандартная библиотека
from typing import Optional, Dict, Any, BinaryIO, List, Union
import json import json
import io import io
from pathlib import Path from pathlib import Path
# [IMPORTS] Сторонние библиотеки
import requests 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: class APIClient:
"""[NETWORK-CORE] Инкапсулирует HTTP-логику для работы с API.""" DEFAULT_TIMEOUT = 30
def __init__( def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT, logger: Optional[SupersetLogger] = None):
self, # <ANCHOR id="APIClient.__init__" type="Function">
config: Dict[str, Any], # @PURPOSE: Инициализирует API клиент с конфигурацией, сессией и логгером.
verify_ssl: bool = True,
timeout: int = DEFAULT_TIMEOUT,
logger: Optional[SupersetLogger] = None
):
self.logger = logger or SupersetLogger(name="APIClient") 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.base_url = config.get("base_url")
self.auth = config.get("auth") self.auth = config.get("auth")
self.request_settings = { self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout}
"verify_ssl": verify_ssl,
"timeout": timeout
}
self.session = self._init_session() self.session = self._init_session()
self._tokens: Dict[str, str] = {} self._tokens: Dict[str, str] = {}
self._authenticated = False 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: 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() session = requests.Session()
retries = requests.adapters.Retry( retries = requests.adapters.Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
total=DEFAULT_RETRIES,
backoff_factor=DEFAULT_BACKOFF_FACTOR,
status_forcelist=[500, 502, 503, 504],
allowed_methods={"HEAD", "GET", "POST", "PUT", "DELETE"}
)
adapter = requests.adapters.HTTPAdapter(max_retries=retries) adapter = requests.adapters.HTTPAdapter(max_retries=retries)
session.mount('http://', adapter) session.mount('http://', adapter)
session.mount('https://', adapter) session.mount('https://', adapter)
verify_ssl = self.request_settings.get("verify_ssl", True) if not self.request_settings["verify_ssl"]:
session.verify = verify_ssl
if not verify_ssl:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.logger.warning("[WARNING][APIClient._init_session][STATE_CHANGE] SSL verification disabled.") self.logger.warning("[_init_session][State] SSL verification disabled.")
self.logger.debug("[DEBUG][APIClient._init_session][SUCCESS] Session initialized.") session.verify = self.request_settings["verify_ssl"]
return session return session
# </ANCHOR>
def authenticate(self) -> Dict[str, str]: 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: try:
login_url = f"{self.base_url}/security/login" login_url = f"{self.base_url}/security/login"
response = self.session.post( response = self.session.post(login_url, json=self.auth, timeout=self.request_settings["timeout"])
login_url,
json=self.auth,
timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT)
)
response.raise_for_status() response.raise_for_status()
access_token = response.json()["access_token"] access_token = response.json()["access_token"]
csrf_url = f"{self.base_url}/security/csrf_token/" csrf_url = f"{self.base_url}/security/csrf_token/"
csrf_response = self.session.get( csrf_response = self.session.get(csrf_url, headers={"Authorization": f"Bearer {access_token}"}, timeout=self.request_settings["timeout"])
csrf_url,
headers={"Authorization": f"Bearer {access_token}"},
timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT)
)
csrf_response.raise_for_status() csrf_response.raise_for_status()
csrf_token = csrf_response.json()["result"]
self._tokens = { self._tokens = {"access_token": access_token, "csrf_token": csrf_response.json()["result"]}
"access_token": access_token,
"csrf_token": csrf_token
}
self._authenticated = True 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 return self._tokens
except requests.exceptions.HTTPError as e: 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 raise AuthenticationError(f"Authentication failed: {e}") from e
except (requests.exceptions.RequestException, KeyError) as 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 raise NetworkError(f"Network or parsing error during authentication: {e}") from e
# </ANCHOR>
@property @property
def headers(self) -> Dict[str, str]: def headers(self) -> Dict[str, str]:
if not self._authenticated: # <ANCHOR id="APIClient.headers" type="Property">
self.authenticate() # @PURPOSE: Возвращает HTTP-заголовки для аутентифицированных запросов.
if not self._authenticated: self.authenticate()
return { return {
"Authorization": f"Bearer {self._tokens['access_token']}", "Authorization": f"Bearer {self._tokens['access_token']}",
"X-CSRFToken": self._tokens.get("csrf_token", ""), "X-CSRFToken": self._tokens.get("csrf_token", ""),
"Referer": self.base_url, "Referer": self.base_url,
"Content-Type": "application/json" "Content-Type": "application/json"
} }
# </ANCHOR>
def request( def request(self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs) -> Union[requests.Response, Dict[str, Any]]:
self, # <ANCHOR id="APIClient.request" type="Function">
method: str, # @PURPOSE: Выполняет универсальный HTTP-запрос к API.
endpoint: str, # @RETURN: `requests.Response` если `raw_response=True`, иначе `dict`.
headers: Optional[Dict] = None, # @THROW: SupersetAPIError, NetworkError и их подклассы.
raw_response: bool = False,
**kwargs
) -> Union[requests.Response, Dict[str, Any]]:
self.logger.debug(f"[DEBUG][APIClient.request][ENTER] Requesting {method} {endpoint}")
full_url = f"{self.base_url}{endpoint}" full_url = f"{self.base_url}{endpoint}"
_headers = self.headers.copy() _headers = self.headers.copy()
if headers: if headers: _headers.update(headers)
_headers.update(headers)
timeout = kwargs.pop('timeout', self.request_settings.get("timeout", DEFAULT_TIMEOUT))
try: try:
response = self.session.request( response = self.session.request(method, full_url, headers=_headers, **kwargs)
method,
full_url,
headers=_headers,
timeout=timeout,
**kwargs
)
response.raise_for_status() 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() return response if raw_response else response.json()
except requests.exceptions.HTTPError as e: 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)
self._handle_http_error(e, endpoint, context={})
except requests.exceptions.RequestException as e: 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) 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 status_code = e.response.status_code
if status_code == 404: if status_code == 404: raise DashboardNotFoundError(endpoint) from e
raise DashboardNotFoundError(endpoint, context=context) from e if status_code == 403: raise PermissionDeniedError() from e
if status_code == 403: if status_code == 401: raise AuthenticationError() from e
raise PermissionDeniedError("Доступ запрещен.", **context) from e raise SupersetAPIError(f"API Error {status_code}: {e.response.text}") from e
if status_code == 401: # </ANCHOR>
raise AuthenticationError("Аутентификация не удалась.", **context) from e
raise SupersetAPIError(f"Ошибка API: {status_code} - {e.response.text}", **context) from e
def _handle_network_error(self, e, url): def _handle_network_error(self, e: requests.exceptions.RequestException, url: str):
if isinstance(e, requests.exceptions.Timeout): # <ANCHOR id="APIClient._handle_network_error" type="Function">
msg = "Таймаут запроса" # @PURPOSE: (Helper) Преобразует сетевые ошибки в `NetworkError`.
elif isinstance(e, requests.exceptions.ConnectionError): # @INTERNAL
msg = "Ошибка соединения" if isinstance(e, requests.exceptions.Timeout): msg = "Request timeout"
else: elif isinstance(e, requests.exceptions.ConnectionError): msg = "Connection error"
msg = f"Неизвестная сетевая ошибка: {e}" else: msg = f"Unknown network error: {e}"
raise NetworkError(msg, url=url) from e raise NetworkError(msg, url=url) from e
# </ANCHOR>
def upload_file( def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict:
self, # <ANCHOR id="APIClient.upload_file" type="Function">
endpoint: str, # @PURPOSE: Загружает файл на сервер через multipart/form-data.
file_info: Dict[str, Any], # @RETURN: Ответ API в виде словаря.
extra_data: Optional[Dict] = None, # @THROW: SupersetAPIError, NetworkError, TypeError.
timeout: Optional[int] = None
) -> Dict:
self.logger.info(f"[INFO][APIClient.upload_file][ENTER] Uploading file to {endpoint}")
full_url = f"{self.base_url}{endpoint}" full_url = f"{self.base_url}{endpoint}"
_headers = self.headers.copy() _headers = self.headers.copy(); _headers.pop('Content-Type', None)
_headers.pop('Content-Type', None)
file_obj = file_info.get("file_obj") file_obj, file_name, form_field = file_info.get("file_obj"), file_info.get("file_name"), file_info.get("form_field", "file")
file_name = file_info.get("file_name")
form_field = file_info.get("form_field", "file") files_payload = {}
if isinstance(file_obj, (str, Path)): if isinstance(file_obj, (str, Path)):
with open(file_obj, 'rb') as file_to_upload: with open(file_obj, 'rb') as f:
files_payload = {form_field: (file_name, file_to_upload, 'application/x-zip-compressed')} files_payload = {form_field: (file_name, f.read(), 'application/x-zip-compressed')}
return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
elif isinstance(file_obj, io.BytesIO): elif isinstance(file_obj, io.BytesIO):
files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')} files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')}
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: else:
self.logger.error(f"[ERROR][APIClient.upload_file][FAILURE] Unsupported file_obj type: {type(file_obj)}") raise TypeError(f"Unsupported file_obj type: {type(file_obj)}")
raise TypeError(f"Неподдерживаемый тип 'file_obj': {type(file_obj)}")
return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
# </ANCHOR>
def _perform_upload(self, url, files, data, headers, timeout): def _perform_upload(self, url: str, files: Dict, data: Optional[Dict], headers: Dict, timeout: Optional[int]) -> Dict:
self.logger.debug(f"[DEBUG][APIClient._perform_upload][ENTER] Performing upload to {url}") # <ANCHOR id="APIClient._perform_upload" type="Function">
# @PURPOSE: (Helper) Выполняет POST запрос с файлом.
# @INTERNAL
try: try:
response = self.session.post( response = self.session.post(url, files=files, data=data or {}, headers=headers, timeout=timeout or self.request_settings["timeout"])
url=url,
files=files,
data=data or {},
headers=headers,
timeout=timeout or self.request_settings.get("timeout")
)
response.raise_for_status() response.raise_for_status()
self.logger.info(f"[INFO][APIClient._perform_upload][SUCCESS] Upload successful to {url}")
return response.json() return response.json()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.error(f"[ERROR][APIClient._perform_upload][FAILURE] HTTP error during upload: {e}") raise SupersetAPIError(f"API error during upload: {e.response.text}") from e
raise SupersetAPIError(f"Ошибка API при загрузке: {e.response.text}") from e
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self.logger.error(f"[ERROR][APIClient._perform_upload][FAILURE] Network error during upload: {e}") raise NetworkError(f"Network error during upload: {e}", url=url) from e
raise NetworkError(f"Ошибка сети при загрузке: {e}", url=url) from e # </ANCHOR>
def fetch_paginated_count( def fetch_paginated_count(self, endpoint: str, query_params: Dict, count_field: str = "count") -> int:
self, # <ANCHOR id="APIClient.fetch_paginated_count" type="Function">
endpoint: str, # @PURPOSE: Получает общее количество элементов для пагинации.
query_params: Dict, response_json = self.request("GET", endpoint, params={"q": json.dumps(query_params)})
count_field: str = "count", return response_json.get(count_field, 0)
timeout: Optional[int] = None # </ANCHOR>
) -> 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_data( def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]:
self, # <ANCHOR id="APIClient.fetch_paginated_data" type="Function">
endpoint: str, # @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта.
pagination_options: Dict[str, Any], base_query, total_count = pagination_options["base_query"], pagination_options["total_count"]
timeout: Optional[int] = None results_field, page_size = pagination_options["results_field"], base_query.get('page_size')
) -> List[Any]: assert page_size and page_size > 0, "'page_size' must be a positive number."
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 = [] results = []
for page in range(total_pages): for page in range((total_count + page_size - 1) // page_size):
query = {**base_query, 'page': page} query = {**base_query, 'page': page}
response_json = self.request( response_json = self.request("GET", endpoint, params={"q": json.dumps(query)})
method="GET", results.extend(response_json.get(results_field, []))
endpoint=endpoint, return results
params={"q": json.dumps(query)}, # </ANCHOR>
timeout=timeout or self.request_settings.get("timeout")
) # </ANCHOR id="APIClient">
page_results = response_json.get(results_field, [])
results.extend(page_results) # --- Конец кода модуля ---
self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_data][SUCCESS] Fetched paginated data. Total items: {len(results)}")
return results # </GRACE_MODULE id="superset_tool.utils.network">

View File

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

File diff suppressed because it is too large Load Diff

View 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"
}