28 Commits
2.1.1 ... 3.3.1

Author SHA1 Message Date
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
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
deesiigneer
14166cf519 version update 2024-04-18 22:38:23 +05:00
deesiigneer
ab60b4f104 module load fix 2024-04-18 22:36:16 +05:00
Aleksey
b738db3252 Merge pull request #15 from deesiigneer/v3-(asyncio)
new version
2024-04-18 21:49:27 +05:00
deesiigneer
b3d56a6059 new version 2024-04-18 21:45:13 +05:00
18c1ff1daf Update conf.py 2024-04-18 18:34:34 +05:00
Aleksey
eff14052fd Merge pull request #11 from stepan-zubkov/fix-readme
Fix Linux/Mac OS installation in README
2023-01-09 12:24:16 +03:00
Степан Зубков
3f935a060b Update README.rst 2023-01-09 10:48:49 +03:00
Aleksey
3ecf1fff8a add v3-asyncio 2022-08-16 08:38:27 +03:00
deesiigneer
74a46277f8 version dropdown fix 2022-08-16 00:12:04 +03:00
Aleksey
63ee509067 update logo 2022-07-23 20:05:52 +03:00
deesiigneer
4ecafef192 upload repo banner 2022-07-23 20:04:52 +03:00
deesiigneer
d44199c4cc upload repo banner 2022-07-23 20:00:37 +03:00
deesiigneer
056ebce615 logo fix (incorrect gradient placement, lol) 2022-07-23 19:35:22 +03:00
deesiigneer
b00b2b76cd docs upload 2022-07-23 19:33:28 +03:00
deesiigneer
da86771e3c version bump 2022-07-20 22:21:33 +03:00
deesiigneer
479a02b95f transactions fix #9 2022-07-20 22:20:22 +03:00
49 changed files with 3376 additions and 469 deletions

View File

@@ -17,22 +17,28 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ 3.9 ]
python-version: [3.12]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install twine
- name: Compile package
run: |
python3 setup.py sdist
- name: Publish package
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
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
run: poetry install --no-interaction
- name: Build package
run: poetry build
- name: Publish package
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}

18
.readthedocs.yml Normal file
View File

@@ -0,0 +1,18 @@
version: 2
build:
os: ubuntu-lts-latest
tools:
python: '3.12'
sphinx:
configuration: docs/conf.py
builder: html
python:
install:
- method: pip
path: .
extra_requirements:
- docs

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 Aleksey
Copyright (c) 2022 deesiigneer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

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

View File

@@ -1,4 +1,4 @@
.. image:: https://i.imgur.com/melhWhU.png
.. image:: https://raw.githubusercontent.com/deesiigneer/pyspapi/main/assets/repo-banner.png
:alt: pyspapi
.. image:: https://img.shields.io/discord/850091193190973472?color=5865F2&label=discord
@@ -37,7 +37,7 @@ Installation
.. code:: sh
sudo apt pip3 install pyspapi
pip3 install pyspapi
Quick example
--------------
@@ -46,9 +46,17 @@ Checking the balance
~~~~~~~~~~~~~~~~~~~~~
.. code:: py
import pyspapi
from pyspapi import SPAPI
from asyncio import get_event_loop
print(pyspapi.SPAPI(card_id='card_id', token='token').balance)
spapi = SPAPI(card_id='CARD_ID', token='TOKEN')
async def main():
print(await spapi.balance)
loop = get_event_loop()
loop.run_until_complete(main())
More examples can be found in the `examples <https://github.com/deesiigneer/pyspapi/tree/main/examples>`_

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
assets/repo-banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

20
docs/Makefile Normal file
View File

@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

BIN
docs/_static/404.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

19
docs/_static/custom.css vendored Normal file
View File

@@ -0,0 +1,19 @@
/* Background of stable should be green */
.version-switcher__container a[data-version-name*="stable"] {
position: relative;
}
.version-switcher__container a[data-version-name*="stable"] span {
color: var(--pst-color-success);
}
.version-switcher__container a[data-version-name*="stable"] span:before {
content: "";
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background-color: var(--pst-color-success);
opacity: 0.1;
}

17
docs/_static/switcher.json vendored Normal file
View File

