3 Commits
3.1.1 ... 3.2.0

Author SHA1 Message Date
deesiigneer
d36ecfca36 feat(api): добавлены новые исключения и параметр raise_exception для управления ошибками
- Добавлены классы исключений SPAPIError и ValidationError для улучшенной обработки ошибок API
- В APISession добавлен параметр raise_exception, который позволяет выбрасывать исключения при ошибках API
- Обновлены методы request, get, post, put для поддержки raise_exception
- Расширена модель SPAPI с передачей параметра raise_exception

refactor(api, models): улучшена структура кода и модели данных
- Упрощена и улучшена реализация APISession, исправлены устаревшие методы и типы
- Модель City переработана: добавлены новые поля (nether_x, nether_z, lane, role, created_at), улучшены свойства и __repr__
- Исправлена модель Card (исправлено имя класса с Cards на Card)
- В модели Account добавлено поле minecraftuuid, заменено поле city на cities с поддержкой списка объектов City
- Исправлены типы возвращаемых значений и добавлены аннотации типов в ключевых местах
- Устранены дублирования и улучшена читаемость кода
- Комментарии и докстринги уточнены и унифицированы

fix(api): исправлены ошибки и опечатки в коде
- Исправлено использование устаревших методов для запросов к API
- Удалены лишние пустые строки и форматирование под PEP8

Fixes #16

Signed-off-by: deesiigneer <goldenrump@gmail.com>
2025-07-14 21:35:57 +05:00
deesiigneer
c086954c25 fix endpoint 2024-04-18 23:09:40 +05:00
deesiigneer
8d60472b9a update packages in setup.py 2024-04-18 22:56:23 +05:00
7 changed files with 214 additions and 185 deletions

View File

@@ -1,16 +1,15 @@
from pyspapi import SPAPI
from asyncio import get_event_loop
from pyspapi import SPAPI
spapi = SPAPI(card_id='CARD_ID', token='TOKEN')
async def main():
new_balance = await spapi.create_transaction(receiver='77552',
amount=1,
comment="test"
)
comment='test')
print(new_balance)
loop = get_event_loop()
loop.run_until_complete(main())

View File

@@ -1,10 +1,9 @@
from .api import *
from .spworlds import *
from .types import *
from .api import *
__author__ = 'deesiigneer'
__url__ = 'https://github.com/deesiigneer/pyspapi'
__description__ = 'API wrapper for SP servers written in Python.'
__license__ = 'MIT'
__version__ = "3.1.1"
__version__ = "3.2.0"

View File

