mirror of
https://github.com/deesiigneer/pyspapi.git
synced 2026-04-20 12:35:26 +00:00
Compare commits
1 Commits
83d4308906
...
3.0.0a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c08394addf |
42
.github/workflows/python-publish.yml
vendored
42
.github/workflows/python-publish.yml
vendored
@@ -17,28 +17,22 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.12]
|
python-version: [ 3.9 ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
uses: actions/setup-python@v2
|
||||||
uses: actions/setup-python@v2
|
with:
|
||||||
with:
|
python-version: ${{ matrix.python-version }}
|
||||||
python-version: ${{ matrix.python-version }}
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
- name: Install Poetry
|
python -m pip install --upgrade pip
|
||||||
run: |
|
pip install twine
|
||||||
curl -sSL https://install.python-poetry.org | python3 -
|
- name: Compile package
|
||||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
run: |
|
||||||
|
python3 setup.py sdist
|
||||||
- name: Install dependencies
|
- name: Publish package
|
||||||
run: poetry install --no-interaction
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
with:
|
||||||
- name: Build package
|
user: __token__
|
||||||
run: poetry build
|
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||||
|
|
||||||
- name: Publish package
|
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
|
||||||
with:
|
|
||||||
user: __token__
|
|
||||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
formats: []
|
||||||
|
|
||||||
build:
|
build:
|
||||||
os: ubuntu-lts-latest
|
image: latest
|
||||||
tools:
|
|
||||||
python: '3.12'
|
|
||||||
|
|
||||||
|
|
||||||
sphinx:
|
sphinx:
|
||||||
configuration: docs/conf.py
|
configuration: docs/conf.py
|
||||||
|
fail_on_warning: false
|
||||||
builder: html
|
builder: html
|
||||||
|
|
||||||
python:
|
python:
|
||||||
|
version: "3.8"
|
||||||
install:
|
install:
|
||||||
- method: pip
|
- method: pip
|
||||||
path: .
|
path: .
|
||||||
extra_requirements:
|
extra_requirements:
|
||||||
- docs
|
- docs
|
||||||
|
- requirements: docs/requirements.txt
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2022 deesiigneer
|
Copyright (c) 2022 Aleksey
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
3
MANIFEST.in
Normal file
3
MANIFEST.in
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
include README.rst
|
||||||
|
include LICENSE
|
||||||
|
include requirements.txt
|
||||||
15
README.rst
15
README.rst
@@ -37,7 +37,7 @@ Installation
|
|||||||
|
|
||||||
.. code:: sh
|
.. code:: sh
|
||||||
|
|
||||||
pip3 install pyspapi
|
sudo apt pip3 install pyspapi
|
||||||
|
|
||||||
Quick example
|
Quick example
|
||||||
--------------
|
--------------
|
||||||
@@ -46,17 +46,9 @@ Checking the balance
|
|||||||
~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
.. code:: py
|
.. code:: py
|
||||||
|
|
||||||
from pyspapi import SPAPI
|
import pyspapi
|
||||||
from asyncio import get_event_loop
|
|
||||||
|
|
||||||
spapi = SPAPI(card_id='CARD_ID', token='TOKEN')
|
print(await pyspapi.API(card_id='card_id', token='token').balance)
|
||||||
|
|
||||||
|
|
||||||
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>`_
|
More examples can be found in the `examples <https://github.com/deesiigneer/pyspapi/tree/main/examples>`_
|
||||||
|
|
||||||
@@ -65,5 +57,6 @@ Links
|
|||||||
|
|
||||||
- `Discord server <https://discord.gg/VbyHaKRAaN>`_
|
- `Discord server <https://discord.gg/VbyHaKRAaN>`_
|
||||||
- `pyspapi documentation <https://pyspapi.readthedocs.io/>`_
|
- `pyspapi documentation <https://pyspapi.readthedocs.io/>`_
|
||||||
|
- `API documentation <https://spworlds.readthedocs.io>`_
|
||||||
- `PyPi <https://pypi.org/project/pyspapi/>`_
|
- `PyPi <https://pypi.org/project/pyspapi/>`_
|
||||||
- `API documentation for SP sites <https://github.com/sp-worlds/api-docs>`_
|
- `API documentation for SP sites <https://github.com/sp-worlds/api-docs>`_
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
# from the environment for the first two.
|
# from the environment for the first two.
|
||||||
SPHINXOPTS ?=
|
SPHINXOPTS ?=
|
||||||
SPHINXBUILD ?= sphinx-build
|
SPHINXBUILD ?= sphinx-build
|
||||||
SOURCEDIR = .
|
SOURCEDIR = source
|
||||||
BUILDDIR = _build
|
BUILDDIR = build
|
||||||
|
|
||||||
# Put it first so that "make" without argument is like "make help".
|
# Put it first so that "make" without argument is like "make help".
|
||||||
help:
|
help:
|
||||||
@@ -17,4 +17,4 @@ help:
|
|||||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||||
%: Makefile
|
%: Makefile
|
||||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
8
docs/_static/custom.css
vendored
8
docs/_static/custom.css
vendored
@@ -1,13 +1,13 @@
|
|||||||
/* Background of stable should be green */
|
/* Background of stable should be green */
|
||||||
.version-switcher__container a[data-version-name*="stable"] {
|
#version_switcher a[data-version-name*="stable"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-switcher__container a[data-version-name*="stable"] span {
|
#version_switcher a[data-version-name*="stable"] span {
|
||||||
color: var(--pst-color-success);
|
color: var(--pst-color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-switcher__container a[data-version-name*="stable"] span:before {
|
#version_switcher a[data-version-name*="stable"] span:before {
|
||||||
content: "";
|
content: "";
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -16,4 +16,4 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
background-color: var(--pst-color-success);
|
background-color: var(--pst-color-success);
|
||||||
opacity: 0.1;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
|
|||||||
9
docs/_static/switcher.json
vendored
9
docs/_static/switcher.json
vendored
@@ -2,16 +2,11 @@
|
|||||||
{
|
{
|
||||||
"name": "latest",
|
"name": "latest",
|
||||||
"version": "latest",
|
"version": "latest",
|
||||||
"url": "https://pyspapi.readthedocs.io/ru/latest/"
|
"url": "https://pyspapi.readthedocs.io/en/latest/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "stable",
|
"name": "stable",
|
||||||
"version": "stable",
|
"version": "stable",
|
||||||
"url": "https://pyspapi.readthedocs.io/ru/stable/"
|
"url": "https://pyspapi.readthedocs.io/en/stable/"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "v3-asyncio",
|
|
||||||
"version": "v3-asyncio",
|
|
||||||
"url": "https://pyspapi.readthedocs.io/ru/v3-asyncio/"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
64
docs/api.rst
64
docs/api.rst
@@ -1,30 +1,74 @@
|
|||||||
.. currentmodule:: pyspapi
|
.. py:currentmodule:: pyspapi
|
||||||
|
|
||||||
Справочник API
|
API Reference
|
||||||
===============
|
===============
|
||||||
|
|
||||||
В следующем разделе описывается API pyspapi.
|
The following section outlines the API of pyspapi.
|
||||||
|
|
||||||
Информация о версии
|
Version Info
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
Существует два основных способа запроса информации о версии.
|
There are two main ways to query version information.
|
||||||
|
|
||||||
.. data:: version_info
|
.. data:: version_info
|
||||||
|
|
||||||
Именованный кортеж, аналогичный :obj:`py:sys.version_info`.
|
A named tuple that is similar to :obj:`py:sys.version_info`.
|
||||||
|
|
||||||
Как и в :obj:`py:sys.version_info`, допустимые значения для ``releaselevel`` это
|
Just like :obj:`py:sys.version_info` the valid values for ``releaselevel`` are
|
||||||
'alpha', 'beta', 'candidate' и 'final'.
|
'alpha', 'beta', 'candidate' and 'final'.
|
||||||
|
|
||||||
.. data:: __version__
|
.. data:: __version__
|
||||||
|
|
||||||
Строковое представление версии.
|
A string representation of the version.
|
||||||
|
|
||||||
``pyspapi``
|
``pyspapi``
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
``SPAPI``
|
``SPAPI``
|
||||||
~~~~~~~~~
|
~~~~~
|
||||||
.. autoclass:: SPAPI
|
.. autoclass:: SPAPI
|
||||||
:members:
|
: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:
|
||||||
|
|||||||
70
docs/conf.py
70
docs/conf.py
@@ -1,44 +1,46 @@
|
|||||||
from importlib.metadata import version as pkg_version
|
from re import search, MULTILINE
|
||||||
import os
|
|
||||||
|
|
||||||
project = "pyspapi"
|
project = 'pyspapi'
|
||||||
author = "deesiigneer"
|
copyright = '2022, deesiigneer'
|
||||||
copyright = "2022, deesiigneer"
|
author = 'deesiigneer'
|
||||||
|
with open("../pyspapi/__init__.py") as f:
|
||||||
|
match = search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), MULTILINE)
|
||||||
|
|
||||||
version = pkg_version("pyspapi")
|
if not match or match.group(1) is None:
|
||||||
|
raise RuntimeError("The version could not be resolved")
|
||||||
|
|
||||||
|
version = match.group(1)
|
||||||
|
|
||||||
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = version
|
release = version
|
||||||
|
|
||||||
|
# -- General configuration
|
||||||
|
|
||||||
extensions = [
|
extensions = [
|
||||||
"sphinx.ext.duration",
|
'sphinx.ext.duration',
|
||||||
"sphinx.ext.doctest",
|
'sphinx.ext.doctest',
|
||||||
"sphinx.ext.autodoc",
|
'sphinx.ext.autodoc',
|
||||||
"sphinx.ext.autosummary",
|
'sphinx.ext.autosummary',
|
||||||
"sphinx.ext.intersphinx",
|
'sphinx.ext.intersphinx',
|
||||||
]
|
]
|
||||||
|
|
||||||
autosummary_generate = True
|
intersphinx_mapping = {
|
||||||
|
'python': ('https://docs.python.org/3/', None),
|
||||||
version_match = os.environ.get("READTHEDOCS_VERSION")
|
'sphinx': ('https://www.sphinx-doc.org/en/master/', None),
|
||||||
json_url = f"https://pyspapi.readthedocs.io/ru/{version_match}/_static/switcher.json"
|
}
|
||||||
|
intersphinx_disabled_domains = ['std']
|
||||||
|
language = None
|
||||||
language = "ru"
|
locale_dirs = ["locale/"]
|
||||||
exclude_patterns = []
|
exclude_patterns = []
|
||||||
html_static_path = ["_static"]
|
html_static_path = ["_static"]
|
||||||
|
|
||||||
html_theme = "pydata_sphinx_theme"
|
html_theme = "pydata_sphinx_theme"
|
||||||
html_logo = "./images/logo.png"
|
html_logo = "./images/logo.png"
|
||||||
html_favicon = "./images/logo.ico"
|
html_favicon = "./images/logo.ico"
|
||||||
|
|
||||||
html_theme_options = {
|
html_theme_options = {
|
||||||
"external_links": [
|
"external_links": [
|
||||||
{
|
{
|
||||||
"url": "https://github.com/deesiigneer/pyspapi/releases",
|
"url": "https://github.com/deesiigneer/pyspapi/releases",
|
||||||
"name": "Changelog",
|
"name": "Changelog",
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/sp-worlds/api-docs/wiki",
|
|
||||||
"name": "SPWorlds API Docs",
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"icon_links": [
|
"icon_links": [
|
||||||
@@ -46,27 +48,29 @@ html_theme_options = {
|
|||||||
"name": "GitHub",
|
"name": "GitHub",
|
||||||
"url": "https://github.com/deesiigneer/pyspapi",
|
"url": "https://github.com/deesiigneer/pyspapi",
|
||||||
"icon": "fab fa-brands fa-github",
|
"icon": "fab fa-brands fa-github",
|
||||||
"type": "fontawesome",
|
"type": "fontawesome"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Discord",
|
"name": "Discord",
|
||||||
"url": "https://discord.gg/VbyHaKRAaN",
|
"url": "https://discord.gg/VbyHaKRAaN",
|
||||||
"icon": "fab fa-brands fa-discord",
|
"icon": "fab fa-brands fa-discord",
|
||||||
"type": "fontawesome",
|
"type": "fontawesome"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "PyPI",
|
"name": "PyPi",
|
||||||
"url": "https://pypi.org/project/pyspapi/",
|
"url": "https://pypi.org/project/pyspapi/",
|
||||||
"icon": "fab fa-brands fa-python",
|
"icon": "fab fa-brands fa-python",
|
||||||
"type": "fontawesome",
|
"type": "fontawesome"
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
"header_links_before_dropdown": 4,
|
"header_links_before_dropdown": 4,
|
||||||
"show_toc_level": 1,
|
"show_toc_level": 1,
|
||||||
"navbar_start": ["navbar-logo"],
|
"navbar_start": ["navbar-logo", "version-switcher"],
|
||||||
"navigation_with_keys": True,
|
|
||||||
"switcher": {
|
"switcher": {
|
||||||
"json_url": json_url,
|
"json_url": "https://pyspapi.readthedocs.io/en/latest/_static/switcher.json",
|
||||||
"version_match": version_match,
|
"version_match": "latest"
|
||||||
},
|
},
|
||||||
|
"navigation_with_keys": True,
|
||||||
}
|
}
|
||||||
|
html_css_files = ["custom.css"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
:theme_html_remove_secondary_sidebar:
|
:theme_html_remove_secondary_sidebar:
|
||||||
|
|
||||||
Добро пожаловать в pyspapi
|
Welcome to pyspapi
|
||||||
===========================
|
===================
|
||||||
|
|
||||||
Обертка API для SPWorlds серверов, написанная на Python.
|
API wrapper for SP servers written in Python.
|
||||||
|
|
||||||
Начало работы
|
Getting started
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
Вы впервые используете библиотеку? Это место, с которого нужно начать!
|
Is this your first time using the library? This is the place to get started!
|
||||||
|
|
||||||
- **Первые шаги:** :ref:`Быстрый старт <quickstart>`
|
- **First steps:** :ref:`Quickstart <quickstart>`
|
||||||
- **Примеры:** Много примеров доступно в `папке примеров <https://github.com/deesiigneer/pyspapi/tree/main/examples/>`_.
|
- **Examples:** Many examples are available in the `examples directory <https://github.com/deesiigneer/pyspapi/tree/main/examples/>`_.
|
||||||
|
|
||||||
Получение помощи
|
Getting help
|
||||||
------------------
|
------------
|
||||||
|
|
||||||
Если у вас есть проблемы с чем-то, эти ресурсы могут помочь.
|
If you're having trouble with something, these resources might help.
|
||||||
|
|
||||||
- Задавайте вопросы на сервере `Discord <https://discord.gg/VbyHaKRAaN>`_.
|
- Ask questions in `Discord <https://discord.gg/VbyHaKRAaN>`_ server.
|
||||||
- Если вы ищете что-то конкретное, попробуйте :ref:`поиск <search>`.
|
- If you're looking for something specific, try the :ref:`searching <search>`.
|
||||||
- Сообщайте об ошибках в `трекер проблем <https://github.com/deesiigneer/pyspapi/issues>`_.
|
- Report bugs in the `issue tracker <https://github.com/deesiigneer/pyspapi/issues>`_.
|
||||||
|
- Ask in `GitHub discussions page <https://github.com/deesiigneer/pyspapi/discussions>`_.
|
||||||
|
|
||||||
Руководства
|
Manuals
|
||||||
-----------
|
-------
|
||||||
|
|
||||||
Эти страницы подробно описывают все, что может сделать API.
|
These pages go into great detail about everything the API can do.
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 1
|
:maxdepth: 1
|
||||||
|
|
||||||
api
|
api
|
||||||
quickstart
|
quickstart
|
||||||
@@ -4,32 +4,25 @@
|
|||||||
|
|
||||||
.. currentmodule:: pyspapi
|
.. currentmodule:: pyspapi
|
||||||
|
|
||||||
Быстрый старт
|
Quickstart
|
||||||
==============
|
==========
|
||||||
|
|
||||||
На этой странице дается краткое введение в библиотеку.
|
This page gives a brief introduction to the library.
|
||||||
|
|
||||||
Проверка баланса
|
Checking balance
|
||||||
-----------------
|
-------------
|
||||||
|
|
||||||
Выведем количество денег, оставшихся на счету карты, на консоль.
|
Let's output the amount of money remaining in the card account to the console.
|
||||||
|
|
||||||
Это выглядит примерно так:
|
It looks something like this:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from pyspapi import SPAPI
|
import pyspapi
|
||||||
from asyncio import get_event_loop
|
|
||||||
|
|
||||||
spapi = SPAPI(card_id='CARD_ID', token='TOKEN')
|
print(pyspapi.SPAPI(card_id='card_id', token='token').balance)
|
||||||
|
|
||||||
|
Make sure not to name it ``pyspapi`` as that'll conflict with the library.
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
You can find more examples in the `examples directory <https://github.com/deesiigneer/pyspapi/tree/main/examples/>`_ on GitHub.
|
||||||
print(await spapi.balance)
|
|
||||||
|
|
||||||
loop = get_event_loop()
|
|
||||||
loop.run_until_complete(main())
|
|
||||||
|
|
||||||
Убедитесь, что вы не называете его ``pyspapi``, так как это вызовет конфликт с библиотекой.
|
|
||||||
|
|
||||||
Вы можете найти больше примеров в `папке примеров <https://github.com/deesiigneer/pyspapi/tree/main/examples/>`_ на GitHub.
|
|
||||||
12
examples/get_name_history.py
Normal file
12
examples/get_name_history.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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())
|
||||||
|
|
||||||
15
examples/get_profile.py
Normal file
15
examples/get_profile.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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())
|
||||||
|
|
||||||
21
examples/get_user and get_users.py
Normal file
21
examples/get_user and get_users.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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())
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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())
|
|
||||||
17
examples/get_uuid and get_uuids.py
Normal file
17
examples/get_uuid and get_uuids.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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())
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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())
|
|
||||||
@@ -1,25 +1,16 @@
|
|||||||
|
import pyspapi
|
||||||
import asyncio
|
import asyncio
|
||||||
from pyspapi import SPAPI
|
|
||||||
from pyspapi.types import Item
|
|
||||||
|
|
||||||
spapi = SPAPI(card_id="CARD_ID", token="TOKEN")
|
api = pyspapi.API(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():
|
async def main():
|
||||||
print(
|
print(await api.payment(amount=1,
|
||||||
await spapi.create_payment(
|
redirect_url='https://www.google.com/',
|
||||||
items=items,
|
webhook_url='https://www.google.com/',
|
||||||
redirect_url="https://www.google.com/",
|
data='some-data'
|
||||||
webhook_url="https://www.google.com/",
|
)
|
||||||
data="some-data",
|
)
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
asyncio.run(main())
|
loop.run_until_complete(main())
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
|
import pyspapi
|
||||||
import asyncio
|
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():
|
async def main():
|
||||||
new_balance = await spapi.create_transaction(
|
print(await api.transaction(receiver=12345,
|
||||||
receiver="20199", amount=1, comment="test"
|
amount=1,
|
||||||
)
|
comment="test"
|
||||||
print(new_balance)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
asyncio.run(main())
|
loop.run_until_complete(main())
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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())
|
|
||||||
5
examples/webhook_listener.py
Normal file
5
examples/webhook_listener.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import pyspapi
|
||||||
|
|
||||||
|
api = pyspapi.API(card_id='your_card_id', token='your_token')
|
||||||
|
|
||||||
|
api.listener(host='myhost.com', port=80, webhook_path='/webhook/')
|
||||||
10
examples/webhook_verify.py
Normal file
10
examples/webhook_verify.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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
1826
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,53 +1,4 @@
|
|||||||
"""
|
from .api import API
|
||||||
SPWorlds API Wrapper
|
from .types import SPUser, MojangProfile, Skin, UsernameToUUID
|
||||||
~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
High-level client for interacting with the SPWorlds API.
|
__version__ = "3.0.0a0"
|
||||||
|
|
||||||
: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")
|
|
||||||
|
|||||||
182
pyspapi/api.py
Normal file
182
pyspapi/api.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
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']
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from .api import APISession
|
|
||||||
|
|
||||||
__all__ = ["APISession"]
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
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)
|
|
||||||
29
pyspapi/errors.py
Normal file
29
pyspapi/errors.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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)
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
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"},
|
|
||||||
)
|
|
||||||
31
pyspapi/request.py
Normal file
31
pyspapi/request.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
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()
|
|
||||||
63
pyspapi/types.py
Normal file
63
pyspapi/types.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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'])
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from pyspapi.types.me import Account
|
|
||||||
from pyspapi.types.payment import Item
|
|
||||||
from pyspapi.types.users import Cards, User
|
|
||||||
|
|
||||||
__all__ = ["Account", "Item", "Cards", "User"]
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
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})>"
|
|
||||||
)
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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__)
|
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
requests==2.28.1
|
||||||
|
aiohttp>=3.8.0,<4.0.0
|
||||||
47
setup.py
Normal file
47
setup.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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',
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user