diff --git a/.gitignore b/.gitignore index 9800e1f6..86f1e043 100644 --- a/.gitignore +++ b/.gitignore @@ -5,17 +5,11 @@ __pycache__/ garbage.py # C extensions *.so -glQiwiApi/test2.py test.py -web_test.py -bot -glQiwiApi/tranzoo +test2.py setup.py */test.* *.pem -glQiwiApi/webapp.py -glQiwiApi/templates -glQiwiApi/web # Distribution / packaging .Python build/ diff --git a/README.md b/README.md index 8c678ce9..90c0e536 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ pip install glQiwiApi |aiofiles | saving receipts in pdf | |uvloop | Optional(can boost API), but work only on Linux| |pydantic | Json data validator. Very fast instead of custom| +|loguru | library which aims to bring enjoyable logging in Python| --- @@ -116,23 +117,23 @@ from glQiwiApi import QiwiWrapper async def main(): - # You can pass on only p2p tokens, if you want to use only p2p api - async with QiwiWrapper( - secret_p2p="your_secret_p2p" - ) as w: - # Таким образом можно создать p2p счет - # В примере указан счёт на 1 рубль с комментарием some_comment - bill = await w.create_p2p_bill( - amount=1, - comment='my_comm' - ) - # Проверка на статус "оплачено" созданного p2p счёта - if (await w.check_p2p_bill_status(bill_id=bill.bill_id)) == 'PAID': - print('Успешно оплачено') - else: - print('Транзакция не найдена') - # Или, начиная с версии апи 0.2.0 - print(await bill.check()) # This will print you bool answer + # You can pass on only p2p tokens, if you want to use only p2p api + async with QiwiWrapper( + secret_p2p="your_secret_p2p" + ) as w: + # Таким образом можно создать p2p счет + # В примере указан счёт на 1 рубль с комментарием some_comment + bill = await w.create_p2p_bill( + amount=1, + comment='my_comm' + ) + # Проверка на статус "оплачено" созданного p2p счёта + if (await w.check_p2p_bill_status(bill_id=bill.bill_id)) == 'PAID': + print('Успешно оплачено') + else: + print('Транзакция не найдена') + # Или, начиная с версии апи 0.2.0 + print(await bill.paid) # This will print you bool answer asyncio.run(main()) @@ -175,9 +176,9 @@ asyncio.run(main()) ## 🌟Webhooks & handlers ```python -import logging from glQiwiApi import QiwiWrapper, types +from glQiwiApi.utils import executor wallet = QiwiWrapper( api_access_token='token from https://qiwi.com/api/', @@ -194,22 +195,16 @@ async def get_transaction(event: types.WebHook): async def fetch_bill(notification: types.Notification): print(notification) - -FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - -wallet.start_webhook( - port=80, - level=logging.INFO, - format=FORMAT -) - + +executor.start_webhook(wallet, port=80) ``` -## 🧑🏻‍🔬Polling updates +## 🧑🏻🔬Polling updates ```python import datetime from glQiwiApi import QiwiWrapper, types +from glQiwiApi.utils import executor api_access_token = "your token" phone_number = "your number" @@ -229,7 +224,8 @@ def on_startup(wrapper: QiwiWrapper): wrapper.dispatcher.logger.info("This message logged on startup") -wallet.start_polling( +executor.start_polling( + wallet, get_updates_from=datetime.datetime.now() - datetime.timedelta(hours=1), on_startup=on_startup ) diff --git a/SETUP_REQUIREMENTS.txt b/SETUP_REQUIREMENTS.txt new file mode 100644 index 00000000..02b32f53 --- /dev/null +++ b/SETUP_REQUIREMENTS.txt @@ -0,0 +1,7 @@ +pytz==2021.1 +aiofiles==0.6.0 +aiohttp==3.7.4post0 +pydantic==1.8.2 +wheel==0.36.2 +loguru==0.5.3 + diff --git a/docs/API.rst b/docs/API.rst index 63289353..58fdb831 100644 --- a/docs/API.rst +++ b/docs/API.rst @@ -1,15 +1,15 @@ .. currentmodule:: glQiwiApi -=== -API -=== +============= +API reference +============= Payment wrappers ---------------- .. automodule:: glQiwiApi :members: QiwiWrapper, YooMoneyAPI, QiwiMaps -QiwiWebhooks +Qiwi Webhooks ------------ .. automodule:: glQiwiApi.core.web_hooks.filter :members: @@ -21,6 +21,14 @@ QiwiWebhooks :members: +.. _Executor overview: + +Polling +------- +.. automodule:: glQiwiApi.utils.executor + :members: + + Low level API ------------- .. automodule:: glQiwiApi.core.basic_requests_api @@ -31,12 +39,20 @@ Low level API .. automodule:: glQiwiApi.core.storage :members: - + + +Aiogram integration +------------------- +.. automodule:: glQiwiApi.core.builtin.telegram + :members: + + Synchronous adapter ------------------- .. automodule:: glQiwiApi.utils.basics :members: sync, _await_sync, _run_forever_safe + Exceptions ---------- .. automodule:: glQiwiApi.utils.exceptions diff --git a/docs/conf.py b/docs/conf.py index f24a38cb..f06a26ba 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,6 +42,7 @@ 'sphinx.ext.autodoc', 'sphinx.ext.ifconfig', "sphinx.ext.napoleon", + 'sphinx.ext.autosectionlabel' ] # Add any paths that contain templates here, relative to this directory. @@ -130,15 +131,15 @@ # # The paper size ('letterpaper' or 'a4paper'). # # # # 'papersize': 'letterpaper', - +# # # The font size ('10pt', '11pt' or '12pt'). # # # # 'pointsize': '10pt', - +# # # Additional stuff for the LaTeX preamble. # # # # 'preamble': '', - +# # # Latex figure (float) alignment # # # # 'figure_align': 'htbp', diff --git a/docs/examples/qiwi/polling.rst b/docs/examples/qiwi/polling.rst index 2cf3bb50..c227e602 100644 --- a/docs/examples/qiwi/polling.rst +++ b/docs/examples/qiwi/polling.rst @@ -2,20 +2,29 @@ Polling updates ================ -.. tip:: 👩🏻‍🎨 ``start_polling`` has the same signature as ``start_webhook`` +.. tip:: 👩🏻🎨 ``start_polling`` has the same signature as ``start_webhook`` "under the hood" -👩🏻‍🔬This API method gives a chance to hook updates without webhooks on your machine, +👩🏻🔬This API method gives a chance to hook updates without webhooks on your machine, but it's possible to use both approaches anyway. -🧑‍🎓 *First of all, before starting polling, you need to register handlers, just like when installing webhooks* +🧑🎓 *First of all, before starting polling, you need to register handlers, just like when installing webhooks.* +Lets do it with decorators, *but we also can do it another way, using* ``wallet.dispatcher.register_transaction_handler`` .. literalinclude:: ./../../../examples/qiwi/polling.py :caption: Add handlers :language: python - :emphasize-lines: 14-16 + :emphasize-lines: 15-17 -👨‍🔬 Then, you can start polling, but, let's make it clear which arguments you should pass on to ``start_polling`` method. +🧙♀️So, we also have to import ``executor`` :ref:`Executor overview` and pass on our client, +that contains user-friendly functions ``start_polling`` and ``start_webhook``. + +.. literalinclude:: ./../../../examples/qiwi/polling.py + :caption: import executor module + :language: python + :emphasize-lines: 4, 24 + +👨🔬 Then, you can start polling, but, let's make it clear which arguments you should pass on to ``start_polling`` function. So, in this example we see ``get_updates_from`` and ``on_startup``, it means, that in example we want to receive notifications that came an hour ago and execute some function on startup of polling updates @@ -23,21 +32,27 @@ ago and execute some function on startup of polling updates .. literalinclude:: ./../../../examples/qiwi/polling.py :caption: Args of polling :language: python - :emphasize-lines: 23-26 + :emphasize-lines: 26-27 😼 As you can see, in the example we have a function that we pass as an argument to ``on_startup``. - As you may have guessed, this function will be executed at the beginning of the polling. .. literalinclude:: ./../../../examples/qiwi/polling.py :caption: Args of polling :language: python - :emphasize-lines: 19-20,25 + :emphasize-lines: 20-21,27 😻 If you did everything correctly, you will get something like this .. code-block:: bash - 2021-05-13 16:27:22,921 - [INFO] - Start polling! - 2021-05-13 16:27:22,922 - [INFO] - This message came on startup - 2021-05-13 16:27:23,938 - [INFO] - Stop polling! + 2021-06-05 17:21:07.423 | DEBUG | glQiwiApi.utils.executor:welcome:373 - Start polling! + +🧚♀️ *Also, you can very easily implement simultaneous polling of updates from both aiogram and QIWI API.* + +In the example below, we catch all text messages and return the same "Hello" response. + +.. literalinclude:: ./../../../examples/qiwi/aiogram_integration.py + :caption: polling together with aiogram + :language: python + :emphasize-lines: 12-13,38,21-23,1-2,5,6 diff --git a/examples/other/without_context.py b/examples/other/without_context.py index d73809d6..9de15393 100644 --- a/examples/other/without_context.py +++ b/examples/other/without_context.py @@ -16,7 +16,7 @@ async def main(): bill = await wallet.create_p2p_bill(amount=1) # new version - new_status = await bill.check() + new_status = await bill.paid # old version old_status = (await wallet.check_p2p_bill_status(bill.bill_id)) == 'PAID' assert new_status == old_status diff --git a/examples/qiwi/aiogram_integration.py b/examples/qiwi/aiogram_integration.py new file mode 100644 index 00000000..cabdb513 --- /dev/null +++ b/examples/qiwi/aiogram_integration.py @@ -0,0 +1,39 @@ +from aiogram import Bot, Dispatcher +from aiogram import types + +from glQiwiApi import QiwiWrapper +from glQiwiApi.core.builtin import TelegramPollingProxy +from glQiwiApi.types import Transaction +from glQiwiApi.utils import executor + +api_access_token = "your token" +phone_number = "your number" + +bot = Bot("token from BotFather") +dp = Dispatcher(bot) + +wallet = QiwiWrapper( + api_access_token=api_access_token, + phone_number=phone_number +) + + +@dp.message_handler() +async def message_handler(msg: types.Message): + await msg.answer(text="Привет😇") + + +@wallet.transaction_handler() +async def my_first_handler(update: Transaction): + assert isinstance(update, Transaction) + + +def on_startup(wrapper: QiwiWrapper): + wrapper.dispatcher.logger.info("This message logged on startup") + + +executor.start_polling( + wallet, + on_startup=on_startup, + tg_app=TelegramPollingProxy(dp) +) diff --git a/examples/qiwi/p2p.py b/examples/qiwi/p2p.py index 431c3d27..a1569bba 100644 --- a/examples/qiwi/p2p.py +++ b/examples/qiwi/p2p.py @@ -17,19 +17,19 @@ async def p2p_usage(): status_1 = (await w.check_p2p_bill_status( bill_id=bill.bill_id )) == 'PAID' - # Или, начиная с версии 0.2.0 - status_2 = await bill.check() + # Или можно так(выглядит лаконичнее на мой взгляд) + status_2 = await bill.paid print(status_1 == status_2) # Это выдаст ошибку, так как не передан api_access_token и phone_number # Вы можете в любой момент переназначить токен или номер try: - await w.get_bills(rows=50) + await w.get_bills(rows_num=50) except RequestError as ex: print(ex) # Переназначаем токены w.api_access_token = 'TOKEN from https://qiwi.api' w.phone_number = '+NUMBER' - print(await w.get_bills(rows=20)) + print(await w.get_bills(rows_num=20)) asyncio.run(p2p_usage()) diff --git a/examples/qiwi/polling.py b/examples/qiwi/polling.py index 9b6765c7..556ea2fe 100644 --- a/examples/qiwi/polling.py +++ b/examples/qiwi/polling.py @@ -1,6 +1,7 @@ import datetime from glQiwiApi import QiwiWrapper, types +from glQiwiApi.utils import executor api_access_token = "your token" phone_number = "your number" @@ -20,7 +21,8 @@ def on_startup(wrapper: QiwiWrapper): wrapper.dispatcher.logger.info("This message logged on startup") -wallet.start_polling( +executor.start_polling( + wallet, get_updates_from=datetime.datetime.now() - datetime.timedelta(hours=1), on_startup=on_startup ) diff --git a/examples/qiwi/pretty_webhook.py b/examples/qiwi/pretty_webhook.py index 952bfe84..55c34805 100644 --- a/examples/qiwi/pretty_webhook.py +++ b/examples/qiwi/pretty_webhook.py @@ -1,6 +1,5 @@ -import logging - from glQiwiApi import QiwiWrapper, types +from glQiwiApi.utils import executor wallet = QiwiWrapper( api_access_token='token from https://qiwi.com/api/', @@ -18,10 +17,4 @@ async def fetch_bill(notification: types.Notification): print(notification) -FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - -wallet.start_webhook( - port=80, - level=logging.INFO, - format=FORMAT -) +executor.start_webhook(wallet, port=80) diff --git a/examples/qiwi/qiwi_webhook.py b/examples/qiwi/qiwi_webhook.py index f2599134..a41ba368 100644 --- a/examples/qiwi/qiwi_webhook.py +++ b/examples/qiwi/qiwi_webhook.py @@ -4,6 +4,7 @@ from glQiwiApi import QiwiWrapper, types from glQiwiApi.core.web_hooks.config import Path +from glQiwiApi.utils import executor TOKEN = 'token from https://qiwi.com/api/' QIWI_SECRET = 'secret token from https://qiwi.com/p2p-admin/' @@ -47,12 +48,10 @@ async def main2(event: types.Notification): bill_path="/my_webhook/" ) -wallet.start_webhook( +executor.start_webhook( + wallet, # You can pass on any port, but it must be open for web # You can use any VPS server to catching webhook or # your configured local machine - port=8080, - level=logging.INFO, - format=FORMAT, path=path ) diff --git a/examples/qiwi/usage_in_bots.py b/examples/qiwi/usage_in_bots.py index dfe623b3..44f91da7 100644 --- a/examples/qiwi/usage_in_bots.py +++ b/examples/qiwi/usage_in_bots.py @@ -37,7 +37,7 @@ async def payment(message: types.Message, state: FSMContext): async def successful_payment(message: types.Message, state: FSMContext): async with state.proxy() as data: bill: qiwi_types.Bill = data.get('bill') - status = await bill.check() + status = await bill.paid if status: await message.answer('Вы успешно оплатили счет') await state.finish() diff --git a/glQiwiApi/__init__.py b/glQiwiApi/__init__.py index cc8bbd6c..cc54dac3 100644 --- a/glQiwiApi/__init__.py +++ b/glQiwiApi/__init__.py @@ -1,11 +1,11 @@ import sys from .qiwi import QiwiWrapper, QiwiMaps # NOQA -from .utils.basics import sync # NOQA +from .utils.basics import sync, async_as_sync # NOQA from .utils.exceptions import * # NOQA from .yoo_money import YooMoneyAPI # NOQA -__version__ = '0.2.2' +__version__ = '1.0.0' __all__ = ( ( @@ -13,6 +13,7 @@ 'YooMoneyAPI', 'QiwiMaps', 'sync', + 'async_as_sync' ) + utils.exceptions.__all__ # NOQA ) diff --git a/glQiwiApi/core/__init__.py b/glQiwiApi/core/__init__.py index fd387312..98f5fa98 100644 --- a/glQiwiApi/core/__init__.py +++ b/glQiwiApi/core/__init__.py @@ -5,7 +5,7 @@ ) from .aiohttp_custom_api import RequestManager from .basic_requests_api import HttpXParser -from .core_mixins import BillMixin, ToolsMixin +from .core_mixins import ToolsMixin, ContextInstanceMixin from .storage import Storage __all__ = ( @@ -14,7 +14,8 @@ 'AbstractParser', 'BaseStorage', 'AioTestCase', - 'BillMixin', + 'RequestManager', + 'executor', 'ToolsMixin', - 'RequestManager' + 'ContextInstanceMixin' ) diff --git a/glQiwiApi/core/abstracts.py b/glQiwiApi/core/abstracts.py index 2c4edc27..c8e97ecd 100644 --- a/glQiwiApi/core/abstracts.py +++ b/glQiwiApi/core/abstracts.py @@ -1,13 +1,15 @@ +from __future__ import annotations + import abc import asyncio +import typing import unittest -from typing import AsyncGenerator, Optional, Dict, Any, Union, List, Tuple +from types import TracebackType +from typing import AsyncGenerator, Optional, Dict, Any, Union, List, Tuple, Type -import aiohttp -from aiohttp import ClientSession, web +from aiohttp import web from aiohttp.typedefs import LooseCookies -from glQiwiApi import types from glQiwiApi.core.web_hooks.dispatcher import Dispatcher from glQiwiApi.types import Response @@ -29,16 +31,15 @@ def __call__(cls, *args, **kwargs): return cls._instances[cls] -class BaseStorage(abc.ABC): +T = typing.TypeVar("T") + + +class BaseStorage(abc.ABC, typing.Generic[T]): """ Абстрактный класс контроллера кэша """ - @abc.abstractmethod - def get_current(self, key: str) -> Any: - raise NotImplementedError - @abc.abstractmethod def clear(self, key: str, force: bool = False) -> Any: raise NotImplementedError @@ -65,7 +66,7 @@ def _format_url_kwargs(url_: str, **kwargs: Any) -> Optional[str]: try: return url_.format(**kwargs) except KeyError: - raise ValueError("Bad kwargs for url build") + raise RuntimeError("Bad kwargs for url build") @abc.abstractmethod def build_url(self, api_method: str, **kwargs: Any) -> str: @@ -101,10 +102,9 @@ def __getattribute__(self, item): class AbstractParser(abc.ABC): """ Abstract class of parser for send request to different API's""" - session: Optional[aiohttp.ClientSession] = None @abc.abstractmethod - async def _request( + async def _make_request( self, url: str, get_json: bool, @@ -122,7 +122,9 @@ async def _request( Dict[str, Union[str, int, List[ Union[str, int] ]]] - ]) -> Response: + ], + get_bytes: bool, + **kwargs) -> Response: raise NotImplementedError @abc.abstractmethod @@ -135,6 +137,13 @@ async def fetch( """ You can override it, but is must to return an AsyncGenerator """ raise NotImplementedError + @abc.abstractmethod + async def close(self) -> None: # pragma: no cover + """ + Close client session + """ + pass + def raise_exception( self, status_code: str, @@ -143,13 +152,16 @@ def raise_exception( ) -> None: """Метод для обработки исключений и лучшего логирования""" - def create_session(self, **kwargs) -> None: - """ Creating new session if old was close or it's None """ - if self.session is None: - self.session = ClientSession(**kwargs) - elif isinstance(self.session, ClientSession): - if self.session.closed: - self.session = ClientSession(**kwargs) + async def __aenter__(self) -> AbstractParser: + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + await self.close() class BaseWebHookView(web.View): @@ -208,9 +220,7 @@ async def post(self) -> Any: await self.handler_manager.process_event(update) - def _hash_validator(self, update: Union[ - types.Notification, types.WebHook - ]) -> None: + def _hash_validator(self, update) -> None: """ Validating by hash of update """ @property diff --git a/glQiwiApi/core/aiohttp_custom_api.py b/glQiwiApi/core/aiohttp_custom_api.py index 1d209194..8ef2d434 100644 --- a/glQiwiApi/core/aiohttp_custom_api.py +++ b/glQiwiApi/core/aiohttp_custom_api.py @@ -1,9 +1,8 @@ -from typing import Dict, Optional, Any, Union +from typing import Dict, Optional, Any, Union, NoReturn import aiohttp -import glQiwiApi -from glQiwiApi.core.basic_requests_api import HttpXParser +from glQiwiApi.core.basic_requests_api import HttpXParser, _ProxyType from glQiwiApi.core.storage import Storage from glQiwiApi.types import Response from glQiwiApi.types.basics import Cached, DEFAULT_CACHE_TIME @@ -12,67 +11,76 @@ class RequestManager(HttpXParser): """ - Немного переделанный наследник HttpXParser - под платежные системы и кэширование запросов + Deal with :class:`Storage`, + caching queries and managing stable work of sending requests """ - __slots__ = ('without_context', 'messages', '_cache', '_cached_key') + __slots__ = ( + 'without_context', 'messages', '_cache', '_should_reset_connector', + '_connector_type', '_connector_init', '_proxy' + ) def __init__( self, without_context: bool = False, messages: Optional[Dict[str, str]] = None, - cache_time: Union[float, int] = DEFAULT_CACHE_TIME + cache_time: Union[float, int] = DEFAULT_CACHE_TIME, + proxy: Optional[_ProxyType] = None ) -> None: - super(RequestManager, self).__init__() + super(RequestManager, self).__init__(proxy=proxy) + self.without_context: bool = without_context self.messages: Optional[Dict[str, str]] = messages - self._cache: Storage = Storage(cache_time) - self._cached_key: str = "session" + self._cache: Storage = Storage(cache_time=cache_time) - def clear_cache(self) -> None: + def reset_cache(self) -> None: """ Clear all cache in storage """ self._cache.clear(force=True) - def get_cached_session(self) -> Optional[aiohttp.ClientSession]: - """ Get cached session from storage """ - cached = self._cache[self._cached_key] - if self.check_session(cached): - return cached - return None - - def set_cached_session(self): - cached_session = self.get_cached_session() - if cached_session: - self.session = cached_session + async def make_request(self, **kwargs) -> Response: + """ The user-friendly method that allows sending requests to any URL """ + return await super(RequestManager, self)._make_request(**kwargs) - async def _request(self, *args, **kwargs) -> Response: + async def _make_request(self, *args, **kwargs) -> Response: + """ Send request to service(API) """ # Получаем текущий кэш используя ссылку как ключ - response = self._cache.get_current(kwargs.get('url')) + response = self._cache[(kwargs.get('url'))] if not self._cache.validate(kwargs): - self.set_cached_session() - response = await super()._request(*args, **kwargs) + try: + response = await super()._make_request(*args, **kwargs) + except aiohttp.ContentTypeError: + raise RequestError(message="Unexpected error. Cannot deserialize answer.", + status_code="unknown") + # Проверяем, не был ли запрос в кэше, если нет, # то проверяем статус код и если он не 200 - выбрасываем ошибку if not isinstance(response, Cached): + await self._close_session() if response.status_code != 200: - await self._close_session() self.raise_exception( str(response.status_code), json_info=response.response_data ) - await self._close_session() self._cache_all(response, kwargs) return response + async def _close_session(self): + if self.without_context: + await super(RequestManager, self).close() + + async def close(self) -> None: + """ Close aiohttp session and reset cache data """ + await super(RequestManager, self).close() + self.reset_cache() + def raise_exception( self, status_code: str, json_info: Optional[Dict[str, Any]] = None, message: Optional[str] = None - ) -> None: + ) -> NoReturn: """ Raise RequestError exception with pretty explanation """ if not isinstance(message, str): if self.messages is not None: @@ -80,45 +88,25 @@ def raise_exception( raise RequestError( message, status_code, - additional_info=f"{glQiwiApi.__version__} version api", + additional_info=f"0.2.23 version api", json_info=json_info ) - def cache_session(self, session: Optional[aiohttp.ClientSession]) -> None: - if self.check_session(session): - self._cache.update_data( - obj_to_cache=session, - key='session' - ) - - async def _close_session(self) -> None: - if self.without_context: - await self.session.close() - def _cache_all(self, response: Response, kwargs: Dict[Any, Any]): - resolved = self._cache.initialize_response_to_resolve( + resolved: Cached = self._cache.convert_to_cache( result=response.response_data, kwargs=kwargs, status_code=response.status_code ) - self._cache.update_data( - key=None, - obj_to_cache=resolved - ) - self.cache_session(self.session) + self._cache[kwargs["url"]] = resolved - @staticmethod - def check_session(session: Any) -> bool: - if isinstance(session, aiohttp.ClientSession): - if not session.closed: + @property + def is_session_closed(self) -> bool: + if isinstance(self._session, aiohttp.ClientSession): + if not self._session.closed: return True return False - def create_session(self, **kwargs) -> None: - """ Create new session or get it from cache """ - self.set_cached_session() - super().create_session(**kwargs) - @classmethod def filter_dict(cls, dictionary: dict) -> dict: """ diff --git a/glQiwiApi/core/basic_requests_api.py b/glQiwiApi/core/basic_requests_api.py index bd88381c..f5fcfc72 100644 --- a/glQiwiApi/core/basic_requests_api.py +++ b/glQiwiApi/core/basic_requests_api.py @@ -1,39 +1,87 @@ +from __future__ import annotations + from asyncio import as_completed, set_event_loop_policy from itertools import repeat from typing import ( - Dict, - AsyncGenerator, - NoReturn, Coroutine, Any + AsyncGenerator ) -from typing import Optional, List, Union +from typing import Dict, Optional, Any, Union, Tuple, Type, Iterable, cast, List import aiohttp from aiohttp import ( ClientTimeout, ClientProxyConnectionError, ServerDisconnectedError, - ContentTypeError + ClientConnectionError, ClientSession ) -from aiohttp.hdrs import USER_AGENT from aiohttp.typedefs import LooseCookies from glQiwiApi.core import AbstractParser from glQiwiApi.core.constants import DEFAULT_TIMEOUT from glQiwiApi.types import Response -from glQiwiApi.types.basics import Cached + +_ProxyBasic = Union[str, Tuple[str, aiohttp.BasicAuth]] +_ProxyChain = Iterable[_ProxyBasic] +_ProxyType = Union[_ProxyChain, _ProxyBasic] + + +def _retrieve_basic(basic: _ProxyBasic) -> Dict[str, Any]: + from aiohttp_socks.utils import parse_proxy_url # type: ignore + + proxy_auth: Optional[aiohttp.BasicAuth] = None + + if isinstance(basic, str): + proxy_url = basic + else: + proxy_url, proxy_auth = basic + + proxy_type, host, port, username, password = parse_proxy_url(proxy_url) + if isinstance(proxy_auth, aiohttp.BasicAuth): + username = proxy_auth.login + password = proxy_auth.password + + return dict( + proxy_type=proxy_type, + host=host, + port=port, + username=username, + password=password, + rdns=True, + ) + + +def _prepare_connector( + chain_or_plain: _ProxyType +) -> Tuple[Type["aiohttp.TCPConnector"], Dict[str, Any]]: + from aiohttp_socks import ChainProxyConnector, ProxyConnector, ProxyInfo # type: ignore + + # since tuple is Iterable(compatible with _ProxyChain) object, we assume that + # user wants chained proxies if tuple is a pair of string(url) and BasicAuth + if isinstance(chain_or_plain, str) or ( + isinstance(chain_or_plain, tuple) and len(chain_or_plain) == 2 + ): + chain_or_plain = cast(_ProxyBasic, chain_or_plain) + return ProxyConnector, _retrieve_basic(chain_or_plain) + + chain_or_plain = cast(_ProxyChain, chain_or_plain) + infos: List[ProxyInfo] = [] + for basic in chain_or_plain: + infos.append(ProxyInfo(**_retrieve_basic(basic))) + + return ChainProxyConnector, dict(proxy_infos=infos) class HttpXParser(AbstractParser): """ - Обвертка над aiohttp + Aiohttp wrapper, implements the method of sending a request """ _sleep_time = 2 - def __init__(self) -> None: + def __init__(self, proxy: Optional[_ProxyType] = None) -> None: self.base_headers = { - 'User-Agent': USER_AGENT, + 'User-Agent': "glQiwiApi/1.0beta", 'Accept-Language': "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7" } self._timeout = ClientTimeout( @@ -42,8 +90,35 @@ def __init__(self) -> None: sock_connect=5, sock_read=None ) + self._session: Optional[ClientSession] = None + self._connector_type: Type[aiohttp.TCPConnector] = aiohttp.TCPConnector + self._connector_init: Dict[str, Any] = {} + self._should_reset_connector = False # flag determines connector state + self._proxy: Optional[_ProxyType] = None + + if proxy is not None: + try: + self._setup_proxy_connector(proxy) + except ImportError as exc: # pragma: no cover + raise RuntimeError( + "In order to use aiohttp client for proxy requests, install " + "https://pypi.org/project/aiohttp-socks/" + ) from exc - async def _request( + def _setup_proxy_connector(self, proxy: _ProxyType) -> None: + self._connector_type, self._connector_init = _prepare_connector(proxy) + self._proxy = proxy + + @property + def proxy(self) -> Optional[_ProxyType]: + return self._proxy + + @proxy.setter + def proxy(self, proxy: _ProxyType) -> None: + self._setup_proxy_connector(proxy) + self._should_reset_connector = True + + async def _make_request( self, url: str, get_json: bool = False, @@ -56,19 +131,16 @@ async def _request( Union[str, int] ]]] ] = None, - headers: Optional[Dict[str, Union[str, int]]] = None, + headers: Optional[dict] = None, params: Optional[ Dict[str, Union[str, int, List[ Union[str, int] ]]] - ] = None) -> Response: + ] = None, + get_bytes: bool = False, + **kwargs) -> Response: """ - Метод для отправки запроса, - может возвращать в Response ProxyError в качестве response_data, - это означает, что вы имеете проблемы с подключением к прокси, - возможно нужно добавить дополнительные post данные, - если вы используете method = POST, или headers, - если запрос GET + Send request to some url. Method has a similar signature with the `aiohttp.request` :param url: ссылка, куда вы хотите отправить ваш запрос @@ -76,20 +148,25 @@ async def _request( в формате json :param method: Тип запроса :param data: payload data - :param cookies: - :param headers: - aiohttp.ClientSession initialization + :param set_timeout: + :param json: + :param cookies: куки запроса + :param headers: заголовки запроса + :param params: + :param get_bytes: указывает на то, хотите ли вы получить ответ + в байтах + :param kwargs: :return: Response instance """ - headers = self.get_headers(headers) + headers = headers or self.base_headers # Create new session if old was closed - self.create_session( + await self.create_session( timeout=self._timeout if set_timeout else DEFAULT_TIMEOUT ) # sending query to some endpoint url try: - response = await self.session.request( + response = await self._session.request( method=method, url=url, data=data, @@ -97,22 +174,23 @@ async def _request( json=json if isinstance(json, dict) else None, cookies=cookies, params=params, + **kwargs ) except ( ClientProxyConnectionError, - ServerDisconnectedError + ServerDisconnectedError, + ClientConnectionError ): self.raise_exception(status_code='400_special_bad_proxy') return Response.bad_response() # Get content and return response - try: - data = await response.json( - content_type="application/json" - ) - except ContentTypeError: - if get_json: - return Response(status_code=response.status) + if get_json: + data = await response.json() + elif get_bytes: data = await response.read() + else: + data = await response.text() + return Response( status_code=response.status, response_data=data, @@ -124,11 +202,6 @@ async def _request( url=response.url.__str__() ) - def get_headers(self, headers: Optional[dict]) -> Optional[dict]: - if isinstance(headers, dict): - return headers - return self.base_headers - async def fetch( self, *, @@ -141,15 +214,15 @@ async def fetch( async for response in parser.fetch(): print(response) - :param times: int of quantity requests + :param times: quantity requests :param kwargs: HttpXParser._request kwargs :return: """ - coroutines = [self._request(**kwargs) for _ in repeat(None, times)] + coroutines = [self._make_request(**kwargs) for _ in repeat(None, times)] for future in as_completed(fs=coroutines): yield await future - def fast(self) -> 'HttpXParser': + def fast(self) -> HttpXParser: """ Method to fetching faster with using faster event loop(uvloop) \n USE IT ONLY ON LINUX SYSTEMS, @@ -162,6 +235,38 @@ def fast(self) -> 'HttpXParser': set_event_loop_policy(EventLoopPolicy()) except ImportError: # Catching import error and forsake standard policy - from asyncio import DefaultEventLoopPolicy as EventLoopPolicy + from asyncio import DefaultEventLoopPolicy as EventLoopPolicy # type: ignore set_event_loop_policy(EventLoopPolicy()) return self + + async def create_session(self, **kwargs) -> Optional[aiohttp.ClientSession]: + """ Creating new session if old was close or it's None """ + if self.proxy is not None: + kwargs.update(connector=self._connector_type(**self._connector_init)) + + if self._should_reset_connector and isinstance(self._session, ClientSession): + await self._session.close() + + if not isinstance(self._session, ClientSession): + self._session = ClientSession(**kwargs) + self._should_reset_connector = False + elif isinstance(self._session, ClientSession): + if self._session.closed: + self._session = ClientSession(**kwargs) + self._should_reset_connector = False + return self._session + + async def close(self) -> None: + """ close aiohttp session""" + if isinstance(self._session, ClientSession): + if not self._session.closed: + await self._session.close() + + async def stream_content( + self, url: str, timeout: int, chunk_size: int + ) -> AsyncGenerator[bytes, None]: + await self.create_session() + + async with self._session.get(url, timeout=timeout) as resp: + async for chunk in resp.content.iter_chunked(chunk_size): + yield chunk diff --git a/glQiwiApi/core/builtin/__init__.py b/glQiwiApi/core/builtin/__init__.py new file mode 100644 index 00000000..03c4e1d6 --- /dev/null +++ b/glQiwiApi/core/builtin/__init__.py @@ -0,0 +1,12 @@ +from .filters import bill_webhook_filter, transaction_webhook_filter +from .logger import InterceptHandler +from .telegram import TelegramPollingProxy, TelegramWebhookProxy, BaseProxy + +__all__ = ( + 'TelegramPollingProxy', + 'TelegramWebhookProxy', + 'BaseProxy', + 'bill_webhook_filter', + 'transaction_webhook_filter', + 'InterceptHandler' +) diff --git a/glQiwiApi/core/builtin/filters.py b/glQiwiApi/core/builtin/filters.py new file mode 100644 index 00000000..6e9ce7c1 --- /dev/null +++ b/glQiwiApi/core/builtin/filters.py @@ -0,0 +1,12 @@ +import glQiwiApi.types.qiwi_types as types +from glQiwiApi.core.web_hooks.filter import Filter + +# Default filter for transaction handler +transaction_webhook_filter: Filter = Filter( + lambda update: isinstance(update, (types.WebHook, types.Transaction)) +) + +# Default filter for bill handler +bill_webhook_filter: Filter = Filter( + lambda update: isinstance(update, types.Notification) +) diff --git a/glQiwiApi/core/builtin/logger.py b/glQiwiApi/core/builtin/logger.py new file mode 100644 index 00000000..e40fc5ef --- /dev/null +++ b/glQiwiApi/core/builtin/logger.py @@ -0,0 +1,27 @@ +import logging + +from loguru import logger + + +class InterceptHandler(logging.Handler): + + def __init__(self, level=logging.NOTSET) -> None: + super(InterceptHandler, self).__init__(level) + + def emit(self, record): + # Get corresponding Loguru level if it exists + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + # Find caller from where originated the logged message + frame, depth = logging.currentframe(), 2 + while frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log( + level, + record.getMessage() + ) diff --git a/glQiwiApi/core/builtin/telegram.py b/glQiwiApi/core/builtin/telegram.py new file mode 100644 index 00000000..72db30b9 --- /dev/null +++ b/glQiwiApi/core/builtin/telegram.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import abc +import typing +from asyncio import AbstractEventLoop + +from aiohttp import web + +from glQiwiApi.utils.basics import take_event_loop + +if typing.TYPE_CHECKING: + try: + from aiogram import Dispatcher + except (ModuleNotFoundError, ImportError): + pass + +__all__ = ["TelegramWebhookProxy", "TelegramPollingProxy", "BaseProxy"] + +# Some aliases =) +ListOfRoutes = typing.List[web.ResourceRoute] +SubApps = typing.List[typing.Tuple[str, web.Application, ListOfRoutes]] + + +def _init_sub_apps_handlers(app: web.Application, routes: ListOfRoutes): + """ + Initialize sub application handlers + + :param app: + :param routes: list of AbstractRoute subclasses + """ + for route in routes: + app.router.add_route( + handler=route.handler, + method=route.method, + name=route.name, + path=route.url_for().path + ) + + +class BaseProxy(abc.ABC): + + def __init__(self, dispatcher: Dispatcher, *, + loop: typing.Optional[AbstractEventLoop] = None): + self.bot = dispatcher.bot + self.dispatcher = dispatcher + + if isinstance(loop, AbstractEventLoop): + if loop.is_closed(): + raise RuntimeError("The event loop, that you have passed on is closed") + self._loop = loop + else: + self._loop = take_event_loop(set_debug=True) + + @abc.abstractmethod + def setup(self, **kwargs: typing.Any) -> typing.Any: + """ + This method should establish the necessary for work + + """ + raise NotImplementedError + + +class TelegramWebhookProxy(BaseProxy): + """ + Managing loading webhooks of aiogram together with QiwiWrapper + + """ + + execution_path: str = "/bot/{token}" + """You can override this parameter to change the default route path""" + + prefix: str = "/tg/webhooks" + """ You can override the prefix for the application """ + + def __init__(self, dispatcher: Dispatcher, sub_apps: typing.Optional[SubApps] = None): + """ + + :param dispatcher: instance of aiogram class Dispatcher + :param sub_apps: list of tuples(prefix as string, web.Application) + """ + from aiogram.dispatcher.webhook import WebhookRequestHandler + + super(TelegramWebhookProxy, self).__init__(dispatcher) + self._app: web.Application = web.Application() + self._app.router.add_route( + '*', self.execution_path, WebhookRequestHandler, + name='webhook_handler' + ) + self._app['BOT_DISPATCHER'] = self.dispatcher + self.sub_apps: SubApps = sub_apps or [] + + def setup(self, **kwargs) -> web.Application: + """ + A method that connects the main application, and the proxy in the form of a telegram + + :param kwargs: keyword arguments, which contains application and host + """ + main_app: web.Application = kwargs.pop("app") + host: str = kwargs.pop("host") + + main_app.add_subapp(self.prefix, self._app) + for prefix, sub_app, handlers in self.sub_apps: + sub_app['bot'] = self.bot + sub_app['dp'] = self.dispatcher + _init_sub_apps_handlers(sub_app, handlers) + main_app.add_subapp(prefix, sub_app) + self._loop.run_until_complete( + self.configure_webhook(host, **kwargs) + ) + + return self._app + + async def configure_webhook(self, host: str, **kwargs) -> typing.Any: + """ + You can override this method to correctly setup webhooks with aiogram + API method `set_webhook` like this: self.dispatcher.bot.set_webhook() + + """ + + full_url: str = host + self.prefix + + if isinstance(self.execution_path, str): + full_url += self.execution_path + + await self.dispatcher.bot.set_webhook(full_url, **kwargs) + + +class TelegramPollingProxy(BaseProxy): + """ + Builtin telegram proxy. + Allows you to use Telegram and QIWI webhooks together + + """ + + def setup(self, **kwargs: typing.Any): + """ + Set up polling to run polling qiwi updates concurrently with aiogram + + :param kwargs: you can pass on loop as key/value parameter + """ + loop = kwargs.pop("loop") or self._loop + loop.create_task(self.dispatcher.start_polling(**kwargs)) diff --git a/glQiwiApi/core/core_mixins.py b/glQiwiApi/core/core_mixins.py index 03b1cbf6..79799c4c 100644 --- a/glQiwiApi/core/core_mixins.py +++ b/glQiwiApi/core/core_mixins.py @@ -1,63 +1,25 @@ -import abc -import copy -from typing import Optional, Any, Awaitable - - -class QiwiProto(abc.ABC): - """ - Class, which replaces standard signature of the QiwiWrapper class - to avoid a circular import. Inherits from abc, because python 3.7 - doesn't support typing.Protocol - - """ - - def __aenter__(self) -> Awaitable[Any]: - ... - - def __aexit__(self, exc_type, exc_val, exc_tb) -> Awaitable[Any]: - ... - - async def check_p2p_bill_status(self, bill_id: Optional[str]) -> str: - ... +from __future__ import annotations +import contextvars +import copy +from typing import Any, TYPE_CHECKING, TypeVar, Optional, cast, Generic, ClassVar, Dict -class BillMixin(object): - """ - Примесь, позволяющая проверять счет, не используя метод QiwiWrapper, - добавляя метод check() объекту Bill - - """ - _w: QiwiProto - bill_id: Optional[str] = None - - def initialize(self, wallet: Any): - self._w = copy.copy(wallet) - return self - - async def check(self) -> bool: - """ - Checking p2p payment - - """ - async with self._w: - return (await self._w.check_p2p_bill_status( - bill_id=self.bill_id - )) == 'PAID' +if TYPE_CHECKING: + from glQiwiApi.core.aiohttp_custom_api import RequestManager class ToolsMixin(object): """ Object: ToolsMixin """ - _requests: Any + _requests: RequestManager async def __aenter__(self): """Создаем сессию, чтобы не пересоздавать её много раз""" - self._requests.create_session() + await self._requests.create_session() return self async def close(self): - if self._requests.session: - await self._requests.session.close() - self._requests.clear_cache() + """ shutdown wrapper, close aiohttp session and clear storage """ + await self._requests.close() async def __aexit__(self, exc_type, exc_val, exc_tb): """Закрываем сессию и очищаем кэш при выходе""" @@ -69,16 +31,88 @@ def _get(self, item: Any) -> Any: except AttributeError: return None - def __deepcopy__(self, memo) -> 'ToolsMixin': + def __new__(cls, *args, **kwargs): + return super().__new__(cls, *args, **kwargs) + + def __deepcopy__(self, memo): cls = self.__class__ - result = cls.__new__(cls) + kw: Dict[str, bool] = {"__copy_signal__": True} + result = cls.__new__(cls, **kw) memo[id(self)] = result - dct = {slot: self._get(slot) for slot in self.__slots__ if - self._get(slot) is not None} + dct = { + slot: self._get(slot) for slot in self.__slots__ if + self._get(slot) is not None + } for k, value in dct.items(): if k == '_requests': - value.session = None + value._session = None elif k == 'dispatcher': - value.loop = None + value._loop = None setattr(result, k, copy.deepcopy(value, memo)) return result + + @property + def data(self): + data = getattr(self, '_data', None) + if data is None: + data = {} + setattr(self, '_data', data) + return data + + def __getitem__(self, item): + return self.data[item] + + def __setitem__(self, key, value): + self.data[key] = value + + def __delitem__(self, key): + del self.data[key] + + def __contains__(self, key): + return key in self.data + + def get(self, key, default=None): + return self.data.get(key, default) + + +ContextInstance = TypeVar("ContextInstance") + + +class ContextInstanceMixin(Generic[ContextInstance]): + __context_instance: ClassVar[contextvars.ContextVar[ContextInstance]] + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__() + cls.__context_instance = contextvars.ContextVar(f"instance_{cls.__name__}") + + @classmethod # noqa: F811 + def get_current( # noqa: F811 + cls, no_error: bool = True + ) -> Optional[ContextInstance]: # pragma: no cover # noqa: F811 + """ Get current instance from context """ + # on mypy 0.770 I catch that contextvars.ContextVar always contextvars.ContextVar[Any] + cls.__context_instance = cast( + contextvars.ContextVar[ContextInstance], cls.__context_instance + ) + + try: + current: Optional[ContextInstance] = cls.__context_instance.get() + except LookupError: + if no_error: + current = None + else: + raise + + return current + + @classmethod + def set_current(cls, value: ContextInstance) -> contextvars.Token[ContextInstance]: + if not isinstance(value, cls): + raise TypeError( + f"Value should be instance of {cls.__name__!r} not {type(value).__name__!r}" + ) + return cls.__context_instance.set(value) + + @classmethod + def reset_current(cls, token: contextvars.Token[ContextInstance]) -> None: + cls.__context_instance.reset(token) diff --git a/glQiwiApi/core/storage.py b/glQiwiApi/core/storage.py index a1d77ae4..dd510b7d 100644 --- a/glQiwiApi/core/storage.py +++ b/glQiwiApi/core/storage.py @@ -3,80 +3,59 @@ from glQiwiApi.core import BaseStorage from glQiwiApi.core.constants import uncached -from glQiwiApi.types import MEMData from glQiwiApi.types.basics import Cached, Attributes -from glQiwiApi.utils.exceptions import InvalidData +MEMData = typing.TypeVar("MEMData", bound=typing.Dict[typing.Any, typing.Any]) -class Storage(BaseStorage): + +class Storage(BaseStorage[MEMData]): """ - Класс, позволяющий кэшировать результаты запросов + Deal with cache and data. Easy to use + + >>> storage = Storage(cache_time=5) + >>> storage["hello_world"] = 5 + >>> print(storage["hello_world"]) # print 5 """ # Доступные критерии, по которым проходит валидацию кэш - available = ('params', 'json', 'data', 'headers') + _validator_args = ('params', 'json', 'data', 'headers') - __slots__ = ('data', '_cache_time', '_default_key') + __slots__ = ('data', '_cache_time') - def __init__( - self, - cache_time: typing.Union[float, int], - default_key: typing.Optional[str] = None - ) -> None: + def __init__(self, *, cache_time: typing.Union[float, int]) -> None: """ :param cache_time: Время кэширования в секундах - :param default_key: дефолтный ключ, по которому будет доставаться кэш """ - if isinstance(cache_time, (int, float)): - if cache_time > 60 or cache_time < 0: - raise InvalidData( - "Время кэширования должно быть в пределах" - " от 0 до 60 секунд" - ) - - self.data: MEMData = MEMData({}) + + self.data: MEMData = dict() self._cache_time = cache_time - self.__initialize_default_key(default_key) - - def __initialize_default_key(self, key: typing.Optional[str]) -> None: - """ Initialize default_key attribute """ - if not isinstance(key, str): - self._default_key = "url" - else: - self._default_key = key - self.data.update({ - self._default_key: {} - }) - - def get_current(self, key: typing.Any) -> typing.Any: - """ Method to get element by key from data """ - try: - obj = self.data.get(self._default_key).get(key) - return obj if not self._is_expire(obj) else None - except AttributeError: - return None - def clear(self, key: typing.Optional[str] = None, + def clear(self, key: typing.Optional[str] = None, *, force: bool = False) -> typing.Any: """ Method to delete element from the cache by key, or if force passed on its clear all data from the cache + :param key: by this key to delete an element in storage + :param force: If this argument is passed as True, + the date in the storage will be completely cleared. + """ if force: return self.data.clear() - key: str - return self.data[self._default_key].pop(key) + return self.data.pop(key) def __setitem__(self, key, value) -> None: - self.data[self._default_key][key] = value + return self.update_data(value, key) def __getitem__(self, item) -> typing.Any: try: - return self.data[self._default_key][item] + obj = self.data[item] + if not self._is_expire(obj["cached_in"], item): + return obj["data"] except KeyError: - return None + pass def _is_contain_uncached(self, value: typing.Optional[typing.Any]) -> bool: if self._cache_time < 0.1: @@ -90,43 +69,47 @@ def _is_contain_uncached(self, value: typing.Optional[typing.Any]) -> bool: return False return False - def initialize_response_to_resolve( + def convert_to_cache( self, result: typing.Any, kwargs: dict, status_code: typing.Union[str, int] ) -> Cached: - value = kwargs.get(self._default_key) + """ + Method, which convert response of API to :class:`Cached` + + :param result: response data + :param kwargs: key/value of request payload data + :param status_code: status_code answer + """ + value = kwargs.get("url") if not self._is_contain_uncached(value): return Cached( - kwargs=Attributes.format(kwargs, self.available), + kwargs=Attributes.format(kwargs, self._validator_args), response_data=result, - key=self._default_key, status_code=status_code, - method=kwargs.get('method'), - cache_to=value + method=kwargs.get('method') ) - if uncached[1] in value: - self.clear(value, True) + elif uncached[1] in value: + self.clear(value, force=True) def update_data(self, obj_to_cache: typing.Any, key: typing.Any) -> None: """ - Метод, который добавляет результат запроса в кэш + Метод, который добавляет результат запроса в кэш. + + >>> storage = Storage(cache_time=5) + >>> storage.update_data(obj_to_cache="hello world", key="world") + >>> storage["world"] = "hello_world" # This approach is in priority and + # the same as on the line of code above :param obj_to_cache: объект для кэширования :param key: ключ, по которому будет зарезервирован этот кэш - """ - if isinstance(obj_to_cache, Cached): - key = obj_to_cache.cache_to - else: - try: - setattr(obj_to_cache, 'cached_in', time.monotonic()) - setattr(obj_to_cache, 'cache_to', key) - except AttributeError: - pass if not self._is_contain_uncached(obj_to_cache): - self.data[self._default_key][key] = obj_to_cache + self.data[key] = { + "data": obj_to_cache, + "cached_in": time.monotonic(), + } @staticmethod def _check_get_request(cached: Cached, kwargs: dict) -> bool: @@ -137,15 +120,15 @@ def _check_get_request(cached: Cached, kwargs: dict) -> bool: return True return False - def _is_expire(self, cached: typing.Any) -> bool: + def _is_expire(self, cached_in: float, key: typing.Any) -> bool: """ Method to check live cache time, and if it expired return True """ - if time.monotonic() - cached.cached_in > self._cache_time: - self.clear(cached.cache_to) + if time.monotonic() - cached_in > self._cache_time: + self.clear(key) return True return False def _validate_other(self, cached: Cached, kwargs: dict) -> bool: - keys = (key for key in self.available if key != 'headers') + keys = (key for key in self._validator_args if key != 'headers') for key in keys: if getattr(cached.kwargs, key) == kwargs.get(key, ''): return True @@ -161,15 +144,11 @@ def validate(self, kwargs: typing.Dict[str, typing.Any]) -> bool: :return: boolean, прошел ли кэшированный запрос валидацию """ # Если параметры и ссылка запроса совпадает - cached = self.get_current(kwargs.get(self._default_key)) + cached = self[kwargs.get("url")] if isinstance(cached, Cached): - if self._is_expire(cached): - return False - # Проверяем запрос методом GET на кэш if self._check_get_request(cached, kwargs): return True - elif self._validate_other(cached, kwargs): return True diff --git a/glQiwiApi/core/web_hooks/__init__.py b/glQiwiApi/core/web_hooks/__init__.py index e69de29b..8b137891 100644 --- a/glQiwiApi/core/web_hooks/__init__.py +++ b/glQiwiApi/core/web_hooks/__init__.py @@ -0,0 +1 @@ + diff --git a/glQiwiApi/core/web_hooks/dispatcher.py b/glQiwiApi/core/web_hooks/dispatcher.py index 2e7d7ada..d3285b01 100644 --- a/glQiwiApi/core/web_hooks/dispatcher.py +++ b/glQiwiApi/core/web_hooks/dispatcher.py @@ -1,37 +1,21 @@ import asyncio -import inspect import logging -import types -from datetime import datetime, timedelta -from typing import List, Tuple, Coroutine, Any, Union, Optional, NoReturn, \ - Callable - -import aiohttp +from typing import List, Tuple, Coroutine, Any, Union from .config import EventHandlerFunctor, EventFilter, E -from .filter import Filter, transaction_webhook_filter, bill_webhook_filter - - -async def _inspect_and_execute_callback(client, callback: Callable): - if inspect.iscoroutinefunction(callback): - await callback(client) - else: - callback(client) - - -def _get_stream_handler() -> logging.StreamHandler: - _log_format = f"%(asctime)s - [%(levelname)s] - %(message)s" - stream_handler = logging.StreamHandler() - stream_handler.setLevel(logging.INFO) - stream_handler.setFormatter(logging.Formatter(_log_format)) - return stream_handler +from .filter import Filter +from ..builtin import ( + bill_webhook_filter, + transaction_webhook_filter, + InterceptHandler +) def _setup_logger() -> logging.Logger: logger = logging.getLogger(__name__) - logger.setLevel(level=logging.INFO) + logger.setLevel(level=logging.DEBUG) if not logger.handlers: - logger.addHandler(_get_stream_handler()) + logger.addHandler(InterceptHandler()) return logger @@ -41,7 +25,7 @@ class EventHandler: """ - def __init__(self, functor: EventHandlerFunctor, *filter_: Filter) -> None: + def __init__(self, functor: EventHandlerFunctor, *filter_: Tuple[Filter]) -> None: """ :param functor: @@ -73,37 +57,17 @@ class Dispatcher: """ - def __init__( - self, - loop: asyncio.AbstractEventLoop, - wallet: Any - ): + def __init__(self, loop: asyncio.AbstractEventLoop): if not isinstance(loop, asyncio.AbstractEventLoop): - raise ValueError( + raise RuntimeError( f"Listener must have its event loop implemented with" f" {asyncio.AbstractEventLoop!r}" ) - self.loop = loop + self._loop = loop self.transaction_handlers: List[EventHandler] = [] self.bill_handlers: List[EventHandler] = [] self._logger: logging.Logger = _setup_logger() - # Polling variables - self._polling: bool = False - self.offset: Optional[int] = None - self.offset_start_date: Optional[ - Union[ - datetime, - timedelta - ] - ] = None - self.offset_end_date = None - self.client: Any = wallet - self.request_timeout: Optional[ - Union[float, int, aiohttp.ClientTimeout] - ] = None - self._on_startup: List[Callable] = [] - self._on_shutdown: List[Callable] = [] def register_transaction_handler( self, @@ -139,18 +103,6 @@ def logger(self) -> logging.Logger: def logger(self, logger: logging.Logger): self._logger = logger - def __setitem__(self, key: str, callback: Callable): - if key not in ["on_shutdown", "on_startup"]: - raise ValueError() - - if not isinstance(callback, types.FunctionType): - raise ValueError("Invalid type of callback") - - if key == "on_shutdown": - self._on_shutdown.append(callback) - else: - self._on_startup.append(callback) - @staticmethod def wrap_handler( event_handler: EventHandlerFunctor, @@ -179,7 +131,7 @@ def wrap_handler( return EventHandler(event_handler, *filters) - def transaction_handler_wrapper(self, *filters: EventFilter) -> E: + def transaction_handler_wrapper(self, *filters: EventFilter): def decorator(callback: EventHandlerFunctor) -> EventHandlerFunctor: self.register_transaction_handler(callback, *filters) @@ -187,7 +139,7 @@ def decorator(callback: EventHandlerFunctor) -> EventHandlerFunctor: return decorator - def bill_handler_wrapper(self, *filters: EventFilter) -> E: + def bill_handler_wrapper(self, *filters: EventFilter): def decorator(callback: EventHandlerFunctor) -> EventHandlerFunctor: self.register_bill_handler(callback, *filters) @@ -203,18 +155,3 @@ async def process_event(self, event: E) -> None: for handler in self.handlers: await handler.check_then_execute(event) - - async def welcome(self) -> None: - for callback in self._on_startup: - await _inspect_and_execute_callback( - callback=callback, - client=self.client - ) - - async def goodbye(self) -> None: - self.logger.info("Stop polling!") - for callback in self._on_shutdown: - await _inspect_and_execute_callback( - callback=callback, - client=self.client - ) diff --git a/glQiwiApi/core/web_hooks/filter.py b/glQiwiApi/core/web_hooks/filter.py index ab2ff21f..60133535 100644 --- a/glQiwiApi/core/web_hooks/filter.py +++ b/glQiwiApi/core/web_hooks/filter.py @@ -1,8 +1,9 @@ +from __future__ import annotations + import inspect import operator from typing import Any, Callable -from glQiwiApi import types from .config import CF, E @@ -44,22 +45,22 @@ def __init__(self, function: CF): function ) or inspect.isawaitable(function) - def __eq__(self, other: Any) -> "Filter": + def __eq__(self, other: Any): return _sing_filter(self, lambda filter1: operator.eq(filter1, other)) - def __ne__(self, other: Any) -> "Filter": + def __ne__(self, other: Any): return _sing_filter(self, lambda filter1: operator.ne(filter1, other)) - def __invert__(self) -> "Filter": + def __invert__(self) -> Filter: return _sing_filter(self, operator.not_) - def __xor__(self, other: "Filter") -> "Filter": + def __xor__(self, other: Filter) -> Filter: return _compose_filter(self, other, xor) - def __and__(self, other: "Filter") -> "Filter": + def __and__(self, other: Filter) -> Filter: return _compose_filter(self, other, special_comparator) - def __or__(self, other: "Filter") -> "Filter": + def __or__(self, other: Filter) -> Filter: return _compose_filter(self, other, or_) @@ -126,18 +127,6 @@ def func(event: E) -> Any: return Filter(func) -# Default filter for transaction handler -transaction_webhook_filter = Filter( - lambda update: isinstance(update, (types.WebHook, types.Transaction)) -) - -# Default filter for bill handler -bill_webhook_filter = Filter( - lambda update: isinstance(update, types.Notification) -) - __all__ = ( - "Filter", - "transaction_webhook_filter", - "bill_webhook_filter" + "Filter" ) diff --git a/glQiwiApi/core/web_hooks/server.py b/glQiwiApi/core/web_hooks/server.py index 15f7a6b4..267350dc 100644 --- a/glQiwiApi/core/web_hooks/server.py +++ b/glQiwiApi/core/web_hooks/server.py @@ -7,9 +7,8 @@ from glQiwiApi import types from glQiwiApi.core.abstracts import BaseWebHookView -from glQiwiApi.core.web_hooks.dispatcher import Dispatcher -from glQiwiApi.utils.basics import hmac_for_transaction, hmac_key -from .config import ( +from glQiwiApi.core.builtin import BaseProxy +from glQiwiApi.core.web_hooks.config import ( DEFAULT_QIWI_WEBHOOK_PATH, allowed_ips, DEFAULT_QIWI_ROUTER_NAME, @@ -17,11 +16,14 @@ DEFAULT_QIWI_BILLS_WEBHOOK_PATH, Path ) +from glQiwiApi.core.web_hooks.dispatcher import Dispatcher +from glQiwiApi.utils.basics import hmac_for_transaction, hmac_key def _check_ip(ip_address: str) -> bool: """ Check if ip is allowed to request us + :param ip_address: IP-address :return: address is allowed """ @@ -49,11 +51,9 @@ async def parse_update(self) -> types.WebHook: async def post(self) -> web.Response: await super().post() - return web.Response(text="ok", status=200) + return web.Response(text="ok") - def _hash_validator(self, update: typing.Union[ - types.Notification, types.WebHook - ]) -> None: + def _hash_validator(self, update: types.WebHook) -> None: base64_key = self.request.app.get('_base64_key') if not update.payment: @@ -85,9 +85,7 @@ class QiwiBillWebView(BaseWebHookView): def _check_ip(self, ip_address: str) -> bool: return _check_ip(ip_address) - def _hash_validator(self, update: typing.Union[ - types.Notification, types.WebHook - ]) -> None: + def _hash_validator(self, update: types.Notification) -> None: sha256_signature = self.request.headers.get("X-Api-Signature-SHA256") logging.info(sha256_signature) _secret = self.request.app.get("_secret_key") @@ -98,9 +96,7 @@ def _hash_validator(self, update: typing.Union[ if answer != sha256_signature: raise web.HTTPBadRequest() - async def parse_update(self) -> typing.Union[ - types.Notification, types.WebHook - ]: + async def parse_update(self) -> types.Notification: payload = await self.request.json() return types.Notification.parse_raw(payload) @@ -109,11 +105,10 @@ async def post(self) -> Response: update = await self.parse_update() - # Validation is still in development # self._hash_validator(update) await self.handler_manager.process_event(update) - return web.json_response(data={"error": "0"}, status=200) + return web.json_response(data={"error": "0"}) app_key_check_ip = "_qiwi_bill_check_ip" app_key_handler_manager = "_qiwi_bill_handler_manager" @@ -129,11 +124,11 @@ def setup_transaction_data( app[QiwiWalletWebView.app_key_check_ip] = _check_ip app[QiwiWalletWebView.app_key_handler_manager] = handler_manager if isinstance(path, Path): - path = path.transaction_path + txn_path: str = path.transaction_path else: - path = DEFAULT_QIWI_WEBHOOK_PATH + txn_path = DEFAULT_QIWI_WEBHOOK_PATH app.router.add_view( - path, + txn_path, QiwiWalletWebView, name=DEFAULT_QIWI_ROUTER_NAME ) @@ -149,69 +144,60 @@ def setup_bill_data( app[QiwiBillWebView.app_key_check_ip] = _check_ip app[QiwiBillWebView.app_key_handler_manager] = handler_manager if isinstance(path, Path): - path: str = path.bill_path + bill_path: str = path.bill_path else: - path: str = DEFAULT_QIWI_BILLS_WEBHOOK_PATH + bill_path = DEFAULT_QIWI_BILLS_WEBHOOK_PATH app.router.add_view( handler=QiwiBillWebView, name=DEFAULT_QIWI_BILLS_ROUTER_NAME, - path=path + path=bill_path ) def setup( dispatcher: Dispatcher, app: web.Application, - instance: typing.Any, - on_startup: typing.Optional[ - typing.Callable[[web.Application], typing.Awaitable[None] - ]], - on_shutdown: typing.Optional[ - typing.Callable[ - [web.Application], typing.Awaitable[None] - ]], + host: str, path: typing.Optional[Path] = None, secret_key: typing.Optional[str] = None, base64_key: typing.Optional[str] = None, + tg_app: typing.Optional[BaseProxy] = None, ) -> None: """ - Setup web application for webhooks + Entirely configures the web app for webhooks :param dispatcher: dispatcher, which processing events :param app: aiohttp.web.Application + :param instance: + :param host: :param path: Path obj, contains two paths :param secret_key: secret p2p key :param base64_key: Base64-encoded webhook key - :param on_startup: coroutine,which will be executed on startup - :param on_shutdown: coroutine, which will be executed on shutdown :param instance: instance of the QiwiWrapper + :param tg_app: """ setup_bill_data(app, secret_key, dispatcher, path) setup_transaction_data(app, base64_key, dispatcher, path) - _setup_callbacks(on_startup, on_shutdown, instance, app) - - -def _setup_callbacks( - on_startup: typing.Optional[ - typing.Callable[[web.Application], typing.Awaitable[None]] - ], - on_shutdown: typing.Optional[ - typing.Callable[[web.Application], typing.Awaitable[None]] - ], - instance: typing.Any, - app: web.Application + _setup_tg_proxy(tg_app, app, host) + + +def _setup_tg_proxy( + tg_app: typing.Optional[BaseProxy], + app: web.Application, host: str ) -> None: """ - Function, which deals with on_startup and on_shutdown functions + Function, which setup tg proxy application to main webapp - :param on_startup: coroutine,which will be executed on startup - :param on_shutdown: coroutine, which will be executed on shutdown - :param instance: instance of the QiwiWrapper - :param app: instance of aiohttp.web.Application() + :param tg_app: BaseTelegramProxy subclass or builtin + :param app: main application """ - app["qiwi_wrapper"] = instance - app.on_startup.append(on_startup) - app.on_shutdown.append(on_shutdown) + if tg_app is not None: + if not isinstance(tg_app, BaseProxy): + raise TypeError( + "Invalid telegram proxy. It must " + "inherit from the parent class `BaseTelegramProxy`." + ) + tg_app.setup(app=app, host=host) diff --git a/glQiwiApi/qiwi/API.py b/glQiwiApi/qiwi/API.py deleted file mode 100644 index 8146cad8..00000000 --- a/glQiwiApi/qiwi/API.py +++ /dev/null @@ -1,719 +0,0 @@ -import pathlib -from copy import deepcopy -from datetime import datetime, timedelta -from typing import Union, Optional, Dict, List, Any, MutableMapping - -from aiohttp import ClientSession - -from glQiwiApi.core import RequestManager, ToolsMixin -from glQiwiApi.core.web_hooks import dispatcher -from glQiwiApi.qiwi.mixins import ( - QiwiKassaMixin, - QiwiMasterMixin, - HistoryPollingMixin, - QiwiWebHookMixin, - QiwiPaymentsMixin -) -from glQiwiApi.qiwi.settings import QiwiRouter, QiwiKassaRouter -from glQiwiApi.types import ( - QiwiAccountInfo, - Transaction, - Statistic, - Limit, - Account, - Balance, - Identification, - Sum, - Card, - Restriction -) -from glQiwiApi.types.basics import DEFAULT_CACHE_TIME -from glQiwiApi.utils import basics as api_helper -from glQiwiApi.utils.exceptions import InvalidData - - -class QiwiWrapper( - QiwiWebHookMixin, QiwiPaymentsMixin, - QiwiMasterMixin, ToolsMixin, - HistoryPollingMixin, QiwiKassaMixin -): - """ - Qiwi wallet api methods including webhooks and non api polling - - """ - - __slots__ = ( - 'api_access_token', - 'phone_number', - 'secret_p2p', - '_requests', - '_router', - '_p2p_router', - 'dispatcher' - ) - - def __init__(self, api_access_token: Optional[str] = None, - phone_number: Optional[str] = None, - secret_p2p: Optional[str] = None, - without_context: bool = False, - cache_time: Union[float, int] = DEFAULT_CACHE_TIME) -> None: - """ - :param api_access_token: токен, полученный с https://qiwi.com/api - :param phone_number: номер вашего телефона с + - :param secret_p2p: секретный ключ, полученный с https://p2p.qiwi.com/ - :param without_context: bool, указывает будет ли объект класса - "глобальной" переменной или будет использована в async with контексте - :param cache_time: Время кэширование запросов в секундах, - по умолчанию 0, соответственно, - запрос не будет использовать кэш по дефолту, максимальное время - кэширование 60 секунд - """ - if isinstance(phone_number, str): - self.phone_number = phone_number.replace('+', '') - if self.phone_number.startswith('8'): - self.phone_number = '7' + self.phone_number[1:] - - self._router: QiwiRouter = QiwiRouter() - self._p2p_router: QiwiKassaRouter = QiwiKassaRouter() - self._requests: RequestManager = RequestManager( - without_context=without_context, - messages=self._router.config.ERROR_CODE_NUMBERS, - cache_time=cache_time - ) - self.api_access_token = api_access_token - self.secret_p2p = secret_p2p - # Special dispatcher to manage handlers and events from polling - # or webhooks - self.dispatcher = dispatcher.Dispatcher( - loop=api_helper.take_event_loop(), - wallet=self - ) - super(QiwiWrapper, self).__init__( - router=self._router, requests_manager=self._requests, - secret_p2p=self.secret_p2p - ) - - def _auth_token( - self, - headers: MutableMapping, - p2p: bool = False - ) -> MutableMapping: - """ - Make auth for API - - :param headers: dictionary - :param p2p: boolean - """ - headers['Authorization'] = headers['Authorization'].format( - token=self.api_access_token if not p2p else self.secret_p2p - ) - return headers - - @property - def session(self) -> Optional[ClientSession]: - """Return aiohttp session object""" - return self._requests.session - - @property - def stripped_number(self) -> str: - try: - return self.phone_number.replace("+", "") - except AttributeError: - raise InvalidData( - "You should pass on phone number to execute this method" - ) from None - - @api_helper.override_error_messages( - { - 404: { - "message": "Введена неправильный номер карты, возможно, " - "карта на которую вы переводите заблокирована" - } - } - ) - async def _detect_mobile_number(self, phone_number: str): - """ - Метод для получения идентификатора телефона - - https://developer.qiwi.com/ru/qiwi-wallet-personal/?python#cards - """ - headers = deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) - headers.update( - { - 'Content-Type': 'application/x-www-form-urlencoded' - } - ) - async for response in self._requests.fetch( - url='https://qiwi.com/mobile/detect.action', - headers=headers, - method='POST', - data={ - 'phone': phone_number - } - ): - return response.response_data.get('message') - - async def get_balance(self) -> Sum: - """Метод для получения баланса киви""" - if not isinstance(self.phone_number, str): - raise InvalidData( - "Для вызова этого метода вы должны передать номер кошелька" - ) - - headers = self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - url = self._router.build_url( - "GET_BALANCE", - phone_number=self.phone_number - ) - async for response in self._requests.fast().fetch( - url=url, - headers=headers, - method='GET', - get_json=True - ): - return Sum.parse_obj( - response.response_data['accounts'][0]['balance'] - ) - - async def transactions( - self, - rows_num: int = 50, - operation: str = 'ALL', - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None - ) -> List[Transaction]: - """ - Метод для получения транзакций на счёту - Более подробная документация: - https://developer.qiwi.com/ru/qiwi-wallet-personal/?http#payments_list - - Возможные значения параметра operation: - - 'ALL' - - 'IN' - - 'OUT' - - 'QIWI_CARD' - - :param rows_num: кол-во транзакций, которые вы хотите получить - :param operation: Тип операций в отчете, для отбора. - :param start_date:Начальная дата поиска платежей. - Используется только вместе с end_date. - :param end_date: конечная дата поиска платежей. - Используется только вместе со start_date. - """ - if rows_num > 50 or rows_num <= 0: - raise InvalidData('Можно проверять не более 50 транзакций') - - headers = self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - - payload_data = api_helper.check_dates( - start_date=start_date, - end_date=end_date, - payload_data={ - 'rows': rows_num, - 'operation': operation - } - ) - url = self._router.build_url( - "TRANSACTIONS", - stripped_number=self.stripped_number - ) - - async for response in self._requests.fast().fetch( - url=url, - params=payload_data, - headers=headers, - method='GET', - get_json=True - ): - return api_helper.multiply_objects_parse( - lst_of_objects=(response.response_data.get('data'),), - model=Transaction - ) - - async def transaction_info( - self, - transaction_id: Union[str, int], - transaction_type: str - ) -> Transaction: - """ - Метод для получения полной информации о транзакции\n - Подробная документация: - https://developer.qiwi.com/ru/qiwi-wallet-personal/?python#txn_info - - :param transaction_id: номер транзакции - :param transaction_type: тип транзакции, может быть только IN или OUT - :return: Transaction object - """ - headers = self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - payload_data = { - 'type': transaction_type - } - url = self._router.build_url( - "TRANSACTION_INFO", - transaction_id=transaction_id - ) - async for response in self._requests.fast().fetch( - url=url, - headers=headers, - params=payload_data, - method='GET', - get_json=True - ): - return Transaction.parse_obj(response.response_data) - - async def check_restriction(self) -> List[Restriction]: - """ - Метод для проверки ограничений на вашем киви кошельке\n - Подробная документация: - https://developer.qiwi.com/ru/qiwi-wallet-personal/?python#restrictions - - :return: Список, где находиться словарь с ограничениями, - если ограничений нет - возвращает пустой список - """ - if not isinstance(self.phone_number, str): - raise InvalidData( - "Для вызова этого метода вы должны передать номер кошелька" - ) - headers = self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - url = self._router.build_url( - "CHECK_RESTRICTION", - phone_number=self.phone_number - ) - async for response in self._requests.fast().fetch( - url=url, - headers=headers, - method='GET' - ): - return api_helper.simple_multiply_parse( - lst_of_objects=response.response_data, - model=Restriction - ) - - @property - async def identification(self) -> Identification: - """ - Функция, которая позволяет - получить данные идентификации вашего кошелька - Более подробная документация: - https://developer.qiwi.com/ru/qiwi-wallet-personal/?http#ident - - :return: Response object - """ - if not isinstance(self.phone_number, str): - raise InvalidData( - "Для вызова этого метода вы должны передать номер кошелька" - ) - - headers = self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - url = self._router.build_url( - "GET_IDENTIFICATION", - phone_number=self.phone_number - ) - async for response in self._requests.fast().fetch( - url=url, - method='GET', - headers=headers - ): - return Identification.parse_obj(response.response_data) - - async def check_transaction( - self, - amount: Union[int, float], - transaction_type: str = 'IN', - sender: Optional[str] = None, - rows_num: int = 50, - comment: Optional[str] = None - ) -> bool: - """ - [ NON API METHOD ] - Метод для проверки транзакции.\n - Данный метод использует self.transactions(rows_num=rows_num) - для получения платежей.\n - Для небольшой оптимизации вы можете уменьшить rows_num задав его, - однако это не гарантирует правильный результат - Возможные значения параметра transaction_type: - - 'IN' - - 'OUT' - - 'QIWI_CARD' - - - :param amount: сумма платежа - :param transaction_type: тип платежа - :param sender: номер получателя - :param rows_num: кол-во платежей, которое будет проверяться - :param comment: комментарий, по которому будет проверяться транзакция - :return: bool, есть ли такая транзакция в истории платежей - """ - if transaction_type not in ['IN', 'OUT', 'QIWI_CARD']: - raise InvalidData('Вы ввели неправильный метод транзакции') - - elif rows_num > 50 or rows_num <= 0: - raise InvalidData('Можно проверять не более 50 транзакций') - - transactions = await self.transactions(rows_num=rows_num) - - return api_helper.check_transaction( - transactions=transactions, - transaction_type=transaction_type, - comment=comment, - amount=amount, - sender=sender - ) - - async def get_limits(self) -> Dict[str, Limit]: - """ - Функция для получения лимитов по счёту киви кошелька\n - Возвращает лимиты по кошельку в виде списка, - если лимита по определенной стране нет, то не включает его в список - Подробная документация: - https://developer.qiwi.com/ru/qiwi-wallet-personal/?http#limits - - :return: Limit object of Limit(pydantic) - """ - headers = self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - - payload = {} - limit_types = self._router.config.LIMIT_TYPES - for index, limit_type in enumerate(limit_types): - payload['types[' + str(index) + ']'] = limit_type - url = self._router.build_url( - "GET_LIMITS", - stripped_number=self.stripped_number - ) - async for response in self._requests.fast().fetch( - url=url, - get_json=True, - headers=headers, - params=payload, - method='GET' - ): - return api_helper.parse_limits(response, Limit) - - async def get_list_of_cards(self) -> List[Card]: - """ - Данный метод позволяет вам получить список ваших карт. - - """ - headers = self._auth_token( - deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) - ) - async for response in self._requests.fast().fetch( - url=self._router.build_url("GET_LIST_OF_CARDS"), - method='GET', - headers=headers - ): - return api_helper.simple_multiply_parse( - lst_of_objects=response.response_data, - model=Card - ) - - async def authenticate( - self, - birth_date: str, - first_name: str, - last_name: str, - patronymic: str, - passport: str, - oms: Optional[str] = None, - inn: Optional[str] = None, - snils: Optional[str] = None - ) -> Optional[Dict[str, bool]]: - """ - Данный запрос позволяет отправить данные - для идентификации вашего QIWI кошелька. - Допускается идентифицировать не более 5 кошельков на одного владельца - - Для идентификации кошелька вы обязательно должны отправить ФИО, - серию/номер паспорта и дату рождения.\n - Если данные прошли проверку, то в ответе будет отображен - ваш ИНН и упрощенная идентификация кошелька будет установлена. - В случае если данные не прошли проверку, - кошелек остается в статусе "Минимальный". - - :param birth_date: Дата рождения в виде строки формата 1998-02-11 - :param first_name: Ваше имя - :param last_name: Ваша фамилия - :param patronymic: Ваше отчество - :param passport: Серия / Номер паспорта. Пример 4400111222 - :param oms: - :param snils: - :param inn: - """ - - payload = { - "birthDate": birth_date, - "firstName": first_name, - "inn": inn, - "lastName": last_name, - "middleName": patronymic, - "oms": oms, - "passport": passport, - "snils": snils - } - headers = self._auth_token( - deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) - ) - url = self._router.build_url( - "AUTHENTICATE", - stripped_number=self.stripped_number - ) - async for response in self._requests.fast().fetch( - url=url, - data=self._requests.filter_dict(payload), - headers=headers - ): - if response.ok and response.status_code == 200: - return {'success': True} - - @api_helper.override_error_messages({ - 422: { - "message": "Невозможно получить чек, из-за того, что " - "транзакция по такому айди не была проведена, " - "то есть произошла ошибка при проведении транзакции" - } - }) - async def get_receipt( - self, - transaction_id: Union[str, int], - transaction_type: str, - dir_path: Union[str, pathlib.Path] = None, - file_name: Optional[str] = None - ) -> Union[bytes, int]: - """ - Метод для получения чека в формате байтов или файлом.\n - Возможные значения transaction_type: - - 'IN' - - 'OUT' - - 'QIWI_CARD' - - :param transaction_id: str or int, id транзакции, - можно получить при вызове методе to_wallet, to_card - :param transaction_type: тип транзакции может быть: - 'IN', 'OUT', 'QIWI_CARD' - :param dir_path: путь к директории, куда вы хотите сохранить чек, - если не указан, возвращает байты - :param file_name: Имя файла без формата. Пример: my_receipt - :return: pdf файл в байтовом виде или номер записанных байтов - """ - headers = self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - - data = { - 'type': transaction_type, - 'format': 'PDF' - } - url = self._router.build_url( - "GET_RECEIPT", transaction_id=transaction_id - ) - async for response in self._requests.fast().fetch( - url=url, - headers=headers, - method='GET', - params=data - ): - if not isinstance( - dir_path, (str, pathlib.Path) - ) or not isinstance(file_name, str): - return response.response_data - - return await api_helper.save_file( - dir_path=dir_path, file_name=file_name, - data=response.response_data - ) - - @property - async def account_info(self) -> QiwiAccountInfo: - """ - Метод для получения информации об аккаунте - - """ - headers = self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - async for response in self._requests.fast().fetch( - url=self._router.build_url("ACCOUNT_INFO"), - headers=headers, - method='GET', - get_json=True - ): - return QiwiAccountInfo.parse_obj(response.response_data) - - async def fetch_statistics( - self, - start_date: Union[datetime, timedelta], - end_date: Union[datetime, timedelta], - operation: str = 'ALL', - sources: Optional[List[str]] = None - ) -> Statistic: - """ - Данный запрос используется для получения сводной статистики - по суммам платежей за заданный период.\n - Более подробная документация: - https://developer.qiwi.com/ru/qiwi-wallet-personal/?http#payments_list - - :param start_date: Начальная дата периода статистики. - Обязательный параметр - :param end_date: Конечная дата периода статистики. - Обязательный параметр - :param operation: Тип операций, учитываемых при подсчете статистики. - Допустимые значения: - ALL - все операции, - IN - только пополнения, - OUT - только платежи, - QIWI_CARD - только платежи по картам QIWI (QVC, QVP). - По умолчанию ALL. - :param sources: Источники платежа, по которым вернутся данные. - QW_RUB - рублевый счет кошелька, - QW_USD - счет кошелька в долларах, - QW_EUR - счет кошелька в евро, - CARD - привязанные и непривязанные к кошельку банковские карты, - MK - счет мобильного оператора. Если не указан, - учитываются все источники платежа. - """ - headers = self._auth_token( - deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) - ) - # Raise exception if invalid value of start_date or end_date - api_helper.check_dates_for_statistic_request( - start_date=start_date, - end_date=end_date - ) - params = { - 'startDate': api_helper.datetime_to_str_in_iso(start_date), - 'endDate': api_helper.datetime_to_str_in_iso(end_date), - 'operation': operation, - } - - if sources: - params.update({'sources': ' '.join(sources)}) - - url = self._router.build_url( - "FETCH_STATISTICS", - stripped_number=self.stripped_number - ) - async for response in self._requests.fast().fetch( - url=url, - params=params, - headers=headers, - get_json=True, - method='GET' - ): - return Statistic.parse_obj(response.response_data) - - async def list_of_balances(self) -> List[Account]: - """ - Запрос выгружает текущие балансы счетов вашего QIWI Кошелька. - Более подробная документация: - https://developer.qiwi.com/ru/qiwi-wallet-personal/?http#balances_list - - """ - url = self._router.build_url( - "LIST_OF_BALANCES", - stripped_number=self.stripped_number - ) - async for response in self._requests.fast().fetch( - url=url, - headers=self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )), - method='GET' - ): - return api_helper.simple_multiply_parse( - response.response_data.get('accounts'), Account - ) - - @api_helper.allow_response_code(201) - async def create_new_balance( - self, currency_alias: str - ) -> Optional[Dict[str, bool]]: - """ - Запрос создает новый счет и баланс в вашем QIWI Кошельке - - :param currency_alias: Псевдоним нового счета - :return: Возвращает значение из декоратора allow_response_code - Пример результата, если запрос был проведен успешно: {"success": True} - """ - headers = self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - payload = { - 'alias': currency_alias - } - url = self._router.build_url( - "CREATE_NEW_BALANCE", - stripped_number=self.stripped_number - ) - async for response in self._requests.fast().fetch( - url=url, - headers=headers, - data=payload - ): - return response.response_data - - async def available_balances(self) -> List[Balance]: - """ - Запрос отображает псевдонимы счетов, - доступных для создания в вашем QIWI Кошельке - Сигнатура объекта ответа: - class Balance(BaseModel): - alias: str - currency: int - - """ - url = self._router.build_url( - "AVAILABLE_BALANCES", - stripped_number=self.stripped_number - ) - headers = self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - async for response in self._requests.fast().fetch( - url=url, - headers=headers, - method='GET' - ): - return api_helper.simple_multiply_parse( - lst_of_objects=response.response_data, model=Balance) - - @api_helper.allow_response_code(204) - async def set_default_balance(self, currency_alias: str) -> Any: - """ - Запрос устанавливает для вашего QIWI Кошелька счет, - баланс которого будет использоваться для фондирования - всех платежей по умолчанию. - Счет должен содержаться в списке счетов, получить список можно вызвав - метод list_of_balances - - :param currency_alias: Псевдоним нового счета, - можно получить из list_of_balances - :return: Возвращает значение из декоратора allow_response_code - Пример результата, если запрос был проведен успешно: {"success": True} - """ - headers = self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - url = self._router.build_url( - "SET_DEFAULT_BALANCE", - stripped_number=self.stripped_number, - currency_alias=currency_alias - ) - async for response in self._requests.fast().fetch( - url=url, - headers=headers, - method='PATCH', - json={'defaultAccount': True} - ): - return response diff --git a/glQiwiApi/qiwi/__init__.py b/glQiwiApi/qiwi/__init__.py index fc521712..384e075b 100644 --- a/glQiwiApi/qiwi/__init__.py +++ b/glQiwiApi/qiwi/__init__.py @@ -1,4 +1,4 @@ -from .API import QiwiWrapper +from .client import QiwiWrapper from .qiwi_maps import QiwiMaps __all__ = ('QiwiWrapper', 'QiwiMaps') diff --git a/glQiwiApi/qiwi/client.py b/glQiwiApi/qiwi/client.py new file mode 100644 index 00000000..d75b3282 --- /dev/null +++ b/glQiwiApi/qiwi/client.py @@ -0,0 +1,1571 @@ +""" +Gracefully and lightweight wrapper to deal with QIWI API +It's an open-source project so you always can improve the quality of code/API by +adding something of your own... +Easy to integrate to Telegram bot, which was written on aiogram or another async/sync library. + +""" +from __future__ import annotations + +import pathlib +import uuid +from abc import ABC +from copy import deepcopy +from datetime import datetime, timedelta +from typing import List, Tuple, Dict, Union, Optional, Any, MutableMapping, Pattern, Match, Callable + +from glQiwiApi.core import RequestManager +from glQiwiApi.core.core_mixins import ContextInstanceMixin, ToolsMixin +from glQiwiApi.core.web_hooks.dispatcher import Dispatcher +from glQiwiApi.qiwi.settings import QiwiRouter, QiwiKassaRouter +from glQiwiApi.types import ( + QiwiAccountInfo, + Transaction, + Statistic, + Limit, + Account, + Balance, + Identification, + Sum, + Card, + Restriction, + Commission, + CrossRate, + PaymentMethod, + FreePaymentDetailsFields, + PaymentInfo, + OrderDetails, + Bill, + OptionalSum, + P2PKeys, + RefundBill, + WebHookConfig +) +from glQiwiApi.types.basics import DEFAULT_CACHE_TIME +from glQiwiApi.utils import basics as api_helper +from glQiwiApi.utils.exceptions import ( + InvalidCardNumber, + RequestError, + InvalidData +) + + +def _is_copy_signal(kwargs: dict): + try: + return kwargs.pop("__copy_signal__") + except KeyError: + return False + + +class BaseWrapper(ABC): + """ Base wrapper class""" + set_current: Callable + + def __init__(self, api_access_token: Optional[str] = None, + phone_number: Optional[str] = None, + secret_p2p: Optional[str] = None, + without_context: bool = False, + cache_time: Union[float, int] = DEFAULT_CACHE_TIME, + validate_params: bool = False, + proxy: Any = None) -> None: + """ + :param api_access_token: токен, полученный с https://qiwi.com/api + :param phone_number: номер вашего телефона с + + :param secret_p2p: секретный ключ, полученный с https://p2p.qiwi.com/ + :param without_context: bool, указывает, будет ли объект класса + "глобальной" переменной или будет использована в async with контексте + :param cache_time: Время кэширование запросов в секундах, + по умолчанию 0, соответственно, + запрос не будет использовать кэш по дефолту, максимальное время + кэширование 60 секунд + :param proxy: Прокси, которое будет использовано при создании сессии, может замедлить + работу АПИ + """ + if validate_params: + self._validate_params( + api_access_token=api_access_token, + cache_time=cache_time, + secret_p2p=secret_p2p, + phone_number=phone_number, + without_context=without_context + ) + + if isinstance(phone_number, str): + self.phone_number = phone_number.replace('+', '') + if self.phone_number.startswith('8'): + self.phone_number = '7' + self.phone_number[1:] + + self._router: QiwiRouter = QiwiRouter() + self._p2p_router: QiwiKassaRouter = QiwiKassaRouter() + self._requests: RequestManager = RequestManager( + without_context=without_context, + messages=self._router.config.ERROR_CODE_NUMBERS, + cache_time=cache_time, + proxy=proxy + ) + self.api_access_token = api_access_token + self.secret_p2p = secret_p2p + + self.dispatcher = Dispatcher(loop=api_helper.take_event_loop()) + + # Method from ContextInstanceMixin + self.set_current(self) # pragma: no cover + + def _auth_token( + self, + headers: MutableMapping, + p2p: bool = False + ) -> MutableMapping: + """ + Make auth for API + + :param headers: dictionary + :param p2p: boolean + """ + headers['Authorization'] = headers['Authorization'].format( + token=self.api_access_token if not p2p else self.secret_p2p + ) + return headers + + @property + def request_manager(self) -> RequestManager: + """ Return :class:`RequestManager` """ + return self._requests + + @request_manager.setter + def request_manager(self, manager: RequestManager): + if not isinstance(manager, RequestManager): + raise TypeError("Expected `RequestManager` hair, got %s" % type(manager)) + self._requests = manager + + @property + def stripped_number(self) -> str: + """returns number, in which the `+` sign is removed""" + try: + return self.phone_number.replace("+", "") + except AttributeError: + raise InvalidData( + "You should pass on phone number to execute this method" + ) from None + + @staticmethod + def _validate_params(api_access_token: Optional[str], + phone_number: Optional[str], + secret_p2p: Optional[str], + without_context: bool, + cache_time: Union[float, int]): + """ + Validating all parameters by `isinstance` function or `regex` + + :param api_access_token: + :param phone_number: + :param without_context: + :param cache_time: + """ + import re + + if not isinstance(api_access_token, str): + raise InvalidData("Invalid type of api_access_token parameter, required `string`," + "you have passed %s" % type(api_access_token)) + if not isinstance(secret_p2p, str): + raise InvalidData("Invalid type of secret_p2p parameter, required `string`," + "you have passed %s" % type(secret_p2p)) + if not isinstance(without_context, bool): + raise InvalidData("Invalid type of without_context parameter, required `bool`," + "you have passed %s" % type(without_context)) + if not isinstance(cache_time, (float, int)): + raise InvalidData("Invalid type of cache_time parameter, required `bool`," + "you have passed %s" % type(cache_time)) + + phone_number_pattern: Pattern[str] = re.compile( + r"^[+]?[(]?[0-9]{3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}$" + ) + match: Optional[Match[Any]] = re.fullmatch(phone_number_pattern, phone_number) + + if not match: + raise InvalidData("Failed to verify parameter `phone_number` by regex. " + "Please, enter the correct phone number.") + + def __new__(cls, api_access_token: Optional[str] = None, + phone_number: Optional[str] = None, + secret_p2p: Optional[str] = None, + without_context: bool = False, + cache_time: Union[float, int] = DEFAULT_CACHE_TIME, + *args, **kwargs): + if not isinstance(api_access_token, str) and not isinstance(secret_p2p, str): + if not _is_copy_signal(kwargs): + raise RuntimeError("Cannot initialize an instance without any tokens") + + return super().__new__(cls) + + +class QiwiWrapper(BaseWrapper, ToolsMixin, ContextInstanceMixin["QiwiWrapper"]): + """ + Delegates the work of QIWI API, webhooks, polling. + Fast and versatile wrapper. + + """ + __slots__ = ( + 'api_access_token', + 'phone_number', + 'secret_p2p', + '_requests', + '_router', + '_p2p_router', + 'dispatcher' + ) + + async def _register_webhook( + self, + web_url: Optional[str], + txn_type: int = 2 + ) -> WebHookConfig: + """ + This method register a new webhook + + :param web_url: service url + :param txn_type: 0 => incoming, 1 => outgoing, 2 => all + :return: Active Hooks + """ + url = self._router.build_url("REG_WEBHOOK") + async for response in self._requests.fast().fetch( + url=url, + method='PUT', + headers=self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )), + params={ + 'hookType': 1, + 'param': web_url, + 'txnType': txn_type + }, + get_json=True + ): + return WebHookConfig.parse_obj(response.response_data) + + async def get_current_webhook(self) -> WebHookConfig: + """ + Список действующих (активных) обработчиков уведомлений, + связанных с вашим кошельком, можно получить данным запросом. + Так как сейчас используется только один тип хука - webhook, + то в ответе содержится только один объект данных + + """ + url = self._router.build_url("GET_CURRENT_WEBHOOK") + async for response in self._requests.fast().fetch( + url=url, + method='GET', + headers=self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )), + get_json=True + ): + return WebHookConfig.parse_obj(response.response_data) + + async def _send_test_notification(self) -> Dict[str, str]: + """ + Для проверки вашего обработчика webhooks используйте данный запрос. + Тестовое уведомление отправляется на адрес, указанный при вызове + register_webhook + + """ + url = self._router.build_url("SEND_TEST_NOTIFICATION") + async for response in self._requests.fast().fetch( + url=url, + method='GET', + headers=self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )), + get_json=True + ): + return response.response_data + + async def get_webhook_secret_key(self, hook_id: str) -> str: + """ + Каждое уведомление содержит цифровую подпись сообщения, + зашифрованную ключом. + Для получения ключа проверки подписи используйте данный запрос. + + :param hook_id: UUID of webhook + :return: Base64-закодированный ключ + """ + url = self._router.build_url( + "GET_WEBHOOK_SECRET", + hook_id=hook_id + ) + async for response in self._requests.fast().fetch( + url=url, + method='GET', + headers=self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )), + get_json=True + ): + return response.response_data.get('key') + + async def delete_current_webhook(self) -> Optional[Dict[str, str]]: + """ + Method to delete webhook + + :return: Описание результата операции + """ + try: + hook = await self.get_current_webhook() + except RequestError as ex: + raise RequestError( + message=" You didn't register any webhook to delete ", + status_code='422', + json_info=ex.json() + ) from None + + url = self._router.build_url( + "DELETE_CURRENT_WEBHOOK", + hook_id=hook.hook_id + ) + async for response in self._requests.fast().fetch( + url=url, + headers=self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )), + method='DELETE', + get_json=True + ): + return response.response_data + + async def change_webhook_secret(self, hook_id: str) -> str: + """ + Для смены ключа шифрования уведомлений используйте данный запрос. + + :param hook_id: UUID of webhook + :return: Base64-закодированный ключ + """ + url = self._router.build_url( + "CHANGE_WEBHOOK_SECRET", + hook_id=hook_id + ) + async for response in self._requests.fast().fetch( + url=url, + headers=self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )), + method='POST', + get_json=True + ): + return response.response_data.get('key') + + async def bind_webhook( + self, + url: Optional[str] = None, + transactions_type: int = 2, + *, + send_test_notification: bool = False, + delete_old: bool = False + ) -> Tuple[Optional[WebHookConfig], str]: + """ + [NON-API] EXCLUSIVE method to register new webhook or get old + + :param url: service url + :param transactions_type: 0 => incoming, 1 => outgoing, 2 => all + :param send_test_notification: test_qiwi will send + you test webhook update + :param delete_old: boolean, if True - delete old webhook + + :return: Tuple of Hook and Base64-encoded key + """ + key: Optional[str] = None + + if delete_old: + await self.delete_current_webhook() + + try: + # Try to register new webhook + webhook = await self._register_webhook( + web_url=url, + txn_type=transactions_type + ) + except (RequestError, TypeError): + # Catching exception, if webhook already was registered + try: + webhook = await self.get_current_webhook() + except RequestError as ex: + raise RequestError( + message="You didn't pass on url to register new hook " + "and you didn't have registered webhooks", + status_code="422", + json_info=ex.json() + ) from None + key = await self.get_webhook_secret_key(webhook.hook_id) + return webhook, key + + if send_test_notification: + await self._send_test_notification() + + if not isinstance(key, str): + key = await self.get_webhook_secret_key(webhook.hook_id) + + return webhook, key + + @api_helper.override_error_messages( + { + 404: { + "message": "Введена неправильный номер карты, возможно, " + "карта на которую вы переводите заблокирована" + } + } + ) + async def _detect_mobile_number(self, phone_number: str): + """ + Метод для получения идентификатора телефона + + https://developer.qiwi.com/ru/qiwi-wallet-personal/?python#cards + """ + headers = deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) + headers.update( + { + 'Content-Type': 'application/x-www-form-urlencoded' + } + ) + async for response in self._requests.fetch( + url='https://qiwi.com/mobile/detect.action', + headers=headers, + method='POST', + data={ + 'phone': phone_number + }, + get_json=True + ): + return response.response_data.get('message') + + async def get_balance(self) -> Sum: + """Метод для получения баланса киви""" + if not isinstance(self.phone_number, str): + raise InvalidData( + "Для вызова этого метода вы должны передать номер кошелька" + ) + + headers = self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )) + url = self._router.build_url( + "GET_BALANCE", + phone_number=self.phone_number + ) + async for response in self._requests.fast().fetch( + url=url, + headers=headers, + method='GET', + get_json=True + ): + return Sum.parse_obj( + response.response_data['accounts'][0]['balance'] + ) + + async def transactions( + self, + rows_num: int = 50, + operation: str = 'ALL', + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None + ) -> List[Transaction]: + """ + Метод для получения транзакций на счёту + Более подробная документация: + https://developer.qiwi.com/ru/qiwi-wallet-personal/?http#payments_list + + Возможные значения параметра operation: + - 'ALL' + - 'IN' + - 'OUT' + - 'QIWI_CARD' + + :param rows_num: кол-во транзакций, которые вы хотите получить + :param operation: Тип операций в отчете, для отбора. + :param start_date:Начальная дата поиска платежей. + Используется только вместе с end_date. + :param end_date: конечная дата поиска платежей. + Используется только вместе со start_date. + """ + if rows_num > 50 or rows_num <= 0: + raise InvalidData('Можно проверять не более 50 транзакций') + + headers = self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )) + + payload_data = api_helper.check_dates( + start_date=start_date, + end_date=end_date, + payload_data={ + 'rows': rows_num, + 'operation': operation + } + ) + url = self._router.build_url( + "TRANSACTIONS", + stripped_number=self.stripped_number + ) + + async for response in self._requests.fast().fetch( + url=url, + params=payload_data, + headers=headers, + method='GET', + get_json=True + ): + return api_helper.multiply_objects_parse( + lst_of_objects=(response.response_data.get('data'),), + model=Transaction + ) + + async def transaction_info( + self, + transaction_id: Union[str, int], + transaction_type: str + ) -> Transaction: + """ + Метод для получения полной информации о транзакции\n + Подробная документация: + https://developer.qiwi.com/ru/qiwi-wallet-personal/?python#txn_info + + :param transaction_id: номер транзакции + :param transaction_type: тип транзакции, может быть только IN или OUT + :return: Transaction object + """ + headers = self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )) + payload_data = { + 'type': transaction_type + } + url = self._router.build_url( + "TRANSACTION_INFO", + transaction_id=transaction_id + ) + async for response in self._requests.fast().fetch( + url=url, + headers=headers, + params=payload_data, + method='GET', + get_json=True + ): + return Transaction.parse_obj(response.response_data) + + async def check_restriction(self) -> List[Restriction]: + """ + Метод для проверки ограничений на вашем киви кошельке\n + Подробная документация: + https://developer.qiwi.com/ru/qiwi-wallet-personal/?python#restrictions + + :return: Список, где находиться словарь с ограничениями, + если ограничений нет - возвращает пустой список + """ + if not isinstance(self.phone_number, str): + raise InvalidData( + "Для вызова этого метода вы должны передать номер кошелька" + ) + headers = self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )) + url = self._router.build_url( + "CHECK_RESTRICTION", + phone_number=self.phone_number + ) + async for response in self._requests.fast().fetch( + url=url, + headers=headers, + method='GET', + get_json=True + ): + return api_helper.simple_multiply_parse( + lst_of_objects=response.response_data, + model=Restriction + ) + + @property + async def identification(self) -> Identification: + """ + Функция, которая позволяет + получить данные идентификации вашего кошелька + Более подробная документация: + https://developer.qiwi.com/ru/qiwi-wallet-personal/?http#ident + + :return: Response object + """ + if not isinstance(self.phone_number, str): + raise InvalidData( + "Для вызова этого метода вы должны передать номер кошелька" + ) + + headers = self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )) + url = self._router.build_url( + "GET_IDENTIFICATION", + phone_number=self.phone_number + ) + async for response in self._requests.fast().fetch( + url=url, + method='GET', + headers=headers, + get_json=True + ): + return Identification.parse_obj(response.response_data) + + async def check_transaction( + self, + amount: Union[int, float], + transaction_type: str = 'IN', + sender: Optional[str] = None, + rows_num: int = 50, + comment: Optional[str] = None + ) -> bool: + """ + [ NON API METHOD ] + Метод для проверки транзакции.\n + Данный метод использует self.transactions(rows_num=rows_num) + для получения платежей.\n + Для небольшой оптимизации вы можете уменьшить rows_num задав его, + однако это не гарантирует правильный результат + Возможные значения параметра transaction_type: + - 'IN' + - 'OUT' + - 'QIWI_CARD' + + + :param amount: сумма платежа + :param transaction_type: тип платежа + :param sender: номер получателя + :param rows_num: кол-во платежей, которое будет проверяться + :param comment: комментарий, по которому будет проверяться транзакция + :return: bool, есть ли такая транзакция в истории платежей + """ + if transaction_type not in ['IN', 'OUT', 'QIWI_CARD']: + raise InvalidData('Вы ввели неправильный метод транзакции') + + elif rows_num > 50 or rows_num <= 0: + raise InvalidData('Можно проверять не более 50 транзакций') + + transactions = await self.transactions(rows_num=rows_num) + + return api_helper.check_transaction( + transactions=transactions, + transaction_type=transaction_type, + comment=comment, + amount=amount, + sender=sender + ) + + async def get_limits(self) -> Dict[str, Limit]: + """ + Функция для получения лимитов по счёту киви кошелька\n + Возвращает лимиты по кошельку в виде списка, + если лимита по определенной стране нет, то не включает его в список + Подробная документация: + https://developer.qiwi.com/ru/qiwi-wallet-personal/?http#limits + + :return: Limit object of Limit(pydantic) + """ + headers = self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )) + + payload = {} + limit_types = self._router.config.LIMIT_TYPES + for index, limit_type in enumerate(limit_types): + payload['types[' + str(index) + ']'] = limit_type + url = self._router.build_url( + "GET_LIMITS", + stripped_number=self.stripped_number + ) + async for response in self._requests.fast().fetch( + url=url, + get_json=True, + headers=headers, + params=payload, + method='GET' + ): + return api_helper.parse_limits(response, Limit) + + async def get_list_of_cards(self) -> List[Card]: + """ + Данный метод позволяет вам получить список ваших карт. + + """ + headers = self._auth_token( + deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) + ) + async for response in self._requests.fast().fetch( + url=self._router.build_url("GET_LIST_OF_CARDS"), + method='GET', + headers=headers, + get_json=True + ): + return api_helper.simple_multiply_parse( + lst_of_objects=response.response_data, + model=Card + ) + + async def authenticate( + self, + birth_date: str, + first_name: str, + last_name: str, + patronymic: str, + passport: str, + oms: Optional[str] = None, + inn: Optional[str] = None, + snils: Optional[str] = None + ) -> Optional[Dict[str, bool]]: + """ + Данный запрос позволяет отправить данные + для идентификации вашего QIWI кошелька. + Допускается идентифицировать не более 5 кошельков на одного владельца + + Для идентификации кошелька вы обязательно должны отправить ФИО, + серию/номер паспорта и дату рождения.\n + Если данные прошли проверку, то в ответе будет отображен + ваш ИНН и упрощенная идентификация кошелька будет установлена. + В случае если данные не прошли проверку, + кошелек остается в статусе "Минимальный". + + :param birth_date: Дата рождения в виде строки формата 1998-02-11 + :param first_name: Ваше имя + :param last_name: Ваша фамилия + :param patronymic: Ваше отчество + :param passport: Серия / Номер паспорта. Пример 4400111222 + :param oms: + :param snils: + :param inn: + """ + + payload = { + "birthDate": birth_date, + "firstName": first_name, + "inn": inn, + "lastName": last_name, + "middleName": patronymic, + "oms": oms, + "passport": passport, + "snils": snils + } + headers = self._auth_token( + deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) + ) + url = self._router.build_url( + "AUTHENTICATE", + stripped_number=self.stripped_number + ) + async for response in self._requests.fast().fetch( + url=url, + data=self._requests.filter_dict(payload), + headers=headers, + get_json=True + ): + if response.ok and response.status_code == 200: + return {'success': True} + + @api_helper.override_error_messages({ + 422: { + "message": "Невозможно получить чек, из-за того, что " + "транзакция по такому айди не была проведена, " + "то есть произошла ошибка при проведении транзакции" + } + }) + async def get_receipt( + self, + transaction_id: Union[str, int], + transaction_type: str, + dir_path: Union[str, pathlib.Path] = None, + file_name: Optional[str] = None + ) -> Union[bytes, int]: + """ + Метод для получения чека в формате байтов или файлом.\n + Возможные значения transaction_type: + - 'IN' + - 'OUT' + - 'QIWI_CARD' + + :param transaction_id: str or int, id транзакции, + можно получить при вызове методе to_wallet, to_card + :param transaction_type: тип транзакции может быть: + 'IN', 'OUT', 'QIWI_CARD' + :param dir_path: путь к директории, куда вы хотите сохранить чек, + если не указан, возвращает байты + :param file_name: Имя файла без формата. Пример: my_receipt + :return: pdf файл в байтовом виде или номер записанных байтов + """ + headers = self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )) + + data = { + 'type': transaction_type, + 'format': 'PDF' + } + url = self._router.build_url( + "GET_RECEIPT", transaction_id=transaction_id + ) + async for response in self._requests.fast().fetch( + url=url, + headers=headers, + method='GET', + params=data, + get_bytes=True + ): + if not isinstance( + dir_path, (str, pathlib.Path) + ) or not isinstance(file_name, str): + return response.response_data + + return await api_helper.save_file( + dir_path=dir_path, file_name=file_name, + data=response.response_data + ) + + @property + async def account_info(self) -> QiwiAccountInfo: + """ + Метод для получения информации об аккаунте + + """ + headers = self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )) + async for response in self._requests.fast().fetch( + url=self._router.build_url("ACCOUNT_INFO"), + headers=headers, + method='GET', + get_json=True + ): + return QiwiAccountInfo.parse_obj(response.response_data) + + async def fetch_statistics( + self, + start_date: Union[datetime, timedelta], + end_date: Union[datetime, timedelta], + operation: str = 'ALL', + sources: Optional[List[str]] = None + ) -> Statistic: + """ + Данный запрос используется для получения сводной статистики + по суммам платежей за заданный период.\n + Более подробная документация: + https://developer.qiwi.com/ru/qiwi-wallet-personal/?http#payments_list + + :param start_date: Начальная дата периода статистики. + Обязательный параметр + :param end_date: Конечная дата периода статистики. + Обязательный параметр + :param operation: Тип операций, учитываемых при подсчете статистики. + Допустимые значения: + ALL - все операции, + IN - только пополнения, + OUT - только платежи, + QIWI_CARD - только платежи по картам QIWI (QVC, QVP). + По умолчанию ALL. + :param sources: Источники платежа, по которым вернутся данные. + QW_RUB - рублевый счет кошелька, + QW_USD - счет кошелька в долларах, + QW_EUR - счет кошелька в евро, + CARD - привязанные и непривязанные к кошельку банковские карты, + MK - счет мобильного оператора. Если не указан, + учитываются все источники платежа. + """ + headers = self._auth_token( + deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) + ) + # Raise exception if invalid value of start_date or end_date + api_helper.check_dates_for_statistic_request( + start_date=start_date, + end_date=end_date + ) + params = { + 'startDate': api_helper.datetime_to_str_in_iso(start_date), + 'endDate': api_helper.datetime_to_str_in_iso(end_date), + 'operation': operation, + } + + if sources: + params.update({'sources': ' '.join(sources)}) + + url = self._router.build_url( + "FETCH_STATISTICS", + stripped_number=self.stripped_number + ) + async for response in self._requests.fast().fetch( + url=url, + params=params, + headers=headers, + get_json=True, + method='GET' + ): + return Statistic.parse_obj(response.response_data) + + async def list_of_balances(self) -> List[Account]: + """ + Запрос выгружает текущие балансы счетов вашего QIWI Кошелька. + Более подробная документация: + https://developer.qiwi.com/ru/qiwi-wallet-personal/?http#balances_list + + """ + url = self._router.build_url( + "LIST_OF_BALANCES", + stripped_number=self.stripped_number + ) + async for response in self._requests.fast().fetch( + url=url, + headers=self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )), + method='GET', + get_json=True + ): + return api_helper.simple_multiply_parse( + response.response_data.get('accounts'), Account + ) + + @api_helper.allow_response_code(201) + async def create_new_balance( + self, currency_alias: str + ) -> Optional[Dict[str, bool]]: + """ + Запрос создает новый счет и баланс в вашем QIWI Кошельке + + :param currency_alias: Псевдоним нового счета + :return: Возвращает значение из декоратора allow_response_code + Пример результата, если запрос был проведен успешно: {"success": True} + """ + headers = self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )) + payload = { + 'alias': currency_alias + } + url = self._router.build_url( + "CREATE_NEW_BALANCE", + stripped_number=self.stripped_number + ) + async for response in self._requests.fast().fetch( + url=url, + headers=headers, + data=payload, + get_json=True + ): + return response.response_data + + async def available_balances(self) -> List[Balance]: + """ + Запрос отображает псевдонимы счетов, + доступных для создания в вашем QIWI Кошельке + Сигнатура объекта ответа: + class Balance(BaseModel): + alias: str + currency: int + + """ + url = self._router.build_url( + "AVAILABLE_BALANCES", + stripped_number=self.stripped_number + ) + headers = self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )) + async for response in self._requests.fast().fetch( + url=url, + headers=headers, + method='GET', + get_json=True + ): + return api_helper.simple_multiply_parse( + lst_of_objects=response.response_data, model=Balance) + + @api_helper.allow_response_code(204) + async def set_default_balance(self, currency_alias: str) -> Any: + """ + Запрос устанавливает для вашего QIWI Кошелька счет, + баланс которого будет использоваться для фондирования + всех платежей по умолчанию. + Счет должен содержаться в списке счетов, получить список можно вызвав + метод list_of_balances + + :param currency_alias: Псевдоним нового счета, + можно получить из list_of_balances + :return: Возвращает значение из декоратора allow_response_code + Пример результата, если запрос был проведен успешно: {"success": True} + """ + headers = self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )) + url = self._router.build_url( + "SET_DEFAULT_BALANCE", + stripped_number=self.stripped_number, + currency_alias=currency_alias + ) + async for response in self._requests.fast().fetch( + url=url, + headers=headers, + method='PATCH', + json={'defaultAccount': True}, + get_json=True + ): + return response + + @property + def transaction_handler(self): + """ + Handler manager for default QIWI transactions, + you can pass on lambda filter, if you want, + but it must to return a boolean + + """ + return self.dispatcher.transaction_handler_wrapper + + @property + def bill_handler(self): + """ + Handler manager for P2P bills, + you can pass on lambda filter, if you want + But it must to return a boolean + + """ + return self.dispatcher.bill_handler_wrapper + + async def to_wallet( + self, + to_number: str, + trans_sum: Union[str, float, int], + currency: str = '643', + comment: str = '+comment+') -> Optional[str]: + """ + Метод для перевода денег на другой кошелек\n + Подробная документация: + https://developer.qiwi.com/ru/qiwi-wallet-personal/?python#p2p + + :param to_number: номер получателя + :param trans_sum: кол-во денег, которое вы хотите перевести + :param currency: особенный код валюты + :param comment: комментарий к платежу + """ + data = api_helper.set_data_to_wallet( + data=deepcopy(self._router.config.QIWI_TO_WALLET), + to_number=to_number, + trans_sum=trans_sum, + currency=currency, + comment=comment + ) + data.headers = self._auth_token( + headers=data.headers + ) + async for response in self._requests.fast().fetch( + url=self._router.build_url("TO_WALLET"), + json=data.json, + headers=data.headers, + get_json=True + ): + return response.response_data['transaction']['id'] + + async def to_card( + self, + trans_sum: Union[float, int], + to_card: str + ) -> Optional[str]: + """ + Метод для отправки средств на карту. + Более подробная документация: + https://developer.qiwi.com/ru/qiwi-wallet-personal/#cards + + :param trans_sum: сумма перевода + :param to_card: номер карты получателя + :return: + """ + data = api_helper.parse_card_data( + default_data=self._router.config.QIWI_TO_CARD, + to_card=to_card, + trans_sum=trans_sum, + auth_maker=self._auth_token + ) + privat_card_id = await self._detect_card_number(card_number=to_card) + async for response in self._requests.fast().fetch( + url=self._router.build_url( + "TO_CARD", privat_card_id=privat_card_id + ), + headers=data.headers, + json=data.json, + get_json=True + ): + return response.response_data.get('id') + + async def _detect_card_number(self, card_number: str) -> str: + """ + Метод для получения идентификатора карты + + https://developer.qiwi.com/ru/qiwi-wallet-personal/?python#cards + """ + headers = deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) + headers.update( + { + 'Content-Type': 'application/x-www-form-urlencoded' + } + ) + async for response in self._requests.fetch( + url='https://qiwi.com/card/detect.action', + headers=headers, + method='POST', + data={ + 'cardNumber': card_number + }, + get_json=True + ): + try: + return response.response_data.get('message') + except KeyError: + raise InvalidCardNumber( + 'Invalid card number or qiwi api is not response' + ) from None + + async def commission( + self, + to_account: str, + pay_sum: Union[int, float] + ) -> Commission: + """ + Возвращается полная комиссия QIWI Кошелька + за платеж в пользу указанного провайдера + с учетом всех тарифов по заданному набору платежных реквизитов. + + :param to_account: номер карты или киви кошелька + :param pay_sum: сумма, за которую вы хотите узнать комиссию + :return: Commission object + """ + payload, special_code = api_helper.parse_commission_request_payload( + default_data=self._router.config.COMMISSION_DATA, + auth_maker=self._auth_token, + to_account=to_account, + pay_sum=pay_sum + ) + if not isinstance(special_code, str): + special_code = await self._detect_card_number( + card_number=to_account + ) + url = self._router.build_url("COMMISSION", special_code=special_code) + async for response in self._requests.fast().fetch( + url=url, + headers=payload.headers, + json=payload.json, + get_json=True + ): + return Commission.parse_obj(response.response_data) + + async def get_cross_rates(self) -> List[CrossRate]: + """ + Метод возвращает текущие курсы и кросс-курсы валют КИВИ Банка. + + """ + url = self._router.build_url("GET_CROSS_RATES") + async for response in self._requests.fast().fetch( + url=url, + method='GET', + get_json=True + ): + return api_helper.simple_multiply_parse( + lst_of_objects=response.response_data.get("result"), + model=CrossRate + ) + + async def payment_by_payment_details( + self, + payment_sum: Sum, + payment_method: PaymentMethod, + fields: FreePaymentDetailsFields, + payment_id: Optional[str] = None + ) -> PaymentInfo: + """ + Оплата услуг коммерческих организаций по их банковским реквизитам. + + :param payment_id: id платежа, если не передается, используется + uuid4 + :param payment_sum: обьект Sum, в котором указывается сумма платежа + :param payment_method: метод платежа + :param fields: Набор реквизитов платежа + """ + url = self._router.build_url("SPECIAL_PAYMENT") + headers = deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) + payload = { + "id": payment_id if isinstance(payment_id, str) else str( + uuid.uuid4() + ), + "sum": payment_sum.dict(), + "paymentMethod": payment_method.dict(), + "fields": fields.dict() + } + async for response in self._requests.fast().fetch( + url=url, + json=payload, + headers=headers, + get_json=True + ): + return PaymentInfo.parse_obj(response.response_data) + + async def buy_qiwi_master(self) -> PaymentInfo: + """ + Метод для покупки пакета QIWI Мастер + + Для вызова методов API вам потребуется токен API QIWI Wallet + с разрешениями на следующие действия: + + 1. Управление виртуальными картами, + 2. Запрос информации о профиле кошелька, + 3. Просмотр истории платежей, + 4. Проведение платежей без SMS. + + Эти права вы можете выбрать при создании нового апи токена, + чтобы пользоваться апи QIWI Master + """ + url = self._router.build_url("BUY_QIWI_MASTER") + payload = api_helper.qiwi_master_data(self.stripped_number, + self._router.config.QIWI_MASTER) + async for response in self._requests.fast().fetch( + url=url, + json=payload, + method='POST', + headers=self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )), + get_json=True + ): + return PaymentInfo.parse_obj(response.response_data) + + async def __pre_qiwi_master_request( + self, + card_alias: str = 'qvc-cpa' + ) -> OrderDetails: + """ + Метод для выпуска виртуальной карты QIWI Мастер + + :param card_alias: Тип карты + :return: OrderDetails + """ + url = self._router.build_url( + "PRE_QIWI_REQUEST", + stripped_number=self.stripped_number + ) + async for response in self._requests.fast().fetch( + url=url, + headers=self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )), + json={"cardAlias": card_alias}, + method='POST', + get_json=True + ): + return OrderDetails.parse_obj(response.response_data) + + async def _confirm_qiwi_master_request( + self, + card_alias: str = 'qvc-cpa' + ) -> OrderDetails: + """ + Подтверждение заказа выпуска карты + + :param card_alias: Тип карты + :return: OrderDetails + """ + details = await self.__pre_qiwi_master_request(card_alias) + url = self._router.build_url( + "_CONFIRM_QIWI_MASTER", + stripped_number=self.stripped_number, + order_id=details.order_id + ) + async for response in self._requests.fast().fetch( + url=url, + headers=self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )), + method='PUT', + get_json=True + ): + return OrderDetails.parse_obj(response.response_data) + + async def __buy_new_qiwi_card( + self, + **kwargs + ) -> Optional[OrderDetails]: + """ + Покупка карты, если она платная + + :param kwargs: + :return: OrderDetails + """ + kwargs.update(data=self._router.config.QIWI_MASTER) + url, payload = api_helper.new_card_data(**kwargs) + async for response in self._requests.fast().fetch( + url=url, + json=payload, + headers=self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )), + get_json=True + ): + return OrderDetails.parse_obj(response.response_data) + + async def issue_qiwi_master_card( + self, + card_alias: str = 'qvc-cpa' + ) -> Optional[OrderDetails]: + """ + Выпуск новой карты, используя Qiwi Master API + + При выпуске карты производиться 3, а возможно 3 запроса, + а именно по такой схеме: + - __pre_qiwi_master_request - данный метод создает заявку + - _confirm_qiwi_master_request - подтверждает выпуск карты + - __buy_new_qiwi_card - покупает новую карту, + если такая карта не бесплатна + + + Подробная документация: + + https://developer.qiwi.com/ru/qiwi-wallet-personal/#qiwi-master-issue-card + + :param card_alias: Тип карты + :return: OrderDetails + """ + pre_response = await self._confirm_qiwi_master_request(card_alias) + if pre_response.status == 'COMPLETED': + return pre_response + return await self.__buy_new_qiwi_card( + ph_number=self.stripped_number, + order_id=pre_response.order_id + ) + + async def _cards_qiwi_master(self): + """ + Метод для получение списка всех ваших карт QIWI Мастер + + """ + url = self._router.build_url("CARDS_QIWI_MASTER") + async for response in self._requests.fast().fetch( + url=url, + headers=self._auth_token(deepcopy( + self._router.config.DEFAULT_QIWI_HEADERS + )), + method='GET', + get_json=True + ): + return response + + async def reject_p2p_bill(self, bill_id: str) -> Bill: + """ + Метод для отмены транзакции. + + :param bill_id: номер p2p транзакции + :return: Bill obj + """ + if not self.secret_p2p: + raise InvalidData('Не задан p2p токен') + data = deepcopy(self._p2p_router.config.P2P_DATA) + headers = self._auth_token(data.headers, p2p=True) + url = f'https://api.qiwi.com/partner/bill/v1/bills/{bill_id}/reject' + async for response in self._requests.fast().fetch( + url=url, + method='POST', + headers=headers, + get_json=True + ): + return Bill.parse_obj(response.response_data) + + async def check_p2p_bill_status(self, bill_id: str) -> str: + """ + Метод для проверки статуса p2p транзакции.\n + Возможные типы транзакции: \n + WAITING Счет выставлен, ожидает оплаты \n + PAID Счет оплачен \n + REJECTED Счет отклонен\n + EXPIRED Время жизни счета истекло. Счет не оплачен\n + Более подробная документация: + https://developer.qiwi.com/ru/p2p-payments/?shell#invoice-status + + :param bill_id: номер p2p транзакции + :return: статус транзакции строкой + """ + if not self.secret_p2p: + raise InvalidData('Не задан p2p токен') + + data = deepcopy(self._router.config.P2P_DATA) + headers = self._auth_token(data.headers, p2p=True) + url = self._p2p_router.build_url( + "CHECK_P2P_BILL_STATUS", + bill_id=bill_id + ) + async for response in self._requests.fast().fetch( + url=url, + method='GET', + headers=headers, + get_json=True + ): + return Bill.parse_obj(response.response_data).status.value + + async def create_p2p_bill( + self, + amount: Union[int, float], + bill_id: Optional[str] = None, + comment: Optional[str] = None, + life_time: Optional[datetime] = None, + theme_code: Optional[str] = None, + pay_source_filter: Optional[List[str]] = None + ) -> Bill: + """ + Метод для выставление p2p счёта. + Надежный способ для интеграции. + Параметры передаются server2server с использованием авторизации. + Возможные значения pay_source_filter: + - 'qw' + - 'card' + - 'mobile' + + :param amount: сумма платежа + :param bill_id: уникальный номер транзакции, если не передан, + генерируется автоматически, + :param life_time: дата, до которой счет будет доступен для оплаты. + :param comment: комментарий к платежу + :param theme_code: специальный код темы + :param pay_source_filter: При открытии формы будут отображаться + только указанные способы перевода + """ + from glQiwiApi.core import constants + + if not self.secret_p2p: + raise InvalidData('Не задан p2p токен') + + if not isinstance(bill_id, (str, int)): + bill_id = str(uuid.uuid4()) + + _life_time = api_helper.datetime_to_str_in_iso( + constants.DEFAULT_BILL_TIME if not life_time else life_time + ) + + data = deepcopy(self._p2p_router.config.P2P_DATA) + + headers = self._auth_token(data.headers, p2p=True) + + payload = api_helper.set_data_p2p_create( + wrapped_data=data, + amount=amount, + comment=comment, + theme_code=theme_code, + pay_source_filter=pay_source_filter, + life_time=str(_life_time) + ) + url = self._p2p_router.build_url( + "CREATE_P2P_BILL", + bill_id=bill_id + ) + async for response in self._requests.fast().fetch( + url=url, + json=payload, + headers=headers, + method='PUT', + get_json=True + ): + return Bill.parse_obj(response.response_data) + + async def get_bills(self, rows_num: int) -> List[Bill]: + """ + Метод получения списка неоплаченных счетов вашего кошелька. + Список строится в обратном хронологическом порядке. + По умолчанию, список разбивается на страницы по 50 элементов в каждой, + но вы можете задать другое количество элементов (не более 50). + В запросе можно использовать фильтры по времени выставления счета, + начальному идентификатору счета. + """ + headers = self._auth_token( + deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) + ) + if rows_num > 50: + raise InvalidData('Можно получить не более 50 счетов') + + params = { + 'rows': rows_num, + 'statuses': 'READY_FOR_PAY' + } + async for response in self._requests.fast().fetch( + url=self._router.build_url("GET_BILLS"), + headers=headers, + method='GET', + params=params, + get_json=True + ): + return api_helper.simple_multiply_parse( + response.response_data.get("bills"), Bill + ) + + async def refund_bill( + self, + bill_id: Union[str, int], + refund_id: Union[str, int], + json_bill_data: Union[OptionalSum, Dict[str, Union[str, int]]] + ) -> RefundBill: + """ + Метод позволяет сделать возврат средств по оплаченному счету. + в JSON-теле запроса параметра json_bill_data:\n + amount.value - сумма возврата. \n + amount.currency - валюта возврата. + Может быть словарем или объектом OptionalSum\n + Пример словаря: { + "amount": { + "currency": "RUB", + "value": 1 + } + } + + :param bill_id: уникальный идентификатор счета в системе мерчанта + :param refund_id: уникальный идентификатор возврата в системе мерчанта. + :param json_bill_data: + :return: RefundBill object + """ + url = self._router.build_url( + "REFUND_BILL", + refund_id=refund_id, + bill_id=bill_id + ) + headers = self._auth_token( + deepcopy(self._router.config.DEFAULT_QIWI_HEADERS), p2p=True + ) + async for response in self._requests.fast().fetch( + url=url, + headers=headers, + method='PUT', + json=json_bill_data if isinstance( + json_bill_data, + dict + ) else json_bill_data.json(), + get_json=True + ): + return RefundBill.parse_obj(response.response_data) + + async def create_p2p_keys( + self, + key_pair_name: str, + server_notification_url: Optional[str] = None) -> P2PKeys: + """ + Метод создает новые p2p ключи + + :param key_pair_name: Название пары токенов P2P (произвольная строка) + :param server_notification_url: url для вебхуков, необязательный + параметр + """ + url = self._router.build_url("CREATE_P2P_KEYS") + headers = self._auth_token( + deepcopy(self._router.config.DEFAULT_QIWI_HEADERS), p2p=True + ) + data = { + 'keysPairName': key_pair_name, + 'serverNotificationsUrl': server_notification_url + } + async for response in self._requests.fast().fetch( + url=url, + headers=headers, + json=data, + get_json=True + ): + return P2PKeys.parse_obj(response.response_data) diff --git a/glQiwiApi/qiwi/mixins/__init__.py b/glQiwiApi/qiwi/mixins/__init__.py deleted file mode 100644 index e9142dcb..00000000 --- a/glQiwiApi/qiwi/mixins/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .kassa_mixin import QiwiKassaMixin -from .master_mixin import QiwiMasterMixin -from .polling_mixin import HistoryPollingMixin -from .webhook_mixin import QiwiWebHookMixin -from .payment_mixin import QiwiPaymentsMixin - -__all__ = ( - 'QiwiKassaMixin', - 'QiwiMasterMixin', - 'HistoryPollingMixin', - 'QiwiWebHookMixin', - 'QiwiPaymentsMixin' -) diff --git a/glQiwiApi/qiwi/mixins/kassa_mixin.py b/glQiwiApi/qiwi/mixins/kassa_mixin.py deleted file mode 100644 index bfc45637..00000000 --- a/glQiwiApi/qiwi/mixins/kassa_mixin.py +++ /dev/null @@ -1,241 +0,0 @@ -import uuid -from copy import deepcopy -from datetime import datetime -from typing import Union, Optional, Dict, List, Any, MutableMapping - -from glQiwiApi.core import constants -from glQiwiApi.core.abstracts import AbstractRouter -from glQiwiApi.types import Bill, BillError, OptionalSum -from glQiwiApi.types.qiwi_types.bill import RefundBill, P2PKeys -from glQiwiApi.utils import basics as api_helper -from glQiwiApi.utils.exceptions import InvalidData - - -class QiwiKassaMixin: - - def __init__( - self, - p2p_router: AbstractRouter, - default_router: AbstractRouter, - requests_manager: Any, - secret_p2p: str - ): - self._router = default_router - self._p2p_router = p2p_router - self._requests = requests_manager - self.secret_p2p = secret_p2p - - def _auth_token( - self, - headers: MutableMapping, - p2p: bool = False - ) -> MutableMapping: - ... - - async def reject_p2p_bill(self, bill_id: str) -> Bill: - """ - Метод для отмены транзакции. - - :param bill_id: номер p2p транзакции - :return: Bill obj - """ - if not self.secret_p2p: - raise InvalidData('Не задан p2p токен') - data = deepcopy(self._p2p_router.config.P2P_DATA) - headers = self._auth_token(data.headers, p2p=True) - url = f'https://api.qiwi.com/partner/bill/v1/bills/{bill_id}/reject' - async for response in self._requests.fast().fetch( - url=url, - method='POST', - headers=headers - ): - return Bill.parse_obj(response.response_data) - - async def check_p2p_bill_status(self, bill_id: str) -> str: - """ - Метод для проверки статуса p2p транзакции.\n - Возможные типы транзакции: \n - WAITING Счет выставлен, ожидает оплаты \n - PAID Счет оплачен \n - REJECTED Счет отклонен\n - EXPIRED Время жизни счета истекло. Счет не оплачен\n - Более подробная документация: - https://developer.qiwi.com/ru/p2p-payments/?shell#invoice-status - - :param bill_id: номер p2p транзакции - :return: статус транзакции строкой - """ - if not self.secret_p2p: - raise InvalidData('Не задан p2p токен') - - data = deepcopy(self._router.config.P2P_DATA) - headers = self._auth_token(data.headers, p2p=True) - url = self._p2p_router.build_url( - "CHECK_P2P_BILL_STATUS", - bill_id=bill_id - ) - async for response in self._requests.fast().fetch( - url=url, - method='GET', - headers=headers - ): - return Bill.parse_obj(response.response_data).status.value - - async def create_p2p_bill( - self, - amount: int, - bill_id: Optional[str] = None, - comment: Optional[str] = None, - life_time: Optional[datetime] = None, - theme_code: Optional[str] = None, - pay_source_filter: Optional[List[str]] = None - ) -> Union[Bill, BillError]: - """ - Метод для выставление p2p счёта. - Надежный способ для интеграции. - Параметры передаются server2server с использованием авторизации. - Возможные значения pay_source_filter: - - 'qw' - - 'card' - - 'mobile' - - :param amount: сумма платежа - :param bill_id: уникальный номер транзакции, если не передан, - генерируется автоматически, - :param life_time: дата, до которой счет будет доступен для оплаты. - :param comment: комментарий к платежу - :param theme_code: специальный код темы - :param pay_source_filter: При открытии формы будут отображаться - только указанные способы перевода - """ - if not self.secret_p2p: - raise InvalidData('Не задан p2p токен') - - if not isinstance(bill_id, (str, int)): - bill_id = str(uuid.uuid4()) - - _life_time = api_helper.datetime_to_str_in_iso( - constants.DEFAULT_BILL_TIME if not life_time else life_time - ) - - data = deepcopy(self._p2p_router.config.P2P_DATA) - - headers = self._auth_token(data.headers, p2p=True) - - payload = api_helper.set_data_p2p_create( - wrapped_data=data, - amount=amount, - comment=comment, - theme_code=theme_code, - pay_source_filter=pay_source_filter, - life_time=str(_life_time) - ) - url = self._p2p_router.build_url( - "CREATE_P2P_BILL", - bill_id=bill_id - ) - async for response in self._requests.fast().fetch( - url=url, - json=payload, - headers=headers, - method='PUT' - ): - return Bill.parse_obj(response.response_data).initialize(self) - - async def get_bills(self, rows: int) -> List[Bill]: - """ - Метод получения списка неоплаченных счетов вашего кошелька. - Список строится в обратном хронологическом порядке. - По умолчанию, список разбивается на страницы по 50 элементов в каждой, - но вы можете задать другое количество элементов (не более 50). - В запросе можно использовать фильтры по времени выставления счета, - начальному идентификатору счета. - """ - headers = self._auth_token( - deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) - ) - if rows > 50: - raise InvalidData('Можно получить не более 50 счетов') - - params = { - 'rows': rows, - 'statuses': 'READY_FOR_PAY' - } - async for response in self._requests.fast().fetch( - url=self._router.build_url("GET_BILLS"), - headers=headers, - method='GET', - params=params - ): - return api_helper.simple_multiply_parse( - response.response_data.get("bills"), Bill - ) - - async def refund_bill( - self, - bill_id: Union[str, int], - refund_id: Union[str, int], - json_bill_data: Union[OptionalSum, Dict[str, Union[str, int]]] - ) -> RefundBill: - """ - Метод позволяет сделать возврат средств по оплаченному счету. - в JSON-теле запроса параметра json_bill_data:\n - amount.value - сумма возврата. \n - amount.currency - валюта возврата. - Может быть словарем или объектом OptionalSum\n - Пример словаря: { - "amount": { - "currency": "RUB", - "value": 1 - } - } - - :param bill_id: уникальный идентификатор счета в системе мерчанта - :param refund_id: уникальный идентификатор возврата в системе мерчанта. - :param json_bill_data: - :return: RefundBill object - """ - url = self._router.build_url( - "REFUND_BILL", - refund_id=refund_id, - bill_id=bill_id - ) - headers = self._auth_token( - deepcopy(self._router.config.DEFAULT_QIWI_HEADERS), p2p=True - ) - async for response in self._requests.fast().fetch( - url=url, - headers=headers, - method='PUT', - json=json_bill_data if isinstance( - json_bill_data, - dict - ) else json_bill_data.json() - ): - return RefundBill.parse_obj(response.response_data) - - async def create_p2p_keys( - self, - key_pair_name: str, - server_notification_url: Optional[str] = None) -> P2PKeys: - """ - Метод создает новые p2p ключи - - :param key_pair_name: Название пары токенов P2P (произвольная строка) - :param server_notification_url: url для вебхуков, необязательный - параметр - """ - url = self._router.build_url("CREATE_P2P_KEYS") - headers = self._auth_token( - deepcopy(self._router.config.DEFAULT_QIWI_HEADERS), p2p=True - ) - data = { - 'keysPairName': key_pair_name, - 'serverNotificationsUrl': server_notification_url - } - async for response in self._requests.fast().fetch( - url=url, - headers=headers, - json=data - ): - return P2PKeys.parse_obj(response.response_data) diff --git a/glQiwiApi/qiwi/mixins/master_mixin.py b/glQiwiApi/qiwi/mixins/master_mixin.py deleted file mode 100644 index bb06d968..00000000 --- a/glQiwiApi/qiwi/mixins/master_mixin.py +++ /dev/null @@ -1,168 +0,0 @@ -from copy import deepcopy -from typing import Optional, Any, MutableMapping - -from glQiwiApi.core.abstracts import AbstractRouter -from glQiwiApi.types import PaymentInfo, OrderDetails -from glQiwiApi.utils import basics as api_helper - - -class QiwiMasterMixin: - """Mixin, which implements QIWI master API logic""" - - def __init__(self, router: AbstractRouter, request_manager: Any): - self._router = router - self._requests = request_manager - - def _auth_token( - self, - headers: MutableMapping, - p2p: bool = False - ) -> MutableMapping: - ... - - @property - def stripped_number(self) -> str: - return "" - - async def buy_qiwi_master(self) -> PaymentInfo: - """ - Метод для покупки пакета QIWI Мастер - - Для вызова методов API вам потребуется токен API QIWI Wallet - с разрешениями на следующие действия: - - 1. Управление виртуальными картами, - 2. Запрос информации о профиле кошелька, - 3. Просмотр истории платежей, - 4. Проведение платежей без SMS. - - Эти права вы можете выбрать при создании нового апи токена, - чтобы пользоваться апи QIWI Master - """ - url = self._router.build_url("BUY_QIWI_MASTER") - payload = api_helper.qiwi_master_data(self.stripped_number, - self._router.config.QIWI_MASTER) - async for response in self._requests.fast().fetch( - url=url, - json=payload, - method='POST', - headers=self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - ): - return PaymentInfo.parse_obj(response.response_data) - - async def __pre_qiwi_master_request( - self, - card_alias: str = 'qvc-cpa' - ) -> OrderDetails: - """ - Метод для выпуска виртуальной карты QIWI Мастер - - :param card_alias: Тип карты - :return: OrderDetails - """ - url = self._router.build_url( - "PRE_QIWI_REQUEST", - stripped_number=self.stripped_number - ) - async for response in self._requests.fast().fetch( - url=url, - headers=self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )), - json={"cardAlias": card_alias}, - method='POST' - ): - return OrderDetails.parse_obj(response.response_data) - - async def _confirm_qiwi_master_request( - self, - card_alias: str = 'qvc-cpa' - ) -> OrderDetails: - """ - Подтверждение заказа выпуска карты - - :param card_alias: Тип карты - :return: OrderDetails - """ - details = await self.__pre_qiwi_master_request(card_alias) - url = self._router.build_url( - "_CONFIRM_QIWI_MASTER", - stripped_number=self.stripped_number, - order_id=details.order_id - ) - async for response in self._requests.fast().fetch( - url=url, - headers=self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )), - method='PUT' - ): - return OrderDetails.parse_obj(response.response_data) - - async def __buy_new_qiwi_card( - self, - **kwargs - ) -> Optional[OrderDetails]: - """ - Покупка карты, если она платная - - :param kwargs: - :return: OrderDetails - """ - kwargs.update(data=self._router.config.QIWI_MASTER) - url, payload = api_helper.new_card_data(**kwargs) - async for response in self._requests.fast().fetch( - url=url, - json=payload, - headers=self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - ): - return OrderDetails.parse_obj(response.response_data) - - async def issue_qiwi_master_card( - self, - card_alias: str = 'qvc-cpa' - ) -> Optional[OrderDetails]: - """ - Выпуск новой карты, используя Qiwi Master API - - При выпуске карты производиться 3, а возможно 3 запроса, - а именно по такой схеме: - - __pre_qiwi_master_request - данный метод создает заявку - - _confirm_qiwi_master_request - подтверждает выпуск карты - - __buy_new_qiwi_card - покупает новую карту, - если такая карта не бесплатна - - - Подробная документация: - - https://developer.qiwi.com/ru/qiwi-wallet-personal/#qiwi-master-issue-card - - :param card_alias: Тип карты - :return: OrderDetails - """ - pre_response = await self._confirm_qiwi_master_request(card_alias) - if pre_response.status == 'COMPLETED': - return pre_response - return await self.__buy_new_qiwi_card( - ph_number=self.stripped_number, - order_id=pre_response.order_id - ) - - async def _cards_qiwi_master(self): - """ - Метод для получение списка всех ваших карт QIWI Мастер - - """ - url = self._router.build_url("CARDS_QIWI_MASTER") - async for response in self._requests.fast().fetch( - url=url, - headers=self._auth_token(deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )), - method='GET' - ): - return response diff --git a/glQiwiApi/qiwi/mixins/payment_mixin.py b/glQiwiApi/qiwi/mixins/payment_mixin.py deleted file mode 100644 index b31f0bf1..00000000 --- a/glQiwiApi/qiwi/mixins/payment_mixin.py +++ /dev/null @@ -1,195 +0,0 @@ -import uuid -from copy import deepcopy -from typing import Any, Optional, Union, List, MutableMapping - -from glQiwiApi.types import Commission, CrossRate, Sum, PaymentMethod, \ - FreePaymentDetailsFields, PaymentInfo -from glQiwiApi.utils.exceptions import InvalidCardNumber -from glQiwiApi.core.abstracts import AbstractRouter -from glQiwiApi.utils import basics as api_helper - - -class QiwiPaymentsMixin: - """Provides payment QIWI API""" - - def __init__(self, requests_manager: Any, router: AbstractRouter): - self._requests = requests_manager - self._router: AbstractRouter = router - - def _auth_token( - self, - headers: MutableMapping, - p2p: bool = False - ) -> MutableMapping: - ... - - async def to_wallet( - self, - to_number: str, - trans_sum: Union[str, float, int], - currency: str = '643', - comment: str = '+comment+') -> Optional[str]: - """ - Метод для перевода денег на другой кошелек\n - Подробная документация: - https://developer.qiwi.com/ru/qiwi-wallet-personal/?python#p2p - - :param to_number: номер получателя - :param trans_sum: кол-во денег, которое вы хотите перевести - :param currency: особенный код валюты - :param comment: комментарий к платежу - """ - data = api_helper.set_data_to_wallet( - data=deepcopy(self._router.config.QIWI_TO_WALLET), - to_number=to_number, - trans_sum=trans_sum, - currency=currency, - comment=comment - ) - data.headers = self._auth_token( - headers=data.headers - ) - async for response in self._requests.fast().fetch( - url=self._router.build_url("TO_WALLET"), - json=data.json, - headers=data.headers - ): - return response.response_data['transaction']['id'] - - async def to_card( - self, - trans_sum: Union[float, int], - to_card: str - ) -> Optional[str]: - """ - Метод для отправки средств на карту. - Более подробная документация: - https://developer.qiwi.com/ru/qiwi-wallet-personal/#cards - - :param trans_sum: сумма перевода - :param to_card: номер карты получателя - :return: - """ - data = api_helper.parse_card_data( - default_data=self._router.config.QIWI_TO_CARD, - to_card=to_card, - trans_sum=trans_sum, - auth_maker=self._auth_token - ) - privat_card_id = await self._detect_card_number(card_number=to_card) - async for response in self._requests.fast().fetch( - url=self._router.build_url( - "TO_CARD", privat_card_id=privat_card_id - ), - headers=data.headers, - json=data.json, - get_json=True - ): - return response.response_data.get('id') - - async def _detect_card_number(self, card_number: str) -> str: - """ - Метод для получения идентификатора карты - - https://developer.qiwi.com/ru/qiwi-wallet-personal/?python#cards - """ - headers = deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) - headers.update( - { - 'Content-Type': 'application/x-www-form-urlencoded' - } - ) - async for response in self._requests.fetch( - url='https://qiwi.com/card/detect.action', - headers=headers, - method='POST', - data={ - 'cardNumber': card_number - } - ): - try: - return response.response_data.get('message') - except KeyError: - raise InvalidCardNumber( - 'Invalid card number or qiwi api is not response' - ) from None - - async def commission( - self, - to_account: str, - pay_sum: Union[int, float] - ) -> Commission: - """ - Возвращается полная комиссия QIWI Кошелька - за платеж в пользу указанного провайдера - с учетом всех тарифов по заданному набору платежных реквизитов. - - :param to_account: номер карты или киви кошелька - :param pay_sum: сумма, за которую вы хотите узнать комиссию - :return: Commission object - """ - payload, special_code = api_helper.parse_commission_request_payload( - default_data=self._router.config.COMMISSION_DATA, - auth_maker=self._auth_token, - to_account=to_account, - pay_sum=pay_sum - ) - if not isinstance(special_code, str): - special_code = await self._detect_card_number( - card_number=to_account - ) - url = self._router.build_url("COMMISSION", special_code=special_code) - async for response in self._requests.fast().fetch( - url=url, - headers=payload.headers, - json=payload.json - ): - return Commission.parse_obj(response.response_data) - - async def get_cross_rates(self) -> List[CrossRate]: - """ - Метод возвращает текущие курсы и кросс-курсы валют КИВИ Банка. - - """ - url = self._router.build_url("GET_CROSS_RATES") - async for response in self._requests.fast().fetch( - url=url, - method='GET' - ): - return api_helper.simple_multiply_parse( - lst_of_objects=response.response_data.get("result"), - model=CrossRate - ) - - async def payment_by_payment_details( - self, - payment_sum: Sum, - payment_method: PaymentMethod, - fields: FreePaymentDetailsFields, - payment_id: Optional[str] = None - ) -> PaymentInfo: - """ - Оплата услуг коммерческих организаций по их банковским реквизитам. - - :param payment_id: id платежа, если не передается, используется - uuid4 - :param payment_sum: обьект Sum, в котором указывается сумма платежа - :param payment_method: метод платежа - :param fields: Набор реквизитов платежа - """ - url = self._router.build_url("SPECIAL_PAYMENT") - headers = deepcopy(self._router.config.DEFAULT_QIWI_HEADERS) - payload = { - "id": payment_id if isinstance(payment_id, str) else str( - uuid.uuid4() - ), - "sum": payment_sum.dict(), - "paymentMethod": payment_method.dict(), - "fields": fields.dict() - } - async for response in self._requests.fast().fetch( - url=url, - json=payload, - headers=headers - ): - return PaymentInfo.parse_obj(response.response_data) diff --git a/glQiwiApi/qiwi/mixins/polling_mixin.py b/glQiwiApi/qiwi/mixins/polling_mixin.py deleted file mode 100644 index ddeecc3a..00000000 --- a/glQiwiApi/qiwi/mixins/polling_mixin.py +++ /dev/null @@ -1,223 +0,0 @@ -import asyncio -import datetime -from typing import Any, Union, Optional, List, \ - Tuple, Callable - -from aiohttp import ClientTimeout - -from glQiwiApi.types.qiwi_types.transaction import Transaction -from glQiwiApi.utils import basics as api_helper -from glQiwiApi.utils.exceptions import NoUpdatesToExecute - -DEFAULT_TIMEOUT = 20.0 # timeout in seconds - - -def _get_last_payment( - history: List[Transaction] -) -> Tuple[Optional[Transaction], Optional[int]]: - """ - Function, which gets last payment and his id of history - - :param history: - """ - last_payment = history[0] - last_payment_id = last_payment.transaction_id - return last_payment, last_payment_id - - -def _setup_callbacks( - dispatcher, - on_startup: Optional[Callable] = None, - on_shutdown: Optional[Callable] = None -): - """ - Function, which setup callbacks and set it to dispatcher object - - :param on_startup: - :param on_shutdown: - """ - if on_startup is not None: - dispatcher["on_startup"] = on_startup - if on_shutdown is not None: - dispatcher["on_shutdown"] = on_shutdown - - -def parse_timeout( - timeout: Union[float, int, ClientTimeout] -) -> Optional[float]: - """ - Parse timeout - - :param timeout: - """ - if isinstance(timeout, float): - return timeout - elif isinstance(timeout, int): - return float(timeout) - elif isinstance(timeout, ClientTimeout): - return timeout.total - else: - raise ValueError("Timeout must be float, int or ClientTimeout. You " - f"passed {type(timeout)}") - - -class HistoryPollingMixin: - """ Mixin, which provides polling """ - _requests: Any # type: ignore - - def __init__(self, dispatcher: Any) -> None: - self.dispatcher = dispatcher - - async def __parse_history_and_process_events( - self, - history: List[Transaction], - last_payment_id: int - ): - """ - Processing events and send callbacks to handlers - - :param history: [list] transactions list - :param last_payment_id: id of last payment in history - """ - history_iterator = iter(history[::-1]) - - while self.dispatcher.offset < last_payment_id: - try: - payment = next(history_iterator) - await self.dispatcher.process_event(payment) - except StopIteration: # handle exhausted iterator - break - - self.dispatcher.offset = payment.transaction_id - self.dispatcher.offset_start_date = self.dispatcher.offset_end_date - - async def __pre_process( - self, - get_updates_from: Optional[datetime.datetime] - ): - """ - Pre process method, which set start date and end date of polling - :param get_updates_from: date from which will be polling - """ - try: - current_time = datetime.datetime.now() - assert isinstance(get_updates_from, datetime.datetime) - assert ( - current_time - get_updates_from - ).total_seconds() > 0 - except AssertionError as ex: - raise ValueError( - "Invalid value of get_updates_from, it must " - "be instance of datetime and no more than the current time" - ) from ex - - self.dispatcher.offset_end_date = current_time - - if self.dispatcher.offset_start_date is None: - self.dispatcher.offset_start_date = get_updates_from - - async def _get_history(self) -> List[Transaction]: - """ - Get history by call 'transactions' method from QiwiWrapper. - If history is empty or not all transactions not isinstance - class Transaction - raise exception - - """ - history = await self.dispatcher.client.transactions( - end_date=self.dispatcher.offset_end_date, - start_date=self.dispatcher.offset_start_date - ) - - if not history or not all( - isinstance(txn, Transaction) for txn in history): - raise NoUpdatesToExecute() - - return history - - async def _pool_process( - self, - get_updates_from: Optional[datetime.datetime] - ): - """ - Method, which manage pool process - - :param get_updates_from: date from which will be polling - """ - await self.__pre_process(get_updates_from) - try: - history = await self._get_history() - except NoUpdatesToExecute: - return - - last_payment = history[0] - last_txn_id = last_payment.transaction_id - - if self.dispatcher.offset is None: - first_payment = history[-1] - self.dispatcher.offset = first_payment.transaction_id - 1 - - await self.__parse_history_and_process_events( - history=history, - last_payment_id=last_txn_id - ) - - async def __start_polling(self, **kwargs): - """ - Blocking method, which start polling process - - :param kwargs: - """ - self.dispatcher._polling = True - self.dispatcher.request_timeout = parse_timeout(kwargs.pop("timeout")) - while self.dispatcher._polling: - await self._pool_process(**kwargs) - await asyncio.sleep(self.dispatcher.request_timeout) - - def __on_shutdown(self, loop: asyncio.AbstractEventLoop): - """ - On shutdown we gracefully cancel all tasks, close event loop - and call __aexit__ method to close aiohttp session - """ - loop.run_until_complete(self.dispatcher.goodbye()) - api_helper.sync(self.__aexit__, None, None, None) - api_helper.safe_cancel(loop) - - async def __aexit__(self, exc_type, exc_val, exc_tb): - """Закрываем сессию и очищаем кэш при выходе""" - if self._requests.session: - await self._requests.session.close() - self._requests.clear_cache() - - def start_polling( - self, - get_updates_from: datetime.datetime = datetime.datetime.now(), - timeout: Union[float, int, ClientTimeout] = DEFAULT_TIMEOUT, - on_startup: Optional[Callable] = None, - on_shutdown: Optional[Callable] = None - ): - """ - Start long-polling mode - - :param get_updates_from: date from which will be polling, - if it's None, polling will skip all updates - :param timeout: timeout of polling in seconds, if the timeout is less, - the API can throw an exception - :param on_startup: function or coroutine, - which will be executed on startup - :param on_shutdown: function or coroutine, - which will be executed on shutdown - """ - self.dispatcher.logger.info("Start polling!") - loop = api_helper.take_event_loop() - _setup_callbacks(self.dispatcher, on_startup, on_shutdown) - try: - loop.run_until_complete(self.dispatcher.welcome()) - loop.create_task(self.__start_polling( - get_updates_from=get_updates_from, - timeout=timeout - )) - api_helper.run_forever_safe(loop=loop) - except (SystemExit, KeyboardInterrupt): - pass - finally: - self.__on_shutdown(loop) diff --git a/glQiwiApi/qiwi/mixins/webhook_mixin.py b/glQiwiApi/qiwi/mixins/webhook_mixin.py deleted file mode 100644 index c28f9441..00000000 --- a/glQiwiApi/qiwi/mixins/webhook_mixin.py +++ /dev/null @@ -1,292 +0,0 @@ -import asyncio -import copy -import logging -from typing import Optional, Any, Awaitable, Dict, Tuple, Callable, \ - MutableMapping - -from aiohttp import web - -from glQiwiApi.core.abstracts import AbstractRouter -from glQiwiApi.core.web_hooks import dispatcher, server -from glQiwiApi.core.web_hooks.config import Path -from glQiwiApi.types import WebHookConfig -from glQiwiApi.utils import basics as api_helper -from glQiwiApi.utils.exceptions import RequestError - - -class QiwiWebHookMixin: - """Mixin, which implements webhook logic""" - - def __init__( - self, - router: AbstractRouter, - requests_manager: Any, - secret_p2p: str - ): - self.dispatcher = dispatcher.Dispatcher( - loop=asyncio.get_event_loop(), - wallet=self - ) - self._router = router - self._requests = requests_manager - self.secret_p2p = secret_p2p - - def _auth_token( - self, - headers: MutableMapping, - p2p: bool = False - ) -> MutableMapping: - ... - - async def _register_webhook( - self, - web_url: Optional[str], - txn_type: int = 2 - ) -> Optional[WebHookConfig]: - """ - This method register a new webhook - - :param web_url: service url - :param txn_type: 0 => incoming, 1 => outgoing, 2 => all - :return: Active Hooks - """ - url = self._router.build_url("REG_WEBHOOK") - async for response in self._requests.fast().fetch( - url=url, - method='PUT', - headers=self._auth_token(copy.deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )), - params={ - 'hookType': 1, - 'param': web_url, - 'txnType': txn_type - } - ): - return WebHookConfig.parse_obj(response.response_data) - - async def get_current_webhook(self) -> Optional[WebHookConfig]: - """ - Список действующих (активных) обработчиков уведомлений, - связанных с вашим кошельком, можно получить данным запросом. - Так как сейчас используется только один тип хука - webhook, - то в ответе содержится только один объект данных - - """ - url = self._router.build_url("GET_CURRENT_WEBHOOK") - async for response in self._requests.fast().fetch( - url=url, - method='GET', - headers=self._auth_token(copy.deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - ): - try: - return WebHookConfig.parse_obj(response.response_data) - except RequestError: - return None - - async def _send_test_notification(self) -> Dict[str, str]: - """ - Для проверки вашего обработчика webhooks используйте данный запрос. - Тестовое уведомление отправляется на адрес, указанный при вызове - register_webhook - - """ - url = self._router.build_url("SEND_TEST_NOTIFICATION") - async for response in self._requests.fast().fetch( - url=url, - method='GET', - headers=self._auth_token(copy.deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - ): - return response.response_data - - async def get_webhook_secret_key(self, hook_id: str) -> str: - """ - Каждое уведомление содержит цифровую подпись сообщения, - зашифрованную ключом. - Для получения ключа проверки подписи используйте данный запрос. - - :param hook_id: UUID of webhook - :return: Base64-закодированный ключ - """ - url = self._router.build_url( - "GET_WEBHOOK_SECRET", - hook_id=hook_id - ) - async for response in self._requests.fast().fetch( - url=url, - method='GET', - headers=self._auth_token(copy.deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )) - ): - return response.response_data.get('key') - - async def delete_current_webhook(self) -> Optional[Dict[str, str]]: - """ - Method to delete webhook - - :return: Описание результата операции - """ - try: - hook = await self.get_current_webhook() - except RequestError as ex: - raise RequestError( - message=" You didn't register any webhook to delete ", - status_code='422', - json_info=ex.json() - ) from None - - url = self._router.build_url( - "DELETE_CURRENT_WEBHOOK", - hook_id=hook.hook_id - ) - async for response in self._requests.fast().fetch( - url=url, - headers=self._auth_token(copy.deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )), - method='DELETE' - ): - return response.response_data - - async def change_webhook_secret(self, hook_id: str) -> str: - """ - Для смены ключа шифрования уведомлений используйте данный запрос. - - :param hook_id: UUID of webhook - :return: Base64-закодированный ключ - """ - url = self._router.build_url( - "CHANGE_WEBHOOK_SECRET", - hook_id=hook_id - ) - async for response in self._requests.fast().fetch( - url=url, - headers=self._auth_token(copy.deepcopy( - self._router.config.DEFAULT_QIWI_HEADERS - )), - method='POST' - ): - return response.response_data.get('key') - - async def bind_webhook( - self, - url: Optional[str] = None, - transactions_type: int = 2, - *, - send_test_notification: bool = False, - delete_old: bool = False - ) -> Tuple[Optional[WebHookConfig], str]: - """ - [NON-API] EXCLUSIVE method to register new webhook or get old - - :param url: service url - :param transactions_type: 0 => incoming, 1 => outgoing, 2 => all - :param send_test_notification: test_qiwi will send you test webhook update - :param delete_old: boolean, if True - delete old webhook - - :return: Tuple of Hook and Base64-encoded key - """ - key: Optional[str] = None - - if delete_old: - await self.delete_current_webhook() - - try: - # Try to register new webhook - webhook = await self._register_webhook( - web_url=url, - txn_type=transactions_type - ) - except (RequestError, TypeError): - # Catching exception, if webhook already was registered - try: - webhook = await self.get_current_webhook() - except RequestError as ex: - raise RequestError( - message="You didn't pass on url to register new hook " - "and you didn't have registered webhooks", - status_code="422", - json_info=ex.json() - ) - key = await self.get_webhook_secret_key(webhook.hook_id) - return webhook, key - - if send_test_notification: - await self._send_test_notification() - - if not isinstance(key, str): - key = await self.get_webhook_secret_key(webhook.hook_id) - - return webhook, key - - def start_webhook( - self, - host: str = "localhost", - port: int = 8080, - path: Optional[Path] = None, - app: Optional["web.Application"] = None, - on_startup: Optional[ - Callable[ - [web.Application], Awaitable[None] - ]] = None, - on_shutdown: Optional[ - Callable[ - [web.Application], Awaitable[None] - ]] = None, - **logger_config: Any - ): - """ - Blocking function, which listening webhooks - - :param host: server host - :param port: server port that open for tcp/ip trans. - :param path: path for test_qiwi that will send requests - :param app: pass web.Application - :param on_startup: coroutine,which will be executed on startup - :param on_shutdown: coroutine, which will be executed on shutdown - """ - self._requests.without_context = True - - app = app if app is not None else web.Application() - - hook_config, key = api_helper.sync(self.bind_webhook) - - server.setup( - dispatcher=self.dispatcher, - app=app, - path=Path() if not path else path, - secret_key=self.secret_p2p, - base64_key=key, - on_startup=on_startup, - on_shutdown=on_shutdown, - instance=self - ) - - logging.basicConfig(**logger_config) - - web.run_app(app, host=host, port=port) - - @property - def transaction_handler(self): - """ - Handler manager for default test_qiwi transactions, - you can pass on lambda filter, if you want, - but it must to return a boolean - - """ - return self.dispatcher.transaction_handler_wrapper - - @property - def bill_handler(self): - """ - Handler manager for p2p bills, - you can pass on lambda filter, if you want - But it must to return a boolean - - """ - return self.dispatcher.bill_handler_wrapper diff --git a/glQiwiApi/qiwi/qiwi_maps.py b/glQiwiApi/qiwi/qiwi_maps.py index 5850250f..26f008db 100644 --- a/glQiwiApi/qiwi/qiwi_maps.py +++ b/glQiwiApi/qiwi/qiwi_maps.py @@ -1,12 +1,13 @@ import typing from glQiwiApi import types -from glQiwiApi.core import RequestManager, ToolsMixin +from glQiwiApi.core import RequestManager +from glQiwiApi.core.core_mixins import ContextInstanceMixin, ToolsMixin from glQiwiApi.types.basics import DEFAULT_CACHE_TIME from glQiwiApi.utils import basics as api_helper -class QiwiMaps(ToolsMixin): +class QiwiMaps(ToolsMixin, ContextInstanceMixin["QiwiMaps"]): """ API Карты терминалов QIWI позволяет установить местонахождение терминалов QIWI на территории РФ @@ -16,11 +17,13 @@ class QiwiMaps(ToolsMixin): def __init__( self, without_context: bool = False, - cache_time: int = DEFAULT_CACHE_TIME + cache_time: int = DEFAULT_CACHE_TIME, + proxy: typing.Optional[typing.Any] = None ) -> None: self._requests = RequestManager( without_context=without_context, - cache_time=cache_time + cache_time=cache_time, + proxy=proxy ) async def terminals( @@ -71,7 +74,8 @@ async def terminals( async for response in self._requests.fast().fetch( url=url, method='GET', - params=params + params=params, + get_json=True ): return api_helper.multiply_objects_parse( lst_of_objects=response.response_data, @@ -86,7 +90,8 @@ async def partners(self) -> typing.List[types.Partner]: async for response in self._requests.fast().fetch( url='http://edge.qiwi.com/locator/v3/ttp-groups', method='GET', - headers={"Content-type": "text/json"} + headers={"Content-type": "text/json"}, + get_json=True ): return api_helper.multiply_objects_parse( lst_of_objects=response.response_data, diff --git a/glQiwiApi/qiwi/settings.py b/glQiwiApi/qiwi/settings.py index 8f39f2fa..281cd91f 100644 --- a/glQiwiApi/qiwi/settings.py +++ b/glQiwiApi/qiwi/settings.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import functools import time from dataclasses import dataclass @@ -8,6 +10,34 @@ from glQiwiApi.types import WrapperData from glQiwiApi.utils.basics import check_api_method +__all__ = ('get_settings', "QiwiRouter", "QiwiKassaRouter") + + +@lru_cache() +def get_settings() -> QiwiSettings: + settings = QiwiSettings() + return settings + + +class QiwiRouter(AbstractRouter): + """Class, that delegates building the right paths, except p2p""" + __head__ = "https://edge.qiwi.com" + + @functools.lru_cache() + def build_url(self, api_method: str, **kwargs: Any) -> str: + check_api_method(api_method) + tail: str = getattr(self.config, api_method, None) + pre_build_url = self.__head__ + tail + return super()._format_url_kwargs(pre_build_url, **kwargs) + + def setup_config(self) -> Any: + return get_settings() + + +class QiwiKassaRouter(QiwiRouter): + """ QIWI P2P router""" + __head__ = "https://api.qiwi.com/partner/bill/v1/bills" + @dataclass class QiwiSettings: @@ -224,33 +254,4 @@ def __init__(self, *args, **kwargs): headers=DEFAULT_QIWI_HEADERS ) - QIWI_MASTER: Dict[str, Union[int, float, str]] = None - - -@lru_cache() -def get_settings() -> QiwiSettings: - settings = QiwiSettings() - return settings - - -class QiwiRouter(AbstractRouter): - """Class, which deals with all methods, except p2p""" - __head__ = "https://edge.qiwi.com" - - @functools.lru_cache() - def build_url(self, api_method: str, **kwargs: Any) -> str: - check_api_method(api_method) - tail: str = getattr(self.config, api_method, None) - pre_build_url = self.__head__ + tail - return super()._format_url_kwargs(pre_build_url, **kwargs) - - def setup_config(self) -> Any: - return get_settings() - - -class QiwiKassaRouter(QiwiRouter): - """ QIWI P2P router""" - __head__ = "https://api.qiwi.com/partner/bill/v1/bills" - - -__all__ = ('get_settings', "QiwiRouter", "QiwiKassaRouter") + QIWI_MASTER: Optional[Dict[str, Union[int, float, str]]] = None diff --git a/glQiwiApi/types/__init__.py b/glQiwiApi/types/__init__.py index 5cbb3629..8fd09bbd 100644 --- a/glQiwiApi/types/__init__.py +++ b/glQiwiApi/types/__init__.py @@ -65,8 +65,6 @@ FuncT = TypeVar('FuncT', bound=Callable[..., Any]) -MEMData = NewType('MEMData', MutableMapping[str, Dict[str, Any]]) - N = TypeVar('N') __all__ = ( @@ -105,7 +103,6 @@ 'Notification', 'Executors', 'FuncT', - 'MEMData', 'N', 'CrossRate', 'FreePaymentDetailsFields', diff --git a/glQiwiApi/types/base.py b/glQiwiApi/types/base.py new file mode 100644 index 00000000..19997008 --- /dev/null +++ b/glQiwiApi/types/base.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +if TYPE_CHECKING: + from glQiwiApi.qiwi.client import QiwiWrapper + + +class Base(BaseModel): + + @property + def client(self) -> QiwiWrapper: + """ Returning an instance of :class:`QiwiWrapper` """ + from glQiwiApi.qiwi.client import QiwiWrapper + + instance = QiwiWrapper.get_current() + + if instance is None: + raise RuntimeError("Can't get client instance from context. " + "You can fix it with setting current instance: " + "'QiwiWrapper.set_current(wrapper_instance)'") + return instance diff --git a/glQiwiApi/types/basics.py b/glQiwiApi/types/basics.py index eb4d8589..e757a83b 100644 --- a/glQiwiApi/types/basics.py +++ b/glQiwiApi/types/basics.py @@ -1,9 +1,7 @@ -import time from dataclasses import dataclass from typing import Union, Any, Optional -from pydantic import BaseModel, Field, validator, Extra -from glQiwiApi.utils.basics import custom_load +from pydantic import BaseModel, Field, validator DEFAULT_CACHE_TIME = 0 @@ -16,22 +14,15 @@ class Sum(BaseModel): amount: Union[int, float, str] currency: Any - class Config: - """ Pydantic config """ - json_loads = custom_load - extra = Extra.allow - - def __str__(self) -> str: - return '' - - def __repr__(self) -> str: - return self.__str__() - - @validator("currency", pre=True, check_fields=True) + @validator("currency", pre=True) def humanize_pay_currency(cls, v): from glQiwiApi.utils.currency_util import Currency + if not isinstance(v, int): - return v + try: + v = int(v) + except ValueError: + return v return Currency.get(str(v)) @@ -52,16 +43,6 @@ class Commission(BaseModel): qiwi_commission: Sum = Field("qwCommission") withdraw_to_enrollment_rate: int = Field(alias="withdrawToEnrollmentRate") - class Config: - """ Pydantic config """ - json_loads = custom_load - - def __str__(self) -> str: - return '' - - def __repr__(self) -> str: - return self.__str__() - class Type(BaseModel): """ @@ -99,10 +80,7 @@ class Cached: """ kwargs: Attributes response_data: Any - key: Optional[str] - cache_to: Optional[Any] method: Optional[Any] - cached_in: float = time.monotonic() status_code: Union[str, int, None] = None diff --git a/glQiwiApi/types/qiwi_types/__init__.py b/glQiwiApi/types/qiwi_types/__init__.py index 607be07b..6d01e4fd 100644 --- a/glQiwiApi/types/qiwi_types/__init__.py +++ b/glQiwiApi/types/qiwi_types/__init__.py @@ -9,11 +9,11 @@ from .payment_info import PaymentInfo from .polygon import Polygon from .qiwi_master import OrderDetails, Card +from .restriction import Restriction from .stats import Statistic from .terminal import Terminal from .transaction import Transaction from .webhooks import WebHookConfig, WebHook -from .restriction import Restriction __all__ = ( 'QiwiAccountInfo', diff --git a/glQiwiApi/types/qiwi_types/account.py b/glQiwiApi/types/qiwi_types/account.py index 19588dfd..e7303add 100644 --- a/glQiwiApi/types/qiwi_types/account.py +++ b/glQiwiApi/types/qiwi_types/account.py @@ -1,13 +1,14 @@ from typing import Optional -from pydantic import BaseModel, Field, validator +from pydantic import Field, validator +from glQiwiApi.types.base import Base from glQiwiApi.types.basics import Sum, Type from glQiwiApi.types.qiwi_types.currency_parsed import CurrencyModel from glQiwiApi.utils.currency_util import Currency -class Account(BaseModel): +class Account(Base): """ object: Account """ alias: str title: str diff --git a/glQiwiApi/types/qiwi_types/account_info.py b/glQiwiApi/types/qiwi_types/account_info.py index 1f9925ce..22bcf55a 100644 --- a/glQiwiApi/types/qiwi_types/account_info.py +++ b/glQiwiApi/types/qiwi_types/account_info.py @@ -2,14 +2,15 @@ from datetime import datetime from typing import Optional, List -from pydantic import Field, BaseModel, validator +from pydantic import Field, validator +from glQiwiApi.types.base import Base from glQiwiApi.types.basics import Sum from glQiwiApi.types.qiwi_types.currency_parsed import CurrencyModel from glQiwiApi.utils.currency_util import Currency -class PassInfo(BaseModel): +class PassInfo(Base): """ object: PassInfo """ last_pass_change: str = Field(alias="lastPassChange") @@ -17,7 +18,7 @@ class PassInfo(BaseModel): password_used: bool = Field(alias="passwordUsed") -class MobilePinInfo(BaseModel): +class MobilePinInfo(Base): """ object: MobilePinInfo """ last_mobile_pin_change: Optional[ @@ -27,12 +28,12 @@ class MobilePinInfo(BaseModel): next_mobile_pin_change: str = Field(alias="nextMobilePinChange") -class PinInfo(BaseModel): +class PinInfo(Base): """ object: PinInfo """ pin_used: bool = Field(alias="pinUsed") -class AuthInfo(BaseModel): +class AuthInfo(Base): """ object: AuthInfo """ ip: ipaddress.IPv4Address bound_email: Optional[str] = Field(alias="boundEmail", const=None) @@ -47,7 +48,7 @@ class AuthInfo(BaseModel): registration_date: datetime = Field(alias="registrationDate") -class SmsNotification(BaseModel): +class SmsNotification(Base): """ object: SmsNotification """ price: Sum enabled: bool @@ -55,14 +56,14 @@ class SmsNotification(BaseModel): end_date: Optional[datetime] = Field(alias="endDate", const=None) -class IdentificationInfo(BaseModel): +class IdentificationInfo(Base): """ object: IdentificationInfo """ bank_alias: str = Field(alias="bankAlias") identification_level: str = Field(alias="identificationLevel") passport_expired: bool = Field(alias="passportExpired") -class NickName(BaseModel): +class NickName(Base): """ object: NickName """ nickname: Optional[str] = None can_change: bool = Field(alias="canChange") @@ -70,7 +71,7 @@ class NickName(BaseModel): description: str = "" -class Feature(BaseModel): +class Feature(Base): """ object: Feature """ feature_id: int = Field(alias="featureId") feature_value: str = Field(alias="featureValue") @@ -78,7 +79,7 @@ class Feature(BaseModel): end_date: str = Field(alias="endDate") -class ContractInfo(BaseModel): +class ContractInfo(Base): """ object: ContractInfo """ blocked: bool = False contract_id: int = Field(alias="contractId") @@ -91,7 +92,7 @@ class ContractInfo(BaseModel): features: Optional[List[Feature]] = None -class UserInfo(BaseModel): +class UserInfo(Base): """ object: UserInfo """ default_pay_currency: CurrencyModel = Field(alias="defaultPayCurrency") default_pay_source: Optional[ @@ -115,7 +116,8 @@ def humanize_pay_currency(cls, v): return v return Currency.get(str(v)) -class QiwiAccountInfo(BaseModel): + +class QiwiAccountInfo(Base): """Информация об аккаунте""" auth_info: Optional[AuthInfo] = Field(alias="authInfo", const=None) contract_info: Optional[ diff --git a/glQiwiApi/types/qiwi_types/balance.py b/glQiwiApi/types/qiwi_types/balance.py index 2b1968f4..e897b282 100644 --- a/glQiwiApi/types/qiwi_types/balance.py +++ b/glQiwiApi/types/qiwi_types/balance.py @@ -1,10 +1,11 @@ -from pydantic import BaseModel, validator +from pydantic import validator +from glQiwiApi.types.base import Base from glQiwiApi.types.qiwi_types.currency_parsed import CurrencyModel from glQiwiApi.utils.currency_util import Currency -class Balance(BaseModel): +class Balance(Base): """ object: Balance """ alias: str currency: CurrencyModel diff --git a/glQiwiApi/types/qiwi_types/bill.py b/glQiwiApi/types/qiwi_types/bill.py index 18a60f22..845987e5 100644 --- a/glQiwiApi/types/qiwi_types/bill.py +++ b/glQiwiApi/types/qiwi_types/bill.py @@ -1,33 +1,33 @@ from datetime import datetime from typing import Optional, Union -from pydantic import BaseModel, Field, Extra +from pydantic import Field, Extra -from glQiwiApi.core.core_mixins import BillMixin +from glQiwiApi.types.base import Base from glQiwiApi.types.basics import OptionalSum -class Customer(BaseModel): +class Customer(Base): """ Object: Customer """ phone: Optional[str] = None email: Optional[str] = None account: Optional[str] = None -class BillStatus(BaseModel): +class BillStatus(Base): """ Object: BillStatus """ value: str changed_datetime: datetime = Field(alias="changedDateTime") -class CustomFields(BaseModel): +class CustomFields(Base): """ Object: CustomFields """ pay_sources_filter: Optional[str] = Field(alias="paySourcesFilter", default=None) theme_code: Optional[str] = Field(alias="themeCode", default=None) -class BillError(BaseModel): +class BillError(Base): """ Object: BillError """ service_name: str = Field(alias="serviceName") error_code: str = Field(alias="errorCode") @@ -37,7 +37,7 @@ class BillError(BaseModel): trace_id: str = Field(alias="traceId") -class Bill(BaseModel, BillMixin): +class Bill(Base): """ Object: Bill """ site_id: str = Field(alias="siteId") bill_id: str = Field(alias="billId") @@ -60,8 +60,18 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() + @property + async def paid(self) -> bool: + """ + Checking p2p payment -class RefundBill(BaseModel): + """ + return (await self.client.check_p2p_bill_status( + bill_id=self.bill_id + )) == 'PAID' + + +class RefundBill(Base): """ Модель счёта киви апи @@ -78,7 +88,7 @@ def get_value(self) -> Union[float, int]: return self.amount.value -class Notification(BaseModel): +class Notification(Base): """Object: Notification""" version: str = Field(..., alias="version") @@ -91,7 +101,7 @@ def __repr__(self) -> str: return self.__str__() -class P2PKeys(BaseModel): +class P2PKeys(Base): public_key: str = Field(..., alias="PublicKey") secret_key: str = Field(..., alias="SecretKey") diff --git a/glQiwiApi/types/qiwi_types/currency_parsed.py b/glQiwiApi/types/qiwi_types/currency_parsed.py index a6d20aca..47f16399 100644 --- a/glQiwiApi/types/qiwi_types/currency_parsed.py +++ b/glQiwiApi/types/qiwi_types/currency_parsed.py @@ -1,9 +1,9 @@ from typing import Union, Optional -from pydantic import BaseModel +from glQiwiApi.types.base import Base -class CurrencyModel(BaseModel): +class CurrencyModel(Base): code: str decimal_digits: int name: str diff --git a/glQiwiApi/types/qiwi_types/identification.py b/glQiwiApi/types/qiwi_types/identification.py index 8f434531..1218645b 100644 --- a/glQiwiApi/types/qiwi_types/identification.py +++ b/glQiwiApi/types/qiwi_types/identification.py @@ -1,10 +1,12 @@ from datetime import date from typing import Optional -from pydantic import BaseModel, Field +from pydantic import Field +from glQiwiApi.types.base import Base -class Identification(BaseModel): + +class Identification(Base): """ object: Identification """ identification_id: int = Field(..., alias="id") first_name: str = Field(..., alias="firstName") @@ -15,4 +17,4 @@ class Identification(BaseModel): inn: Optional[str] snils: Optional[str] oms: Optional[str] - type: str \ No newline at end of file + type: str diff --git a/glQiwiApi/types/qiwi_types/limit.py b/glQiwiApi/types/qiwi_types/limit.py index bb6c195d..f2643939 100644 --- a/glQiwiApi/types/qiwi_types/limit.py +++ b/glQiwiApi/types/qiwi_types/limit.py @@ -1,19 +1,20 @@ from datetime import datetime from typing import Union -from pydantic import BaseModel, Field, validator +from pydantic import Field, validator +from glQiwiApi.types.base import Base from glQiwiApi.types.qiwi_types.currency_parsed import CurrencyModel from glQiwiApi.utils.currency_util import Currency -class Interval(BaseModel): +class Interval(Base): """ object: Interval """ date_from: datetime = Field(alias="dateFrom") date_till: datetime = Field(alias="dateTill") -class Limit(BaseModel): +class Limit(Base): """ object: Limit """ currency: CurrencyModel rest: Union[float, int] diff --git a/glQiwiApi/types/qiwi_types/other.py b/glQiwiApi/types/qiwi_types/other.py index 714df206..2e155878 100644 --- a/glQiwiApi/types/qiwi_types/other.py +++ b/glQiwiApi/types/qiwi_types/other.py @@ -1,12 +1,13 @@ from typing import Union -from pydantic import BaseModel, Field, validator +from pydantic import Field, validator +from glQiwiApi.types.base import Base from glQiwiApi.types.qiwi_types.currency_parsed import CurrencyModel from glQiwiApi.utils.currency_util import Currency -class CrossRate(BaseModel): +class CrossRate(Base): """Курс валюты""" rate_from: Union[str, CurrencyModel] = Field(..., alias="from") rate_to: Union[str, CurrencyModel] = Field(..., alias="to") @@ -22,12 +23,12 @@ def humanize_rates(cls, v): return cur -class PaymentMethod(BaseModel): +class PaymentMethod(Base): payment_type: str account_id: str -class FreePaymentDetailsFields(BaseModel): +class FreePaymentDetailsFields(Base): """ Набор реквизитов платежа""" name: str """Наименование банка получателя""" diff --git a/glQiwiApi/types/qiwi_types/partner.py b/glQiwiApi/types/qiwi_types/partner.py index e6a3119b..61f15950 100644 --- a/glQiwiApi/types/qiwi_types/partner.py +++ b/glQiwiApi/types/qiwi_types/partner.py @@ -1,14 +1,15 @@ """Main model: Partner""" from typing import List, Optional -from pydantic import BaseModel +from glQiwiApi.types.base import Base -class Partner(BaseModel): +class Partner(Base): """ Base partner class """ title: str id: int maps: Optional[List[str]] = None + __all__ = ("Partner",) diff --git a/glQiwiApi/types/qiwi_types/payment_info.py b/glQiwiApi/types/qiwi_types/payment_info.py index 3781ea03..92577346 100644 --- a/glQiwiApi/types/qiwi_types/payment_info.py +++ b/glQiwiApi/types/qiwi_types/payment_info.py @@ -1,27 +1,28 @@ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import Field from glQiwiApi.types import Sum +from glQiwiApi.types.base import Base -class Fields(BaseModel): +class Fields(Base): """ Специальные поля """ account: str -class State(BaseModel): +class State(Base): """ State """ code: str -class TransactionInfo(BaseModel): +class TransactionInfo(Base): """ Информация о транзакции """ txn_id: int = Field(..., alias="id") state: State -class PaymentInfo(BaseModel): +class PaymentInfo(Base): """Информация о платеже""" payment_id: int = Field(..., alias="id") terms: str diff --git a/glQiwiApi/types/qiwi_types/qiwi_master.py b/glQiwiApi/types/qiwi_types/qiwi_master.py index 796d784d..bac9ef4d 100644 --- a/glQiwiApi/types/qiwi_types/qiwi_master.py +++ b/glQiwiApi/types/qiwi_types/qiwi_master.py @@ -1,12 +1,13 @@ import datetime from typing import Optional, List -from pydantic import BaseModel, Field +from pydantic import Field from glQiwiApi.types import Sum +from glQiwiApi.types.base import Base -class OrderDetails(BaseModel): +class OrderDetails(Base): """ object: OrderDetails """ order_id: str = Field(..., alias="id") card_alias: str = Field(..., alias="cardAlias") @@ -15,7 +16,7 @@ class OrderDetails(BaseModel): card_id: Optional[str] = Field(alias="cardId", default=None) -class CardCredentials(BaseModel): +class CardCredentials(Base): """object: CardCredentials""" qvx_id: int = Field(..., alias="id") masked_pan: str = Field(..., alias="maskedPan") @@ -34,13 +35,13 @@ class CardCredentials(BaseModel): card_expire_year: str = Field(..., alias="cardExpireYear") -class Requisite(BaseModel): +class Requisite(Base): """object: Requisite""" name: str value: str -class Details(BaseModel): +class Details(Base): """object: Details""" info: str description: str @@ -50,7 +51,7 @@ class Details(BaseModel): requisites: List[Requisite] -class CardInfo(BaseModel): +class CardInfo(Base): """object: CardInfo""" id_: int = Field(..., alias="id") name: str @@ -61,7 +62,7 @@ class CardInfo(BaseModel): details: Details -class Card(BaseModel): +class Card(Base): """ object: Card description: Данные выпущенных карт diff --git a/glQiwiApi/types/qiwi_types/restriction.py b/glQiwiApi/types/qiwi_types/restriction.py index ec332347..f413de8c 100644 --- a/glQiwiApi/types/qiwi_types/restriction.py +++ b/glQiwiApi/types/qiwi_types/restriction.py @@ -1,7 +1,9 @@ -from pydantic import BaseModel, Field +from pydantic import Field +from glQiwiApi.types.base import Base -class Restriction(BaseModel): + +class Restriction(Base): code: str = Field(..., alias="restrictionCode") description: str = Field(..., alias="restrictionDescription") diff --git a/glQiwiApi/types/qiwi_types/stats.py b/glQiwiApi/types/qiwi_types/stats.py index 62009cbc..bf4414cf 100644 --- a/glQiwiApi/types/qiwi_types/stats.py +++ b/glQiwiApi/types/qiwi_types/stats.py @@ -1,11 +1,12 @@ from typing import List -from pydantic import BaseModel, Field +from pydantic import Field +from glQiwiApi.types.base import Base from glQiwiApi.types.basics import Sum -class Statistic(BaseModel): +class Statistic(Base): """ object: Statistic """ incoming: List[Sum] = Field(alias="incomingTotal") out: List[Sum] = Field(alias="outgoingTotal") diff --git a/glQiwiApi/types/qiwi_types/terminal.py b/glQiwiApi/types/qiwi_types/terminal.py index 6549057d..77a99f03 100644 --- a/glQiwiApi/types/qiwi_types/terminal.py +++ b/glQiwiApi/types/qiwi_types/terminal.py @@ -1,10 +1,12 @@ """Main model: Terminal""" from typing import Optional -from pydantic import Field, BaseModel +from pydantic import Field +from glQiwiApi.types.base import Base -class Coordinate(BaseModel): + +class Coordinate(Base): """Object: coordinate""" latitude: float = Field(..., alias="latitude") @@ -12,7 +14,7 @@ class Coordinate(BaseModel): precision: int = Field(..., alias="precision") -class Terminal(BaseModel): +class Terminal(Base): """Object: Terminal""" terminal_id: int = Field(..., alias="terminalId") diff --git a/glQiwiApi/types/qiwi_types/transaction.py b/glQiwiApi/types/qiwi_types/transaction.py index 587d5361..50b9d2ac 100644 --- a/glQiwiApi/types/qiwi_types/transaction.py +++ b/glQiwiApi/types/qiwi_types/transaction.py @@ -1,12 +1,13 @@ from datetime import datetime from typing import Optional, Union -from pydantic import BaseModel, Field, Extra +from pydantic import Field +from glQiwiApi.types.base import Base from glQiwiApi.types.basics import Sum -class Provider(BaseModel): +class Provider(Base): """ object: Provider """ id: Optional[int] = None """ID провайдера в QIWI Wallet""" @@ -14,10 +15,10 @@ class Provider(BaseModel): short_name: Optional[str] = Field(default=None, alias="shortName") """краткое наименование провайдера""" - long_name: Optional[str] = Field(alias="longName", const=None) + long_name: Optional[str] = Field(alias="longName", default=None) """развернутое наименование провайдера""" - logo_url: Optional[str] = Field(alias="logoUrl", const=None) + logo_url: Optional[str] = Field(alias="logoUrl", default=None) """ссылка на логотип провайдера""" description: Optional[str] = None @@ -26,11 +27,11 @@ class Provider(BaseModel): keys: Optional[Union[str, list]] = None """список ключевых слов""" - site_url: Optional[str] = Field(alias="siteUrl", const=None) + site_url: Optional[str] = Field(alias="siteUrl", default=None) """сайт провайдера""" -class Transaction(BaseModel): +class Transaction(Base): """ object: Transaction """ transaction_id: int = Field(alias="txnId") """ ID транзакции в сервисе QIWI Кошелек""" @@ -45,7 +46,7 @@ class Transaction(BaseModel): Для запросов данных о транзакции - Дата/время платежа, время московское """ - error_code: Optional[int] = Field(alias="errorCode", const=None) + error_code: Optional[int] = Field(alias="errorCode", default=None) """Код ошибки платежа""" error: Optional[str] = None diff --git a/glQiwiApi/types/qiwi_types/webhooks.py b/glQiwiApi/types/qiwi_types/webhooks.py index c5c94686..3acace91 100644 --- a/glQiwiApi/types/qiwi_types/webhooks.py +++ b/glQiwiApi/types/qiwi_types/webhooks.py @@ -1,12 +1,13 @@ from datetime import datetime from typing import Optional -from pydantic import BaseModel, Field +from pydantic import Field from glQiwiApi.types import Sum +from glQiwiApi.types.base import Base -class Payment(BaseModel): +class Payment(Base): """ Scheme of webhook payment object """ account: str = Field(..., alias="account") comment: str = Field(..., alias="comment") @@ -23,7 +24,7 @@ class Payment(BaseModel): total: Sum = Field(..., alias="total") -class WebHook(BaseModel): +class WebHook(Base): """ Хуки или уведомления с данными о событии (платеже/пополнении) @@ -37,13 +38,13 @@ class WebHook(BaseModel): payment: Optional[Payment] = Field(default=None, alias="payment") -class HookParameters(BaseModel): +class HookParameters(Base): """hookParameters object""" url: str = Field(..., alias="url") -class WebHookConfig(BaseModel): +class WebHookConfig(Base): """WebHookConfig object""" hook_id: str = Field(..., alias="hookId") diff --git a/glQiwiApi/utils/basics.py b/glQiwiApi/utils/basics.py index 0d512ee5..7e5e9cd4 100644 --- a/glQiwiApi/utils/basics.py +++ b/glQiwiApi/utils/basics.py @@ -98,7 +98,7 @@ def check_dates(start_date, end_date, payload_data): } ) else: - raise Exception( + raise RuntimeError( 'end_date не может быть больше чем start_date' ) return payload_data @@ -341,10 +341,8 @@ def to_datetime(string_representation): :return: datetime representation """ try: - parsed = orjson.dumps( - {'dt': string_representation} - ) - return Parser.parse_obj(parsed).dt + dictionary = {'dt': string_representation} + return Parser.parse_obj(dictionary).dt except (ValidationError, orjson.JSONDecodeError) as ex: return ex.json(indent=4) @@ -407,7 +405,7 @@ def safe_cancel(loop) -> None: for task in loop_tasks_all: task.cancel() - # NOTE: `cancel` does not guarantee that the Task will be cancelled + # NOTE: `cancel` does not guarantee that the task will be cancelled for task in loop_tasks_all: if not (task.done() or task.cancelled()): @@ -461,7 +459,7 @@ def _on_shutdown(executor, loop): def sync(func, *args, **kwargs): """ - Function to use async functions(libraries) synchronously + Function to execute async functions synchronously :param func: Async function, which you want to execute in synchronous code :param args: args, which need your async func @@ -485,6 +483,16 @@ def sync(func, *args, **kwargs): _on_shutdown(executor, loop) +def async_as_sync(func): + """ Decorator, which use `sync` function to implement async to sync """ + + @ft.wraps(func) + def wrapper(*args, **kwargs): + return sync(func, *args, **kwargs) + + return wrapper + + def check_transaction( transactions, amount, @@ -548,7 +556,8 @@ async def wrapper(*args, **kwargs): def check_api_method(api_method): if not isinstance(api_method, str): - raise ValueError("Invalid type of api_method(must be string)") + raise RuntimeError(f"Invalid type of api_method(must be string)." + f" Passed {type(api_method)}") def check_dates_for_statistic_request(start_date, end_date): @@ -573,11 +582,19 @@ def check_dates_for_statistic_request(start_date, end_date): async def save_file(dir_path, file_name, data): + """ + Saving file in dir_path/file_name.pdf with some data + + :param dir_path: + :param file_name: + :param data: + """ if isinstance(dir_path, str): dir_path = pathlib.Path(dir_path) + if not dir_path.is_dir(): raise RuntimeError("Invalid path to save, its not directory!") - dir_path: pathlib.Path - path_to_file = dir_path / (file_name + '.pdf') + + path_to_file: pathlib.Path = dir_path / (file_name + '.pdf') async with aiofiles.open(path_to_file, 'wb') as file: return await file.write(data) diff --git a/glQiwiApi/utils/basics.pyi b/glQiwiApi/utils/basics.pyi index 28a52d8e..787cd335 100644 --- a/glQiwiApi/utils/basics.pyi +++ b/glQiwiApi/utils/basics.pyi @@ -58,7 +58,7 @@ def set_data_p2p_create( life_time: str, comment: Optional[str] = None, theme_code: Optional[str] = None, - pay_source_filter: Optional[str] = None + pay_source_filter: Optional[List[str]] = None ) -> Dict[MutableMapping, Any]: ... @@ -513,6 +513,9 @@ def sync( ) -> Any: ... +def async_as_sync(func: types.FuncT) -> types.FuncT: ... + + def check_transaction( transactions: List[types.Transaction], amount: Union[int, float], diff --git a/glQiwiApi/utils/currency_util.py b/glQiwiApi/utils/currency_util.py index 6fbed699..362c826a 100644 --- a/glQiwiApi/utils/currency_util.py +++ b/glQiwiApi/utils/currency_util.py @@ -8,7 +8,9 @@ class Currency: Class with many currencies >>> usd = Currency.get('840') >>> usd - ... cur.CurrencyModel(code='USD', decimal_digits=2, name='US Dollar', name_plural='US dollars', rounding=0, symbol='$', symbol_native='$') + ... cur.CurrencyModel(code='USD', decimal_digits=2, name='US Dollar', name_plural='US dollars', + ... rounding=0, symbol='$', symbol_native='$') + >>> usd.symbol ... '$' """ @@ -32,6 +34,6 @@ def get(cls, currency_code: Union[str, int]) -> Optional[ else: return cur.described.get(currency_code.upper()) except (KeyError, AssertionError): - raise ValueError( + raise RuntimeError( f"Currency code `{currency_code}` was not found" ) diff --git a/glQiwiApi/utils/exceptions.py b/glQiwiApi/utils/exceptions.py index 30e707b8..0fe7ddaf 100644 --- a/glQiwiApi/utils/exceptions.py +++ b/glQiwiApi/utils/exceptions.py @@ -36,14 +36,14 @@ class InvalidToken(Exception): class InvalidData(Exception): """ - Ошибка возникает, если были переданы или получены невалидные данные + Ошибка возникает если были переданы или получены невалидные данные """ class NoUpdatesToExecute(Exception): """ - Ошибка возбуждается, если при полинге нет транзакций, чтобы обрабатывать + Ошибка возбуждается если при полинге нет транзакций, чтобы обрабатывать """ diff --git a/glQiwiApi/utils/executor.py b/glQiwiApi/utils/executor.py new file mode 100644 index 00000000..eae0c854 --- /dev/null +++ b/glQiwiApi/utils/executor.py @@ -0,0 +1,387 @@ +""" +Managing polling and webhooks +""" +from __future__ import annotations + +import asyncio +import inspect +import logging +import types +from datetime import datetime, timedelta +from typing import Union, Optional, List, \ + Callable, Awaitable, Dict, TypeVar, TYPE_CHECKING, Coroutine, Any + +from aiohttp import ClientTimeout, web + +from glQiwiApi.core.builtin import BaseProxy, logger +from glQiwiApi.core.constants import DEFAULT_TIMEOUT +from glQiwiApi.core.web_hooks import server +from glQiwiApi.core.web_hooks.config import Path +from glQiwiApi.core.web_hooks.dispatcher import Dispatcher +from glQiwiApi.types import Transaction +from glQiwiApi.utils import basics as api_helper +from glQiwiApi.utils.exceptions import NoUpdatesToExecute + +__all__ = ['start_webhook', 'start_polling'] + +if TYPE_CHECKING: + from glQiwiApi.qiwi.client import QiwiWrapper + +T = TypeVar("T") + + +def start_webhook(client: QiwiWrapper, *, host: str = "localhost", + port: int = 8080, + path: Optional[Path] = None, + on_startup: Optional[ + Callable[ + [QiwiWrapper], Awaitable[None] + ]] = None, + on_shutdown: Optional[ + Callable[ + [QiwiWrapper], Awaitable[None] + ]] = None, + tg_app: Optional[BaseProxy] = None, + app: Optional["web.Application"] = None): + """ + Blocking function that listens for webhooks + + :param client: + :param host: server host + :param port: server port that open for tcp/ip trans. + :param path: path for qiwi that will send requests + :param app: pass web.Application + :param on_startup: coroutine,which will be executed on startup + :param on_shutdown: coroutine, which will be executed on shutdown + :param tg_app: builtin TelegramWebhookProxy or other + or class, that inherits from BaseProxy and deal with aiogram updates + :param logger_config: + """ + executor = Executor(client, tg_app=tg_app) + _setup_callbacks(executor, on_startup, on_shutdown) + executor.start_webhook(host=host, port=port, path=path, app=app) + + +def start_polling(client: QiwiWrapper, *, get_updates_from: datetime = datetime.now(), + timeout: Union[float, int, ClientTimeout] = 5, + on_startup: Optional[ + Callable[ + [QiwiWrapper], Any + ]] = None, + on_shutdown: Optional[ + Callable[ + [QiwiWrapper], Any + ]] = None, + tg_app: Optional[BaseProxy] = None, + **kwargs): + """ + Setup for long-polling mode + + :param client: + :param get_updates_from: date from which will be polling, + if it's None, polling will skip all updates + :param timeout: timeout of polling in seconds, if the timeout is too small, + the API can throw an exception + :param kwargs: keyword arguments that you can pass + into the aiogram `start_polling` method + :param on_startup: function or coroutine, + which will be executed on startup + :param on_shutdown: function or coroutine, + which will be executed on shutdown + :param tg_app: builtin TelegramPollingProxy or other + class, that inherits from BaseProxy, deal with aiogram updates + """ + executor = Executor(client, tg_app=tg_app) + _setup_callbacks(executor, on_startup, on_shutdown) + executor.start_polling( + get_updates_from=get_updates_from, + timeout=timeout, + **kwargs + ) + + +async def _inspect_and_execute_callback(client, callback: Callable): + if inspect.iscoroutinefunction(callback): + await callback(client) + else: + callback(client) + + +def _setup_callbacks( + executor: Executor, + on_startup: Optional[Callable] = None, + on_shutdown: Optional[Callable] = None +): + """ + Function, which setup callbacks and set it to dispatcher object + + :param executor: + :param on_startup: + :param on_shutdown: + """ + if on_startup is not None: + executor["on_startup"] = on_startup + if on_shutdown is not None: + executor["on_shutdown"] = on_shutdown + + +def parse_timeout( + timeout: Union[float, int, ClientTimeout] +) -> float: + """ + Parse timeout + + :param timeout: + """ + if isinstance(timeout, float): + return timeout + elif isinstance(timeout, int): + return float(timeout) + elif isinstance(timeout, ClientTimeout): + return timeout.total or DEFAULT_TIMEOUT.total + else: + raise ValueError("Timeout must be float, int or ClientTimeout. You have " + f"passed on {type(timeout)}") + + +class Executor: + """ + Provides normal work of webhooks and polling + + """ + + def __init__(self, client: QiwiWrapper, tg_app: Optional[BaseProxy]): + """ + + :param client: instance of BaseWrapper + :param tg_app: optional proxy to connect aiogram polling/webhook mode + """ + self.dispatcher: Dispatcher = client.dispatcher + self._loop: asyncio.AbstractEventLoop = client.dispatcher._loop + self._logger_config: Dict[str, Union[List[logging.Handler], int]] = { + "handlers": [logger.InterceptHandler()], + "level": logging.DEBUG + } + self.tg_app: Optional[BaseProxy] = tg_app + self._polling: bool = False + self.offset: Optional[int] = None + self.offset_start_date: Optional[datetime] = None + self.offset_end_date: Optional[datetime] = None + self.client: QiwiWrapper = client + self._on_startup_calls: List[Callable] = [] + self._on_shutdown_calls: List[Callable] = [] + + from glQiwiApi import QiwiWrapper + QiwiWrapper.set_current(client) + + def __setitem__(self, key: str, callback: Callable): + if key not in ["on_shutdown", "on_startup"]: + raise RuntimeError() + + if not isinstance(callback, types.FunctionType): + raise RuntimeError("Invalid type of callback") + + if key == "on_shutdown": + self._on_shutdown_calls.append(callback) + else: + self._on_startup_calls.append(callback) + + async def _pre_process(self, get_updates_from: Optional[datetime]): + """ + Preprocess method, which set start date and end date of polling + :param get_updates_from: date from which will be polling + """ + try: + current_time = datetime.now() + assert isinstance(get_updates_from, datetime) + assert ( + current_time - get_updates_from + ).total_seconds() > 0 + except AssertionError as ex: + raise ValueError( + "Invalid value of get_updates_from, it must " + "be instance of datetime and no more than the current time" + ) from ex + + self.offset_end_date = current_time + + if self.offset_start_date is None: + self.offset_start_date = get_updates_from + else: + self.offset_start_date = current_time - timedelta(milliseconds=1) + + async def _get_history(self) -> List[Transaction]: + """ + Get history by call 'transactions' method from QiwiWrapper. + If history is empty or not all transactions not isinstance + class Transaction - raise exception + + """ + history = await self.client.transactions( + end_date=self.offset_end_date, + start_date=self.offset_start_date + ) + + if not history or not all( + isinstance(txn, Transaction) for txn in history): + raise NoUpdatesToExecute() + + return history + + async def _pool_process( + self, + get_updates_from: Optional[datetime] + ): + """ + Method, which manage pool process + + :param get_updates_from: date from which will be polling + """ + await self._pre_process(get_updates_from) + try: + history: List[Transaction] = await self._get_history() + except NoUpdatesToExecute: + return + + last_payment: Transaction = history[0] + last_txn_id: int = last_payment.transaction_id + + if self.offset is None: + first_payment: Transaction = history[-1] + self.offset = first_payment.transaction_id - 1 + + await self._parse_history_and_process_events( + history=history, + last_payment_id=last_txn_id + ) + + async def _start_polling(self, **kwargs): + """ + Blocking method, which start polling process + + :param kwargs: + """ + self._polling = True + timeout: float = parse_timeout(kwargs.pop("timeout")) + while self._polling: + try: + await self._pool_process(**kwargs) + except Exception as ex: + self.dispatcher.logger.error("Handle `%s`. Set a smaller timeout or open issue. " + "Sleeping %s seconds", repr(ex), timeout + 100) + timeout += 100 + await asyncio.sleep(timeout) + + def _on_shutdown(self): + """ + On shutdown, we gracefully cancel all tasks, close event loop + and call `close` method to clear resources + """ + coroutines: List[Coroutine] = [self.goodbye(), self.client.close()] + if isinstance(self.tg_app, BaseProxy): + coroutines.append(self._shutdown_tg_app()) + self._loop.run_until_complete( + asyncio.gather(*coroutines, loop=self._loop) + ) + + async def _shutdown_tg_app(self): + """ + Gracefully shutdown tg application + + """ + self.tg_app.dispatcher.stop_polling() + await self.tg_app.dispatcher.storage.close() + await self.tg_app.dispatcher.storage.wait_closed() + await self.tg_app.dispatcher.bot.session.close() + + async def _parse_history_and_process_events( + self, + history: List[Transaction], + last_payment_id: int + ): + """ + Processing events and send callbacks to handlers + + :param history: [list] list of :class:`Transaction` + :param last_payment_id: id of last payment in history + """ + history_iterator = iter(history[::-1]) + + while self.offset < last_payment_id: + try: + payment = next(history_iterator) + await self.dispatcher.process_event(payment) + self.offset = payment.transaction_id + self.offset_start_date = self.offset_end_date + except StopIteration: # handle exhausted iterator + break + + def start_polling( + self, *, + get_updates_from: datetime = datetime.now(), + timeout: Union[float, int, ClientTimeout] = DEFAULT_TIMEOUT, + **kwargs + ): + try: + self._loop.run_until_complete(self.welcome()) + self._loop.create_task(self._start_polling( + get_updates_from=get_updates_from, + timeout=timeout + )) + if isinstance(self.tg_app, BaseProxy): + self.tg_app.setup(**kwargs, loop=self._loop) + api_helper.run_forever_safe(loop=self._loop) + except (SystemExit, KeyboardInterrupt): # pragma: no cover + # Allow to graceful shutdown + pass + finally: + self._polling = False + self._on_shutdown() + + def start_webhook( + self, *, + host: str = "localhost", + port: int = 8080, + path: Optional[Path] = None, + app: Optional[web.Application] = None + ): + application = app or web.Application() + + hook_config, key = self._loop.run_until_complete(self.client.bind_webhook()) + + server.setup( + dispatcher=self.dispatcher, + app=application, + path=path, + secret_key=self.client.secret_p2p, + base64_key=key, + tg_app=self.tg_app, + host=host + ) + + try: + self._loop.run_until_complete(self.welcome()) + web.run_app(application, host=host, port=port) + except (KeyboardInterrupt, SystemExit): + # Allow to graceful shutdown + pass + finally: + self._on_shutdown() + + async def welcome(self) -> None: + """ Execute on_startup callback""" + self.dispatcher.logger.debug("Start polling!") + for callback in self._on_startup_calls: + await _inspect_and_execute_callback( + callback=callback, + client=self.client + ) + + async def goodbye(self) -> None: + """ Execute on_shutdown callback """ + self.dispatcher.logger.debug("Goodbye!") + for callback in self._on_shutdown_calls: + await _inspect_and_execute_callback( + callback=callback, + client=self.client + ) diff --git a/glQiwiApi/yoo_money/__init__.py b/glQiwiApi/yoo_money/__init__.py index 33b31d36..3a9baab5 100644 --- a/glQiwiApi/yoo_money/__init__.py +++ b/glQiwiApi/yoo_money/__init__.py @@ -1,3 +1,3 @@ -from .API import YooMoneyAPI +from .client import YooMoneyAPI __all__ = ('YooMoneyAPI',) diff --git a/glQiwiApi/yoo_money/API.py b/glQiwiApi/yoo_money/client.py similarity index 96% rename from glQiwiApi/yoo_money/API.py rename to glQiwiApi/yoo_money/client.py index 6493c19e..50d93ea7 100644 --- a/glQiwiApi/yoo_money/API.py +++ b/glQiwiApi/yoo_money/client.py @@ -1,3 +1,7 @@ +""" +Provides effortless work with YooMoney API using asynchronous requests. + +""" from datetime import datetime from typing import List, Dict, Any, Union, Optional, Tuple @@ -6,7 +10,8 @@ import glQiwiApi.utils.basics as api_helper from glQiwiApi.core import ( RequestManager, - ToolsMixin + ToolsMixin, + ContextInstanceMixin ) from glQiwiApi.types import ( AccountInfo, @@ -22,7 +27,7 @@ from glQiwiApi.yoo_money.settings import YooMoneyRouter -class YooMoneyAPI(ToolsMixin): +class YooMoneyAPI(ToolsMixin, ContextInstanceMixin["YooMoneyAPI"]): """ Класс, реализующий обработку запросов к YooMoney Удобен он тем, что не просто отдает json подобные объекты, @@ -38,7 +43,8 @@ def __init__( self, api_access_token: str, without_context: bool = False, - cache_time: Union[float, int] = DEFAULT_CACHE_TIME + cache_time: Union[float, int] = DEFAULT_CACHE_TIME, + proxy: Optional[Any] = None ) -> None: """ Конструктор принимает токен, полученный из класс метода @@ -59,7 +65,8 @@ def __init__( self._requests = RequestManager( without_context=without_context, messages=self._router.config.ERROR_CODE_NUMBERS, - cache_time=cache_time + cache_time=cache_time, + proxy=proxy ) def _auth_token(self, headers: dict) -> Dict[Any, Any]: @@ -119,7 +126,8 @@ async def get_access_token( cls, code: str, client_id: str, - redirect_uri: str = 'https://example.com' + redirect_uri: str = 'https://example.com', + client_secret: Optional[str] = None ) -> str: """ Метод для получения токена для запросов к YooMoney API @@ -128,6 +136,8 @@ async def get_access_token( :param client_id: идентификатор приложения, тип string :param redirect_uri: воронка, куда попадет временный код, который нужен для получения основного токена + :param client_secret: Секретное слово для проверки подлинности приложения. + Указывается, если сервис зарегистрирован с проверкой подлинности. :return: YooMoney API TOKEN """ router = YooMoneyRouter() @@ -136,12 +146,12 @@ async def get_access_token( 'code': code, 'client_id': client_id, 'grant_type': 'authorization_code', - 'redirect_uri': redirect_uri + 'redirect_uri': redirect_uri, + 'client_secret': client_secret } async for response in RequestManager( without_context=True, - messages=router.config.ERROR_CODE_NUMBERS, - cache_time=DEFAULT_CACHE_TIME + messages=router.config.ERROR_CODE_NUMBERS ).fast().fetch( url=router.build_url("GET_ACCESS_TOKEN"), headers=headers, @@ -185,7 +195,8 @@ async def account_info(self) -> AccountInfo: async for response in self._requests.fast().fetch( url=self._router.build_url("ACCOUNT_INFO"), headers=headers, - method='POST' + method='POST', + get_json=True ): return AccountInfo.parse_obj(response.response_data) @@ -314,7 +325,8 @@ async def transaction_info(self, operation_id: str) -> OperationDetails: async for response in self._requests.fast().fetch( url=self._router.build_url("TRANSACTION_INFO"), headers=headers, - data=payload + data=payload, + get_json=True ): return OperationDetails.parse_obj(response.response_data) @@ -371,7 +383,8 @@ async def _pre_process_payment( async for response in self._requests.fast().fetch( url=self._router.build_url("PRE_PROCESS_PAYMENT"), headers=headers, - data=payload + data=payload, + get_json=True ): try: return PreProcessPaymentResponse.parse_obj( @@ -380,7 +393,7 @@ async def _pre_process_payment( except ValidationError: msg = "Недостаточно денег для перевода или ошибка сервиса" self._requests.raise_exception( - status_code=400, + status_code="400", message=msg ) @@ -480,7 +493,8 @@ async def send( url=url, method='POST', headers=headers, - data=payload + data=payload, + get_json=True ): return Payment.parse_obj( response.response_data diff --git a/glQiwiApi/yoo_money/settings.py b/glQiwiApi/yoo_money/settings.py index 4d4084f1..1d1cca8a 100644 --- a/glQiwiApi/yoo_money/settings.py +++ b/glQiwiApi/yoo_money/settings.py @@ -1,11 +1,33 @@ +from __future__ import annotations + import functools from dataclasses import dataclass -from typing import Any, Optional, Dict +from typing import Optional, Dict, Any from glQiwiApi.core.abstracts import AbstractRouter from glQiwiApi.utils.basics import check_api_method +class YooMoneyRouter(AbstractRouter): + __head__ = 'https://yoomoney.ru' + + def setup_config(self) -> Any: + return get_settings() + + @functools.lru_cache() + def build_url(self, api_method: str, **kwargs: Any) -> str: + check_api_method(api_method) + tail_path: Optional[str] = getattr(self.config, api_method, None) + pre_build_url = self.__head__ + tail_path + return super()._format_url_kwargs(pre_build_url, **kwargs) + + +@functools.lru_cache() +def get_settings() -> YooMoneySettings: + settings = YooMoneySettings() + return settings + + @dataclass class YooMoneySettings: # Operations with token @@ -45,24 +67,4 @@ def __init__(self): content_and_auth: Optional[Dict[str, bool]] = None -class YooMoneyRouter(AbstractRouter): - __head__ = 'https://yoomoney.ru' - - def setup_config(self) -> Any: - return get_settings() - - @functools.lru_cache() - def build_url(self, api_method: str, **kwargs: Any) -> str: - check_api_method(api_method) - tail_path: Optional[str] = getattr(self.config, api_method, None) - pre_build_url = self.__head__ + tail_path - return super()._format_url_kwargs(pre_build_url, **kwargs) - - -@functools.lru_cache() -def get_settings() -> YooMoneySettings: - settings = YooMoneySettings() - return settings - - __all__ = ('YooMoneyRouter',) diff --git a/setup.py b/setup.py index 059b276c..4cca59c1 100644 --- a/setup.py +++ b/setup.py @@ -6,14 +6,14 @@ README = (PATH / "README.md").read_text() -REQUIREMENTS = (PATH / "docs/requirements.txt").read_text() +REQUIREMENTS = (PATH / "SETUP_REQUIREMENTS.txt").read_text() setuptools.setup( packages=setuptools.find_packages(exclude=( 'tests', 'examples', 'examples.*', 'tests', 'tests.*') ), name="glQiwiApi", # Replace with your own username - version="0.2.21", + version="1.0.0", author="GLEF1X", author_email="glebgar567@gmail.com", description="Light and fast wrapper of QIWI and YooMoney api's", diff --git a/tests/test_api/test_init.py b/tests/test_api/test_init.py index 32ea7da6..afb20d3f 100644 --- a/tests/test_api/test_init.py +++ b/tests/test_api/test_init.py @@ -23,13 +23,13 @@ async def test_create_api(self): # initially session is None, # it's done to save performance and # it creates new session when you make a request (API method call) - assert api.session is None + assert api.request_manager._session is None # such a session is created under the hood(when you call API method) - api._requests.create_session() + await api._requests.create_session() # And now it's aiohttp.ClientSession instance - assert isinstance(api.session, aiohttp.ClientSession) + assert isinstance(api.request_manager._session, aiohttp.ClientSession) assert isinstance(api._router, QiwiRouter) assert isinstance(api._requests, RequestManager) @@ -41,16 +41,23 @@ async def test_create_api(self): async def test_create_api_with_wrong_data(self): from tests.types.dataset import WRONG_API_DATA + with pytest.raises(InvalidData): QiwiWrapper(**WRONG_API_DATA) + async def test_raise_runtime(self): + from tests.types.dataset import EMPTY_DATA + + with pytest.raises(RuntimeError): + QiwiWrapper(**EMPTY_DATA) + async def test_close_session(self): from tests.types.dataset import API_DATA api = QiwiWrapper(**API_DATA) - api._requests.create_session() + await api._requests.create_session() - aiohttp_session = api.session + aiohttp_session = api.request_manager._session with patch("aiohttp.ClientSession.close", new=CoroutineMock()) as mocked_close: @@ -58,3 +65,18 @@ async def test_close_session(self): mocked_close.assert_called_once() await api.close() + + +class TestContextMixin: + + def test_get_from_context(self): + from tests.types.dataset import API_DATA + QiwiWrapper.set_current(QiwiWrapper(**API_DATA)) + instance = QiwiWrapper.get_current() + assert isinstance(instance, QiwiWrapper) + + def test_implicit_get_from_context(self): + from tests.types.dataset import API_DATA + QiwiWrapper(**API_DATA) + assert isinstance(QiwiWrapper.get_current(), QiwiWrapper) + diff --git a/tests/test_dispatcher/test_filters/test_builtin.py b/tests/test_dispatcher/test_filters/test_builtin.py index 21ee02b4..74b2cd30 100644 --- a/tests/test_dispatcher/test_filters/test_builtin.py +++ b/tests/test_dispatcher/test_filters/test_builtin.py @@ -1,5 +1,5 @@ import pytest -from glQiwiApi.core.web_hooks.filter import ( +from glQiwiApi.core.builtin import ( transaction_webhook_filter, bill_webhook_filter ) diff --git a/tests/test_dispatcher/test_polling.py b/tests/test_dispatcher/test_polling.py index df563081..dc39ade5 100644 --- a/tests/test_dispatcher/test_polling.py +++ b/tests/test_dispatcher/test_polling.py @@ -1,22 +1,29 @@ -from typing import Dict +import asyncio +from datetime import datetime import pytest import timeout_decorator +from _pytest.fixtures import SubRequest from glQiwiApi import QiwiWrapper from glQiwiApi import types +from glQiwiApi.types import Transaction, Sum +from glQiwiApi.types.qiwi_types.transaction import Provider +pytestmark = pytest.mark.asyncio -@pytest.fixture(name='credentials') -def credentials_fixture(): - """ credentials fixture """ - from ..types.dataset import API_DATA - yield API_DATA +txn = Transaction(txnId=50, personId=3254235, date=datetime.now(), + status="OUT", statusText="hello", + trmTxnId="world", account="+38908234234", + sum=Sum(amount=999, currency=643), + total=Sum(amount=999, currency=643), + provider=Provider(), + commission=Sum(amount=999, currency=643), + currencyRate=643, type="OUT") -@pytest.mark.asyncio @pytest.fixture(name='api') -async def api_fixture(credentials: Dict[str, str]): +async def api_fixture(credentials: dict, request: SubRequest, capsys): """ Api fixture """ _wrapper = QiwiWrapper(**credentials) yield _wrapper @@ -24,14 +31,16 @@ async def api_fixture(credentials: Dict[str, str]): async def _on_startup_callback(api: QiwiWrapper): - from ..types.dataset import TO_WALLET_DATA - await api.to_wallet(**TO_WALLET_DATA) + await asyncio.sleep(1) + await api.dispatcher.process_event(txn) class TestPolling: @timeout_decorator.timeout(5) def _start_polling(self, api: QiwiWrapper): + from glQiwiApi.utils import executor + self._handled = False # Also, without decorators, you can do like this @@ -41,10 +50,7 @@ async def my_handler(event: types.Transaction): self._handled = True assert isinstance(event, types.Transaction) - api.start_polling( - timeout=10, - on_startup=_on_startup_callback - ) + executor.start_polling(api, on_startup=_on_startup_callback) def test_polling(self, api: QiwiWrapper): try: diff --git a/tests/test_qiwi/test_cache.py b/tests/test_qiwi/test_cache.py index d0d9d314..bf5b826c 100644 --- a/tests/test_qiwi/test_cache.py +++ b/tests/test_qiwi/test_cache.py @@ -4,7 +4,7 @@ import pytest -from glQiwiApi import QiwiWrapper, InvalidData +from glQiwiApi import QiwiWrapper pytestmark = pytest.mark.asyncio @@ -23,15 +23,6 @@ async def api_fixture( class TestCache: - @pytest.mark.parametrize("cache_time", [-5, 70, 65]) - async def test_wrong_initialize_api( - self, - credentials: Dict[str, str], - cache_time: int - ): - with pytest.raises(InvalidData): - QiwiWrapper(**credentials, cache_time=cache_time) - @pytest.mark.parametrize("payload", [ {"rows_num": 50}, {"rows_num": 50, diff --git a/tests/test_qiwi/test_maps.py b/tests/test_qiwi/test_maps.py index e8c45f59..9662e762 100644 --- a/tests/test_qiwi/test_maps.py +++ b/tests/test_qiwi/test_maps.py @@ -16,6 +16,7 @@ def maps_data(): @pytest.fixture(name="maps") async def maps_fixture(): + """ :class:`QiwiMaps` fixture """ _maps = QiwiMaps() yield _maps await _maps.close() diff --git a/tests/test_qiwi/test_sync_adapter.py b/tests/test_qiwi/test_sync_adapter.py index bb0684c3..fc4ee2d8 100644 --- a/tests/test_qiwi/test_sync_adapter.py +++ b/tests/test_qiwi/test_sync_adapter.py @@ -47,7 +47,7 @@ def test_is_session_closing(self, api: QiwiWrapper): # Send request to API sync(api.get_balance) - api_session = api.session + api_session = api.request_manager._session assert isinstance(api_session, aiohttp.ClientSession) @@ -55,6 +55,6 @@ def test_is_session_closing(self, api: QiwiWrapper): sync(api.get_balance) - new_session = api.session + new_session = api.request_manager._session assert api_session != new_session diff --git a/tests/test_qiwi/test_wrapper.py b/tests/test_qiwi/test_wrapper.py index fa0e48d8..e34ba464 100644 --- a/tests/test_qiwi/test_wrapper.py +++ b/tests/test_qiwi/test_wrapper.py @@ -1,17 +1,21 @@ import datetime import pathlib import uuid -from typing import Dict, Union +from typing import Union import pytest -from glQiwiApi import types, InvalidData +from _pytest.capture import CaptureFixture +from _pytest.monkeypatch import MonkeyPatch + from glQiwiApi import QiwiWrapper +from glQiwiApi import types, InvalidData pytestmark = pytest.mark.asyncio @pytest.fixture(name='api') -async def api_fixture(credentials: Dict[str, str]): +async def api_fixture(credentials: dict, capsys: CaptureFixture, + monkeypatch: MonkeyPatch): """ Api fixture """ _wrapper = QiwiWrapper(**credentials) yield _wrapper @@ -67,6 +71,7 @@ async def test_identification(api: QiwiWrapper): assert isinstance(result, types.Identification) +@pytest.mark.skip() @pytest.mark.parametrize("payload", [ { "transaction_type": "OUT", @@ -221,7 +226,8 @@ async def test_check_p2p_bill_status(api: QiwiWrapper): async def test_check_p2p_on_object(api: QiwiWrapper): async with api: bill = await api.create_p2p_bill(amount=1) - result = await bill.check() + assert isinstance(bill, types.Bill) + result = await bill.paid assert isinstance(result, bool) @@ -229,7 +235,7 @@ async def test_check_p2p_on_object(api: QiwiWrapper): @pytest.mark.parametrize("rows", [5, 10, 50]) async def test_get_bills(api: QiwiWrapper, rows: int): async with api: - result = await api.get_bills(rows=rows) + result = await api.get_bills(rows_num=rows) assert isinstance(result, list) assert all(isinstance(b, types.Bill) for b in result) diff --git a/tests/test_utils/test_currency_parser.py b/tests/test_utils/test_currency_parser.py index 2c04dac0..57a4e4c7 100644 --- a/tests/test_utils/test_currency_parser.py +++ b/tests/test_utils/test_currency_parser.py @@ -1,12 +1,18 @@ import pytest + from glQiwiApi.utils.currency_util import Currency, cur pytestmark = pytest.mark.asyncio -_ = Currency() + +@pytest.fixture(name="_") +def currency_fixture(): + """ :class:`Currency` fixture """ + _ = Currency() + yield _ -async def test_currency_parser(): +async def test_currency_parser(_: Currency): from glQiwiApi.types.qiwi_types.currency_parsed import CurrencyModel condition = all( isinstance(_.get(key), CurrencyModel) for key in cur.described.keys() diff --git a/tests/test_utils/test_default_utils.py b/tests/test_utils/test_default_utils.py new file mode 100644 index 00000000..9b95fb4d --- /dev/null +++ b/tests/test_utils/test_default_utils.py @@ -0,0 +1,24 @@ +import asyncio +from datetime import datetime + +from glQiwiApi.utils.basics import async_as_sync, to_datetime + + +def test_async_as_sync(): + result = 0 + + @async_as_sync + async def my_async_func(): + nonlocal result + await asyncio.sleep(0.5) + result += 1 + + my_async_func() + assert result == 1 + + +def test_to_datetime_util(): + datetime_as_string: str = "2021-06-02 15:07:55" + + assert isinstance(to_datetime(datetime_as_string), datetime) + diff --git a/tests/test_yoomoney/test_wrapper.py b/tests/test_yoomoney/test_wrapper.py index c8f1f1a4..c6e14b36 100644 --- a/tests/test_yoomoney/test_wrapper.py +++ b/tests/test_yoomoney/test_wrapper.py @@ -1,4 +1,3 @@ -import asyncio import datetime from typing import Dict diff --git a/tests/types/dataset.py b/tests/types/dataset.py index 8363e519..418bb520 100644 --- a/tests/types/dataset.py +++ b/tests/types/dataset.py @@ -2,7 +2,7 @@ "api_access_token": "", "secret_p2p": "", - "phone_number": "" + "phone_number": "+" } YOO_MONEY_DATA = { @@ -11,10 +11,13 @@ WRONG_API_DATA = { "api_access_token": dict(), - "secret_p2p": 5454, - "phone_number": 1234 + "secret_p2p": "5454", + "phone_number": "12121", + "validate_params": True } +EMPTY_DATA: dict = {} + TO_WALLET_DATA = { "to_number": "+380985272064", "trans_sum": 1,