mirror of
https://github.com/deesiigneer/pyspapi.git
synced 2026-04-20 12:35:26 +00:00
refactor: enhance error handling and logging in API interactions, improve exception classes
This commit is contained in:
@@ -1,13 +1,53 @@
|
|||||||
"""pyspapi - API wrapper for SP servers written in Python
|
"""
|
||||||
TODO: заполнить описание"""
|
SPWorlds API Wrapper
|
||||||
|
~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
High-level client for interacting with the SPWorlds API.
|
||||||
|
|
||||||
|
:copyright: (c) 2022-present deesiigneer
|
||||||
|
:license: MIT, see LICENSE for more details.
|
||||||
|
"""
|
||||||
|
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
from .spworlds import SPAPI
|
|
||||||
|
|
||||||
__all__ = [SPAPI]
|
from pyspapi.exceptions import (
|
||||||
|
BadRequestError,
|
||||||
|
ClientError,
|
||||||
|
ForbiddenError,
|
||||||
|
HTTPError,
|
||||||
|
InsufficientBalanceError,
|
||||||
|
NetworkError,
|
||||||
|
NotFoundError,
|
||||||
|
RateLimitError,
|
||||||
|
ServerError,
|
||||||
|
SPAPIError,
|
||||||
|
TimeoutError,
|
||||||
|
UnauthorizedError,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
from pyspapi.spworlds import SPAPI
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SPAPI",
|
||||||
|
"BadRequestError",
|
||||||
|
"ClientError",
|
||||||
|
"ForbiddenError",
|
||||||
|
"HTTPError",
|
||||||
|
"InsufficientBalanceError",
|
||||||
|
"NetworkError",
|
||||||
|
"NotFoundError",
|
||||||
|
"RateLimitError",
|
||||||
|
"ServerError",
|
||||||
|
"SPAPIError",
|
||||||
|
"TimeoutError",
|
||||||
|
"UnauthorizedError",
|
||||||
|
"ValidationError",
|
||||||
|
]
|
||||||
|
|
||||||
|
__title__: str = "pyspapi"
|
||||||
__author__: str = "deesiigneer"
|
__author__: str = "deesiigneer"
|
||||||
__url__: str = "https://github.com/deesiigneer/pyspapi"
|
|
||||||
__description__: str = "API wrapper for SP servers written in Python."
|
__description__: str = "API wrapper for SP servers written in Python."
|
||||||
__license__: str = "MIT"
|
__license__: str = "MIT"
|
||||||
|
__url__: str = "https://github.com/deesiigneer/pyspapi"
|
||||||
|
__copyright__: str = "2022-present deesiigneer"
|
||||||
__version__: str = importlib.metadata.version("pyspapi")
|
__version__: str = importlib.metadata.version("pyspapi")
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from .api import APISession
|
from .api import APISession
|
||||||
|
|
||||||
__all__ = [APISession]
|
__all__ = ["APISession"]
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from logging import getLogger
|
from logging import NullHandler, getLogger
|
||||||
from typing import Optional, Any, Dict
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from ..exceptions import ValidationError, SPAPIError
|
from pyspapi.exceptions import (
|
||||||
|
BadRequestError,
|
||||||
|
ClientError,
|
||||||
|
ForbiddenError,
|
||||||
|
HTTPError,
|
||||||
|
InsufficientBalanceError,
|
||||||
|
NetworkError,
|
||||||
|
NotFoundError,
|
||||||
|
RateLimitError,
|
||||||
|
ServerError,
|
||||||
|
SPAPIError,
|
||||||
|
UnauthorizedError,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
from pyspapi.exceptions import (
|
||||||
|
TimeoutError as APITimeoutError,
|
||||||
|
)
|
||||||
|
|
||||||
log = getLogger("pyspapi")
|
log = getLogger("pyspapi")
|
||||||
|
log.addHandler(NullHandler())
|
||||||
|
|
||||||
|
|
||||||
class APISession(object):
|
class APISession(object):
|
||||||
@@ -22,6 +39,8 @@ class APISession(object):
|
|||||||
raise_exception: bool = False,
|
raise_exception: bool = False,
|
||||||
proxy: str = None,
|
proxy: str = None,
|
||||||
):
|
):
|
||||||
|
self._validate_credentials(card_id, token)
|
||||||
|
|
||||||
self.__url = "https://spworlds.ru/api/public/"
|
self.__url = "https://spworlds.ru/api/public/"
|
||||||
self.__id = card_id
|
self.__id = card_id
|
||||||
self.__token = token
|
self.__token = token
|
||||||
@@ -31,61 +50,294 @@ class APISession(object):
|
|||||||
self.__raise_exception = raise_exception
|
self.__raise_exception = raise_exception
|
||||||
self.__proxy = proxy
|
self.__proxy = proxy
|
||||||
self.session: Optional[aiohttp.ClientSession] = None
|
self.session: Optional[aiohttp.ClientSession] = None
|
||||||
|
self._session_owner = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_credentials(card_id: str, token: str) -> None:
|
||||||
|
if not card_id or not isinstance(card_id, str):
|
||||||
|
raise ValueError("card_id must be a non-empty string")
|
||||||
|
if not token or not isinstance(token, str):
|
||||||
|
raise ValueError("token must be a non-empty string")
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
print("proxy=", self.__proxy)
|
try:
|
||||||
self.session = aiohttp.ClientSession(
|
if not self.session:
|
||||||
json_serialize=json.dumps,
|
self.session = aiohttp.ClientSession(
|
||||||
timeout=aiohttp.ClientTimeout(total=self.__timeout),
|
json_serialize=json.dumps,
|
||||||
proxy=self.__proxy,
|
timeout=aiohttp.ClientTimeout(total=self.__timeout),
|
||||||
)
|
proxy=self.__proxy,
|
||||||
|
)
|
||||||
|
self._session_owner = True
|
||||||
|
log.debug(f"[pyspapi] Session created with timeout={self.__timeout}s")
|
||||||
|
else:
|
||||||
|
self._session_owner = False
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"[pyspapi] Failed to create session: {e}")
|
||||||
|
raise
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, *err):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
await self.session.close()
|
if self._session_owner and self.session:
|
||||||
self.session = None
|
try:
|
||||||
|
await self.session.close()
|
||||||
|
log.debug("[pyspapi] Session closed")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"[pyspapi] Error closing session: {e}")
|
||||||
|
self.session = None
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_auth_header(self) -> str:
|
||||||
|
credentials = f"{self.__id}:{self.__token}"
|
||||||
|
encoded = b64encode(credentials.encode("utf-8")).decode("utf-8")
|
||||||
|
return f"Bearer {encoded}"
|
||||||
|
|
||||||
|
def _get_headers(self) -> Dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": self._get_auth_header(),
|
||||||
|
"User-Agent": "https://github.com/deesiigneer/pyspapi",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _parse_error_response(self, content: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return json.loads(content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"raw_response": content}
|
||||||
|
|
||||||
|
def _format_error_message(
|
||||||
|
self, error_data: Dict[str, Any], status_code: int
|
||||||
|
) -> str:
|
||||||
|
message = (
|
||||||
|
error_data.get("message")
|
||||||
|
or error_data.get("detail")
|
||||||
|
or error_data.get("msg")
|
||||||
|
or f"HTTP {status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in error_data:
|
||||||
|
message = f"{message} (error: {error_data['error']})"
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
def _log_error_with_details(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
status_code: int,
|
||||||
|
error_data: Dict[str, Any],
|
||||||
|
content: str,
|
||||||
|
) -> None:
|
||||||
|
message = self._format_error_message(error_data, status_code)
|
||||||
|
|
||||||
|
log.error(
|
||||||
|
f"[pyspapi] HTTP {status_code}: {method.upper()} {endpoint} | {message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _should_retry(self, status_code: int, attempt: int) -> bool:
|
||||||
|
if attempt > self.__retries:
|
||||||
|
return False
|
||||||
|
return status_code in {408, 429, 500, 502, 503, 504}
|
||||||
|
|
||||||
|
async def _handle_http_error(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
status_code: int,
|
||||||
|
content: str,
|
||||||
|
) -> None:
|
||||||
|
error_data = self._parse_error_response(content)
|
||||||
|
|
||||||
|
self._log_error_with_details(method, endpoint, status_code, error_data, content)
|
||||||
|
|
||||||
|
if not self.__raise_exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
error_message = self._format_error_message(error_data, status_code)
|
||||||
|
|
||||||
|
if status_code == 400:
|
||||||
|
error_code = error_data.get("error", "")
|
||||||
|
if "notEnoughBalance" in error_code:
|
||||||
|
raise InsufficientBalanceError(details=error_data)
|
||||||
|
raise BadRequestError(details=error_data)
|
||||||
|
elif status_code == 401:
|
||||||
|
raise UnauthorizedError(details=error_data)
|
||||||
|
elif status_code == 403:
|
||||||
|
raise ForbiddenError(details=error_data)
|
||||||
|
elif status_code == 404:
|
||||||
|
raise NotFoundError(resource=endpoint, details=error_data)
|
||||||
|
elif status_code == 422:
|
||||||
|
raise ValidationError(error_data)
|
||||||
|
elif status_code == 429:
|
||||||
|
retry_after = error_data.get("retry_after")
|
||||||
|
raise RateLimitError(retry_after=retry_after, details=error_data)
|
||||||
|
elif 400 <= status_code < 500:
|
||||||
|
raise ClientError(
|
||||||
|
status_code=status_code,
|
||||||
|
message=error_message,
|
||||||
|
response_body=content,
|
||||||
|
details=error_data,
|
||||||
|
)
|
||||||
|
elif 500 <= status_code < 600:
|
||||||
|
raise ServerError(
|
||||||
|
status_code=status_code,
|
||||||
|
message=error_message,
|
||||||
|
response_body=content,
|
||||||
|
details=error_data,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPError(
|
||||||
|
status_code=status_code,
|
||||||
|
message=error_message,
|
||||||
|
response_body=content,
|
||||||
|
details=error_data,
|
||||||
|
)
|
||||||
|
|
||||||
async def request(
|
async def request(
|
||||||
self, method: str, endpoint: str, data: Optional[Dict] = None
|
self, method: str, endpoint: str, data: Optional[Dict] = None
|
||||||
) -> Any:
|
) -> Any:
|
||||||
url = self.__url + endpoint
|
url = self.__url + endpoint
|
||||||
headers = {
|
headers = self._get_headers()
|
||||||
"Authorization": f"Bearer {str(b64encode(str(f'{self.__id}:{self.__token}').encode('utf-8')), 'utf-8')}",
|
|
||||||
"User-Agent": "https://github.com/deesiigneer/pyspapi",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
attempt = 0
|
attempt = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
attempt += 1
|
attempt += 1
|
||||||
if attempt > 1:
|
if attempt > 1:
|
||||||
log.warning(
|
log.warning(
|
||||||
f"[pyspapi] Repeat attempt {attempt}: {method.upper()} {url}"
|
f"[pyspapi] Retry attempt {attempt}/{self.__retries + 1}: {method.upper()} {endpoint}"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with self.session.request(
|
async with self.session.request(
|
||||||
method, url, json=data, headers=headers
|
method, url, json=data, headers=headers
|
||||||
) as resp:
|
) as resp:
|
||||||
|
response_text = await resp.text()
|
||||||
|
|
||||||
if resp.status == 422:
|
if resp.status == 422:
|
||||||
errors = await resp.json()
|
try:
|
||||||
log.error(f"[pyspapi] Validation error: {errors}")
|
errors = json.loads(response_text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
errors = {"raw_response": response_text}
|
||||||
|
|
||||||
|
error_msg = self._format_error_message(errors, 422)
|
||||||
|
log.error(
|
||||||
|
f"[pyspapi] Validation error (422): {method.upper()} {endpoint} | {error_msg}"
|
||||||
|
)
|
||||||
if self.__raise_exception:
|
if self.__raise_exception:
|
||||||
raise ValidationError(errors)
|
raise ValidationError(errors)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if resp.status >= 400:
|
if resp.status >= 400:
|
||||||
content = await resp.text()
|
await self._handle_http_error(
|
||||||
log.error(f"[pyspapi] API error {resp.status}: {content}")
|
method, endpoint, resp.status, response_text
|
||||||
if self.__raise_exception:
|
)
|
||||||
raise SPAPIError(resp.status, content)
|
|
||||||
|
if self._should_retry(resp.status, attempt):
|
||||||
|
await asyncio.sleep(self.__sleep_timeout * attempt)
|
||||||
|
continue
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return await resp.json()
|
try:
|
||||||
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
return await resp.json()
|
||||||
log.exception(f"[pyspapi] Connection error: {e} \n attempt {attempt}")
|
except json.JSONDecodeError as e:
|
||||||
if attempt > self.__retries:
|
log.error(
|
||||||
return None
|
f"[pyspapi] Failed to parse JSON response: {e} | Status: {resp.status}"
|
||||||
await asyncio.sleep(self.__sleep_timeout)
|
)
|
||||||
|
if self.__raise_exception:
|
||||||
|
raise SPAPIError(
|
||||||
|
status_code=resp.status,
|
||||||
|
message="Invalid JSON in response",
|
||||||
|
details={
|
||||||
|
"error": str(e),
|
||||||
|
"response": response_text[:500],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.warning(
|
||||||
|
f"[pyspapi] Request timeout ({self.__timeout}s): {method.upper()} {endpoint} | Attempt {attempt}/{self.__retries + 1}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._should_retry(408, attempt):
|
||||||
|
await asyncio.sleep(self.__sleep_timeout * attempt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.error("[pyspapi] Max retries reached for timeout")
|
||||||
|
if self.__raise_exception:
|
||||||
|
raise APITimeoutError(
|
||||||
|
timeout=self.__timeout,
|
||||||
|
endpoint=endpoint,
|
||||||
|
details={"method": method, "attempt": attempt},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except aiohttp.ClientSSLError as e:
|
||||||
|
log.error(f"[pyspapi] SSL error: {e} | {method.upper()} {endpoint}")
|
||||||
|
if self.__raise_exception:
|
||||||
|
raise NetworkError(
|
||||||
|
message=f"SSL error: {str(e)}",
|
||||||
|
details={
|
||||||
|
"method": method,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"error": str(e),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except (aiohttp.ClientConnectorError, aiohttp.ClientOSError) as e:
|
||||||
|
log.warning(
|
||||||
|
f"[pyspapi] Connection error: {e} | {method.upper()} {endpoint} | Attempt {attempt}/{self.__retries + 1}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._should_retry(0, attempt):
|
||||||
|
await asyncio.sleep(self.__sleep_timeout * attempt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.error("[pyspapi] Max retries reached for connection error")
|
||||||
|
if self.__raise_exception:
|
||||||
|
raise NetworkError(
|
||||||
|
message=f"Connection error: {str(e)}",
|
||||||
|
details={
|
||||||
|
"method": method,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"error": str(e),
|
||||||
|
"attempt": attempt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
log.error(f"[pyspapi] Client error: {e} | {method.upper()} {endpoint}")
|
||||||
|
if self.__raise_exception:
|
||||||
|
raise NetworkError(
|
||||||
|
message=f"HTTP client error: {str(e)}",
|
||||||
|
details={
|
||||||
|
"method": method,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"error": str(e),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except SPAPIError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(
|
||||||
|
f"[pyspapi] Unexpected error: {e} | {method.upper()} {endpoint}"
|
||||||
|
)
|
||||||
|
if self.__raise_exception:
|
||||||
|
raise SPAPIError(
|
||||||
|
message=f"Unexpected error: {str(e)}",
|
||||||
|
details={
|
||||||
|
"error": str(e),
|
||||||
|
"method": method,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
async def get(self, endpoint: str) -> Any:
|
async def get(self, endpoint: str) -> Any:
|
||||||
async with self:
|
async with self:
|
||||||
|
|||||||
@@ -1,25 +1,197 @@
|
|||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
class SPAPIError(Exception):
|
class SPAPIError(Exception):
|
||||||
"""
|
"""
|
||||||
Базовая ошибка для всех исключений, связанных с API SPWorlds.
|
Базовая ошибка для всех исключений, связанных с API SPWorlds.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, status_code: int, message: str):
|
def __init__(
|
||||||
|
self,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
message: str = "",
|
||||||
|
details: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
self.message = message
|
self.message = message
|
||||||
super().__init__(f"[{status_code}] {message}")
|
self.details = details or {}
|
||||||
|
error_msg = f"[{status_code}] {message}" if status_code else message
|
||||||
|
super().__init__(error_msg)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"SPAPIError: [{self.status_code}] {self.message}"
|
if self.status_code:
|
||||||
|
return f"{self.__class__.__name__}: [{self.status_code}] {self.message}"
|
||||||
|
return f"{self.__class__.__name__}: {self.message}"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"{self.__class__.__name__}(status_code={self.status_code}, message={self.message!r}, details={self.details!r})"
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(SPAPIError):
|
class ValidationError(SPAPIError):
|
||||||
"""
|
"""
|
||||||
Ошибка валидации.
|
Ошибка валидации (HTTP 422).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, errors):
|
def __init__(self, errors: Dict[str, Any]):
|
||||||
self.errors = errors
|
self.errors = errors
|
||||||
super().__init__(422, f"Validation failed: {errors}")
|
super().__init__(
|
||||||
|
status_code=422,
|
||||||
|
message="Validation failed",
|
||||||
|
details={"validation_errors": errors},
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"ValidationError: {self.errors}"
|
return f"{self.__class__.__name__}: {self.errors}"
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkError(SPAPIError):
|
||||||
|
"""
|
||||||
|
Ошибка сетевого соединения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
||||||
|
super().__init__(message=message, details=details)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeoutError(SPAPIError):
|
||||||
|
"""
|
||||||
|
Ошибка истечения времени ожидания.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
timeout: float,
|
||||||
|
endpoint: str = "",
|
||||||
|
details: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
|
msg = f"Request timeout after {timeout}s"
|
||||||
|
if endpoint:
|
||||||
|
msg += f" for endpoint: {endpoint}"
|
||||||
|
super().__init__(
|
||||||
|
message=msg, details=details or {"timeout": timeout, "endpoint": endpoint}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPError(SPAPIError):
|
||||||
|
"""
|
||||||
|
Ошибка HTTP (4xx, 5xx).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
status_code: int,
|
||||||
|
message: str = "",
|
||||||
|
response_body: str = "",
|
||||||
|
details: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
|
self.response_body = response_body
|
||||||
|
super().__init__(
|
||||||
|
status_code=status_code,
|
||||||
|
message=message or f"HTTP {status_code}",
|
||||||
|
details=details or {"response_body": response_body},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientError(HTTPError):
|
||||||
|
"""
|
||||||
|
Ошибка клиента (4xx).
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ServerError(HTTPError):
|
||||||
|
"""
|
||||||
|
Ошибка сервера (5xx).
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(ClientError):
|
||||||
|
"""
|
||||||
|
Превышен лимит запросов (HTTP 429).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
retry_after: Optional[int] = None,
|
||||||
|
details: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
|
self.retry_after = retry_after
|
||||||
|
msg = "Rate limit exceeded"
|
||||||
|
if retry_after:
|
||||||
|
msg += f". Retry after {retry_after}s"
|
||||||
|
super().__init__(
|
||||||
|
status_code=429,
|
||||||
|
message=msg,
|
||||||
|
details=details or {"retry_after": retry_after},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UnauthorizedError(ClientError):
|
||||||
|
"""
|
||||||
|
Ошибка аутентификации (HTTP 401).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, details: Optional[Dict[str, Any]] = None):
|
||||||
|
super().__init__(
|
||||||
|
status_code=401,
|
||||||
|
message="Unauthorized. Invalid or missing credentials.",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ForbiddenError(ClientError):
|
||||||
|
"""
|
||||||
|
Ошибка доступа (HTTP 403).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, details: Optional[Dict[str, Any]] = None):
|
||||||
|
super().__init__(
|
||||||
|
status_code=403,
|
||||||
|
message="Forbidden. Access denied.",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(ClientError):
|
||||||
|
"""
|
||||||
|
Ресурс не найден (HTTP 404).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, resource: str = "", details: Optional[Dict[str, Any]] = None):
|
||||||
|
msg = "Resource not found"
|
||||||
|
if resource:
|
||||||
|
msg += f": {resource}"
|
||||||
|
super().__init__(
|
||||||
|
status_code=404,
|
||||||
|
message=msg,
|
||||||
|
details=details or {"resource": resource},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BadRequestError(ClientError):
|
||||||
|
"""
|
||||||
|
Некорректный запрос (HTTP 400).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, details: Optional[Dict[str, Any]] = None):
|
||||||
|
super().__init__(
|
||||||
|
status_code=400,
|
||||||
|
message="Bad request. Invalid request parameters.",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientBalanceError(ClientError):
|
||||||
|
"""
|
||||||
|
Недостаточно средств на счете.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, details: Optional[Dict[str, Any]] = None):
|
||||||
|
super().__init__(
|
||||||
|
status_code=400,
|
||||||
|
message="Insufficient balance. Not enough funds to complete the transaction.",
|
||||||
|
details=details or {"error": "error.public.transactions.notEnoughBalance"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from hmac import new, compare_digest
|
from hmac import compare_digest, new
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .api import APISession
|
from pyspapi.api import APISession
|
||||||
|
from pyspapi.exceptions import InsufficientBalanceError
|
||||||
from pyspapi.types import User
|
from pyspapi.types import User
|
||||||
from pyspapi.types.me import Account
|
from pyspapi.types.me import Account
|
||||||
from pyspapi.types.payment import Item
|
from pyspapi.types.payment import Item
|
||||||
@@ -44,7 +45,7 @@ class SPAPI(APISession):
|
|||||||
:type retries: int
|
:type retries: int
|
||||||
:param raise_exception: Поднимать исключения при ошибке, если True.
|
:param raise_exception: Поднимать исключения при ошибке, если True.
|
||||||
:type raise_exception: bool
|
:type raise_exception: bool
|
||||||
:param proxy: Прокся!
|
:param proxy: Прокси для подключения к API. По умолчанию None.
|
||||||
:type proxy: str
|
:type proxy: str
|
||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -67,7 +68,15 @@ class SPAPI(APISession):
|
|||||||
:return: Текущий баланс карты.
|
:return: Текущий баланс карты.
|
||||||
:rtype: int
|
:rtype: int
|
||||||
"""
|
"""
|
||||||
return int((await super().get("card"))["balance"])
|
try:
|
||||||
|
response = await super().get("card")
|
||||||
|
if response is None:
|
||||||
|
return None
|
||||||
|
return int(response.get("balance", 0))
|
||||||
|
except (KeyError, ValueError, TypeError) as e:
|
||||||
|
log = __import__("logging").getLogger("pyspapi")
|
||||||
|
log.error(f"Failed to parse balance response: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
async def webhook(self) -> Optional[str]:
|
async def webhook(self) -> Optional[str]:
|
||||||
@@ -77,7 +86,15 @@ class SPAPI(APISession):
|
|||||||
:return: URL вебхука.
|
:return: URL вебхука.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
return str((await super().get("card"))["webhook"])
|
try:
|
||||||
|
response = await super().get("card")
|
||||||
|
if response is None:
|
||||||
|
return None
|
||||||
|
return str(response.get("webhook", ""))
|
||||||
|
except (KeyError, ValueError, TypeError) as e:
|
||||||
|
log = __import__("logging").getLogger("pyspapi")
|
||||||
|
log.error(f"Failed to parse webhook response: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
async def me(self) -> Optional[Account]:
|
async def me(self) -> Optional[Account]:
|
||||||
@@ -87,17 +104,25 @@ class SPAPI(APISession):
|
|||||||
:return: Объект Account, представляющий аккаунт текущего пользователя.
|
:return: Объект Account, представляющий аккаунт текущего пользователя.
|
||||||
:rtype: :class:`Account`
|
:rtype: :class:`Account`
|
||||||
"""
|
"""
|
||||||
me = await super().get("accounts/me")
|
try:
|
||||||
return Account(
|
me = await super().get("accounts/me")
|
||||||
account_id=me["id"],
|
if me is None:
|
||||||
username=me["username"],
|
return None
|
||||||
minecraftuuid=me["minecraftUUID"],
|
|
||||||
status=me["status"],
|
return Account(
|
||||||
roles=me["roles"],
|
account_id=me.get("id"),
|
||||||
cities=me["cities"],
|
username=me.get("username"),
|
||||||
cards=me["cards"],
|
minecraftuuid=me.get("minecraftUUID"),
|
||||||
created_at=me["createdAt"],
|
status=me.get("status"),
|
||||||
)
|
roles=me.get("roles", []),
|
||||||
|
cities=me.get("cities", []),
|
||||||
|
cards=me.get("cards", []),
|
||||||
|
created_at=me.get("createdAt"),
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
|
log = __import__("logging").getLogger("pyspapi")
|
||||||
|
log.error(f"Failed to parse account response: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_user(self, discord_id: int) -> Optional[User]:
|
async def get_user(self, discord_id: int) -> Optional[User]:
|
||||||
"""
|
"""
|
||||||
@@ -109,11 +134,22 @@ class SPAPI(APISession):
|
|||||||
:return: Объект User, представляющий пользователя.
|
:return: Объект User, представляющий пользователя.
|
||||||
:rtype: :class:`User`
|
:rtype: :class:`User`
|
||||||
"""
|
"""
|
||||||
user = await super().get(f"users/{discord_id}")
|
if not discord_id:
|
||||||
if user:
|
raise ValueError("discord_id must be a non-empty integer")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = await super().get(f"users/{discord_id}")
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
cards = await super().get(f"accounts/{user['username']}/cards")
|
cards = await super().get(f"accounts/{user['username']}/cards")
|
||||||
|
if cards is None:
|
||||||
|
cards = []
|
||||||
|
|
||||||
return User(user["username"], user["uuid"], cards)
|
return User(user["username"], user["uuid"], cards)
|
||||||
else:
|
except (KeyError, TypeError) as e:
|
||||||
|
log = __import__("logging").getLogger("pyspapi")
|
||||||
|
log.error(f"Failed to parse user response: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def create_transaction(
|
async def create_transaction(
|
||||||
@@ -132,9 +168,27 @@ class SPAPI(APISession):
|
|||||||
:return: Баланс после транзакции.
|
:return: Баланс после транзакции.
|
||||||
:rtype: int
|
:rtype: int
|
||||||
"""
|
"""
|
||||||
data = {"receiver": receiver, "amount": amount, "comment": comment}
|
if not receiver:
|
||||||
|
raise ValueError("receiver must be a non-empty string")
|
||||||
|
if not isinstance(amount, int) or amount <= 0:
|
||||||
|
raise ValueError("amount must be a positive integer")
|
||||||
|
|
||||||
return int((await super().post("transactions", data))["balance"])
|
try:
|
||||||
|
data = {"receiver": receiver, "amount": amount, "comment": comment}
|
||||||
|
response = await super().post("transactions", data)
|
||||||
|
|
||||||
|
if response is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return int(response.get("balance", 0))
|
||||||
|
except (KeyError, ValueError, TypeError) as e:
|
||||||
|
log = __import__("logging").getLogger("pyspapi")
|
||||||
|
log.error(f"Failed to create transaction: {e}")
|
||||||
|
return None
|
||||||
|
except InsufficientBalanceError as ibe:
|
||||||
|
log = __import__("logging").getLogger("pyspapi")
|
||||||
|
log.error(f"Insufficient balance for transaction: {ibe}")
|
||||||
|
return None
|
||||||
|
|
||||||
async def create_payment(
|
async def create_payment(
|
||||||
self, webhook_url: str, redirect_url: str, data: str, items: list[Item]
|
self, webhook_url: str, redirect_url: str, data: str, items: list[Item]
|
||||||
@@ -153,14 +207,29 @@ class SPAPI(APISession):
|
|||||||
:return: URL для платежа или None при ошибке.
|
:return: URL для платежа или None при ошибке.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
data = {
|
if not webhook_url or not redirect_url:
|
||||||
"items": items,
|
raise ValueError("webhook_url and redirect_url must be non-empty strings")
|
||||||
"redirectUrl": redirect_url,
|
if not items or len(items) == 0:
|
||||||
"webhookUrl": webhook_url,
|
raise ValueError("items must contain at least one item")
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
|
|
||||||
return str((await super().post("payments", data))["url"])
|
try:
|
||||||
|
payload = {
|
||||||
|
"items": items,
|
||||||
|
"redirectUrl": redirect_url,
|
||||||
|
"webhookUrl": webhook_url,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await super().post("payments", payload)
|
||||||
|
|
||||||
|
if response is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return str(response.get("url", ""))
|
||||||
|
except (KeyError, ValueError, TypeError) as e:
|
||||||
|
log = __import__("logging").getLogger("pyspapi")
|
||||||
|
log.error(f"Failed to create payment: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
async def update_webhook(self, url: str) -> Optional[dict]:
|
async def update_webhook(self, url: str) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -169,9 +238,21 @@ class SPAPI(APISession):
|
|||||||
:param url: Новый URL вебхука.
|
:param url: Новый URL вебхука.
|
||||||
:return: Ответ API в виде словаря или None при ошибке.
|
:return: Ответ API в виде словаря или None при ошибке.
|
||||||
"""
|
"""
|
||||||
data = {"url": url}
|
if not url:
|
||||||
|
raise ValueError("url must be a non-empty string")
|
||||||
|
|
||||||
return await super().put("card/webhook", data)
|
try:
|
||||||
|
data = {"url": url}
|
||||||
|
response = await super().put("card/webhook", data)
|
||||||
|
|
||||||
|
if response is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return response
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
|
log = __import__("logging").getLogger("pyspapi")
|
||||||
|
log.error(f"Failed to update webhook: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def webhook_verify(self, data: str, header: str) -> bool:
|
def webhook_verify(self, data: str, header: str) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from .me import Account
|
from pyspapi.types.me import Account
|
||||||
from .payment import Item
|
from pyspapi.types.payment import Item
|
||||||
from .users import Cards, User
|
from pyspapi.types.users import Cards, User
|
||||||
|
|
||||||
__all__ = [Account, Item, Cards, User]
|
__all__ = ["Account", "Item", "Cards", "User"]
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
class City:
|
class City:
|
||||||
def __init__(self, city_id=None, name=None, x=None, z=None, nether_x=None, nether_z=None, lane=None, role=None,
|
def __init__(
|
||||||
created_at=None):
|
self,
|
||||||
|
city_id=None,
|
||||||
|
name=None,
|
||||||
|
x=None,
|
||||||
|
z=None,
|
||||||
|
nether_x=None,
|
||||||
|
nether_z=None,
|
||||||
|
lane=None,
|
||||||
|
role=None,
|
||||||
|
created_at=None,
|
||||||
|
):
|
||||||
self._id = city_id
|
self._id = city_id
|
||||||
self._name = name
|
self._name = name
|
||||||
self._x = x
|
self._x = x
|
||||||
@@ -48,11 +58,7 @@ class City:
|
|||||||
return self._created_at
|
return self._created_at
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return (
|
return f"<{self.__class__.__name__}(id={self._id!r}, name={self._name!r}, lane={self._lane!r}, role={self._role!r})>"
|
||||||
f"City(id={self._id}, name={self._name}, x={self._x}, z={self._z}, "
|
|
||||||
f"nether_x={self._nether_x}, nether_z={self._nether_z}, lane={self._lane}, role={self._role}, "
|
|
||||||
f"created_at={self._created_at})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Card:
|
class Card:
|
||||||
@@ -79,11 +85,21 @@ class Card:
|
|||||||
return self._color
|
return self._color
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"Card(id={self._id}, name={self._name}, number={self._number}, color={self._color})"
|
return f"<{self.__class__.__name__}(id={self._id!r}, name={self._name!r}, number={self._number!r})>"
|
||||||
|
|
||||||
|
|
||||||
class Account:
|
class Account:
|
||||||
def __init__(self, account_id, username, minecraftuuid, status, roles, created_at, cards, cities):
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_id,
|
||||||
|
username,
|
||||||
|
minecraftuuid,
|
||||||
|
status,
|
||||||
|
roles,
|
||||||
|
created_at,
|
||||||
|
cards,
|
||||||
|
cities,
|
||||||
|
):
|
||||||
self._id = account_id
|
self._id = account_id
|
||||||
self._username = username
|
self._username = username
|
||||||
self._minecraftuuid = minecraftuuid
|
self._minecraftuuid = minecraftuuid
|
||||||
@@ -91,15 +107,15 @@ class Account:
|
|||||||
self._roles = roles
|
self._roles = roles
|
||||||
self._cities = [
|
self._cities = [
|
||||||
City(
|
City(
|
||||||
city_id=city['city_id'],
|
city_id=city["city"]["id"],
|
||||||
name=city['name'],
|
name=city["city"]["name"],
|
||||||
x=city['x'],
|
x=city["city"]["x"],
|
||||||
z=city['z'],
|
z=city["city"]["z"],
|
||||||
nether_x=city['nether_x'],
|
nether_x=city["city"]["netherX"],
|
||||||
nether_z=city['nether_z'],
|
nether_z=city["city"]["netherZ"],
|
||||||
lane=city['lane'],
|
lane=city["city"]["lane"],
|
||||||
role=city['role'],
|
role=city["role"],
|
||||||
created_at=city['created_at'],
|
created_at=city["createdAt"],
|
||||||
)
|
)
|
||||||
for city in cities
|
for city in cities
|
||||||
]
|
]
|
||||||
@@ -147,6 +163,7 @@ class Account:
|
|||||||
return self._created_at
|
return self._created_at
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return (f"Account(id={self._id}, username={self._username}, minecraftUUID={self._minecraftuuid}, "
|
return (
|
||||||
f"status={self._status}, roles={self._roles}, cities={self._cities}, cards={self._cards}, "
|
f"<{self.__class__.__name__}(id={self._id!r}, username={self._username!r}, status={self._status!r}, "
|
||||||
f"created_at={self._created_at})")
|
f"roles={self._roles}, cities={self._cities}, cards={self._cards})>"
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ class Item:
|
|||||||
def name(self):
|
def name(self):
|
||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{self.__class__.__name__}(name={self._name!r}, count={self._count!r}, price={self._price!r}, comment={self._comment!r})>"
|
||||||
|
|
||||||
def to_json(self):
|
def to_json(self):
|
||||||
return {
|
return {
|
||||||
"name": self._name,
|
"name": self._name,
|
||||||
"count": self._count,
|
"count": self._count,
|
||||||
"price": self._price,
|
"price": self._price,
|
||||||
"comment": self._comment
|
"comment": self._comment,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
class Cards:
|
class UserCards:
|
||||||
|
|
||||||
def __init__(self, name, number):
|
def __init__(self, name, number):
|
||||||
self._name: str = name
|
self._name: str = name
|
||||||
self._number: str = number
|
self._number: str = number
|
||||||
@@ -12,20 +11,16 @@ class Cards:
|
|||||||
def number(self):
|
def number(self):
|
||||||
return self._number
|
return self._number
|
||||||
|
|
||||||
# def __repr__(self):
|
def __repr__(self):
|
||||||
# return "%s(%s)" % (
|
return f"<{self.__class__.__name__}(id={self._id!r}, name={self._name!r}, number={self._number!r})>"
|
||||||
# self.__class__.__name__,
|
|
||||||
# self.__dict__
|
|
||||||
# )
|
|
||||||
|
|
||||||
|
|
||||||
class User:
|
class User:
|
||||||
|
|
||||||
def __init__(self, username, uuid, cards):
|
def __init__(self, username, uuid, cards):
|
||||||
self._username: str = username
|
self._username: str = username
|
||||||
self._uuid: str = uuid
|
self._uuid: str = uuid
|
||||||
self._cards = [
|
self._cards = [
|
||||||
Cards(
|
UserCards(
|
||||||
name=card["name"],
|
name=card["name"],
|
||||||
number=card["number"],
|
number=card["number"],
|
||||||
)
|
)
|
||||||
@@ -45,7 +40,4 @@ class User:
|
|||||||
return self._cards
|
return self._cards
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "%s(%s)" % (
|
return "%s(%s)" % (self.__class__.__name__, self.__dict__)
|
||||||
self.__class__.__name__,
|
|
||||||
self.__dict__
|
|
||||||
)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user