14 Commits
3.2.0 ... main

Author SHA1 Message Date
deesiigneer
508a209e74 fix: bump version to 3.3.2 in pyproject.toml 2026-03-09 12:53:55 +00:00
deesiigneer
2573c30800 fix: update Python version requirement to 3.12 and include Card, City in exported members 2026-03-09 12:52:50 +00:00
Aleksey
dc36f05221 Merge pull request #19 from TonyAleksandr/patch-1
fix: include UserCards in exported module members
2026-03-09 15:30:27 +03:00
deesiigneer
2e75de605d fix: include UserCards in exported module members 2026-03-09 12:25:57 +00:00
TonyAleksandr
22219f3e37 fix: remove Cards from exported module members 2026-03-09 09:01:20 +03:00
deesiigneer
83d4308906 refactor: enhance error handling and logging in API interactions, improve exception classes 2026-02-01 14:57:16 +00:00
deesiigneer
e22a22b777 bump version to 3.3.1 and enable package mode in pyproject.toml 2026-01-30 22:36:39 +00:00
deesiigneer
6906afb090 refactor: replace get_event_loop with asyncio.run for better async handling in example scripts 2026-01-30 22:36:18 +00:00
deesiigneer
931b1a8621 update poetry 2026-01-17 19:39:52 +00:00
deesiigneer
e9765e8b6a refactor: update Python publish workflow to use Poetry for dependency management 2026-01-17 19:30:51 +00:00
deesiigneer
047dbb38d0 chore: update Python version to 3.12 in GitHub Actions workflow 2026-01-17 19:02:52 +00:00
deesiigneer
6da906e0d1 feat(docs): localize documentation to Russian and update Makefile for Sphinx 2026-01-17 19:02:11 +00:00
deesiigneer
4fc530caeb refactor: improve code structure and add proxy support in APISession and SPAPI 2026-01-17 18:59:58 +00:00
deesiigneer
6e77bac3ba feat: migrate to poetry for dependency management and project configuration
- Added pyproject.toml for project metadata and dependencies.
- Removed requirements.txt as dependencies are now managed by poetry.
- Deleted setup.py as it is no longer needed with poetry.
2026-01-17 18:59:20 +00:00
27 changed files with 2711 additions and 318 deletions

View File

@@ -17,20 +17,26 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [ 3.9 ] python-version: [3.12]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install Poetry
run: |
curl -sSL https://install.python-poetry.org | python3 -
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install dependencies - name: Install dependencies
run: | run: poetry install --no-interaction
python -m pip install --upgrade pip
pip install twine - name: Build package
- name: Compile package run: poetry build
run: |
python3 setup.py sdist
- name: Publish package - name: Publish package
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@release/v1
with: with:

View File

@@ -1,15 +1,13 @@
version: 2 version: 2
formats: []
build: build:
os: ubuntu-lts-latest os: ubuntu-lts-latest
tools: tools:
python: '3.8' python: '3.12'
sphinx: sphinx:
configuration: docs/conf.py configuration: docs/conf.py
fail_on_warning: false
builder: html builder: html
python: python:
@@ -18,4 +16,3 @@ python:
path: . path: .
extra_requirements: extra_requirements:
- docs - docs
- requirements: docs/requirements.txt

View File

@@ -1,3 +0,0 @@
include README.rst
include LICENSE
include requirements.txt

View File

@@ -24,7 +24,7 @@ pyspapi
Installation Installation
------------- -------------
**Requires Python 3.8 or higher** **Requires Python 3.12 or higher**
*Windows* *Windows*

View File

@@ -5,8 +5,8 @@
# from the environment for the first two. # from the environment for the first two.
SPHINXOPTS ?= SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build SPHINXBUILD ?= sphinx-build
SOURCEDIR = source SOURCEDIR = .
BUILDDIR = build BUILDDIR = _build
# Put it first so that "make" without argument is like "make help". # Put it first so that "make" without argument is like "make help".
help: help:

View File

