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 asyncio import get_event_loop
from pyspapi import SPAPI
spapi = SPAPI(card_id='CARD_ID', token='TOKEN') spapi = SPAPI(card_id='CARD_ID', token='TOKEN')
async def main(): async def main():
new_balance = await spapi.create_transaction(receiver='77552', new_balance = await spapi.create_transaction(receiver='77552',
amount=1, amount=1,
comment="test" comment='test')
)
print(new_balance) print(new_balance)
loop = get_event_loop() loop = get_event_loop()
loop.run_until_complete(main()) loop.run_until_complete(main())

View File

@@ -1,10 +1,9 @@
from .api import *
from .spworlds import * from .spworlds import *
from .types import * from .types import *
from .api import *
__author__ = 'deesiigneer' __author__ = 'deesiigneer'
__url__ = 'https://github.com/deesiigneer/pyspapi' __url__ = 'https://github.com/deesiigneer/pyspapi'
__description__ = 'API wrapper for SP servers written in Python.' __description__ = 'API wrapper for SP servers written in Python.'
__license__ = 'MIT' __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 base64 import b64encode
from logging import getLogger from logging import getLogger
import aiohttp from typing import Optional, Any, Dict
import json
import aiohttp
from ..exceptions import ValidationError, SPAPIError
log = getLogger('pyspapi') log = getLogger('pyspapi')
class APISession(object): 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): def __init__(self, card_id: str,
self.__url = "https://spworlds.ru/" 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.__id = card_id
self.__token = token self.__token = token
self.__sleep_timeout = sleep_time self.__sleep_timeout = sleep_time
self.__retries = retries self.__retries = retries
self.__timeout = timeout self.__timeout = timeout
self.session = None self.__raise_exception = raise_exception
self.session: Optional[aiohttp.ClientSession] = None
async def __aenter__(self): async def __aenter__(self):
self.session = aiohttp.ClientSession( self.session = aiohttp.ClientSession(
@@ -29,62 +38,50 @@ class APISession(object):
await self.session.close() await self.session.close()
self.session = None self.session = None
def __get_url(self, endpoint: str) -> str: async def request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Any:
""" Get URL for requests """ url = self.__url + endpoint
url = self.__url headers = {
api = "api/public" 'Authorization': f"Bearer {str(b64encode(str(f'{self.__id}:{self.__token}').encode('utf-8')), 'utf-8')}",
return f"{url}{api}/{endpoint}" 'User-Agent': 'https://github.com/deesiigneer/pyspapi',
"Content-Type": "application/json",
}
async def __request(self, method: str, endpoint: str, data=None): attempt = 0
url = self.__get_url(endpoint) while True:
response = await self.session.request( attempt += 1
method=method, if attempt > 1:
url=url, log.warning(f'[pyspapi] Repeat attempt {attempt}: {method.upper()} {url}')
json=data, try:
headers={'Authorization': f"Bearer {str(b64encode(str(f'{self.__id}:{self.__token}').encode('utf-8')), 'utf-8')}", async with self.session.request(method, url, json=data, headers=headers) as resp:
'User-Agent': 'https://github.com/deesiigneer/pyspapi'}, if resp.status == 422:
ssl=True errors = await resp.json()
) log.error(f"[pyspapi] Validation error: {errors}")
if response.status not in [200, 201]: if self.__raise_exception:
message = await response.json() raise ValidationError(errors)
raise aiohttp.ClientResponseError( return None
code=response.status,
message=message['message'],
headers=response.headers,
history=response.history,
request_info=response.request_info
)
return response
async def get(self, endpoint, **kwargs): if resp.status >= 400:
""" GET requests """ content = await resp.text()
try: log.error(f"[pyspapi] API error {resp.status}: {content}")
return await self.__request("GET", endpoint, None, **kwargs) if self.__raise_exception:
except aiohttp.ClientResponseError as e: raise SPAPIError(resp.status, content)
log.error(f"GET request to {endpoint} failed with status {e.status}: {e.message}") return None
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}")
async def post(self, endpoint, data, **kwargs): return await resp.json()
""" POST requests """ except (aiohttp.ClientError, asyncio.TimeoutError) as e:
try: log.exception(f"[pyspapi] Connection error: {e}")
return await self.__request("POST", endpoint, data, **kwargs) if attempt > self.__retries:
except aiohttp.ClientResponseError as e: return None
log.error(f"POST request to {endpoint} failed with status {e.status}: {e.message}") await asyncio.sleep(self.__sleep_timeout)
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}")
async def put(self, endpoint, data, **kwargs): async def get(self, endpoint: str) -> Any:
""" PUT requests """ async with self:
try: return await self.request("GET", endpoint)
return await self.__request("PUT", endpoint, data, **kwargs)
except aiohttp.ClientResponseError as e: async def post(self, endpoint: str, data: Optional[Dict] = None) -> Any:
log.error(f"PUT request to {endpoint} failed with status {e.status}: {e.message}") async with self:
except aiohttp.ClientError as e: return await self.request("POST", endpoint, data)
log.error(f"PUT request to {endpoint} failed: {e}")
except Exception as e: async def put(self, endpoint: str, data: Optional[Dict] = None) -> Any:
log.error(f"PUT request to {endpoint} failed: {e}") 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 .api import APISession
from .types import User from .types import User
from .types.me import Account from .types.me import Account
from .types.payment import Item from .types.payment import Item
from hmac import new, compare_digest
from hashlib import sha256
from base64 import b64encode
import aiohttp
__all__ = ['SPAPI'] __all__ = ['SPAPI']
class SPAPI(APISession): 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. Инициализирует объект SPAPI.
@@ -24,13 +33,15 @@ class SPAPI(APISession):
:param token: Токен API. :param token: Токен API.
:type token: str :type token: str
:param timeout: Таймаут для запросов API в секундах. По умолчанию 5. :param timeout: Таймаут для запросов API в секундах. По умолчанию 5.
:type timeout: int, optional :type timeout: int
:param sleep_time: Время ожидания между повторными запросами в секундах. По умолчанию 0.2. :param sleep_time: Время ожидания между повторными запросами в секундах. По умолчанию 0.2.
:type sleep_time: float, optional :type sleep_time: float
:param retries: Количество повторных попыток для неудачных запросов. По умолчанию 0. :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.__card_id = card_id
self.__token = token self.__token = token
@@ -38,67 +49,48 @@ class SPAPI(APISession):
""" """
Возвращает строковое представление объекта SPAPI. Возвращает строковое представление объекта SPAPI.
""" """
return "%s(%s)" % ( return f"{self.__class__.__name__}({vars(self)})"
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
@property @property
async def balance(self): async def balance(self) -> Optional[int]:
""" """
Получает текущий баланс карты. Получает текущий баланс карты.
:return: Текущий баланс карты. :return: Текущий баланс карты.
:rtype: int :rtype: int
""" """
card = await self.__get('card') return int((await super().get('card'))['balance'])
return card['balance']
@property @property
async def webhook(self) -> str: async def webhook(self) -> Optional[str]:
""" """
Получает URL вебхука, связанного с картой. Получает URL вебхука, связанного с картой.
:return: URL вебхука. :return: URL вебхука.
:rtype: str :rtype: str
""" """
card = await self.__get('card') return str((await super().get('card'))['webhook'])
return card['webhook']
@property @property
async def me(self): async def me(self) -> Optional[Account]:
""" """
Получает информацию об аккаунте текущего пользователя. Получает информацию об аккаунте текущего пользователя.
:return: Объект Account, представляющий аккаунт текущего пользователя. :return: Объект Account, представляющий аккаунт текущего пользователя.
:rtype: Account :rtype: :class:`Account`
""" """
me = await self.__get('account/me') me = await super().get('accounts/me')
return Account( return Account(
account_id=me['id'], account_id=me['id'],
username=me['username'], username=me['username'],
minecraftuuid=me['minecraftUUID'],
status=me['status'], status=me['status'],
roles=me['roles'], roles=me['roles'],
city=me['city'], cities=me['cities'],
cards=me['cards'], cards=me['cards'],
created_at=me['createdAt']) 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. Получает информацию о пользователе по его ID в Discord.
@@ -106,13 +98,13 @@ class SPAPI(APISession):
:type discord_id: int :type discord_id: int
:return: Объект User, представляющий пользователя. :return: Объект User, представляющий пользователя.
:rtype: User :rtype: :class:`User`
""" """
user = await self.__get(f'users/{discord_id}') user = await super().get(f'users/{discord_id}')
cards = await self.__get(f"accounts/{user['username']}/cards") cards = await super().get(f"accounts/{user['username']}/cards")
return User(user['username'], user['uuid'], 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: Баланс после транзакции. :return: Баланс после транзакции.
:rtype: int :rtype: int
""" """
async with APISession(self.__card_id, self.__token) as session: data = {
data = { 'receiver': receiver,
'receiver': receiver, 'amount': amount,
'amount': amount, 'comment': comment
'comment': comment }
}
res = await session.post('transactions', data)
res = await res.json()
return res['balance']
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 :type data: str
:param items: Элементы, включаемые в платеж. :param items: Элементы, включаемые в платеж.
:return: URL для платежа. :return: URL для платежа или None при ошибке.
:rtype: str :rtype: str
""" """
async with APISession(self.__card_id, self.__token) as session: data = {
data = { 'items': items,
'items': items, 'redirectUrl': redirect_url,
'redirectUrl': redirect_url, 'webhookUrl': webhook_url,
'webhookUrl': webhook_url, 'data': data
'data': data }
}
res = await session.post('payments',data)
res = await res.json()
return res['url']
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 вебхука, связанного с картой. Обновляет URL вебхука, связанного с картой.
:param url: Новый URL вебхука. :param url: Новый URL вебхука.
:type url: str :return: Ответ API в виде словаря или None при ошибке.
:return: JSON-ответ от API.
:rtype: dict
""" """
async with APISession(self.__card_id, self.__token) as session: data = {'url': url}
data = {
'url': url
}
res = await session.put(endpoint='card/webhook', data=data)
if res:
res = await res.json()
return res
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: class City:
def __init__( def __init__(self, city_id=None, name=None, x=None, z=None, nether_x=None, nether_z=None, lane=None, role=None,
self, created_at=None):
city_id=None,
name=None,
description=None,
x_cord=None,
z_cord=None,
is_mayor=None,
):
self._id = city_id self._id = city_id
self._name = name self._name = name
self._description = description self._x = x
self._x_cord = x_cord self._z = z
self._z_cord = z_cord self._nether_x = nether_x
self._isMayor = is_mayor self._nether_z = nether_z
self._lane = lane
self._role = role
self._created_at = created_at
@property @property
def id(self): def id(self):
return self._id return self._id
@property
def description(self):
return self._description
@property @property
def name(self): def name(self):
return self._name return self._name
@property @property
def x_cord(self): def x(self):
return self._x_cord return self._x
@property @property
def z_cord(self): def z(self):
return self._z_cord return self._z
@property @property
def mayor(self): def nether_x(self):
return self._isMayor 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): 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): def __init__(self, card_id=None, name=None, number=None, color=None):
self._id = card_id self._id = card_id
self._name = name self._name = name
@@ -67,18 +79,32 @@ class Cards:
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"Card(id={self._id}, name={self._name}, number={self._number}, color={self._color})"
class Account: 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._id = account_id
self._username = username self._username = username
self._minecraftuuid = minecraftuuid
self._status = status self._status = status
self._roles = roles 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 = [ self._cards = [
Cards( Card(
card_id=card["id"], card_id=card["id"],
name=card["name"], name=card["name"],
number=card["number"], number=card["number"],
@@ -96,6 +122,10 @@ class Account:
def username(self): def username(self):
return self._username return self._username
@property
def minecraftuuid(self):
return self._minecraftuuid
@property @property
def status(self): def status(self):
return self._status return self._status
@@ -105,8 +135,8 @@ class Account:
return self._roles return self._roles
@property @property
def city(self): def cities(self):
return self._city return self._cities
@property @property
def cards(self): def cards(self):
@@ -117,4 +147,6 @@ 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}, 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 import re
from setuptools import setup from setuptools import setup, find_packages
requirements = [] requirements = []
with open("requirements.txt") as f: with open("requirements.txt") as f:
@@ -21,10 +21,6 @@ readme = ""
with open("README.rst") as f: with open("README.rst") as f:
readme = f.read() readme = f.read()
packages = [
"pyspapi"
]
setup( setup(
name='pyspapi', name='pyspapi',
license='MIT', license='MIT',
@@ -39,7 +35,8 @@ setup(
description='API wrapper for SP servers written in Python', description='API wrapper for SP servers written in Python',
long_description=readme, long_description=readme,
long_description_content_type='text/x-rst', long_description_content_type='text/x-rst',
packages=packages, packages=find_packages(),
package_data={'pyspapi': ['types/*', 'api/*']}, # Включаем дополнительные файлы и папки
include_package_data=True, include_package_data=True,
install_requires=requirements, install_requires=requirements,
python_requires='>=3.8.0', python_requires='>=3.8.0',