migration refactor
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user