@@ -1,25 +1,25 @@
.. currentmodule:: pyspapi .. currentmodule:: pyspapi
API Reference Справочник API
=============== ===============
The following section outlines the API of pyspapi. В следующем разделе описывается API pyspapi.
Version Info Информация о версии
--------------------- ---------------------
There are two main ways to query version information. Существует два основных способа запроса информации о версии.
.. data:: version_info .. data:: version_info
A named tuple that is similar to :obj:`py:sys.version_info`. Именованный кортеж, аналогичный :obj:`py:sys.version_info`.
Just like :obj:`py:sys.version_info` the valid values for ``releaselevel`` are Как и в :obj:`py:sys.version_info`, допустимые значения для ``releaselevel`` это
'alpha', 'beta', 'candidate' and 'final'. 'alpha', 'beta', 'candidate' и 'final'.
.. data:: __version__ .. data:: __version__
A string representation of the version. Строковое представление версии.
``pyspapi`` ``pyspapi``
----------- -----------

View File

@@ -1,59 +1,44 @@
from re import search, MULTILINE from importlib.metadata import version as pkg_version
import os import os
import sys
project = "pyspapi"
author = "deesiigneer"
copyright = "2022, deesiigneer"
project = 'pyspapi' version = pkg_version("pyspapi")
copyright = '2022, deesiigneer'
author = 'deesiigneer'
with open("../pyspapi/__init__.py") as f:
match = search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), MULTILINE)
if not match or match.group(1) is None:
raise RuntimeError("The version could not be resolved")
version = match.group(1)
# The full version, including alpha/beta/rc tags.
release = version release = version
# -- General configuration
sys.path.insert(0, os.path.abspath(".."))
extensions = [ extensions = [
'sphinx.ext.duration', "sphinx.ext.duration",
'sphinx.ext.doctest', "sphinx.ext.doctest",
'sphinx.ext.autodoc', "sphinx.ext.autodoc",
'sphinx.ext.autosummary', "sphinx.ext.autosummary",
'sphinx.ext.intersphinx', "sphinx.ext.intersphinx",
] ]
autodoc_member_order = "bysource" autosummary_generate = True
autodoc_typehinta = "none"
intersphinx_mapping = {
'python': ('https://docs.python.org/3/', None),
'sphinx': ('https://www.sphinx-doc.org/en/master/', None),
}
version_match = os.environ.get("READTHEDOCS_VERSION") version_match = os.environ.get("READTHEDOCS_VERSION")
json_url = f"https://pyspapi.readthedocs.io/ru/{version_match}/_static/switcher.json" json_url = f"https://pyspapi.readthedocs.io/ru/{version_match}/_static/switcher.json"
intersphinx_disabled_domains = ['std']
language = 'en' language = "ru"
locale_dirs = ["locale/"]
exclude_patterns = [] exclude_patterns = []
html_static_path = ["_static"] html_static_path = ["_static"]
html_theme = "pydata_sphinx_theme" html_theme = "pydata_sphinx_theme"
html_logo = "./images/logo.png" html_logo = "./images/logo.png"
html_favicon = "./images/logo.ico" html_favicon = "./images/logo.ico"
html_theme_options = { html_theme_options = {
"external_links": [ "external_links": [
{ {
"url": "https://github.com/deesiigneer/pyspapi/releases", "url": "https://github.com/deesiigneer/pyspapi/releases",
"name": "Changelog", "name": "Changelog",
},
{
"url": "https://github.com/sp-worlds/api-docs/wiki",
"name": "SPWorlds API Docs",
} }
], ],
"icon_links": [ "icon_links": [
@@ -61,24 +46,27 @@ html_theme_options = {
"name": "GitHub", "name": "GitHub",
"url": "https://github.com/deesiigneer/pyspapi", "url": "https://github.com/deesiigneer/pyspapi",
"icon": "fab fa-brands fa-github", "icon": "fab fa-brands fa-github",
"type": "fontawesome" "type": "fontawesome",
}, },
{ {
"name": "Discord", "name": "Discord",
"url": "https://discord.gg/VbyHaKRAaN", "url": "https://discord.gg/VbyHaKRAaN",
"icon": "fab fa-brands fa-discord", "icon": "fab fa-brands fa-discord",
"type": "fontawesome" "type": "fontawesome",
}, },
{ {
"name": "PyPi", "name": "PyPI",
"url": "https://pypi.org/project/pyspapi/", "url": "https://pypi.org/project/pyspapi/",
"icon": "fab fa-brands fa-python", "icon": "fab fa-brands fa-python",
"type": "fontawesome" "type": "fontawesome",
} },
], ],
"header_links_before_dropdown": 4, "header_links_before_dropdown": 4,
"show_toc_level": 1, "show_toc_level": 1,
"navbar_start": ["navbar-logo"], "navbar_start": ["navbar-logo"],
"navigation_with_keys": True, "navigation_with_keys": True,
"switcher": {
"json_url": json_url,
"version_match": version_match,
},
} }
html_css_files = ["custom.css"]