@@ -0,0 +1,17 @@
[
{
"name": "latest",
"version": "latest",
"url": "https://pyspapi.readthedocs.io/ru/latest/"
},
{
"name": "stable",
"version": "stable",
"url": "https://pyspapi.readthedocs.io/ru/stable/"
},
{
"name": "v3-asyncio",
"version": "v3-asyncio",
"url": "https://pyspapi.readthedocs.io/ru/v3-asyncio/"
}
]

30
docs/api.rst Normal file
View File

@@ -0,0 +1,30 @@
.. currentmodule:: pyspapi
Справочник API
===============
В следующем разделе описывается API pyspapi.
Информация о версии
---------------------
Существует два основных способа запроса информации о версии.
.. data:: version_info
Именованный кортеж, аналогичный :obj:`py:sys.version_info`.
Как и в :obj:`py:sys.version_info`, допустимые значения для ``releaselevel`` это
'alpha', 'beta', 'candidate' и 'final'.
.. data:: __version__
Строковое представление версии.
``pyspapi``
-----------
``SPAPI``
~~~~~~~~~
.. autoclass:: SPAPI
:members:

72
docs/conf.py Normal file
View File

@@ -0,0 +1,72 @@
from importlib.metadata import version as pkg_version
import os
project = "pyspapi"
author = "deesiigneer"
copyright = "2022, deesiigneer"
version = pkg_version("pyspapi")
release = version
extensions = [
"sphinx.ext.duration",
"sphinx.ext.doctest",
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.intersphinx",
]
autosummary_generate = True
version_match = os.environ.get("READTHEDOCS_VERSION")
json_url = f"https://pyspapi.readthedocs.io/ru/{version_match}/_static/switcher.json"
language = "ru"
exclude_patterns = []
html_static_path = ["_static"]
html_theme = "pydata_sphinx_theme"
html_logo = "./images/logo.png"
html_favicon = "./images/logo.ico"
html_theme_options = {
"external_links": [
{
"url": "https://github.com/deesiigneer/pyspapi/releases",
"name": "Changelog",
},
{
"url": "https://github.com/sp-worlds/api-docs/wiki",
"name": "SPWorlds API Docs",
}
],
"icon_links": [
{
"name": "GitHub",
"url": "https://github.com/deesiigneer/pyspapi",
"icon": "fab fa-brands fa-github",
"type": "fontawesome",
},
{
"name": "Discord",
"url": "https://discord.gg/VbyHaKRAaN",
"icon": "fab fa-brands fa-discord",
"type": "fontawesome",
},
{
"name": "PyPI",
"url": "https://pypi.org/project/pyspapi/",
"icon": "fab fa-brands fa-python",
"type": "fontawesome",
},
],
"header_links_before_dropdown": 4,
"show_toc_level": 1,
"navbar_start": ["navbar-logo"],
"navigation_with_keys": True,
"switcher": {
"json_url": json_url,
"version_match": version_match,
},
}

BIN
docs/images/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
docs/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

34
docs/index.rst Normal file
View File

@@ -0,0 +1,34 @@
:theme_html_remove_secondary_sidebar:
Добро пожаловать в pyspapi
===========================
Обертка API для SPWorlds серверов, написанная на Python.
Начало работы
---------------
Вы впервые используете библиотеку? Это место, с которого нужно начать!
- **Первые шаги:** :ref:`Быстрый старт <quickstart>`
- **Примеры:** Много примеров доступно в `папке примеров <https://github.com/deesiigneer/pyspapi/tree/main/examples/>`_.
Получение помощи
------------------
Если у вас есть проблемы с чем-то, эти ресурсы могут помочь.
- Задавайте вопросы на сервере `Discord <https://discord.gg/VbyHaKRAaN>`_.
- Если вы ищете что-то конкретное, попробуйте :ref:`поиск <search>`.
- Сообщайте об ошибках в `трекер проблем <https://github.com/deesiigneer/pyspapi/issues>`_.
Руководства
-----------
Эти страницы подробно описывают все, что может сделать API.
.. toctree::
:maxdepth: 1
api
quickstart

35
docs/make.bat Normal file
View File

@@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

35
docs/quickstart.rst Normal file
View File

