26 Commits

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
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
40 changed files with 3181 additions and 619 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 }}

View File

@@ -1,19 +1,18 @@
version: 2
formats: []
build:
image: latest
os: ubuntu-lts-latest
tools:
python: '3.12'
sphinx:
configuration: docs/conf.py
fail_on_warning: false
builder: html
python:
version: "3.8"
install:
- method: pip
path: .
extra_requirements:
- docs
- requirements: docs/requirements.txt

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

@@ -24,7 +24,7 @@ pyspapi
Installation
-------------
**Requires Python 3.8 or higher**
**Requires Python 3.12 or higher**
*Windows*
@@ -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(await pyspapi.API(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>`_
@@ -57,6 +65,5 @@ Links
- `Discord server <https://discord.gg/VbyHaKRAaN>`_
- `pyspapi documentation <https://pyspapi.readthedocs.io/>`_
- `API documentation <https://spworlds.readthedocs.io>`_
- `PyPi <https://pypi.org/project/pyspapi/>`_
- `API documentation for SP sites <https://github.com/sp-worlds/api-docs>`_

View File

@@ -5,8 +5,8 @@
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@@ -17,4 +17,4 @@ help:
# 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)
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

View File

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

View File

@@ -2,11 +2,16 @@
{
"name": "latest",
"version": "latest",
"url": "https://pyspapi.readthedocs.io/en/latest/"
"url": "https://pyspapi.readthedocs.io/ru/latest/"
},
{
"name": "stable",
"version": "stable",
"url": "https://pyspapi.readthedocs.io/en/stable/"
"url": "https://pyspapi.readthedocs.io/ru/stable/"
},
{
"name": "v3-asyncio",
"version": "v3-asyncio",
"url": "https://pyspapi.readthedocs.io/ru/v3-asyncio/"
}
]

View File

@@ -1,74 +1,30 @@
.. py: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
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
'alpha', 'beta', 'candidate' and 'final'.
Как и в :obj:`py:sys.version_info`, допустимые значения для ``releaselevel`` это
'alpha', 'beta', 'candidate' и 'final'.
.. data:: __version__
A string representation of the version.
Строковое представление версии.
``pyspapi``
-----------
``SPAPI``
~~~~~
~~~~~~~~~
.. autoclass:: SPAPI
:members:
.. automethod:: SPAPI.event()
:decorator:
.. automethod:: SPAPI.check_user_access
:decorator:
.. automethod:: SPAPI.get_user
:decorator:
.. automethod:: SPAPI.get_users
:decorator:
.. automethod:: SPAPI.payment
:decorator:
.. automethod:: SPAPI.transaction
:decorator:
.. automethod:: SPAPI.webhook_verify
:decorator:
MojangAPI
~~~~~
.. autoclass:: MojangAPI
:members:
.. automethod:: SPAPI.event()
:decorator:
.. automethod:: SPAPI.get_name_history
:decorator:
.. automethod:: SPAPI.get_profile
:decorator:
.. automethod:: SPAPI.get_username
:decorator:
.. automethod:: SPAPI.get_uuid
:decorator:
.. automethod:: SPAPI.get_uuids
:decorator:

View File

@@ -1,46 +1,44 @@
from re import search, MULTILINE
from importlib.metadata import version as pkg_version
import os
project = 'pyspapi'
copyright = '2022, deesiigneer'
author = 'deesiigneer'
with open("../pyspapi/__init__.py") as f:
match = search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), MULTILINE)
project = "pyspapi"
author = "deesiigneer"
copyright = "2022, deesiigneer"
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.
version = pkg_version("pyspapi")
release = version
# -- General configuration
extensions = [
'sphinx.ext.duration',
'sphinx.ext.doctest',
'sphinx.ext.autodoc',
'sphinx.ext.autosummary',
'sphinx.ext.intersphinx',
"sphinx.ext.duration",
"sphinx.ext.doctest",
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.intersphinx",
]
intersphinx_mapping = {
'python': ('https://docs.python.org/3/', None),
'sphinx': ('https://www.sphinx-doc.org/en/master/', None),
}
intersphinx_disabled_domains = ['std']
language = None
locale_dirs = ["locale/"]
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": [
@@ -48,29 +46,27 @@ html_theme_options = {
"name": "GitHub",
"url": "https://github.com/deesiigneer/pyspapi",
"icon": "fab fa-brands fa-github",
"type": "fontawesome"
"type": "fontawesome",
},
{
"name": "Discord",
"url": "https://discord.gg/VbyHaKRAaN",
"icon": "fab fa-brands fa-discord",
"type": "fontawesome"
"type": "fontawesome",
},
{
"name": "PyPi",
"name": "PyPI",
"url": "https://pypi.org/project/pyspapi/",
"icon": "fab fa-brands fa-python",
"type": "fontawesome"
}
"type": "fontawesome",
},
],
"header_links_before_dropdown": 4,
"show_toc_level": 1,
"navbar_start": ["navbar-logo", "version-switcher"],
"switcher": {
"json_url": "https://pyspapi.readthedocs.io/en/latest/_static/switcher.json",
"version_match": "latest"
},
"navbar_start": ["navbar-logo"],
"navigation_with_keys": True,
"switcher": {
"json_url": json_url,
"version_match": version_match,
},
}
html_css_files = ["custom.css"]