@@ -1,23 +1,32 @@
import asyncio
import json
from base64 import b64encode
from logging import getLogger
import aiohttp
import json
from typing import Optional, Any, Dict
import aiohttp
from ..exceptions import ValidationError, SPAPIError
log = getLogger('pyspapi')
class APISession(object):
""" Holds aiohttp session for its lifetime and wraps different types of request """
def __init__(self, card_id: str, token: str, timeout=5, sleep_time=0.2, retries=0):
self.__url = "https://spworlds.ru/"
def __init__(self, card_id: str,
token: str,
timeout: int = 5,
sleep_time: float = 0.2,
retries: int = 0,
raise_exception: bool = False):
self.__url = "https://spworlds.ru/api/public/"
self.__id = card_id
self.__token = token
self.__sleep_timeout = sleep_time
self.__retries = retries
self.__timeout = timeout
self.session = None
self.__raise_exception = raise_exception
self.session: Optional[aiohttp.ClientSession] = None
async def __aenter__(self):
self.session = aiohttp.ClientSession(
@@ -29,62 +38,50 @@ class APISession(object):
await self.session.close()
self.session = None
def __get_url(self, endpoint: str) -> str:
""" Get URL for requests """
url = self.__url
api = "api/public"
return f"{url}{api}/{endpoint}"
async def request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Any:
url = self.__url + endpoint
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",
}
async def __request(self, method: str, endpoint: str, data=None):
url = self.__get_url(endpoint)
response = await self.session.request(
method=method,
url=url,
json=data,
headers={'Authorization': f"Bearer {str(b64encode(str(f'{self.__id}:{self.__token}').encode('utf-8')), 'utf-8')}",
'User-Agent': 'https://github.com/deesiigneer/pyspapi'},
ssl=True
)
if response.status not in [200, 201]:
message = await response.json()
raise aiohttp.ClientResponseError(
code=response.status,
message=message['message'],
headers=response.headers,
history=response.history,
request_info=response.request_info
)
return response
attempt = 0
while True:
attempt += 1
if attempt > 1:
log.warning(f'[pyspapi] Repeat attempt {attempt}: {method.upper()} {url}')
try:
async with self.session.request(method, url, json=data, headers=headers) as resp:
if resp.status == 422:
errors = await resp.json()
log.error(f"[pyspapi] Validation error: {errors}")
if self.__raise_exception:
raise ValidationError(errors)
return None
async def get(self, endpoint, **kwargs):
""" GET requests """
try:
return await self.__request("GET", endpoint, None, **kwargs)
except aiohttp.ClientResponseError as e:
log.error(f"GET request to {endpoint} failed with status {e.status}: {e.message}")
except aiohttp.ClientError as e:
log.error(f"GET request to {endpoint} failed: {e}")
except Exception as e:
log.error(f"GET request to {endpoint} failed: {e}")
if resp.status >= 400:
content = await resp.text()
log.error(f"[pyspapi] API error {resp.status}: {content}")
if self.__raise_exception:
raise SPAPIError(resp.status, content)
return None
async def post(self, endpoint, data, **kwargs):
""" POST requests """
try:
return await self.__request("POST", endpoint, data, **kwargs)
except aiohttp.ClientResponseError as e:
log.error(f"POST request to {endpoint} failed with status {e.status}: {e.message}")
except aiohttp.ClientError as e:
log.error(f"POST request to {endpoint} failed: {e}")
except Exception as e:
log.error(f"POST request to {endpoint} failed: {e}")
return await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
log.exception(f"[pyspapi] Connection error: {e}")
if attempt > self.__retries:
return None
await asyncio.sleep(self.__sleep_timeout)
async def put(self, endpoint, data, **kwargs):
""" PUT requests """
try:
return await self.__request("PUT", endpoint, data, **kwargs)
except aiohttp.ClientResponseError as e:
log.error(f"PUT request to {endpoint} failed with status {e.status}: {e.message}")
except aiohttp.ClientError as e:
log.error(f"PUT request to {endpoint} failed: {e}")
except Exception as e:
log.error(f"PUT request to {endpoint} failed: {e}")
async def get(self, endpoint: str) -> Any:
async with self:
return await self.request("GET", endpoint)
async def post(self, endpoint: str, data: Optional[Dict] = None) -> Any:
async with self:
return await self.request("POST", endpoint, data)
async def put(self, endpoint: str, data: Optional[Dict] = None) -> Any:
async with self:
return await self.request("PUT", endpoint, data)

View File

@@ -0,0 +1,25 @@
class SPAPIError(Exception):
"""
Базовая ошибка для всех исключений, связанных с API SPWorlds.
"""
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f"[{status_code}] {message}")
def __str__(self):
return f"SPAPIError: [{self.status_code}] {self.message}"
class ValidationError(SPAPIError):
"""
Ошибка валидации.
"""
def __init__(self, errors):
self.errors = errors
super().__init__(422, f"Validation failed: {errors}")
def __str__(self):
return f"ValidationError: {self.errors}"

View File