@@ -0,0 +1,35 @@
:orphan:
.. _quickstart:
.. currentmodule:: pyspapi
Быстрый старт
==============
На этой странице дается краткое введение в библиотеку.
Проверка баланса
-----------------
Выведем количество денег, оставшихся на счету карты, на консоль.
Это выглядит примерно так:
.. code-block:: python
from pyspapi import SPAPI
from asyncio import get_event_loop
spapi = SPAPI(card_id='CARD_ID', token='TOKEN')
async def main():
print(await spapi.balance)
loop = get_event_loop()
loop.run_until_complete(main())
Убедитесь, что вы не называете его ``pyspapi``, так как это вызовет конфликт с библиотекой.
Вы можете найти больше примеров в `папке примеров <https://github.com/deesiigneer/pyspapi/tree/main/examples/>`_ на GitHub.

1
docs/requirements.txt Normal file
View File

@@ -0,0 +1 @@
pydata_sphinx_theme

15
examples/get_user.py Normal file
View File

@@ -0,0 +1,15 @@
import asyncio
from pyspapi import SPAPI
spapi = SPAPI(card_id="CARD_ID", token="TOKEN")
async def main():
user = await spapi.get_user(262632724928397312)
print(user.username, user.uuid)
for card in user.cards:
print(card.name, card.number)
asyncio.run(main())

12
examples/me.py Normal file
View File

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

View File

@@ -1,3 +0,0 @@
import pyspapi
print(pyspapi.MojangAPI.get_name_history(uuid='63ed47877aa3470fbfc46c5356c3d797'))

View File

@@ -1,20 +0,0 @@
import pyspapi
print(pyspapi.MojangAPI.get_profile(uuid='63ed47877aa3470fbfc46c5356c3d797'))
print(pyspapi.MojangAPI.get_profile(uuid='63ed47877aa3470fbfc46c5356c3d797').timestamp)
print(pyspapi.MojangAPI.get_profile(uuid='63ed47877aa3470fbfc46c5356c3d797').id)
print(pyspapi.MojangAPI.get_profile(uuid='63ed47877aa3470fbfc46c5356c3d797').name)
print(pyspapi.MojangAPI.get_profile(uuid='63ed47877aa3470fbfc46c5356c3d797').is_legacy_profile)
print(pyspapi.MojangAPI.get_profile(uuid='63ed47877aa3470fbfc46c5356c3d797').cape_url)
print(pyspapi.MojangAPI.get_profile(uuid='63ed47877aa3470fbfc46c5356c3d797').skin_url)
print(pyspapi.MojangAPI.get_profile(uuid='63ed47877aa3470fbfc46c5356c3d797').skin_model)
print(pyspapi.MojangAPI.get_profile(uuid='63ed47877aa3470fbfc46c5356c3d797').skin)

View File

@@ -1,3 +0,0 @@
import pyspapi
print(pyspapi.MojangAPI.get_username(uuid='63ed47877aa3470fbfc46c5356c3d797'))

View File

@@ -1,5 +0,0 @@
import pyspapi
print(pyspapi.MojangAPI.get_uuid(username='deesiigneer'))
print(pyspapi.MojangAPI.get_uuids(['deesiigneer', '5opka', 'OsterMiner']))

25
examples/payments.py Normal file
View File

@@ -0,0 +1,25 @@
import asyncio
from pyspapi import SPAPI
from pyspapi.types import Item
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(),
]
async def main():
print(
await spapi.create_payment(
items=items,
redirect_url="https://www.google.com/",
webhook_url="https://www.google.com/",
data="some-data",
)
)
asyncio.run(main())

View File

@@ -1,5 +0,0 @@
import pyspapi
spapi = pyspapi.SPAPI(card_id='card_id', token='token')
print(spapi.check_users_access([262632724928397312, 264329096920563714]))

View File

@@ -1,11 +0,0 @@
import pyspapi
spapi = pyspapi.SPAPI(card_id='card_id', token='token')
print(spapi.get_user(262632724928397312))
print(spapi.get_user(262632724928397312).username)
print(spapi.get_user(262632724928397312).access)
print(spapi.get_users([262632724928397312, 264329096920563714]))

View File

@@ -1,10 +0,0 @@
import pyspapi
spapi = pyspapi.SPAPI(card_id='card_id', token='token')
print(spapi.payment(amount=1,
redirect_url='https://www.google.com/',
webhook_url='https://www.google.com/',
data='some-data'
)
)

View File

@@ -1,9 +0,0 @@
import pyspapi
spapi = pyspapi.SPAPI(card_id='CARD_ID', token='TOKEN')
print(spapi.transaction(receiver=12345,
amount=1,
comment="test"
)
)

View File