View File

@@ -1,35 +1,34 @@
: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>`
- **Examples:** Many examples are available in the `examples directory <https://github.com/deesiigneer/pyspapi/tree/main/examples/>`_.
- **Первые шаги:** :ref:`Быстрый старт <quickstart>`
- **Примеры:** Много примеров доступно в `папке примеров <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.
- If you're looking for something specific, try the :ref:`searching <search>`.
- Report bugs in the `issue tracker <https://github.com/deesiigneer/pyspapi/issues>`_.
- Ask in `GitHub discussions page <https://github.com/deesiigneer/pyspapi/discussions>`_.
- Задавайте вопросы на сервере `Discord <https://discord.gg/VbyHaKRAaN>`_.
- Если вы ищете что-то конкретное, попробуйте :ref:`поиск <search>`.
- Сообщайте об ошибках в `трекер проблем <https://github.com/deesiigneer/pyspapi/issues>`_.
Manuals
-------
Руководства
-----------
These pages go into great detail about everything the API can do.
Эти страницы подробно описывают все, что может сделать API.
.. toctree::
:maxdepth: 1
api
quickstart
quickstart

View File

@@ -4,25 +4,32 @@
.. 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
import pyspapi
from pyspapi import SPAPI
from asyncio import get_event_loop
print(pyspapi.SPAPI(card_id='card_id', token='token').balance)
Make sure not to name it ``pyspapi`` as that'll conflict with the library.
spapi = SPAPI(card_id='CARD_ID', token='TOKEN')
You can find more examples in the `examples directory <https://github.com/deesiigneer/pyspapi/tree/main/examples/>`_ on GitHub.
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.

View File

@@ -1,12 +0,0 @@
import pyspapi
import asyncio
api = pyspapi.API(card_id='card_id', token='token')
async def main():
print(await api.get_name_history(uuid='63ed47877aa3470fbfc46c5356c3d797'))
loop = asyncio.get_event_loop()
loop.run_until_complete(main())

View File

@@ -1,15 +0,0 @@
import pyspapi
import asyncio
api = pyspapi.API(card_id='card_id', token='token')
async def main():
mojang_profile = await api.get_profile(uuid='63ed47877aa3470fbfc46c5356c3d797')
print(mojang_profile)
print(mojang_profile.id, mojang_profile.timestamp)
print(mojang_profile.skin, mojang_profile.skin.model, mojang_profile.skin.cape_url, mojang_profile.skin.url)
loop = asyncio.get_event_loop()
loop.run_until_complete(main())

View File

@@ -1,21 +0,0 @@
import pyspapi
import asyncio
api = pyspapi.API(card_id='card_id', token='token')
async def main():
user = await api.get_user(264329096920563714)
print(user)
print(user.access)
# У API есть лимиты, каждый user = 1 запрос, учитывайте это при использовании get_users
# https://spworlds.readthedocs.io/ru/latest/index.html#id3
users = await api.get_users([262632724928397312, 264329096920563714])
for user in users:
print(user)
if user is not None:
print(user.access)
loop = asyncio.get_event_loop()
loop.run_until_complete(main())

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())

View File

