fractal refactor
This commit is contained in:
@@ -2,7 +2,7 @@ from superset_tool.models import SupersetConfig
|
|||||||
from superset_tool.client import SupersetClient
|
from superset_tool.client import SupersetClient
|
||||||
from superset_tool.utils.logger import SupersetLogger
|
from superset_tool.utils.logger import SupersetLogger
|
||||||
from superset_tool.exceptions import AuthenticationError
|
from superset_tool.exceptions import AuthenticationError
|
||||||
from superset_tool.utils.fileio import save_and_unpack_dashboard, update_db_yaml, create_dashboard_export, create_temp_file
|
from superset_tool.utils.fileio import save_and_unpack_dashboard, update_yamls, create_dashboard_export, create_temp_file,read_dashboard_from_disk
|
||||||
import os
|
import os
|
||||||
import keyring
|
import keyring
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -15,7 +15,7 @@ logger = SupersetLogger(
|
|||||||
console=True
|
console=True
|
||||||
)
|
)
|
||||||
|
|
||||||
database_config_click={"new":
|
database_config_click={"old":
|
||||||
{
|
{
|
||||||
"database_name": "Prod Clickhouse",
|
"database_name": "Prod Clickhouse",
|
||||||
"sqlalchemy_uri": "clickhousedb+connect://clicketl:XXXXXXXXXX@rgm-s-khclk.hq.root.ad:443/dm",
|
"sqlalchemy_uri": "clickhousedb+connect://clicketl:XXXXXXXXXX@rgm-s-khclk.hq.root.ad:443/dm",
|
||||||
@@ -25,7 +25,7 @@ database_config_click={"new":
|
|||||||
"allow_cvas": "false",
|
"allow_cvas": "false",
|
||||||
"allow_dml": "false"
|
"allow_dml": "false"
|
||||||
},
|
},
|
||||||
"old": {
|
"new": {
|
||||||
"database_name": "Dev Clickhouse",
|
"database_name": "Dev Clickhouse",
|
||||||
"sqlalchemy_uri": "clickhousedb+connect://dwhuser:XXXXXXXXXX@10.66.229.179:8123/dm",
|
"sqlalchemy_uri": "clickhousedb+connect://dwhuser:XXXXXXXXXX@10.66.229.179:8123/dm",
|
||||||
"uuid": "e9fd8feb-cb77-4e82-bc1d-44768b8d2fc2",
|
"uuid": "e9fd8feb-cb77-4e82-bc1d-44768b8d2fc2",
|
||||||
@@ -36,7 +36,7 @@ database_config_click={"new":
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
database_config_gp={"new":
|
database_config_gp={"old":
|
||||||
{
|
{
|
||||||
"database_name": "Prod Greenplum",
|
"database_name": "Prod Greenplum",
|
||||||
"sqlalchemy_uri": "postgresql+psycopg2://viz_powerbi_gp_prod:XXXXXXXXXX@10.66.229.201:5432/dwh",
|
"sqlalchemy_uri": "postgresql+psycopg2://viz_powerbi_gp_prod:XXXXXXXXXX@10.66.229.201:5432/dwh",
|
||||||
@@ -46,7 +46,7 @@ database_config_gp={"new":
|
|||||||
"allow_cvas": "true",
|
"allow_cvas": "true",
|
||||||
"allow_dml": "true"
|
"allow_dml": "true"
|
||||||
},
|
},
|
||||||
"old": {
|
"new": {
|
||||||
"database_name": "DEV Greenplum",
|
"database_name": "DEV Greenplum",
|
||||||
"sqlalchemy_uri": "postgresql+psycopg2://viz_superset_gp_dev:XXXXXXXXXX@10.66.229.171:5432/dwh",
|
"sqlalchemy_uri": "postgresql+psycopg2://viz_superset_gp_dev:XXXXXXXXXX@10.66.229.171:5432/dwh",
|
||||||
"uuid": "97b97481-43c3-4181-94c5-b69eaaa1e11f",
|
"uuid": "97b97481-43c3-4181-94c5-b69eaaa1e11f",
|
||||||
@@ -102,9 +102,9 @@ dev_client = SupersetClient(dev_config)
|
|||||||
sandbox_client = SupersetClient(sandbox_config)
|
sandbox_client = SupersetClient(sandbox_config)
|
||||||
prod_client = SupersetClient(prod_config)
|
prod_client = SupersetClient(prod_config)
|
||||||
|
|
||||||
from_c = dev_client
|
from_c = sandbox_client
|
||||||
to_c = sandbox_client
|
to_c = dev_client
|
||||||
dashboard_slug = "FI0050"
|
dashboard_slug = "FI0070"
|
||||||
dashboard_id = 53
|
dashboard_id = 53
|
||||||
|
|
||||||
dashboard_meta = from_c.get_dashboard(dashboard_slug)
|
dashboard_meta = from_c.get_dashboard(dashboard_slug)
|
||||||
@@ -115,8 +115,11 @@ dashboard_id = dashboard_meta["id"]
|
|||||||
|
|
||||||
with create_temp_file(suffix='.dir', logger=logger) as temp_root:
|
with create_temp_file(suffix='.dir', logger=logger) as temp_root:
|
||||||
# Экспорт дашборда во временную директорию
|
# Экспорт дашборда во временную директорию
|
||||||
zip_content, filename = from_c.export_dashboard(dashboard_id, logger=logger)
|
#zip_content, filename = from_c.export_dashboard(dashboard_id, logger=logger)
|
||||||
|
zip_db_path = r"C:\Users\VolobuevAA\Downloads\dashboard_export_20250616T174203.zip"
|
||||||
|
|
||||||
|
zip_content, filename = read_dashboard_from_disk(zip_db_path, logger=logger)
|
||||||
|
|
||||||
# Сохранение и распаковка во временную директорию
|
# Сохранение и распаковка во временную директорию
|
||||||
zip_path, unpacked_path = save_and_unpack_dashboard(
|
zip_path, unpacked_path = save_and_unpack_dashboard(
|
||||||
zip_content=zip_content,
|
zip_content=zip_content,
|
||||||
@@ -128,8 +131,7 @@ with create_temp_file(suffix='.dir', logger=logger) as temp_root:
|
|||||||
|
|
||||||
# Обновление конфигураций
|
# Обновление конфигураций
|
||||||
source_path = unpacked_path / Path(filename).stem
|
source_path = unpacked_path / Path(filename).stem
|
||||||
update_db_yaml(database_config_click, path=source_path, logger=logger)
|
update_yamls([database_config_click,database_config_gp], path=source_path, logger=logger)
|
||||||
update_db_yaml(database_config_gp, path=source_path, logger=logger)
|
|
||||||
|
|
||||||
# Создание нового экспорта во временной директории
|
# Создание нового экспорта во временной директории
|
||||||
temp_zip = temp_root / f"{dashboard_slug}.zip"
|
temp_zip = temp_root / f"{dashboard_slug}.zip"
|
||||||
|
|||||||
@@ -1,39 +1,177 @@
|
|||||||
import requests
|
# [MODULE] Superset API Client
|
||||||
from requests.exceptions import HTTPError
|
# @contract: Реализует полное взаимодействие с Superset API
|
||||||
import urllib3
|
# @semantic_layers:
|
||||||
|
# 1. Авторизация/CSRF
|
||||||
|
# 2. Основные операции (дашборды)
|
||||||
|
# 3. Импорт/экспорт
|
||||||
|
# @coherence:
|
||||||
|
# - Согласован с models.SupersetConfig
|
||||||
|
# - Полная обработка всех errors из exceptions.py
|
||||||
|
|
||||||
|
# [IMPORTS] Стандартная библиотека
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Optional, Tuple, List, Any
|
from typing import Optional, Dict, Tuple, List, Any, Literal, Union,BinaryIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# [IMPORTS] Сторонние библиотеки
|
||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from .utils.fileio import *
|
from requests.exceptions import HTTPError
|
||||||
from .exceptions import *
|
|
||||||
|
# [IMPORTS] Локальные модули
|
||||||
from .models import SupersetConfig
|
from .models import SupersetConfig
|
||||||
|
from .exceptions import (
|
||||||
|
AuthenticationError,
|
||||||
|
SupersetAPIError,
|
||||||
|
DashboardNotFoundError,
|
||||||
|
NetworkError,
|
||||||
|
PermissionDeniedError,
|
||||||
|
ExportError
|
||||||
|
)
|
||||||
|
from .utils.fileio import get_filename_from_headers
|
||||||
from .utils.logger import SupersetLogger
|
from .utils.logger import SupersetLogger
|
||||||
|
|
||||||
class SupersetClient:
|
# [CONSTANTS] Логирование
|
||||||
def __init__(self, config: SupersetConfig):
|
HTTP_METHODS = Literal['GET', 'POST', 'PUT', 'DELETE']
|
||||||
self.config = config
|
DEFAULT_TIMEOUT = 30 # seconds
|
||||||
self.logger = config.logger or SupersetLogger(console=False)
|
|
||||||
self.session = requests.Session()
|
|
||||||
self._setup_session()
|
|
||||||
self._authenticate()
|
|
||||||
|
|
||||||
def _setup_session(self):
|
# [TYPE-ALIASES] Для сложных сигнатур
|
||||||
|
JsonType = Union[Dict[str, Any], List[Dict[str, Any]]]
|
||||||
|
ResponseType = Tuple[bytes, str]
|
||||||
|
|
||||||
|
# [CHECK] Валидация импортов для контрактов
|
||||||
|
try:
|
||||||
|
# Проверка наличия ключевых зависимостей
|
||||||
|
assert requests.__version__ >= '2.28.0' # для retry механизмов
|
||||||
|
assert urllib3.__version__ >= '1.26.0' # для SSL warnings
|
||||||
|
|
||||||
|
# Проверка локальных модулей
|
||||||
|
from .utils.fileio import get_filename_from_headers as fileio_check
|
||||||
|
assert callable(fileio_check)
|
||||||
|
|
||||||
|
except (ImportError, AssertionError) as imp_err:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"[COHERENCE_CHECK_FAILED] Импорт не прошел валидацию: {str(imp_err)}"
|
||||||
|
) from imp_err
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SupersetClient:
|
||||||
|
"""[MAIN-CONTRACT] Клиент для работы с Superset API
|
||||||
|
@pre:
|
||||||
|
- config должен быть валидным SupersetConfig
|
||||||
|
- Целевой API доступен
|
||||||
|
@post:
|
||||||
|
- Все методы возвращают данные или вызывают явные ошибки
|
||||||
|
- Токены автоматически обновляются
|
||||||
|
@invariant:
|
||||||
|
- Сессия остается валидной между вызовами
|
||||||
|
- Все ошибки типизированы согласно exceptions.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: SupersetConfig):
|
||||||
|
"""[INIT] Инициализация клиента
|
||||||
|
@semantic:
|
||||||
|
- Создает сессию requests
|
||||||
|
- Настраивает адаптеры подключения
|
||||||
|
- Выполняет первичную аутентификацию
|
||||||
|
"""
|
||||||
|
self._validate_config(config)
|
||||||
|
self.config = config
|
||||||
|
self.logger = config.logger or SupersetLogger(name="client")
|
||||||
|
self.session = self._setup_session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._authenticate()
|
||||||
|
self.logger.info(
|
||||||
|
"[COHERENCE_CHECK_PASSED] Клиент успешно инициализирован",
|
||||||
|
extra={"base_url": config.base_url}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
"[INIT_FAILED] Ошибка инициализации клиента",
|
||||||
|
exc_info=True,
|
||||||
|
extra={"config": config.dict()}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _validate_config(self, config: SupersetConfig) -> None:
|
||||||
|
"""[PRECONDITION] Валидация конфигурации клиента
|
||||||
|
@semantic:
|
||||||
|
- Проверяет обязательные поля
|
||||||
|
- Валидирует URL и учетные данные
|
||||||
|
@raise:
|
||||||
|
- ValueError при невалидных параметрах
|
||||||
|
- TypeError при некорректном типе
|
||||||
|
"""
|
||||||
|
if not isinstance(config, SupersetConfig):
|
||||||
|
self.logger.error(
|
||||||
|
"[CONFIG_VALIDATION_FAILED] Некорректный тип конфигурации",
|
||||||
|
extra={"actual_type": type(config).__name__}
|
||||||
|
)
|
||||||
|
raise TypeError("Конфигурация должна быть экземпляром SupersetConfig")
|
||||||
|
|
||||||
|
required_fields = ["base_url", "auth"]
|
||||||
|
for field in required_fields:
|
||||||
|
if not getattr(config, field, None):
|
||||||
|
self.logger.error(
|
||||||
|
"[CONFIG_VALIDATION_FAILED] Отсутствует обязательное поле",
|
||||||
|
extra={"missing_field": field}
|
||||||
|
)
|
||||||
|
raise ValueError(f"Обязательное поле {field} не указано")
|
||||||
|
|
||||||
|
if not config.auth.get("username") or not config.auth.get("password"):
|
||||||
|
self.logger.error(
|
||||||
|
"[CONFIG_VALIDATION_FAILED] Не указаны учетные данные",
|
||||||
|
extra={"auth_keys": list(config.auth.keys())}
|
||||||
|
)
|
||||||
|
raise ValueError("В конфигурации должны быть указаны username и password")
|
||||||
|
|
||||||
|
# Дополнительная валидация URL
|
||||||
|
if not config.base_url.startswith(("http://", "https://")):
|
||||||
|
self.logger.error(
|
||||||
|
"[CONFIG_VALIDATION_FAILED] Некорректный URL",
|
||||||
|
extra={"base_url": config.base_url}
|
||||||
|
)
|
||||||
|
raise ValueError("base_url должен начинаться с http:// или https://")
|
||||||
|
|
||||||
|
# [CHUNK] Настройка сессии и адаптеров
|
||||||
|
def _setup_session(self) -> requests.Session:
|
||||||
|
"""[INTERNAL] Конфигурация HTTP-сессии
|
||||||
|
@coherence_check: SSL verification должен соответствовать config
|
||||||
|
"""
|
||||||
|
session = requests.Session()
|
||||||
adapter = requests.adapters.HTTPAdapter(
|
adapter = requests.adapters.HTTPAdapter(
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
pool_connections=10,
|
pool_connections=10,
|
||||||
pool_maxsize=100
|
pool_maxsize=100
|
||||||
)
|
)
|
||||||
|
|
||||||
|
session.mount('https://', adapter)
|
||||||
|
session.verify = self.config.verify_ssl
|
||||||
|
|
||||||
if not self.config.verify_ssl:
|
if not self.config.verify_ssl:
|
||||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
urllib3.disable_warnings()
|
||||||
self.logger.debug(f"Проверка сертификатов SSL отключена")
|
self.logger.debug(
|
||||||
|
"[CONFIG] SSL verification отключен",
|
||||||
self.session.mount('https://', adapter)
|
extra={"base_url": self.config.base_url}
|
||||||
self.session.verify = self.config.verify_ssl
|
)
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
# [CHUNK] Процесс аутентификации
|
||||||
def _authenticate(self):
|
def _authenticate(self):
|
||||||
|
"""[AUTH-FLOW] Получение токенов
|
||||||
|
@semantic_steps:
|
||||||
|
1. Получение access_token
|
||||||
|
2. Получение CSRF токена
|
||||||
|
@error_handling:
|
||||||
|
- AuthenticationError при проблемах credentials
|
||||||
|
- NetworkError при проблемах связи
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Сначала логинимся для получения access_token
|
# [STEP 1] Получение bearer token
|
||||||
login_url = f"{self.config.base_url}/security/login"
|
login_url = f"{self.config.base_url}/security/login"
|
||||||
response = self.session.post(
|
response = self.session.post(
|
||||||
login_url,
|
login_url,
|
||||||
@@ -41,38 +179,59 @@ class SupersetClient:
|
|||||||
"username": self.config.auth["username"],
|
"username": self.config.auth["username"],
|
||||||
"password": self.config.auth["password"],
|
"password": self.config.auth["password"],
|
||||||
"provider": self.config.auth["provider"],
|
"provider": self.config.auth["provider"],
|
||||||
"refresh": True
|
"refresh": self.config.auth["refresh"]
|
||||||
},
|
},
|
||||||
verify=self.config.verify_ssl
|
timeout=self.config.timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise AuthenticationError(
|
||||||
|
"Invalid credentials",
|
||||||
|
context={
|
||||||
|
"endpoint": login_url,
|
||||||
|
"username": self.config.auth["username"],
|
||||||
|
"status_code": response.status_code
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
self.access_token = response.json()["access_token"]
|
self.access_token = response.json()["access_token"]
|
||||||
self.logger.info(
|
|
||||||
f"Токен Bearer {self.access_token} получен c {login_url}")
|
|
||||||
|
|
||||||
# Затем получаем CSRF токен с использованием access_token
|
# [STEP 2] Получение CSRF token
|
||||||
csrf_url = f"{self.config.base_url}/security/csrf_token/"
|
csrf_url = f"{self.config.base_url}/security/csrf_token/"
|
||||||
|
|
||||||
response = self.session.get(
|
response = self.session.get(
|
||||||
csrf_url,
|
csrf_url,
|
||||||
headers={"Authorization": f"Bearer {self.access_token}"},
|
headers={"Authorization": f"Bearer {self.access_token}"},
|
||||||
verify=self.config.verify_ssl
|
timeout=self.config.timeout
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
self.csrf_token = response.json()["result"]
|
self.csrf_token = response.json()["result"]
|
||||||
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"Токен CSRF {self.csrf_token} получен c {csrf_url}")
|
"[AUTH_SUCCESS] Токены успешно получены",
|
||||||
except HTTPError as e:
|
extra={
|
||||||
if e.response.status_code == 401:
|
"access_token": f"{self.access_token[:5]}...",
|
||||||
error_msg = f"Неверные данные для аутенфикации для {login_url}" if "login" in e.request.url else f"Не удалось получить CSRF токен с {csrf_url}"
|
"csrf_token": f"{self.csrf_token[:5]}..."
|
||||||
self.logger.error(f"Ошибка получения: {error_msg}")
|
}
|
||||||
raise AuthenticationError(
|
)
|
||||||
f"{error_msg}. Проверь данные аутенфикации") from e
|
|
||||||
raise
|
except requests.exceptions.RequestException as e:
|
||||||
|
error_context = {
|
||||||
|
"method": e.request.method,
|
||||||
|
"url": e.request.url,
|
||||||
|
"status_code": getattr(e.response, 'status_code', None)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isinstance(e, (requests.Timeout, requests.ConnectionError)):
|
||||||
|
raise NetworkError("Connection failed", context=error_context) from e
|
||||||
|
raise SupersetAPIError("Auth flow failed", context=error_context) from e
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def headers(self):
|
def headers(self) -> dict:
|
||||||
|
"""[INTERFACE] Базовые заголовки для API-вызовов
|
||||||
|
@semantic: Объединяет общие заголовки для всех запросов
|
||||||
|
@post: Всегда возвращает актуальные токены
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"Authorization": f"Bearer {self.access_token}",
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
"X-CSRFToken": self.csrf_token,
|
"X-CSRFToken": self.csrf_token,
|
||||||
@@ -80,256 +239,358 @@ class SupersetClient:
|
|||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_dashboard(self, dashboard_id_or_slug: str) -> Dict:
|
# [MAIN-OPERATIONS] Работа с дашбордами
|
||||||
"""
|
def get_dashboard(self, dashboard_id_or_slug: str) -> dict:
|
||||||
Получаем информацию по дашборду (если передан dashboard_id_or_slug)
|
"""[CONTRACT] Получение метаданных дашборда
|
||||||
Параметры:
|
@pre:
|
||||||
:dashboard_id_or_slug - id или короткая ссылка
|
- dashboard_id_or_slug должен существовать
|
||||||
|
- Токены должны быть валидны
|
||||||
|
@post:
|
||||||
|
- Возвращает полные метаданные
|
||||||
|
- В случае 404 вызывает DashboardNotFoundError
|
||||||
"""
|
"""
|
||||||
url = f"{self.config.base_url}/dashboard/{dashboard_id_or_slug}"
|
url = f"{self.config.base_url}/dashboard/{dashboard_id_or_slug}"
|
||||||
self.logger.debug(f"Получаем информацию по дашборду с /{url}...")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.session.get(
|
response = self.session.get(
|
||||||
url,
|
url,
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
timeout=self.config.timeout
|
timeout=self.config.timeout
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
|
||||||
self.logger.info(f"ОК - Получили информацию по дашборду с {response.url}")
|
if response.status_code == 404:
|
||||||
|
raise DashboardNotFoundError(
|
||||||
return response.json()["result"]
|
dashboard_id_or_slug,
|
||||||
except requests.exceptions.RequestException as e:
|
context={"url": url}
|
||||||
self.logger.error(
|
|
||||||
f"Ошибка при получении информации о дашборде: {str(e)}", exc_info=True)
|
|
||||||
raise SupersetAPIError(
|
|
||||||
f"Ошибка при получении информации о дашборде: {str(e)}") from e
|
|
||||||
|
|
||||||
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
|
||||||
"""
|
|
||||||
Получаем информацию по всем дашбордам с учетом пагинации.
|
|
||||||
|
|
||||||
Параметры:
|
|
||||||
query (Optional[Dict]): Дополнительные параметры запроса, включая пагинацию и фильтры.
|
|
||||||
|
|
||||||
Возвращает:
|
|
||||||
Tuple[int, List[Dict]]: Кортеж, содержащий общее количество дашбордов и список всех дашбордов.
|
|
||||||
"""
|
|
||||||
url = f"{self.config.base_url}/dashboard/"
|
|
||||||
self.logger.debug(f"Получаем информацию по дашбордам с {url}...")
|
|
||||||
modified_query: Dict = {}
|
|
||||||
all_results: List[Dict] = []
|
|
||||||
total_count: int = 0
|
|
||||||
current_page: int = 0
|
|
||||||
q_param = '{ "columns": [ "id" ], "page": 0, "page_size": 20}'
|
|
||||||
try:
|
|
||||||
response = self.session.get(
|
|
||||||
url=f"{url}?q={q_param}",
|
|
||||||
#params={"q": json.dumps(default_query)}, # Передаем такой body, иначе на prodta отдает 1 дашборд
|
|
||||||
headers=self.headers,
|
|
||||||
timeout=self.config.timeout
|
|
||||||
)
|
|
||||||
total_count = response.json()['count']
|
|
||||||
self.logger.info(
|
|
||||||
f"ОК - Получили кол-во дашбордов ({total_count}) с {url}")
|
|
||||||
self.logger.info(f"Запрос - {response.url}")
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Ошибка при получении кол-ва дашбордов: {str(e)}", exc_info=True)
|
|
||||||
raise SupersetAPIError(
|
|
||||||
f"Ошибка при получении кол-ва дашбордов: {str(e)}") from e
|
|
||||||
# Инициализация параметров запроса с учетом переданного query
|
|
||||||
|
|
||||||
if query:
|
|
||||||
modified_query = query.copy()
|
|
||||||
# Убедимся, что page_size установлен, если не передан
|
|
||||||
modified_query.setdefault("page_size", 20)
|
|
||||||
else:
|
|
||||||
modified_query = {
|
|
||||||
"columns": [
|
|
||||||
"slug",
|
|
||||||
"id",
|
|
||||||
"changed_on_utc",
|
|
||||||
"dashboard_title",
|
|
||||||
"published"
|
|
||||||
],
|
|
||||||
"page": 0,
|
|
||||||
"page_size": 20
|
|
||||||
}
|
|
||||||
|
|
||||||
page_size = modified_query["page_size"]
|
|
||||||
|
|
||||||
total_pages = (total_count + page_size - 1) // page_size
|
|
||||||
|
|
||||||
try:
|
|
||||||
while current_page < total_pages:
|
|
||||||
modified_query["page"] = current_page
|
|
||||||
response = self.session.get(
|
|
||||||
url,
|
|
||||||
headers=self.headers,
|
|
||||||
params={"q": json.dumps(modified_query)},
|
|
||||||
timeout=self.config.timeout
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
response.raise_for_status()
|
||||||
all_results.extend(data.get("result", []))
|
return response.json()["result"]
|
||||||
current_page += 1
|
|
||||||
|
|
||||||
self.logger.info(f"ОК - Получили информацию по дашбордам с {url}")
|
|
||||||
# Проверка, достигли ли последней страницы
|
|
||||||
return total_count, all_results
|
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
self.logger.error(
|
self._handle_api_error("get_dashboard", e, url)
|
||||||
f"Ошибка при получении информации о дашбордах: {str(e)}", exc_info=True)
|
|
||||||
raise SupersetAPIError(
|
|
||||||
f"Ошибка при получении информации о дашбордах: {str(e)}") from e
|
|
||||||
|
|
||||||
def export_dashboard(self, dashboard_id: int, logger: Optional[SupersetLogger] = None) -> Tuple[bytes, str]:
|
def export_dashboard(self, dashboard_id: int) -> tuple[bytes, str]:
|
||||||
"""Экспортирует дашборд из Superset в виде ZIP-архива и возвращает его содержимое с именем файла.
|
"""[CONTRACT] Экспорт дашборда в ZIP
|
||||||
|
@error_handling:
|
||||||
Параметры:
|
- DashboardNotFoundError если дашборд не существует
|
||||||
:dashboard_id (int): Идентификатор дашборда для экспорта
|
- ExportError при проблемах экспорта
|
||||||
|
"""
|
||||||
Возвращает:
|
|
||||||
Tuple[bytes, str]: Кортеж, содержащий:
|
|
||||||
- bytes: Бинарное содержимое ZIP-архива с дашбордом
|
|
||||||
- str: Имя файла (из заголовков ответа или в формате dashboard_{id}.zip)
|
|
||||||
|
|
||||||
Исключения:
|
|
||||||
SupersetAPIError: Вызывается при ошибках:
|
|
||||||
- Проблемы с сетью/соединением
|
|
||||||
- Невалидный ID дашборда
|
|
||||||
- Отсутствие прав доступа
|
|
||||||
- Ошибки сервера (status code >= 400)
|
|
||||||
|
|
||||||
Пример использования:
|
|
||||||
content, filename = client.export_dashboard(5)
|
|
||||||
with open(filename, 'wb') as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
Примечания:
|
|
||||||
- Для экспорта используется API Endpoint: /dashboard/export/
|
|
||||||
- Имя файла пытается извлечь из Content-Disposition заголовка
|
|
||||||
- По умолчанию возвращает имя в формате dashboard_{id}.zip
|
|
||||||
- Архив содержит JSON-метаданные и связанные элементы (датасеты, чарты)
|
|
||||||
"""
|
|
||||||
url = f"{self.config.base_url}/dashboard/export/"
|
url = f"{self.config.base_url}/dashboard/export/"
|
||||||
params = {"q": f"[{dashboard_id}]"}
|
|
||||||
logger = logger or SupersetLogger(name="client", console=False)
|
|
||||||
self.logger.debug(f"Экспортируем дашборд ID {dashboard_id} c {url}...")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.session.get(
|
response = self.session.get(
|
||||||
url,
|
url,
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
params=params,
|
params={"q": f"[{dashboard_id}]"},
|
||||||
timeout=self.config.timeout
|
timeout=self.config.timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise DashboardNotFoundError(dashboard_id)
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
|
filename = (
|
||||||
|
get_filename_from_headers(response.headers)
|
||||||
|
or f"dashboard_{dashboard_id}.zip"
|
||||||
|
)
|
||||||
|
return response.content, filename
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
self._handle_api_error("export_dashboard", e, url)
|
||||||
|
|
||||||
filename = get_filename_from_headers(
|
# [ERROR-HANDLER] Централизованная обработка ошибок
|
||||||
response.headers) or f"dashboard_{dashboard_id}.zip"
|
def _handle_api_error(self, method_name: str, error: Exception, url: str) -> None:
|
||||||
self.logger.info(f"Дашборд сохранен в {filename}")
|
"""[UNIFIED-ERROR] Обработка API-ошибок
|
||||||
|
@semantic: Преобразует requests исключения в наши типы
|
||||||
|
"""
|
||||||
|
context = {
|
||||||
|
"method": method_name,
|
||||||
|
"url": url,
|
||||||
|
"status_code": getattr(error.response, 'status_code', None)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isinstance(error, requests.Timeout):
|
||||||
|
raise NetworkError("Request timeout", context=context) from error
|
||||||
|
elif getattr(error.response, 'status_code', None) == 403:
|
||||||
|
raise PermissionDeniedError(context=context) from error
|
||||||
|
else:
|
||||||
|
raise SupersetAPIError(str(error), context=context) from error
|
||||||
|
|
||||||
|
# [SECTION] EXPORT OPERATIONS
|
||||||
|
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
|
||||||
|
"""[CONTRACT] Экспорт дашборда в ZIP-архив
|
||||||
|
@pre:
|
||||||
|
- dashboard_id должен существовать
|
||||||
|
- Пользователь имеет права на экспорт
|
||||||
|
@post:
|
||||||
|
- Возвращает кортеж (бинарное содержимое, имя файла)
|
||||||
|
- Имя файла извлекается из headers или генерируется
|
||||||
|
@errors:
|
||||||
|
- DashboardNotFoundError если дашборд не существует
|
||||||
|
- ExportError при проблемах экспорта
|
||||||
|
"""
|
||||||
|
url = f"{self.config.base_url}/dashboard/export/"
|
||||||
|
self.logger.debug(
|
||||||
|
"[EXPORT_START] Запуск экспорта",
|
||||||
|
extra={"dashboard_id": dashboard_id, "export_url": url}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._execute_export_request(dashboard_id, url)
|
||||||
|
self._validate_export_response(response, dashboard_id)
|
||||||
|
|
||||||
|
filename = self._resolve_export_filename(response, dashboard_id)
|
||||||
return response.content, filename
|
return response.content, filename
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.HTTPError as http_err:
|
||||||
self.logger.error(f"Ошибка при экспорте: {str(e)}", exc_info=True)
|
error_ctx = {
|
||||||
raise SupersetAPIError(f"Export failed: {str(e)}") from e
|
"dashboard_id": dashboard_id,
|
||||||
|
"status_code": http_err.response.status_code
|
||||||
|
}
|
||||||
|
if http_err.response.status_code == 404:
|
||||||
|
self.logger.error(
|
||||||
|
"[EXPORT_FAILED] Дашборд не найден",
|
||||||
|
extra=error_ctx
|
||||||
|
)
|
||||||
|
raise DashboardNotFoundError(dashboard_id, context=error_ctx)
|
||||||
|
raise ExportError("HTTP ошибка экспорта", context=error_ctx) from http_err
|
||||||
|
|
||||||
def import_dashboard(self, zip_path) -> Dict:
|
except requests.exceptions.RequestException as req_err:
|
||||||
"""Импортирует дашборд в Superset из ZIP-архива с детальной обработкой ошибок.
|
error_ctx = {"dashboard_id": dashboard_id}
|
||||||
|
self.logger.error(
|
||||||
|
"[EXPORT_FAILED] Ошибка запроса",
|
||||||
|
exc_info=True,
|
||||||
|
extra=error_ctx
|
||||||
|
)
|
||||||
|
raise ExportError("Ошибка экспорта", context=error_ctx) from req_err
|
||||||
|
|
||||||
Параметры:
|
def _execute_export_request(self, dashboard_id: int, url: str) -> requests.Response:
|
||||||
zip_path (Union[str, Path]): Путь к ZIP-файлу с дашбордом
|
"""[HELPER] Выполнение запроса экспорта
|
||||||
|
@coherence_check:
|
||||||
Возвращает:
|
- Ответ должен иметь status_code 200
|
||||||
dict: Ответ API в формате JSON с результатами импорта
|
- Content-Type: application/zip
|
||||||
|
|
||||||
Пример использования:
|
|
||||||
result = client.import_dashboard(Path("my_dashboard.zip"))
|
|
||||||
print(f"Импортирован дашборд: {result['title']}")
|
|
||||||
|
|
||||||
Примечания:
|
|
||||||
- Использует API Endpoint: /dashboard/import/
|
|
||||||
- Автоматически устанавливает overwrite=true для перезаписи существующих дашбордов
|
|
||||||
- Удваивает стандартный таймаут для обработки длительных операций
|
|
||||||
- Удаляет Content-Type заголовок (автоматически генерируется для multipart/form-data)
|
|
||||||
- Архив должен содержать валидные JSON-метаданные в формате Superset
|
|
||||||
- Ответ может содержать информацию о созданных/обновленных ресурсах (датасеты, чарты, владельцы)
|
|
||||||
- При конфликте имен может потребоваться ручное разрешение через параметры импорта
|
|
||||||
"""
|
"""
|
||||||
url = f"{self.config.base_url}/dashboard/import/"
|
response = self.session.get(
|
||||||
self.logger.debug(f"Импортируем дашборд ID {zip_path} на {url}...")
|
url,
|
||||||
|
headers=self.headers,
|
||||||
# Валидация входного файла
|
params={"q": f"[{dashboard_id}]"},
|
||||||
|
timeout=self.config.timeout,
|
||||||
|
stream=True
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _validate_export_response(self, response: requests.Response, dashboard_id: int) -> None:
|
||||||
|
"""[HELPER] Валидация ответа экспорта
|
||||||
|
@semantic:
|
||||||
|
- Проверка Content-Type
|
||||||
|
- Проверка наличия данных
|
||||||
|
"""
|
||||||
|
if 'application/zip' not in response.headers.get('Content-Type', ''):
|
||||||
|
self.logger.error(
|
||||||
|
"[EXPORT_VALIDATION_FAILED] Неверный Content-Type",
|
||||||
|
extra={
|
||||||
|
"dashboard_id": dashboard_id,
|
||||||
|
"content_type": response.headers.get('Content-Type')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise ExportError("Получен не ZIP-архив")
|
||||||
|
|
||||||
|
if not response.content:
|
||||||
|
self.logger.error(
|
||||||
|
"[EXPORT_VALIDATION_FAILED] Пустой ответ",
|
||||||
|
extra={"dashboard_id": dashboard_id}
|
||||||
|
)
|
||||||
|
raise ExportError("Получены пустые данные")
|
||||||
|
|
||||||
|
def _resolve_export_filename(self, response: requests.Response, dashboard_id: int) -> str:
|
||||||
|
"""[HELPER] Определение имени экспортируемого файла
|
||||||
|
@fallback: Генерирует имя если не найден заголовок
|
||||||
|
"""
|
||||||
|
filename = get_filename_from_headers(response.headers)
|
||||||
|
if not filename:
|
||||||
|
filename = f"dashboard_export_{dashboard_id}_{datetime.now().strftime('%Y%m%d')}.zip"
|
||||||
|
self.logger.debug(
|
||||||
|
"[EXPORT_FALLBACK] Используется сгенерированное имя файла",
|
||||||
|
extra={"filename": filename}
|
||||||
|
)
|
||||||
|
return filename
|
||||||
|
|
||||||
|
def export_to_file(self, dashboard_id: int, output_dir: Union[str, Path]) -> Path:
|
||||||
|
"""[CONTRACT] Экспорт дашборда прямо в файл
|
||||||
|
@pre:
|
||||||
|
- output_dir должен существовать
|
||||||
|
- Доступ на запись в директорию
|
||||||
|
@post:
|
||||||
|
- Возвращает Path сохраненного файла
|
||||||
|
- Создает поддиректорию с именем дашборда
|
||||||
|
"""
|
||||||
|
output_dir = Path(output_dir)
|
||||||
|
if not output_dir.exists():
|
||||||
|
self.logger.error(
|
||||||
|
"[EXPORT_PRE_FAILED] Директория не существует",
|
||||||
|
extra={"output_dir": str(output_dir)}
|
||||||
|
)
|
||||||
|
raise FileNotFoundError(f"Директория {output_dir} не найдена")
|
||||||
|
|
||||||
|
content, filename = self.export_dashboard(dashboard_id)
|
||||||
|
target_path = output_dir / filename
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not Path(zip_path).exists():
|
with open(target_path, 'wb') as f:
|
||||||
raise FileNotFoundError(f"Файл не найден: {zip_path}")
|
f.write(content)
|
||||||
|
|
||||||
if not zipfile.is_zipfile(zip_path):
|
self.logger.info(
|
||||||
raise InvalidZipFormatError(f"Файл не является ZIP-архивом: {zip_path}")
|
"[EXPORT_SUCCESS] Дашборд сохранен на диск",
|
||||||
|
extra={
|
||||||
|
"dashboard_id": dashboard_id,
|
||||||
|
"file_path": str(target_path),
|
||||||
|
"file_size": len(content)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return target_path
|
||||||
|
|
||||||
|
except IOError as io_err:
|
||||||
|
self.logger.error(
|
||||||
|
"[EXPORT_IO_FAILED] Ошибка записи файла",
|
||||||
|
exc_info=True,
|
||||||
|
extra={"target_path": str(target_path)}
|
||||||
|
)
|
||||||
|
raise ExportError("Ошибка сохранения файла") from io_err
|
||||||
|
|
||||||
|
# [SECTION] Основной интерфейс API
|
||||||
|
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
||||||
|
"""[CONTRACT] Получение списка дашбордов с пагинацией
|
||||||
|
@pre:
|
||||||
|
- Клиент должен быть авторизован
|
||||||
|
- Параметры пагинации должны быть валидны
|
||||||
|
@post:
|
||||||
|
- Возвращает кортеж (total_count, список метаданных)
|
||||||
|
- Поддерживает кастомные query-параметры
|
||||||
|
@invariant:
|
||||||
|
- Всегда возвращает полный список (обходит пагинацию)
|
||||||
|
"""
|
||||||
|
url = f"{self.config.base_url}/dashboard/"
|
||||||
|
self.logger.debug(
|
||||||
|
"[API_CALL] Запрос списка дашбордов",
|
||||||
|
extra={"query": query}
|
||||||
|
)
|
||||||
|
|
||||||
|
# [COHERENCE_CHECK] Валидация параметров
|
||||||
|
validated_query = self._validate_query_params(query)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Инициализация пагинации
|
||||||
|
total_count = self._fetch_total_count(url)
|
||||||
|
paginated_data = self._fetch_all_pages(url, validated_query, total_count)
|
||||||
|
|
||||||
# Дополнительная проверка содержимого архива
|
self.logger.info(
|
||||||
with zipfile.ZipFile(zip_path) as zf:
|
"[API_SUCCESS] Дашборды получены",
|
||||||
if not any(name.endswith('metadata.yaml') for name in zf.namelist()):
|
extra={"count": total_count}
|
||||||
raise DashboardNotFoundError("Архив не содержит metadata.yaml")
|
)
|
||||||
|
return total_count, paginated_data
|
||||||
except (FileNotFoundError, InvalidZipFormatError, DashboardNotFoundError) as e:
|
|
||||||
self.logger.error(f"Ошибка валидации архива: {str(e)}", exc_info=True)
|
except requests.exceptions.RequestException as e:
|
||||||
raise
|
error_ctx = {"method": "get_dashboards", "query": validated_query}
|
||||||
|
self._handle_api_error("Пагинация дашбордов", e, error_ctx)
|
||||||
headers = {
|
|
||||||
k: v for k, v in self.headers.items()
|
|
||||||
if k.lower() != 'content-type'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# [SECTION] Импорт/экспорт
|
||||||
|
def import_dashboard(self, zip_path: Union[str, Path]) -> Dict:
|
||||||
|
"""[CONTRACT] Импорт дашборда из архива
|
||||||
|
@pre:
|
||||||
|
- Файл должен существовать и быть валидным ZIP
|
||||||
|
- Должны быть права на импорт
|
||||||
|
@post:
|
||||||
|
- Возвращает метаданные импортированного дашборда
|
||||||
|
- При конфликтах выполняет overwrite
|
||||||
|
"""
|
||||||
|
self._validate_import_file(zip_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(zip_path, 'rb') as f:
|
with open(zip_path, 'rb') as f:
|
||||||
files = {
|
return self._execute_import(
|
||||||
'formData': (
|
file_obj=f,
|
||||||
Path(zip_path).name,
|
file_name=Path(zip_path).name
|
||||||
f,
|
|
||||||
'application/x-zip-compressed'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.session.post(
|
|
||||||
url,
|
|
||||||
files=files,
|
|
||||||
data={'overwrite': 'true'},
|
|
||||||
headers=headers,
|
|
||||||
timeout=self.config.timeout * 2
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Обработка HTTP-ошибок
|
|
||||||
if response.status_code == 404:
|
|
||||||
raise DashboardNotFoundError("Эндпоинт импорта не найден")
|
|
||||||
elif response.status_code == 403:
|
|
||||||
raise PermissionDeniedError("Недостаточно прав для импорта")
|
|
||||||
elif response.status_code >= 500:
|
|
||||||
raise SupersetServerError(f"Ошибка сервера: {response.status_code}")
|
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
self.logger.info(f"Дашборд успешно импортирован из {zip_path}")
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
except requests.exceptions.ConnectionError as e:
|
|
||||||
error_msg = f"Ошибка соединения: {str(e)}"
|
|
||||||
self.logger.error(error_msg, exc_info=True)
|
|
||||||
raise NetworkError(error_msg) from e
|
|
||||||
|
|
||||||
except requests.exceptions.Timeout as e:
|
|
||||||
error_msg = f"Таймаут при импорте дашборда"
|
|
||||||
self.logger.error(error_msg, exc_info=True)
|
|
||||||
raise NetworkError(error_msg) from e
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
error_msg = f"Ошибка при импорте: {str(e)}"
|
|
||||||
self.logger.error(error_msg, exc_info=True)
|
|
||||||
raise DashboardImportError(error_msg) from e
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Неожиданная ошибка: {str(e)}"
|
self.logger.error(
|
||||||
self.logger.critical(error_msg, exc_info=True)
|
"[IMPORT_FAILED] Критическая ошибка импорта",
|
||||||
raise DashboardImportError(error_msg) from e
|
exc_info=True,
|
||||||
|
extra={"file": str(zip_path)}
|
||||||
|
)
|
||||||
|
raise DashboardImportError(f"Import failed: {str(e)}") from e
|
||||||
|
|
||||||
|
# [SECTION] Приватные методы-помощники
|
||||||
|
def _validate_query_params(self, query: Optional[Dict]) -> Dict:
|
||||||
|
"""[HELPER] Нормализация параметров запроса"""
|
||||||
|
base_query = {
|
||||||
|
"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"],
|
||||||
|
"page": 0,
|
||||||
|
"page_size": 20
|
||||||
|
}
|
||||||
|
return {**base_query, **(query or {})}
|
||||||
|
|
||||||
|
def _fetch_total_count(self, url: str) -> int:
|
||||||
|
"""[HELPER] Получение общего количества дашбордов"""
|
||||||
|
count_response = self.session.get(
|
||||||
|
f"{url}?q={json.dumps({'columns': ['id'], 'page': 0, 'page_size': 1})}",
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=self.config.timeout
|
||||||
|
)
|
||||||
|
count_response.raise_for_status()
|
||||||
|
return count_response.json()['count']
|
||||||
|
|
||||||
|
def _fetch_all_pages(self, url: str, query: Dict, total_count: int) -> List[Dict]:
|
||||||
|
"""[HELPER] Обход всех страниц с пагинацией"""
|
||||||
|
results = []
|
||||||
|
page_size = query['page_size']
|
||||||
|
total_pages = (total_count + page_size - 1) // page_size
|
||||||
|
|
||||||
|
for page in range(total_pages):
|
||||||
|
query['page'] = page
|
||||||
|
response = self.session.get(
|
||||||
|
url,
|
||||||
|
headers=self.headers,
|
||||||
|
params={"q": json.dumps(query)},
|
||||||
|
timeout=self.config.timeout
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
results.extend(response.json().get('result', []))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
|
||||||
|
"""[HELPER] Проверка файла перед импортом"""
|
||||||
|
path = Path(zip_path)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"[FILE_ERROR] {zip_path} не существует")
|
||||||
|
|
||||||
|
if not zipfile.is_zipfile(path):
|
||||||
|
raise InvalidZipFormatError(f"[FILE_ERROR] {zip_path} не ZIP-архив")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(path) as zf:
|
||||||
|
if not any(n.endswith('metadata.yaml') for n in zf.namelist()):
|
||||||
|
raise DashboardNotFoundError("Архив не содержит metadata.yaml")
|
||||||
|
|
||||||
|
def _execute_import(self, file_obj: BinaryIO, file_name: str) -> Dict:
|
||||||
|
"""[HELPER] Выполнение API-запроса импорта"""
|
||||||
|
url = f"{self.config.base_url}/dashboard/import/"
|
||||||
|
|
||||||
|
files = {'formData': (file_name, file_obj, 'application/x-zip-compressed')}
|
||||||
|
headers = {k: v for k, v in self.headers.items() if k.lower() != 'content-type'}
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
files=files,
|
||||||
|
data={'overwrite': 'true'},
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.config.timeout * 2 # Увеличенный таймаут для импорта
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionDeniedError("Недостаточно прав для импорта")
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
@@ -1,32 +1,79 @@
|
|||||||
|
# [MODULE] Иерархия исключений
|
||||||
|
# @contract: Все ошибки наследуют SupersetToolError
|
||||||
|
# @semantic: Каждый тип соответствует конкретной проблемной области
|
||||||
|
# @coherence:
|
||||||
|
# - Полное покрытие всех сценариев клиента
|
||||||
|
# - Четкая классификация по уровню серьезности
|
||||||
|
|
||||||
|
# [IMPORTS] Exceptions
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
class SupersetToolError(Exception):
|
class SupersetToolError(Exception):
|
||||||
"""Base exception class for all tool errors"""
|
"""[BASE] Базовый класс ошибок инструмента
|
||||||
|
@semantic: Должен содержать контекст для диагностики
|
||||||
|
"""
|
||||||
|
def __init__(self, message: str, context: Optional[dict] = None):
|
||||||
|
self.context = context or {}
|
||||||
|
super().__init__(f"{message} | Context: {self.context}")
|
||||||
|
|
||||||
|
# [ERROR-GROUP] Проблемы аутентификации и авторизации
|
||||||
class AuthenticationError(SupersetToolError):
|
class AuthenticationError(SupersetToolError):
|
||||||
"""Authentication related errors"""
|
"""[AUTH] Ошибки credentials или доступа
|
||||||
|
@context: url, username, error_detail
|
||||||
|
"""
|
||||||
|
def __init__(self, message="Auth failed", **context):
|
||||||
|
super().__init__(
|
||||||
|
f"[AUTH_FAILURE] {message}",
|
||||||
|
{"type": "authentication", **context}
|
||||||
|
)
|
||||||
|
|
||||||
|
class PermissionDeniedError(AuthenticationError):
|
||||||
|
"""[AUTH] Ошибка отказа в доступе из-за недостаточных прав
|
||||||
|
@context: required_permission, user_roles
|
||||||
|
"""
|
||||||
|
def __init__(self, required_permission: str, **context):
|
||||||
|
super().__init__(
|
||||||
|
f"Permission denied: {required_permission}",
|
||||||
|
{"type": "authorization", "required_permission": required_permission, **context}
|
||||||
|
)
|
||||||
|
|
||||||
|
# [ERROR-GROUP] Проблемы API-вызовов
|
||||||
class SupersetAPIError(SupersetToolError):
|
class SupersetAPIError(SupersetToolError):
|
||||||
"""General API communication errors"""
|
"""[API] Ошибки взаимодействия с Superset API
|
||||||
|
@context: endpoint, method, status_code, response
|
||||||
|
"""
|
||||||
|
def __init__(self, message="API error", **context):
|
||||||
|
super().__init__(
|
||||||
|
f"[API_FAILURE] {message}",
|
||||||
|
{"type": "api_call", **context}
|
||||||
|
)
|
||||||
|
|
||||||
class ExportError(SupersetToolError):
|
# [ERROR-SUBCLASS] Детализированные ошибки API
|
||||||
"""Dashboard export errors"""
|
class ExportError(SupersetAPIError):
|
||||||
|
"""[API:EXPORT] Проблемы экспорта дашбордов"""
|
||||||
|
...
|
||||||
|
|
||||||
class ImportError(SupersetToolError):
|
class DashboardNotFoundError(SupersetAPIError):
|
||||||
"""Dashboard import errors"""
|
"""[API:404] Запрошенный ресурс не существует"""
|
||||||
|
def __init__(self, dashboard_id, **context):
|
||||||
|
super().__init__(
|
||||||
|
f"Dashboard {dashboard_id} not found",
|
||||||
|
{"dashboard_id": dashboard_id, **context}
|
||||||
|
)
|
||||||
|
|
||||||
|
# [ERROR-SUBCLASS] Детализированные ошибки обработки файлов
|
||||||
|
class InvalidZipFormatError(SupersetAPIError):
|
||||||
|
"""[API:ZIP] Некорректный формат ZIP-архива
|
||||||
|
@context: file_path, expected_format, error_detail
|
||||||
|
"""
|
||||||
|
def __init__(self, file_path: str, **context):
|
||||||
|
super().__init__(
|
||||||
|
f"Invalid ZIP format for file: {file_path}",
|
||||||
|
{"type": "zip_validation", "file_path": file_path, **context}
|
||||||
|
)
|
||||||
|
|
||||||
class InvalidZipFormatError(SupersetToolError):
|
|
||||||
"Archive zip errors"
|
|
||||||
|
|
||||||
class DashboardNotFoundError(SupersetToolError):
|
|
||||||
"404 error"
|
|
||||||
|
|
||||||
class PermissionDeniedError(SupersetToolError):
|
|
||||||
"403 error"
|
|
||||||
|
|
||||||
class SupersetServerError(SupersetToolError):
|
|
||||||
"500 error"
|
|
||||||
|
|
||||||
|
# [ERROR-GROUP] Системные и network-ошибки
|
||||||
class NetworkError(SupersetToolError):
|
class NetworkError(SupersetToolError):
|
||||||
"Network errors"
|
"""[NETWORK] Проблемы соединения или таймауты"""
|
||||||
|
...
|
||||||
class DashboardImportError(SupersetToolError):
|
|
||||||
"Api import errors"
|
|
||||||
@@ -1,21 +1,98 @@
|
|||||||
# models.py
|
# [MODULE] Сущности данных конфигурации
|
||||||
from pydantic import BaseModel, validator
|
# @desc: Определяет структуры данных для работы с Superset API
|
||||||
from typing import Optional
|
# @contracts:
|
||||||
|
# - Проверка валидности URL
|
||||||
|
# - Валидация параметров аутентификации
|
||||||
|
# @coherence:
|
||||||
|
# - Все модели согласованы с API Superset v1
|
||||||
|
# - Совместимы с клиентскими методами
|
||||||
|
|
||||||
|
# [IMPORTS] Models
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from pydantic import BaseModel, validator,Field
|
||||||
from .utils.logger import SupersetLogger
|
from .utils.logger import SupersetLogger
|
||||||
|
|
||||||
class SupersetConfig(BaseModel):
|
class SupersetConfig(BaseModel):
|
||||||
base_url: str
|
"""[CONFIG] Конфигурация подключения к Superset
|
||||||
|
@semantic: Основные параметры подключения к API
|
||||||
|
@invariant:
|
||||||
|
- base_url должен содержать версию API (/v1/)
|
||||||
|
- auth должен содержать все обязательные поля
|
||||||
|
"""
|
||||||
|
base_url: str = Field(..., regex=r'.*/api/v1.*')
|
||||||
auth: dict
|
auth: dict
|
||||||
verify_ssl: bool = True
|
verify_ssl: bool = True
|
||||||
timeout: int = 30
|
timeout: int = 30
|
||||||
logger: Optional[SupersetLogger] = None
|
logger: Optional[SupersetLogger] = None
|
||||||
|
|
||||||
class Config:
|
# [VALIDATOR] Проверка параметров аутентификации
|
||||||
arbitrary_types_allowed = True # Разрешаем произвольные типы
|
@validator('auth')
|
||||||
|
def validate_auth(cls, v):
|
||||||
|
required = {'provider', 'username', 'password', 'refresh'}
|
||||||
|
if not required.issubset(v.keys()):
|
||||||
|
raise ValueError(
|
||||||
|
f"[CONTRACT_VIOLATION] Auth must contain {required}"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
arbitrary_types_allowed = True
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"base_url": "https://host/api/v1/",
|
||||||
|
"auth": {
|
||||||
|
"provider": "db",
|
||||||
|
"username": "user",
|
||||||
|
"password": "pass",
|
||||||
|
"refresh": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# [SEMANTIC-TYPE] Конфигурация БД для миграций
|
||||||
class DatabaseConfig(BaseModel):
|
class DatabaseConfig(BaseModel):
|
||||||
database_config: dict
|
"""[CONFIG] Параметры трансформации БД при миграции
|
||||||
|
@semantic: Содержит old/new состояние для преобразования
|
||||||
|
@invariant:
|
||||||
|
- Должны быть указаны оба состояния (old/new)
|
||||||
|
- UUID должен соответствовать формату
|
||||||
|
"""
|
||||||
|
database_config: Dict[str, Dict[str, Any]]
|
||||||
logger: Optional[SupersetLogger] = None
|
logger: Optional[SupersetLogger] = None
|
||||||
|
|
||||||
|
@validator('database_config')
|
||||||
|
def validate_config(cls, v):
|
||||||
|
if not {'old', 'new'}.issubset(v.keys()):
|
||||||
|
raise ValueError(
|
||||||
|
"[COHERENCE_ERROR] Config must contain both old/new states"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
arbitrary_types_allowed = True
|
arbitrary_types_allowed = True
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"database_config": {
|
||||||
|
"old":
|
||||||
|
{
|
||||||
|
"database_name": "Prod Clickhouse",
|
||||||
|
"sqlalchemy_uri": "clickhousedb+connect://clicketl:XXXXXXXXXX@rgm-s-khclk.hq.root.ad:443/dm",
|
||||||
|
"uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9",
|
||||||
|
"database_uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9",
|
||||||
|
"allow_ctas": "false",
|
||||||
|
"allow_cvas": "false",
|
||||||
|
"allow_dml": "false"
|
||||||
|
},
|
||||||
|
"new": {
|
||||||
|
"database_name": "Dev Clickhouse",
|
||||||
|
"sqlalchemy_uri": "clickhousedb+connect://dwhuser:XXXXXXXXXX@10.66.229.179:8123/dm",
|
||||||
|
"uuid": "e9fd8feb-cb77-4e82-bc1d-44768b8d2fc2",
|
||||||
|
"database_uuid": "e9fd8feb-cb77-4e82-bc1d-44768b8d2fc2",
|
||||||
|
"allow_ctas": "true",
|
||||||
|
"allow_cvas": "true",
|
||||||
|
"allow_dml": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -42,20 +42,17 @@ class SupersetLogger:
|
|||||||
def _get_timestamp(self) -> str:
|
def _get_timestamp(self) -> str:
|
||||||
return datetime.now().strftime("%Y%m%d")
|
return datetime.now().strftime("%Y%m%d")
|
||||||
|
|
||||||
def info(self, message: str, exc_info: bool = False):
|
def info(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
|
||||||
self.logger.info(message, exc_info=exc_info)
|
self.logger.info(message, extra=extra, exc_info=exc_info)
|
||||||
|
|
||||||
def error(self, message: str, exc_info: bool = False):
|
def error(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
|
||||||
self.logger.error(message, exc_info=exc_info)
|
self.logger.error(message, extra=extra, exc_info=exc_info)
|
||||||
|
|
||||||
def warning(self, message: str, exc_info: bool = False):
|
def warning(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
|
||||||
self.logger.warning(message, exc_info=exc_info)
|
self.logger.warning(message, extra=extra, exc_info=exc_info)
|
||||||
|
|
||||||
def debug(self, message: str, exc_info: bool = False):
|
def debug(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
|
||||||
self.logger.debug(message, exc_info=exc_info)
|
self.logger.debug(message, extra=extra, exc_info=exc_info)
|
||||||
|
|
||||||
def exception(self, message: str):
|
def exception(self, message: str):
|
||||||
self.logger.exception(message)
|
self.logger.exception(message)
|
||||||
|
|
||||||
def critical(self, message: str, exc_info: bool = False):
|
|
||||||
self.logger.critical(message, exc_info=exc_info)
|
|
||||||
|
|||||||
113
test api.py
113
test api.py
@@ -1,113 +0,0 @@
|
|||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
import shutil
|
|
||||||
import os
|
|
||||||
import keyring
|
|
||||||
from pathlib import Path
|
|
||||||
from superset_tool.models import SupersetConfig, DatabaseConfig
|
|
||||||
from superset_tool.client import SupersetClient
|
|
||||||
from superset_tool.utils.fileio import save_and_unpack_dashboard, archive_exports
|
|
||||||
|
|
||||||
# Настройка логирования
|
|
||||||
LOG_DIR = Path("P:\\Superset\\010 Бекапы\\Logs")
|
|
||||||
LOG_DIR.mkdir(exist_ok=True, parents=True)
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
||||||
handlers=[
|
|
||||||
logging.FileHandler(LOG_DIR / f"superset_backup_{datetime.now().strftime('%Y%m%d')}.log"),
|
|
||||||
logging.StreamHandler()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def setup_clients():
|
|
||||||
"""Инициализация клиентов для разных окружений"""
|
|
||||||
clients = {}
|
|
||||||
try:
|
|
||||||
|
|
||||||
# Конфигурация для Prod
|
|
||||||
prod_config = SupersetConfig(
|
|
||||||
base_url="https://prodta.bi.dwh.rusal.com/api/v1",
|
|
||||||
auth={
|
|
||||||
"provider": "db",
|
|
||||||
"username": "migrate_user",
|
|
||||||
"password": keyring.get_password("system", "prod migrate"),
|
|
||||||
"refresh": True
|
|
||||||
},
|
|
||||||
verify_ssl=False
|
|
||||||
)
|
|
||||||
# Конфигурация для Dev
|
|
||||||
dev_config = SupersetConfig(
|
|
||||||
base_url="https://devta.bi.dwh.rusal.com/api/v1",
|
|
||||||
auth={
|
|
||||||
"provider": "db",
|
|
||||||
"username": "migrate_user",
|
|
||||||
"password": keyring.get_password("system", "dev migrate"),
|
|
||||||
"refresh": True
|
|
||||||
},
|
|
||||||
verify_ssl=False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Конфигурация для Sandbox
|
|
||||||
sandbox_config = SupersetConfig(
|
|
||||||
base_url="https://sandboxta.bi.dwh.rusal.com/api/v1",
|
|
||||||
auth={
|
|
||||||
"provider": "db",
|
|
||||||
"username": "migrate_user",
|
|
||||||
"password": keyring.get_password("system", "sandbox migrate"),
|
|
||||||
"refresh": True
|
|
||||||
},
|
|
||||||
verify_ssl=False
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
clients['dev'] = SupersetClient(dev_config)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка инициализации клиента: {str(e)}")
|
|
||||||
# try:
|
|
||||||
# clients['sbx'] = SupersetClient(sandbox_config)
|
|
||||||
# except Exception as e:
|
|
||||||
# logger.error(f"Ошибка инициализации клиента: {str(e)}")
|
|
||||||
try:
|
|
||||||
clients['prod'] = SupersetClient(prod_config)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка инициализации клиента: {str(e)}")
|
|
||||||
|
|
||||||
return clients
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка инициализации клиентов: {str(e)}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def backup_dashboards(client, env_name, backup_root):
|
|
||||||
"""Выполнение бэкапа дашбордов для указанного окружения"""
|
|
||||||
#logger.info(f"Начало бэкапа для окружения {env_name}")
|
|
||||||
|
|
||||||
#print(client.get_dashboards())
|
|
||||||
print(client.get_dashboard("IM0010"))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
clients = setup_clients()
|
|
||||||
superset_backup_repo = Path("P:\\Superset\\010 Бекапы")
|
|
||||||
|
|
||||||
# Бэкап для DEV
|
|
||||||
dev_success = backup_dashboards(
|
|
||||||
clients['dev'],
|
|
||||||
"DEV",
|
|
||||||
superset_backup_repo
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Бэкап для Sandbox
|
|
||||||
# sbx_success = backup_dashboards(
|
|
||||||
# clients['sbx'],
|
|
||||||
# "SBX",
|
|
||||||
# superset_backup_repo
|
|
||||||
# )
|
|
||||||
|
|
||||||
prod_success = backup_dashboards(
|
|
||||||
clients['prod'],
|
|
||||||
"PROD",
|
|
||||||
superset_backup_repo
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user