@@ -1,5 +0,0 @@
import pyspapi
spapi = pyspapi.SPAPI(card_id='your_card_id', token='your_token')
print(spapi.webhook_verify(data='webhook_data', header='webhook_header'))

15
examples/transaction.py Normal file
View File

@@ -0,0 +1,15 @@
import asyncio
from pyspapi import SPAPI
spapi = SPAPI(card_id="CARD_ID", token="TOKEN")
async def main():
new_balance = await spapi.create_transaction(
receiver="20199", amount=1, comment="test"
)
print(new_balance)
asyncio.run(main())

13
examples/webhook.py Normal file
View File

@@ -0,0 +1,13 @@
import asyncio
from pyspapi import SPAPI
spapi = SPAPI(card_id="CARD_ID", token="TOKEN")
# print(spapi.webhook_verify(data='webhook_data', header='webhook_header'))
async def main():
print(await spapi.update_webhook(url="https://example.com/webhook"))
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.1"
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,3 +1,53 @@
from .api import *
"""
SPWorlds API Wrapper
~~~~~~~~~~~~~~~~~~~
__version__ = "2.1.1"
High-level client for interacting with the SPWorlds API.
:copyright: (c) 2022-present deesiigneer
:license: MIT, see LICENSE for more details.
"""
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,268 +0,0 @@
import json.decoder
from sys import version_info
import ast
from base64 import b64encode, b64decode
from hmac import new, compare_digest
from hashlib import sha256
from logging import getLogger
from requests import get, post, Response
from typing import Any, Dict, List, Optional
from .models import MojangUserProfile, SPUserProfile
import warnings
log = getLogger('pyspapi')
class _Error(Exception):
"""
"""
def __init__(self, message: Optional[str] = None):
self.message = message if message else self.__class__.__doc__
super().__init__(self.message)
class SPAPI:
"""
class SPAPI
"""
_SPWORLDS_DOMAIN_ = "https://spworlds.ru/api/public"
def __init__(self, card_id: str, token: str):
self.__id = card_id
self.__token = token
self.__header = {
'Authorization': f"Bearer {str(b64encode(str(f'{self.__id}:{self.__token}').encode('utf-8')), 'utf-8')}",
'User-Agent': f'pyspapi (https://github.com/deesiigneer/pyspapi) '
f'Python {version_info.major}.{version_info.minor}.{version_info.micro}'
}
self.balance = self.__check_balance()
def __make_request(self, method: str, path: str, data: Optional[dict]) -> Optional[Response]:
if method == 'GET':
response = get(self._SPWORLDS_DOMAIN_ + path, headers=self.__header)
return response
elif method == 'POST':
response = post(self._SPWORLDS_DOMAIN_ + path, headers=self.__header, json=data)
return response
def get_user(self, user_id: int) -> Optional[SPUserProfile]:
"""
Получение информации об игроке SP \n
:param user_id: ID пользователя в Discord.
:return: Class SPUserProfile.
"""
response = self.__make_request('GET', f'/users/{str(user_id)}', data=None)
if not response.ok:
return None
try:
username = response.json()['username']
return SPUserProfile(access=True if username is not None else False, username=username)
except json.decoder.JSONDecodeError:
return
def get_users(self, user_ids: List[int]) -> List[str]:
"""
Получение никнеймов игроков в майнкрафте. **Не более 10**\n
:param user_ids: List[int] ID пользователей в Discord.
:return: List[str] который содержит майнкрафт никнеймы игроков в том же порядке, который был задан,
None если пользователь не найден или нет проходки.
"""
if len(user_ids) > 10:
user_ids = user_ids[:10]
warnings.warn('user_ids more than 10. Reduced to 10')
nicknames_list = []
for user_id in user_ids:
nicknames_list.append(self.get_user(user_id).username
if self.get_user(user_id) is not None else None)
return nicknames_list
def check_users_access(self, user_ids: List[int]) -> List[bool]:
"""
Проверка наличия проходки у списка пользователей Discord. **Не более 10**\n
:param user_ids: Список(List[int]) содержащий ID пользователей в Discord.
:return: Список(List[bool]) в том же порядке, который был задан.True - проходка имеется, иначе False.
"""
if len(user_ids) > 10:
user_ids = user_ids[:10]
warnings.warn('user_ids more than 10. Reduced to 10')
ids_list = []
for user_id in user_ids:
ids_list.append(self.get_user(user_id).access
if self.get_user(user_id) is not None else None)
return ids_list
def payment(self, amount: int, redirect_url: str, webhook_url: str, data: str) -> Optional[str]:
"""
Создание ссылки для оплаты.\n
:param amount: Стоимость покупки в АРах.
:param redirect_url: URL страницы, на которую попадет пользователь после оплаты.
:param webhook_url: URL, куда наш сервер направит запрос, чтобы оповестить ваш сервер об успешной оплате.
:param data: Строка до 100 символов, сюда можно поместить любые полезные данных.
:return: Ссылку на страницу оплаты, на которую стоит перенаправить пользователя.
"""
if len(data) > 100:
raise _Error('В data больше 100 символов')
body = {
'amount': amount,
'redirectUrl': redirect_url,
'webhookUrl': webhook_url,
'data': data
}
response = self.__make_request('POST', '/payment', data=body)
if not response.ok:
return None
try:
return response.json()['url']
except json.decoder.JSONDecodeError:
return None
def webhook_verify(self, data: str, header) -> bool:
"""
Проверяет достоверность webhook'а. \n
:param data: data из webhook.
:param header: header X-Body-Hash из webhook.
:return: True если header из webhook'а достоверен, иначе False
"""
hmac_data = b64encode(new(self.__token.encode('utf-8'), data, sha256).digest())
return compare_digest(hmac_data, header.encode('utf-8'))
def transaction(self, receiver: int, amount: int, comment: str) -> Optional[str]:
"""
Перевод АР на карту. \n
:param receiver: Номер карты получателя.
:param amount: Количество АР для перевода.
:param comment: Комментарий для перевода.
:return: True если перевод успешен, иначе False.
"""
body = {
'receiver': receiver,
'amount': amount,
'comment': comment
}
response = self.__make_request('POST', 'transactions', data=body)
if not response.ok:
return None
try:
return 'Success' if response.status_code == 200 else 'Fail'
except json.decoder.JSONDecodeError:
return None
def __check_balance(self) -> Optional[int]:
"""
Проверка баланса карты \n
:return: Количество АР на карте.
"""
response = self.__make_request('GET', '/card', None)
if not response.ok:
return None
try:
return response.json()['balance']
except json.decoder.JSONDecodeError:
return None
class MojangAPI:
"""
class MojangAPI
"""
_API_DOMAIN_ = "https://api.mojang.com"
_SESSIONSERVER_DOMAIN_ = "https://sessionserver.mojang.com"
@classmethod
def __make_request(cls, server: str, method: str, path: str, data=Optional[dict]) -> Optional[Response]:
if server == 'API':
if method == 'GET':
return get(cls._API_DOMAIN_ + path)
elif method == 'POST':
return post(cls._API_DOMAIN_ + path, json=data)
elif server == 'SESSION':
if method == 'GET':
return get(cls._SESSIONSERVER_DOMAIN_ + path)
@classmethod
def get_uuid(cls, username: str) -> Optional[str]:
"""
Получить UUID игрока Minecraft.\n
:param username: str никнейм игрока Minecraft.
:return: Optional[str] UUID игрока Minecraft.
"""
response = cls.__make_request('API', 'GET', f'/users/profiles/minecraft/{username}')
if not response.ok:
return None
try:
return response.json()['id']
except json.decoder.JSONDecodeError:
return None
@classmethod
def get_uuids(cls, names: List[str]) -> Dict[str, str]:
"""
Получить UUID's игроков Minecraft.\n
:param names: List[str] Список с никнеймами игроков Minecraft.
:return: Dict[str, str] UUID игрока Minecraft.
"""
if len(names) > 10:
names = names[:10]
response = cls.__make_request('API', 'POST', '/profiles/minecraft', data=names).json()
if not isinstance(response, list):
if response.get('error'):
raise ValueError(response['errorMessage'])
else:
raise _Error(response)
return {uuids['name']: uuids['id'] for uuids in response}
@classmethod
def get_username(cls, uuid: str) -> Optional[str]:
"""
Получить никнейм игрока.\n
:param uuid: UUID игрока Minecraft.
:return: Optional[str] в виде никнейма игрока Minecraft.
"""
response = cls.__make_request('SESSION', 'GET', f'/session/minecraft/profile/{uuid}', None)
if not response.ok:
return None
try:
return response.json()["name"]
except json.decoder.JSONDecodeError:
return None
@classmethod
def get_profile(cls, uuid: str) -> Optional[MojangUserProfile]:
"""
Профиль игрока Minecraft.\n
:param uuid: UUID игрока Minecraft.
:return: Class MojangUserProfile
"""
response = cls.__make_request('SESSION', 'GET', f'/session/minecraft/profile/{uuid}')
if not response.ok:
return None
try:
value = response.json()["properties"][0]["value"]
except (KeyError, json.decoder.JSONDecodeError):
return None
user_profile = ast.literal_eval(b64decode(value).decode())
return MojangUserProfile(user_profile)
@classmethod
def get_name_history(cls, uuid: str) -> List[Dict[str, Any]]:
"""
История никнеймов в Minecraft.\n
:param uuid: UUID игрока Minecraft.
:return: List[Dict[str, Any]] который содержит name и changed_to_at
"""
requests = cls.__make_request('API', 'GET', f"/user/profiles/{uuid}/names")
name_history = requests.json()
name_data = []
for data in name_history:
name_data_dict = {"name": data["name"]}
if data.get("changedToAt"):
name_data_dict["changed_to_at"] = data["changedToAt"]
else:
name_data_dict["changed_to_at"] = 0
name_data.append(name_data_dict)
return name_data