View File

@@ -1,32 +1,31 @@
:theme_html_remove_secondary_sidebar: :theme_html_remove_secondary_sidebar:
Welcome to pyspapi Добро пожаловать в pyspapi
=================== ===========================
API wrapper for SP servers written in Python. Обертка API для SPWorlds серверов, написанная на Python.
Getting started Начало работы
--------------- ---------------
Is this your first time using the library? This is the place to get started! Вы впервые используете библиотеку? Это место, с которого нужно начать!
- **First steps:** :ref:`Quickstart <quickstart>` - **Первые шаги:** :ref:`Быстрый старт <quickstart>`
- **Examples:** Many examples are available in the `examples directory <https://github.com/deesiigneer/pyspapi/tree/main/examples/>`_. - **Примеры:** Много примеров доступно в `папке примеров <https://github.com/deesiigneer/pyspapi/tree/main/examples/>`_.
Getting help Получение помощи
------------ ------------------
If you're having trouble with something, these resources might help. Если у вас есть проблемы с чем-то, эти ресурсы могут помочь.
- Ask questions in `Discord <https://discord.gg/VbyHaKRAaN>`_ server. - Задавайте вопросы на сервере `Discord <https://discord.gg/VbyHaKRAaN>`_.
- If you're looking for something specific, try the :ref:`searching <search>`. - Если вы ищете что-то конкретное, попробуйте :ref:`поиск <search>`.
- Report bugs in the `issue tracker <https://github.com/deesiigneer/pyspapi/issues>`_. - Сообщайте об ошибках в `трекер проблем <https://github.com/deesiigneer/pyspapi/issues>`_.
- Ask in `GitHub discussions page <https://github.com/deesiigneer/pyspapi/discussions>`_.
Manuals Руководства
------- -----------
These pages go into great detail about everything the API can do. Эти страницы подробно описывают все, что может сделать API.
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1

View File

@@ -4,17 +4,17 @@
.. currentmodule:: pyspapi .. currentmodule:: pyspapi
Quickstart Быстрый старт
========== ==============
This page gives a brief introduction to the library. На этой странице дается краткое введение в библиотеку.
Checking balance Проверка баланса
---------------- -----------------
Let's output the amount of money remaining in the card account to the console. Выведем количество денег, оставшихся на счету карты, на консоль.
It looks something like this: Это выглядит примерно так:
.. code-block:: python .. code-block:: python
@@ -30,7 +30,6 @@ It looks something like this:
loop = get_event_loop() loop = get_event_loop()
loop.run_until_complete(main()) loop.run_until_complete(main())
Make sure not to name it ``pyspapi`` as that'll conflict with the library. Убедитесь, что вы не называете его ``pyspapi``, так как это вызовет конфликт с библиотекой.
Вы можете найти больше примеров в `папке примеров <https://github.com/deesiigneer/pyspapi/tree/main/examples/>`_ на GitHub.
You can find more examples in the `examples directory <https://github.com/deesiigneer/pyspapi/tree/main/examples/>`_ on GitHub.

View File

@@ -1,7 +1,8 @@
import asyncio
from pyspapi import SPAPI from pyspapi import SPAPI
from asyncio import get_event_loop
spapi = SPAPI(card_id='CARD_ID', token='TOKEN') spapi = SPAPI(card_id="CARD_ID", token="TOKEN")
async def main(): async def main():
@@ -11,5 +12,4 @@ async def main():
print(card.name, card.number) print(card.name, card.number)
loop = get_event_loop() asyncio.run(main())
loop.run_until_complete(main())

View File

@@ -1,12 +1,12 @@
import asyncio
from pyspapi import SPAPI from pyspapi import SPAPI
from asyncio import get_event_loop
spapi = SPAPI(card_id='CARD_ID', token='TOKEN') spapi = SPAPI(card_id="CARD_ID", token="TOKEN")
async def main(): async def main():
me = await spapi.me me = await spapi.me
print(me) print(me)
loop = get_event_loop()
loop.run_until_complete(main()) asyncio.run(main())

View File