@@ -1,17 +0,0 @@
import pyspapi
import pyspapi
import asyncio
api = pyspapi.API(card_id='card_id', token='token')
async def main():
uuid = await pyspapi.API(card_id='card_id', token='token').get_uuid(username='deesiigneer')
print(uuid)
print(uuid.id, uuid.name)
print(await pyspapi.API(card_id='card_id', token='token').get_uuids(['deesiigneer', '5opka', 'OsterMiner']))
loop = asyncio.get_event_loop()
loop.run_until_complete(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,16 +1,25 @@
import pyspapi
import asyncio
from pyspapi import SPAPI
from pyspapi.types import Item
api = pyspapi.API(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(),
]
async def main():
print(await api.payment(amount=1,
redirect_url='https://www.google.com/',
webhook_url='https://www.google.com/',
data='some-data'
)
)
print(
await spapi.create_payment(
items=items,
redirect_url="https://www.google.com/",
webhook_url="https://www.google.com/",
data="some-data",
)
)
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
asyncio.run(main())

View File

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

View File

@@ -1,5 +0,0 @@
import pyspapi
api = pyspapi.API(card_id='your_card_id', token='your_token')
api.listener(host='myhost.com', port=80, webhook_path='/webhook/')

View File

@@ -1,10 +0,0 @@
import pyspapi
import asyncio
api = pyspapi.API(card_id='your_card_id', token='your_token')
async def main():
print(await api.webhook_verify(data='webhook_data', header='webhook_header'))
loop = asyncio.get_event_loop()
loop.run_until_complete(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,4 +1,53 @@
from .api import API
from .types import SPUser, MojangProfile, Skin, UsernameToUUID
"""
SPWorlds API Wrapper
~~~~~~~~~~~~~~~~~~~
__version__ = "3.0.0a0"
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,182 +0,0 @@
import ast
import warnings
import asyncio
from aiohttp import web
from sys import version_info
from base64 import b64encode, b64decode
from hmac import new, compare_digest
from hashlib import sha256
from logging import getLogger
from typing import Any, Dict, List, Optional, Union
from .types import SPUser, MojangProfile, UsernameToUUID
from .request import Request
from .errors import Error
log = getLogger('pyspapi')
class _BaseAPI:
_SPWORLDS = "https://spworlds.ru/api/public"
_API_MOJANG = "https://api.mojang.com"
_SESSIONSERVER_MOJANG = "https://sessionserver.mojang.com"
def __init__(self, card_id: str, token: str):
"""
:param card_id:
:param token:
"""
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}'
}
async 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
"""
print()
hmac_data = b64encode(new(self._token.encode('utf-8'), data, sha256).digest())
return compare_digest(hmac_data, header.encode('utf-8'))
def listener(self, host: str = '127.0.0.1', port: int = 80, webhook_path: str = '/webhook/'):
app = web.Application()
async def handle(request):
request_data = await request.read()
header = request.headers.get('X-Body-Hash')
if header is not None:
if await self.webhook_verify(data=request_data, header=header) is True:
web.json_response(status=202)
return True
else:
web.json_response(status=400)
else:
return web.json_response(status=404)
app.add_routes([web.get(webhook_path, handle)])
web.run_app(app, port=port, host=host)
class API(_BaseAPI):
"""
class API
"""
async def get_user(self, user_id: int) -> Optional[SPUser]:
"""
Получение информации об игроке SP \n
:param user_id: ID пользователя в Discord.
:return: Class User.
"""
sp_user = await Request.get(f'{self._SPWORLDS}/users/{str(user_id)}', self._HEADER)
if sp_user is not None:
return SPUser(await Request.get(f'{self._SPWORLDS}/users/{str(user_id)}', self._HEADER))
else:
return None
async def get_users(self, user_ids: List[int]) -> Union[SPUser, Any]:
"""
Получение никнеймов игроков в майнкрафте. **Максимально можно указать 60 user_ids, не используйте эту функцию
чаще 1 раза в минуту если указали больше 60 user_ids**\n
https://spworlds.readthedocs.io/ru/latest/index.html#id3\n
:param user_ids: List[int] ID пользователей в Discord.
:return: List[str] который содержит майнкрафт никнеймы игроков в том же порядке, который был задан, None если
пользователь не найден или нет проходки.
"""
if len(user_ids) > 60:
user_ids = user_ids[:60]
warnings.warn('user_ids больше чем 60. Уменьшено до 60.')
tasks = []
for user_id in user_ids:
tasks.append(self.get_user(user_id))
return await asyncio.gather(*tasks, return_exceptions=True)
async def get_uuid(self, username: str) -> Optional[UsernameToUUID]:
"""
Получить UUID игрока Minecraft.\n
:param username: str никнейм игрока Minecraft.
:return: Optional[str] UUID игрока Minecraft.
"""
response = await Request.get(f'{self._API_MOJANG}/users/profiles/minecraft/{username}')
return UsernameToUUID(await Request.get(f'{self._API_MOJANG}/users/profiles/minecraft/{username}'))
async def get_uuids(self, usernames: list[str]) -> Dict[str, str]:
"""
Получить UUID's игроков Minecraft. **Не больше 10**\n
:param usernames: List[str] Список с никнеймами игроков Minecraft.
:return: Dict[str, str] UUID игроков Minecraft.
"""
if len(usernames) > 10:
usernames = usernames[:10]
warnings.warn('usernames больше чем 10. Уменьшено до 10.')
return await Request.post(f'{self._API_MOJANG}/profiles/minecraft', payload=usernames)
async def get_name_history(self, uuid: str) -> List[Dict[str, Any]]:
"""
История никнеймов в Minecraft.\n
:param uuid: UUID игрока Minecraft.
:return: List[Dict[str, Any]] который содержит name и changed_to_at
"""
requests = await Request.get(f"{self._API_MOJANG}/user/profiles/{uuid}/names")
name_data = []
for data in requests:
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
async def get_profile(self, uuid: str) -> MojangProfile:
response = await Request.get(f'{self._SESSIONSERVER_MOJANG}/session/minecraft/profile/{uuid}')
return MojangProfile(ast.literal_eval(b64decode(response["properties"][0]["value"]).decode()))
async 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 символов')
return await Request.post(f'{self._SPWORLDS}/payment',
payload={
'amount': amount,
'redirectUrl': redirect_url,
'webhookUrl': webhook_url,
'data': data},
headers=self._HEADER)
async def transaction(self, receiver: int, amount: int, comment: str) -> Optional[str]:
"""
Перевод АР на карту. \n
:param receiver: Номер карты получателя.
:param amount: Количество АР для перевода.
:param comment: Комментарий для перевода.
:return: True если перевод успешен, иначе False.
"""
return 'Удачно' if await Request.post(f'{self._SPWORLDS}/transactions',
payload={'receiver': receiver,
'amount': amount,
'comment': comment},
headers=self._HEADER) else 'Что-то пошло не так...'
@property
async def balance(self) -> Optional[int]:
"""
Проверка баланса карты \n
:return: Количество АР на карте.
"""
balance = await Request.get(f'{self._SPWORLDS}/card', headers=self._HEADER)
return balance['balance']

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)

View File

@@ -1,29 +0,0 @@
class Error(Exception):
pass
class Unauthorized(Exception):
pass
class NotFound(Exception):
pass
class TooManyRequests(Exception):
pass
class UserNotFound(Exception):
pass
def handle(response):
if response['error'] == 'Unauthorized':
raise Unauthorized(response['message'])
elif response['error'] == 'Not Found':
raise NotFound(response['message'])
elif response['error'] == 'Too Many Requests':
raise TooManyRequests(response['message'])
else:
raise Exception(response)

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,31 +0,0 @@
import aiohttp
from .errors import handle
from typing import Coroutine, Any, TypeVar
T = TypeVar("T")
Response = Coroutine[Any, Any, T]
class Request:
async def get(url: str, headers=None):
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as session_response:
try:
response = await session_response.json()
except:
response = await session_response.text()
if session_response.status == 404:
return None
if not session_response.ok:
handle(response)
return response
async def post(url: str, payload, headers=None):
async with aiohttp.ClientSession() as session:
session_response = await session.post(url, json=payload, headers=headers)
try:
response = await session_response.json()
except:
response = await session_response.text()
if not session_response.ok:
handle(response)
return response

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

@@ -1,63 +0,0 @@
class SPUser:
def __init__(self, data: dict):
self.__data = data
@property
def access(self) -> bool:
return True if self.__data['username'] is not None else False
def __repr__(self):
return self.__data['username']
class MojangProfile:
def __init__(self, data: dict):
self.__data = data
self.skin = Skin(data)
@property
def id(self) -> str:
return self.__data['profileId']
@property
def timestamp(self):
return self.__data['timestamp']
def __repr__(self):
return self.__data['profileName']
class Skin:
def __init__(self, data: dict):
self.__data = data
@property
def url(self) -> str:
return self.__data['textures']['SKIN']['url']
@property
def cape_url(self) -> str:
return self.__data['textures']['CAPE']['url']
@property
def model(self) -> str:
return 'classic' if self.__data['textures']['SKIN'].get('metadata') is None else 'slim'
def __repr__(self):
return str(self.__data['textures']['SKIN'])
class UsernameToUUID:
def __init__(self, data: dict):
self.__data = data
@property
def id(self):
return self.__data['id']
@property
def name(self):
return self.__data['name']
def __repr__(self):
return str(self.__data['id'])

View File

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

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,2 +0,0 @@
requests==2.28.1
aiohttp>=3.8.0,<4.0.0

View File

@@ -1,47 +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={
"pyspapi documentation": "https://pyspapi.readthedocs.io/",
"api documentation": "https://spworlds.readthedocs.io/",
"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',
)