├── .editorconfig ├── .github ├── dependabot.yaml └── workflows │ ├── ci.yml │ ├── codeql.yml │ ├── locks.yml │ └── publish_pypi.yml ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── LICENSE ├── README.md ├── benchmarks └── p2p │ ├── p2p.svg │ └── test_performance.py ├── codecov.yaml ├── docs ├── API │ ├── index.rst │ ├── qiwi_api.rst │ ├── qiwi_maps.rst │ └── yoomoney_api.rst ├── Makefile ├── _static │ └── logo.png ├── advanced_features │ ├── cache.rst │ ├── index.rst │ ├── known-issues.rst │ └── proxy.rst ├── code │ ├── __init__.py │ ├── polling │ │ ├── __init__.py │ │ ├── events.py │ │ ├── qiwi.py │ │ ├── with_aiogram.py │ │ ├── with_aiogram_non_blocking.py │ │ └── without_globals.py │ └── webhooks │ │ ├── __init__.py │ │ └── qiwi.py ├── conf.py ├── getting-started │ ├── index.rst │ ├── qiwi │ │ ├── examples.rst │ │ ├── index.rst │ │ └── usage_with_aiogram.rst │ └── yoomoney │ │ ├── examples.rst │ │ └── index.rst ├── index.rst ├── installation.rst ├── make.bat ├── polling.rst ├── requirements.txt ├── static │ └── demo.gif ├── types │ ├── arbitrary │ │ └── index.rst │ ├── index.rst │ ├── qiwi_types │ │ └── index.rst │ └── yoomoney_types │ │ └── index.rst └── webhooks.rst ├── glQiwiApi ├── __init__.py ├── core │ ├── __init__.py │ ├── abc │ │ ├── __init__.py │ │ ├── api_method.py │ │ └── base_api_client.py │ ├── cache │ │ ├── __init__.py │ │ ├── cached_types.py │ │ ├── constants.py │ │ ├── exceptions.py │ │ ├── invalidation.py │ │ ├── storage.py │ │ └── utils.py │ ├── event_fetching │ │ ├── __init__.py │ │ ├── class_based │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── bill.py │ │ │ ├── error.py │ │ │ ├── transaction.py │ │ │ └── webhook_transaction.py │ │ ├── dispatcher.py │ │ ├── executor.py │ │ ├── filters.py │ │ └── webhooks │ │ │ ├── __init__.py │ │ │ ├── app.py │ │ │ ├── config.py │ │ │ ├── dto │ │ │ ├── __init__.py │ │ │ └── errors.py │ │ │ ├── middlewares │ │ │ ├── __init__.py │ │ │ └── ip.py │ │ │ ├── services │ │ │ ├── __init__.py │ │ │ ├── collision_detector.py │ │ │ └── security │ │ │ │ ├── __init__.py │ │ │ │ └── ip.py │ │ │ ├── utils.py │ │ │ └── views │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── bill_view.py │ │ │ └── transaction_view.py │ ├── request_service.py │ └── session │ │ ├── __init__.py │ │ └── holder.py ├── ext │ ├── __init__.py │ └── webhook_url.py ├── py.typed ├── qiwi │ ├── __init__.py │ ├── base.py │ ├── clients │ │ ├── __init__.py │ │ ├── maps │ │ │ ├── __init__.py │ │ │ ├── client.py │ │ │ ├── methods │ │ │ │ ├── __init__.py │ │ │ │ ├── get_partners.py │ │ │ │ └── get_terminals.py │ │ │ └── types │ │ │ │ ├── __init__.py │ │ │ │ ├── polygon.py │ │ │ │ └── terminal.py │ │ ├── p2p │ │ │ ├── __init__.py │ │ │ ├── client.py │ │ │ ├── methods │ │ │ │ ├── __init__.py │ │ │ │ ├── create_p2p_bill.py │ │ │ │ ├── create_p2p_key_pair.py │ │ │ │ ├── get_bill_by_id.py │ │ │ │ ├── refund_bill.py │ │ │ │ └── reject_p2p_bill.py │ │ │ └── types.py │ │ ├── wallet │ │ │ ├── __init__.py │ │ │ ├── client.py │ │ │ ├── methods │ │ │ │ ├── __init__.py │ │ │ │ ├── authenticate_wallet.py │ │ │ │ ├── check_restriction.py │ │ │ │ ├── create_new_balance.py │ │ │ │ ├── detect_mobile_number.py │ │ │ │ ├── fetch_statistics.py │ │ │ │ ├── get_account_info.py │ │ │ │ ├── get_available_balances.py │ │ │ │ ├── get_balances.py │ │ │ │ ├── get_cards.py │ │ │ │ ├── get_cross_rates.py │ │ │ │ ├── get_identification.py │ │ │ │ ├── get_limits.py │ │ │ │ ├── get_nickname.py │ │ │ │ ├── get_receipt.py │ │ │ │ ├── history.py │ │ │ │ ├── list_of_invoices.py │ │ │ │ ├── pay_invoice.py │ │ │ │ ├── payment_by_details.py │ │ │ │ ├── predict_comission.py │ │ │ │ ├── qiwi_master │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── block_card.py │ │ │ │ │ ├── buy_qiwi_card.py │ │ │ │ │ ├── buy_qiwi_master.py │ │ │ │ │ ├── confirm_qiwi_master.py │ │ │ │ │ ├── create_card_purchase_order.py │ │ │ │ │ ├── get_card_requisites.py │ │ │ │ │ ├── get_statement.py │ │ │ │ │ ├── rename_card.py │ │ │ │ │ └── unblock_card.py │ │ │ │ ├── reveal_card_id.py │ │ │ │ ├── set_default_balance.py │ │ │ │ ├── transaction_info.py │ │ │ │ ├── transfer_money.py │ │ │ │ ├── transfer_money_to_card.py │ │ │ │ └── webhook │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── change_webhook_secret.py │ │ │ │ │ ├── delete_current_webhook.py │ │ │ │ │ ├── get_current_webhook.py │ │ │ │ │ ├── get_webhook_secret.py │ │ │ │ │ ├── register_webhook.py │ │ │ │ │ └── send_test_notification.py │ │ │ └── types │ │ │ │ ├── __init__.py │ │ │ │ ├── account_info.py │ │ │ │ ├── balance.py │ │ │ │ ├── commission.py │ │ │ │ ├── identification.py │ │ │ │ ├── limit.py │ │ │ │ ├── mobile_operator.py │ │ │ │ ├── nickname.py │ │ │ │ ├── other.py │ │ │ │ ├── partner.py │ │ │ │ ├── payment_info.py │ │ │ │ ├── qiwi_master.py │ │ │ │ ├── restriction.py │ │ │ │ ├── stats.py │ │ │ │ ├── transaction.py │ │ │ │ └── webhooks.py │ │ └── wrapper.py │ └── exceptions.py ├── types │ ├── __init__.py │ ├── _currencies.py │ ├── amount.py │ ├── arbitrary │ │ ├── __init__.py │ │ ├── file.py │ │ └── inputs.py │ ├── base.py │ └── exceptions.py ├── utils │ ├── __init__.py │ ├── certificates.py │ ├── compat.py │ ├── currency_util.py │ ├── date_conversion.py │ ├── deprecated.py │ ├── mypy_hacks.py │ ├── payload.py │ ├── synchronous │ │ ├── __init__.py │ │ ├── adapter.py │ │ └── adapter.pyi │ └── validators.py └── yoo_money │ ├── __init__.py │ ├── client.py │ ├── exceptions.py │ ├── methods │ ├── __init__.py │ ├── acccept_incoming_transfer.py │ ├── build_auth_url.py │ ├── get_access_token.py │ ├── make_cellular_payment.py │ ├── operation_details.py │ ├── operation_history.py │ ├── process_payment.py │ ├── reject_incoming_transfer.py │ ├── request_payment.py │ ├── retrieve_account_info.py │ └── revoke_api_token.py │ └── types.py ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── conftest.py ├── integration ├── __init__.py ├── test_qiwi │ ├── __init__.py │ └── test_clients │ │ ├── __init__.py │ │ ├── test_maps.py │ │ ├── test_p2p_client.py │ │ └── test_wallet.py └── test_yoomoney │ ├── __init__.py │ └── test_client.py ├── settings.py └── unit ├── __init__.py ├── base.py ├── test_api_methods ├── __init__.py ├── test_api_method.py ├── test_qiwi_api_method.py └── test_runtime_value.py ├── test_errors └── test_yoomoney_errors.py ├── test_event_fetching ├── __init__.py ├── mocks.py ├── test_executor.py ├── test_filters │ ├── __init__.py │ └── test_custom_filters.py └── test_webhook │ ├── __init__.py │ ├── conftest.py │ ├── test_app.py │ ├── test_services │ ├── __init__.py │ ├── test_collision_detector.py │ └── test_security.py │ ├── test_utils.py │ └── test_views │ ├── __init__.py │ ├── conftest.py │ ├── test_p2p_view.py │ └── test_txn_view.py ├── test_initialization ├── __init__.py └── test_qiwi_p2p_client.py ├── test_plugins ├── __init__.py └── test_telegram_plugins │ ├── __init__.py │ ├── test_polling.py │ └── test_webhook.py ├── test_session ├── __init__.py └── test_aiohttp_session_holder.py ├── test_types ├── __init__.py └── test_arbitrary │ ├── __init__.py │ └── test_file.py └── test_utils ├── __init__.py ├── test_api_helper.py ├── test_certificates.py ├── test_currency_parser.py └── test_executor ├── __init__.py └── test_webhook.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [**] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | max_line_length = 99 11 | 12 | [**.{yml,yaml}] 13 | indent_size = 2 14 | 15 | [**.json] 16 | indent_size = 2 17 | 18 | [**.{md,txt,rst}] 19 | trim_trailing_whitespace = false 20 | 21 | [Makefile] 22 | indent_style = tab 23 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "dev-2.x", "dev-1.x" ] 6 | pull_request: 7 | branches: [ "dev-2.x" ] 8 | schedule: 9 | - cron: "3 5 * * 0" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ python ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/locks.yml: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/pallets/flask/blob/main/.github/workflows/lock.yaml 2 | 3 | # This does not automatically close "stale" issues. Instead, it locks closed issues after 3 weeks of no activity. 4 | # If there's a new issue related to an old one, we've found it's much easier to work on as a new issue. 5 | 6 | name: 'Lock threads' 7 | 8 | on: 9 | schedule: 10 | - cron: '0 0 * * *' 11 | 12 | jobs: 13 | lock: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: dessant/lock-threads@v3 17 | with: 18 | github-token: ${{ github.token }} 19 | issue-inactive-days: 21 20 | pr-inactive-days: 21 21 | -------------------------------------------------------------------------------- /.github/workflows/publish_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to the Pypi 2 | 3 | on: 4 | push: 5 | tags: 6 | - '2.*' 7 | 8 | jobs: 9 | build: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | 15 | - name: Set up Python 3.10 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: "3.10" 19 | 20 | - name: Install and configure Poetry 21 | uses: snok/install-poetry@v1 22 | with: 23 | version: 1.2.1 24 | virtualenvs-create: false 25 | installer-parallel: true 26 | 27 | - name: Build 28 | run: | 29 | poetry build 30 | - name: Try install wheel 31 | run: | 32 | pip install -U virtualenv 33 | mkdir -p try_install 34 | cd try_install 35 | virtualenv venv 36 | venv/bin/pip install ../dist/glQiwiApi-*.whl 37 | venv/bin/python -c "import glQiwiApi; print(glQiwiApi.__version__)" 38 | - name: Publish artifacts 39 | uses: actions/upload-artifact@v2 40 | with: 41 | name: dist 42 | path: dist/* 43 | 44 | publish: 45 | name: Publish 46 | needs: build 47 | if: "success() && startsWith(github.ref, 'refs/tags/')" 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Download artifacts 51 | uses: actions/download-artifact@v1 52 | with: 53 | name: dist 54 | path: dist 55 | 56 | - name: Publish a Python distribution to PyPI 57 | uses: pypa/gh-action-pypi-publish@master 58 | with: 59 | user: __token__ 60 | password: ${{ secrets.PYPI_TOKEN }} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | garbage.py 6 | .vscode 7 | # C ext 8 | *.so 9 | glQiwiApi/test.py 10 | ssl_load_s.py 11 | setup.py 12 | .coverage 13 | htmlcov 14 | 15 | .idea 16 | 17 | .benchmarks 18 | 19 | glQiwiApi/t.py 20 | *.pem 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | pip-wheel-metadata/ 36 | share/python-wheels/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | MANIFEST 41 | 42 | # PyInstaller 43 | # Usually these files are written by a python script from a template 44 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 45 | *.manifest 46 | *.spec 47 | 48 | # Installer logs 49 | pip-log.txt 50 | pip-delete-this-directory.txt 51 | 52 | # Unit test / coverage reports 53 | htmlcov/ 54 | .tox/ 55 | .nox/ 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | *.py,cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | db.sqlite3-journal 74 | 75 | # Flask stuff: 76 | instance/ 77 | .webassets-cache 78 | 79 | # Scrapy stuff: 80 | .scrapy 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | 85 | # PyBuilder 86 | target/ 87 | 88 | # Jupyter Notebook 89 | .ipynb_checkpoints 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | .python-version 97 | 98 | # pipenv 99 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 100 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 101 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 102 | # install all needed dependencies. 103 | #Pipfile.lock 104 | 105 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 106 | __pypackages__/ 107 | 108 | # Celery stuff 109 | celerybeat-schedule 110 | celerybeat.pid 111 | 112 | # SageMath parsed files 113 | *.sage.py 114 | 115 | # Environments 116 | .venv 117 | */.env 118 | env/ 119 | venv/ 120 | ENV/ 121 | env.bak/ 122 | venv.bak/ 123 | 124 | # Spyder project settings 125 | .spyderproject 126 | .spyproject 127 | 128 | # Rope project settings 129 | .ropeproject 130 | 131 | # mkdocs documentation 132 | /site 133 | 134 | # mypy 135 | .mypy_cache/ 136 | .dmypy.json 137 | dmypy.json 138 | 139 | # Pyre type checker 140 | .pyre/ 141 | .idea/* 142 | .env 143 | 144 | docker_test/* 145 | docker_test 146 | 147 | .ruff_cache/* 148 | playground.py 149 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "qiwi-referrer-proxy"] 2 | path = qiwi-referrer-proxy 3 | url = https://github.com/GLEF1X/qiwi-referrer-proxy.git 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: "trailing-whitespace" 6 | - id: "check-case-conflict" 7 | - id: "check-merge-conflict" 8 | - id: "debug-statements" 9 | - id: "end-of-file-fixer" 10 | - id: "mixed-line-ending" 11 | - id: "check-yaml" 12 | - id: "detect-private-key" 13 | - id: "check-toml" 14 | 15 | - repo: https://github.com/psf/black 16 | rev: 22.12.0 17 | hooks: 18 | - id: black 19 | files: &files '^(glQiwiApi|tests|examples|benchmarks|docs)' 20 | 21 | - repo: https://github.com/pre-commit/mirrors-isort 22 | rev: v5.10.1 23 | hooks: 24 | - id: isort 25 | additional_dependencies: [ toml ] 26 | files: *files 27 | 28 | - repo: https://github.com/floatingpurr/sync_with_poetry 29 | rev: 0.4.0 30 | hooks: 31 | - id: sync_with_poetry 32 | 33 | - repo: https://github.com/charliermarsh/ruff-pre-commit 34 | rev: v0.0.204 35 | hooks: 36 | - id: ruff 37 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | formats: 7 | - htmlzip 8 | - epub 9 | 10 | python: 11 | version: 3.10 12 | install: 13 | - method: pip 14 | path: . 15 | extra_requirements: 16 | - docs 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 GLEF1X 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | [![PyPI version](https://img.shields.io/pypi/v/glQiwiApi.svg)](https://pypi.org/project/glQiwiApi/) ![Downloads](https://img.shields.io/pypi/dm/glQiwiApi) ![docs](https://readthedocs.org/projects/pip/badge/?version=latest) 6 | [![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/GLEF1X/glQiwiApi.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/GLEF1X/glQiwiApi/context:python) [![CodeFactor](https://www.codefactor.io/repository/github/glef1x/glqiwiapi/badge)](https://www.codefactor.io/repository/github/glef1x/glqiwiapi) 7 | ![codecov](https://codecov.io/gh/GLEF1X/glQiwiApi/branch/dev-2.x/graph/badge.svg?token=OD538HKV15) 8 | ![CI](https://github.com/GLEF1X/glQiwiApi/actions/workflows/tests.yml/badge.svg) ![mypy](https://img.shields.io/badge/%20type_checker-mypy-%231674b1?style=flat) [![Downloads](https://static.pepy.tech/badge/glqiwiapi/month)](https://pepy.tech/project/glqiwiapi) [![Downloads](https://static.pepy.tech/badge/glqiwiapi)](https://pepy.tech/project/glqiwiapi) 9 | 10 | 11 |