3
pyspapi/api/__init__.py Normal file
View File

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

352
pyspapi/api/api.py Normal file
View File

@@ -0,0 +1,352 @@
import asyncio
import json
from base64 import b64encode
from logging import NullHandler, getLogger
from typing import Any, Dict, Optional
import aiohttp
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.addHandler(NullHandler())
class APISession(object):
def __init__(
self,
card_id: str,
token: str,
timeout: int = 5,
sleep_time: float = 0.2,
retries: int = 0,
raise_exception: bool = False,
proxy: str = None,
):
self._validate_credentials(card_id, token)
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.__raise_exception = raise_exception
self.__proxy = proxy
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):
try:
if not self.session:
self.session = aiohttp.ClientSession(
json_serialize=json.dumps,
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
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self._session_owner and self.session:
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(
self, method: str, endpoint: str, data: Optional[Dict] = None
) -> Any:
url = self.__url + endpoint
headers = self._get_headers()
attempt = 0
while True:
attempt += 1
if attempt > 1:
log.warning(
f"[pyspapi] Retry attempt {attempt}/{self.__retries + 1}: {method.upper()} {endpoint}"
)
try:
async with self.session.request(
method, url, json=data, headers=headers
) as resp:
response_text = await resp.text()
if resp.status == 422:
try:
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:
raise ValidationError(errors)
return None
if resp.status >= 400:
await self._handle_http_error(
method, endpoint, resp.status, response_text
)
if self._should_retry(resp.status, attempt):
await asyncio.sleep(self.__sleep_timeout * attempt)
continue
return None
try:
return await resp.json()
except json.JSONDecodeError as e:
log.error(
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
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)

197
pyspapi/exceptions.py Normal file
View File

@@ -0,0 +1,197 @@
from typing import Any, Dict, Optional
class SPAPIError(Exception):
"""
Базовая ошибка для всех исключений, связанных с API SPWorlds.
"""
def __init__(
self,
status_code: Optional[int] = None,
message: str = "",
details: Optional[Dict[str, Any]] = None,
):
self.status_code = status_code
self.message = message
self.details = details or {}
error_msg = f"[{status_code}] {message}" if status_code else message
super().__init__(error_msg)
def __str__(self):
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):
"""
Ошибка валидации (HTTP 422).
"""
def __init__(self, errors: Dict[str, Any]):
self.errors = errors
super().__init__(
status_code=422,
message="Validation failed",
details={"validation_errors": errors},
)
def __str__(self):
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,55 +0,0 @@
class _SPObject:
"""Возвращает словарь всех атрибутов экземпляра"""
def to_dict(self) -> dict:
return self.__dict__.copy()
def __repr__(self):
return "%s(%s)" % (
self.__class__.__name__,
self.__dict__
)
class SPUserProfile(_SPObject):
def __init__(self,
access: bool,
username: str,
):
self.access = access
self.username = username
class _MojangObject:
def to_dict(self) -> dict:
"""Возвращает словарь всех атрибутов экземпляра"""
return self.__dict__.copy()
def __repr__(self):
return "%s(%s)" % (
self.__class__.__name__,
self.__dict__
)
class MojangUserProfile(_MojangObject):
def __init__(self, data: dict):
self.timestamp = data['timestamp']
self.id = data['profileId']
self.name = data['profileName']
self.is_legacy_profile = data.get('legacy')
if self.is_legacy_profile is None:
self.is_legacy_profile = False
self.cape_url = None
self.skin_url = None
self.skin_model = 'classic'
if data['textures'].get('CAPE'):
self.cape_url = data['textures']['CAPE']['url']
if data['textures'].get('SKIN'):
self.skin_url = data['textures']['SKIN']['url']
self.skin = data['textures']['SKIN']
if data['textures']['SKIN'].get('metadata'):
self.skin_model = 'slim'