@@ -1,21 +1,25 @@
import asyncio
from pyspapi import SPAPI from pyspapi import SPAPI
from pyspapi.types import Item from pyspapi.types import Item
from asyncio import get_event_loop
spapi = SPAPI(card_id='CARD_ID', token='TOKEN') spapi = SPAPI(card_id="CARD_ID", token="TOKEN")
items = [Item('first item', 1, 2, 'first item comment').to_json(),
Item('second item', 3, 4, 'second item comment').to_json()] items = [
Item("first item", 1, 2, "first item comment").to_json(),
Item("second item", 3, 4, "second item comment").to_json(),
]
async def main(): async def main():
print(await spapi.create_payment(items=items, print(
redirect_url='https://www.google.com/', await spapi.create_payment(
webhook_url='https://www.google.com/', items=items,
data='some-data' redirect_url="https://www.google.com/",
webhook_url="https://www.google.com/",
data="some-data",
) )
) )
loop = get_event_loop() asyncio.run(main())
loop.run_until_complete(main())

View File

@@ -1,15 +1,15 @@
from asyncio import get_event_loop import asyncio
from pyspapi import SPAPI 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(
amount=1, receiver="20199", amount=1, comment="test"
comment='test') )
print(new_balance) print(new_balance)
loop = get_event_loop()
loop.run_until_complete(main()) asyncio.run(main())

View File

@@ -1,13 +1,13 @@
import asyncio
from pyspapi import SPAPI from pyspapi import SPAPI
from asyncio import get_event_loop
spapi = SPAPI(card_id='CARD_ID', token='TOKEN') spapi = SPAPI(card_id="CARD_ID", token="TOKEN")
# print(spapi.webhook_verify(data='webhook_data', header='webhook_header')) # print(spapi.webhook_verify(data='webhook_data', header='webhook_header'))
async def main(): async def main():
print(await spapi.update_webhook(url='https://example.com/webhook')) print(await spapi.update_webhook(url="https://example.com/webhook"))
loop = get_event_loop()
loop.run_until_complete(main()) asyncio.run(main())

1826
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

32
pyproject.toml Normal file
View File

@@ -0,0 +1,32 @@
[project]
name = "pyspapi"
version = "3.3.2"
description = "API wrapper for SP servers written in Python."
readme = "README.rst"
license = { text = "MIT" }
authors = [{ name = "deesiigneer", email = "xdeesiigneerx@gmail.com" }]
requires-python = ">=3.12,<3.15"
dependencies = ["aiohttp>=3.9,<4.0"]
[project.urls]
Homepage = "https://pyspapi.deesiigneer.ru"
Documentation = "https://pyspapi.readthedocs.org"
Repository = "https://github.com/deesiigneer/pyspapi"
"Issue Tracker" = "https://github.com/deesiigneer/pyspapi/issues"
Discord = "https://discord.com/invite/VbyHaKRAaN"
[project.optional-dependencies]
docs = [
"sphinx>=7,<9",
"sphinx-autobuild>=2025.8.25,<2026.0.0",
"pydata-sphinx-theme>=0.16.1,<0.17.0",
]
dev = ["ruff>=0.14,<0.15", "toml-sort>=0.24,<0.25"]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
package-mode = true

View File

@@ -1,9 +1,53 @@
from .api import * """
from .spworlds import * SPWorlds API Wrapper
from .types import * ~~~~~~~~~~~~~~~~~~~
__author__ = 'deesiigneer' High-level client for interacting with the SPWorlds API.
__url__ = 'https://github.com/deesiigneer/pyspapi'
__description__ = 'API wrapper for SP servers written in Python.' :copyright: (c) 2022-present deesiigneer
__license__ = 'MIT' :license: MIT, see LICENSE for more details.
__version__ = "3.2.0" """
import importlib.metadata
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"
__description__: str = "API wrapper for SP servers written in Python."
__license__: str = "MIT"
__url__: str = "https://github.com/deesiigneer/pyspapi"
__copyright__: str = "2022-present deesiigneer"
__version__: str = importlib.metadata.version("pyspapi")

View File

@@ -1,2 +1,3 @@
from .api import APISession from .api import APISession
__all__ = ["APISession"]

View File

