migration refactor
This commit is contained in:
@@ -1,15 +1,11 @@
|
||||
# [MODULE] Сетевой клиент для API
|
||||
# @contract: Инкапсулирует низкоуровневую HTTP-логику, аутентификацию, повторные попытки и обработку сетевых ошибок.
|
||||
# @semantic_layers:
|
||||
# 1. Инициализация сессии `requests` с настройками SSL и таймаутов.
|
||||
# 2. Управление аутентификацией (получение и обновление access/CSRF токенов).
|
||||
# 3. Выполнение HTTP-запросов (GET, POST и т.д.) с автоматическими заголовками.
|
||||
# 4. Обработка пагинации для API-ответов.
|
||||
# 5. Обработка загрузки файлов.
|
||||
# @coherence:
|
||||
# - Полностью независим от `SupersetClient`, предоставляя ему чистый API для сетевых операций.
|
||||
# - Использует `SupersetLogger` для внутреннего логирования.
|
||||
# - Всегда выбрасывает типизированные исключения из `superset_tool.exceptions`.
|
||||
# -*- coding: utf-8 -*-
|
||||
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument
|
||||
"""
|
||||
[MODULE] Сетевой клиент для API
|
||||
|
||||
[DESCRIPTION]
|
||||
Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API.
|
||||
"""
|
||||
|
||||
# [IMPORTS] Стандартная библиотека
|
||||
from typing import Optional, Dict, Any, BinaryIO, List, Union
|
||||
@@ -19,173 +15,106 @@ from pathlib import Path
|
||||
|
||||
# [IMPORTS] Сторонние библиотеки
|
||||
import requests
|
||||
import urllib3 # Для отключения SSL-предупреждений
|
||||
import urllib3 # Для отключения SSL-предупреждений
|
||||
|
||||
# [IMPORTS] Локальные модули
|
||||
from ..exceptions import AuthenticationError, NetworkError, DashboardNotFoundError, SupersetAPIError, PermissionDeniedError
|
||||
from .logger import SupersetLogger # Импорт логгера
|
||||
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
|
||||
|
||||
class APIClient:
|
||||
"""[NETWORK-CORE] Инкапсулирует HTTP-логику для работы с API.
|
||||
@contract:
|
||||
- Гарантирует retry-механизмы для запросов.
|
||||
- Выполняет SSL-валидацию или отключает ее по конфигурации.
|
||||
- Автоматически управляет access и CSRF токенами.
|
||||
- Преобразует HTTP-ошибки в типизированные исключения `superset_tool.exceptions`.
|
||||
@pre:
|
||||
- `base_url` должен быть валидным URL.
|
||||
- `auth` должен содержать необходимые данные для аутентификации.
|
||||
- `logger` должен быть инициализирован.
|
||||
@post:
|
||||
- Аутентификация выполняется при первом запросе или явно через `authenticate()`.
|
||||
- `self._tokens` всегда содержит актуальные access/CSRF токены после успешной аутентификации.
|
||||
@invariant:
|
||||
- Сессия `requests` активна и настроена.
|
||||
- Все запросы используют актуальные токены.
|
||||
"""
|
||||
"""[NETWORK-CORE] Инкапсулирует HTTP-логику для работы с API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
auth: Dict[str, Any],
|
||||
config: Dict[str, Any],
|
||||
verify_ssl: bool = True,
|
||||
timeout: int = 30,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
logger: Optional[SupersetLogger] = None
|
||||
):
|
||||
# [INIT] Основные параметры
|
||||
self.base_url = base_url
|
||||
self.auth = auth
|
||||
self.verify_ssl = verify_ssl
|
||||
self.timeout = timeout
|
||||
self.logger = logger or SupersetLogger(name="APIClient") # [COHERENCE_CHECK_PASSED] Инициализация логгера
|
||||
|
||||
# [INIT] Сессия Requests
|
||||
self.logger = logger or SupersetLogger(name="APIClient")
|
||||
self.logger.info("[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.session = self._init_session()
|
||||
self._tokens: Dict[str, str] = {} # [STATE] Хранилище токенов
|
||||
self._authenticated = False # [STATE] Флаг аутентификации
|
||||
|
||||
self.logger.debug(
|
||||
"[INIT] APIClient инициализирован.",
|
||||
extra={"base_url": self.base_url, "verify_ssl": self.verify_ssl}
|
||||
)
|
||||
self._tokens: Dict[str, str] = {}
|
||||
self._authenticated = False
|
||||
self.logger.info("[INFO][APIClient.__init__][SUCCESS] APIClient initialized.")
|
||||
|
||||
def _init_session(self) -> requests.Session:
|
||||
"""[HELPER] Настройка сессии `requests` с адаптерами и SSL-опциями.
|
||||
@semantic: Создает и конфигурирует объект `requests.Session`.
|
||||
"""
|
||||
self.logger.debug("[DEBUG][APIClient._init_session][ENTER] Initializing session.")
|
||||
session = requests.Session()
|
||||
# [CONTRACT] Настройка повторных попыток
|
||||
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"}
|
||||
)
|
||||
session.mount('http://', requests.adapters.HTTPAdapter(max_retries=retries))
|
||||
session.mount('https://', requests.adapters.HTTPAdapter(max_retries=retries))
|
||||
|
||||
session.verify = self.verify_ssl
|
||||
if not self.verify_ssl:
|
||||
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:
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
self.logger.warning("[SECURITY] Отключена проверка SSL-сертификатов. Не использовать в продакшене без явной необходимости.")
|
||||
self.logger.warning("[WARNING][APIClient._init_session][STATE_CHANGE] SSL verification disabled.")
|
||||
self.logger.debug("[DEBUG][APIClient._init_session][SUCCESS] Session initialized.")
|
||||
return session
|
||||
|
||||
def authenticate(self) -> Dict[str, str]:
|
||||
"""[AUTH-FLOW] Получение access и CSRF токенов.
|
||||
@pre:
|
||||
- `self.auth` содержит валидные учетные данные.
|
||||
@post:
|
||||
- `self._tokens` обновлен актуальными токенами.
|
||||
- Возвращает обновленные токены.
|
||||
- `self._authenticated` устанавливается в `True`.
|
||||
@raise:
|
||||
- `AuthenticationError`: При ошибках аутентификации (неверные credentials, проблемы с API security).
|
||||
- `NetworkError`: При проблемах с сетью.
|
||||
"""
|
||||
self.logger.info(f"[AUTH] Попытка аутентификации для {self.base_url}")
|
||||
self.logger.info(f"[INFO][APIClient.authenticate][ENTER] Authenticating to {self.base_url}")
|
||||
try:
|
||||
# Шаг 1: Получение access_token
|
||||
login_url = f"{self.base_url}/security/login"
|
||||
response = self.session.post(
|
||||
login_url,
|
||||
json=self.auth, # Используем self.auth, который уже имеет "provider": "db", "refresh": True
|
||||
timeout=self.timeout
|
||||
json=self.auth,
|
||||
timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT)
|
||||
)
|
||||
response.raise_for_status() # Выбросит HTTPError для 4xx/5xx ответов
|
||||
response.raise_for_status()
|
||||
access_token = response.json()["access_token"]
|
||||
self.logger.debug("[AUTH] Access token успешно получен.")
|
||||
|
||||
# Шаг 2: Получение CSRF токена
|
||||
csrf_url = f"{self.base_url}/security/csrf_token/"
|
||||
csrf_response = self.session.get(
|
||||
csrf_url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=self.timeout
|
||||
timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT)
|
||||
)
|
||||
csrf_response.raise_for_status()
|
||||
csrf_token = csrf_response.json()["result"]
|
||||
self.logger.debug("[AUTH] CSRF token успешно получен.")
|
||||
|
||||
# [STATE] Сохранение токенов и обновление флага
|
||||
self._tokens = {
|
||||
"access_token": access_token,
|
||||
"csrf_token": csrf_token
|
||||
}
|
||||
self._authenticated = True
|
||||
self.logger.info("[COHERENCE_CHECK_PASSED] Аутентификация успешно завершена.")
|
||||
self.logger.info("[INFO][APIClient.authenticate][SUCCESS] Authenticated successfully.")
|
||||
return self._tokens
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
error_msg = f"HTTP Error during authentication: {e.response.status_code} - {e.response.text}"
|
||||
self.logger.error(f"[AUTH_FAILED] {error_msg}", exc_info=True)
|
||||
if e.response.status_code == 401: # Unauthorized
|
||||
raise AuthenticationError(
|
||||
f"Неверные учетные данные или истекший токен.",
|
||||
url=login_url, username=self.auth.get("username"),
|
||||
status_code=e.response.status_code, response_text=e.response.text
|
||||
) from e
|
||||
elif e.response.status_code == 403: # Forbidden
|
||||
raise PermissionDeniedError(
|
||||
"Недостаточно прав для аутентификации.",
|
||||
url=login_url, username=self.auth.get("username"),
|
||||
status_code=e.response.status_code, response_text=e.response.text
|
||||
) from e
|
||||
else:
|
||||
raise SupersetAPIError(
|
||||
f"API ошибка при аутентификации: {error_msg}",
|
||||
url=login_url, status_code=e.response.status_code, response_text=e.response.text
|
||||
) from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"[NETWORK_ERROR] Сетевая ошибка при аутентификации: {str(e)}", exc_info=True)
|
||||
raise NetworkError(f"Ошибка сети при аутентификации: {str(e)}", url=login_url) from e
|
||||
except KeyError as e:
|
||||
self.logger.error(f"[AUTH_FAILED] Некорректный формат ответа при аутентификации: {str(e)}", exc_info=True)
|
||||
raise AuthenticationError(f"Некорректный формат ответа API при аутентификации: {str(e)}") from e
|
||||
except Exception as e:
|
||||
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка аутентификации: {str(e)}", exc_info=True)
|
||||
raise AuthenticationError(f"Непредвиденная ошибка аутентификации: {str(e)}") from 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
|
||||
|
||||
@property
|
||||
def headers(self) -> Dict[str, str]:
|
||||
"""[INTERFACE] Возвращает стандартные заголовки с текущими токенами.
|
||||
@semantic: Если токены не получены, пытается выполнить аутентификацию.
|
||||
@post: Всегда возвращает словарь с 'Authorization' и 'X-CSRFToken'.
|
||||
@raise: `AuthenticationError` если аутентификация невозможна.
|
||||
"""
|
||||
if not self._authenticated:
|
||||
self.authenticate() # Попытка аутентификации при первом запросе заголовков
|
||||
|
||||
# [CONTRACT] Проверка наличия токенов
|
||||
if not self._tokens or "access_token" not in self._tokens or "csrf_token" not in self._tokens:
|
||||
self.logger.error("[CONTRACT_VIOLATION] Токены отсутствуют после попытки аутентификации.", extra={"tokens": self._tokens})
|
||||
raise AuthenticationError("Не удалось получить токены для заголовков.")
|
||||
|
||||
self.authenticate()
|
||||
return {
|
||||
"Authorization": f"Bearer {self._tokens['access_token']}",
|
||||
"X-CSRFToken": self._tokens["csrf_token"],
|
||||
"X-CSRFToken": self._tokens.get("csrf_token", ""),
|
||||
"Referer": self.base_url,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
@@ -198,180 +127,95 @@ class APIClient:
|
||||
raw_response: bool = False,
|
||||
**kwargs
|
||||
) -> Union[requests.Response, Dict[str, Any]]:
|
||||
"""[NETWORK-CORE] Обертка для всех HTTP-запросов к Superset API.
|
||||
@semantic:
|
||||
- Выполняет запрос с заданными параметрами.
|
||||
- Автоматически добавляет базовые заголовки (токены, CSRF).
|
||||
- Обрабатывает HTTP-ошибки и преобразует их в типизированные исключения.
|
||||
- В случае 401/403, пытается обновить токен и повторить запрос один раз.
|
||||
@pre:
|
||||
- `method` - валидный HTTP-метод ('GET', 'POST', 'PUT', 'DELETE').
|
||||
- `endpoint` - валидный путь API.
|
||||
@post:
|
||||
- Возвращает объект `requests.Response` (если `raw_response=True`) или `dict` (JSON-ответ).
|
||||
@raise:
|
||||
- `AuthenticationError`, `PermissionDeniedError`, `NetworkError`, `SupersetAPIError`, `DashboardNotFoundError`.
|
||||
"""
|
||||
self.logger.debug(f"[DEBUG][APIClient.request][ENTER] Requesting {method} {endpoint}")
|
||||
full_url = f"{self.base_url}{endpoint}"
|
||||
self.logger.debug(f"[REQUEST] Выполнение запроса: {method} {full_url}", extra={"kwargs_keys": list(kwargs.keys())})
|
||||
|
||||
# [STATE] Заголовки для текущего запроса
|
||||
_headers = self.headers.copy() # Получаем базовые заголовки с актуальными токенами
|
||||
if headers: # Объединяем с переданными кастомными заголовками (переданные имеют приоритет)
|
||||
_headers = self.headers.copy()
|
||||
if headers:
|
||||
_headers.update(headers)
|
||||
|
||||
retries_left = 1 # Одна попытка на обновление токена
|
||||
while retries_left >= 0:
|
||||
try:
|
||||
response = self.session.request(
|
||||
method,
|
||||
full_url,
|
||||
headers=_headers,
|
||||
#timeout=self.timeout,
|
||||
**kwargs
|
||||
)
|
||||
response.raise_for_status() # Проверяем статус сразу
|
||||
self.logger.debug(f"[COHERENCE_CHECK_PASSED] Запрос {method} {endpoint} успешно выполнен.")
|
||||
return response if raw_response else response.json()
|
||||
try:
|
||||
response = self.session.request(
|
||||
method,
|
||||
full_url,
|
||||
headers=_headers,
|
||||
timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT),
|
||||
**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={})
|
||||
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)
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status_code = e.response.status_code
|
||||
error_context = {
|
||||
"method": method,
|
||||
"url": full_url,
|
||||
"status_code": status_code,
|
||||
"response_text": e.response.text
|
||||
}
|
||||
|
||||
if status_code in [401, 403] and retries_left > 0:
|
||||
self.logger.warning(f"[AUTH_REFRESH] Токен истек или недействителен ({status_code}). Попытка обновить и повторить...", extra=error_context)
|
||||
try:
|
||||
self.authenticate() # Попытка обновить токены
|
||||
_headers = self.headers.copy() # Обновляем заголовки с новыми токенами
|
||||
if headers:
|
||||
_headers.update(headers)
|
||||
retries_left -= 1
|
||||
continue # Повторяем цикл
|
||||
except AuthenticationError as auth_err:
|
||||
self.logger.error("[AUTH_FAILED] Не удалось обновить токены.", exc_info=True)
|
||||
raise PermissionDeniedError("Аутентификация не удалась или права отсутствуют после обновления токена.", **error_context) from auth_err
|
||||
|
||||
# [ERROR_MAPPING] Преобразование стандартных HTTP-ошибок в кастомные исключения
|
||||
if status_code == 404:
|
||||
raise DashboardNotFoundError(endpoint, context=error_context) from e
|
||||
elif status_code == 403:
|
||||
raise PermissionDeniedError("Доступ запрещен.", **error_context) from e
|
||||
elif status_code == 401:
|
||||
raise AuthenticationError("Аутентификация не удалась.", **error_context) from e
|
||||
else:
|
||||
raise SupersetAPIError(f"Ошибка API: {status_code} - {e.response.text}", **error_context) from e
|
||||
|
||||
except requests.exceptions.Timeout as e:
|
||||
self.logger.error(f"[NETWORK_ERROR] Таймаут запроса: {str(e)}", exc_info=True, extra={"url": full_url})
|
||||
raise NetworkError("Таймаут запроса", url=full_url) from e
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
self.logger.error(f"[NETWORK_ERROR] Ошибка соединения: {str(e)}", exc_info=True, extra={"url": full_url})
|
||||
raise NetworkError("Ошибка соединения", url=full_url) from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.critical(f"[CRITICAL] Неизвестная ошибка запроса: {str(e)}", exc_info=True, extra={"url": full_url})
|
||||
raise NetworkError(f"Неизвестная сетевая ошибка: {str(e)}", url=full_url) from e
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"[API_FAILED] Ошибка парсинга JSON ответа: {str(e)}", exc_info=True, extra={"url": full_url, "response_text_sample": response.text[:200]})
|
||||
raise SupersetAPIError(f"Некорректный JSON ответ: {str(e)}", url=full_url) from e
|
||||
except Exception as e:
|
||||
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка в APIClient.request: {str(e)}", exc_info=True, extra={"url": full_url})
|
||||
raise SupersetAPIError(f"Непредвиденная ошибка: {str(e)}", url=full_url) from e
|
||||
|
||||
# [COHERENCE_CHECK_FAILED] Если дошли сюда, значит, все повторные попытки провалились
|
||||
self.logger.error(f"[CONTRACT_VIOLATION] Все повторные попытки для запроса {method} {endpoint} исчерпаны.")
|
||||
raise SupersetAPIError(f"Все повторные попытки запроса {method} {endpoint} исчерпаны.")
|
||||
def _handle_http_error(self, e, endpoint, context):
|
||||
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
|
||||
|
||||
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}"
|
||||
raise NetworkError(msg, url=url) from e
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
endpoint: str,
|
||||
file_obj: Union[str, Path, BinaryIO], # Может быть Path, str или байтовый поток
|
||||
file_name: str,
|
||||
form_field: str = "file",
|
||||
file_info: Dict[str, Any],
|
||||
extra_data: Optional[Dict] = None,
|
||||
timeout: Optional[int] = None
|
||||
) -> Dict:
|
||||
"""[CONTRACT] Отправка файла на сервер через POST-запрос.
|
||||
@pre:
|
||||
- `endpoint` - валидный API endpoint для загрузки.
|
||||
- `file_obj` - путь к файлу или открытый бинарный файловый объект.
|
||||
- `file_name` - имя файла для отправки в форме.
|
||||
@post:
|
||||
- Возвращает JSON-ответ от сервера в виде словаря.
|
||||
@raise:
|
||||
- `FileNotFoundError`: Если `file_obj` является путем и файл не найден.
|
||||
- `PermissionDeniedError`: Если недостаточно прав.
|
||||
- `SupersetAPIError`, `NetworkError`.
|
||||
"""
|
||||
self.logger.info(f"[INFO][APIClient.upload_file][ENTER] Uploading file to {endpoint}")
|
||||
full_url = f"{self.base_url}{endpoint}"
|
||||
_headers = self.headers.copy()
|
||||
# [IMPORTANT] Content-Type для files формируется requests, поэтому удаляем его из общих заголовков
|
||||
_headers.pop('Content-Type', None)
|
||||
|
||||
files_payload = None
|
||||
should_close_file = False
|
||||
|
||||
_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")
|
||||
if isinstance(file_obj, (str, Path)):
|
||||
file_path = Path(file_obj)
|
||||
if not file_path.exists():
|
||||
self.logger.error(f"[CONTRACT_VIOLATION] Файл для загрузки не найден: {file_path}", extra={"file_path": str(file_path)})
|
||||
raise FileNotFoundError(f"Файл {file_path} не найден для загрузки.")
|
||||
files_payload = {form_field: (file_name, open(file_path, 'rb'), 'application/x-zip-compressed')}
|
||||
should_close_file = True
|
||||
self.logger.debug(f"[UPLOAD] Загрузка файла из пути: {file_path}")
|
||||
elif isinstance(file_obj, io.BytesIO): # In-memory binary file
|
||||
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)
|
||||
elif isinstance(file_obj, io.BytesIO):
|
||||
files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')}
|
||||
self.logger.debug(f"[UPLOAD] Загрузка файла из байтового потока (in-memory).")
|
||||
elif hasattr(file_obj, 'read') and hasattr(file_obj, 'seek'): # Generic binary file-like object
|
||||
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')}
|
||||
self.logger.debug(f"[UPLOAD] Загрузка файла из файлового объекта.")
|
||||
return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
|
||||
else:
|
||||
self.logger.error(f"[CONTRACT_VIOLATION] Неподдерживаемый тип файла для загрузки: {type(file_obj).__name__}")
|
||||
raise TypeError("Неподдерживаемый тип 'file_obj'. Ожидается Path, str, io.BytesIO или другой файлоподобный объект.")
|
||||
self.logger.error(f"[ERROR][APIClient.upload_file][FAILURE] Unsupported file_obj type: {type(file_obj)}")
|
||||
raise TypeError(f"Неподдерживаемый тип 'file_obj': {type(file_obj)}")
|
||||
|
||||
def _perform_upload(self, url, files, data, headers, timeout):
|
||||
self.logger.debug(f"[DEBUG][APIClient._perform_upload][ENTER] Performing upload to {url}")
|
||||
try:
|
||||
response = self.session.post(
|
||||
url=full_url,
|
||||
files=files_payload,
|
||||
data=extra_data or {},
|
||||
headers=_headers,
|
||||
timeout=timeout or self.timeout
|
||||
url=url,
|
||||
files=files,
|
||||
data=data or {},
|
||||
headers=headers,
|
||||
timeout=timeout or self.request_settings.get("timeout")
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# [COHERENCE_CHECK_PASSED] Файл успешно загружен.
|
||||
self.logger.info(f"[UPLOAD_SUCCESS] Файл '{file_name}' успешно загружен на {endpoint}.")
|
||||
self.logger.info(f"[INFO][APIClient._perform_upload][SUCCESS] Upload successful to {url}")
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
error_context = {
|
||||
"endpoint": endpoint,
|
||||
"file": file_name,
|
||||
"status_code": e.response.status_code,
|
||||
"response_text": e.response.text
|
||||
}
|
||||
if e.response.status_code == 403:
|
||||
raise PermissionDeniedError("Доступ запрещен для загрузки файла.", **error_context) from e
|
||||
else:
|
||||
raise SupersetAPIError(f"Ошибка API при загрузке файла: {e.response.status_code} - {e.response.text}", **error_context) from e
|
||||
self.logger.error(f"[ERROR][APIClient._perform_upload][FAILURE] HTTP error during upload: {e}")
|
||||
raise SupersetAPIError(f"Ошибка API при загрузке: {e.response.text}") from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_context = {"endpoint": endpoint, "file": file_name, "error_type": type(e).__name__}
|
||||
self.logger.error(f"[NETWORK_ERROR] Ошибка запроса при загрузке файла: {str(e)}", exc_info=True, extra=error_context)
|
||||
raise NetworkError(f"Ошибка сети при загрузке файла: {str(e)}", url=full_url) from e
|
||||
except Exception as e:
|
||||
error_context = {"endpoint": endpoint, "file": file_name, "error_type": type(e).__name__}
|
||||
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при загрузке файла: {str(e)}", exc_info=True, extra=error_context)
|
||||
raise SupersetAPIError(f"Непредвиденная ошибка загрузки файла: {str(e)}", context=error_context) from e
|
||||
finally:
|
||||
# Закрываем файл, если он был открыт в этом методе
|
||||
if should_close_file and files_payload and files_payload[form_field] and hasattr(files_payload[form_field][1], 'close'):
|
||||
files_payload[form_field][1].close()
|
||||
self.logger.debug(f"[UPLOAD] Закрыт файл '{file_name}'.")
|
||||
self.logger.error(f"[ERROR][APIClient._perform_upload][FAILURE] Network error during upload: {e}")
|
||||
raise NetworkError(f"Ошибка сети при загрузке: {e}", url=url) from e
|
||||
|
||||
def fetch_paginated_count(
|
||||
self,
|
||||
@@ -380,100 +224,41 @@ class APIClient:
|
||||
count_field: str = "count",
|
||||
timeout: Optional[int] = None
|
||||
) -> int:
|
||||
"""[CONTRACT] Получение общего количества элементов в пагинированном API.
|
||||
@delegates:
|
||||
- Использует `self.request` для выполнения HTTP-запроса.
|
||||
@pre:
|
||||
- `endpoint` должен указывать на пагинированный ресурс.
|
||||
- `query_params` должны быть валидны для запроса количества.
|
||||
@post:
|
||||
- Возвращает целочисленное количество элементов.
|
||||
@raise:
|
||||
- `NetworkError`, `SupersetAPIError`, `KeyError` (если `count_field` не найден).
|
||||
"""
|
||||
self.logger.debug(f"[PAGINATION] Запрос количества элементов для {endpoint} с параметрами: {query_params}")
|
||||
try:
|
||||
response_json = self.request(
|
||||
method="GET",
|
||||
endpoint=endpoint,
|
||||
params={"q": json.dumps(query_params)},
|
||||
timeout=timeout or self.timeout
|
||||
)
|
||||
|
||||
if count_field not in response_json:
|
||||
self.logger.error(
|
||||
f"[CONTRACT_VIOLATION] Ответ API для {endpoint} не содержит поле '{count_field}'",
|
||||
extra={"response_keys": list(response_json.keys())}
|
||||
)
|
||||
raise KeyError(f"Ответ API для {endpoint} не содержит поле '{count_field}'")
|
||||
|
||||
count = response_json[count_field]
|
||||
self.logger.debug(f"[COHERENCE_CHECK_PASSED] Получено количество: {count} для {endpoint}.")
|
||||
return count
|
||||
|
||||
except (KeyError, SupersetAPIError, NetworkError, PermissionDeniedError, DashboardNotFoundError) as e:
|
||||
self.logger.error(f"[ERROR] Ошибка получения количества элементов для {endpoint}: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
|
||||
raise
|
||||
except Exception as e:
|
||||
error_ctx = {"endpoint": endpoint, "params": query_params, "error_type": type(e).__name__}
|
||||
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении количества: {str(e)}", exc_info=True, extra=error_ctx)
|
||||
raise SupersetAPIError(f"Непредвиденная ошибка при получении count для {endpoint}: {str(e)}", context=error_ctx) from e
|
||||
|
||||
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_data(
|
||||
self,
|
||||
endpoint: str,
|
||||
base_query: Dict,
|
||||
total_count: int,
|
||||
results_field: str = "result",
|
||||
pagination_options: Dict[str, Any],
|
||||
timeout: Optional[int] = None
|
||||
) -> List[Any]:
|
||||
"""[CONTRACT] Получение всех данных с пагинированного API.
|
||||
@delegates:
|
||||
- Использует `self.request` для выполнения запросов по страницам.
|
||||
@pre:
|
||||
- `base_query` должен содержать 'page_size'.
|
||||
- `total_count` должен быть корректным общим количеством элементов.
|
||||
@post:
|
||||
- Возвращает список всех собранных данных со всех страниц.
|
||||
@raise:
|
||||
- `NetworkError`, `SupersetAPIError`, `ValueError` (если `page_size` невалиден), `KeyError`.
|
||||
"""
|
||||
self.logger.debug(f"[PAGINATION] Запуск получения всех данных для {endpoint}. Total: {total_count}, Base Query: {base_query}")
|
||||
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:
|
||||
self.logger.error("[CONTRACT_VIOLATION] 'page_size' в базовом запросе невалиден.", extra={"page_size": page_size})
|
||||
raise ValueError("Параметр 'page_size' должен быть положительным числом.")
|
||||
|
||||
raise ValueError("'page_size' должен быть положительным числом.")
|
||||
total_pages = (total_count + page_size - 1) // page_size
|
||||
results = []
|
||||
|
||||
for page in range(total_pages):
|
||||
query = {**base_query, 'page': page}
|
||||
self.logger.debug(f"[PAGINATION] Запрос страницы {page+1}/{total_pages} для {endpoint}.")
|
||||
try:
|
||||
response_json = self.request(
|
||||
method="GET",
|
||||
endpoint=endpoint,
|
||||
params={"q": json.dumps(query)},
|
||||
timeout=timeout or self.timeout
|
||||
)
|
||||
|
||||
if results_field not in response_json:
|
||||
self.logger.warning(
|
||||
f"[CONTRACT_VIOLATION] Ответ API для {endpoint} на странице {page} не содержит поле '{results_field}'",
|
||||
extra={"response_keys": list(response_json.keys())}
|
||||
)
|
||||
# Если поле результатов отсутствует на одной странице, это может быть не фатально, но надо залогировать.
|
||||
continue
|
||||
|
||||
results.extend(response_json[results_field])
|
||||
except (SupersetAPIError, NetworkError, PermissionDeniedError, DashboardNotFoundError) as e:
|
||||
self.logger.error(f"[ERROR] Ошибка получения страницы {page+1} для {endpoint}: {str(e)}", exc_info=True, extra=getattr(e, 'context', {}))
|
||||
raise # Пробрасываем ошибку выше, так как не можем продолжить пагинацию
|
||||
except Exception as e:
|
||||
error_ctx = {"endpoint": endpoint, "page": page, "error_type": type(e).__name__}
|
||||
self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении страницы {page+1} для {endpoint}: {str(e)}", exc_info=True, extra=error_ctx)
|
||||
raise SupersetAPIError(f"Непредвиденная ошибка пагинации для {endpoint}: {str(e)}", context=error_ctx) from e
|
||||
|
||||
self.logger.debug(f"[COHERENCE_CHECK_PASSED] Все данные с пагинацией для {endpoint} успешно собраны. Всего элементов: {len(results)}")
|
||||
return results
|
||||
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
|
||||
Reference in New Issue
Block a user