@@ -1,21 +1,30 @@
from base64 import b64encode
from hashlib import sha256
from hmac import new, compare_digest
from typing import Optional
from .api import APISession
from .types import User
from .types.me import Account
from .types.payment import Item
from hmac import new, compare_digest
from hashlib import sha256
from base64 import b64encode
import aiohttp
__all__ = ['SPAPI']
class SPAPI(APISession):
"""
Представляет собой клиент API для взаимодействия с конкретным сервисом.
pyspapi — высокоуровневый клиент для взаимодействия с SPWorldsAPI.
Предоставляет удобные методы для работы с балансом карты, вебхуками,
информацией о пользователе, транзакциями и платежами, а также верификацией вебхуков.
"""
def __init__(self, card_id=None, token=None, timeout=5, sleep_time=0.2, retries=0):
def __init__(self, card_id: str,
token: str,
timeout: int = 5,
sleep_time: float = 0.2,
retries: int = 0,
raise_exception: bool = False):
"""
Инициализирует объект SPAPI.
@@ -24,13 +33,15 @@ class SPAPI(APISession):
:param token: Токен API.
:type token: str
:param timeout: Таймаут для запросов API в секундах. По умолчанию 5.
:type timeout: int, optional
:type timeout: int
:param sleep_time: Время ожидания между повторными запросами в секундах. По умолчанию 0.2.
:type sleep_time: float, optional
:type sleep_time: float
:param retries: Количество повторных попыток для неудачных запросов. По умолчанию 0.
:type retries: int, optional
:type retries: int
:param raise_exception: Поднимать исключения при ошибке, если True.
:type raise_exception: bool
"""
super().__init__(card_id, token, timeout, sleep_time, retries)
super().__init__(card_id, token, timeout, sleep_time, retries, raise_exception)
self.__card_id = card_id
self.__token = token
@@ -38,67 +49,48 @@ class SPAPI(APISession):
"""
Возвращает строковое представление объекта SPAPI.
"""
return "%s(%s)" % (
self.__class__.__name__,
self.__dict__
)
async def __get(self, method):
"""
Выполняет GET-запрос к API.
:param method: Метод API для вызова.
:type method: str
:return: JSON-ответ от API.
:rtype: dict
"""
async with APISession(self.__card_id, self.__token) as session:
response = await session.get(method)
response = await response.json()
return response
return f"{self.__class__.__name__}({vars(self)})"
@property
async def balance(self):
async def balance(self) -> Optional[int]:
"""
Получает текущий баланс карты.
:return: Текущий баланс карты.
:rtype: int
"""
card = await self.__get('card')
return card['balance']
return int((await super().get('card'))['balance'])
@property
async def webhook(self) -> str:
async def webhook(self) -> Optional[str]:
"""
Получает URL вебхука, связанного с картой.
:return: URL вебхука.
:rtype: str
"""
card = await self.__get('card')
return card['webhook']
return str((await super().get('card'))['webhook'])
@property
async def me(self):
async def me(self) -> Optional[Account]:
"""
Получает информацию об аккаунте текущего пользователя.
:return: Объект Account, представляющий аккаунт текущего пользователя.
:rtype: Account
:rtype: :class:`Account`
"""
me = await self.__get('account/me')
me = await super().get('accounts/me')
return Account(
account_id=me['id'],
username=me['username'],
minecraftuuid=me['minecraftUUID'],
status=me['status'],
roles=me['roles'],
city=me['city'],
cities=me['cities'],
cards=me['cards'],
created_at=me['createdAt'])
async def get_user(self, discord_id: int) -> User:
async def get_user(self, discord_id: int) -> Optional[User]:
"""
Получает информацию о пользователе по его ID в Discord.
@@ -106,13 +98,13 @@ class SPAPI(APISession):
:type discord_id: int
:return: Объект User, представляющий пользователя.
:rtype: User
:rtype: :class:`User`
"""
user = await self.__get(f'users/{discord_id}')
cards = await self.__get(f"accounts/{user['username']}/cards")
user = await super().get(f'users/{discord_id}')
cards = await super().get(f"accounts/{user['username']}/cards")
return User(user['username'], user['uuid'], cards)
async def create_transaction(self, receiver: str, amount: int, comment: str):
async def create_transaction(self, receiver: str, amount: int, comment: str) -> Optional[int]:
"""
Создает транзакцию.
@@ -126,17 +118,15 @@ class SPAPI(APISession):
:return: Баланс после транзакции.
:rtype: int
"""
async with APISession(self.__card_id, self.__token) as session:
data = {
'receiver': receiver,
'amount': amount,
'comment': comment
}
res = await session.post('transactions', data)
res = await res.json()
return res['balance']
data = {
'receiver': receiver,
'amount': amount,
'comment': comment
}
async def create_payment(self, webhook_url: str, redirect_url: str, data: str, items) -> str:
return int((await super().post('transactions', data))['balance'])
async def create_payment(self, webhook_url: str, redirect_url: str, data: str, items: list[Item]) -> Optional[str]:
"""
Создает платеж.
@@ -148,40 +138,30 @@ class SPAPI(APISession):
:type data: str
:param items: Элементы, включаемые в платеж.
:return: URL для платежа.
:return: URL для платежа или None при ошибке.
:rtype: str
"""
async with APISession(self.__card_id, self.__token) as session:
data = {
'items': items,
'redirectUrl': redirect_url,
'webhookUrl': webhook_url,
'data': data
}
res = await session.post('payments',data)
res = await res.json()
return res['url']
data = {
'items': items,
'redirectUrl': redirect_url,
'webhookUrl': webhook_url,
'data': data
}
async def update_webhook(self, url: str):
return str((await super().post('payments', data))['url'])
async def update_webhook(self, url: str) -> Optional[dict]:
"""
Обновляет URL вебхука, связанного с картой.
:param url: Новый URL вебхука.
:type url: str
:return: JSON-ответ от API.
:rtype: dict
:return: Ответ API в виде словаря или None при ошибке.
"""
async with APISession(self.__card_id, self.__token) as session:
data = {
'url': url
}
res = await session.put(endpoint='card/webhook', data=data)
if res:
res = await res.json()
return res
data = {'url': url}
def webhook_verify(self, data: str, header) -> bool:
return await super().put('card/webhook', data)
def webhook_verify(self, data: str, header: str) -> bool:
"""
Проверяет достоверность вебхука.

View File

@@ -1,49 +1,61 @@
class City:
def __init__(
self,
city_id=None,
name=None,
description=None,
x_cord=None,
z_cord=None,
is_mayor=None,
):
def __init__(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._name = name
self._description = description
self._x_cord = x_cord
self._z_cord = z_cord
self._isMayor = is_mayor
self._x = x
self._z = z
self._nether_x = nether_x
self._nether_z = nether_z
self._lane = lane
self._role = role
self._created_at = created_at
@property
def id(self):
return self._id
@property
def description(self):
return self._description
@property
def name(self):
return self._name
@property
def x_cord(self):
return self._x_cord
def x(self):
return self._x
@property
def z_cord(self):
return self._z_cord
def z(self):
return self._z
@property
def mayor(self):
return self._isMayor
def nether_x(self):
return self._nether_x
@property
def nether_z(self):
return self._nether_z
@property
def lane(self):
return self._lane
@property
def role(self):
return self._role
@property
def created_at(self):
return self._created_at
def __repr__(self):
return f"City(id={self.id}, name={self.name}, description={self.description}, x={self.x_cord}, z={self.z_cord}, is_mayor={self.mayor})"
return (
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 Cards:
class Card:
def __init__(self, card_id=None, name=None, number=None, color=None):
self._id = card_id
self._name = name
@@ -67,18 +79,32 @@ class Cards:
return self._color
def __repr__(self):
return f"Card(id={self.id}, name={self.name}, number={self.number}, color={self.color})"
return f"Card(id={self._id}, name={self._name}, number={self._number}, color={self._color})"
class Account:
def __init__(self, account_id, username, status, roles, created_at, cards, city):
def __init__(self, account_id, username, minecraftuuid, status, roles, created_at, cards, cities):
self._id = account_id
self._username = username
self._minecraftuuid = minecraftuuid
self._status = status
self._roles = roles
self._city = City(**city) if city else None
self._cities = [
City(
city_id=city['city_id'],
name=city['name'],
x=city['x'],
z=city['z'],
nether_x=city['nether_x'],
nether_z=city['nether_z'],
lane=city['lane'],
role=city['role'],
created_at=city['created_at'],
)
for city in cities
]
self._cards = [
Cards(
Card(
card_id=card["id"],
name=card["name"],
number=card["number"],
@@ -96,6 +122,10 @@ class Account:
def username(self):
return self._username
@property
def minecraftuuid(self):
return self._minecraftuuid
@property
def status(self):
return self._status
@@ -105,8 +135,8 @@ class Account:
return self._roles
@property
def city(self):
return self._city
def cities(self):
return self._cities
@property
def cards(self):
@@ -117,4 +147,6 @@ class Account:
return self._created_at
def __repr__(self):
return f"Account(id={self.id}, username={self.username}, status={self.status}, roles={self.roles}, city={self.city}, cards={self.cards}, created_at={self.created_at})"
return (f"Account(id={self._id}, username={self._username}, minecraftUUID={self._minecraftuuid}, "
f"status={self._status}, roles={self._roles}, cities={self._cities}, cards={self._cards}, "
f"created_at={self._created_at})")

View File

@@ -1,6 +1,6 @@
import re
from setuptools import setup
from setuptools import setup, find_packages
requirements = []
with open("requirements.txt") as f:
@@ -21,10 +21,6 @@ readme = ""
with open("README.rst") as f:
readme = f.read()
packages = [
"pyspapi"
]
setup(
name='pyspapi',
license='MIT',
@@ -39,7 +35,8 @@ setup(
description='API wrapper for SP servers written in Python',
long_description=readme,
long_description_content_type='text/x-rst',
packages=packages,
packages=find_packages(),
package_data={'pyspapi': ['types/*', 'api/*']}, # Включаем дополнительные файлы и папки
include_package_data=True,
install_requires=requirements,
python_requires='>=3.8.0',