backup worked

This commit is contained in:
Volobuev Andrey
2025-10-06 13:59:30 +03:00
parent 2f8aea3620
commit 8f6b44c679
7 changed files with 1144 additions and 480 deletions

View File

@@ -1,82 +1,106 @@
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument
"""
[MODULE] Superset API Client
@contract: Реализует полное взаимодействие с Superset API
"""
# [MODULE_PATH] superset_tool.client
# [FILE] client.py
# [SEMANTICS] superset, api, client, logging, error-handling, slug-support
# [IMPORTS] Стандартная библиотека
# --------------------------------------------------------------
# [IMPORTS]
# --------------------------------------------------------------
import json
from typing import Optional, Dict, Tuple, List, Any, Union
import datetime
from pathlib import Path
import zipfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from requests import Response
# [IMPORTS] Локальные модули
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.logger import SupersetLogger
from superset_tool.utils.network import APIClient
# [END_IMPORTS]
# [CONSTANTS]
DEFAULT_TIMEOUT = 30
# [TYPE-ALIASES]
JsonType = Union[Dict[str, Any], List[Dict[str, Any]]]
ResponseType = Tuple[bytes, str]
# --------------------------------------------------------------
# [ENTITY: Service('SupersetClient')]
# [RELATION: Service('SupersetClient')] -> [DEPENDS_ON] -> [PythonModule('superset_tool.utils.network')]
# --------------------------------------------------------------
"""
:purpose: Класс‑обёртка над Superset RESTAPI.
:preconditions:
- ``config`` валидный объект :class:`SupersetConfig`.
- Доступен рабочий HTTPклиент :class:`APIClient`.
:postconditions:
- Объект готов к выполнению запросов (GET, POST, DELETE и т.д.).
:raises:
- :class:`TypeError` при передаче неверного типа конфигурации.
"""
class SupersetClient:
"""[MAIN-CONTRACT] Клиент для работы с Superset API"""
# [ENTITY: Function('__init__')]
# CONTRACT:
# PURPOSE: Инициализация клиента Superset.
# PRECONDITIONS: `config` должен быть валидным `SupersetConfig`.
# POSTCONDITIONS: Клиент успешно инициализирован.
"""
: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):
self.logger = logger or SupersetLogger(name="SupersetClient")
self.logger.info("[INFO][SupersetClient.__init__][ENTER] Initializing SupersetClient.")
self.logger.info("[INFO][SupersetClient.__init__] Initializing SupersetClient.")
self._validate_config(config)
self.config = config
self.env = config.env
self.network = APIClient(
config=config.dict(),
verify_ssl=config.verify_ssl,
timeout=config.timeout,
logger=self.logger
logger=self.logger,
)
self.logger.info("[INFO][SupersetClient.__init__][SUCCESS] SupersetClient initialized successfully.")
# END_FUNCTION___init__
self.delete_before_reimport: bool = False
self.logger.info("[INFO][SupersetClient.__init__] SupersetClient initialized.")
# [END_ENTITY]
# [ENTITY: Function('_validate_config')]
# CONTRACT:
# PURPOSE: Валидация конфигурации клиента.
# PRECONDITIONS: `config` должен быть экземпляром `SupersetConfig`.
# POSTCONDITIONS: Конфигурация валидна.
# --------------------------------------------------------------
# [ENTITY: Method('_validate_config')]
# --------------------------------------------------------------
"""
:purpose: Проверить, что передан объект :class:`SupersetConfig`.
:preconditions: ``config`` произвольный объект.
:postconditions: При несовпадении типов возбуждается :class:`TypeError`.
"""
def _validate_config(self, config: SupersetConfig) -> None:
self.logger.debug("[DEBUG][SupersetClient._validate_config][ENTER] Validating config.")
self.logger.debug("[DEBUG][_validate_config][ENTER] Validating SupersetConfig.")
if not isinstance(config, SupersetConfig):
self.logger.error("[ERROR][SupersetClient._validate_config][FAILURE] Invalid config type.")
self.logger.error("[ERROR][_validate_config][FAILURE] Invalid config type.")
raise TypeError("Конфигурация должна быть экземпляром SupersetConfig")
self.logger.debug("[DEBUG][SupersetClient._validate_config][SUCCESS] Config validated.")
# END_FUNCTION__validate_config
self.logger.debug("[DEBUG][_validate_config][SUCCESS] Config is valid.")
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Property('headers')]
# --------------------------------------------------------------
@property
def headers(self) -> dict:
"""[INTERFACE] Базовые заголовки для API-вызовов."""
"""Базовые HTTPзаголовки, используемые клиентом."""
return self.network.headers
# END_FUNCTION_headers
# [END_ENTITY]
# [ENTITY: Function('get_dashboards')]
# CONTRACT:
# PURPOSE: Получение списка дашбордов с пагинацией.
# PRECONDITIONS: None
# POSTCONDITIONS: Возвращает кортеж с общим количеством и списком дашбордов.
# --------------------------------------------------------------
# [ENTITY: Method('get_dashboards')]
# --------------------------------------------------------------
"""
:purpose: Получить список дашбордов с поддержкой пагинации.
:preconditions: None.
:postconditions: Возвращается кортеж ``(total_count, list_of_dashboards)``.
"""
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
self.logger.info("[INFO][SupersetClient.get_dashboards][ENTER] Getting dashboards.")
self.logger.info("[INFO][get_dashboards][ENTER] Fetching dashboards.")
validated_query = self._validate_query_params(query)
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
paginated_data = self._fetch_all_pages(
@@ -85,236 +109,368 @@ class SupersetClient:
"base_query": validated_query,
"total_count": total_count,
"results_field": "result",
}
},
)
self.logger.info("[INFO][SupersetClient.get_dashboards][SUCCESS] Got dashboards.")
self.logger.info("[INFO][get_dashboards][SUCCESS] Got dashboards.")
return total_count, paginated_data
# END_FUNCTION_get_dashboards
# [END_ENTITY]
# [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
# [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:
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
def get_databases(self) -> List[Dict]:
self.logger.info("[INFO][SupersetClient.get_databases][ENTER] Getting databases.")
response = self.network.request("GET", "/database/")
self.logger.info("[INFO][SupersetClient.get_databases][SUCCESS] Got databases.")
return response.get('result', [])
# [ENTITY: Function('export_dashboard')]
# CONTRACT:
# PURPOSE: Экспорт дашборда в ZIP-архив.
# PRECONDITIONS: `dashboard_id` должен существовать.
# POSTCONDITIONS: Возвращает содержимое ZIP-архива и имя файла.
# --------------------------------------------------------------
# [ENTITY: Method('export_dashboard')]
# --------------------------------------------------------------
"""
:purpose: Скачать дашборд в виде ZIPархива.
:preconditions: ``dashboard_id`` существующий идентификатор.
:postconditions: Возвращается бинарное содержимое и имя файла.
"""
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
self.logger.info(f"[INFO][SupersetClient.export_dashboard][ENTER] Exporting dashboard: {dashboard_id}")
self.logger.info("[INFO][export_dashboard][ENTER] Exporting dashboard %s.", dashboard_id)
response = self.network.request(
method="GET",
endpoint="/dashboard/export/",
params={"q": json.dumps([dashboard_id])},
stream=True,
raw_response=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
self.logger.info("[INFO][export_dashboard][SUCCESS] Exported dashboard %s.", dashboard_id)
return response.content, filename
# [END_ENTITY]
# [ENTITY: Function('_validate_export_response')]
# CONTRACT:
# PURPOSE: Валидация ответа экспорта.
# PRECONDITIONS: `response` должен быть валидным HTTP-ответом.
# POSTCONDITIONS: Ответ валиден.
def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
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(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("[ERROR][SupersetClient._validate_export_response][FAILURE] Empty response content.")
raise ExportError("Получены пустые данные при экспорте")
self.logger.debug(f"[DEBUG][SupersetClient._validate_export_response][SUCCESS] Export response validated for dashboard: {dashboard_id}")
# END_FUNCTION__validate_export_response
# --------------------------------------------------------------
# [ENTITY: Method('import_dashboard')]
# --------------------------------------------------------------
"""
:purpose: Импортировать дашборд из ZIPфайла. При неуспешном импорте,
если ``delete_before_reimport`` = True, сначала удаляется
дашборд по ID, затем импорт повторяется.
:preconditions:
- ``file_name`` путь к существующему ZIPархиву (str|Path).
- ``dash_id`` (опционально) ID дашборда, который следует удалить.
:postconditions: Возвращается словарь‑ответ API при успехе.
"""
def import_dashboard(
self,
file_name: Union[str, Path],
dash_id: Optional[int] = None,
dash_slug: Optional[str] = None, # сохраняем для возможного логирования
) -> Dict:
# -----------------------------------------------------------------
# 1⃣ Приводим путь к строке (APIклиент ожидает str)
# -----------------------------------------------------------------
file_path: str = str(file_name) # <--- гарантируем тип str
self._validate_import_file(file_path)
# [ENTITY: Function('_resolve_export_filename')]
# CONTRACT:
# PURPOSE: Определение имени экспортируемого файла.
# PRECONDITIONS: `response` должен быть валидным HTTP-ответом.
# POSTCONDITIONS: Возвращает имя файла.
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
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:
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
try:
import_response = self._do_import(file_path)
self.logger.info("[INFO][import_dashboard] Imported %s.", file_path)
return import_response
# [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:
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(f"[ERROR][SupersetClient.export_to_file][FAILURE] Output directory does not exist: {output_dir}")
raise FileNotFoundError(f"Директория {output_dir} не найдена")
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
except Exception as exc:
# -----------------------------------------------------------------
# 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:
raise
# [ENTITY: Function('import_dashboard')]
# CONTRACT:
# PURPOSE: Импорт дашборда из ZIP-архива.
# PRECONDITIONS: `file_name` должен быть валидным ZIP-файлом.
# POSTCONDITIONS: Возвращает ответ API.
def import_dashboard(self, file_name: Union[str, Path]) -> Dict:
self.logger.info(f"[INFO][SupersetClient.import_dashboard][ENTER] Importing dashboard from: {file_name}")
self._validate_import_file(file_name)
import_response = self.network.upload_file(
# -----------------------------------------------------------------
# 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:
self.logger.error("[ERROR][import_dashboard] No ID available for deleteretry.")
raise
# -----------------------------------------------------------------
# 4⃣ Удаляем найденный дашборд (по ID)
# -----------------------------------------------------------------
try:
self.delete_dashboard(target_id)
self.logger.info("[INFO][import_dashboard] Deleted dashboard ID %s, retrying import.", target_id)
except Exception as del_exc:
self.logger.error("[ERROR][import_dashboard] Delete failed: %s", del_exc, exc_info=True)
raise
# -----------------------------------------------------------------
# 5⃣ Повторный импорт (тот же файл)
# -----------------------------------------------------------------
try:
import_response = self._do_import(file_path)
self.logger.info("[INFO][import_dashboard] 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:
return 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
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
# [END_ENTITY]
# [ENTITY: Function('_validate_query_params')]
# CONTRACT:
# PURPOSE: Нормализация и валидация параметров запроса.
# PRECONDITIONS: None
# POSTCONDITIONS: Возвращает валидный словарь параметров.
# --------------------------------------------------------------
# [ENTITY: Method('delete_dashboard')]
# --------------------------------------------------------------
"""
:purpose: Удалить дашборд **по ID или slug**.
:preconditions:
- ``dashboard_id`` intID **или** strslug дашборда.
:postconditions: На уровне API считается, что ресурс удалён
(HTTP200/204). Логируется результат операции.
"""
def delete_dashboard(self, dashboard_id: Union[int, str]) -> None:
# ``dashboard_id`` может быть целым числом или строковым slug.
self.logger.info("[INFO][delete_dashboard][ENTER] Deleting dashboard %s.", dashboard_id)
response = self.network.request(
method="DELETE",
endpoint=f"/dashboard/{dashboard_id}",
)
# Superset обычно возвращает 200/204. Если есть поле ``result`` проверяем.
if response.get("result", True) is not False:
self.logger.info("[INFO][delete_dashboard] Dashboard %s deleted.", dashboard_id)
else:
self.logger.warning("[WARN][delete_dashboard] Unexpected response while deleting %s.", dashboard_id)
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('_extract_dashboard_id_from_zip')]
# --------------------------------------------------------------
"""
: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]:
try:
import yaml
with zipfile.ZipFile(file_name, "r") as zf:
for name in zf.namelist():
if name.endswith("metadata.yaml"):
with zf.open(name) as meta_file:
meta = yaml.safe_load(meta_file.read())
dash_id = meta.get("dashboard_uuid") or meta.get("dashboard_id")
if dash_id is not None:
return int(dash_id)
except Exception as exc:
self.logger.error("[ERROR][_extract_dashboard_id_from_zip] %s", exc, exc_info=True)
return None
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('_extract_dashboard_slug_from_zip')]
# --------------------------------------------------------------
"""
: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]:
try:
import yaml
with zipfile.ZipFile(file_name, "r") as zf:
for name in zf.namelist():
if name.endswith("metadata.yaml"):
with zf.open(name) as meta_file:
meta = yaml.safe_load(meta_file.read())
slug = meta.get("slug")
if slug:
return str(slug)
except Exception as exc:
self.logger.error("[ERROR][_extract_dashboard_slug_from_zip] %s", exc, exc_info=True)
return None
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('_validate_export_response')]
# --------------------------------------------------------------
"""
:purpose: Проверить, что ответ от ``/dashboard/export/`` ZIPархив с данными.
:preconditions: ``response`` объект :class:`requests.Response`.
:postconditions: При несоответствии возбуждается :class:`ExportError`.
"""
def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
self.logger.debug("[DEBUG][_validate_export_response][ENTER] Validating response for %s.", dashboard_id)
content_type = response.headers.get("Content-Type", "")
if "application/zip" not in content_type:
self.logger.error("[ERROR][_validate_export_response][FAILURE] Invalid content type: %s", content_type)
raise ExportError(f"Получен не ZIPархив (Content-Type: {content_type})")
if not response.content:
self.logger.error("[ERROR][_validate_export_response][FAILURE] Empty response content.")
raise ExportError("Получены пустые данные при экспорте")
self.logger.debug("[DEBUG][_validate_export_response][SUCCESS] Response validated.")
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('_resolve_export_filename')]
# --------------------------------------------------------------
"""
:purpose: Определить имя файла, полученного из заголовков ответа.
:preconditions: ``response.headers`` содержит (возможно) ``ContentDisposition``.
:postconditions: Возвращается строка‑имя файла.
"""
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
self.logger.debug("[DEBUG][_resolve_export_filename][ENTER] Resolving filename.")
filename = get_filename_from_headers(response.headers)
if not filename:
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
self.logger.warning("[WARN][_resolve_export_filename] Generated filename: %s", filename)
self.logger.debug("[DEBUG][_resolve_export_filename][SUCCESS] Filename: %s", filename)
return filename
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('_validate_query_params')]
# --------------------------------------------------------------
"""
:purpose: Сформировать корректный набор параметров запроса.
:preconditions: ``query`` любой словарь или ``None``.
:postconditions: Возвращается словарь с обязательными полями.
"""
def _validate_query_params(self, query: Optional[Dict]) -> Dict:
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,
}
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
validated = {**base_query, **(query or {})}
self.logger.debug("[DEBUG][_validate_query_params] %s", validated)
return validated
# [END_ENTITY]
# [ENTITY: Function('_fetch_total_object_count')]
# CONTRACT:
# PURPOSE: Получение общего количества объектов.
# PRECONDITIONS: `endpoint` должен быть валидным.
# POSTCONDITIONS: Возвращает общее количество объектов.
def _fetch_total_object_count(self, endpoint:str) -> int:
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}
# --------------------------------------------------------------
# [ENTITY: Method('_fetch_total_object_count')]
# --------------------------------------------------------------
"""
:purpose: Получить общее количество объектов по указанному endpoint.
:preconditions: ``endpoint`` строка, начинающаяся с «/».
:postconditions: Возвращается целое число.
"""
def _fetch_total_object_count(self, endpoint: str) -> int:
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"
count_field="count",
)
self.logger.debug(f"[DEBUG][SupersetClient._fetch_total_object_count][SUCCESS] Fetched total object count: {count}")
self.logger.debug("[DEBUG][_fetch_total_object_count] %s%s", endpoint, count)
return count
# END_FUNCTION__fetch_total_object_count
# [END_ENTITY]
# [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}")
# --------------------------------------------------------------
# [ENTITY: Method('_fetch_all_pages')]
# --------------------------------------------------------------
"""
: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]:
all_data = self.network.fetch_paginated_data(
endpoint=endpoint,
pagination_options=pagination_options
pagination_options=pagination_options,
)
self.logger.debug(f"[DEBUG][SupersetClient._fetch_all_pages][SUCCESS] Fetched all pages for endpoint: {endpoint}")
self.logger.debug("[DEBUG][_fetch_all_pages] Fetched %s items from %s.", len(all_data), endpoint)
return all_data
# END_FUNCTION__fetch_all_pages
# [END_ENTITY]
# [ENTITY: Function('_validate_import_file')]
# CONTRACT:
# PURPOSE: Проверка файла перед импортом.
# PRECONDITIONS: `zip_path` должен быть путем к файлу.
# POSTCONDITIONS: Файл валиден.
# --------------------------------------------------------------
# [ENTITY: Method('_validate_import_file')]
# --------------------------------------------------------------
"""
:purpose: Проверить, что файл существует, является ZIPархивом и
содержит ``metadata.yaml``.
:preconditions: ``zip_path`` путь к файлу.
:postconditions: При невалидном файле возбуждается :class:`InvalidZipFormatError`.
"""
def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
self.logger.debug(f"[DEBUG][SupersetClient._validate_import_file][ENTER] Validating import file: {zip_path}")
path = Path(zip_path)
if not path.exists():
self.logger.error(f"[ERROR][SupersetClient._validate_import_file][FAILURE] Import file does not exist: {zip_path}")
self.logger.error("[ERROR][_validate_import_file] File not found: %s", zip_path)
raise FileNotFoundError(f"Файл {zip_path} не существует")
if not zipfile.is_zipfile(path):
self.logger.error(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}")
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:
if not any(n.endswith("metadata.yaml") for n in zf.namelist()):
self.logger.error("[ERROR][_validate_import_file] No metadata.yaml in %s", zip_path)
raise InvalidZipFormatError(f"Архив {zip_path} не содержит 'metadata.yaml'")
self.logger.debug(f"[DEBUG][SupersetClient._validate_import_file][SUCCESS] Validated import file: {zip_path}")
# END_FUNCTION__validate_import_file
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)``.
"""
def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
self.logger.info("[INFO][get_datasets][ENTER] Fetching datasets.")
validated_query = self._validate_query_params(query)
total_count = self._fetch_total_object_count(endpoint="/dataset/")
paginated_data = self._fetch_all_pages(
endpoint="/dataset/",
pagination_options={
"base_query": validated_query,
"total_count": total_count,
"results_field": "result",
},
)
self.logger.info("[INFO][get_datasets][SUCCESS] Got datasets.")
return total_count, paginated_data
# [END_ENTITY]
# [END_FILE client.py]