278
pyspapi/spworlds.py Normal file
View File

@@ -0,0 +1,278 @@
from base64 import b64encode
from hashlib import sha256
from hmac import compare_digest, new
from typing import Optional
from pyspapi.api import APISession
from pyspapi.exceptions import InsufficientBalanceError
from pyspapi.types import User
from pyspapi.types.me import Account
from pyspapi.types.payment import Item
__all__ = ["SPAPI"]
class SPAPI(APISession):
"""
pyspapi — высокоуровневый клиент для взаимодействия с SPWorlds API.
Предоставляет удобные методы для работы с балансом карты, вебхуками,
информацией о пользователе, транзакциями и платежами, а также верификацией вебхуков.
"""
def __init__(
self,
card_id: str,
token: str,
timeout: int = 5,
sleep_time: float = 0.2,
retries: int = 0,
raise_exception: bool = False,
proxy: str = None,
):
"""
Инициализирует объект SPAPI.
:param card_id: Идентификатор карты.
:type card_id: str
:param token: Токен API.
:type token: str
:param timeout: Таймаут для запросов API в секундах. По умолчанию 5.
:type timeout: int
:param sleep_time: Время ожидания между повторными запросами в секундах. По умолчанию 0.2.
:type sleep_time: float
:param retries: Количество повторных попыток для неудачных запросов. По умолчанию 0.
:type retries: int
:param raise_exception: Поднимать исключения при ошибке, если True.
:type raise_exception: bool
:param proxy: Прокси для подключения к API. По умолчанию None.
:type proxy: str
"""
super().__init__(
card_id, token, timeout, sleep_time, retries, raise_exception, proxy
)
self.__card_id = card_id
self.__token = token
def __repr__(self):
"""
Возвращает строковое представление объекта SPAPI.
"""
return f"{self.__class__.__name__}({vars(self)})"
@property
async def balance(self) -> Optional[int]:
"""
Получает текущий баланс карты.
:return: Текущий баланс карты.
:rtype: int
"""
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
async def webhook(self) -> Optional[str]:
"""
Получает URL вебхука, связанного с картой.
:return: URL вебхука.
:rtype: str
"""
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
async def me(self) -> Optional[Account]:
"""
Получает информацию об аккаунте текущего пользователя.
:return: Объект Account, представляющий аккаунт текущего пользователя.
:rtype: :class:`Account`
"""
try:
me = await super().get("accounts/me")
if me is None:
return None
return Account(
account_id=me.get("id"),
username=me.get("username"),
minecraftuuid=me.get("minecraftUUID"),
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]:
"""
Получает информацию о пользователе по его ID в Discord.
:param discord_id: ID пользователя в Discord.
:type discord_id: int
:return: Объект User, представляющий пользователя.
:rtype: :class:`User`
"""
if not discord_id:
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")
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]:
"""
Создает транзакцию.
:param receiver: Получатель транзакции.
:type receiver: str
:param amount: Сумма транзакции.
:type amount: int
:param comment: Комментарий к транзакции.
:type comment: str
:return: Баланс после транзакции.
:rtype: int
"""
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")
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(
self, webhook_url: str, redirect_url: str, data: str, items: list[Item]
) -> Optional[str]:
"""
Создает платеж.
:param webhook_url: URL вебхука для платежа.
:type webhook_url: str
:param redirect_url: URL для перенаправления после платежа.
:type redirect_url: str
:param data: Дополнительные данные для платежа.
:type data: str
:param items: Элементы, включаемые в платеж.
:return: URL для платежа или None при ошибке.
:rtype: str
"""
if not webhook_url or not redirect_url:
raise ValueError("webhook_url and redirect_url must be non-empty strings")
if not items or len(items) == 0:
raise ValueError("items must contain at least one item")
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]:
"""
Обновляет URL вебхука, связанного с картой.
:param url: Новый URL вебхука.
:return: Ответ API в виде словаря или None при ошибке.
"""
if not url:
raise ValueError("url must be a non-empty string")
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:
"""
Проверяет достоверность вебхука.
:param data: Данные из вебхука.
:type data: str
:param header: Заголовок X-Body-Hash из вебхука.
:return: True, если заголовок из вебхука достоверен, иначе False.
:rtype: bool
"""
hmac_data = b64encode(new(self.__token.encode("utf-8"), data, sha256).digest())
return compare_digest(hmac_data, header.encode("utf-8"))
def to_dict(self) -> dict:
"""
Преобразует объект SPAPI в словарь.
:return: Словарное представление объекта SPAPI.
:rtype: dict
"""
return self.__dict__.copy()