@@ -1,24 +1,46 @@
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):
def __init__(
def __init__(self, card_id: str, self,
card_id: str,
token: str, token: str,
timeout: int = 5, timeout: int = 5,
sleep_time: float = 0.2, sleep_time: float = 0.2,
retries: int = 0, retries: int = 0,
raise_exception: bool = False): raise_exception: bool = False,
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
@@ -26,53 +48,296 @@ class APISession(object):
self.__retries = retries self.__retries = retries
self.__timeout = timeout self.__timeout = timeout
self.__raise_exception = raise_exception self.__raise_exception = raise_exception
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):
try:
if not self.session:
self.session = aiohttp.ClientSession( self.session = aiohttp.ClientSession(
json_serialize=json.dumps, json_serialize=json.dumps,
timeout=aiohttp.ClientTimeout(total=self.__timeout)) 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):
if self._session_owner and self.session:
try:
await self.session.close() await self.session.close()
log.debug("[pyspapi] Session closed")
except Exception as e:
log.error(f"[pyspapi] Error closing session: {e}")
self.session = None self.session = None
async def request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Any: return False
url = self.__url + endpoint
headers = { def _get_auth_header(self) -> str:
'Authorization': f"Bearer {str(b64encode(str(f'{self.__id}:{self.__token}').encode('utf-8')), 'utf-8')}", credentials = f"{self.__id}:{self.__token}"
'User-Agent': 'https://github.com/deesiigneer/pyspapi', 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", "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(
self, method: str, endpoint: str, data: Optional[Dict] = None
) -> Any:
url = self.__url + endpoint
headers = self._get_headers()
attempt = 0 attempt = 0
while True: while True:
attempt += 1 attempt += 1
if attempt > 1: if attempt > 1:
log.warning(f'[pyspapi] Repeat attempt {attempt}: {method.upper()} {url}') log.warning(
f"[pyspapi] Retry attempt {attempt}/{self.__retries + 1}: {method.upper()} {endpoint}"
)
try: try:
async with self.session.request(method, url, json=data, headers=headers) as resp: async with self.session.request(
method, url, json=data, headers=headers
) 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
try:
return await resp.json() return await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError) as e: except json.JSONDecodeError as e:
log.exception(f"[pyspapi] Connection error: {e}") log.error(
if attempt > self.__retries: f"[pyspapi] Failed to parse JSON response: {e} | Status: {resp.status}"
)
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 return None
await asyncio.sleep(self.__sleep_timeout)
async def get(self, endpoint: str) -> Any: async def get(self, endpoint: str) -> Any:
async with self: async with self:

View File

@@ -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"},
)

View File

