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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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