mapper + lint

This commit is contained in:
2025-10-06 18:49:40 +03:00
parent b550cb38ff
commit 74b7779e45
18 changed files with 4512 additions and 2250 deletions

View File

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