View File

@@ -0,0 +1,5 @@
from pyspapi.types.me import Account
from pyspapi.types.payment import Item
from pyspapi.types.users import Cards, User
__all__ = ["Account", "Item", "Cards", "User"]

169
pyspapi/types/me.py Normal file
View File

@@ -0,0 +1,169 @@
class City:
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._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 name(self):
return self._name
@property
def x(self):
return self._x
@property
def z(self):
return self._z
@property
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"<{self.__class__.__name__}(id={self._id!r}, name={self._name!r}, lane={self._lane!r}, role={self._role!r})>"
class Card:
def __init__(self, card_id=None, name=None, number=None, color=None):
self._id = card_id
self._name = name
self._number = number
self._color = color
@property
def id(self):
return self._id
@property
def name(self):
return self._name
@property
def number(self):
return self._number
@property
def color(self):
return self._color
def __repr__(self):
return f"<{self.__class__.__name__}(id={self._id!r}, name={self._name!r}, number={self._number!r})>"
class Account:
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._cities = [
City(
city_id=city["city"]["id"],
name=city["city"]["name"],
x=city["city"]["x"],
z=city["city"]["z"],
nether_x=city["city"]["netherX"],
nether_z=city["city"]["netherZ"],
lane=city["city"]["lane"],
role=city["role"],
created_at=city["createdAt"],
)
for city in cities
]
self._cards = [
Card(
card_id=card["id"],
name=card["name"],
number=card["number"],
color=card["color"],
)
for card in cards
]
self._created_at = created_at
@property
def id(self):
return self._id
@property
def username(self):
return self._username
@property
def minecraftuuid(self):
return self._minecraftuuid
@property
def status(self):
return self._status
@property
def roles(self):
return self._roles
@property
def cities(self):
return self._cities
@property
def cards(self):
return self._cards
@property
def created_at(self):
return self._created_at
def __repr__(self):
return (
f"<{self.__class__.__name__}(id={self._id!r}, username={self._username!r}, status={self._status!r}, "
f"roles={self._roles}, cities={self._cities}, cards={self._cards})>"
)

