migration refactor

This commit is contained in:
2025-08-16 12:29:37 +03:00
parent f368f5ced9
commit 0e2fc14732
16 changed files with 1977 additions and 2761 deletions

View File

@@ -1,661 +1,313 @@
# [MODULE] Superset API Client
# @contract: Реализует полное взаимодействие с Superset API
# @semantic_layers:
# 1. Авторизация/CSRF (делегируется `APIClient`)
# 2. Основные операции (получение метаданных, список дашбордов)
# 3. Импорт/экспорт дашбордов
# @coherence:
# - Согласован с `models.SupersetConfig` для конфигурации.
# - Полная обработка всех ошибок из `exceptions.py` (делегируется `APIClient` и дополняется специфичными).
# - Полностью использует `utils.network.APIClient` для всех HTTP-запросов.
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument
"""
[MODULE] Superset API Client
@contract: Реализует полное взаимодействие с Superset API
"""
# [IMPORTS] Стандартная библиотека
import json
from typing import Optional, Dict, Tuple, List, Any, Literal, Union
from typing import Optional, Dict, Tuple, List, Any, Union
import datetime
from pathlib import Path
import zipfile
from requests import Response
import zipfile # Для валидации ZIP-файлов
# [IMPORTS] Сторонние библиотеки (убраны requests и urllib3, т.к. они теперь в network.py)
# [IMPORTS] Локальные модули
from superset_tool.models import SupersetConfig
from superset_tool.exceptions import (
AuthenticationError,
SupersetAPIError,
DashboardNotFoundError,
NetworkError,
PermissionDeniedError,
ExportError,
InvalidZipFormatError
)
from superset_tool.utils.fileio import get_filename_from_headers
from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.network import APIClient # [REFACTORING_TARGET] Использование APIClient
from superset_tool.utils.network import APIClient
# [CONSTANTS] Общие константы (для информации, т.к. тайм-аут теперь в конфиге)
DEFAULT_TIMEOUT = 30 # seconds - используется как значение по умолчанию в SupersetConfig
# [CONSTANTS]
DEFAULT_TIMEOUT = 30
# [TYPE-ALIASES] Для сложных сигнатур
# [TYPE-ALIASES]
JsonType = Union[Dict[str, Any], List[Dict[str, Any]]]
ResponseType = Tuple[bytes, str]
# [CHECK] Валидация импортов для контрактов
# [COHERENCE_CHECK_PASSED] Теперь зависимость на requests и urllib3 скрыта за APIClient
try:
from .utils.fileio import get_filename_from_headers as fileio_check
assert callable(fileio_check)
from .utils.network import APIClient as network_check
assert callable(network_check)
except (ImportError, AssertionError) as imp_err:
raise RuntimeError(
f"[COHERENCE_CHECK_FAILED] Импорт не прошел валидацию: {str(imp_err)}"
) from imp_err
class SupersetClient:
"""[MAIN-CONTRACT] Клиент для работы с Superset API
@pre:
- `config` должен быть валидным `SupersetConfig`.
- Целевой API доступен и учетные данные корректны.
@post:
- Все методы возвращают ожидаемые данные или вызывают явные, типизированные ошибки.
- Токены для API-вызовов автоматически управляются (`APIClient`).
@invariant:
- Сессия остается валидной между вызовами.
- Все ошибки типизированы согласно `exceptions.py`.
- Все HTTP-запросы проходят через `self.network`.
"""
"""[MAIN-CONTRACT] Клиент для работы с Superset API"""
# [ENTITY: Function('__init__')]
# CONTRACT:
# PURPOSE: Инициализация клиента Superset.
# PRECONDITIONS: `config` должен быть валидным `SupersetConfig`.
# POSTCONDITIONS: Клиент успешно инициализирован.
def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None):
"""[INIT] Инициализация клиента Superset.
@semantic:
- Валидирует входную конфигурацию.
- Инициализирует внутренний `APIClient` для сетевого взаимодействия.
- Выполняет первичную аутентификацию через `APIClient`.
"""
# [PRECONDITION] Валидация конфигурации
self.logger = logger or SupersetLogger(name="SupersetClient")
self.logger.info("[INFO][SupersetClient.__init__][ENTER] Initializing SupersetClient.")
self._validate_config(config)
self.config = config
# [ANCHOR] API_CLIENT_INIT
# [REFACTORING_COMPLETE] Теперь вся сетевая логика инкапсулирована в APIClient.
# APIClient отвечает за аутентификацию, повторные попытки и обработку низкоуровневых ошибок.
self.network = APIClient(
base_url=config.base_url,
auth=config.auth,
config=config.dict(),
verify_ssl=config.verify_ssl,
timeout=config.timeout,
logger=self.logger # Передаем логгер в APIClient
logger=self.logger
)
try:
# Аутентификация выполняется в конструкторе APIClient или по первому запросу
# Для явного вызова: self.network.authenticate()
# APIClient сам управляет токенами после первого успешного входа
self.logger.info(
"[COHERENCE_CHECK_PASSED] Клиент Superset успешно инициализирован",
extra={"base_url": config.base_url}
)
except Exception as e:
self.logger.error(
"[INIT_FAILED] Ошибка инициализации клиента Superset",
exc_info=True,
extra={"config_base_url": config.base_url, "error": str(e)}
)
raise # Перевыброс ошибки инициализации
self.logger.info("[INFO][SupersetClient.__init__][SUCCESS] SupersetClient initialized successfully.")
# END_FUNCTION___init__
# [ENTITY: Function('_validate_config')]
# CONTRACT:
# PURPOSE: Валидация конфигурации клиента.
# PRECONDITIONS: `config` должен быть экземпляром `SupersetConfig`.
# POSTCONDITIONS: Конфигурация валидна.
def _validate_config(self, config: SupersetConfig) -> None:
"""[PRECONDITION] Валидация конфигурации клиента.
@semantic:
- Проверяет, что `config` является экземпляром `SupersetConfig`.
- Проверяет обязательные поля `base_url` и `auth`.
- Логирует ошибки валидации.
@raise:
- `TypeError`: если `config` не является `SupersetConfig`.
- `ValueError`: если отсутствуют обязательные поля или они невалидны.
"""
self.logger.debug("[DEBUG][SupersetClient._validate_config][ENTER] Validating config.")
if not isinstance(config, SupersetConfig):
self.logger.error(
"[CONTRACT_VIOLATION] Некорректный тип конфигурации",
extra={"actual_type": type(config).__name__}
)
self.logger.error("[ERROR][SupersetClient._validate_config][FAILURE] Invalid config type.")
raise TypeError("Конфигурация должна быть экземпляром SupersetConfig")
self.logger.debug("[DEBUG][SupersetClient._validate_config][SUCCESS] Config validated.")
# END_FUNCTION__validate_config
# Pydantic SupersetConfig уже выполняет основную валидацию через Field и validator.
# Здесь можно добавить дополнительные бизнес-правила или проверки доступности, если нужно.
try:
# Попытка доступа к полям через Pydantic для проверки их существования
_ = config.base_url
_ = config.auth
_ = config.auth.get("username")
_ = config.auth.get("password")
self.logger.debug("[COHERENCE_CHECK_PASSED] Конфигурация SupersetClient прошла внутреннюю валидацию.")
except Exception as e:
self.logger.error(
f"[CONTRACT_VIOLATION] Ошибка валидации полей конфигурации: {e}",
extra={"config_dict": config.dict()}
)
raise ValueError(f"Конфигурация SupersetConfig невалидна: {e}") from e
@property
def headers(self) -> dict:
"""[INTERFACE] Базовые заголовки для API-вызовов.
@semantic: Делегирует получение актуальных заголовков `APIClient`.
@post: Всегда возвращает актуальные токены и CSRF-токен.
@invariant: Заголовки содержат 'Authorization' и 'X-CSRFToken'.
"""
# [REFACTORING_COMPLETE] Заголовки теперь управляются APIClient.
"""[INTERFACE] Базовые заголовки для API-вызовов."""
return self.network.headers
# END_FUNCTION_headers
# [SECTION] API для получения списка дашбордов или получения одного дашборда
# [ENTITY: Function('get_dashboards')]
# CONTRACT:
# PURPOSE: Получение списка дашбордов с пагинацией.
# PRECONDITIONS: None
# POSTCONDITIONS: Возвращает кортеж с общим количеством и списком дашбордов.
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
"""[CONTRACT] Получение списка дашбордов с пагинацией.
@pre:
- Клиент должен быть авторизован.
- Параметры `query` (если предоставлены) должны быть валидны для API Superset.
@post:
- Возвращает кортеж: (общееоличествоашбордов, список_метаданныхашбордов).
- Обходит пагинацию для получения всех доступных дашбордов.
@invariant:
- Всегда возвращает полный список (если `total_count` > 0).
@raise:
- `SupersetAPIError`: При ошибках API (например, неверный формат ответа).
- `NetworkError`: При проблемах с сетью.
- `ValueError`: При некорректных параметрах пагинации (внутренняя ошибка).
"""
self.logger.info("[INFO] Запрос списка всех дашбордов.")
# [COHERENCE_CHECK] Валидация и нормализация параметров запроса
self.logger.info("[INFO][SupersetClient.get_dashboards][ENTER] Getting dashboards.")
validated_query = self._validate_query_params(query)
self.logger.debug("[DEBUG] Параметры запроса списка дашбордов после валидации.", extra={"validated_query": validated_query})
try:
# [ANCHOR] FETCH_TOTAL_COUNT
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
self.logger.info(f"[INFO] Обнаружено {total_count} дашбордов в системе.")
# [ANCHOR] FETCH_ALL_PAGES
paginated_data = self._fetch_all_pages(endpoint="/dashboard/",
query=validated_query,
total_count=total_count)
self.logger.info(
f"[COHERENCE_CHECK_PASSED] Успешно получено {len(paginated_data)} дашбордов из {total_count}."
)
return total_count, paginated_data
except (SupersetAPIError, NetworkError, ValueError, PermissionDeniedError) as e:
self.logger.error(f"[ERROR] Ошибка при получении списка дашбордов: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
raise
except Exception as e:
error_ctx = {"query": query, "error_type": type(e).__name__}
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении списка дашбордов: {str(e)}", exc_info=True, extra=error_ctx)
raise SupersetAPIError(f"Непредвиденная ошибка: {str(e)}", context=error_ctx) from e
def get_dashboard(self, dashboard_id_or_slug: str) -> dict:
"""[CONTRACT] Получение метаданных дашборда по ID или SLUG.
@pre:
- `dashboard_id_or_slug` должен быть строкой (ID или slug).
- Клиент должен быть аутентифицирован (токены актуальны).
@post:
- Возвращает `dict` с метаданными дашборда.
@raise:
- `DashboardNotFoundError`: Если дашборд не найден (HTTP 404).
- `SupersetAPIError`: При других ошибках API.
- `NetworkError`: При проблемах с сетью.
"""
self.logger.info(f"[INFO] Запрос метаданных дашборда: {dashboard_id_or_slug}")
try:
response_data = self.network.request(
method="GET",
endpoint=f"/dashboard/{dashboard_id_or_slug}",
# headers=self.headers # [REFACTORING_NOTE] APIClient теперь сам добавляет заголовки
)
# [POSTCONDITION] Проверка структуры ответа
if "result" not in response_data:
self.logger.warning("[CONTRACT_VIOLATION] Ответ API не содержит поле 'result'", extra={"response": response_data})
raise SupersetAPIError("Некорректный формат ответа API при получении дашборда")
self.logger.debug(f"[DEBUG] Метаданные дашборда '{dashboard_id_or_slug}' успешно получены.")
return response_data["result"]
except (DashboardNotFoundError, SupersetAPIError, NetworkError, PermissionDeniedError) as e:
self.logger.error(f"[ERROR] Не удалось получить дашборд '{dashboard_id_or_slug}': {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
raise # Перевыброс уже типизированной ошибки
except Exception as e:
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении дашборда '{dashboard_id_or_slug}': {str(e)}", exc_info=True)
raise SupersetAPIError(f"Непредвиденная ошибка: {str(e)}", context={"dashboard_id_or_slug": dashboard_id_or_slug}) from e
# [SECTION] API для получения списка датасетов или получения одного датасета
def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
"""[CONTRACT] Получение списка датасетов с пагинацией.
@pre:
- Клиент должен быть авторизован.
- Параметры `query` (если предоставлены) должны быть валидны для API Superset.
@post:
- Возвращает кортеж: (общееоличествоатасетов, список_метаданныхатасетов).
- Обходит пагинацию для получения всех доступных датасетов.
@invariant:
- Всегда возвращает полный список (если `total_count` > 0).
@raise:
- `SupersetAPIError`: При ошибках API (например, неверный формат ответа).
- `NetworkError`: При проблемах с сетью.
- `ValueError`: При некорректных параметрах пагинации (внутренняя ошибка).
"""
self.logger.info("[INFO] Запрос списка всех датасетов")
try:
# Получаем общее количество датасетов
total_count = self._fetch_total_object_count(endpoint="/dataset/")
self.logger.info(f"[INFO] Обнаружено {total_count} датасетов в системе")
# Валидируем параметры запроса
base_query = {
"columns": ["id", "table_name", "sql", "database", "schema"],
"page": 0,
"page_size": 100
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
paginated_data = self._fetch_all_pages(
endpoint="/dashboard/",
pagination_options={
"base_query": validated_query,
"total_count": total_count,
"results_field": "result",
}
validated_query = {**base_query, **(query or {})}
)
self.logger.info("[INFO][SupersetClient.get_dashboards][SUCCESS] Got dashboards.")
return total_count, paginated_data
# END_FUNCTION_get_dashboards
# Получаем все страницы
datasets = self._fetch_all_pages(
endpoint="/dataset/",
query=validated_query,
total_count=total_count#,
#results_field="result"
)
# [ENTITY: Function('get_dashboard')]
# CONTRACT:
# PURPOSE: Получение метаданных дашборда по ID или SLUG.
# PRECONDITIONS: `dashboard_id_or_slug` должен существовать.
# POSTCONDITIONS: Возвращает метаданные дашборда.
def get_dashboard(self, dashboard_id_or_slug: str) -> dict:
self.logger.info(f"[INFO][SupersetClient.get_dashboard][ENTER] Getting dashboard: {dashboard_id_or_slug}")
response_data = self.network.request(
method="GET",
endpoint=f"/dashboard/{dashboard_id_or_slug}",
)
self.logger.info(f"[INFO][SupersetClient.get_dashboard][SUCCESS] Got dashboard: {dashboard_id_or_slug}")
return response_data.get("result", {})
# END_FUNCTION_get_dashboard
self.logger.info(
f"[COHERENCE_CHECK_PASSED] Успешно получено {len(datasets)} датасетов"
)
return total_count, datasets
except Exception as e:
error_ctx = {"query": query, "error_type": type(e).__name__}
self.logger.error(
f"[ERROR] Ошибка получения списка датасетов: {str(e)}",
exc_info=True,
extra=error_ctx
)
raise
# [ENTITY: Function('get_datasets')]
# CONTRACT:
# PURPOSE: Получение списка датасетов с пагинацией.
# PRECONDITIONS: None
# POSTCONDITIONS: Возвращает кортеж с общим количеством и списком датасетов.
def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
self.logger.info("[INFO][SupersetClient.get_datasets][ENTER] Getting datasets.")
total_count = self._fetch_total_object_count(endpoint="/dataset/")
base_query = {
"columns": ["id", "table_name", "sql", "database", "schema"],
"page": 0,
"page_size": 100
}
validated_query = {**base_query, **(query or {})}
datasets = self._fetch_all_pages(
endpoint="/dataset/",
pagination_options={
"base_query": validated_query,
"total_count": total_count,
"results_field": "result",
}
)
self.logger.info("[INFO][SupersetClient.get_datasets][SUCCESS] Got datasets.")
return total_count, datasets
# END_FUNCTION_get_datasets
# [ENTITY: Function('get_dataset')]
# CONTRACT:
# PURPOSE: Получение метаданных датасета по ID.
# PRECONDITIONS: `dataset_id` должен существовать.
# POSTCONDITIONS: Возвращает метаданные датасета.
def get_dataset(self, dataset_id: str) -> dict:
"""[CONTRACT] Получение метаданных датасета по ID.
@pre:
- `dataset_id` должен быть строкой (ID или slug).
- Клиент должен быть аутентифицирован (токены актуальны).
@post:
- Возвращает `dict` с метаданными датасета.
@raise:
- `DashboardNotFoundError`: Если дашборд не найден (HTTP 404).
- `SupersetAPIError`: При других ошибках API.
- `NetworkError`: При проблемах с сетью.
"""
self.logger.info(f"[INFO] Запрос метаданных дашборда: {dataset_id}")
try:
response_data = self.network.request(
method="GET",
endpoint=f"/dataset/{dataset_id}",
# headers=self.headers # [REFACTORING_NOTE] APIClient теперь сам добавляет заголовки
)
# [POSTCONDITION] Проверка структуры ответа
if "result" not in response_data:
self.logger.warning("[CONTRACT_VIOLATION] Ответ API не содержит поле 'result'", extra={"response": response_data})
raise SupersetAPIError("Некорректный формат ответа API при получении дашборда")
self.logger.debug(f"[DEBUG] Метаданные дашборда '{dataset_id}' успешно получены.")
return response_data["result"]
except (DashboardNotFoundError, SupersetAPIError, NetworkError, PermissionDeniedError) as e:
self.logger.error(f"[ERROR] Не удалось получить дашборд '{dataset_id}': {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
raise # Перевыброс уже типизированной ошибки
except Exception as e:
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении дашборда '{dataset_id}': {str(e)}", exc_info=True)
raise SupersetAPIError(f"Непредвиденная ошибка: {str(e)}", context={"dashboard_id_or_slug": dataset_id}) from e
self.logger.info(f"[INFO][SupersetClient.get_dataset][ENTER] Getting dataset: {dataset_id}")
response_data = self.network.request(
method="GET",
endpoint=f"/dataset/{dataset_id}",
)
self.logger.info(f"[INFO][SupersetClient.get_dataset][SUCCESS] Got dataset: {dataset_id}")
return response_data.get("result", {})
# END_FUNCTION_get_dataset
# [SECTION] EXPORT OPERATIONS
# [ENTITY: Function('export_dashboard')]
# CONTRACT:
# PURPOSE: Экспорт дашборда в ZIP-архив.
# PRECONDITIONS: `dashboard_id` должен существовать.
# POSTCONDITIONS: Возвращает содержимое ZIP-архива и имя файла.
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
"""[CONTRACT] Экспорт дашборда в ZIP-архив.
@pre:
- `dashboard_id` должен быть целочисленным ID существующего дашборда.
- Пользователь должен иметь права на экспорт.
@post:
- Возвращает кортеж: (бинарное_содержимое_zip, имя_файла).
- Имя файла извлекается из заголовков `Content-Disposition` или генерируется.
@raise:
- `DashboardNotFoundError`: Если дашборд с `dashboard_id` не найден (HTTP 404).
- `ExportError`: При любых других проблемах экспорта (например, неверный тип контента, пустой ответ).
- `NetworkError`: При проблемах с сетью.
"""
self.logger.info(f"[INFO] Запуск экспорта дашборда с ID: {dashboard_id}")
try:
# [ANCHOR] EXECUTE_EXPORT_REQUEST
# [REFACTORING_COMPLETE] Использование self.network.request для экспорта
response = self.network.request(
method="GET",
endpoint="/dashboard/export/",
params={"q": json.dumps([dashboard_id])},
stream=True, # Используем stream для обработки больших файлов
raw_response=True # Получаем сырой объект ответа requests.Response
# headers=self.headers # APIClient сам добавляет заголовки
)
response.raise_for_status() # Проверка статуса ответа
# [ANCHOR] VALIDATE_EXPORT_RESPONSE
self._validate_export_response(response, dashboard_id)
# [ANCHOR] RESOLVE_FILENAME
filename = self._resolve_export_filename(response, dashboard_id)
# [POSTCONDITION] Успешный экспорт
content = response.content # Получаем все содержимое
self.logger.info(
f"[COHERENCE_CHECK_PASSED] Дашборд {dashboard_id} успешно экспортирован. Размер: {len(content)} байт, Имя файла: {filename}"
)
return content, filename
except (DashboardNotFoundError, ExportError, NetworkError, PermissionDeniedError, SupersetAPIError) as e:
# Перехват и перевыброс уже типизированных ошибок от APIClient или предыдущих валидаций
self.logger.error(f"[ERROR] Ошибка экспорта дашборда {dashboard_id}: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
raise
except Exception as e:
# Обработка любых непредвиденных ошибок
error_ctx = {"dashboard_id": dashboard_id, "error_type": type(e).__name__}
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при экспорте дашборда {dashboard_id}: {str(e)}", exc_info=True, extra=error_ctx)
raise ExportError(f"Непредвиденная ошибка при экспорте: {str(e)}", context=error_ctx) from e
# [HELPER] Метод _execute_export_request был инлайнирован в export_dashboard
# Это сделано, чтобы избежать лишней абстракции, так как он просто вызывает self.network.request.
# Валидация HTTP-ответа и ошибок теперь происходит в self.network.request и последующей self.raise_for_status().
self.logger.info(f"[INFO][SupersetClient.export_dashboard][ENTER] Exporting dashboard: {dashboard_id}")
response = self.network.request(
method="GET",
endpoint="/dashboard/export/",
params={"q": json.dumps([dashboard_id])},
stream=True,
raw_response=True
)
self._validate_export_response(response, dashboard_id)
filename = self._resolve_export_filename(response, dashboard_id)
content = response.content
self.logger.info(f"[INFO][SupersetClient.export_dashboard][SUCCESS] Exported dashboard: {dashboard_id}")
return content, filename
# END_FUNCTION_export_dashboard
# [ENTITY: Function('_validate_export_response')]
# CONTRACT:
# PURPOSE: Валидация ответа экспорта.
# PRECONDITIONS: `response` должен быть валидным HTTP-ответом.
# POSTCONDITIONS: Ответ валиден.
def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
"""[HELPER] Валидация ответа экспорта.
@semantic:
- Проверяет, что Content-Type является `application/zip`.
- Проверяет, что ответ не пуст.
@raise:
- `ExportError`: При невалидном Content-Type или пустом содержимом.
"""
self.logger.debug(f"[DEBUG][SupersetClient._validate_export_response][ENTER] Validating export response for dashboard: {dashboard_id}")
content_type = response.headers.get('Content-Type', '')
if 'application/zip' not in content_type:
self.logger.error(
"[CONTRACT_VIOLATION] Неверный Content-Type для экспорта",
extra={
"dashboard_id": dashboard_id,
"expected_type": "application/zip",
"received_type": content_type
}
)
self.logger.error(f"[ERROR][SupersetClient._validate_export_response][FAILURE] Invalid content type: {content_type}")
raise ExportError(f"Получен не ZIP-архив (Content-Type: {content_type})")
if not response.content:
self.logger.error(
"[CONTRACT_VIOLATION] Пустой ответ при экспорте дашборда",
extra={"dashboard_id": dashboard_id}
)
self.logger.error("[ERROR][SupersetClient._validate_export_response][FAILURE] Empty response content.")
raise ExportError("Получены пустые данные при экспорте")
self.logger.debug(f"[COHERENCE_CHECK_PASSED] Ответ экспорта для дашборда {dashboard_id} валиден.")
self.logger.debug(f"[DEBUG][SupersetClient._validate_export_response][SUCCESS] Export response validated for dashboard: {dashboard_id}")
# END_FUNCTION__validate_export_response
# [ENTITY: Function('_resolve_export_filename')]
# CONTRACT:
# PURPOSE: Определение имени экспортируемого файла.
# PRECONDITIONS: `response` должен быть валидным HTTP-ответом.
# POSTCONDITIONS: Возвращает имя файла.
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
"""[HELPER] Определение имени экспортируемого файла.
@semantic:
- Пытается извлечь имя файла из заголовка `Content-Disposition`.
- Если заголовок отсутствует, генерирует имя файла на основе ID дашборда и текущей даты.
@post:
- Возвращает строку с именем файла.
"""
self.logger.debug(f"[DEBUG][SupersetClient._resolve_export_filename][ENTER] Resolving export filename for dashboard: {dashboard_id}")
filename = get_filename_from_headers(response.headers)
if not filename:
# [FALLBACK] Генерация имени файла
filename = f"dashboard_export_{dashboard_id}_{datetime.datetime.now().strftime('%Y%m%dT%H%M%S')}.zip"
self.logger.warning(
"[WARN] Не удалось извлечь имя файла из заголовков. Используется сгенерированное имя.",
extra={"generated_filename": filename, "dashboard_id": dashboard_id}
)
else:
self.logger.debug(
"[DEBUG] Имя файла экспорта получено из заголовков.",
extra={"header_filename": filename, "dashboard_id": dashboard_id}
)
timestamp = datetime.datetime.now().strftime('%Y%m%dT%H%M%S')
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
self.logger.warning(f"[WARNING][SupersetClient._resolve_export_filename][STATE_CHANGE] Could not resolve filename from headers, generated: {filename}")
self.logger.debug(f"[DEBUG][SupersetClient._resolve_export_filename][SUCCESS] Resolved export filename: {filename}")
return filename
# END_FUNCTION__resolve_export_filename
# [ENTITY: Function('export_to_file')]
# CONTRACT:
# PURPOSE: Экспорт дашборда напрямую в файл.
# PRECONDITIONS: `output_dir` должен существовать.
# POSTCONDITIONS: Дашборд сохранен в файл.
def export_to_file(self, dashboard_id: int, output_dir: Union[str, Path]) -> Path:
"""[CONTRACT] Экспорт дашборда напрямую в файл.
@pre:
- `dashboard_id` должен быть существующим ID дашборда.
- `output_dir` должен быть валидным, существующим путем и иметь права на запись.
@post:
- Дашборд экспортируется и сохраняется как ZIP-файл в `output_dir`.
- Возвращает `Path` к сохраненному файлу.
@raise:
- `FileNotFoundError`: Если `output_dir` не существует.
- `ExportError`: При ошибках экспорта или записи файла.
- `NetworkError`: При проблемах с сетью.
"""
self.logger.info(f"[INFO][SupersetClient.export_to_file][ENTER] Exporting dashboard {dashboard_id} to file in {output_dir}")
output_dir = Path(output_dir)
if not output_dir.exists():
self.logger.error(
"[CONTRACT_VIOLATION] Целевая директория для экспорта не найдена.",
extra={"output_dir": str(output_dir)}
)
self.logger.error(f"[ERROR][SupersetClient.export_to_file][FAILURE] Output directory does not exist: {output_dir}")
raise FileNotFoundError(f"Директория {output_dir} не найдена")
self.logger.info(f"[INFO] Экспорт дашборда {dashboard_id} в файл в директорию: {output_dir}")
try:
content, filename = self.export_dashboard(dashboard_id)
target_path = output_dir / filename
content, filename = self.export_dashboard(dashboard_id)
target_path = output_dir / filename
with open(target_path, 'wb') as f:
f.write(content)
self.logger.info(f"[INFO][SupersetClient.export_to_file][SUCCESS] Exported dashboard {dashboard_id} to {target_path}")
return target_path
# END_FUNCTION_export_to_file
with open(target_path, 'wb') as f:
f.write(content)
self.logger.info(
"[COHERENCE_CHECK_PASSED] Дашборд успешно сохранен на диск.",
extra={
"dashboard_id": dashboard_id,
"file_path": str(target_path),
"file_size": len(content)
}
)
return target_path
except (FileNotFoundError, ExportError, NetworkError, SupersetAPIError, DashboardNotFoundError) as e:
self.logger.error(f"[ERROR] Ошибка сохранения дашборда {dashboard_id} на диск: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
raise
except IOError as io_err:
error_ctx = {"target_path": str(target_path), "dashboard_id": dashboard_id}
self.logger.critical(f"[CRITICAL] Ошибка записи файла для дашборда {dashboard_id}: {str(io_err)}", exc_info=True, extra=error_ctx)
raise ExportError("Ошибка сохранения файла на диск") from io_err
except Exception as e:
error_ctx = {"dashboard_id": dashboard_id, "error_type": type(e).__name__}
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при экспорте в файл: {str(e)}", exc_info=True, extra=error_ctx)
raise ExportError(f"Непредвиденная ошибка экспорта в файл: {str(e)}", context=error_ctx) from e
# [SECTION] Импорт дашбордов
# [ENTITY: Function('import_dashboard')]
# CONTRACT:
# PURPOSE: Импорт дашборда из ZIP-архива.
# PRECONDITIONS: `file_name` должен быть валидным ZIP-файлом.
# POSTCONDITIONS: Возвращает ответ API.
def import_dashboard(self, file_name: Union[str, Path]) -> Dict:
"""[CONTRACT] Импорт дашборда из ZIP-архива.
@pre:
- `file_name` должен указывать на существующий и валидный ZIP-файл Superset экспорта.
- Пользователь должен иметь права на импорт дашбордов.
@post:
- Дашборд импортируется (или обновляется, если `overwrite` включен).
- Возвращает `dict` с ответом API об импорте.
@raise:
- `FileNotFoundError`: Если файл не существует.
- `InvalidZipFormatError`: Если файл не является корректным ZIP-архивом Superset.
- `PermissionDeniedError`: Если у пользователя нет прав на импорт.
- `SupersetAPIError`: При других ошибках API импорта.
- `NetworkError`: При проблемах с сетью.
"""
self.logger.info(f"[INFO] Инициирован импорт дашборда из файла: {file_name}")
# [PRECONDITION] Валидация входного файла
self.logger.info(f"[INFO][SupersetClient.import_dashboard][ENTER] Importing dashboard from: {file_name}")
self._validate_import_file(file_name)
try:
# [ANCHOR] UPLOAD_FILE_TO_API
# [REFACTORING_COMPLETE] Использование self.network.upload_file
import_response = self.network.upload_file(
endpoint="/dashboard/import/",
file_obj=Path(file_name), # Pathlib объект, который APIClient может преобразовать в бинарный
file_name=Path(file_name).name, # Имя файла для FormData
form_field="formData",
extra_data={'overwrite': 'true'}, # Предполагаем, что всегда хотим перезаписывать
timeout=self.config.timeout * 2 # Удвоенный таймаут для загрузки больших файлов
# headers=self.headers # APIClient сам добавляет заголовки
)
# [POSTCONDITION] Проверка успешного ответа импорта (Superset обычно возвращает JSON)
if not isinstance(import_response, dict) or "message" not in import_response:
self.logger.warning("[CONTRACT_VIOLATION] Неожиданный формат ответа при импорте", extra={"response": import_response})
raise SupersetAPIError("Неожиданный формат ответа после импорта дашборда.")
import_response = self.network.upload_file(
endpoint="/dashboard/import/",
file_info={
"file_obj": Path(file_name),
"file_name": Path(file_name).name,
"form_field": "formData",
},
extra_data={'overwrite': 'true'},
timeout=self.config.timeout * 2
)
self.logger.info(f"[INFO][SupersetClient.import_dashboard][SUCCESS] Imported dashboard from: {file_name}")
return import_response
# END_FUNCTION_import_dashboard
self.logger.info(
f"[COHERENCE_CHECK_PASSED] Дашборд из '{file_name}' успешно импортирован.",
extra={"api_message": import_response.get("message", "N/A"), "file": file_name}
)
return import_response
except (FileNotFoundError, InvalidZipFormatError, PermissionDeniedError, SupersetAPIError, NetworkError, DashboardNotFoundError) as e:
self.logger.error(f"[ERROR] Ошибка импорта дашборда из '{file_name}': {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
raise
except Exception as e:
error_ctx = {"file": file_name, "error_type": type(e).__name__}
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при импорте дашборда: {str(e)}", exc_info=True, extra=error_ctx)
raise SupersetAPIError(f"Непредвиденная ошибка импорта: {str(e)}", context=error_ctx) from e
# [SECTION] Приватные методы-помощники
# [ENTITY: Function('_validate_query_params')]
# CONTRACT:
# PURPOSE: Нормализация и валидация параметров запроса.
# PRECONDITIONS: None
# POSTCONDITIONS: Возвращает валидный словарь параметров.
def _validate_query_params(self, query: Optional[Dict]) -> Dict:
"""[HELPER] Нормализация и валидация параметров запроса для списка дашбордов.
@semantic:
- Устанавливает значения по умолчанию для `columns`, `page`, `page_size`.
- Объединяет предоставленные `query` параметры с дефолтными.
@post:
- Возвращает словарь с полными и валидными параметрами запроса.
"""
self.logger.debug("[DEBUG][SupersetClient._validate_query_params][ENTER] Validating query params.")
base_query = {
"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"],
"page": 0,
"page_size": 1000 # Достаточно большой размер страницы для обхода пагинации
"page_size": 1000
}
# [COHERENCE_CHECK_PASSED] Параметры запроса сформированы корректно.
return {**base_query, **(query or {})}
validated_query = {**base_query, **(query or {})}
self.logger.debug(f"[DEBUG][SupersetClient._validate_query_params][SUCCESS] Validated query params: {validated_query}")
return validated_query
# END_FUNCTION__validate_query_params
# [ENTITY: Function('_fetch_total_object_count')]
# CONTRACT:
# PURPOSE: Получение общего количества объектов.
# PRECONDITIONS: `endpoint` должен быть валидным.
# POSTCONDITIONS: Возвращает общее количество объектов.
def _fetch_total_object_count(self, endpoint:str) -> int:
"""[CONTRACT][HELPER] Получение общего количества объектов (дашбордов, датасетов, чартов, баз данных) в системе.
@delegates:
- Сетевой запрос к `APIClient.fetch_paginated_count`.
@pre:
- Клиент должен быть авторизован.
@post:
- Возвращает целочисленное количество дашбордов.
@raise:
- `SupersetAPIError` или `NetworkError` при проблемах с API/сетью.
"""
query_params_for_count = {
'columns': ['id'],
'page': 0,
'page_size': 1
}
self.logger.debug("[DEBUG] Запрос общего количества дашбордов.")
try:
# [REFACTORING_COMPLETE] Использование self.network.fetch_paginated_count
count = self.network.fetch_paginated_count(
endpoint=endpoint,
query_params=query_params_for_count,
count_field="count"
)
self.logger.debug(f"[COHERENCE_CHECK_PASSED] Получено общее количество дашбордов: {count}")
return count
except (SupersetAPIError, NetworkError, PermissionDeniedError) as e:
self.logger.error(f"[ERROR] Ошибка получения общего количества дашбордов: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
raise # Перевыброс ошибки
except Exception as e:
error_ctx = {"error_type": type(e).__name__}
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении общего количества: {str(e)}", exc_info=True, extra=error_ctx)
raise SupersetAPIError(f"Непредвиденная ошибка при получении count: {str(e)}", context=error_ctx) from e
self.logger.debug(f"[DEBUG][SupersetClient._fetch_total_object_count][ENTER] Fetching total object count for endpoint: {endpoint}")
query_params_for_count = {'page': 0, 'page_size': 1}
count = self.network.fetch_paginated_count(
endpoint=endpoint,
query_params=query_params_for_count,
count_field="count"
)
self.logger.debug(f"[DEBUG][SupersetClient._fetch_total_object_count][SUCCESS] Fetched total object count: {count}")
return count
# END_FUNCTION__fetch_total_object_count
def _fetch_all_pages(self, endpoint:str, query: Dict, total_count: int) -> List[Dict]:
"""[CONTRACT][HELPER] Обход всех страниц пагинированного API для получения всех данных.
@delegates:
- Сетевые запросы к `APIClient.fetch_paginated_data()`.
@pre:
- `query` должен содержать `page_size`.
- `total_count` должен быть корректным общим количеством элементов.
- `endpoint` должен содержать часть url запроса, например endpoint="/dashboard/".
@post:
- Возвращает список всех элементов, собранных со всех страниц.
@raise:
- `SupersetAPIError` или `NetworkError` при проблемах с API/сетью.
- `ValueError` при некорректных параметрах пагинации.
"""
self.logger.debug(f"[DEBUG] Запуск обхода пагинации. Всего элементов: {total_count}, query: {query}")
try:
if 'page_size' not in query or not query['page_size']:
self.logger.error("[CONTRACT_VIOLATION] Параметр 'page_size' отсутствует или неверен в query.")
raise ValueError("Отсутствует 'page_size' в query параметрах для пагинации")
# [REFACTORING_COMPLETE] Использование self.network.fetch_paginated_data
all_data = self.network.fetch_paginated_data(
endpoint=endpoint,
base_query=query,
total_count=total_count,
results_field="result"
)
self.logger.debug(f"[COHERENCE_CHECK_PASSED] Успешно получено {len(all_data)} элементов со всех страниц.")
return all_data
except (SupersetAPIError, NetworkError, ValueError, PermissionDeniedError) as e:
self.logger.error(f"[ERROR] Ошибка при обходе пагинации: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
raise
except Exception as e:
error_ctx = {"query": query, "total_count": total_count, "error_type": type(e).__name__}
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при обходе пагинации: {str(e)}", exc_info=True, extra=error_ctx)
raise SupersetAPIError(f"Непредвиденная ошибка пагинации: {str(e)}", context=error_ctx) from e
# [ENTITY: Function('_fetch_all_pages')]
# CONTRACT:
# PURPOSE: Обход всех страниц пагинированного API.
# PRECONDITIONS: `pagination_options` должен содержать необходимые параметры.
# POSTCONDITIONS: Возвращает список всех объектов.
def _fetch_all_pages(self, endpoint:str, pagination_options: Dict) -> List[Dict]:
self.logger.debug(f"[DEBUG][SupersetClient._fetch_all_pages][ENTER] Fetching all pages for endpoint: {endpoint}")
all_data = self.network.fetch_paginated_data(
endpoint=endpoint,
pagination_options=pagination_options
)
self.logger.debug(f"[DEBUG][SupersetClient._fetch_all_pages][SUCCESS] Fetched all pages for endpoint: {endpoint}")
return all_data
# END_FUNCTION__fetch_all_pages
# [ENTITY: Function('_validate_import_file')]
# CONTRACT:
# PURPOSE: Проверка файла перед импортом.
# PRECONDITIONS: `zip_path` должен быть путем к файлу.
# POSTCONDITIONS: Файл валиден.
def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
"""[HELPER] Проверка файла перед импортом.
@semantic:
- Проверяет существование файла.
- Проверяет, что файл является валидным ZIP-архивом.
- Проверяет, что ZIP-архив содержит `metadata.yaml` (ключевой для экспорта Superset).
@raise:
- `FileNotFoundError`: Если файл не существует.
- `InvalidZipFormatError`: Если файл не ZIP или не содержит `metadata.yaml`.
"""
self.logger.debug(f"[DEBUG][SupersetClient._validate_import_file][ENTER] Validating import file: {zip_path}")
path = Path(zip_path)
self.logger.debug(f"[DEBUG] Валидация файла для импорта: {path}")
if not path.exists():
self.logger.error(
"[CONTRACT_VIOLATION] Файл для импорта не найден.",
extra={"file_path": str(path)}
)
self.logger.error(f"[ERROR][SupersetClient._validate_import_file][FAILURE] Import file does not exist: {zip_path}")
raise FileNotFoundError(f"Файл {zip_path} не существует")
if not zipfile.is_zipfile(path):
self.logger.error(
"[CONTRACT_VIOLATION] Файл не является валидным ZIP-архивом.",
extra={"file_path": str(path)}
)
self.logger.error(f"[ERROR][SupersetClient._validate_import_file][FAILURE] Import file is not a zip file: {zip_path}")
raise InvalidZipFormatError(f"Файл {zip_path} не является ZIP-архивом")
with zipfile.ZipFile(path, 'r') as zf:
if not any(n.endswith('metadata.yaml') for n in zf.namelist()):
self.logger.error(f"[ERROR][SupersetClient._validate_import_file][FAILURE] Import file does not contain metadata.yaml: {zip_path}")
raise InvalidZipFormatError(f"Архив {zip_path} не содержит 'metadata.yaml'")
self.logger.debug(f"[DEBUG][SupersetClient._validate_import_file][SUCCESS] Validated import file: {zip_path}")
# END_FUNCTION__validate_import_file
try:
with zipfile.ZipFile(path, 'r') as zf:
# [CONTRACT] Проверяем наличие metadata.yaml
if not any(n.endswith('metadata.yaml') for n in zf.namelist()):
self.logger.error(
"[CONTRACT_VIOLATION] ZIP-архив не содержит 'metadata.yaml'.",
extra={"file_path": str(path), "zip_contents": zf.namelist()[:5]} # Логируем первые 5 файлов для отладки
)
raise InvalidZipFormatError(f"Архив {zip_path} не содержит 'metadata.yaml', не является корректным экспортом Superset.")
self.logger.debug(f"[COHERENCE_CHECK_PASSED] Файл '{path}' успешно прошел валидацию для импорта.")
except zipfile.BadZipFile as e:
self.logger.error(
f"[CONTRACT_VIOLATION] Ошибка чтения ZIP-файла: {str(e)}",
exc_info=True, extra={"file_path": str(path)}
)
raise InvalidZipFormatError(f"Файл {zip_path} поврежден или имеет некорректный формат ZIP.") from e
except Exception as e:
self.logger.critical(
f"[CRITICAL] Непредвиденная ошибка при валидации ZIP-файла: {str(e)}",
exc_info=True, extra={"file_path": str(path)}
)
raise SupersetAPIError(f"Непредвиденная ошибка валидации ZIP: {str(e)}", context={"file_path": str(path)}) from e