12 | 13 | ## 🌎Official API resources: 14 | 15 | * 🎓 __Documentation: [here](https://glqiwiapi.readthedocs.io/en/latest/)__ 16 | * 🖱️ __Telegram chat: [![Dev-Telegram](https://img.shields.io/badge/Telegram-blue.svg?style=flat-square&logo=telegram)](https://t.me/glQiwiAPIOfficial)__ 17 | 18 | ## Benchmarks 19 | 20 | ```bash 21 | hint: smaller is better 22 | glQiwiApi 90.9925 (1.0) 103.3993 (1.0) 95.4082 (1.0) 5.3941 (1.0) 92.4023 (1.0) 8.2798 (1.0) 23 | pyQiwiP2P 112.2819 (1.23) 135.0227 (1.31) 123.7498 (1.30) 9.9919 (1.85) 127.5926 (1.38) 17.2723 (2.09) 24 | ``` 25 | 26 | ## 🐦Dependencies 27 | 28 | | Library | Description | 29 | |:-------:|:-------------------------------------------------------:| 30 | |aiohttp | Asynchronous HTTP Client/Server for asyncio and Python. | 31 | |pydantic | Json data validator. Very fast instead of custom | 32 | -------------------------------------------------------------------------------- /benchmarks/p2p/test_performance.py: -------------------------------------------------------------------------------- 1 | # pip install pytest-benchmark pyqiwip2p 2 | import os 3 | 4 | import pytest 5 | from pyqiwip2p.AioQiwip2p import AioQiwiP2P 6 | from pytest_benchmark.fixture import BenchmarkFixture 7 | 8 | from glQiwiApi import QiwiP2PClient 9 | 10 | wrapper = QiwiP2PClient(secret_p2p=os.getenv('SECRET_P2P')) 11 | 12 | c = AioQiwiP2P(auth_key=os.getenv('SECRET_P2P')) 13 | 14 | 15 | # Results on my machine (smaller is better) 16 | # test_create_bill_with_glQiwiApi 90.9925 (1.0) 103.3993 (1.0) 95.4082 (1.0) 5.3941 (1.0) 92.4023 (1.0) 8.2798 (1.0) 1;0 10.4813 (1.0) 5 11 17 | # test_create_bill_with_pyQiwiP2P 112.2819 (1.23) 135.0227 (1.31) 123.7498 (1.30) 9.9919 (1.85) 127.5926 (1.38) 17.2723 (2.09) 2;0 8.0808 (0.77) 5 10 18 | 19 | 20 | async def create_bill_with_glQiwiApi(): 21 | await wrapper.create_p2p_bill(amount=1) 22 | 23 | 24 | async def create_bill_with_pyQiwiP2P(): 25 | await c.bill(amount=1) 26 | 27 | 28 | @pytest.fixture() 29 | def aio_benchmark(benchmark: BenchmarkFixture) -> BenchmarkFixture: 30 | import asyncio 31 | import threading 32 | 33 | class Sync2Async: 34 | def __init__(self, coro, *args, **kwargs): 35 | self.coro = coro 36 | self.args = args 37 | self.kwargs = kwargs 38 | self.custom_loop = None 39 | self.thread = None 40 | 41 | def start_background_loop(self) -> None: 42 | asyncio.set_event_loop(self.custom_loop) 43 | self.custom_loop.run_forever() 44 | 45 | def __call__(self): 46 | evloop = None 47 | awaitable = self.coro(*self.args, **self.kwargs) 48 | try: 49 | evloop = asyncio.get_running_loop() 50 | except: 51 | pass 52 | if evloop is None: 53 | return asyncio.run(awaitable) 54 | else: 55 | if not self.custom_loop or not self.thread or not self.thread.is_alive(): 56 | self.custom_loop = asyncio.new_event_loop() 57 | self.thread = threading.Thread(target=self.start_background_loop, daemon=True) 58 | self.thread.start() 59 | 60 | return asyncio.run_coroutine_threadsafe(awaitable, self.custom_loop).result() 61 | 62 | def _wrapper(func, *args, **kwargs): 63 | if asyncio.iscoroutinefunction(func): 64 | benchmark(Sync2Async(func, *args, **kwargs)) 65 | else: 66 | benchmark(func, *args, **kwargs) 67 | 68 | return _wrapper 69 | 70 | 71 | @pytest.mark.asyncio 72 | async def test_create_bill_with_glQiwiApi(aio_benchmark: BenchmarkFixture) -> None: 73 | @aio_benchmark 74 | async def _(): 75 | await create_bill_with_glQiwiApi() 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_create_bill_with_pyQiwiP2P(aio_benchmark: BenchmarkFixture) -> None: 80 | @aio_benchmark 81 | async def _(): 82 | await create_bill_with_pyQiwiP2P() 83 | -------------------------------------------------------------------------------- /codecov.yaml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | branch: dev-2.x 4 | 5 | ignore: 6 | - "glQiwiApi/ext" 7 | - "glQiwiApi/utils/mypy_hacks.py" 8 | - "glQiwiApi/utils/compat.py" 9 | - "glQiwiApi/utils/payload.py" 10 | - "glQiwiApi/qiwi/clients/wrapper.py" 11 | - "glQiwiApi/utils/deprecated.py" 12 | - "glQiwiApi/utils/date_conversion.py" 13 | - "glQiwiApi/utils/synchronous.py" 14 | - "glQiwiApi/core/cache" 15 | 16 | coverage: 17 | precision: 2 18 | round: down 19 | range: "70...100" 20 | 21 | parsers: 22 | gcov: 23 | branch_detection: 24 | conditional: yes 25 | loop: yes 26 | method: no 27 | macro: no 28 | 29 | comment: 30 | layout: "reach,diff,flags,tree" 31 | behavior: default 32 | require_changes: no 33 | branches: 34 | - dev-2.x 35 | after_n_builds: 6 36 | -------------------------------------------------------------------------------- /docs/API/index.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | Available API wrappers 3 | ====================== 4 | 5 | The glQiwiApi is capable of interacting with many APIs independently. 6 | Actually, there is no restrictions to usage of both YooMoney and QIWI API. 7 | 8 | .. toctree:: 9 | qiwi_api 10 | yoomoney_api 11 | qiwi_maps 12 | -------------------------------------------------------------------------------- /docs/API/qiwi_api.rst: -------------------------------------------------------------------------------- 1 | ######## 2 | QIWI API 3 | ######## 4 | 5 | 6 | .. autoclass:: glQiwiApi.qiwi.clients.wrapper.QiwiWrapper 7 | :members: 8 | :show-inheritance: 9 | :member-order: bysource 10 | :special-members: __init__ 11 | :undoc-members: True 12 | 13 | .. autoclass:: glQiwiApi.qiwi.clients.wallet.client.QiwiWallet 14 | :members: 15 | :show-inheritance: 16 | :member-order: bysource 17 | :special-members: __init__ 18 | :undoc-members: True 19 | 20 | .. autoclass:: glQiwiApi.qiwi.clients.p2p.client.QiwiP2PClient 21 | :members: 22 | :show-inheritance: 23 | :member-order: bysource 24 | :special-members: __init__ 25 | :undoc-members: True 26 | -------------------------------------------------------------------------------- /docs/API/qiwi_maps.rst: -------------------------------------------------------------------------------- 1 | ############# 2 | QIWI Maps API 3 | ############# 4 | 5 | .. autoclass:: glQiwiApi.qiwi.clients.maps.client.QiwiMaps 6 | :members: 7 | :show-inheritance: 8 | :member-order: bysource 9 | :special-members: __init__ 10 | :undoc-members: True 11 | -------------------------------------------------------------------------------- /docs/API/yoomoney_api.rst: -------------------------------------------------------------------------------- 1 | ############ 2 | YooMoney API 3 | ############ 4 | 5 | .. autoclass:: glQiwiApi.yoo_money.client.YooMoneyAPI 6 | :members: 7 | :show-inheritance: 8 | :member-order: bysource 9 | :special-members: __init__ 10 | :undoc-members: True 11 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SPHINXINTL ?= sphinx-intl 9 | SOURCEDIR = . 10 | BUILDDIR = _build 11 | 12 | # Put it first so that "make" without argument is like "make help". 13 | help: 14 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 15 | 16 | .PHONY: help update-intl Makefile 17 | 18 | # Catch-all target: route all unknown targets to Sphinx using the new 19 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 20 | %: Makefile 21 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 22 | -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/advanced_features/cache.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Query caching 3 | ============= 4 | 5 | glQiwiApi provides builtin cache storage and cache invalidation strategies 6 | such a `InMemoryCacheStorage` and `APIResponsesCacheInvalidationStrategy` accordingly. 7 | Obviously, you can extend functionality of library and add, for example, Redis based cache storage. 8 | 9 | 10 | 11 | .. tip:: By default InMemoryCacheStorage use `UnrealizedCacheInvalidationStrategy` as an invalidation strategy 12 | 13 | Straightforward example 14 | ----------------------- 15 | 16 | .. code-block:: python 17 | 18 | import asyncio 19 | 20 | from glQiwiApi.core.cache import InMemoryCacheStorage 21 | 22 | storage = InMemoryCacheStorage() # here is UnrealizedCacheInvalidationStrategy as an invalidation strategy 23 | 24 | 25 | async def cache(): 26 | await storage.update(cached=5) 27 | cached = await storage.retrieve("cached") 28 | print(f"You have cached {cached}") 29 | 30 | 31 | asyncio.run(cache()) 32 | 33 | Advanced usage 34 | -------------- 35 | 36 | This very cache invalidation by timer strategy is worth using if you want to achieve cache invalidation. 37 | It should be noted that previous `UnrealizedCacheInvalidationStrategy` just ignores the invalidation and don't get rid of aged cache. 38 | 39 | .. code-block:: python 40 | 41 | import asyncio 42 | 43 | from glQiwiApi.core.cache import InMemoryCacheStorage, CacheInvalidationByTimerStrategy 44 | 45 | storage = InMemoryCacheStorage(CacheInvalidationByTimerStrategy(cache_time_in_seconds=1)) 46 | 47 | 48 | async def main(): 49 | await storage.update(x=5) 50 | 51 | await asyncio.sleep(1) 52 | 53 | value = await storage.retrieve("x") # None 54 | 55 | 56 | asyncio.run(main()) 57 | -------------------------------------------------------------------------------- /docs/advanced_features/index.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | Low level API features 3 | ====================== 4 | 5 | .. toctree:: 6 | cache 7 | proxy 8 | known-issues 9 | -------------------------------------------------------------------------------- /docs/advanced_features/known-issues.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Known issues: 3 | ============= 4 | 5 | 6 | ``` 7 | aiohttp.client_exceptions.ClientConnectorError: Cannot connect to api.qiwi.com ssl:true... 8 | ``` 9 | 10 | This exception can be fixed this way: 11 | 12 | .. code-block:: python 13 | 14 | import glQiwiApi 15 | from glQiwiApi import QiwiWallet 16 | from glQiwiApi.core import RequestService 17 | from glQiwiApi.core.session import AiohttpSessionHolder 18 | 19 | 20 | async def create_request_service(w: QiwiWallet): 21 | return RequestService( 22 | session_holder=AiohttpSessionHolder( 23 | headers={ 24 | "Content-Type": "application/json", 25 | "Accept": "application/json", 26 | "Authorization": f"Bearer {w._api_access_token}", 27 | "Host": "edge.qiwi.com", 28 | "User-Agent": f"glQiwiApi/{glQiwiApi.__version__}", 29 | }, 30 | trust_env=True 31 | ) 32 | ) 33 | 34 | 35 | wallet = QiwiWallet(request_service_factory=create_request_service) 36 | -------------------------------------------------------------------------------- /docs/advanced_features/proxy.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Using proxy 3 | =========== 4 | 5 | 6 | In the example below we'll use `aiohttp-socks` library to establish socks5 proxy: 7 | 8 | 9 | .. code-block:: python 10 | 11 | import asyncio 12 | 13 | from aiohttp_socks import ProxyConnector 14 | 15 | from glQiwiApi import QiwiWallet 16 | from glQiwiApi.core import RequestService 17 | from glQiwiApi.core.session import AiohttpSessionHolder 18 | 19 | 20 | def create_request_service_with_proxy(w: QiwiWallet): 21 | return RequestService( 22 | session_holder=AiohttpSessionHolder( 23 | connector=ProxyConnector.from_url("socks5://34.134.60.185:443"), # some proxy 24 | headers={ 25 | "Content-Type": "application/json", 26 | "Accept": "application/json", 27 | "Authorization": f"Bearer {w._api_access_token}", 28 | "Host": "edge.qiwi.com", 29 | }, 30 | ) 31 | ) 32 | 33 | 34 | wallet = QiwiWallet( 35 | api_access_token="your token", 36 | phone_number="+phone number", 37 | request_service_factory=create_request_service_with_proxy, 38 | ) 39 | 40 | 41 | async def main(): 42 | async with wallet: 43 | print(await wallet.get_balance()) 44 | 45 | 46 | asyncio.run(main()) 47 | -------------------------------------------------------------------------------- /docs/code/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/docs/code/__init__.py -------------------------------------------------------------------------------- /docs/code/polling/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/docs/code/polling/__init__.py -------------------------------------------------------------------------------- /docs/code/polling/events.py: -------------------------------------------------------------------------------- 1 | from glQiwiApi import QiwiWallet 2 | from glQiwiApi.core.event_fetching import executor 3 | from glQiwiApi.core.event_fetching.dispatcher import QiwiDispatcher 4 | from glQiwiApi.core.event_fetching.executor import HandlerContext 5 | from glQiwiApi.qiwi.clients.wallet.types import Transaction 6 | 7 | qiwi_dp = QiwiDispatcher() 8 | wallet = QiwiWallet(api_access_token='token', phone_number='+phone number') 9 | 10 | 11 | @qiwi_dp.transaction_handler() 12 | async def handle_transaction(t: Transaction, ctx: HandlerContext): 13 | """Handle transaction here""" 14 | ctx.wallet # this way you can use QiwiWallet instance to avoid global variables 15 | 16 | 17 | async def on_startup(ctx: HandlerContext): 18 | ctx.wallet # do something here 19 | 20 | 21 | async def on_shutdown(ctx: HandlerContext): 22 | pass 23 | 24 | 25 | if __name__ == '__main__': 26 | executor.start_polling(wallet, qiwi_dp, on_startup=on_startup, on_shutdown=on_shutdown) 27 | -------------------------------------------------------------------------------- /docs/code/polling/qiwi.py: -------------------------------------------------------------------------------- 1 | from glQiwiApi import QiwiWallet 2 | from glQiwiApi.core.event_fetching import executor 3 | from glQiwiApi.core.event_fetching.dispatcher import QiwiDispatcher 4 | from glQiwiApi.core.event_fetching.executor import HandlerContext 5 | from glQiwiApi.core.event_fetching.filters import ExceptionFilter 6 | from glQiwiApi.qiwi.clients.wallet.types import Transaction 7 | from glQiwiApi.qiwi.exceptions import QiwiAPIError 8 | 9 | qiwi_dp = QiwiDispatcher() 10 | wallet = QiwiWallet(api_access_token='token', phone_number='+phone number') 11 | 12 | 13 | @qiwi_dp.transaction_handler() 14 | async def handle_transaction(t: Transaction, ctx: HandlerContext): 15 | """Handle transaction here""" 16 | ctx.wallet # this way you can use QiwiWallet instance to avoid global variables 17 | 18 | 19 | @qiwi_dp.exception_handler(ExceptionFilter(QiwiAPIError)) 20 | async def handle_exception(err: QiwiAPIError, ctx: HandlerContext): 21 | pass 22 | 23 | 24 | if __name__ == '__main__': 25 | executor.start_polling(wallet, qiwi_dp) 26 | -------------------------------------------------------------------------------- /docs/code/polling/with_aiogram.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, Dispatcher 2 | from aiogram.types import Message 3 | 4 | from glQiwiApi import QiwiWallet 5 | from glQiwiApi.core.event_fetching import executor 6 | from glQiwiApi.core.event_fetching.dispatcher import QiwiDispatcher 7 | from glQiwiApi.core.event_fetching.executor import HandlerContext 8 | from glQiwiApi.plugins import AiogramPollingPlugin 9 | from glQiwiApi.qiwi.clients.wallet.types import Transaction 10 | 11 | qiwi_dp = QiwiDispatcher() 12 | wallet = QiwiWallet(api_access_token='token', phone_number='+phone number') 13 | 14 | dp = Dispatcher(Bot('BOT TOKEN')) 15 | 16 | 17 | @qiwi_dp.transaction_handler() 18 | async def handle_transaction(t: Transaction, ctx: HandlerContext): 19 | """Handle transaction here""" 20 | ctx.wallet # this way you can use QiwiWallet instance to avoid global variables 21 | 22 | 23 | @dp.message_handler() 24 | async def handle_message(msg: Message): 25 | await msg.answer(text='Hello world') 26 | 27 | 28 | if __name__ == '__main__': 29 | executor.start_polling(wallet, qiwi_dp, AiogramPollingPlugin(dp)) 30 | -------------------------------------------------------------------------------- /docs/code/polling/with_aiogram_non_blocking.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, Dispatcher 2 | from aiogram.types import Message 3 | from aiogram.utils import executor 4 | 5 | from glQiwiApi import QiwiWallet 6 | from glQiwiApi.core.event_fetching.dispatcher import QiwiDispatcher 7 | from glQiwiApi.core.event_fetching.executor import ( 8 | HandlerContext, 9 | start_non_blocking_qiwi_api_polling, 10 | ) 11 | from glQiwiApi.qiwi.clients.wallet.types import Transaction 12 | 13 | qiwi_dp = QiwiDispatcher() 14 | wallet = QiwiWallet(api_access_token='token', phone_number='+phone number') 15 | 16 | dp = Dispatcher(Bot('BOT TOKEN')) 17 | 18 | 19 | @qiwi_dp.transaction_handler() 20 | async def handle_transaction(t: Transaction, ctx: HandlerContext): 21 | """Handle transaction here""" 22 | 23 | 24 | @dp.message_handler() 25 | async def handle_message(msg: Message): 26 | await msg.answer(text='Hello world') 27 | 28 | 29 | async def on_startup(dp: Dispatcher): 30 | await start_non_blocking_qiwi_api_polling(wallet, qiwi_dp) 31 | 32 | 33 | if __name__ == '__main__': 34 | executor.start_polling(dp, on_startup=on_startup) 35 | -------------------------------------------------------------------------------- /docs/code/polling/without_globals.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import cast 3 | 4 | from aiogram import Bot, Dispatcher, types 5 | 6 | from glQiwiApi import QiwiWrapper 7 | from glQiwiApi.core.event_fetching import executor 8 | from glQiwiApi.core.event_fetching.dispatcher import QiwiDispatcher 9 | from glQiwiApi.core.event_fetching.executor import HandlerContext 10 | from glQiwiApi.plugins import AiogramPollingPlugin 11 | from glQiwiApi.qiwi.clients.wallet.types import Transaction 12 | 13 | api_access_token = 'token' 14 | phone_number = '+phone number' 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | async def aiogram_message_handler(msg: types.Message): 20 | await msg.answer(text='Привет😇') 21 | 22 | 23 | async def qiwi_transaction_handler(update: Transaction, ctx: HandlerContext): 24 | print(update) 25 | 26 | 27 | def on_startup(ctx: HandlerContext) -> None: 28 | logger.info('This message logged on startup') 29 | register_handlers(ctx) 30 | 31 | 32 | def register_handlers(ctx: HandlerContext): 33 | ctx['qiwi_dp'].transaction_handler()(qiwi_transaction_handler) 34 | dispatcher = cast(Dispatcher, ctx['dp']) 35 | dispatcher.register_message_handler(aiogram_message_handler) 36 | 37 | 38 | def run_application() -> None: 39 | logging.basicConfig(level=logging.INFO) 40 | bot = Bot('BOT TOKEN') 41 | dp = Dispatcher(bot) 42 | wallet = QiwiWrapper(api_access_token=api_access_token, phone_number=phone_number) 43 | qiwi_dp = QiwiDispatcher() 44 | 45 | executor.start_polling( 46 | wallet, 47 | qiwi_dp, 48 | AiogramPollingPlugin(dp), 49 | on_startup=on_startup, 50 | skip_updates=True, 51 | context={'dp': dp, 'qiwi_dp': qiwi_dp}, 52 | ) 53 | 54 | 55 | if __name__ == '__main__': 56 | run_application() 57 | -------------------------------------------------------------------------------- /docs/code/webhooks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/docs/code/webhooks/__init__.py -------------------------------------------------------------------------------- /docs/code/webhooks/qiwi.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import Bot, Dispatcher 4 | from aiogram.dispatcher.webhook import configure_app 5 | from aiohttp import web 6 | 7 | from glQiwiApi import QiwiWallet 8 | from glQiwiApi.core.event_fetching.dispatcher import QiwiDispatcher 9 | from glQiwiApi.core.event_fetching.executor import HandlerContext, configure_app_for_qiwi_webhooks 10 | from glQiwiApi.core.event_fetching.webhooks.config import ( 11 | EncryptionConfig, 12 | HookRegistrationConfig, 13 | WebhookConfig, 14 | ) 15 | from glQiwiApi.qiwi.clients.p2p.types import BillWebhook 16 | 17 | qiwi_dp = QiwiDispatcher() 18 | 19 | dp = Dispatcher(Bot('BOT TOKEN')) 20 | wallet = QiwiWallet(api_access_token='wallet api token') 21 | 22 | 23 | @qiwi_dp.bill_handler() 24 | async def handle_webhook(webhook: BillWebhook, ctx: HandlerContext): 25 | # handle bill 26 | bill = webhook.bill 27 | 28 | 29 | app = web.Application() 30 | configure_app( 31 | dp, 32 | configure_app_for_qiwi_webhooks( 33 | wallet, 34 | qiwi_dp, 35 | app, 36 | WebhookConfig( 37 | encryption=EncryptionConfig( 38 | secret_p2p_key='secret p2p token, который был зарегистрирован с указанием айпи. ' 39 | 'Например http://айпи:8080/webhooks/qiwi/bills/' 40 | ), 41 | hook_registration=HookRegistrationConfig(host_or_ip_address='айпи:8080'), 42 | ), 43 | ), 44 | '/bot', 45 | ) 46 | 47 | logging.basicConfig(level=logging.DEBUG) 48 | 49 | if __name__ == '__main__': 50 | # Порт может быть любым 51 | web.run_app(app, port=8080) 52 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import datetime 5 | 6 | import glQiwiApi 7 | 8 | project = 'glQiwiApi' 9 | author = 'GLEF1X' 10 | copyright = f'{datetime.date.today().year}, {author}' 11 | release = glQiwiApi.__version__ 12 | 13 | # Add any paths that contain templates here, relative to this directory. 14 | templates_path = ['_templates'] 15 | 16 | html_theme = 'furo' 17 | html_logo = '_static/logo.png' 18 | html_static_path = ['_static'] 19 | todo_include_todos = True 20 | 21 | extensions = [ 22 | 'sphinx.ext.autodoc', 23 | 'sphinx.ext.autodoc.typehints', 24 | 'sphinx.ext.doctest', 25 | 'sphinx.ext.intersphinx', 26 | 'sphinx.ext.viewcode', 27 | 'sphinxemoji.sphinxemoji', 28 | ] 29 | 30 | htmlhelp_basename = project 31 | html_theme_options = {} 32 | html_css_files = [ 33 | 'stylesheets/extra.css', 34 | ] 35 | 36 | rst_prolog = f""" 37 | .. role:: pycode(code) 38 | :language: python3 39 | """ 40 | 41 | language = None 42 | locale_dirs = ['locales'] 43 | 44 | exclude_patterns = [] 45 | source_suffix = '.rst' 46 | master_doc = 'index' 47 | 48 | # If true, '()' will be appended to :func: etc. cross-reference text. 49 | add_function_parentheses = True 50 | 51 | latex_documents = [ 52 | (master_doc, f'{project}.tex', f'{project} Documentation', author, 'manual'), 53 | ] 54 | 55 | man_pages = [(master_doc, project, f'{project} Documentation', [author], 1)] 56 | 57 | texinfo_documents = [ 58 | ( 59 | master_doc, 60 | project, 61 | f'{project} Documentation', 62 | author, 63 | project, 64 | 'Modern and fully asynchronous framework for Telegram Bot API', 65 | 'Miscellaneous', 66 | ), 67 | ] 68 | -------------------------------------------------------------------------------- /docs/getting-started/index.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting started 3 | =============== 4 | 5 | .. toctree:: 6 | qiwi/index 7 | yoomoney/index 8 | -------------------------------------------------------------------------------- /docs/getting-started/qiwi/index.rst: -------------------------------------------------------------------------------- 1 | ==== 2 | QIWI 3 | ==== 4 | 5 | .. toctree:: 6 | examples 7 | usage_with_aiogram 8 | -------------------------------------------------------------------------------- /docs/getting-started/yoomoney/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | YooMoney 3 | ======== 4 | 5 | .. toctree:: 6 | examples 7 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | .. tip:: Supported Python versions: `3.7` and higher 9 | 10 | Stable release 11 | ---------------- 12 | 13 | In order to install glQiwiApi, run this command in your terminal: 14 | 15 | .. code-block:: console 16 | 17 | $ pip install glQiwiApi 18 | 19 | This is the preferred installation method as it will always install the most recent stable release. 20 | If you do not have installed `pip`_, this `Python Installation Reference`_ can help you in the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python Installation Reference: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From source files 27 | ----------------- 28 | 29 | You can either clone the public repository: 30 | 31 | .. code-block:: console 32 | 33 | $ git clone -b dev-2.x git://github.com/GLEF1X/glQiwiApi 34 | 35 | Once you get a copy of the source files, you can install them with: 36 | 37 | .. code-block:: console 38 | 39 | $ python setup.py install 40 | 41 | 42 | Recommendations 43 | --------------- 44 | You can hasten api by following the instructions below: 45 | 46 | - Use `uvloop `_ instead of default asyncio loop. 47 | 48 | *uvloop* is a rapid, drop-in replacement of the built-in asyncio event loop. uvloop is implemented in Cython and uses libuv under the hood. 49 | 50 | **Installation:** 51 | 52 | .. code-block:: bash 53 | 54 | $ pip install uvloop 55 | 56 | - Use `orjson `_ instead of the default json module. 57 | 58 | orjson is a rapid, correct JSON library for Python. It benchmarks as the fastest Python library for JSON and is more correct than the standard json library or other third-party libraries 59 | 60 | **Installation:** 61 | 62 | .. code-block:: bash 63 | 64 | $ pip install orjson 65 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/polling.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Polling updates 3 | ================ 4 | 5 | API internals 6 | ~~~~~~~~~~~~~ 7 | 8 | .. automodule:: glQiwiApi.core.event_fetching.executor 9 | :members: start_polling,start_non_blocking_qiwi_api_polling,configure_app_for_qiwi_webhooks 10 | :show-inheritance: 11 | :member-order: bysource 12 | :undoc-members: True 13 | 14 | .. autoclass:: glQiwiApi.core.event_fetching.executor.BaseExecutor 15 | :members: 16 | :show-inheritance: 17 | :member-order: bysource 18 | :special-members: __init__ 19 | :undoc-members: True 20 | 21 | .. autoclass:: glQiwiApi.core.event_fetching.executor.PollingExecutor 22 | :members: 23 | :show-inheritance: 24 | :member-order: bysource 25 | :special-members: __init__ 26 | :undoc-members: True 27 | 28 | Guidelines 29 | ~~~~~~~~~~ 30 | 31 | This section explains how to properly poll transactions from QIWI API. 32 | 33 | 34 | *You can't help registering handlers and start polling, so in example above it is shown how to do it rightly.* 35 | Lets do it with decorators: 36 | 37 | .. literalinclude:: code/qiwi/polling.py 38 | :language: python 39 | :emphasize-lines: 15,21 40 | 41 | ️So, we also have to import ``executor`` and pass on our client, 42 | that contains functions ``start_polling`` and ``start_webhook``. 43 | 44 | .. literalinclude:: code/polling/qiwi.py 45 | :language: python 46 | :emphasize-lines: 2 47 | 48 | 49 | Events 50 | ~~~~~~ 51 | 52 | Then, you can start polling, but, let's make it clear which arguments you should pass on to ``start_polling`` function. 53 | You can also specify events like ``on_shutdown`` or ``on_startup``. 54 | As you can see, in the example we have a function that we pass as an argument to ``on_startup``. 55 | As you may have guessed, this function will be executed at the beginning of the polling. 56 | 57 | .. literalinclude:: code/polling/events.py 58 | :language: python 59 | :emphasize-lines: 17,21,26 60 | 61 | 62 | Make aiogram work with glQiwiApi 63 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 64 | 65 | *Also, you can very easily implement simultaneous polling of updates from both aiogram and QIWI API.* 66 | 67 | In the example below, we catch all text messages and return the same "Hello" response. 68 | 69 | .. literalinclude:: code/polling/with_aiogram.py 70 | :language: python 71 | 72 | Alternatively you can run polling at ``on_startup`` event 73 | 74 | .. literalinclude:: code/polling/with_aiogram_non_blocking.py 75 | :language: python 76 | 77 | Example usage without global variables 78 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 79 | 80 | .. literalinclude:: code/polling/without_globals.py 81 | :language: python 82 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | furo 2 | markdown-include 3 | pygments 4 | sphinx-autobuild 5 | sphinx-copybutton 6 | sphinx-intl 7 | sphinx-intl 8 | sphinx-notfound-page 9 | sphinx-prompt 10 | Sphinx-Substitution-Extensions 11 | sphinxcontrib-mermaid 12 | sphinxemoji 13 | towncrier 14 | typing-extensions 15 | -------------------------------------------------------------------------------- /docs/static/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/docs/static/demo.gif -------------------------------------------------------------------------------- /docs/types/arbitrary/index.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | Custom(arbitrary) types 3 | ======================= 4 | 5 | .. note:: When you use method `QiwiWallet.get_receipt` it returns a custom type File, that helps you to save receipt or get raw bytes 6 | 7 | .. autoclass:: glQiwiApi.types.arbitrary.file.File 8 | :members: 9 | 10 | 11 | 12 | .. automodule:: glQiwiApi.types.arbitrary.inputs 13 | :members: 14 | -------------------------------------------------------------------------------- /docs/types/index.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Types 3 | ===== 4 | 5 | .. toctree:: 6 | qiwi_types/index 7 | yoomoney_types/index 8 | arbitrary/index 9 | -------------------------------------------------------------------------------- /docs/types/qiwi_types/index.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Qiwi types 3 | ========== 4 | 5 | .. automodule:: glQiwiApi.qiwi.clients.wallet.types 6 | :members: 7 | 8 | 9 | .. automodule:: glQiwiApi.qiwi.clients.p2p.types 10 | :members: 11 | 12 | 13 | .. automodule:: glQiwiApi.qiwi.clients.maps.types 14 | :members: 15 | -------------------------------------------------------------------------------- /docs/types/yoomoney_types/index.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | YooMoney types 3 | ============== 4 | 5 | .. automodule:: glQiwiApi.yoo_money.types.types 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/webhooks.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Webhooks 3 | ======== 4 | 5 | 6 | Quick example: 7 | 8 | .. literalinclude:: code/webhooks/qiwi.py 9 | :language: python 10 | -------------------------------------------------------------------------------- /glQiwiApi/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from glQiwiApi.qiwi.clients.maps.client import QiwiMaps 4 | from glQiwiApi.qiwi.clients.p2p.client import QiwiP2PClient 5 | from glQiwiApi.qiwi.clients.wallet.client import QiwiWallet 6 | from glQiwiApi.qiwi.clients.wrapper import QiwiWrapper 7 | 8 | from .core.cache import APIResponsesCacheInvalidationStrategy, InMemoryCacheStorage 9 | from .core.cache.storage import CacheStorage 10 | from .yoo_money import YooMoneyAPI 11 | 12 | 13 | def default_cache_storage() -> CacheStorage: 14 | return InMemoryCacheStorage(invalidate_strategy=APIResponsesCacheInvalidationStrategy()) 15 | 16 | 17 | if sys.version_info >= (3, 8): 18 | from importlib import metadata 19 | else: 20 | import importlib_metadata as metadata 21 | 22 | try: 23 | __version__ = metadata.version('glQiwiApi') 24 | except metadata.PackageNotFoundError: 25 | __version__ = '99.99.99' 26 | try: 27 | import uvloop as _uvloop 28 | 29 | _uvloop.install() 30 | except ImportError: # pragma: no cover 31 | pass 32 | 33 | __all__ = ( 34 | # clients 35 | 'YooMoneyAPI', 36 | 'QiwiMaps', 37 | 'QiwiWallet', 38 | 'QiwiP2PClient', 39 | 'QiwiWrapper', 40 | # other 41 | '__version__', 42 | 'default_cache_storage', 43 | ) 44 | -------------------------------------------------------------------------------- /glQiwiApi/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/core/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/core/abc/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /glQiwiApi/core/cache/__init__.py: -------------------------------------------------------------------------------- 1 | from .invalidation import ( 2 | APIResponsesCacheInvalidationStrategy, 3 | CacheInvalidationByTimerStrategy, 4 | CacheInvalidationStrategy, 5 | UnrealizedCacheInvalidationStrategy, 6 | ) 7 | from .storage import InMemoryCacheStorage 8 | -------------------------------------------------------------------------------- /glQiwiApi/core/cache/cached_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import http 4 | from dataclasses import dataclass 5 | from typing import Any, Dict, Optional, Tuple, Union 6 | 7 | 8 | class Payload: 9 | def __init__( 10 | self, 11 | headers: Optional[Dict[Any, Any]] = None, 12 | json: Optional[Dict[Any, Any]] = None, 13 | params: Optional[Dict[Any, Any]] = None, 14 | data: Optional[Dict[Any, Any]] = None, 15 | **kwargs: Any, 16 | ) -> None: 17 | self.headers = headers 18 | self.json = json 19 | self.params = params 20 | self.data = data 21 | 22 | @classmethod 23 | def new(cls, kwargs: Dict[Any, Any], args: Tuple[Any, ...]) -> Payload: 24 | return cls(**{k: kwargs.get(k) for k in args if isinstance(kwargs.get(k), dict)}) 25 | 26 | 27 | @dataclass(frozen=True) 28 | class CachedAPIRequest: 29 | payload: Payload 30 | response: Any 31 | method: Union[str, http.HTTPStatus] 32 | -------------------------------------------------------------------------------- /glQiwiApi/core/cache/constants.py: -------------------------------------------------------------------------------- 1 | ADD_TIME_PLACEHOLDER = 'add_time' 2 | VALUE_PLACEHOLDER = 'value' 3 | UNCACHED = ('https://api.qiwi.com/partner/bill', '/sinap/api/v2/terms/') 4 | -------------------------------------------------------------------------------- /glQiwiApi/core/cache/exceptions.py: -------------------------------------------------------------------------------- 1 | class CacheExpiredError(Exception): 2 | pass 3 | 4 | 5 | class CacheValidationError(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /glQiwiApi/core/cache/storage.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any, Dict, List, Optional 3 | 4 | from glQiwiApi.core.cache.constants import VALUE_PLACEHOLDER 5 | from glQiwiApi.core.cache.exceptions import CacheExpiredError, CacheValidationError 6 | from glQiwiApi.core.cache.invalidation import ( 7 | CacheInvalidationStrategy, 8 | UnrealizedCacheInvalidationStrategy, 9 | ) 10 | from glQiwiApi.core.cache.utils import embed_cache_time 11 | 12 | 13 | class CacheStorage(abc.ABC): 14 | def __init__(self, invalidate_strategy: Optional[CacheInvalidationStrategy] = None): 15 | if invalidate_strategy is None: 16 | invalidate_strategy = UnrealizedCacheInvalidationStrategy() 17 | self._invalidate_strategy: CacheInvalidationStrategy = invalidate_strategy 18 | 19 | @abc.abstractmethod 20 | async def clear(self) -> None: 21 | pass 22 | 23 | @abc.abstractmethod 24 | async def update(self, **kwargs: Any) -> None: 25 | pass 26 | 27 | @abc.abstractmethod 28 | async def retrieve(self, key: str) -> Any: 29 | pass 30 | 31 | @abc.abstractmethod 32 | async def delete(self, key: str) -> None: 33 | pass 34 | 35 | @abc.abstractmethod 36 | async def retrieve_all(self) -> List[Any]: 37 | ... 38 | 39 | @abc.abstractmethod 40 | async def contains_similar(self, item: Any) -> bool: 41 | ... 42 | 43 | def __getitem__(self, item: Any) -> Any: 44 | return self.retrieve(item) 45 | 46 | def __setitem__(self, key: str, value: Any) -> Any: 47 | self.update(**{key: value}) 48 | 49 | 50 | class InMemoryCacheStorage(CacheStorage): 51 | __slots__ = ('_data', '_invalidate_strategy') 52 | 53 | def __init__(self, invalidate_strategy: Optional[CacheInvalidationStrategy] = None): 54 | CacheStorage.__init__(self, invalidate_strategy) 55 | self._data: Dict[Any, Any] = {} 56 | 57 | async def clear(self) -> None: 58 | await self._invalidate_strategy.process_delete() 59 | self._data.clear() 60 | 61 | async def retrieve_all(self) -> List[Optional[Any]]: 62 | return [self.retrieve(key) for key in list(self._data.keys())] 63 | 64 | async def update(self, **kwargs: Any) -> None: 65 | try: 66 | await self._invalidate_strategy.process_update(**kwargs) 67 | except CacheValidationError: 68 | return None 69 | embedded_data: Dict[Any, Any] = embed_cache_time(**kwargs) 70 | self._data.update(embedded_data) 71 | 72 | async def retrieve(self, key: str) -> Optional[Any]: 73 | obj = self._data.get(key) 74 | if obj is None: 75 | return obj 76 | try: 77 | await self._invalidate_strategy.process_retrieve(obj=obj) 78 | return obj[VALUE_PLACEHOLDER] 79 | except CacheExpiredError: 80 | await self.delete(key) 81 | return None 82 | 83 | async def delete(self, key: str) -> None: 84 | del self._data[key] 85 | 86 | async def contains_similar(self, item: Any) -> bool: 87 | return await self._invalidate_strategy.check_is_contains_similar(self, item) 88 | 89 | def __del__(self) -> None: 90 | del self._data 91 | -------------------------------------------------------------------------------- /glQiwiApi/core/cache/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, Dict 3 | 4 | from glQiwiApi.core.cache.constants import ADD_TIME_PLACEHOLDER, VALUE_PLACEHOLDER 5 | 6 | 7 | def embed_cache_time(**kwargs: Any) -> Dict[Any, Any]: 8 | return { 9 | key: {VALUE_PLACEHOLDER: value, ADD_TIME_PLACEHOLDER: time.monotonic()} 10 | for key, value in kwargs.items() 11 | } 12 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/core/event_fetching/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/class_based/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Handler 2 | from .bill import AbstractBillHandler 3 | from .error import ErrorHandler 4 | from .transaction import AbstractTransactionHandler 5 | from .webhook_transaction import AbstractTransactionWebhookHandler 6 | 7 | __all__ = ( 8 | 'AbstractTransactionHandler', 9 | 'AbstractBillHandler', 10 | 'Handler', 11 | 'AbstractTransactionWebhookHandler', 12 | 'ErrorHandler', 13 | ) 14 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/class_based/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from typing import TYPE_CHECKING, Any, Dict, Generic, TypeVar, Union 5 | 6 | if TYPE_CHECKING: 7 | from glQiwiApi.types.base import Base # NOQA # pragma: no cover 8 | 9 | T = TypeVar('T', bound=Union[Exception, 'Base']) 10 | 11 | 12 | class BaseHandlerMixin(Generic[T]): 13 | if TYPE_CHECKING: # pragma: no cover 14 | event: T 15 | 16 | 17 | class Handler(abc.ABC, BaseHandlerMixin[T]): 18 | """Base class for all class-based handlers""" 19 | 20 | def __init__(self, event: T, *args: Any) -> None: 21 | self.event: T = event 22 | self._args = args 23 | if args: 24 | self.context: Dict[str, Any] = args[0].context 25 | 26 | @abc.abstractmethod 27 | async def process_event(self) -> Any: # pragma: no cover # type: ignore 28 | raise NotImplementedError 29 | 30 | def __await__(self) -> Any: 31 | return self.process_event().__await__() 32 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/class_based/bill.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from typing import TYPE_CHECKING, cast 5 | 6 | from glQiwiApi.core.event_fetching.class_based.base import Handler 7 | from glQiwiApi.qiwi.clients.p2p.types import Bill 8 | from glQiwiApi.types.amount import PlainAmount 9 | 10 | if TYPE_CHECKING: 11 | from glQiwiApi.qiwi.clients.p2p.client import QiwiP2PClient 12 | 13 | 14 | class AbstractBillHandler(Handler[Bill], abc.ABC): 15 | @property 16 | def wallet(self) -> QiwiP2PClient: 17 | return cast(QiwiP2PClient, self.context['wallet']) 18 | 19 | @property 20 | def bill_id(self) -> str: 21 | return self.event.id 22 | 23 | @property 24 | def bill_sum(self) -> PlainAmount: 25 | return self.event.amount 26 | 27 | @property 28 | def pay_url(self) -> str: 29 | return self.event.pay_url 30 | 31 | @property 32 | def shim_url(self) -> str: 33 | return self.wallet.create_shim_url(self.event.invoice_uid) 34 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/class_based/error.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from typing import Any 5 | 6 | from .base import Handler 7 | 8 | 9 | class ErrorHandler(Handler[Exception], abc.ABC): 10 | def __init__(self, event: Exception, *args: Any) -> None: 11 | super().__init__(event) 12 | self.args = args 13 | 14 | @property 15 | def exception_name(self) -> str: 16 | return self.event.__class__.__qualname__ 17 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/class_based/transaction.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from typing import TYPE_CHECKING, cast 5 | 6 | from glQiwiApi.qiwi.clients.wallet.types.transaction import Transaction 7 | from glQiwiApi.types.amount import AmountWithCurrency 8 | 9 | from .base import Handler 10 | 11 | if TYPE_CHECKING: 12 | from glQiwiApi.qiwi.clients.wallet.client import QiwiWallet 13 | 14 | 15 | class AbstractTransactionHandler(Handler[Transaction], abc.ABC): 16 | @property 17 | def wallet(self) -> QiwiWallet: 18 | return cast(QiwiWallet, self.context['wallet']) 19 | 20 | @property 21 | def transaction_id(self) -> int: 22 | return self.event.id 23 | 24 | @property 25 | def transaction_sum(self) -> AmountWithCurrency: 26 | return self.event.sum 27 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/class_based/webhook_transaction.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from typing import Optional 5 | 6 | from glQiwiApi.qiwi.clients.wallet.types.webhooks import TransactionWebhook, WebhookPayment 7 | 8 | from .base import Handler 9 | 10 | 11 | class AbstractTransactionWebhookHandler(Handler[TransactionWebhook], abc.ABC): 12 | @property 13 | def hook_id(self) -> str: 14 | return self.event.id 15 | 16 | @property 17 | def payment(self) -> Optional[WebhookPayment]: 18 | return self.event.payment 19 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/filters.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import inspect 5 | from typing import Any, Awaitable, Callable, Generic, Tuple, Type, TypeVar, Union, cast 6 | 7 | Event = TypeVar('Event') 8 | 9 | 10 | class BaseFilter(abc.ABC, Generic[Event]): 11 | @abc.abstractmethod 12 | async def check(self, update: Event) -> bool: 13 | raise NotImplementedError 14 | 15 | def __and__(self, other: BaseFilter[Event]) -> AndFilter[Event]: 16 | if not isinstance(other, BaseFilter): 17 | raise TypeError( 18 | f"Can't compose two different types of filters, expected Filter, got {type(other)}" 19 | ) 20 | return AndFilter(self, other) 21 | 22 | def __invert__(self) -> NotFilter[Event]: 23 | return NotFilter(self) 24 | 25 | 26 | class AndFilter(BaseFilter[Event]): 27 | def __init__(self, filter1: BaseFilter[Event], filter2: BaseFilter[Event]) -> None: 28 | self.filter2 = filter2 29 | self.filter1 = filter1 30 | 31 | async def check(self, update: Any) -> bool: 32 | return await self.filter1.check(update) and await self.filter2.check(update) 33 | 34 | 35 | class NotFilter(BaseFilter[Event]): 36 | def __init__(self, filter_: BaseFilter[Event]): 37 | self.filter_ = filter_ 38 | 39 | async def check(self, update: Event) -> bool: 40 | return not await self.filter_.check(update) 41 | 42 | 43 | class LambdaBasedFilter(BaseFilter[Event]): 44 | def __init__(self, func: Callable[[Event], Union[bool, Awaitable[bool]]]) -> None: 45 | self.name = f'Filter around <{func!r}>' 46 | 47 | self.function = func 48 | self.awaitable: bool = inspect.iscoroutinefunction(func) or inspect.isawaitable(func) 49 | 50 | async def check(self, update: Event) -> bool: 51 | if self.awaitable: 52 | return await cast(Awaitable[bool], self.function(update)) 53 | else: 54 | return cast(bool, self.function(update)) 55 | 56 | 57 | class ExceptionFilter(BaseFilter[Event]): 58 | def __init__(self, exception: Union[Type[Exception], Tuple[Type[Exception]]]): 59 | self._exception = exception 60 | 61 | async def check(self, update: Event) -> bool: 62 | return isinstance(update, self._exception) 63 | 64 | 65 | __all__ = ('LambdaBasedFilter', 'BaseFilter', 'NotFilter', 'AndFilter', 'Event', 'ExceptionFilter') 66 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/__init__.py: -------------------------------------------------------------------------------- 1 | from glQiwiApi.core.event_fetching.webhooks.views.base import BaseWebhookView 2 | 3 | from . import app # noqa 4 | from .views import QiwiBillWebhookView, QiwiTransactionWebhookView 5 | 6 | __all__ = ('QiwiBillWebhookView', 'QiwiTransactionWebhookView', 'BaseWebhookView', 'app.py') 7 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/app.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp import web 4 | 5 | from glQiwiApi.core.event_fetching.dispatcher import BaseDispatcher 6 | from glQiwiApi.core.event_fetching.webhooks.config import WebhookConfig 7 | from glQiwiApi.core.event_fetching.webhooks.middlewares.ip import ip_filter_middleware 8 | from glQiwiApi.core.event_fetching.webhooks.services.collision_detector import ( 9 | HashBasedCollisionDetector, 10 | ) 11 | from glQiwiApi.core.event_fetching.webhooks.services.security.ip import IPFilter 12 | from glQiwiApi.core.event_fetching.webhooks.utils import inject_dependencies 13 | from glQiwiApi.core.event_fetching.webhooks.views.bill_view import QiwiBillWebhookView 14 | from glQiwiApi.core.event_fetching.webhooks.views.transaction_view import ( 15 | QiwiTransactionWebhookView, 16 | ) 17 | from glQiwiApi.qiwi.clients.p2p.types import BillWebhook 18 | from glQiwiApi.qiwi.clients.wallet.types.webhooks import TransactionWebhook 19 | 20 | 21 | def configure_app( 22 | dispatcher: BaseDispatcher, app: web.Application, webhook_config: WebhookConfig 23 | ) -> web.Application: 24 | """ 25 | Entirely configures the web app for webhooks. 26 | 27 | :param dispatcher: dispatcher, which processing events 28 | :param app: aiohttp.web.Application 29 | :param webhook_config: 30 | """ 31 | 32 | generic_dependencies: t.Dict[str, t.Any] = { 33 | 'dispatcher': dispatcher, 34 | 'collision_detector': HashBasedCollisionDetector(), 35 | } 36 | 37 | app.router.add_view( 38 | handler=inject_dependencies( 39 | QiwiBillWebhookView, 40 | { 41 | 'event_cls': BillWebhook, 42 | 'encryption_key': webhook_config.encryption.secret_p2p_key, 43 | **generic_dependencies, 44 | }, 45 | ), 46 | name=webhook_config.routes.p2p_view_route_name, 47 | path=webhook_config.routes.p2p_path, 48 | ) 49 | 50 | app.router.add_view( 51 | handler=inject_dependencies( 52 | QiwiTransactionWebhookView, 53 | { 54 | 'event_cls': TransactionWebhook, 55 | 'encryption_key': webhook_config.encryption.base64_encryption_key, 56 | **generic_dependencies, 57 | }, 58 | ), 59 | name=webhook_config.routes.standard_qiwi_route_name, 60 | path=webhook_config.routes.standard_qiwi_hook_path, 61 | ) 62 | 63 | if webhook_config.security.check_ip: 64 | app.middlewares.append(ip_filter_middleware(IPFilter.default())) 65 | 66 | return app 67 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ssl 4 | from dataclasses import dataclass, field 5 | from typing import Any, Dict, Optional 6 | 7 | from aiohttp import web 8 | 9 | from glQiwiApi.utils.certificates import SSLCertificate 10 | 11 | DEFAULT_QIWI_WEBHOOK_PATH = '/webhooks/qiwi/transactions/' 12 | DEFAULT_QIWI_ROUTE_NAME = 'QIWI_TRANSACTIONS' 13 | 14 | DEFAULT_QIWI_BILLS_WEBHOOK_PATH = '/webhooks/qiwi/bills/' 15 | DEFAULT_QIWI_BILLS_ROUTE_NAME = 'QIWI_BILLS' 16 | 17 | 18 | @dataclass() 19 | class ApplicationConfig: 20 | base_app: Optional[web.Application] = None 21 | 22 | host: str = 'localhost' 23 | 'server host' 24 | 25 | port: int = 8080 26 | 'server port that open for tcp/ip trans.' 27 | 28 | ssl_certificate: Optional[SSLCertificate] = None 29 | 30 | kwargs: Dict[Any, Any] = field(default_factory=dict) 31 | 32 | @property 33 | def ssl_context(self) -> Optional[ssl.SSLContext]: 34 | if self.ssl_certificate is None: 35 | return None 36 | return self.ssl_certificate.as_ssl_context() 37 | 38 | 39 | @dataclass() 40 | class RoutesConfig: 41 | p2p_path: str = DEFAULT_QIWI_BILLS_WEBHOOK_PATH 42 | standard_qiwi_hook_path: str = DEFAULT_QIWI_WEBHOOK_PATH 43 | 44 | p2p_view_route_name: str = DEFAULT_QIWI_BILLS_ROUTE_NAME 45 | standard_qiwi_route_name: str = DEFAULT_QIWI_ROUTE_NAME 46 | 47 | 48 | @dataclass() 49 | class EncryptionConfig: 50 | secret_p2p_key: str 51 | base64_encryption_key: Optional[ 52 | str 53 | ] = None # taken from QIWI API using QiwiWallet instance by default 54 | 55 | 56 | @dataclass() 57 | class SecurityConfig: 58 | check_ip: bool = True 59 | 60 | 61 | @dataclass() 62 | class HookRegistrationConfig: 63 | host_or_ip_address: Optional[str] = None 64 | 65 | 66 | @dataclass 67 | class WebhookConfig: 68 | encryption: EncryptionConfig 69 | hook_registration: HookRegistrationConfig = field(default_factory=HookRegistrationConfig) 70 | app: ApplicationConfig = field(default_factory=ApplicationConfig) 71 | routes: RoutesConfig = field(default_factory=RoutesConfig) 72 | security: SecurityConfig = field(default_factory=SecurityConfig) 73 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/dto/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/core/event_fetching/webhooks/dto/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/dto/errors.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class WebhookAPIError(BaseModel): 7 | status: str 8 | detail: Optional[str] = None 9 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/middlewares/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/core/event_fetching/webhooks/middlewares/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/middlewares/ip.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Awaitable, Callable 3 | 4 | from aiohttp import web 5 | from aiohttp.typedefs import Handler 6 | from aiohttp.web_middlewares import middleware 7 | 8 | from glQiwiApi.core.event_fetching.webhooks.services.security.ip import IPFilter 9 | from glQiwiApi.core.event_fetching.webhooks.utils import check_ip 10 | 11 | logger = logging.getLogger('glQiwiApi.webhooks.middlewares') 12 | 13 | 14 | def ip_filter_middleware(ip_filter: IPFilter) -> Callable[[web.Request, Handler], Awaitable[Any]]: 15 | @middleware 16 | async def _ip_filter_middleware(request: web.Request, handler: Handler) -> Any: 17 | ip_address, accept = check_ip(ip_filter=ip_filter, request=request) 18 | if not accept: 19 | logger.warning(f'Blocking request from an unauthorized IP: {ip_address}') 20 | raise web.HTTPUnauthorized() 21 | return await handler(request) 22 | 23 | return _ip_filter_middleware 24 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/core/event_fetching/webhooks/services/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/services/collision_detector.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any, Generic, Set, TypeVar 3 | 4 | T = TypeVar('T') 5 | 6 | 7 | class UnexpectedCollision(Exception): 8 | pass 9 | 10 | 11 | class UnhashableObjectError(TypeError): 12 | pass 13 | 14 | 15 | class AbstractCollisionDetector(abc.ABC, Generic[T]): 16 | """ 17 | QIWI API can transfer_money the same update twice or more, so we need to avoid this problem anyway. 18 | Also, you can override it with redis usage or more advanced hashing. 19 | """ 20 | 21 | @abc.abstractmethod 22 | def has_collision(self, obj: T) -> bool: 23 | raise NotImplementedError 24 | 25 | @abc.abstractmethod 26 | def add_already_processed_event(self, obj: T) -> None: 27 | raise NotImplementedError 28 | 29 | def remember_processed_object(self, obj: T) -> None: 30 | if self.has_collision(obj): 31 | raise UnexpectedCollision() 32 | self.add_already_processed_event(obj) 33 | 34 | 35 | class HashBasedCollisionDetector(AbstractCollisionDetector[T]): 36 | def __init__(self) -> None: 37 | self.already_processed_object_hashes: Set[int] = set() 38 | 39 | def add_already_processed_event(self, obj: T) -> None: 40 | if _is_object_unhashable(obj): 41 | raise UnhashableObjectError(f'Object {obj!r} is unhashable') 42 | self.already_processed_object_hashes.add(hash(obj)) 43 | 44 | def has_collision(self, obj: T) -> bool: 45 | if _is_object_unhashable(obj): 46 | raise UnhashableObjectError(f'Object {obj!r} is unhashable') 47 | return any( 48 | hash(obj) == processed_hash for processed_hash in self.already_processed_object_hashes 49 | ) 50 | 51 | 52 | def _is_object_unhashable(obj: Any) -> bool: 53 | try: 54 | hash(obj) 55 | return False 56 | except TypeError: 57 | return True 58 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/services/security/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/core/event_fetching/webhooks/services/security/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/services/security/ip.py: -------------------------------------------------------------------------------- 1 | from ipaddress import IPv4Address, IPv4Network 2 | from typing import Optional, Sequence, Set, Union 3 | 4 | DEFAULT_QIWI_NETWORKS = [ 5 | IPv4Network('79.142.16.0/20'), 6 | IPv4Network('195.189.100.0/22'), 7 | IPv4Network('91.232.230.0/23'), 8 | IPv4Network('91.213.51.0/24'), 9 | ] 10 | 11 | 12 | class IPFilter: 13 | def __init__(self, ips: Optional[Sequence[Union[str, IPv4Network, IPv4Address]]] = None): 14 | self._allowed_ips: Set[IPv4Address] = set() 15 | 16 | if ips: 17 | self.allow(*ips) 18 | 19 | def allow(self, *ips: Union[str, IPv4Network, IPv4Address]) -> None: 20 | for ip in ips: 21 | self.allow_ip(ip) 22 | 23 | def allow_ip(self, ip: Union[str, IPv4Network, IPv4Address]) -> None: 24 | if isinstance(ip, str): 25 | ip = IPv4Network(ip) if '/' in ip else IPv4Address(ip) 26 | if isinstance(ip, IPv4Address): 27 | self._allowed_ips.add(ip) 28 | elif isinstance(ip, IPv4Network): 29 | self._allowed_ips.update(ip.hosts()) 30 | else: 31 | raise ValueError(f"Invalid type of ipaddress: {type(ip)} ('{ip}')") 32 | 33 | @classmethod 34 | def default(cls) -> 'IPFilter': 35 | return cls(DEFAULT_QIWI_NETWORKS) 36 | 37 | def __contains__(self, item: Union[str, IPv4Address]) -> bool: 38 | return self.check(item) 39 | 40 | def check(self, ip: Union[str, IPv4Address]) -> bool: 41 | if not isinstance(ip, IPv4Address): 42 | ip = IPv4Address(ip) 43 | return ip in self._allowed_ips 44 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import inspect 3 | import sys 4 | from asyncio import Transport 5 | from typing import Any, Mapping, Tuple, Type, TypeVar, cast, no_type_check 6 | 7 | from aiohttp import web 8 | from aiohttp.abc import AbstractView 9 | from aiohttp.web_request import Request 10 | 11 | from glQiwiApi.core.event_fetching.webhooks.services.security.ip import IPFilter 12 | 13 | 14 | def check_ip(ip_filter: IPFilter, request: web.Request) -> Tuple[str, bool]: 15 | # Try to resolve client IP over reverse proxy 16 | forwarded_for = request.headers.get('X-Forwarded-For', '') 17 | if forwarded_for: 18 | forwarded_for, *_ = forwarded_for.split(',', maxsplit=1) 19 | return forwarded_for, forwarded_for in ip_filter 20 | 21 | peer_name = cast(Transport, request.transport).get_extra_info('peername') 22 | # When reverse proxy is not configured IP address can be resolved from incoming connection 23 | if peer_name: 24 | host, _ = peer_name 25 | return host, host in ip_filter 26 | 27 | # Potentially impossible case 28 | return '', False # pragma: no cover 29 | 30 | 31 | View = TypeVar('View', bound=Type[AbstractView]) 32 | 33 | 34 | def inject_dependencies(view: View, dependencies: Mapping[str, Any]) -> View: 35 | params = inspect.signature(view.__init__).parameters 36 | 37 | deps = { 38 | name: dependency 39 | for name, dependency in dependencies.items() 40 | if not isinstance(dependency, (Request, AbstractView)) and name in params 41 | } 42 | 43 | return cast(View, partial_class(view.__qualname__, view, **deps)) # type: ignore 44 | 45 | 46 | @no_type_check 47 | def partial_class(name, cls, *args, **kwds): 48 | new_cls = type( 49 | name, (cls,), {'__init__': functools.partialmethod(cls.__init__, *args, **kwds)} 50 | ) 51 | 52 | # The following is copied nearly ad verbatim from `namedtuple's` source. 53 | """ 54 | # For pickling to work, the __module__ variable needs to be set to the frame 55 | # where the named tuple is created. Bypass this step in enviroments where 56 | # sys._getframe is not defined (Jython for example) or sys._getframe is not 57 | # defined for arguments greater than 0 (IronPython). 58 | """ 59 | try: 60 | new_cls.__module__ = sys._getframe(1).f_globals.get('__name__', '__main__') # noqa 61 | except (AttributeError, ValueError): # pragma: no cover 62 | pass 63 | 64 | return new_cls 65 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .bill_view import QiwiBillWebhookView 2 | from .transaction_view import QiwiTransactionWebhookView 3 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/views/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import logging 3 | from typing import TYPE_CHECKING as MYPY 4 | from typing import Any, Generic, Type, TypeVar 5 | 6 | from aiohttp import web 7 | from aiohttp.web_request import Request 8 | 9 | from glQiwiApi.core.event_fetching.dispatcher import BaseDispatcher 10 | from glQiwiApi.core.event_fetching.webhooks.dto import WebhookAPIError 11 | from glQiwiApi.core.event_fetching.webhooks.services.collision_detector import ( 12 | AbstractCollisionDetector, 13 | UnexpectedCollision, 14 | ) 15 | from glQiwiApi.utils.compat import json 16 | 17 | if MYPY: 18 | from glQiwiApi.types.base import HashableBase # noqa 19 | 20 | Event = TypeVar('Event', bound='HashableBase') 21 | 22 | logger = logging.getLogger('glQiwiApi.webhooks.base') 23 | 24 | 25 | class BaseWebhookView(web.View, Generic[Event]): 26 | """ 27 | Generic webhook view for processing events 28 | You can make your own view and than use it in code, just inheriting this base class 29 | 30 | """ 31 | 32 | def __init__( 33 | self, 34 | request: Request, 35 | dispatcher: BaseDispatcher, 36 | collision_detector: AbstractCollisionDetector[Any], 37 | event_cls: Type[Event], 38 | encryption_key: str, 39 | ) -> None: 40 | super().__init__(request) 41 | self._dispatcher = dispatcher 42 | self._collision_detector = collision_detector 43 | self._event_cls = event_cls 44 | self._encryption_key = encryption_key 45 | 46 | @abc.abstractmethod 47 | async def ok_response(self) -> web.Response: 48 | pass 49 | 50 | async def get(self) -> web.Response: 51 | return web.Response(text='') 52 | 53 | async def post(self) -> web.Response: 54 | event = await self._parse_raw_request() 55 | 56 | try: 57 | self._collision_detector.remember_processed_object(event) 58 | except UnexpectedCollision: 59 | logger.debug('Detect collision on event %s', event) 60 | return await self.ok_response() 61 | 62 | self._validate_event_signature(event) 63 | await self.process_event(event) 64 | return await self.ok_response() 65 | 66 | async def _parse_raw_request(self) -> Event: 67 | """Parse raw update and return pydantic model""" 68 | try: 69 | data = await self.request.json(loads=json.loads) 70 | if isinstance(data, str): 71 | return self._event_cls.parse_raw(data) 72 | elif isinstance(data, dict): # pragma: no cover 73 | return self._event_cls.parse_obj(data) 74 | else: 75 | raise ValueError() 76 | except ValueError: 77 | raise web.HTTPBadRequest( 78 | text=WebhookAPIError(status='Validation error').json(), 79 | content_type='application/json', 80 | ) 81 | 82 | @abc.abstractmethod 83 | def _validate_event_signature(self, update: Event) -> None: 84 | raise NotImplementedError 85 | 86 | async def process_event(self, event: Event) -> None: 87 | await self._dispatcher.process_event(event) 88 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/views/bill_view.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import cast 3 | 4 | from aiohttp import web 5 | 6 | from glQiwiApi.core.event_fetching.webhooks.views.base import BaseWebhookView 7 | from glQiwiApi.qiwi.clients.p2p.types import BillWebhook 8 | from glQiwiApi.types.exceptions import WebhookSignatureUnverifiedError 9 | 10 | logger = logging.getLogger('glQiwiApi.webhooks.p2p') 11 | 12 | 13 | class QiwiBillWebhookView(BaseWebhookView[BillWebhook]): 14 | """View, which processes p2p notifications""" 15 | 16 | def _validate_event_signature(self, update: BillWebhook) -> None: 17 | sha256_signature = cast( 18 | str, self.request.headers.get('X-Api-Signature-SHA256') 19 | ) # pragma: no cover 20 | 21 | try: # pragma: no cover 22 | update.verify_signature(sha256_signature, self._encryption_key) # pragma: no cover 23 | except WebhookSignatureUnverifiedError: # pragma: no cover 24 | logger.debug( 25 | 'Blocking request due to invalid signature of payload.' 26 | ) # pragma: no cover 27 | raise web.HTTPBadRequest() # pragma: no cover 28 | 29 | async def ok_response(self) -> web.Response: 30 | return web.json_response(data={'error': '0'}) 31 | -------------------------------------------------------------------------------- /glQiwiApi/core/event_fetching/webhooks/views/transaction_view.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiohttp import web 4 | 5 | from glQiwiApi.core.event_fetching.webhooks.dto import WebhookAPIError 6 | from glQiwiApi.core.event_fetching.webhooks.views.base import BaseWebhookView 7 | from glQiwiApi.qiwi.clients.wallet.types.webhooks import TransactionWebhook 8 | from glQiwiApi.types.exceptions import WebhookSignatureUnverifiedError 9 | 10 | logger = logging.getLogger('glQiwiApi.webhooks.transaction') 11 | 12 | 13 | class QiwiTransactionWebhookView(BaseWebhookView[TransactionWebhook]): 14 | def _validate_event_signature(self, update: TransactionWebhook) -> None: 15 | if update.is_experimental: # pragma: no cover 16 | return None 17 | 18 | logger.debug('Current encryption key is %s', self._encryption_key) 19 | 20 | try: 21 | update.verify_signature(self._encryption_key) 22 | except WebhookSignatureUnverifiedError: 23 | logger.debug( 24 | 'Request has being blocked due to invalid signature of json request payload.' 25 | ) 26 | raise web.HTTPBadRequest( 27 | text=WebhookAPIError(status='Invalid hash of transaction.').json(), 28 | content_type='application/json', 29 | ) 30 | 31 | async def ok_response(self) -> web.Response: 32 | return web.Response(text='ok') 33 | -------------------------------------------------------------------------------- /glQiwiApi/core/session/__init__.py: -------------------------------------------------------------------------------- 1 | from .holder import AbstractSessionHolder, AiohttpSessionHolder 2 | 3 | __all__ = ('AbstractSessionHolder', 'AiohttpSessionHolder') 4 | -------------------------------------------------------------------------------- /glQiwiApi/ext/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/ext/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/ext/webhook_url.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from collections import namedtuple 5 | from typing import Any, Optional, Pattern, Type, TypeVar, cast 6 | 7 | from glQiwiApi.core.event_fetching.webhooks.config import DEFAULT_QIWI_WEBHOOK_PATH 8 | 9 | _URL = TypeVar('_URL', bound='WebhookURL') 10 | 11 | # Url without port e.g. https://127.0.0.1/ or https://website.com/ 12 | HOST_REGEX: Pattern[str] = re.compile( 13 | r'^(http(s?)://)?' 14 | r'(((www\.)?[a-zA-Z0-9.\-_]+' 15 | r'(\.[a-zA-Z]{2,6})+)|' 16 | r'(\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.)' 17 | r'{3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b))' 18 | r'(/[a-zA-Z0-9_\-\s./?%#&=]*)?$' 19 | ) 20 | 21 | 22 | class WebhookURL( 23 | namedtuple( 24 | 'WebhookURL', 25 | ['host', 'webhook_path', 'port', 'https'], 26 | ) 27 | ): 28 | host: str 29 | webhook_path: Optional[str] 30 | port: Optional[int] 31 | https: bool = False 32 | 33 | @classmethod 34 | def create( 35 | cls: Type[_URL], 36 | host: str, 37 | port: Optional[int] = None, 38 | webhook_path: Optional[str] = None, 39 | https: bool = False, 40 | ) -> _URL: 41 | return cls( 42 | host=cls._assert_host(host, param_name='host'), 43 | webhook_path=cls._assert_str(webhook_path, param_name='webhook_path'), 44 | port=cls._assert_int(port, param_name='port'), 45 | https=https, 46 | ) 47 | 48 | @classmethod 49 | def _assert_int(cls, v: Optional[Any], *, param_name: str) -> Optional[int]: 50 | if v is None: 51 | return v 52 | 53 | if not isinstance(v, int): 54 | raise TypeError('%s must be integer' % param_name) 55 | return v 56 | 57 | @classmethod 58 | def _assert_str(cls, v: Optional[Any], *, param_name: str) -> Optional[str]: 59 | if v is None: 60 | return v 61 | 62 | if not isinstance(v, str): 63 | raise TypeError('%s must be a string' % param_name) 64 | return v 65 | 66 | @classmethod 67 | def _assert_host(cls, v: Any, *, param_name: str) -> str: 68 | if not re.match(HOST_REGEX, v): 69 | raise TypeError( 70 | '%s must be like https://127.0.0.1/ or https://website.com/' % param_name 71 | ) 72 | return cast(str, v) 73 | 74 | def render(self) -> str: 75 | host = self.host 76 | if self.webhook_path is None: 77 | # Here we use `DEFAULT_QIWI_WEBHOOK_PATH` instead of DEFAULT_QIWI_BILLS_WEBHOOK_PATH 78 | # because the second you need to register directly in QIWI P2P API and it's no need to build endpoint to it 79 | self.webhook_path = DEFAULT_QIWI_WEBHOOK_PATH 80 | if self.port is not None: 81 | host += f':{self.port}' 82 | if self._doesnt_contains_slash(): 83 | host += '/' 84 | if self.https: 85 | scheme = 'https://' 86 | else: 87 | scheme = 'http://' 88 | return f'{scheme}{host}{self.webhook_path}' 89 | 90 | def _doesnt_contains_slash(self) -> bool: 91 | return not (self.host.endswith('/') and self.webhook_path.startswith('/')) # type: ignore 92 | -------------------------------------------------------------------------------- /glQiwiApi/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/py.typed -------------------------------------------------------------------------------- /glQiwiApi/qiwi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/qiwi/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/qiwi/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import types 3 | from http import HTTPStatus 4 | from json import JSONDecodeError 5 | from typing import Any, ClassVar, Generic, Sequence, TypeVar, cast 6 | 7 | from glQiwiApi.core.abc.api_method import APIMethod, ReturningType, _sentinel 8 | from glQiwiApi.core.session.holder import HTTPResponse 9 | from glQiwiApi.qiwi.exceptions import QiwiAPIError 10 | 11 | try: 12 | from orjson import JSONDecodeError as OrjsonDecodeError 13 | except ImportError: 14 | from json import JSONDecodeError as OrjsonDecodeError 15 | 16 | T = TypeVar('T', bound=Any) 17 | 18 | 19 | class QiwiAPIMethod(APIMethod[T], abc.ABC, Generic[T]): 20 | arbitrary_allowed_response_status_codes: ClassVar[Sequence[int]] = () 21 | 22 | @classmethod 23 | def parse_http_response(cls, response: HTTPResponse) -> ReturningType: 24 | response_is_successful = cls.check_if_response_status_success(response) 25 | 26 | try: 27 | json_response = response.json() 28 | except (JSONDecodeError, TypeError, OrjsonDecodeError): 29 | response_is_successful = False 30 | 31 | if not response_is_successful: 32 | QiwiAPIError(response).raise_exception_matching_error_code() 33 | 34 | # micro optimization that helps to avoid json re-deserialization 35 | response.json = types.MethodType(lambda self: json_response, response) # type: ignore 36 | 37 | manually_parsed_json = cls.on_json_parse(response) 38 | if manually_parsed_json is not _sentinel: 39 | return cast(ReturningType, manually_parsed_json) 40 | 41 | return super().parse_http_response(response) 42 | 43 | @classmethod 44 | def check_if_response_status_success(cls, response: HTTPResponse) -> bool: 45 | if response.status_code == HTTPStatus.OK: 46 | return True 47 | elif response.status_code in cls.arbitrary_allowed_response_status_codes: 48 | return True 49 | return False 50 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/qiwi/clients/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/maps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/qiwi/clients/maps/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/maps/methods/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/qiwi/clients/maps/methods/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/maps/methods/get_partners.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, List 2 | 3 | from glQiwiApi.qiwi.base import QiwiAPIMethod 4 | from glQiwiApi.qiwi.clients.wallet.types import Partner 5 | 6 | 7 | class GetPartners(QiwiAPIMethod[List[Partner]]): 8 | url: ClassVar[str] = 'http://edge.qiwi.com/locator/v3/ttp-groups' 9 | http_method: ClassVar[str] = 'GET' 10 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/maps/methods/get_terminals.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict, List, Optional 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import Request 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | from glQiwiApi.qiwi.clients.maps.types.polygon import Polygon 8 | from glQiwiApi.qiwi.clients.maps.types.terminal import Terminal 9 | 10 | 11 | class GetTerminals(QiwiAPIMethod[List[Terminal]]): 12 | url: ClassVar[str] = 'http://edge.qiwi.com/locator/v3/nearest/clusters?parameters' 13 | http_method: ClassVar[str] = 'GET' 14 | 15 | polygon: Polygon 16 | pop_if_inactive_x_mins: int = Field(..., alias='activeWithinMinutes') 17 | zoom: Optional[int] = None 18 | include_partners: Optional[bool] = Field(None, alias='withRefillWallet') 19 | partners_ids: Optional[List[Any]] = Field(None, alias='ttpIds') 20 | cache_terminals: Optional[bool] = Field(None, alias='cacheAllowed') 21 | card_terminals: Optional[bool] = Field(None, alias='cardAllowed') 22 | identification_types: Optional[int] = Field(None, alias='identificationTypes') 23 | terminal_groups: Optional[List[Any]] = Field( 24 | None, 25 | alias='ttpGroups', 26 | ) 27 | 28 | def build_request(self, **url_format_kw: Any) -> 'Request': 29 | model_dict = self.dict(exclude_none=True, exclude_unset=True, by_alias=True) 30 | polygon = model_dict.pop('polygon') 31 | 32 | model_dict = _replace_bool_values_with_strings(model_dict) 33 | 34 | return Request( 35 | endpoint=self.url.format(**url_format_kw, **self._get_runtime_path_values()), 36 | http_method=self.http_method, 37 | params={**model_dict, **polygon}, 38 | ) 39 | 40 | 41 | def _replace_bool_values_with_strings(d: Dict[str, Any]) -> Dict[str, Any]: 42 | for k, v in d.items(): 43 | if not isinstance(v, bool): 44 | continue 45 | 46 | d[k] = str(v) 47 | return d 48 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/maps/types/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/qiwi/clients/maps/types/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/maps/types/polygon.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class Polygon(BaseModel): 5 | """Polygon class for QiwiMaps class""" 6 | 7 | latitude_north_western: float = Field(..., alias='latNW') 8 | longitude_north_western: float = Field(..., alias='lngNW') 9 | latitude_south_east: float = Field(..., alias='latSE') 10 | longitude_south_east: float = Field(..., alias='lngSE') 11 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/maps/types/terminal.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.types.base import Base 6 | 7 | 8 | class Coordinate(Base): 9 | """Object: coordinate""" 10 | 11 | latitude: float = Field(..., alias='latitude') 12 | longitude: float = Field(..., alias='longitude') 13 | precision: int = Field(..., alias='precision') 14 | 15 | 16 | class Terminal(Base): 17 | """Object: Terminal""" 18 | 19 | terminal_id: int = Field(..., alias='terminalId') 20 | ttp_id: int = Field(..., alias='ttpId') 21 | last_active: str = Field(..., alias='lastActive') 22 | count: int = Field(..., alias='count') 23 | address: str = Field(..., alias='address') 24 | verified: bool = Field(..., alias='verified') 25 | label: str = Field(..., alias='label') 26 | description: Optional[str] = Field(type(None), alias='description') 27 | cash_allowed: bool = Field(..., alias='cashAllowed') 28 | card_allowed: bool = Field(..., alias='cardAllowed') 29 | identification_type: int = Field(..., alias='identificationType') 30 | coordinate: Coordinate = Field(..., alias='coordinate') 31 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/p2p/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/qiwi/clients/p2p/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/p2p/methods/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/qiwi/clients/p2p/methods/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/p2p/methods/create_p2p_bill.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime, timedelta 3 | from typing import Any, ClassVar, Dict, List, Optional, Union 4 | 5 | from pydantic import Field, validator 6 | 7 | from glQiwiApi.core.abc.api_method import Request, RuntimeValue 8 | from glQiwiApi.qiwi.base import QiwiAPIMethod 9 | from glQiwiApi.qiwi.clients.p2p.types import Bill, Customer 10 | from glQiwiApi.utils.date_conversion import datetime_to_iso8601_with_moscow_timezone 11 | 12 | 13 | def get_default_bill_life_time() -> datetime: 14 | return datetime.now() + timedelta(days=2) 15 | 16 | 17 | class CreateP2PBill(QiwiAPIMethod[Bill]): 18 | http_method: ClassVar[str] = 'PUT' 19 | url: ClassVar[str] = 'https://api.qiwi.com/partner/bill/v1/bills/{bill_id}' 20 | 21 | json_payload_schema: ClassVar[Dict[str, Any]] = { 22 | 'amount': {'currency': 'RUB', 'value': RuntimeValue()}, 23 | 'expirationDateTime': RuntimeValue(default_factory=get_default_bill_life_time), 24 | 'comment': RuntimeValue(mandatory=False), 25 | 'customFields': { 26 | 'paySourcesFilter': RuntimeValue(default=['qw', 'card']), 27 | 'themeCode': RuntimeValue(default='Yvan-YKaSh'), 28 | }, 29 | 'customer': RuntimeValue(mandatory=False), 30 | } 31 | 32 | bill_id: Optional[str] = Field(None, path_runtime_value=True) 33 | expire_at: Optional[datetime] = Field( 34 | scheme_path='expirationDateTime', 35 | ) 36 | amount: float = Field(..., scheme_path='amount.value') 37 | comment: Optional[str] = Field(None, scheme_path='comment') 38 | theme_code: Optional[str] = Field(None, scheme_path='customFields.themeCode') 39 | pay_source_filter: Optional[List[str]] = Field( 40 | None, scheme_path='customFields.paySourcesFilter' 41 | ) 42 | customer: Optional[Customer] = Field(None, schema_path='customer') 43 | 44 | @validator('bill_id') 45 | def set_bill_id_if_it_is_none(cls, v: Optional[str]) -> str: 46 | return v or str(uuid.uuid4()) 47 | 48 | def build_request(self, **url_format_kw: Any) -> 'Request': 49 | request = super().build_request(**url_format_kw) 50 | expire_at = request.json_payload['expirationDateTime'] # type: ignore 51 | request.json_payload['expirationDateTime'] = datetime_to_iso8601_with_moscow_timezone( 52 | expire_at 53 | ) 54 | pay_source_filter = request.json_payload['customFields']['paySourcesFilter'] 55 | if isinstance(pay_source_filter, list): 56 | request.json_payload['customFields']['paySourcesFilter'] = ' '.join( 57 | pay_source_filter 58 | ).replace(' ', ',') 59 | return request 60 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/p2p/methods/create_p2p_key_pair.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict, Optional 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import RuntimeValue 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | from glQiwiApi.qiwi.clients.p2p.types import PairOfP2PKeys 8 | 9 | 10 | class CreateP2PKeyPair(QiwiAPIMethod[PairOfP2PKeys]): 11 | http_method: ClassVar[str] = 'POST' 12 | url: ClassVar[ 13 | str 14 | ] = 'https://api.qiwi.com/partner/bill/v1/bills/widgets-api/api/p2p/protected/keys/create' 15 | 16 | json_payload_schema: ClassVar[Dict[str, Any]] = { 17 | 'keysPairName': RuntimeValue(), 18 | 'serverNotificationsUrl': RuntimeValue(), 19 | } 20 | 21 | key_pair_name: str = Field(..., scheme_path='keysPairName') 22 | server_notification_url: Optional[str] = Field(..., scheme_path='serverNotificationsUrl') 23 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/p2p/methods/get_bill_by_id.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.qiwi.base import QiwiAPIMethod 6 | from glQiwiApi.qiwi.clients.p2p.types import Bill 7 | 8 | 9 | class GetBillByID(QiwiAPIMethod[Bill]): 10 | http_method: ClassVar[str] = 'GET' 11 | url: ClassVar[str] = 'https://api.qiwi.com/partner/bill/v1/bills/{bill_id}' 12 | 13 | bill_id: str = Field(..., path_runtime_value=True) 14 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/p2p/methods/refund_bill.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict, Union 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import Request 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | from glQiwiApi.qiwi.clients.p2p.types import RefundedBill 8 | from glQiwiApi.types.amount import PlainAmount 9 | 10 | 11 | class RefundBill(QiwiAPIMethod[RefundedBill]): 12 | http_method: ClassVar[str] = 'POST' 13 | url: ClassVar[str] = 'https://api.qiwi.com/partner/bill/v1/bills/{bill_id}/refunds/{refund_id}' 14 | 15 | bill_id: str = Field(..., path_runtime_value=True) 16 | refund_id: str = Field(..., path_runtime_value=True) 17 | 18 | json_bill_data: Union[PlainAmount, Dict[str, Union[str, int]]] 19 | 20 | def build_request(self, **url_format_kw: Any) -> 'Request': 21 | json_payload = self.json_bill_data 22 | if isinstance(self.json_bill_data, PlainAmount): 23 | json_payload = self.json_bill_data.json(encoder=self.Config.json_dumps) # type: ignore 24 | 25 | return Request( 26 | endpoint=self.url.format(**url_format_kw, **self._get_runtime_path_values()), 27 | json_payload=json_payload, 28 | http_method=self.http_method, 29 | ) 30 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/p2p/methods/reject_p2p_bill.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.qiwi.base import QiwiAPIMethod 6 | from glQiwiApi.qiwi.clients.p2p.types import Bill 7 | 8 | 9 | class RejectP2PBill(QiwiAPIMethod[Bill]): 10 | http_method: ClassVar[str] = 'POST' 11 | url: ClassVar[str] = 'https://api.qiwi.com/partner/bill/v1/bills/{bill_id}/reject' 12 | 13 | bill_id: str = Field(..., path_runtime_value=True) 14 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/qiwi/clients/wallet/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/qiwi/clients/wallet/methods/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/authenticate_wallet.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict, Optional 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.qiwi.base import QiwiAPIMethod 6 | 7 | 8 | class AuthenticateWallet(QiwiAPIMethod[Dict[str, Any]]): 9 | url: ClassVar[ 10 | str 11 | ] = 'https://edge.qiwi.com/identification/v1/persons/{phone_number}/identification' 12 | http_method: ClassVar[str] = 'POST' 13 | 14 | passport: str 15 | birth_date: str = Field(..., alias='birthDate') 16 | first_name: str = Field(..., alias='firstName') 17 | middle_name: str = Field(..., alias='middleName') 18 | last_name: str = Field(..., alias='lastName') 19 | inn: str = '' 20 | snils: str = '' 21 | oms: str = '' 22 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/check_restriction.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, List 2 | 3 | from glQiwiApi.qiwi.base import QiwiAPIMethod 4 | from glQiwiApi.qiwi.clients.wallet.types.restriction import Restriction 5 | 6 | 7 | class GetRestrictions(QiwiAPIMethod[List[Restriction]]): 8 | url: ClassVar[ 9 | str 10 | ] = 'https://edge.qiwi.com/person-profile/v1/persons/{phone_number}/status/restrictions' 11 | http_method: ClassVar[str] = 'GET' 12 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/create_new_balance.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import Any, ClassVar, Dict, Sequence 3 | 4 | from pydantic import Field 5 | 6 | from glQiwiApi.core.abc.api_method import Request 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | 9 | 10 | class CreateNewBalance(QiwiAPIMethod[Dict[str, Any]]): 11 | url: ClassVar[str] = 'https://edge.qiwi.com/funding-sources/v2/persons/{phone_number}/accounts' 12 | http_method: ClassVar[str] = 'POST' 13 | arbitrary_allowed_response_status_codes: ClassVar[Sequence[int]] = [HTTPStatus.CREATED] 14 | 15 | currency_alias: str = Field(..., alias='alias') 16 | 17 | def build_request(self, **url_format_kw: Any) -> 'Request': 18 | return Request( 19 | endpoint=self.url.format(**url_format_kw, **self._get_runtime_path_values()), 20 | http_method=self.http_method, 21 | json_payload={'alias': self.currency_alias}, 22 | ) 23 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/detect_mobile_number.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, cast 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import ReturningType 6 | from glQiwiApi.core.session.holder import HTTPResponse 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | from glQiwiApi.qiwi.clients.wallet.types.mobile_operator import MobileOperator 9 | from glQiwiApi.qiwi.exceptions import MobileOperatorCannotBeDeterminedError 10 | 11 | 12 | class DetectMobileNumber(QiwiAPIMethod[MobileOperator]): 13 | url: ClassVar[str] = 'https://qiwi.com/mobile/detect.action' 14 | http_method: ClassVar[str] = 'POST' 15 | 16 | phone_number: str = Field(..., alias='phone') 17 | 18 | @classmethod 19 | def parse_http_response(cls, response: HTTPResponse) -> ReturningType: 20 | mobile_operator: MobileOperator = super().parse_http_response(response) 21 | if mobile_operator.code.value == '2' or mobile_operator.code.name == 'ERROR': 22 | raise MobileOperatorCannotBeDeterminedError( 23 | response, custom_message=mobile_operator.message 24 | ) 25 | 26 | return cast(ReturningType, mobile_operator) 27 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/fetch_statistics.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Any, ClassVar, Dict, List, Optional 3 | 4 | from pydantic import Field, root_validator 5 | 6 | from glQiwiApi.core.abc.api_method import Request 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | from glQiwiApi.qiwi.clients.wallet.types import Statistic, TransactionType 9 | from glQiwiApi.utils.date_conversion import datetime_to_iso8601_with_moscow_timezone 10 | 11 | 12 | class FetchStatistics(QiwiAPIMethod[Statistic]): 13 | url: ClassVar[ 14 | str 15 | ] = 'https://edge.qiwi.com/payment-history/v2/persons/{phone_number}/payments/total' 16 | http_method: ClassVar[str] = 'GET' 17 | 18 | start_date: datetime = Field(default_factory=datetime.now) 19 | end_date: datetime = Field(default_factory=lambda: datetime.now() - timedelta(days=90)) 20 | operation: TransactionType = TransactionType.ALL 21 | sources: Optional[List[str]] = None 22 | 23 | @root_validator() 24 | def check_start_date_and_end_date_difference_not_greater_than_90_days( 25 | cls, values: Dict[str, Any] 26 | ) -> Dict[str, Any]: 27 | start_date: Optional[datetime] = values.get('start_date') 28 | end_date: Optional[datetime] = values.get('end_date') 29 | 30 | if start_date is None or end_date is None: # denotes that type of arguments is invalid 31 | return values 32 | 33 | if (end_date - start_date).days > 90 or (start_date - end_date).days > 90: 34 | raise ValueError('The maximum period for downloading statistics is 90 calendar days.') 35 | 36 | return values 37 | 38 | def build_request(self, **url_format_kw: Any) -> Request: 39 | params = { 40 | 'startDate': datetime_to_iso8601_with_moscow_timezone(self.start_date), 41 | 'endDate': datetime_to_iso8601_with_moscow_timezone(self.end_date), 42 | 'operation': self.operation.value, 43 | } 44 | if self.sources: 45 | params.update({'sources': ' '.join(self.sources)}) 46 | return Request( 47 | endpoint=self.url.format(**url_format_kw), params=params, http_method=self.http_method 48 | ) 49 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/get_account_info.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import Request 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | from glQiwiApi.qiwi.clients.wallet.types import UserProfile 8 | 9 | 10 | class GetUserProfile(QiwiAPIMethod[UserProfile]): 11 | url: ClassVar[str] = 'https://edge.qiwi.com/person-profile/v1/profile/current' 12 | http_method: ClassVar[str] = 'GET' 13 | 14 | include_auth_info: bool = Field(True, alias='authInfoEnabled') 15 | include_contract_info: bool = Field(True, alias='contractInfoEnabled') 16 | include_user_info: bool = Field(True, alias='userInfoEnabled') 17 | 18 | def build_request(self, **url_format_kw: Any) -> 'Request': 19 | return Request( 20 | endpoint=self.url.format(**url_format_kw, **self._get_runtime_path_values()), 21 | http_method=self.http_method, 22 | params={ 23 | 'authInfoEnabled': str(self.include_auth_info), 24 | 'contractInfoEnabled': str(self.include_contract_info), 25 | 'userInfoEnabled': str(self.include_user_info), 26 | }, 27 | ) 28 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/get_available_balances.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, List 2 | 3 | from glQiwiApi.qiwi.base import QiwiAPIMethod 4 | from glQiwiApi.qiwi.clients.wallet.types import Balance 5 | from glQiwiApi.qiwi.clients.wallet.types.balance import AvailableBalance 6 | 7 | 8 | class GetAvailableBalances(QiwiAPIMethod[List[AvailableBalance]]): 9 | http_method: ClassVar[str] = 'GET' 10 | url: ClassVar[ 11 | str 12 | ] = 'https://edge.qiwi.com/funding-sources/v2/persons/{phone_number}/accounts/offer' 13 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/get_balances.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, List 2 | 3 | from pydantic import parse_obj_as 4 | 5 | from glQiwiApi.core.abc.api_method import ReturningType 6 | from glQiwiApi.core.session.holder import HTTPResponse 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | from glQiwiApi.qiwi.clients.wallet.types import Balance 9 | 10 | 11 | class GetBalances(QiwiAPIMethod[List[Balance]]): 12 | http_method: ClassVar[str] = 'GET' 13 | url: ClassVar[str] = 'https://edge.qiwi.com/funding-sources/v2/persons/{phone_number}/accounts' 14 | 15 | @classmethod 16 | def on_json_parse(cls, response: HTTPResponse) -> List[Balance]: 17 | return parse_obj_as(List[Balance], response.json()['accounts']) 18 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/get_cards.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, List, Optional 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.qiwi.base import QiwiAPIMethod 6 | from glQiwiApi.qiwi.clients.wallet.types import Card 7 | 8 | 9 | class GetBoundedCards(QiwiAPIMethod[List[Card]]): 10 | http_method: ClassVar[str] = 'GET' 11 | url: ClassVar[str] = 'https://edge.qiwi.com/cards/v1/cards' 12 | 13 | card_alias: Optional[str] = Field(None, alias='vas-alias') 14 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/get_cross_rates.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, List 2 | 3 | from pydantic import parse_obj_as 4 | 5 | from glQiwiApi.core.abc.api_method import ReturningType 6 | from glQiwiApi.core.session.holder import HTTPResponse 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | from glQiwiApi.qiwi.clients.wallet.types import CrossRate 9 | 10 | 11 | class GetCrossRates(QiwiAPIMethod[List[CrossRate]]): 12 | url: ClassVar[str] = 'https://edge.qiwi.com/sinap/crossRates' 13 | http_method: ClassVar[str] = 'GET' 14 | 15 | @classmethod 16 | def on_json_parse(cls, response: HTTPResponse) -> List[CrossRate]: 17 | return parse_obj_as(List[CrossRate], response.json()['result']) 18 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/get_identification.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from glQiwiApi.qiwi.base import QiwiAPIMethod 4 | from glQiwiApi.qiwi.clients.wallet.types import Identification 5 | 6 | 7 | class GetIdentification(QiwiAPIMethod[Identification]): 8 | http_method: ClassVar[str] = 'GET' 9 | url: ClassVar[ 10 | str 11 | ] = 'https://edge.qiwi.com/identification/v1/persons/{phone_number}/identification' 12 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/get_limits.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict, List, Sequence 2 | 3 | from glQiwiApi.core.abc.api_method import Request, ReturningType 4 | from glQiwiApi.core.session.holder import HTTPResponse 5 | from glQiwiApi.qiwi.base import QiwiAPIMethod 6 | from glQiwiApi.qiwi.clients.wallet.types import Limit 7 | 8 | ALL_LIMIT_TYPES = [ 9 | 'TURNOVER', 10 | 'REFILL', 11 | 'PAYMENTS_P2P', 12 | 'PAYMENTS_PROVIDER_INTERNATIONALS', 13 | 'PAYMENTS_PROVIDER_PAYOUT', 14 | 'WITHDRAW_CASH', 15 | ] 16 | 17 | 18 | class GetLimits(QiwiAPIMethod[Dict[str, Limit]]): 19 | http_method: ClassVar[str] = 'GET' 20 | url: ClassVar[str] = 'https://edge.qiwi.com/qw-limits/v1/persons/{phone_number}/actual-limits' 21 | 22 | limit_types: Sequence[str] = ALL_LIMIT_TYPES 23 | 24 | @classmethod 25 | def parse_http_response(cls, response: HTTPResponse) -> Dict[str, Limit]: 26 | return { 27 | code: [Limit.parse_obj(limit) for limit in limits] 28 | for code, limits in response.json()['limits'].items() 29 | } 30 | 31 | def build_request(self, **url_format_kw: Any) -> Request: 32 | return Request( 33 | endpoint=self.url.format(**url_format_kw), 34 | params={ 35 | f'types[{index}]': limit_type for index, limit_type in enumerate(self.limit_types) 36 | }, 37 | http_method=self.http_method, 38 | ) 39 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/get_nickname.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from glQiwiApi.qiwi.base import QiwiAPIMethod 4 | from glQiwiApi.qiwi.clients.wallet.types.nickname import NickName 5 | 6 | 7 | class GetNickName(QiwiAPIMethod[NickName]): 8 | url: ClassVar[str] = 'https://edge.qiwi.com/qw-nicknames/v1/persons/{phone_number}/nickname' 9 | http_method: ClassVar[str] = 'GET' 10 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/get_receipt.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Union 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.session.holder import HTTPResponse 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | from glQiwiApi.qiwi.clients.wallet.types import TransactionType 8 | from glQiwiApi.types.arbitrary import BinaryIOInput, File 9 | 10 | 11 | class GetReceipt(QiwiAPIMethod[File]): 12 | url: ClassVar[ 13 | str 14 | ] = 'https://edge.qiwi.com/payment-history/v1/transactions/{transaction_id}/cheque/file' 15 | http_method: ClassVar[str] = 'GET' 16 | 17 | transaction_id: Union[str, int] = Field(..., path_runtime_value=True) 18 | transaction_type: TransactionType = Field(..., alias='type') 19 | file_format: str = Field(alias='format', default='PDF') 20 | 21 | class Config: 22 | use_enum_values = True 23 | 24 | @classmethod 25 | def parse_http_response(cls, response: HTTPResponse) -> File: # type: ignore 26 | return File(BinaryIOInput.from_bytes(response.body)) 27 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/list_of_invoices.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, List 2 | 3 | from pydantic import conint, parse_obj_as 4 | 5 | from glQiwiApi.core.session.holder import HTTPResponse 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | from glQiwiApi.qiwi.clients.p2p.types import Bill 8 | 9 | MAX_INVOICES_LIMIT = 50 10 | 11 | 12 | class GetListOfInvoices(QiwiAPIMethod[List[Bill]]): 13 | url: ClassVar[str] = 'https://edge.qiwi.com/checkout-api/api/bill/search' 14 | http_method: ClassVar[str] = 'GET' 15 | 16 | rows: conint(le=MAX_INVOICES_LIMIT, strict=True, gt=0) = MAX_INVOICES_LIMIT 17 | statuses: str = 'READY_FOR_PAY' 18 | 19 | @classmethod 20 | def on_json_parse(cls, response: HTTPResponse) -> List[Bill]: 21 | return parse_obj_as(List[Bill], response.json()['bills']) 22 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/pay_invoice.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import RuntimeValue 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | from glQiwiApi.qiwi.clients.p2p.types import InvoiceStatus 8 | 9 | 10 | class PayInvoice(QiwiAPIMethod[InvoiceStatus]): 11 | url: ClassVar[str] = 'https://edge.qiwi.com/checkout-api/invoice/pay/wallet' 12 | http_method: ClassVar[str] = 'POST' 13 | 14 | json_payload_schema: ClassVar[Dict[str, Any]] = { 15 | 'invoice_uid': RuntimeValue(), 16 | 'currency': RuntimeValue(), 17 | } 18 | 19 | invoice_uid: str = Field(..., schema_path='invoice_uid') 20 | currency: str = Field(..., schema_path='currency') 21 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/payment_by_details.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Any, ClassVar, Dict, Optional 3 | 4 | from pydantic import Field 5 | 6 | from glQiwiApi.core.abc.api_method import RuntimeValue 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | from glQiwiApi.qiwi.clients.wallet.types import PaymentDetails, PaymentInfo, PaymentMethod 9 | from glQiwiApi.types.amount import AmountWithCurrency 10 | 11 | 12 | class MakePaymentByDetails(QiwiAPIMethod[PaymentInfo]): 13 | url: ClassVar[str] = 'https://edge.qiwi.com/sinap/api/v2/terms/1717/payments' 14 | http_method: ClassVar[str] = 'POST' 15 | 16 | json_payload_schema: ClassVar[Dict[str, Any]] = { 17 | 'id': RuntimeValue(default_factory=lambda: str(uuid.uuid4())), 18 | 'sum': RuntimeValue(), 19 | 'paymentMethod': RuntimeValue(), 20 | 'fields': RuntimeValue(), 21 | } 22 | 23 | payment_sum: AmountWithCurrency = Field(..., scheme_path='sum') 24 | payment_method: PaymentMethod = Field(..., scheme_path='paymentMethod') 25 | details: PaymentDetails = Field(..., scheme_path='fields') 26 | payment_id: Optional[str] = Field(None, scheme_path='id') 27 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/predict_comission.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict, Optional, Union 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import RuntimeValue 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | from glQiwiApi.qiwi.clients.wallet.types import Commission 8 | 9 | 10 | class PredictCommission(QiwiAPIMethod[Commission]): 11 | http_method: ClassVar[str] = 'POST' 12 | url: ClassVar[str] = 'https://edge.qiwi.com/sinap/providers/{private_card_id}/onlineCommission' 13 | 14 | json_payload_schema: ClassVar[Dict[str, Any]] = { 15 | 'account': RuntimeValue(), 16 | 'paymentMethod': {'type': 'Account', 'accountId': '643'}, 17 | 'purchaseTotals': {'total': {'amount': RuntimeValue(), 'currency': '643'}}, 18 | } 19 | 20 | private_card_id: str = Field(..., path_runtime_value=True) 21 | 22 | invoice_amount: Union[str, int, float] = Field(..., scheme_path='purchaseTotals.total.amount') 23 | to_account: str = Field(..., scheme_path='account') 24 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/qiwi_master/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/qiwi/clients/wallet/methods/qiwi_master/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/qiwi_master/block_card.py: -------------------------------------------------------------------------------- 1 | import http 2 | from typing import Any, ClassVar, Dict, Sequence 3 | 4 | from pydantic import Field 5 | 6 | from glQiwiApi.core.abc.api_method import ReturningType 7 | from glQiwiApi.core.session.holder import HTTPResponse 8 | from glQiwiApi.qiwi.base import QiwiAPIMethod 9 | 10 | 11 | class BlockQiwiMasterCard(QiwiAPIMethod[Dict[str, Any]]): 12 | url: ClassVar[str] = '/cards/v2/persons/{phone_number}/cards/{card_id}/block' 13 | http_method: ClassVar[str] = 'GET' 14 | 15 | arbitrary_allowed_response_status_codes: ClassVar[Sequence[int]] = (http.HTTPStatus.ACCEPTED,) 16 | 17 | card_id: str = Field(..., path_runtime_value=True) 18 | phone_number: str = Field(..., path_runtime_value=True) 19 | 20 | @classmethod 21 | def parse_http_response(cls, response: HTTPResponse) -> Dict[str, Any]: 22 | return {} 23 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/qiwi_master/buy_qiwi_card.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, ClassVar, Dict 3 | 4 | from pydantic import Field 5 | 6 | from glQiwiApi.core.abc.api_method import RuntimeValue 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | from glQiwiApi.qiwi.clients.wallet.types.qiwi_master import OrderDetails 9 | 10 | 11 | class BuyQiwiMasterCard(QiwiAPIMethod[OrderDetails]): 12 | url: ClassVar[str] = 'https://edge.qiwi.com/sinap/api/v2/terms/32064/payments' 13 | http_method: ClassVar[str] = 'POST' 14 | 15 | json_payload_schema: ClassVar[Dict[str, Any]] = { 16 | 'id': RuntimeValue(default_factory=lambda: str(int(time.time() * 1000))), 17 | 'sum': {'amount': RuntimeValue(default=99), 'currency': '643'}, 18 | 'paymentMethod': {'type': 'Account', 'accountId': '643'}, 19 | 'fields': {'account': RuntimeValue(), 'order_id': RuntimeValue()}, 20 | } 21 | 22 | phone_number: str = Field(..., schema_path='fields.account') 23 | order_id: str = Field(..., schema_path='fields.order_id') 24 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/qiwi_master/buy_qiwi_master.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, ClassVar, Dict 3 | 4 | from pydantic import Field 5 | 6 | from glQiwiApi.core.abc.api_method import RuntimeValue 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | from glQiwiApi.qiwi.clients.wallet.types.payment_info import PaymentInfo 9 | 10 | 11 | class BuyQIWIMasterPackage(QiwiAPIMethod[PaymentInfo]): 12 | url: ClassVar[str] = 'https://edge.qiwi.com/sinap/api/v2/terms/28004/payments' 13 | http_method: ClassVar[str] = 'POST' 14 | 15 | json_payload_schema: ClassVar[Dict[str, Any]] = { 16 | 'id': RuntimeValue(default_factory=lambda: str(int(time.time() * 1000))), 17 | 'sum': {'amount': RuntimeValue(default=2999), 'currency': '643'}, 18 | 'paymentMethod': {'type': 'Account', 'accountId': '643'}, 19 | 'comment': 'Оплата', 20 | 'fields': {'account': RuntimeValue(), 'vas_alias': 'qvc-master'}, 21 | } 22 | 23 | phone_number: str = Field(..., scheme_path='fields.account') 24 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/qiwi_master/confirm_qiwi_master.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.qiwi.base import QiwiAPIMethod 6 | from glQiwiApi.qiwi.clients.wallet.types.qiwi_master import OrderDetails 7 | 8 | 9 | class ConfirmQiwiMasterPurchaseOrder(QiwiAPIMethod[OrderDetails]): 10 | url: ClassVar[ 11 | str 12 | ] = 'https://edge.qiwi.com/cards/v2/persons/{phone_number}/orders/{order_id}/submit' 13 | http_method: ClassVar[str] = 'PUT' 14 | 15 | order_id: str = Field(..., path_runtime_value=True) 16 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/qiwi_master/create_card_purchase_order.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import RuntimeValue 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | from glQiwiApi.qiwi.clients.wallet.types import OrderDetails 8 | 9 | 10 | class CreateCardPurchaseOrder(QiwiAPIMethod[OrderDetails]): 11 | url: ClassVar[str] = 'https://edge.qiwi.com/cards/v2/persons/{phone_number}/orders' 12 | http_method: ClassVar[str] = 'POST' 13 | 14 | json_payload_schema: ClassVar[Dict[str, Any]] = {'cardAlias': RuntimeValue()} 15 | 16 | card_alias: str = Field(..., schema_path='cardAlias') 17 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/qiwi_master/get_card_requisites.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import ClassVar, Optional 3 | 4 | from pydantic import Field 5 | 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | from glQiwiApi.qiwi.clients.wallet.types.qiwi_master import QiwiMasterCardRequisites 8 | 9 | 10 | class GetQiwiMasterCardRequisites(QiwiAPIMethod[QiwiMasterCardRequisites]): 11 | http_method: ClassVar[str] = 'PUT' 12 | url: ClassVar[str] = 'https://edge.qiwi.com/cards/v1/cards/{card_id}/details' 13 | 14 | card_id: str = Field(..., path_runtime_value=True) 15 | operation_id: Optional[str] = Field( 16 | default_factory=lambda: str(uuid.uuid4()), alias='operationId' 17 | ) 18 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/qiwi_master/get_statement.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import ClassVar 3 | 4 | from pydantic import Field 5 | 6 | from glQiwiApi.core.session.holder import HTTPResponse 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | from glQiwiApi.types.arbitrary import BinaryIOInput, File 9 | 10 | 11 | class GetQiwiMasterStatement(QiwiAPIMethod[File]): 12 | url: ClassVar[ 13 | str 14 | ] = 'https://edge.qiwi.com/payment-history/v1/persons/{phone_number}/cards/{card_id}/statement' 15 | http_method: ClassVar[str] = 'GET' 16 | 17 | card_id: str = Field(..., path_runtime_value=True) 18 | from_date: datetime = Field(..., alias='from') 19 | till_date: datetime = Field(..., alias='till') 20 | 21 | @classmethod 22 | def parse_http_response(cls, response: HTTPResponse) -> File: # type: ignore 23 | return File(BinaryIOInput.from_bytes(response.body)) 24 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/qiwi_master/rename_card.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import RuntimeValue 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | 8 | 9 | class RenameQiwiMasterCard(QiwiAPIMethod[Dict[str, Any]]): 10 | url: ClassVar[str] = 'https://edge.qiwi.com/cards/v1/cards/{card_id}>/alias' 11 | http_method: ClassVar[str] = 'PUT' 12 | 13 | json_payload_schema = {'alias': RuntimeValue()} 14 | 15 | card_id: str = Field(..., path_runtime_value=True) 16 | alias: str 17 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/qiwi_master/unblock_card.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.qiwi.base import QiwiAPIMethod 6 | 7 | 8 | class UnblockQiwiMasterCard(QiwiAPIMethod[Dict[str, Any]]): 9 | url: ClassVar[ 10 | str 11 | ] = 'https://edge.qiwi.com/cards/v2/persons/{phone_number}/cards/{card_id}/unblock' 12 | http_method: ClassVar[str] = 'GET' 13 | 14 | card_id: str = Field(..., path_runtime_value=True) 15 | phone_number: str = Field(..., path_runtime_value=True) 16 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/reveal_card_id.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import Request 6 | from glQiwiApi.core.session.holder import HTTPResponse 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | 9 | try: 10 | from orjson import JSONDecodeError as OrjsonDecodeError 11 | except ImportError: 12 | from json import JSONDecodeError as OrjsonDecodeError # type: ignore 13 | 14 | 15 | class RevealCardID(QiwiAPIMethod[str]): 16 | http_method: ClassVar[str] = 'POST' 17 | url: ClassVar[str] = 'https://qiwi.com/card/detect.action' 18 | 19 | card_number: str = Field(..., alias='cardNumber') 20 | 21 | @classmethod 22 | def on_json_parse(cls, response: HTTPResponse) -> str: 23 | return response.json()['message'] 24 | 25 | def build_request(self, **url_format_kw: Any) -> 'Request': 26 | r = super().build_request(**url_format_kw) 27 | r.headers['Content-Type'] = 'application/x-www-form-urlencoded' 28 | return r 29 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/set_default_balance.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.qiwi.base import QiwiAPIMethod 6 | 7 | 8 | class SetDefaultBalance(QiwiAPIMethod[Dict[Any, Any]]): 9 | http_method: ClassVar[str] = 'PATCH' 10 | url: ClassVar[ 11 | str 12 | ] = 'https://edge.qiwi.com/funding-sources/v2/persons/{phone_number}/accounts/{account_alias}' 13 | 14 | account_alias: str = Field(..., path_runtime_value=True) 15 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/transaction_info.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import Request 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | from glQiwiApi.qiwi.clients.wallet.types import Transaction, TransactionType 8 | 9 | 10 | class GetTransactionInfo(QiwiAPIMethod[Transaction]): 11 | http_method: ClassVar[str] = 'GET' 12 | url: ClassVar[str] = 'https://edge.qiwi.com/payment-history/v1/transactions/{transaction_id}' 13 | 14 | transaction_id: int 15 | transaction_type: TransactionType = Field(..., alias='type') 16 | 17 | def build_request(self, **url_format_kw: Any) -> 'Request': 18 | return super().build_request(**url_format_kw, transaction_id=self.transaction_id) 19 | 20 | class Config: 21 | use_enum_values = True 22 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/transfer_money.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, ClassVar, Dict, Optional 3 | 4 | from pydantic import Field, validator 5 | 6 | from glQiwiApi.core.abc.api_method import RuntimeValue 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | from glQiwiApi.qiwi.clients.wallet.types import PaymentInfo 9 | 10 | 11 | class TransferMoney(QiwiAPIMethod[PaymentInfo]): 12 | url: ClassVar[str] = 'https://edge.qiwi.com/sinap/api/v2/terms/99/payments' 13 | http_method: ClassVar[str] = 'POST' 14 | 15 | json_payload_schema: ClassVar[Dict[str, Any]] = { 16 | 'id': RuntimeValue(default_factory=lambda: str(int(time.time() * 1000))), 17 | 'sum': {'amount': RuntimeValue(), 'currency': '643'}, 18 | 'paymentMethod': {'type': 'Account', 'accountId': '643'}, 19 | 'comment': RuntimeValue(mandatory=False), 20 | 'fields': {'account': RuntimeValue()}, 21 | } 22 | 23 | @validator('to_wallet') 24 | def add_plus_sign_to_phone_number(cls, v: str) -> str: 25 | if v.startswith('+'): 26 | return v 27 | return f'+{v}' 28 | 29 | amount: float = Field(..., scheme_path='sum.amount') 30 | to_wallet: str = Field(..., scheme_path='fields.account') 31 | comment: Optional[str] = Field(None, scheme_path='comment') 32 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/transfer_money_to_card.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, ClassVar, Dict, Union 3 | 4 | from pydantic import Field 5 | 6 | from glQiwiApi.core.abc.api_method import Request, RuntimeValue 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | from glQiwiApi.qiwi.clients.wallet.types import PaymentInfo 9 | 10 | 11 | class TransferMoneyToCard(QiwiAPIMethod[PaymentInfo]): 12 | http_method: ClassVar[str] = 'POST' 13 | url: ClassVar[str] = 'https://edge.qiwi.com/sinap/api/v2/terms/{private_card_id}/payments' 14 | 15 | json_payload_schema: ClassVar[Dict[str, Any]] = { 16 | 'id': RuntimeValue(default_factory=lambda: str(int(time.time() * 1000))), 17 | 'sum': {'amount': RuntimeValue(), 'currency': '643'}, 18 | 'paymentMethod': {'type': 'Account', 'accountId': '643'}, 19 | 'fields': {'account': RuntimeValue()}, 20 | } 21 | 22 | private_card_id: str = Field(..., path_runtime_value=True) 23 | amount: float = Field(..., scheme_path='sum.amount') 24 | card_number: str = Field(..., scheme_path='fields.account') 25 | kwargs: Dict[str, Any] = {} # parameters for cards with ID 1960, 21012 26 | 27 | def build_request(self, **url_format_kw: Any) -> 'Request': 28 | request_schema = self._get_filled_json_payload_schema() 29 | request_schema['fields'].update(**self.kwargs) 30 | return Request( 31 | json_payload=request_schema, 32 | endpoint=self.url.format(**url_format_kw, **self._get_runtime_path_values()), 33 | http_method=self.http_method, 34 | ) 35 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/webhook/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/qiwi/clients/wallet/methods/webhook/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/webhook/change_webhook_secret.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import ReturningType 6 | from glQiwiApi.core.session.holder import HTTPResponse 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | 9 | 10 | class GenerateWebhookSecret(QiwiAPIMethod[str]): 11 | url: ClassVar[str] = 'https://edge.qiwi.com/payment-notifier/v1/hooks/{hook_id}/newkey' 12 | http_method: ClassVar[str] = 'POST' 13 | 14 | hook_id: str = Field(..., path_runtime_value=True) 15 | 16 | @classmethod 17 | def on_json_parse(cls, response: HTTPResponse) -> ReturningType: 18 | return response.json()['key'] 19 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/webhook/delete_current_webhook.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict 2 | 3 | from glQiwiApi.core.abc.api_method import Request 4 | from glQiwiApi.qiwi.base import QiwiAPIMethod 5 | 6 | 7 | class DeleteWebhook(QiwiAPIMethod[Dict[Any, Any]]): 8 | url: ClassVar[str] = 'https://edge.qiwi.com/payment-notifier/v1/hooks/{hook_id}' 9 | http_method: ClassVar[str] = 'DELETE' 10 | 11 | hook_id: str 12 | 13 | def build_request(self, **url_format_kw: Any) -> Request: 14 | return super().build_request(**url_format_kw, hook_id=self.hook_id) 15 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/webhook/get_current_webhook.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from glQiwiApi.qiwi.base import QiwiAPIMethod 4 | from glQiwiApi.qiwi.clients.wallet.types import WebhookInfo 5 | 6 | 7 | class GetCurrentWebhook(QiwiAPIMethod[WebhookInfo]): 8 | url: ClassVar[str] = 'https://edge.qiwi.com/payment-notifier/v1/hooks/active' 9 | http_method: ClassVar[str] = 'GET' 10 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/webhook/get_webhook_secret.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import ReturningType 6 | from glQiwiApi.core.session.holder import HTTPResponse 7 | from glQiwiApi.qiwi.base import QiwiAPIMethod 8 | 9 | 10 | class GetWebhookSecret(QiwiAPIMethod[str]): 11 | http_method: ClassVar[str] = 'GET' 12 | url: ClassVar[str] = 'https://edge.qiwi.com/payment-notifier/v1/hooks/{hook_id}/key' 13 | 14 | hook_id: str = Field(..., path_runtime_value=True) 15 | 16 | @classmethod 17 | def on_json_parse(cls, response: HTTPResponse) -> str: 18 | return response.json()['key'] 19 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/webhook/register_webhook.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import Request 6 | from glQiwiApi.qiwi.base import QiwiAPIMethod 7 | from glQiwiApi.qiwi.clients.wallet.types import WebhookInfo 8 | 9 | 10 | class RegisterWebhook(QiwiAPIMethod[WebhookInfo]): 11 | url: ClassVar[str] = 'https://edge.qiwi.com/payment-notifier/v1/hooks' 12 | http_method: ClassVar[str] = 'PUT' 13 | 14 | webhook_url: str = Field(..., alias='param') 15 | txn_type: int = Field(..., alias='txnType') 16 | 17 | hook_type: int = Field(default=1, alias='hookType') 18 | 19 | def build_request(self, **url_format_kw: Any) -> 'Request': 20 | return Request( 21 | endpoint=self.url.format(**url_format_kw), 22 | params=self.dict(by_alias=True), 23 | http_method=self.http_method, 24 | ) 25 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/methods/webhook/send_test_notification.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict 2 | 3 | from glQiwiApi.qiwi.base import QiwiAPIMethod 4 | 5 | 6 | class SendTestWebhookNotification(QiwiAPIMethod[Dict[Any, Any]]): 7 | http_method: ClassVar[str] = 'GET' 8 | url: ClassVar[str] = 'https://edge.qiwi.com/payment-notifier/v1/hooks/test' 9 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/__init__.py: -------------------------------------------------------------------------------- 1 | from .account_info import UserProfile 2 | from .balance import Balance 3 | from .commission import Commission 4 | from .identification import Identification 5 | from .limit import Limit 6 | from .other import CrossRate, PaymentDetails, PaymentMethod 7 | from .partner import Partner 8 | from .payment_info import PaymentInfo, QiwiPayment 9 | from .qiwi_master import Card, OrderDetails 10 | from .restriction import Restriction 11 | from .stats import Statistic 12 | from .transaction import History, Source, Transaction, TransactionStatus, TransactionType 13 | from .webhooks import TransactionWebhook, WebhookInfo 14 | 15 | __all__ = ( 16 | 'UserProfile', 17 | 'Transaction', 18 | 'Statistic', 19 | 'Limit', 20 | 'Balance', 21 | 'Identification', 22 | 'PaymentInfo', 23 | 'OrderDetails', 24 | 'Partner', 25 | 'TransactionWebhook', 26 | 'WebhookInfo', 27 | 'CrossRate', 28 | 'PaymentMethod', 29 | 'Card', 30 | 'Restriction', 31 | 'Commission', 32 | 'TransactionType', 33 | 'QiwiPayment', 34 | 'TransactionStatus', 35 | 'Source', 36 | 'PaymentDetails', 37 | 'History', 38 | ) 39 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/balance.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Union 2 | 3 | from pydantic import Field, validator 4 | 5 | from glQiwiApi.types.amount import AmountWithCurrency, CurrencyModel 6 | from glQiwiApi.types.base import HashableBase 7 | from glQiwiApi.utils.currency_util import Currency 8 | 9 | 10 | class AvailableBalance(HashableBase): 11 | alias: str 12 | currency: Union[str, CurrencyModel] 13 | 14 | 15 | class Balance(HashableBase): 16 | """object: Balance""" 17 | 18 | alias: str 19 | title: str 20 | fs_alias: str = Field(alias='fsAlias') 21 | bank_alias: str = Field(alias='bankAlias') 22 | has_balance: bool = Field(alias='hasBalance') 23 | balance: Optional[AmountWithCurrency] = None 24 | currency: CurrencyModel 25 | account_type: Optional[Dict[str, Any]] = Field(None, alias='type') 26 | is_default_account: bool = Field(alias='defaultAccount') 27 | 28 | @validator('currency', pre=True) 29 | def humanize_pay_currency(cls, v): # type: ignore 30 | if not isinstance(v, int): 31 | return v 32 | return Currency.get(str(v)) 33 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/commission.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from glQiwiApi.types.amount import AmountWithCurrency 4 | from glQiwiApi.types.base import Base 5 | 6 | 7 | class Commission(Base): 8 | provider_id: int = Field(alias='providerId') 9 | withdraw_sum: AmountWithCurrency = Field(alias='withdrawSum') 10 | enrollment_sum: AmountWithCurrency = Field(alias='enrollmentSum') 11 | qiwi_commission: AmountWithCurrency = Field(alias='qwCommission') 12 | withdraw_to_enrollment_rate: int = Field(alias='withdrawToEnrollmentRate') 13 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/identification.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import Optional 3 | 4 | from pydantic import Field 5 | 6 | from glQiwiApi.types.base import Base 7 | 8 | 9 | class Identification(Base): 10 | """object: Identification""" 11 | 12 | identification_id: int = Field(..., alias='id') 13 | first_name: str = Field(..., alias='firstName') 14 | middle_name: str = Field(..., alias='middleName') 15 | last_name: str = Field(..., alias='lastName') 16 | birth_date: date = Field(..., alias='birthDate') 17 | passport: str 18 | inn: Optional[str] 19 | snils: Optional[str] 20 | oms: Optional[str] 21 | type: str 22 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/limit.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Union 3 | 4 | from pydantic import Field, validator 5 | 6 | from glQiwiApi.types.amount import CurrencyModel 7 | from glQiwiApi.types.base import Base 8 | from glQiwiApi.utils.currency_util import Currency 9 | 10 | 11 | class Interval(Base): 12 | """object: Interval""" 13 | 14 | date_from: datetime = Field(alias='dateFrom') 15 | date_till: datetime = Field(alias='dateTill') 16 | 17 | 18 | class Limit(Base): 19 | """object: Limit""" 20 | 21 | currency: CurrencyModel 22 | rest: Union[float, int] 23 | max_limit: Union[float, int] = Field(alias='max') 24 | spent: Union[float, int] 25 | interval: Interval 26 | limit_type: str = Field(alias='type') 27 | 28 | @validator('currency', pre=True) 29 | def currency_validate(cls, v): # type: ignore 30 | if not isinstance(v, str): 31 | raise ValueError() 32 | return Currency.get(v) 33 | 34 | 35 | __all__ = ['Limit'] 36 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/mobile_operator.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | from pydantic import Field, Json 4 | 5 | from glQiwiApi.types.base import Base 6 | 7 | 8 | class Code(Base): 9 | value: str 10 | name: str = Field(..., alias='_name') 11 | 12 | 13 | class MobileOperator(Base): 14 | code: Code 15 | data: Optional[Json] 16 | message: str 17 | messages: Optional[Dict[str, Any]] 18 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/nickname.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.types.base import Base 6 | 7 | 8 | class NickName(Base): 9 | can_change: bool = Field(..., alias='canChange') 10 | can_use: bool = Field(..., alias='canUse') 11 | description: str 12 | nickname: Optional[str] = None 13 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/other.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from pydantic import Field, validator 4 | 5 | from glQiwiApi.types.amount import CurrencyModel 6 | from glQiwiApi.types.base import Base, HashableBase 7 | from glQiwiApi.utils.currency_util import Currency 8 | 9 | 10 | class CrossRate(HashableBase): 11 | """Курс валюты""" 12 | 13 | rate_from: Union[str, CurrencyModel] = Field(..., alias='from') 14 | rate_to: Union[str, CurrencyModel] = Field(..., alias='to') 15 | rate: float 16 | 17 | @validator('rate_from', 'rate_to', pre=True) 18 | def humanize_rates(cls, v): # type: ignore 19 | if not isinstance(v, str): 20 | return v 21 | cur = Currency.get(v) 22 | if not cur: 23 | return v 24 | return cur 25 | 26 | 27 | class PaymentMethod(Base): 28 | payment_type: str 29 | account_id: str 30 | 31 | 32 | class PaymentDetails(Base): 33 | """Набор реквизитов платежа""" 34 | 35 | name: str 36 | """Наименование банка получателя""" 37 | 38 | extra_to_bik: str 39 | """БИК банка получателя""" 40 | 41 | to_bik: str 42 | """ БИК банка получателя""" 43 | 44 | city: str 45 | """Город местонахождения получателя""" 46 | 47 | info: str = 'Коммерческие организации' 48 | """Константное значение""" 49 | 50 | is_commercial: str = '1' 51 | """Служебная информация""" 52 | 53 | to_name: str 54 | """Наименование организации""" 55 | 56 | to_inn: str 57 | """ИНН организации""" 58 | 59 | to_kpp: str 60 | """ КПП организации""" 61 | 62 | nds: str 63 | """ 64 | Признак уплаты НДС. 65 | Если вы оплачиваете квитанцию и в ней не указан НДС, 66 | то строка НДС не облагается. В ином случае, строка В т.ч. НДС 67 | """ 68 | 69 | goal: str 70 | """Назначение платежа""" 71 | 72 | urgent: str = '0' 73 | """ 74 | Признак срочного платежа (0 - нет, 1 - да). 75 | Срочный платеж выполняется от 10 минут. 76 | Возможен по будням с 9:00 до 20:30 по московскому времени. 77 | Стоимость услуги — 25 рублей. 78 | """ 79 | 80 | account: str 81 | """Номер счета получателя""" 82 | 83 | from_name: str 84 | """Имя плательщика""" 85 | 86 | from_name_p: str 87 | """Отчество плательщика""" 88 | 89 | from_name_f: str 90 | """ Фамилия плательщика""" 91 | 92 | requestProtocol: str = 'qw1' 93 | """Служебная информация, константа""" 94 | 95 | toServiceId: str = '1717' 96 | """Служебная информация, константа""" 97 | 98 | 99 | __all__ = ('CrossRate', 'PaymentDetails', 'PaymentMethod') 100 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/partner.py: -------------------------------------------------------------------------------- 1 | """Main model: Partner""" 2 | from typing import List, Optional 3 | 4 | from glQiwiApi.types.base import Base 5 | 6 | 7 | class Partner(Base): 8 | """Base partner class""" 9 | 10 | title: str 11 | id: int 12 | 13 | maps: Optional[List[str]] = None 14 | 15 | 16 | __all__ = ('Partner',) 17 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/payment_info.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.types.amount import AmountWithCurrency 6 | from glQiwiApi.types.base import Base 7 | 8 | 9 | class Fields(Base): 10 | """object: Fields""" 11 | 12 | account: str 13 | 14 | 15 | class State(Base): 16 | """object: State""" 17 | 18 | code: str 19 | 20 | 21 | class TransactionInfo(Base): 22 | """object: TransactionInfo""" 23 | 24 | id: int 25 | state: State 26 | 27 | 28 | class PaymentInfo(Base): 29 | """object: PaymentInfo""" 30 | 31 | id: int 32 | amount: AmountWithCurrency = Field(..., alias='sum') 33 | terms: str 34 | fields: Fields 35 | source: str 36 | transaction: Optional[TransactionInfo] = None 37 | comment: Optional[str] = None 38 | 39 | 40 | class PaymentMethod(Base): 41 | type: str = 'Account' 42 | account_id: int = Field(643, alias='accountId') 43 | 44 | 45 | class QiwiPayment(Base): 46 | id: int 47 | sum: AmountWithCurrency 48 | method: PaymentMethod = Field(..., alias='paymentMethod') 49 | fields: Dict[Any, Any] 50 | comment: Optional[str] = None 51 | 52 | 53 | __all__ = ['PaymentInfo', 'QiwiPayment'] 54 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/qiwi_master.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | from typing import List, Optional 5 | 6 | from pydantic import Field 7 | 8 | from glQiwiApi.types.amount import AmountWithCurrency 9 | from glQiwiApi.types.base import Base, HashableBase 10 | 11 | 12 | class QiwiMasterCardRequisites(Base): 13 | status: str 14 | cvv: str 15 | pan: str 16 | 17 | 18 | class OrderDetails(Base): 19 | order_id: str = Field(..., alias='id') 20 | card_alias: str = Field(..., alias='cardAlias') 21 | status: str 22 | price: Optional[AmountWithCurrency] = None 23 | card_id: Optional[str] = Field(alias='cardId', default=None) 24 | 25 | 26 | class CardCredentials(Base): 27 | qvx_id: int = Field(..., alias='id') 28 | masked_pan: str = Field(..., alias='maskedPan') 29 | status: str 30 | card_expire: datetime.datetime = Field(..., alias='cardExpire') 31 | card_type: str = Field(..., alias='cardType') 32 | card_alias: Optional[str] = Field(..., alias='cardAlias') 33 | activated: datetime.datetime 34 | sms_recender: Optional[datetime.datetime] = Field(..., alias='smsResended') 35 | post_number: Optional[str] = Field(default=None, alias='postNumber') 36 | blocked_date: Optional[datetime.datetime] = Field(default=None, alias='blockedDate') 37 | card_id: int = Field(..., alias='cardId') 38 | txn_id: int = Field(..., alias='txnId') 39 | card_expire_month: str = Field(..., alias='cardExpireMonth') 40 | card_expire_year: str = Field(..., alias='cardExpireYear') 41 | 42 | 43 | class Requisite(Base): 44 | name: str 45 | value: str 46 | 47 | 48 | class Details(Base): 49 | info: str 50 | description: str 51 | tariff_link: str = Field(..., alias='tariffLink') 52 | offer_link: str = Field(..., alias='offerLink') 53 | features: List[str] 54 | requisites: List[Requisite] 55 | 56 | 57 | class CardInfo(Base): 58 | id_: int = Field(..., alias='id') 59 | name: str 60 | alias: str 61 | price: AmountWithCurrency 62 | period: str 63 | type_: str = Field(..., alias='type') 64 | details: Details 65 | 66 | 67 | class Card(HashableBase): 68 | details: CardCredentials = Field(..., alias='qvx') 69 | balance: Optional[AmountWithCurrency] = None 70 | info: CardInfo 71 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/restriction.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from glQiwiApi.types.base import HashableBase 4 | 5 | 6 | class Restriction(HashableBase): 7 | code: str = Field(..., alias='restrictionCode') 8 | description: str = Field(..., alias='restrictionDescription') 9 | 10 | 11 | __all__ = ('Restriction',) 12 | -------------------------------------------------------------------------------- /glQiwiApi/qiwi/clients/wallet/types/stats.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.types.amount import AmountWithCurrency 6 | from glQiwiApi.types.base import Base 7 | 8 | 9 | class Statistic(Base): 10 | """object: Statistic""" 11 | 12 | incoming: List[AmountWithCurrency] = Field(alias='incomingTotal') 13 | out: List[AmountWithCurrency] = Field(alias='outgoingTotal') 14 | 15 | 16 | __all__ = ['Statistic'] 17 | -------------------------------------------------------------------------------- /glQiwiApi/types/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/types/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/types/amount.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from pydantic import BaseConfig, BaseModel, Field, validator 4 | 5 | from glQiwiApi.types.base import Base, HashableBase 6 | 7 | 8 | class CurrencyModel(HashableBase): 9 | code: str 10 | decimal_digits: int 11 | name: str 12 | name_plural: str 13 | rounding: Union[int, float] 14 | symbol: str 15 | symbol_native: str 16 | iso_format: Optional[str] = Field(..., alias='isoformat') 17 | 18 | def __str__(self) -> str: 19 | return self.code 20 | 21 | class Config(BaseConfig): 22 | frozen = True 23 | allow_mutation = False 24 | 25 | 26 | class AmountWithCurrency(Base): 27 | amount: float 28 | currency: Union[CurrencyModel, str] # string if currency util couldn't parse it 29 | 30 | @validator('currency', pre=True) 31 | def humanize_pay_currency(cls, v): # type: ignore 32 | from glQiwiApi.utils.currency_util import Currency 33 | 34 | if not isinstance(v, int): 35 | try: 36 | v = int(v) 37 | except ValueError: 38 | return v 39 | return Currency.get(str(v)) 40 | 41 | 42 | class HashableSum(HashableBase, AmountWithCurrency): 43 | ... 44 | 45 | 46 | class PlainAmount(BaseModel): 47 | value: float 48 | currency: str 49 | 50 | 51 | class HashablePlainAmount(HashableBase, PlainAmount): 52 | ... 53 | 54 | 55 | class Type(BaseModel): 56 | id: str 57 | title: str 58 | -------------------------------------------------------------------------------- /glQiwiApi/types/arbitrary/__init__.py: -------------------------------------------------------------------------------- 1 | from .file import File 2 | from .inputs import AbstractInput, BinaryIOInput, PathlibPathInput, PlainPathInput 3 | -------------------------------------------------------------------------------- /glQiwiApi/types/arbitrary/file.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import inspect 5 | import pathlib 6 | from typing import Any, BinaryIO, Union 7 | 8 | from glQiwiApi.types.arbitrary.inputs import AbstractInput 9 | from glQiwiApi.utils.compat import aiofiles 10 | 11 | CHUNK_SIZE = 65536 12 | 13 | StrOrBytesPath = Union[str, bytes, pathlib.Path] # stable 14 | 15 | _OpenFile = Union[StrOrBytesPath, int] 16 | 17 | 18 | class File: 19 | def __init__(self, input: AbstractInput[Any]) -> None: 20 | self._input = input 21 | 22 | def get_filename(self) -> str: 23 | return self._input.get_filename() 24 | 25 | def get_underlying_file_descriptor(self) -> BinaryIO: 26 | return self._input.get_file() 27 | 28 | def get_path(self) -> str: 29 | return self._input.get_path() 30 | 31 | def save(self, path: StrOrBytesPath, chunk_size: int = CHUNK_SIZE) -> None: 32 | file_descriptor = self.get_underlying_file_descriptor() 33 | with open(path, 'wb') as fp: 34 | while True: 35 | data = file_descriptor.read(chunk_size) 36 | if not data: 37 | break 38 | fp.write(data) 39 | fp.flush() 40 | 41 | if file_descriptor.seekable(): 42 | file_descriptor.seek(0) 43 | 44 | async def save_asynchronously( 45 | self, path: StrOrBytesPath, chunk_size: int = CHUNK_SIZE 46 | ) -> None: 47 | file_descriptor = self.get_underlying_file_descriptor() 48 | async with aiofiles.open(path, 'wb') as fp: 49 | while True: 50 | data = file_descriptor.read(chunk_size) 51 | if not data: 52 | break 53 | await fp.write(data) 54 | await fp.flush() 55 | 56 | if file_descriptor.seekable(): 57 | file_descriptor.seek(0) 58 | 59 | def __str__(self) -> str: 60 | try: 61 | return self.get_filename() 62 | except TypeError: 63 | return '' 64 | 65 | def __del__(self) -> None: 66 | if not hasattr(self, '_input'): 67 | return 68 | 69 | if inspect.iscoroutinefunction(self._input.close()): # type: ignore # noqa 70 | return asyncio.ensure_future(self._input.close()) # type: ignore # noqa 71 | self._input.close() 72 | 73 | __repr__ = __str__ 74 | -------------------------------------------------------------------------------- /glQiwiApi/types/arbitrary/inputs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import io 5 | import os 6 | import pathlib 7 | from types import TracebackType 8 | from typing import Any, BinaryIO, Generic, Optional, Type, TypeVar 9 | 10 | InputType = TypeVar('InputType') 11 | 12 | __all__ = ('AbstractInput', 'PlainPathInput', 'PathlibPathInput', 'BinaryIOInput') 13 | 14 | 15 | class AbstractInput(abc.ABC, Generic[InputType]): 16 | def __init__(self, input_: InputType) -> None: 17 | self._input = input_ 18 | self._file_descriptor: Optional[BinaryIO] = None 19 | 20 | @abc.abstractmethod 21 | def get_file(self) -> BinaryIO: 22 | ... 23 | 24 | def get_path(self) -> str: 25 | raise TypeError( 26 | f"{self.__class__.__qualname__} doesn't provide a mechanism to get path to file" 27 | ) 28 | 29 | def get_filename(self) -> str: 30 | raise TypeError( 31 | f"{self.__class__.__qualname__} doesn't provide a mechanism to get filename" 32 | ) 33 | 34 | def close(self) -> None: 35 | if self._file_descriptor is None: 36 | return None 37 | self._file_descriptor.close() 38 | 39 | def __enter__(self) -> AbstractInput[Any]: 40 | self._file_descriptor = self.get_file() 41 | return self 42 | 43 | def __exit__( 44 | self, 45 | exc_type: Optional[Type[BaseException]], 46 | exc_value: Optional[BaseException], 47 | traceback: Optional[TracebackType], 48 | ) -> None: 49 | self.close() 50 | 51 | 52 | class PlainPathInput(AbstractInput[str]): 53 | def get_file(self) -> BinaryIO: 54 | if pathlib.Path(self._input).is_file() is False: 55 | raise TypeError(f'Input {self._input} is not a file!') 56 | descriptor = open(self._input, 'rb') 57 | self._file_descriptor = descriptor 58 | return descriptor 59 | 60 | def get_path(self) -> str: 61 | return self._input 62 | 63 | def get_filename(self) -> str: 64 | return os.path.split(self._input)[-1] 65 | 66 | 67 | class PathlibPathInput(AbstractInput[pathlib.Path]): 68 | def get_file(self) -> BinaryIO: 69 | descriptor = open(self._input, 'rb') 70 | self._file_descriptor = descriptor 71 | return descriptor 72 | 73 | def get_path(self) -> str: 74 | return str(self._input.resolve()) 75 | 76 | def get_filename(self) -> str: 77 | return self._input.name 78 | 79 | 80 | class BinaryIOInput(AbstractInput[BinaryIO]): 81 | def get_file(self) -> BinaryIO: 82 | return self._input 83 | 84 | @classmethod 85 | def from_bytes(cls: Type[BinaryIOInput], b: bytes) -> BinaryIOInput: 86 | return cls(input_=io.BytesIO(b)) 87 | -------------------------------------------------------------------------------- /glQiwiApi/types/base.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseConfig, BaseModel 2 | 3 | from glQiwiApi.utils.compat import json 4 | 5 | 6 | class Base(BaseModel): 7 | class Config(BaseConfig): 8 | json_dumps = json.dumps # type: ignore 9 | json_loads = json.loads 10 | orm_mode = True 11 | 12 | 13 | class HashableBase(Base): 14 | class Config(BaseConfig): 15 | allow_mutation = False 16 | frozen = True 17 | -------------------------------------------------------------------------------- /glQiwiApi/types/exceptions.py: -------------------------------------------------------------------------------- 1 | class WebhookSignatureUnverifiedError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /glQiwiApi/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/utils/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/utils/compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Any, AnyStr, AsyncContextManager 3 | 4 | if sys.version_info >= (3, 9): 5 | 6 | def remove_suffix(input_string: AnyStr, suffix: AnyStr) -> AnyStr: 7 | return input_string.removesuffix(suffix) 8 | 9 | else: 10 | 11 | def remove_suffix(input_string: AnyStr, suffix: AnyStr) -> AnyStr: 12 | """Backport for python 3.9 str.removesuffix(...)""" 13 | if suffix and input_string.endswith(suffix): 14 | return input_string[: -len(suffix)] 15 | return input_string 16 | 17 | 18 | class ModuleNotInstalledException(Exception): 19 | pass 20 | 21 | 22 | class EmptyCls(object): 23 | def __init__(self) -> None: 24 | raise ModuleNotInstalledException() 25 | 26 | 27 | try: 28 | import orjson as json 29 | except ImportError: 30 | import json 31 | 32 | try: 33 | import aiofiles 34 | except ImportError: 35 | 36 | class aiofiles_compat: 37 | def open(self, *args: Any, **kwargs: Any) -> AsyncContextManager[Any]: 38 | raise ModuleNotInstalledException( 39 | "Module aiofiles not installed and you can't use it's " 40 | 'functionality till you install this module.' 41 | ) 42 | 43 | aiofiles = aiofiles_compat() # type: ignore 44 | 45 | try: 46 | import orjson as json # noqa 47 | except (ImportError, ModuleNotFoundError): 48 | import json # type: ignore 49 | 50 | try: 51 | from aiogram import Dispatcher 52 | from aiogram.types import InputFile 53 | except (ModuleNotFoundError, ImportError): 54 | Dispatcher = EmptyCls 55 | InputFile = EmptyCls 56 | 57 | if sys.version_info >= (3, 8): 58 | from typing import Final, Literal, Protocol # noqa 59 | else: 60 | from typing_extensions import Final, Literal, Protocol # noqa 61 | -------------------------------------------------------------------------------- /glQiwiApi/utils/currency_util.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from glQiwiApi.types import _currencies 4 | from glQiwiApi.types.amount import CurrencyModel 5 | 6 | 7 | class Currency: 8 | """ 9 | Class with many currencies 10 | import glQiwiApi.types.basics >>> usd = Currency.get('840') 11 | >>> usd 12 | ... CurrencyModel(code='USD', decimal_digits=2, name='US Dollar', name_plural='US dollars', 13 | ... rounding=0, symbol='$', symbol_native='$') 14 | 15 | >>> usd.symbol 16 | ... '$' 17 | """ 18 | 19 | @classmethod 20 | def get(cls, currency_code: Union[str, int]) -> Optional[CurrencyModel]: 21 | """ 22 | Implements class-based getitem behaviour 23 | 24 | >>> Currency.get('840').symbol 25 | ... '$' 26 | >>> Currency.get('USD').symbol 27 | ... '$' 28 | :param currency_code: ISO 4217 string or CODE 29 | :return: Currency object 30 | """ 31 | if isinstance(currency_code, int) or currency_code.isdigit(): 32 | return _currencies.described.get(_currencies.codes_number[str(currency_code)]) 33 | else: 34 | return _currencies.described.get(currency_code.upper()) 35 | -------------------------------------------------------------------------------- /glQiwiApi/utils/date_conversion.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Optional 3 | 4 | DEFAULT_QIWI_TIMEZONE = 'Europe/Moscow' 5 | 6 | try: 7 | import zoneinfo 8 | except ImportError: 9 | import backports.zoneinfo as zoneinfo 10 | 11 | 12 | def datetime_to_utc_in_iso_format(obj: datetime) -> str: 13 | return obj.astimezone(tz=timezone.utc).isoformat(timespec='milliseconds') 14 | 15 | 16 | def datetime_to_iso8601_with_moscow_timezone(obj: Optional[datetime]) -> str: 17 | """ 18 | Converts a date to a standard format for API's 19 | 20 | :param obj: datetime object to parse to string 21 | :return: string - parsed date 22 | """ 23 | if not isinstance(obj, datetime): 24 | return '' # pragma: no cover 25 | return localize_datetime_according_to_moscow_timezone(obj).isoformat(timespec='seconds') 26 | 27 | 28 | def localize_datetime_according_to_moscow_timezone(dt: datetime): 29 | return dt.astimezone(zoneinfo.ZoneInfo(DEFAULT_QIWI_TIMEZONE)) 30 | -------------------------------------------------------------------------------- /glQiwiApi/utils/mypy_hacks.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Callable, TypeVar 3 | 4 | T = TypeVar('T') 5 | 6 | 7 | def lru_cache(maxsize: int = 128, typed: bool = False) -> Callable[[T], T]: 8 | """ 9 | fix: lru_cache annotation doesn't work with a property 10 | this hack is only needed for the property, so type annotations are as they are 11 | """ 12 | 13 | def wrapper(func: T) -> T: 14 | return functools.lru_cache(maxsize, typed)(func) # type: ignore 15 | 16 | return wrapper 17 | -------------------------------------------------------------------------------- /glQiwiApi/utils/payload.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, Dict, TypeVar, cast 3 | 4 | from pydantic import BaseModel 5 | 6 | Model = TypeVar('Model', bound=BaseModel) 7 | DEFAULT_EXCLUDE = ('cls', 'self', '__class__') 8 | 9 | 10 | def filter_dictionary_none_values(dictionary: Dict[Any, Any]) -> Dict[Any, Any]: 11 | """ 12 | Pop NoneType values and convert everything to str, designed?for=params 13 | :param dictionary: source dict 14 | :return: filtered dict 15 | """ 16 | return {k: str(v) for k, v in dictionary.items() if v is not None} 17 | 18 | 19 | def make_payload(**kwargs: Any) -> Dict[Any, Any]: 20 | exclude_list = kwargs.pop('exclude', ()) 21 | return { 22 | key: value 23 | for key, value in kwargs.items() 24 | if key not in DEFAULT_EXCLUDE + exclude_list and value is not None 25 | } 26 | 27 | 28 | def parse_auth_link(response_data: str) -> str: 29 | """ 30 | Parse link for getting code, which needs to be entered in the method 31 | get_access_token 32 | :param response_data: 33 | """ 34 | regexp = re.compile( 35 | r'https://yoomoney.ru/oauth2/authorize[?]requestid[=]\w+' 36 | ) # pragma: no cover 37 | return cast(str, re.findall(regexp, str(response_data))[0]) # pragma: no cover 38 | -------------------------------------------------------------------------------- /glQiwiApi/utils/synchronous/__init__.py: -------------------------------------------------------------------------------- 1 | from .adapter import async_as_sync, execute_async_as_sync 2 | 3 | __all__ = ( 4 | 'async_as_sync', 5 | 'execute_async_as_sync', 6 | ) 7 | -------------------------------------------------------------------------------- /glQiwiApi/utils/synchronous/adapter.pyi: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from concurrent import futures as futures 5 | from concurrent.futures import Future 6 | from typing import Any, Awaitable, Callable, Coroutine, Optional, TypeVar, Union 7 | 8 | N = TypeVar("N") 9 | 10 | def run_forever_safe( 11 | loop: asyncio.AbstractEventLoop, 12 | callback: Optional[Callable[..., Awaitable[N]]] = None, 13 | ) -> None: ... 14 | def safe_cancel( 15 | loop: asyncio.AbstractEventLoop, 16 | callback: Optional[Callable[..., Awaitable[N]]], 17 | ) -> None: ... 18 | 19 | AnyExecutor = Union[futures.ThreadPoolExecutor, futures.ProcessPoolExecutor, Optional[None]] 20 | 21 | def _cancel_future( 22 | loop: asyncio.AbstractEventLoop, 23 | future: asyncio.Future[N], 24 | executor: AnyExecutor, 25 | ) -> None: ... 26 | def _stop_loop(loop: asyncio.AbstractEventLoop) -> None: ... 27 | def take_event_loop(set_debug: bool = False) -> asyncio.AbstractEventLoop: ... 28 | def await_sync(future: Future[N]) -> N: ... 29 | def execute_async_as_sync( 30 | func: Callable[..., Coroutine[Any, Any, N]], *args: object, **kwargs: object 31 | ) -> N: ... 32 | 33 | class async_as_sync: # NOQA 34 | _async_shutdown_callback: Optional[Callable[..., Awaitable[N]]] 35 | _sync_shutdown_callback: Optional[Callable[[Any], Any]] 36 | def __init__( 37 | self, 38 | async_shutdown_callback: Optional[Callable[..., Awaitable[N]]] = None, 39 | sync_shutdown_callback: Optional[Callable[[Any], Any]] = None, 40 | ) -> None: ... 41 | def __call__(self, func: Callable[..., Awaitable[N]]) -> Callable[..., N]: ... 42 | def execute_sync_callback(self, result: Any) -> Any: ... 43 | -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import YooMoneyAPI 2 | 3 | __all__ = ('YooMoneyAPI',) 4 | -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/methods/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/glQiwiApi/yoo_money/methods/__init__.py -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/methods/acccept_incoming_transfer.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from glQiwiApi.core.abc.api_method import APIMethod 4 | from glQiwiApi.yoo_money.types import IncomingTransaction 5 | 6 | 7 | class AcceptIncomingTransfer(APIMethod[IncomingTransaction]): 8 | http_method: ClassVar[str] = 'POST' 9 | url: ClassVar[str] = 'https://yoomoney.ru/api/incoming-transfer-accept' 10 | 11 | operation_id: str 12 | protection_code: str 13 | -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/methods/build_auth_url.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, ClassVar, List, cast 3 | 4 | from glQiwiApi.core.abc.api_method import APIMethod, Request, ReturningType 5 | from glQiwiApi.core.session.holder import HTTPResponse 6 | 7 | YOO_MONEY_LINK_REGEXP = re.compile(r'https?://yoomoney.ru/oauth2/authorize[?]requestid=\w+') 8 | 9 | 10 | class BuildAuthURL(APIMethod[str]): 11 | http_method: ClassVar[str] = 'POST' 12 | url: ClassVar[str] = 'https://yoomoney.ru/oauth/authorize' 13 | 14 | client_id: str 15 | scopes: List[str] 16 | redirect_uri: str 17 | response_type: str = 'code' 18 | 19 | def build_request(self, **url_format_kw: Any) -> 'Request': 20 | return Request( 21 | endpoint=self.url.format(**url_format_kw, **self._get_runtime_path_values()), 22 | http_method=self.http_method, 23 | data={ 24 | 'client_id': self.client_id, 25 | 'response_type': 'code', 26 | 'redirect_uri': self.redirect_uri, 27 | 'scope': ' '.join(self.scopes), 28 | }, 29 | ) 30 | 31 | @classmethod 32 | def parse_http_response(cls, response: HTTPResponse) -> ReturningType: 33 | try: 34 | return cast( 35 | str, re.findall(YOO_MONEY_LINK_REGEXP, response.body.decode('utf-8'))[0] 36 | ) # pragma: no cover 37 | except IndexError: 38 | raise Exception( 39 | 'Could not find the authorization link in the response from ' 40 | 'the api, check the client_id value' 41 | ) 42 | -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/methods/get_access_token.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Optional 2 | 3 | from glQiwiApi.core.abc.api_method import APIMethod, ReturningType 4 | from glQiwiApi.core.session.holder import HTTPResponse 5 | 6 | 7 | class GetAccessToken(APIMethod[str]): 8 | http_method: ClassVar[str] = 'POST' 9 | url: ClassVar[str] = 'https://yoomoney.ru/oauth/token' 10 | 11 | code: str 12 | client_id: str 13 | grant_type: str = 'authorization_code' 14 | redirect_uri: str = 'https://example.com' 15 | client_secret: Optional[str] = None 16 | 17 | @classmethod 18 | def on_json_parse(cls, response: HTTPResponse) -> str: 19 | return response.json()['access_token'] 20 | -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/methods/make_cellular_payment.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import APIMethod 6 | 7 | 8 | class MakeCellularPayment(APIMethod[Dict[str, Any]]): 9 | http_method: ClassVar[str] = 'POST' 10 | url: ClassVar[str] = 'https://yoomoney.ru/api/operation-details' 11 | 12 | pattern_id: str 13 | phone_number: str = Field(..., alias='phone-number') 14 | amount: float 15 | -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/methods/operation_details.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Union 2 | 3 | from glQiwiApi.core.abc.api_method import APIMethod 4 | from glQiwiApi.yoo_money.types import OperationDetails 5 | 6 | 7 | class OperationDetailsMethod(APIMethod[OperationDetails]): 8 | http_method: ClassVar[str] = 'POST' 9 | url: ClassVar[str] = 'https://yoomoney.ru/api/operation-details' 10 | 11 | operation_id: Union[int, str] 12 | -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/methods/operation_history.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, ClassVar, Iterable, Optional, Union 3 | 4 | from pydantic import conint 5 | 6 | from glQiwiApi.core.abc.api_method import APIMethod, Request 7 | from glQiwiApi.utils.date_conversion import datetime_to_utc_in_iso_format 8 | from glQiwiApi.utils.payload import filter_dictionary_none_values 9 | from glQiwiApi.yoo_money.types import OperationHistory 10 | 11 | MAX_HISTORY_LIMIT = 100 12 | 13 | 14 | class OperationHistoryMethod(APIMethod[OperationHistory]): 15 | http_method: ClassVar[str] = 'GET' 16 | url: ClassVar[str] = 'https://yoomoney.ru/api/operation-history' 17 | 18 | operation_types: Optional[Iterable[str]] = None 19 | start_date: Optional[datetime] = None 20 | end_date: Optional[datetime] = None 21 | start_record: Optional[int] = None 22 | records: conint(le=MAX_HISTORY_LIMIT, strict=True, gt=0) = 30 23 | label: Optional[Union[str, int]] = None 24 | in_detail: bool = False 25 | 26 | def build_request(self, **url_format_kw: Any) -> 'Request': 27 | payload = { 28 | 'records': self.records, 29 | 'label': self.label, 30 | 'start_record': self.start_record, 31 | 'details': str(self.in_detail).lower(), 32 | } 33 | 34 | if self.operation_types is not None: 35 | payload['type'] = ' '.join([op_type.lower() for op_type in self.operation_types]) 36 | if self.start_date: 37 | payload['from'] = datetime_to_utc_in_iso_format(self.start_date) 38 | if self.end_date: 39 | payload['till'] = datetime_to_utc_in_iso_format(self.end_date) 40 | 41 | return Request( 42 | endpoint=self.url.format(**url_format_kw, **self._get_runtime_path_values()), 43 | http_method=self.http_method, 44 | data=filter_dictionary_none_values(payload), 45 | ) 46 | -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/methods/process_payment.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from glQiwiApi.core.abc.api_method import APIMethod 4 | from glQiwiApi.yoo_money.types import Payment 5 | 6 | 7 | class ProcessPayment(APIMethod[Payment]): 8 | http_method: ClassVar[str] = 'POST' 9 | url: ClassVar[str] = 'https://yoomoney.ru/api/process-payment' 10 | 11 | request_id: str 12 | money_source: str = 'wallet' 13 | -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/methods/reject_incoming_transfer.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Dict 2 | 3 | from glQiwiApi.core.abc.api_method import APIMethod 4 | 5 | 6 | class RejectIncomingTransfer(APIMethod[Dict[str, str]]): 7 | http_method: ClassVar[str] = 'POST' 8 | url: ClassVar[str] = 'https://yoomoney.ru/api/incoming-transfer-reject' 9 | 10 | operation_id: str 11 | -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/methods/request_payment.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Optional, Union 2 | 3 | from pydantic import Field 4 | 5 | from glQiwiApi.core.abc.api_method import APIMethod 6 | from glQiwiApi.yoo_money.types import RequestPaymentResponse 7 | 8 | 9 | class RequestPayment(APIMethod[RequestPaymentResponse]): 10 | http_method: ClassVar[str] = 'POST' 11 | url: ClassVar[str] = 'https://yoomoney.ru/api/request-payment' 12 | 13 | to_account: str = Field(..., alias='to') 14 | amount: Union[int, float] = Field(..., alias='amount_due') 15 | pattern_id: str = 'p2p' 16 | comment_for_history: Optional[str] = Field(None, alias='comment') 17 | comment_for_receiver: Optional[str] = Field(None, alias='message') 18 | protect: bool = Field(False, alias='codepro') 19 | expire_period: int = 1 20 | -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/methods/retrieve_account_info.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from glQiwiApi.core.abc.api_method import APIMethod 4 | from glQiwiApi.yoo_money.types import AccountInfo 5 | 6 | 7 | class RetrieveAccountInfo(APIMethod[AccountInfo]): 8 | http_method: ClassVar[str] = 'POST' 9 | url: ClassVar[str] = 'https://yoomoney.ru/api/account-info' 10 | -------------------------------------------------------------------------------- /glQiwiApi/yoo_money/methods/revoke_api_token.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict 2 | 3 | from glQiwiApi.core.abc.api_method import APIMethod, ReturningType 4 | from glQiwiApi.core.session.holder import HTTPResponse 5 | 6 | 7 | class RevokeAPIToken(APIMethod[Dict[Any, Any]]): 8 | http_method: ClassVar[str] = 'POST' 9 | url: ClassVar[str] = 'https://yoomoney.ru/api/revoke' 10 | 11 | @classmethod 12 | def parse_http_response(cls, response: HTTPResponse) -> ReturningType: 13 | return {} 14 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from glQiwiApi.qiwi.clients.wallet.types import Transaction, TransactionStatus, TransactionWebhook 6 | from glQiwiApi.qiwi.clients.wallet.types.transaction import Provider, TransactionType 7 | from glQiwiApi.types.amount import AmountWithCurrency 8 | 9 | 10 | @pytest.fixture() 11 | def transaction() -> Transaction: 12 | return Transaction( 13 | txnId=50, 14 | personId=3254235, 15 | date=datetime.now(), 16 | status=TransactionStatus.SUCCESS, 17 | statusText='hello', 18 | trmTxnId='world', 19 | account='+38908234234', 20 | sum=AmountWithCurrency(amount=999, currency='643'), 21 | total=AmountWithCurrency(amount=999, currency='643'), 22 | provider=Provider(), 23 | commission=AmountWithCurrency(amount=999, currency='643'), 24 | currencyRate=643, 25 | type=TransactionType.OUT, 26 | comment='my_comment', 27 | ) 28 | 29 | 30 | @pytest.fixture(name='test_webhook') 31 | def test_webhook_fixture(): 32 | return TransactionWebhook.parse_obj( 33 | { 34 | 'messageId': 'dc32ba01-1a83-4dc5-82b5-1148f7744ace', 35 | 'hookId': '87995a67-749f-4a23-9629-95dc8fea696f', 36 | 'payment': { 37 | 'txnId': '22994210671', 38 | 'date': '2021-08-24T22:48:03+03:00', 39 | 'type': 'OUT', 40 | 'status': 'SUCCESS', 41 | 'errorCode': '0', 42 | 'personId': 380968317459, 43 | 'account': '+380985272064', 44 | 'comment': '', 45 | 'provider': 99, 46 | 'sum': {'amount': 1, 'currency': 643}, 47 | 'commission': {'amount': 0.02, 'currency': 643}, 48 | 'total': {'amount': 1.02, 'currency': 643}, 49 | 'signFields': 'sum.currency,sum.amount,type,account,txnId', 50 | }, 51 | 'hash': '1de3c14fbe8687b71b34ee23bd55262167304bb8f7d1bb8adaa5d8b4474fb148', 52 | 'version': '1.0.0', 53 | 'test': False, 54 | } 55 | ) 56 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_qiwi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/integration/test_qiwi/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_qiwi/test_clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/integration/test_qiwi/test_clients/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_qiwi/test_clients/test_maps.py: -------------------------------------------------------------------------------- 1 | from typing import Any, AsyncIterator, Dict 2 | 3 | import pytest 4 | 5 | from glQiwiApi import QiwiMaps 6 | from glQiwiApi.qiwi.clients.maps.types.polygon import Polygon 7 | from glQiwiApi.qiwi.clients.maps.types.terminal import Terminal 8 | from glQiwiApi.qiwi.clients.wallet.types import Partner 9 | 10 | pytestmark = pytest.mark.asyncio 11 | 12 | 13 | @pytest.fixture(name='data') 14 | def maps_data() -> AsyncIterator[Dict[str, Any]]: 15 | polygon = Polygon(latNW=55.690881, lngNW=37.386282, latSE=55.580184, lngSE=37.826078) 16 | yield {'polygon': polygon, 'zoom': 12, 'cache_terminals': True} 17 | del polygon 18 | 19 | 20 | @pytest.fixture(name='maps') 21 | async def maps_fixture() -> AsyncIterator[QiwiMaps]: 22 | """:class:`QiwiMaps` fixture""" 23 | async with QiwiMaps() as maps: 24 | yield maps 25 | 26 | 27 | async def test_terminals(maps: QiwiMaps, data: Dict[str, Any]) -> None: 28 | result = await maps.terminals(**data, include_partners=True) 29 | assert all(isinstance(t, Terminal) for t in result) 30 | 31 | 32 | async def test_partners(maps: QiwiMaps) -> None: 33 | result = await maps.partners() 34 | assert all(isinstance(p, Partner) for p in result) 35 | -------------------------------------------------------------------------------- /tests/integration/test_qiwi/test_clients/test_p2p_client.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | from typing import Any, AsyncIterator, Dict 4 | 5 | import pytest 6 | 7 | from glQiwiApi import QiwiP2PClient 8 | from glQiwiApi.qiwi.clients.p2p.types import Bill 9 | from tests.settings import QIWI_P2P_CREDENTIALS 10 | 11 | pytestmark = pytest.mark.asyncio 12 | 13 | 14 | @pytest.fixture(name='api') 15 | async def api_fixture() -> AsyncIterator[QiwiP2PClient]: 16 | async with QiwiP2PClient(**QIWI_P2P_CREDENTIALS) as p2p: 17 | yield p2p 18 | 19 | 20 | @pytest.mark.parametrize( 21 | 'payload', 22 | [ 23 | {'amount': 1}, 24 | {'amount': 1, 'comment': 'test_comment'}, 25 | { 26 | 'amount': 1, 27 | 'comment': 'test_comment', 28 | 'expire_at': datetime.datetime.now() + datetime.timedelta(hours=5), 29 | }, 30 | { 31 | 'amount': 1, 32 | 'comment': 'test_comment', 33 | 'expire_at': datetime.datetime.now() + datetime.timedelta(hours=5), 34 | 'bill_id': str(uuid.uuid4()), 35 | }, 36 | ], 37 | ) 38 | async def test_create_p2p_bill(api: QiwiP2PClient, payload: Dict[str, Any]) -> None: 39 | result = await api.create_p2p_bill(**payload) 40 | assert isinstance(result, Bill) 41 | assert payload['amount'] == result.amount.value 42 | 43 | 44 | async def test_check_p2p_bill_status(api: QiwiP2PClient) -> None: 45 | test_bill = await api.create_p2p_bill(amount=1) 46 | result = await api.get_bill_status(bill_id=test_bill.id) 47 | assert isinstance(result, str) 48 | 49 | 50 | async def test_get_bill_by_id(api: QiwiP2PClient) -> None: 51 | test_bill = await api.create_p2p_bill(amount=1) 52 | assert await api.get_bill_by_id(test_bill.id) == test_bill 53 | 54 | 55 | async def test_check_p2p_on_object(api: QiwiP2PClient) -> None: 56 | bill = await api.create_p2p_bill(amount=1) 57 | assert isinstance(bill, Bill) 58 | result = await api.check_if_bill_was_paid(bill) 59 | 60 | assert result is False 61 | 62 | 63 | async def test_reject_p2p_bill(api: QiwiP2PClient) -> None: 64 | b = await api.create_p2p_bill(amount=1) 65 | rejected_bill = await api.reject_p2p_bill(b.id) 66 | assert isinstance(rejected_bill, Bill) 67 | 68 | 69 | async def test_reject_bill_alias(api: QiwiP2PClient) -> None: 70 | b = await api.create_p2p_bill(amount=1) 71 | rejected_bill = await api.reject_bill(b) 72 | assert rejected_bill.status.value == 'REJECTED' 73 | 74 | 75 | async def test_check_bill_status_alias(api: QiwiP2PClient) -> None: 76 | b = await api.create_p2p_bill(amount=1) 77 | assert await api.check_if_bill_was_paid(b) is False 78 | -------------------------------------------------------------------------------- /tests/integration/test_yoomoney/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/integration/test_yoomoney/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | QIWI_WALLET_CREDENTIALS = { 4 | 'phone_number': os.getenv('QIWI_PHONE_NUMBER'), 5 | 'api_access_token': os.getenv('QIWI_API_ACCESS_TOKEN'), 6 | } 7 | 8 | QIWI_P2P_CREDENTIALS = {'secret_p2p': os.getenv('QIWI_SECRET_P2P')} 9 | 10 | YOO_MONEY_CREDENTIALS = {'api_access_token': os.getenv('YOOMONEY_API_TOKEN')} 11 | 12 | YOO_MONEY_TEST_CLIENT_ID = os.getenv('YOOMONEY_TEST_CLIENT_ID') 13 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/base.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.unit_test 5 | class UnitTestCase: 6 | pass 7 | -------------------------------------------------------------------------------- /tests/unit/test_api_methods/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/test_api_methods/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_api_methods/test_api_method.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | from glQiwiApi.core.abc.api_method import APIMethod, Request, RuntimeValue 6 | from glQiwiApi.core.session.holder import HTTPResponse 7 | 8 | 9 | class MyModel(BaseModel): 10 | f: str 11 | 12 | 13 | class SimpleAPIMethod(APIMethod[MyModel]): 14 | url: ClassVar[str] = 'https://hello.world' 15 | http_method: ClassVar[str] = 'GET' 16 | 17 | 18 | class APIMethodWithRequestSchema(APIMethod[MyModel]): 19 | http_method: ClassVar[str] = 'PUT' 20 | url: ClassVar[str] = 'https://hello.world/{id}' 21 | 22 | json_payload_schema = {'hello': {'world': {'nested': RuntimeValue()}}} 23 | 24 | id: int = Field(..., path_runtime_value=True) 25 | nested_field: str = Field(..., scheme_path='hello.world.nested') 26 | 27 | 28 | def test_parse_response_if_status_code_is_200() -> None: 29 | assert isinstance( 30 | SimpleAPIMethod.parse_http_response( 31 | HTTPResponse(status_code=200, body=b'{"f": "some_value"}', headers={}, content_type='') 32 | ), 33 | MyModel, 34 | ) 35 | 36 | 37 | def test_build_request_of_simple_api_method() -> None: 38 | method = SimpleAPIMethod() 39 | assert method.build_request() == Request( 40 | endpoint='https://hello.world', 41 | ) 42 | 43 | 44 | def test_build_complicated_request() -> None: 45 | method = APIMethodWithRequestSchema(id=5, nested_field='hello world') 46 | assert method.build_request() == Request( 47 | endpoint='https://hello.world/5', 48 | json_payload={'hello': {'world': {'nested': 'hello world'}}}, 49 | http_method='PUT', 50 | ) 51 | 52 | 53 | def test_determine_returning_type_by_generic_value() -> None: 54 | method = SimpleAPIMethod() 55 | assert method.__returning_type__ is MyModel 56 | 57 | 58 | def test_get_filled_request_schema() -> None: 59 | method = APIMethodWithRequestSchema(id=5, nested_field='hello world') 60 | assert method._get_filled_json_payload_schema() == { 61 | 'hello': {'world': {'nested': 'hello world'}} 62 | } 63 | 64 | 65 | def test_get_runtime_path_values() -> None: 66 | method = APIMethodWithRequestSchema(id=5, nested_field='hello world') 67 | assert method._get_runtime_path_values() == {'id': 5} 68 | -------------------------------------------------------------------------------- /tests/unit/test_api_methods/test_qiwi_api_method.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import ClassVar, List 3 | 4 | import pytest 5 | from pydantic import BaseModel 6 | 7 | from glQiwiApi.core.abc.api_method import APIMethod 8 | from glQiwiApi.core.session.holder import HTTPResponse 9 | from glQiwiApi.qiwi.base import QiwiAPIMethod 10 | from glQiwiApi.qiwi.exceptions import QiwiAPIError 11 | 12 | 13 | class M(BaseModel): 14 | id: int 15 | 16 | 17 | class MyQiwiAPIMethod(QiwiAPIMethod[M]): 18 | url: ClassVar[str] = 'https://qiwi.com/hello/world' 19 | http_method: ClassVar[str] = 'GET' 20 | 21 | 22 | def test_parse_http_response() -> None: 23 | method = MyQiwiAPIMethod() 24 | with pytest.raises(QiwiAPIError) as exc_info: 25 | resp = HTTPResponse( 26 | status_code=400, 27 | body=json.dumps({'message': 'Something went wrong', 'code': 'QWRPC-303'}).encode( 28 | 'utf-8' 29 | ), 30 | headers={}, 31 | content_type='', 32 | ) 33 | method.parse_http_response(resp) 34 | 35 | assert exc_info.value.http_response == resp 36 | assert exc_info.value.error_code == '303' 37 | 38 | 39 | def test_designate__returning_type__attribute() -> None: 40 | method = MyQiwiAPIMethod() 41 | assert method.__returning_type__ is M 42 | 43 | 44 | def test_designate__returning_type_with_two_models() -> None: 45 | class Model1(QiwiAPIMethod[List[int]]): 46 | url: ClassVar[str] = 'https://qiwi.com/hello/world' 47 | http_method: ClassVar[str] = 'GET' 48 | 49 | class Model2(QiwiAPIMethod[M]): 50 | url: ClassVar[str] = 'https://qiwi.com/hello/world' 51 | http_method: ClassVar[str] = 'GET' 52 | 53 | assert Model1.__returning_type__ is List[int] 54 | assert Model2.__returning_type__ is M 55 | 56 | 57 | def test_designate__returning_type__with_raw_api_method() -> None: 58 | class K(BaseModel): 59 | pass 60 | 61 | class MyAPIMethod(APIMethod[K]): 62 | url: ClassVar[str] = 'https://qiwi.com/hello/world' 63 | http_method: ClassVar[str] = 'PUT' 64 | 65 | assert MyAPIMethod.__returning_type__ is K 66 | -------------------------------------------------------------------------------- /tests/unit/test_api_methods/test_runtime_value.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | from glQiwiApi.core.abc.api_method import RuntimeValue 6 | 7 | 8 | @pytest.mark.parametrize( 9 | 'rv,expected', 10 | [ 11 | (RuntimeValue(default='hello'), True), 12 | (RuntimeValue(default_factory=lambda: 'world'), True), 13 | (RuntimeValue(), False), 14 | ], 15 | ) 16 | def test_has_default(rv: RuntimeValue, expected: bool) -> None: 17 | assert rv.has_default() is expected 18 | 19 | 20 | @pytest.mark.parametrize( 21 | 'rv,expected', 22 | [ 23 | (RuntimeValue(default='hello'), 'hello'), 24 | (RuntimeValue(default_factory=lambda: 'world'), 'world'), 25 | (RuntimeValue(), None), 26 | ], 27 | ) 28 | def test_get_default(rv: RuntimeValue, expected: Any) -> None: 29 | assert rv.get_default() == expected 30 | -------------------------------------------------------------------------------- /tests/unit/test_errors/test_yoomoney_errors.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | import pytest 4 | 5 | from glQiwiApi.yoo_money.exceptions import ( 6 | AccountBlockedError, 7 | AccountClosedError, 8 | AuthorizationRejectError, 9 | ContractNotFoundError, 10 | ExtActionRequiredError, 11 | IllegalParamError, 12 | InsufficientScopeError, 13 | InvalidGrantError, 14 | InvalidRequestError, 15 | LimitExceededError, 16 | MoneySourceNotAvailableError, 17 | NotEnoughFundsError, 18 | PayeeNotFoundError, 19 | PaymentRefusedError, 20 | TechnicalError, 21 | UnauthorizedClientError, 22 | YooMoneyError, 23 | YooMoneyErrorSchema, 24 | ) 25 | 26 | 27 | @pytest.mark.parametrize( 28 | ['error_code', 'expected_exception_type'], 29 | [ 30 | ['not_enough_funds', NotEnoughFundsError], 31 | ['illegal_param_date', IllegalParamError], 32 | ['invalid_request', InvalidRequestError], 33 | ['unauthorized_client', UnauthorizedClientError], 34 | ['invalid_grant', InvalidGrantError], 35 | ['illegal_param_type', IllegalParamError], 36 | ['some_err', TechnicalError], 37 | ['illegal_param_operation_id', IllegalParamError], 38 | ['not_enough_funds', NotEnoughFundsError], 39 | ['payment_refused', PaymentRefusedError], 40 | ['payee_not_found', PayeeNotFoundError], 41 | ['authorization_reject', AuthorizationRejectError], 42 | ['limit_exceeded', LimitExceededError], 43 | ['account_blocked', AccountBlockedError], 44 | ['account_closed', AccountClosedError], 45 | ['ext_action_required', ExtActionRequiredError], 46 | ['contract_not_found', ContractNotFoundError], 47 | ['money_source_not_available', MoneySourceNotAvailableError], 48 | ['insufficient_scope', InsufficientScopeError], 49 | ], 50 | ) 51 | def test_if_error_class_equals_to_expected( 52 | error_code: str, expected_exception_type: Type[Exception] 53 | ): 54 | with pytest.raises(expected_exception_type) as exc_info: 55 | YooMoneyError.raise_most_appropriate_error(YooMoneyErrorSchema(error_code=error_code)) 56 | 57 | assert exc_info.errisinstance(expected_exception_type) 58 | 59 | 60 | @pytest.mark.parametrize( 61 | ['expected_param_name', 'error_code'], 62 | [ 63 | ['operation_id', 'illegal_param_operation_id'], 64 | ['date', 'illegal_param_date'], 65 | ], 66 | ) 67 | def test_if_illegal_param_error_contains_param_name(expected_param_name: str, error_code: str): 68 | with pytest.raises(IllegalParamError) as exc_info: 69 | YooMoneyError.raise_most_appropriate_error(YooMoneyErrorSchema(error_code=error_code)) 70 | 71 | assert exc_info.value.param_name == expected_param_name 72 | -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/test_event_fetching/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/mocks.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | 3 | TEST_BASE64_WEBHOOK_KEY = 'JcyVhjHCvHQwufz+IHXolyqHgEc5MoayBfParl6Guoc=' 4 | 5 | 6 | @dataclasses.dataclass() 7 | class WebhookTestData: 8 | bill_webhook_json: str 9 | base64_key_to_compare_hash: str 10 | transaction_webhook_json: str 11 | 12 | 13 | MOCK_BILL_WEBHOOK_RAW_DATA = """{ 14 | "bill": { 15 | "siteId": "9hh4jb-00", 16 | "billId": "cc961e8d-d4d6-4f02-b737-2297e51fb48e", 17 | "amount": { 18 | "value": "1.00", 19 | "currency": "RUB" 20 | }, 21 | "status": { 22 | "value": "PAID", 23 | "changedDateTime": "2021-01-18T15:25:18+03" 24 | }, 25 | "customer": { 26 | "phone": "78710009999", 27 | "email": "test@tester.com", 28 | "account": "454678" 29 | }, 30 | "customFields": { 31 | "paySourcesFilter": "qw", 32 | "themeCode": "Yvan-YKaSh", 33 | "yourParam1": "64728940", 34 | "yourParam2": "order 678" 35 | }, 36 | "comment": "Text comment", 37 | "creationDateTime": "2021-01-18T15:24:53+03", 38 | "expirationDateTime": "2025-12-10T09:02:00+03" 39 | }, 40 | "version": "1" 41 | }""" 42 | 43 | MOCK_TRANSACTION_WEBHOOK_RAW_DATA = """{ 44 | "messageId":"7814c49d-2d29-4b14-b2dc-36b377c76156", 45 | "hookId":"5e2027d1-f5f3-4ad1-b409-058b8b8a8c22", 46 | "payment":{ 47 | "txnId":"13353941550", 48 | "date":"2018-06-27T13:39:00+03:00", 49 | "type":"IN", 50 | "status":"SUCCESS", 51 | "errorCode":"0", 52 | "personId":78000008000, 53 | "account":"+79165238345", 54 | "comment":"", 55 | "provider":7, 56 | "sum":{ 57 | "amount":1, 58 | "currency":643 59 | }, 60 | "commission":{ 61 | "amount":0, 62 | "currency":643 63 | }, 64 | "total":{ 65 | "amount":1, 66 | "currency":643 67 | }, 68 | "signFields":"sum.currency,sum.amount,type,account,txnId" 69 | }, 70 | "hash":"76687ffe5c516c793faa46fafba0994e7ca7a6d735966e0e0c0b65eaa43bdca0", 71 | "version":"1.0.0", 72 | "test":false 73 | }""" 74 | -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/test_filters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/test_event_fetching/test_filters/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/test_filters/test_custom_filters.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from pytest_mock import MockerFixture 5 | 6 | from glQiwiApi.core import BaseFilter 7 | from glQiwiApi.core.event_fetching import filters 8 | from glQiwiApi.qiwi.clients.wallet.types import Transaction 9 | 10 | pytestmark = pytest.mark.asyncio 11 | 12 | 13 | class FirstCustomFilter(BaseFilter[Transaction]): 14 | async def check(self, update: Transaction) -> bool: 15 | return False 16 | 17 | 18 | class SecondCustomFilter(BaseFilter[Transaction]): 19 | async def check(self, update: Transaction) -> bool: 20 | return True 21 | 22 | 23 | def test_create_filters(): 24 | first_filter = FirstCustomFilter() 25 | second_filter = SecondCustomFilter() 26 | assert all(isinstance(f, BaseFilter) for f in [first_filter, second_filter]) 27 | 28 | 29 | async def test_and_chain_filters(mocker: MockerFixture): 30 | mock = mocker.Mock(spec=Transaction) 31 | first_filter = FirstCustomFilter() 32 | second_filter = SecondCustomFilter() 33 | chained_filter = filters.AndFilter(first_filter, second_filter) 34 | assert not await chained_filter.check(mock) 35 | 36 | 37 | async def test_not_filter(mocker: MockerFixture): 38 | mock = mocker.Mock(spec=Transaction) 39 | not_filter = filters.NotFilter(FirstCustomFilter()) 40 | assert await not_filter.check(mock) is True 41 | 42 | 43 | @pytest.mark.xfail( 44 | raises=TypeError, 45 | reason="We cannot chain two filters, if they don't inherits from BaseFilter", 46 | ) 47 | async def test_fail_chain_filters(): 48 | first_filter = FirstCustomFilter() 49 | second_filter = lambda x: x is not None # wrong type # noqa 50 | filters.AndFilter(first_filter, second_filter) # type: ignore # noqa 51 | 52 | 53 | async def test_check_lambda_filter(mocker: MockerFixture): 54 | lambda_filter: filters.LambdaBasedFilter[Transaction] = filters.LambdaBasedFilter( 55 | lambda x: x is not None 56 | ) 57 | mock = mocker.Mock(spec=Transaction) 58 | assert await lambda_filter.check(mock) is True 59 | -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/test_webhook/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/test_event_fetching/test_webhook/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/test_webhook/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.unit.test_event_fetching.mocks import ( 4 | MOCK_BILL_WEBHOOK_RAW_DATA, 5 | MOCK_TRANSACTION_WEBHOOK_RAW_DATA, 6 | TEST_BASE64_WEBHOOK_KEY, 7 | WebhookTestData, 8 | ) 9 | 10 | 11 | @pytest.fixture() 12 | def test_data() -> WebhookTestData: 13 | return WebhookTestData( 14 | bill_webhook_json=MOCK_BILL_WEBHOOK_RAW_DATA, 15 | base64_key_to_compare_hash=TEST_BASE64_WEBHOOK_KEY, 16 | transaction_webhook_json=MOCK_TRANSACTION_WEBHOOK_RAW_DATA, 17 | ) 18 | -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/test_webhook/test_app.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from aiohttp.pytest_plugin import AiohttpClient 3 | from aiohttp.test_utils import TestClient 4 | from aiohttp.web_app import Application 5 | from aiohttp.web_request import Request 6 | 7 | from glQiwiApi.core.event_fetching import IPFilter, QiwiDispatcher 8 | from glQiwiApi.core.event_fetching.webhooks.app import configure_app 9 | from glQiwiApi.core.event_fetching.webhooks.config import EncryptionConfig, WebhookConfig 10 | from glQiwiApi.core.event_fetching.webhooks.middlewares.ip import ip_filter_middleware 11 | from tests.unit.test_event_fetching.mocks import WebhookTestData 12 | 13 | 14 | class TestAiohttpServer: 15 | def test_configure_app(self, test_data: WebhookTestData): 16 | app = Application() 17 | 18 | dp = QiwiDispatcher() 19 | configure_app( 20 | dp, 21 | app, 22 | WebhookConfig( 23 | encryption=EncryptionConfig( 24 | secret_p2p_key='', base64_encryption_key=test_data.base64_key_to_compare_hash 25 | ) 26 | ), 27 | ) 28 | 29 | assert len(app.router.routes()) == 2 30 | 31 | async def test_ip_middleware(self, aiohttp_client: AiohttpClient): 32 | app = Application() 33 | ip_filter = IPFilter.default() 34 | app.middlewares.append(ip_filter_middleware(ip_filter)) 35 | 36 | async def handler(_: Request): 37 | return web.json_response({'ok': True}) 38 | 39 | app.router.add_route('POST', '/webhook', handler) 40 | client: TestClient = await aiohttp_client(app) 41 | 42 | resp = await client.post('/webhook') 43 | assert resp.status == 401 44 | 45 | resp = await client.post('/webhook', headers={'X-Forwarded-For': '79.142.16.2'}) 46 | assert resp.status == 200 47 | 48 | resp = await client.post( 49 | '/webhook', headers={'X-Forwarded-For': '79.142.16.2,91.213.51.238'} 50 | ) 51 | assert resp.status == 200 52 | -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/test_webhook/test_services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/test_event_fetching/test_webhook/test_services/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/test_webhook/test_services/test_collision_detector.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from glQiwiApi.core.event_fetching import ( 6 | HashBasedCollisionDetector, 7 | UnexpectedCollision, 8 | UnhashableObjectError, 9 | ) 10 | from glQiwiApi.qiwi.clients.wallet.types import TransactionWebhook 11 | 12 | 13 | class UnhashableTestClass: 14 | def __eq__(self, other): 15 | return False 16 | 17 | 18 | @pytest.fixture(name='detector') 19 | def collision_detector_fixture() -> HashBasedCollisionDetector: 20 | return HashBasedCollisionDetector() 21 | 22 | 23 | def test_add_processed_event( 24 | test_webhook: TransactionWebhook, detector: HashBasedCollisionDetector 25 | ): 26 | detector.remember_processed_object(test_webhook) 27 | assert hash(test_webhook) in detector.already_processed_object_hashes 28 | 29 | 30 | def test_collision_error_raise( 31 | test_webhook: TransactionWebhook, detector: HashBasedCollisionDetector 32 | ): 33 | detector.remember_processed_object(test_webhook) 34 | with pytest.raises(UnexpectedCollision): 35 | detector.remember_processed_object(test_webhook) 36 | 37 | 38 | def test_fail_if_object_is_unhashable( 39 | test_webhook: TransactionWebhook, detector: HashBasedCollisionDetector 40 | ): 41 | with pytest.raises(UnhashableObjectError): 42 | detector.remember_processed_object(UnhashableTestClass()) 43 | -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/test_webhook/test_services/test_security.py: -------------------------------------------------------------------------------- 1 | from ipaddress import IPv4Address, IPv4Network 2 | 3 | import pytest 4 | 5 | from glQiwiApi.core.event_fetching.webhooks.services.security.ip import IPFilter 6 | 7 | 8 | class TestSecurity: 9 | def test_empty_init(self): 10 | ip_filter = IPFilter() 11 | assert not ip_filter._allowed_ips 12 | 13 | @pytest.mark.parametrize( 14 | 'ip,result', 15 | [ 16 | ('127.0.0.1', True), 17 | ('127.0.0.2', False), 18 | (IPv4Address('127.0.0.1'), True), 19 | (IPv4Address('127.0.0.2'), False), 20 | (IPv4Address('91.213.51.3'), True), 21 | ('192.168.0.33', False), 22 | ('91.213.51.5', True), 23 | ('91.213.51.8', True), 24 | ('10.111.1.100', False), 25 | ], 26 | ) 27 | def test_check_ip(self, ip, result): 28 | ip_filter = IPFilter( 29 | ips=['127.0.0.1', IPv4Address('91.213.51.3'), IPv4Network('91.213.51.0/24')] 30 | ) 31 | assert (ip in ip_filter) is result 32 | 33 | def test_default(self): 34 | ip_filter = IPFilter.default() 35 | assert isinstance(ip_filter, IPFilter) 36 | assert len(ip_filter._allowed_ips) == 5880 37 | assert '79.142.16.1' in ip_filter 38 | assert '195.189.100.19' in ip_filter 39 | -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/test_webhook/test_utils.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from aiohttp.web_request import Request 3 | 4 | from glQiwiApi.core.event_fetching.webhooks.utils import inject_dependencies 5 | 6 | 7 | class A: 8 | pass 9 | 10 | 11 | class B: 12 | pass 13 | 14 | 15 | class SomeView(web.View): 16 | def __init__(self, request: Request, a: A, b: B): 17 | super().__init__(request) 18 | self.a = a 19 | self.b = b 20 | 21 | async def post(self): 22 | return web.json_response(data={'ok': True}) 23 | 24 | 25 | def test_inject_dependencies(): 26 | dependencies = {'a': A(), 'b': B()} 27 | patched_view = inject_dependencies(SomeView, dependencies) 28 | assert patched_view('hello').a == dependencies['a'] # type: ignore # noqa 29 | assert patched_view('hello').b == dependencies['b'] # type: ignore # noqa 30 | -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/test_webhook/test_views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/test_event_fetching/test_webhook/test_views/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/test_webhook/test_views/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import no_type_check 2 | 3 | import pytest 4 | 5 | 6 | @no_type_check 7 | @pytest.fixture 8 | def loop(event_loop): 9 | """ 10 | Spike for compatibility pytest-asyncio and pytest-aiohttp 11 | @param event_loop: 12 | @return: 13 | """ 14 | return event_loop 15 | -------------------------------------------------------------------------------- /tests/unit/test_event_fetching/test_webhook/test_views/test_p2p_view.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from asyncio import AbstractEventLoop 3 | 4 | import pytest 5 | from aiohttp.pytest_plugin import AiohttpClient 6 | from aiohttp.test_utils import TestClient 7 | from aiohttp.web_app import Application 8 | 9 | from glQiwiApi.core import QiwiBillWebhookView 10 | from glQiwiApi.core.event_fetching import HashBasedCollisionDetector, QiwiDispatcher 11 | from glQiwiApi.core.event_fetching.webhooks.utils import inject_dependencies 12 | from glQiwiApi.qiwi.clients.p2p.types import BillWebhook 13 | from tests.unit.test_event_fetching.mocks import WebhookTestData 14 | 15 | pytestmark = pytest.mark.asyncio 16 | 17 | 18 | class QiwiBillWebhookViewWithoutSignatureValidation(QiwiBillWebhookView): 19 | def _validate_event_signature(self, update: BillWebhook) -> None: 20 | """ 21 | We cannot test, because we have not X-Api-Signature-SHA256 for webhook. 22 | I have tested it manually on remote host 23 | """ 24 | 25 | 26 | class TestBillWebhookView: 27 | async def test_with_right_payload( 28 | self, aiohttp_client: AiohttpClient, test_data: WebhookTestData, loop: AbstractEventLoop 29 | ): 30 | dp = QiwiDispatcher() 31 | app = Application() 32 | event_handled_by_handler = asyncio.Event() 33 | 34 | @dp.bill_handler() 35 | async def handle_bill_webhook(_: BillWebhook): 36 | event_handled_by_handler.set() 37 | 38 | app.router.add_view( 39 | handler=inject_dependencies( 40 | QiwiBillWebhookViewWithoutSignatureValidation, 41 | { 42 | 'event_cls': BillWebhook, 43 | 'dispatcher': dp, 44 | 'encryption_key': test_data.base64_key_to_compare_hash, 45 | 'collision_detector': HashBasedCollisionDetector(), 46 | }, 47 | ), 48 | path='/webhook', 49 | name='bill_webhook', 50 | ) 51 | 52 | client: TestClient = await aiohttp_client(app) 53 | response = await client.post('/webhook', json=test_data.bill_webhook_json) 54 | 55 | assert response.status == 200 56 | assert await response.json() == {'error': '0'} 57 | 58 | assert event_handled_by_handler.is_set() is True 59 | -------------------------------------------------------------------------------- /tests/unit/test_initialization/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/test_initialization/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_initialization/test_qiwi_p2p_client.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | 5 | from glQiwiApi import QiwiP2PClient 6 | from glQiwiApi.qiwi.clients.p2p.client import NoShimUrlWasProvidedError 7 | from glQiwiApi.utils.compat import remove_suffix 8 | 9 | 10 | def test_old_style_deprecation_warn(): 11 | old_style_shim_url = 'https://example.com/proxy/p2p/{0}' 12 | 13 | expected_warn_message = ( 14 | 'Old-style urls that were used like format-like strings are deprecated ' 15 | ) 16 | f"use plain path like this - {remove_suffix(old_style_shim_url, '{0}')} instead." 17 | 18 | with pytest.warns(DeprecationWarning, match=expected_warn_message): 19 | _ = QiwiP2PClient(secret_p2p='fake secret p2p', shim_server_url=old_style_shim_url) 20 | 21 | 22 | def test_strip_format_variable_when_old_style_url_passed(): 23 | old_style_shim_url = 'https://example.com/proxy/p2p/{0}' 24 | 25 | c = QiwiP2PClient(secret_p2p='fake secret p2p', shim_server_url=old_style_shim_url) 26 | 27 | assert c._shim_server_url == 'https://example.com/proxy/p2p/' 28 | 29 | 30 | def test_create_shim_url(): 31 | c = QiwiP2PClient( 32 | secret_p2p='fake secret p2p', shim_server_url='https://example.com/proxy/p2p/' 33 | ) 34 | 35 | random_uuid = str(uuid.uuid4()) 36 | 37 | assert c.create_shim_url(random_uuid) == f'https://example.com/proxy/p2p/{random_uuid}' 38 | 39 | 40 | def test_create_shim_url_with_old_style_urls(): 41 | c = QiwiP2PClient( 42 | secret_p2p='fake secret p2p', shim_server_url='https://example.com/proxy/p2p/{0}' 43 | ) 44 | 45 | random_uuid = str(uuid.uuid4()) 46 | 47 | assert c.create_shim_url(random_uuid) == f'https://example.com/proxy/p2p/{random_uuid}' 48 | 49 | 50 | def test_raise_when_shim_url_is_None(): 51 | c = QiwiP2PClient(secret_p2p='fake secret p2p') 52 | 53 | with pytest.raises(NoShimUrlWasProvidedError): 54 | c.create_shim_url(str(uuid.uuid4())) 55 | -------------------------------------------------------------------------------- /tests/unit/test_plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/test_plugins/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_plugins/test_telegram_plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/test_plugins/test_telegram_plugins/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_plugins/test_telegram_plugins/test_polling.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aiogram import Bot, Dispatcher 3 | from pytest_mock import MockerFixture 4 | 5 | from glQiwiApi.plugins.aiogram.polling import AiogramPollingPlugin 6 | 7 | 8 | class TestPollingPlugin: 9 | @pytest.mark.skipif( 10 | 'sys.version_info <= (3, 8)', 11 | reason="required functionality of pytest-mock doesn't work for this test on python <= 3.8", 12 | ) 13 | async def test_install_telegram_polling_plugin(self, mocker: MockerFixture): 14 | dispatcher = Dispatcher(Bot(token='231:23dfgd', validate_token=False)) 15 | start_polling_mock_method = mocker.AsyncMock(spec=dispatcher.start_polling) 16 | 17 | dispatcher.start_polling = start_polling_mock_method 18 | plugin = AiogramPollingPlugin(dispatcher=dispatcher) 19 | await plugin.install(ctx={}) 20 | 21 | start_polling_mock_method.assert_awaited_once() 22 | 23 | async def test_shutdown_polling_plugin(self, mocker: MockerFixture): 24 | dispatcher = Dispatcher(Bot(token='231:23dfgd', validate_token=False)) 25 | shutdown_mock_method = mocker.Mock(spec=dispatcher.stop_polling) 26 | dispatcher.stop_polling = shutdown_mock_method 27 | plugin = AiogramPollingPlugin(dispatcher=dispatcher) 28 | await plugin.shutdown() 29 | 30 | shutdown_mock_method.assert_called_once() 31 | -------------------------------------------------------------------------------- /tests/unit/test_plugins/test_telegram_plugins/test_webhook.py: -------------------------------------------------------------------------------- 1 | import py.path 2 | import pytest 3 | from aiogram import Bot, Dispatcher 4 | from pytest_mock import MockerFixture 5 | 6 | from glQiwiApi.core.event_fetching.webhooks.config import ApplicationConfig 7 | from glQiwiApi.plugins.aiogram.webhook import ( 8 | DEFAULT_TELEGRAM_WEBHOOK_PATH, 9 | DEFAULT_TELEGRAM_WEBHOOK_PATH_PREFIX, 10 | AiogramWebhookPlugin, 11 | ) 12 | from glQiwiApi.utils.certificates import SSLCertificate, get_or_generate_self_signed_certificate 13 | 14 | 15 | @pytest.fixture() 16 | def self_signed_certificate(tmpdir: py.path.local) -> SSLCertificate: 17 | tmpdir.mkdir('test_certificates') 18 | path_to_cert = tmpdir.join('cert.pem') 19 | path_to_pkey = tmpdir.join('pkey.pem') 20 | return get_or_generate_self_signed_certificate( 21 | hostname='45.138.24.80', cert_path=path_to_cert, pkey_path=path_to_pkey 22 | ) 23 | 24 | 25 | class TestWebhookPlugins: 26 | @pytest.mark.skipif( 27 | 'sys.version_info <= (3, 8)', 28 | reason="required functionality of pytest-mock doesn't work for this test on python <= 3.8", 29 | ) 30 | async def test_webhook_plugin_install( 31 | self, mocker: MockerFixture, self_signed_certificate: SSLCertificate 32 | ): 33 | bot = Bot('32423:dfgd', validate_token=False) 34 | 35 | async def set_webhook_stub(*args, **kwargs): 36 | pass 37 | 38 | bot.set_webhook = set_webhook_stub 39 | spy = mocker.spy(bot, 'set_webhook') 40 | dispatcher = Dispatcher(bot) 41 | 42 | plugin = AiogramWebhookPlugin( 43 | dispatcher, 44 | host='localhost', 45 | app_config=ApplicationConfig(ssl_certificate=self_signed_certificate), 46 | ) 47 | run_app_mock = mocker.patch('glQiwiApi.plugins.aiogram.webhook.run_app', autospec=True) 48 | await plugin.install(ctx={}) 49 | 50 | expected_webhook_url = ( 51 | 'localhost' # noqa 52 | + DEFAULT_TELEGRAM_WEBHOOK_PATH_PREFIX # noqa 53 | + DEFAULT_TELEGRAM_WEBHOOK_PATH.format(token='32423:dfgd') # noqa 54 | ) 55 | spy.assert_awaited_once_with( 56 | url=expected_webhook_url, certificate=self_signed_certificate.as_input_file() 57 | ) 58 | run_app_mock.assert_called_once() 59 | -------------------------------------------------------------------------------- /tests/unit/test_session/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /tests/unit/test_session/test_aiohttp_session_holder.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | import aiohttp 4 | import pytest 5 | from aiohttp import TCPConnector 6 | 7 | from glQiwiApi.core.session.holder import AiohttpSessionHolder 8 | 9 | pytestmark = pytest.mark.asyncio 10 | 11 | 12 | @pytest.fixture() 13 | async def session_holder() -> AsyncGenerator[AiohttpSessionHolder, None]: 14 | holder = AiohttpSessionHolder() 15 | yield holder 16 | await holder.close() 17 | 18 | 19 | async def test_get_session(session_holder: AiohttpSessionHolder) -> None: 20 | session: aiohttp.ClientSession = await session_holder.get_session() 21 | assert isinstance(session, aiohttp.ClientSession) is True 22 | 23 | 24 | async def test_close(session_holder: AiohttpSessionHolder) -> None: 25 | await session_holder.get_session() 26 | await session_holder.close() 27 | assert session_holder._session.closed is True 28 | 29 | 30 | async def test_update_session_kwargs(session_holder: AiohttpSessionHolder) -> None: 31 | conn = TCPConnector() 32 | session_holder.update_session_kwargs(connector=conn) 33 | assert session_holder._session_kwargs.get('connector') == conn 34 | 35 | 36 | async def test_context_manager_of_holder(session_holder: AiohttpSessionHolder) -> None: 37 | async with session_holder as new_session: 38 | assert isinstance(new_session, aiohttp.ClientSession) is True 39 | assert new_session.closed is True 40 | -------------------------------------------------------------------------------- /tests/unit/test_types/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /tests/unit/test_types/test_arbitrary/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /tests/unit/test_utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/test_utils/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_utils/test_api_helper.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from glQiwiApi import QiwiMaps 4 | from glQiwiApi.qiwi.clients.wallet.types import Partner 5 | from glQiwiApi.utils.synchronous import async_as_sync 6 | 7 | 8 | def test_async_as_sync(): 9 | result = 0 10 | 11 | @async_as_sync() 12 | async def my_async_func(): 13 | nonlocal result 14 | await asyncio.sleep(0.1) 15 | result += 1 16 | 17 | my_async_func() 18 | assert result == 1 19 | 20 | 21 | def test_async_as_sync_with_callback(): 22 | callback_visited = asyncio.Event() 23 | 24 | @async_as_sync() 25 | async def callback(): 26 | callback_visited.set() 27 | 28 | @async_as_sync(async_shutdown_callback=callback) 29 | async def my_async_func(): 30 | async with QiwiMaps() as maps: 31 | partners = await maps.partners() 32 | assert all(isinstance(p, Partner) for p in partners) 33 | 34 | my_async_func() 35 | assert callback_visited.is_set() is True 36 | -------------------------------------------------------------------------------- /tests/unit/test_utils/test_certificates.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | 3 | from py.path import local 4 | 5 | from glQiwiApi.utils.certificates import get_or_generate_self_signed_certificate 6 | 7 | 8 | def test_generate_self_signed_certificates(tmpdir: local) -> None: 9 | tmpdir.mkdir('certificates') 10 | path_to_cert = tmpdir.join('cert.pem') 11 | path_to_pkey = tmpdir.join('pkey.pem') 12 | get_or_generate_self_signed_certificate( 13 | hostname='45.138.24.80', cert_path=path_to_cert, pkey_path=path_to_pkey 14 | ) 15 | assert path_to_cert.isfile() is True 16 | assert path_to_pkey.isfile() is True 17 | 18 | 19 | def test_get_ssl_context(tmpdir: local) -> None: 20 | tmpdir.mkdir('certificates') 21 | ssl_certificate = get_or_generate_self_signed_certificate( 22 | hostname='45.138.24.80', 23 | cert_path=tmpdir.join('cert.pem'), 24 | pkey_path=tmpdir.join('pkey.pem'), 25 | ) 26 | context = ssl_certificate.as_ssl_context() 27 | assert isinstance(context, ssl.SSLContext) 28 | 29 | 30 | def test_get_input_file(tmpdir: local) -> None: 31 | tmpdir.mkdir('certificates') 32 | ssl_certificate = get_or_generate_self_signed_certificate( 33 | hostname='45.138.24.80', 34 | cert_path=tmpdir.join('cert.pem'), 35 | pkey_path=tmpdir.join('pkey.pem'), 36 | ) 37 | input_file = ssl_certificate.as_input_file() 38 | assert input_file.get_file().read() == tmpdir.join('cert.pem').read().encode('utf-8') 39 | -------------------------------------------------------------------------------- /tests/unit/test_utils/test_currency_parser.py: -------------------------------------------------------------------------------- 1 | from glQiwiApi.types._currencies import described 2 | from glQiwiApi.types.amount import CurrencyModel 3 | from glQiwiApi.utils.currency_util import Currency 4 | 5 | 6 | def test_parse_described_currencies(): 7 | condition = all(isinstance(Currency().get(key), CurrencyModel) for key in described.keys()) 8 | assert condition 9 | 10 | 11 | def test_parse_non_existent_currency(): 12 | assert Currency().get(currency_code='dsfgsgdsfg') is None 13 | -------------------------------------------------------------------------------- /tests/unit/test_utils/test_executor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/test_utils/test_executor/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_utils/test_executor/test_webhook.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/glQiwiApi/3414f3b6640531d8409a3f18d82edb87919aee88/tests/unit/test_utils/test_executor/test_webhook.py --------------------------------------------------------------------------------