@@ -1,14 +1,15 @@
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 .types import User from pyspapi.exceptions import InsufficientBalanceError
from .types.me import Account from pyspapi.types import User
from .types.payment import Item from pyspapi.types.me import Account
from pyspapi.types.payment import Item
__all__ = ['SPAPI'] __all__ = ["SPAPI"]
class SPAPI(APISession): class SPAPI(APISession):
@@ -19,12 +20,16 @@ class SPAPI(APISession):
информацией о пользователе, транзакциями и платежами, а также верификацией вебхуков. информацией о пользователе, транзакциями и платежами, а также верификацией вебхуков.
""" """
def __init__(self, card_id: str, def __init__(
self,
card_id: str,
token: str, token: str,
timeout: int = 5, timeout: int = 5,
sleep_time: float = 0.2, sleep_time: float = 0.2,
retries: int = 0, retries: int = 0,
raise_exception: bool = False): raise_exception: bool = False,
proxy: str = None,
):
""" """
Инициализирует объект SPAPI. Инициализирует объект SPAPI.
@@ -40,8 +45,12 @@ 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: Прокси для подключения к API. По умолчанию None.
:type proxy: str
""" """
super().__init__(card_id, token, timeout, sleep_time, retries, raise_exception) super().__init__(
card_id, token, timeout, sleep_time, retries, raise_exception, proxy
)
self.__card_id = card_id self.__card_id = card_id
self.__token = token self.__token = token
@@ -59,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]:
@@ -69,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]:
@@ -79,16 +104,25 @@ class SPAPI(APISession):
:return: Объект Account, представляющий аккаунт текущего пользователя. :return: Объект Account, представляющий аккаунт текущего пользователя.
:rtype: :class:`Account` :rtype: :class:`Account`
""" """
me = await super().get('accounts/me') try:
me = await super().get("accounts/me")
if me is None:
return None
return Account( return Account(
account_id=me['id'], account_id=me.get("id"),
username=me['username'], username=me.get("username"),
minecraftuuid=me['minecraftUUID'], minecraftuuid=me.get("minecraftUUID"),
status=me['status'], status=me.get("status"),
roles=me['roles'], roles=me.get("roles", []),
cities=me['cities'], cities=me.get("cities", []),
cards=me['cards'], cards=me.get("cards", []),
created_at=me['createdAt']) 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]:
""" """
@@ -100,11 +134,27 @@ 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:
cards = await super().get(f"accounts/{user['username']}/cards") raise ValueError("discord_id must be a non-empty integer")
return User(user['username'], user['uuid'], cards)
async def create_transaction(self, receiver: str, amount: int, comment: str) -> Optional[int]: try:
user = await super().get(f"users/{discord_id}")
if user is None:
return None
cards = await super().get(f"accounts/{user['username']}/cards")
if cards is None:
cards = []
return User(user["username"], user["uuid"], cards)
except (KeyError, TypeError) as e:
log = __import__("logging").getLogger("pyspapi")
log.error(f"Failed to parse user response: {e}")
return None
async def create_transaction(
self, receiver: str, amount: int, comment: str
) -> Optional[int]:
""" """
Создает транзакцию. Создает транзакцию.
@@ -118,15 +168,31 @@ class SPAPI(APISession):
:return: Баланс после транзакции. :return: Баланс после транзакции.
:rtype: int :rtype: int
""" """
data = { if not receiver:
'receiver': receiver, raise ValueError("receiver must be a non-empty string")
'amount': amount, if not isinstance(amount, int) or amount <= 0:
'comment': comment 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)
async def create_payment(self, webhook_url: str, redirect_url: str, data: str, items: list[Item]) -> Optional[str]: 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(
self, webhook_url: str, redirect_url: str, data: str, items: list[Item]
) -> Optional[str]:
""" """
Создает платеж. Создает платеж.
@@ -141,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
try:
payload = {
"items": items,
"redirectUrl": redirect_url,
"webhookUrl": webhook_url,
"data": data,
} }
return str((await super().post('payments', data))['url']) 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]:
""" """
@@ -157,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:
""" """
@@ -172,8 +265,8 @@ class SPAPI(APISession):
:return: True, если заголовок из вебхука достоверен, иначе False. :return: True, если заголовок из вебхука достоверен, иначе False.
:rtype: bool :rtype: bool
""" """
hmac_data = b64encode(new(self.__token.encode('utf-8'), data, sha256).digest()) hmac_data = b64encode(new(self.__token.encode("utf-8"), data, sha256).digest())
return compare_digest(hmac_data, header.encode('utf-8')) return compare_digest(hmac_data, header.encode("utf-8"))
def to_dict(self) -> dict: def to_dict(self) -> dict:
""" """

View File

@@ -1,3 +1,5 @@
from .payment import Item from pyspapi.types.me import Account, Card, City
from .users import User from pyspapi.types.payment import Item
from .me import Account from pyspapi.types.users import User, UserCards
__all__ = ["Account", "Card", "City", "Item", "User", "UserCards"]

View File

@@ -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})>"
)

View File

@@ -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,
} }

View File

@@ -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__
)

View File

@@ -1 +0,0 @@
aiohttp>=3.8.0

View File

@@ -1,43 +0,0 @@
import re
from setuptools import setup, find_packages
requirements = []
with open("requirements.txt") as f:
requirements = f.read().splitlines()
version = ""
with open("pyspapi/__init__.py") as f:
match = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE)
if match is None or match.group(1) is None:
raise RuntimeError('Version is not set')
version = match.group(1)
if not version:
raise RuntimeError("Version is not set")
readme = ""
with open("README.rst") as f:
readme = f.read()
setup(
name='pyspapi',
license='MIT',
author='deesiigneer',
version=version,
url='https://github.com/deesiigneer/pyspapi',
project_urls={
"Documentation": "https://pyspapi.readthedocs.io/ru/latest/",
"GitHub": "https://github.com/deesiigneer/pyspapi",
"Discord": "https://discord.com/invite/VbyHaKRAaN"
},
description='API wrapper for SP servers written in Python',
long_description=readme,
long_description_content_type='text/x-rst',
packages=find_packages(),
package_data={'pyspapi': ['types/*', 'api/*']}, # Включаем дополнительные файлы и папки
include_package_data=True,
install_requires=requirements,
python_requires='>=3.8.0',
)