21
pyspapi/types/payment.py Normal file
View File

@@ -0,0 +1,21 @@
class Item:
def __init__(self, name: str, count: int, price: int, comment: str):
self._name = name
self._count = count
self._price = price
self._comment = comment
@property
def name(self):
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):
return {
"name": self._name,
"count": self._count,
"price": self._price,
"comment": self._comment,
}

43
pyspapi/types/users.py Normal file
View File

@@ -0,0 +1,43 @@
class UserCards:
def __init__(self, name, number):
self._name: str = name
self._number: str = number
@property
def name(self):
return self._name
@property
def number(self):
return self._number
def __repr__(self):
return f"<{self.__class__.__name__}(id={self._id!r}, name={self._name!r}, number={self._number!r})>"
class User:
def __init__(self, username, uuid, cards):
self._username: str = username
self._uuid: str = uuid
self._cards = [
UserCards(
name=card["name"],
number=card["number"],
)
for card in cards
]
@property
def username(self):
return self._username
@property
def uuid(self):
return self._uuid
@property
def cards(self):
return self._cards
def __repr__(self):
return "%s(%s)" % (self.__class__.__name__, self.__dict__)

View File

@@ -1 +0,0 @@
requests==2.28.1

View File

@@ -1,46 +0,0 @@
import re
from setuptools import setup
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()
packages = [
"pyspapi"
]
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=packages,
include_package_data=True,
install_requires=requirements,
python_requires='>=3.8.0',
)