├── .editorconfig ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── docs ├── changelog.md ├── code_of_conduct.md ├── contributing.md ├── gen_ref_pages.py ├── getting_started.md ├── implemented_endpoints.md ├── index.md └── license.md ├── mkdocs.yml ├── noxfile.py ├── pyproject.toml ├── src └── pymonzo │ ├── __init__.py │ ├── accounts │ ├── __init__.py │ ├── enums.py │ ├── resources.py │ └── schemas.py │ ├── attachments │ ├── __init__.py │ ├── resources.py │ └── schemas.py │ ├── balance │ ├── __init__.py │ ├── resources.py │ └── schemas.py │ ├── client.py │ ├── exceptions.py │ ├── feed │ ├── __init__.py │ ├── resources.py │ └── schemas.py │ ├── pots │ ├── __init__.py │ ├── resources.py │ └── schemas.py │ ├── py.typed │ ├── resources.py │ ├── settings.py │ ├── transactions │ ├── __init__.py │ ├── enums.py │ ├── resources.py │ └── schemas.py │ ├── utils.py │ ├── webhooks │ ├── __init__.py │ ├── resources.py │ └── schemas.py │ └── whoami │ ├── __init__.py │ ├── resources.py │ └── schemas.py └── tests ├── __init__.py └── pymonzo ├── __init__.py ├── cassettes ├── test_accounts │ └── TestAccountsResource.test_list_vcr.yaml.enc ├── test_balance │ └── TestBalanceResource.test_get_vcr.yaml.enc ├── test_pots │ └── TestPotsResource.test_list_vcr.yaml.enc └── test_whoami │ └── TestWhoAmIResource.test_whoami_vcr.yaml.enc ├── conftest.py ├── test_accounts.py ├── test_attachments.py ├── test_balance.py ├── test_client.py ├── test_feed.py ├── test_pots.py ├── test_resources.py ├── test_settings.py ├── test_transactions.py ├── test_utils.py ├── test_webhooks.py └── test_whoami.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.py] 15 | indent_size = 4 16 | 17 | [Makefile] 18 | indent_style = tab 19 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @pawelad 2 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | pawel.ad@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "CI" 3 | 4 | on: 5 | push: 6 | branches: ["main"] 7 | pull_request: 8 | branches: ["main"] 9 | workflow_call: 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: "${{ github.workflow }}-${{ github.ref }}" 14 | cancel-in-progress: true 15 | 16 | env: 17 | FORCE_COLOR: "1" 18 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 19 | PYTHON_VERSION: "3.11" 20 | 21 | jobs: 22 | tests: 23 | name: "Tests" 24 | runs-on: "ubuntu-latest" 25 | strategy: 26 | matrix: 27 | python-version: 28 | - "3.9" 29 | - "3.10" 30 | - "3.11" 31 | - "3.12" 32 | - "3.13" 33 | steps: 34 | - name: "Checkout code" 35 | uses: "actions/checkout@v4" 36 | 37 | - name: "Setup Python ${{ matrix.python-version }}" 38 | uses: "actions/setup-python@v5" 39 | with: 40 | python-version: "${{ matrix.python-version }}" 41 | cache: "pip" 42 | cache-dependency-path: "pyproject.toml" 43 | 44 | - name: "Update pip and wheel" 45 | run: "python -m pip install --upgrade pip wheel" 46 | 47 | - name: "Install Nox" 48 | run: "python -m pip install nox" 49 | 50 | - name: "Run tests (via Nox) on Python ${{ matrix.python-version }}" 51 | run: "nox -s tests --python ${{ matrix.python-version }}" 52 | env: 53 | VCRPY_ENCRYPTION_KEY: "${{ secrets.VCRPY_ENCRYPTION_KEY }}" 54 | 55 | - name: "Upload coverage data" 56 | uses: "actions/upload-artifact@v4" 57 | with: 58 | name: "coverage-data-${{ matrix.python-version }}" 59 | path: ".coverage.*" 60 | if-no-files-found: "ignore" 61 | include-hidden-files: true 62 | 63 | coverage: 64 | name: "Coverage report" 65 | runs-on: "ubuntu-latest" 66 | needs: "tests" 67 | 68 | steps: 69 | - name: "Checkout code" 70 | uses: "actions/checkout@v4" 71 | 72 | - name: "Setup Python ${{ env.PYTHON_VERSION }}" 73 | uses: "actions/setup-python@v5" 74 | with: 75 | python-version: "${{ env.PYTHON_VERSION }}" 76 | cache: "pip" 77 | cache-dependency-path: "pyproject.toml" 78 | 79 | - name: "Update pip and wheel" 80 | run: "python -m pip install --upgrade pip wheel" 81 | 82 | - name: "Install Nox" 83 | run: "python -m pip install nox" 84 | 85 | - uses: "actions/download-artifact@v4" 86 | with: 87 | pattern: "coverage-data-*" 88 | merge-multiple: "true" 89 | 90 | - name: "Combine coverage (via Nox)" 91 | run: "nox -s coverage_report" 92 | 93 | - name: "Upload coverage reports to Codecov" 94 | uses: "codecov/codecov-action@v4" 95 | with: 96 | files: "coverage.xml" 97 | env: 98 | CODECOV_TOKEN: "${{ secrets.CODECOV_TOKEN }}" 99 | 100 | checks: 101 | name: "${{ matrix.name }}" 102 | runs-on: "ubuntu-latest" 103 | strategy: 104 | matrix: 105 | include: 106 | - name: "Code style checks" 107 | nox_session: "code_style_checks" 108 | - name: "Type checks" 109 | nox_session: "type_checks" 110 | - name: "Docs" 111 | nox_session: "docs" 112 | steps: 113 | - name: "Checkout code" 114 | uses: "actions/checkout@v4" 115 | 116 | - name: "Setup Python ${{ env.PYTHON_VERSION }}" 117 | uses: "actions/setup-python@v5" 118 | with: 119 | python-version: "${{ env.PYTHON_VERSION }}" 120 | cache: "pip" 121 | cache-dependency-path: "pyproject.toml" 122 | 123 | - name: "Update pip and wheel" 124 | run: "python -m pip install --upgrade pip wheel" 125 | 126 | - name: "Install Nox" 127 | run: "python -m pip install nox" 128 | 129 | - name: "Run '${{ matrix.nox_session }}' Nox session" 130 | run: "nox -s ${{ matrix.nox_session }}" 131 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Release package to PyPI" 3 | 4 | on: 5 | release: 6 | types: ["published"] 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: "release-pypi" 11 | cancel-in-progress: false 12 | 13 | permissions: 14 | contents: "read" 15 | id-token: "write" 16 | 17 | jobs: 18 | tests: 19 | name: "Run tests and checks" 20 | uses: "./.github/workflows/ci.yml" 21 | secrets: "inherit" 22 | 23 | build-package: 24 | name: "Build and verify package" 25 | runs-on: "ubuntu-latest" 26 | steps: 27 | - name: "Checkout code" 28 | uses: "actions/checkout@v4" 29 | with: 30 | fetch-depth: 0 31 | 32 | - name: "Build and verify package" 33 | uses: "hynek/build-and-inspect-python-package@v2" 34 | 35 | release-test-pypi: 36 | name: "Publish package to test.pypi.org" 37 | runs-on: "ubuntu-latest" 38 | environment: "release-test-pypi" 39 | needs: 40 | - "tests" 41 | - "build-package" 42 | steps: 43 | - name: "Download packages built by `build-and-inspect-python-package`" 44 | uses: "actions/download-artifact@v4" 45 | with: 46 | name: "Packages" 47 | path: "dist" 48 | 49 | - name: "Upload package to Test PyPI" 50 | uses: "pypa/gh-action-pypi-publish@release/v1" 51 | with: 52 | repository-url: "https://test.pypi.org/legacy/" 53 | 54 | release-pypi: 55 | name: "Publish package to pypi.org" 56 | runs-on: "ubuntu-latest" 57 | environment: "release-pypi" 58 | needs: "release-test-pypi" 59 | steps: 60 | - name: "Download packages built by `build-and-inspect-python-package`" 61 | uses: "actions/download-artifact@v4" 62 | with: 63 | name: "Packages" 64 | path: "dist" 65 | 66 | - name: "Upload package to PyPI" 67 | uses: "pypa/gh-action-pypi-publish@release/v1" 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | 166 | ### Python Patch ### 167 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 168 | poetry.toml 169 | 170 | # ruff 171 | .ruff_cache/ 172 | 173 | # LSP config files 174 | pyrightconfig.json 175 | 176 | # End of https://www.toptal.com/developers/gitignore/api/python 177 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-22.04" 5 | tools: 6 | python: "3.11" 7 | 8 | mkdocs: 9 | configuration: "mkdocs.yml" 10 | 11 | python: 12 | install: 13 | - method: "pip" 14 | path: "." 15 | extra_requirements: 16 | - "docs" 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog], and this project adheres to 5 | [Semantic Versioning]. 6 | 7 | ## Unreleased 8 | 9 | ## [v2.2.1](https://github.com/pawelad/pymonzo/releases/tag/v2.2.1) - 2024-09-11 10 | ### Changed 11 | - Make extra API schema fields accessible through Pydantic `model_extra` attribute. 12 | 13 | ### Fixed 14 | - Never expect undocumented API fields to be present. 15 | 16 | ## [v2.2.0](https://github.com/pawelad/pymonzo/releases/tag/v2.2.0) - 2024-09-11 17 | ### Added 18 | - Add `counterparty` field support to Monzo transactions 19 | (by [@csogilvie](https://github.com/csogilvie) 20 | in [#34](https://github.com/pawelad/pymonzo/issues/34)). 21 | 22 | ### Changed 23 | - Stop being strict with certain Monzo enums and allow any string values. 24 | 25 | Specifically, account's `type`, `currency` and transaction's `decline_reason`. 26 | - Changed `empty_str_to_none` logic to be in line with `empty_dict_to_none`. 27 | 28 | ### Fixed 29 | - Use 'form data' instead of 'query params' for relevant Monzo API endpoints 30 | (by [m-roberts](https://github.com/m-roberts) 31 | in [#39](https://github.com/pawelad/pymonzo/pull/39)). 32 | 33 | Previously, these endpoints (incorrectly) sent request arguments through 'query 34 | params' and not 'form data': 35 | - `AttachmentsResource.upload()` (`POST /attachment/upload`) 36 | - `AttachmentsResource.register()` (`POST /attachment/register`) 37 | - `AttachmentsResource.deregister()` (`POST /attachment/deregister`) 38 | - `FeedResource.create()` (`POST /feed`) 39 | - `PotsResource.deposit()` (`PUT /pots/{pot_id}/deposit`) 40 | - `PotsResource.withdraw()` (`PUT /pots/{pot_id}/withdraw`) 41 | - `TransactionsResource.annotate()` (`PATCH /transactions/{transaction_id}`) 42 | - `WebhooksResource.register()` (`POST /webhooks`) 43 | - Add (more) missing transaction decline reasons 44 | (by [chris987p](https://github.com/chris987p) 45 | in [#42](https://github.com/pawelad/pymonzo/pull/42)). 46 | - Add (more) missing account types (by [@m-roberts](https://github.com/m-roberts) 47 | in [#38](https://github.com/pawelad/pymonzo/pull/38)). 48 | - Fix listing transactions with `expand_merchants=True` when `suggested_tags` isn't 49 | present (by [@csogilvie](https://github.com/csogilvie) 50 | in [#34](https://github.com/pawelad/pymonzo/issues/34)). 51 | - Allow transaction category to be any string. Monzo supports custom categories 52 | as part of their "Plus" plan. 53 | 54 | ## [v2.1.0](https://github.com/pawelad/pymonzo/releases/tag/v2.1.0) - 2024-04-16 55 | ### Added 56 | - Add `rich` support to `MonzoPot`. 57 | 58 | ### Changed 59 | - When using `rich`, make transaction title red if the amount is negative. 60 | - Update `codecov/codecov-action` GitHub Action to v4. 61 | 62 | ## [v2.0.1](https://github.com/pawelad/pymonzo/releases/tag/v2.0.1) - 2024-04-13 63 | ### Fixed 64 | - Monzo pot `goal_amount` is not always present. 65 | (by [@csogilvie](https://github.com/csogilvie) 66 | in [#32](https://github.com/pawelad/pymonzo/pull/32)) 67 | - Add missing account types. 68 | (by [@csogilvie](https://github.com/csogilvie) 69 | in [#31](https://github.com/pawelad/pymonzo/pull/31)) 70 | - Add missing space to `NoSettingsFile` exception message. 71 | 72 | ## [v2.0.0](https://github.com/pawelad/pymonzo/releases/tag/v2.0.0) - 2024-03-07 73 | ### Added 74 | - Add (optional) `rich` and `babel` support. 75 | - Add `expand_merchant` parameter to `TransactionsResource.list`. It's not very 76 | clear in the API docs, but it works on that endpoint as well. 77 | - Add custom `NoSettingsFile` exception. It's raised when the access token wasn't 78 | passed explicitly to `MonzoAPI()` and the settings file couldn't be loaded. 79 | 80 | ### Changed 81 | - Update `MonzoTransactionMerchant` schema with new fields returned by the API. 82 | - Simplify `MonzoAPI` initialization. 83 | This (unfortunately) needed an API change because the current attributes (in 84 | hindsight) didn't really make sense. 85 | Now, you can either use an already generated (and temporary) access 86 | token, or generate it with `MonzoAPI.authorize()` and load from disk. 87 | 88 | ### Fixed 89 | - Make `MonzoTransaction.settled` validator run in `before` mode. 90 | - Add new `MonzoTransactionDeclineReason` values missing from Monzo API docs. 91 | - Add new `MonzoTransactionCategory` values missing from Monzo API docs. 92 | - Remove Markdown links from PyPI package description. 93 | 94 | ## [v1.0.0](https://github.com/pawelad/pymonzo/releases/tag/v1.0.0) - 2024-02-04 95 | ### Changed 96 | - Project refresh. 97 | 98 | ## [v0.11.0](https://github.com/pawelad/pymonzo/releases/tag/v0.11.0) - 2018-02-16 99 | ### Added 100 | - Made redirect URI, token file name and token path configurable via 101 | environment variables. ([#14](https://github.com/pawelad/pymonzo/pull/14)) 102 | - Added Monzo Pots API endpoint. (by [@Sheaffy](https://github.com/Sheaffy) 103 | in [#13](https://github.com/pawelad/pymonzo/pull/13)) 104 | 105 | ### Changed 106 | - Renamed `config.PYMONZO_REDIRECT_URI` to `config.REDIRECT_URI`. 107 | 108 | ## [v0.10.3](https://github.com/pawelad/pymonzo/releases/tag/v0.10.3) - 2017-10-15 109 | ### Fixed 110 | - Fixed saving token file to disk. ([#9](https://github.com/pawelad/pymonzo/pull/9)) 111 | 112 | ## [v0.10.2](https://github.com/pawelad/pymonzo/releases/tag/v0.10.2) - 2017-10-05 113 | ### Fixed 114 | - Fixed automatic token refreshing. (by [@bartonp](https://github.com/bartonp) 115 | in [#5](https://github.com/pawelad/pymonzo/pull/5)) 116 | 117 | ### Changed 118 | - `MonzoAPI()._refresh_oath_token()` now doesn't return anything, replaces 119 | current token and raises `CantRefreshTokenError` when token couldn't be 120 | refreshed. 121 | - Client secret is now saved in token file JSON file. 122 | - Cleaned up exceptions. 123 | 124 | ## [v0.10.1](https://github.com/pawelad/pymonzo/releases/tag/v0.10.1) - 2017-09-24 125 | ### Fixed 126 | - Try to refresh token if API request returned HTTP 401, which could mean that 127 | the token is expired. ([#6](https://github.com/pawelad/pymonzo/pull/6)) 128 | 129 | ## [v0.10.0](https://github.com/pawelad/pymonzo/releases/tag/v0.10.0) - 2017-09-22 130 | ### Changed 131 | - Changed token file format from `shelve` to JSON. Because of that the file 132 | will need to be regenerated. ([#3](https://github.com/pawelad/pymonzo/pull/3)) 133 | - Updated `six` library to version 1.11.0. 134 | 135 | ## [v0.9.1](https://github.com/pawelad/pymonzo/releases/tag/v0.9.1) - 2017-09-21 136 | ### Deprecated 137 | - Added deprecation warning about changing token file format from `shelve` 138 | to JSON in next release. Because of that the file will need to be 139 | regenerated. ([#4](https://github.com/pawelad/pymonzo/pull/4)) 140 | 141 | ## [v0.9.0](https://github.com/pawelad/pymonzo/releases/tag/v0.9.0) - 2017-09-17 142 | ### Added 143 | - Started keeping a changelog. 144 | 145 | ### Changed 146 | - Major test suite overhaul. 147 | - Code cleanup. 148 | 149 | 150 | [keep a changelog]: https://keepachangelog.com/en/1.1.0/ 151 | [semantic versioning]: https://semver.org/spec/v2.0.0.html 152 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | Welcome! If you'd like to contribute to `pymonzo`, you came to right place. Hopefully, 3 | everything noteworthy is mentioned, but if you still have some questions after reading 4 | it all, don't hesitate to open up a [new issue][github new issue]. 5 | 6 | Please also note that this project is released with a [Contributor Code of Conduct]. 7 | By participating in this project you agree to abide by its terms. 8 | 9 | ## Tools used 10 | **Language:** [Python 3.9+][python] 11 | **CI:** [GitHub Actions] 12 | **Docs:** [mkdocs], [mkdocs-material], [mkdocstrings], [Read The Docs] 13 | **Testing:** [pytest], [nox] 14 | **Coverage:** [Coverage.py], [Codecov] 15 | **Type checks:** [mypy] 16 | **Code style:** [black], [isort], [ruff], [interrogate] 17 | **Other:** Makefile 18 | 19 | ## Code style 20 | All code is formatted with the amazing `black`, `isort` and `ruff` tools via 21 | `make format` helper command. 22 | 23 | ## Tests 24 | Tests are written with help of [pytest] and run via [nox] (alongside other checks). 25 | To run the test suite yourself, all you need to do is run: 26 | 27 | ```console 28 | $ # Install nox 29 | $ python -m pip install nox 30 | $ # Run nox 31 | $ make test 32 | ``` 33 | 34 | ## Makefile 35 | Available `make` commands: 36 | 37 | ```console 38 | $ make help 39 | install Install package in editable mode 40 | format Format code 41 | test Run the test suite 42 | docs-build Build docs 43 | docs-serve Serve docs 44 | build Build package 45 | publish Publish package 46 | clean Clean dev artifacts 47 | help Show help message 48 | ``` 49 | 50 | 51 | [black]: https://black.readthedocs.io/ 52 | [codecov]: https://codecov.io/ 53 | [contributor code of conduct]: ./.github/CODE_OF_CONDUCT.md 54 | [coverage.py]: https://coverage.readthedocs.io 55 | [github actions]: https://github.com/features/actions 56 | [github new issue]: https://github.com/pawelad/pymonzo/issues/new/choose 57 | [interrogate]: https://github.com/econchick/interrogate 58 | [isort]: https://github.com/timothycrosley/isort 59 | [mkdocs-material]: https://squidfunk.github.io/mkdocs-material/ 60 | [mkdocs]: https://www.mkdocs.org/ 61 | [mkdocstrings]: https://mkdocstrings.github.io/ 62 | [mypy]: https://mypy-lang.org/ 63 | [nox]: https://nox.readthedocs.io/ 64 | [pytest]: https://pytest.org/ 65 | [python]: https://www.python.org/ 66 | [read the docs]: https://readthedocs.com/ 67 | [ruff]: https://docs.astral.sh/ruff 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Source: http://clarkgrubb.com/makefile-style-guide 2 | MAKEFLAGS += --warn-undefined-variables 3 | .DEFAULT_GOAL := help 4 | 5 | .PHONY: install 6 | install: ## Install package in editable mode 7 | python -m pip install --upgrade pip wheel 8 | python -m pip install --editable ".[dev]" 9 | 10 | .PHONY: format 11 | format: ## Format code 12 | black src/ tests/ noxfile.py 13 | isort src/ tests/ noxfile.py 14 | ruff check --fix src/ tests/ noxfile.py 15 | 16 | .PHONY: test 17 | test: ## Run the test suite 18 | nox 19 | 20 | .PHONY: docs-build 21 | docs-build: ## Build docs 22 | mkdocs build 23 | 24 | .PHONY: docs-serve 25 | docs-serve: ## Serve docs 26 | mkdocs serve 27 | 28 | .PHONY: build 29 | build: ## Build package 30 | python -m flit build 31 | 32 | .PHONY: publish 33 | publish: ## Publish package 34 | python -m flit publish 35 | 36 | .PHONY: clean 37 | clean: ## Clean dev artifacts 38 | rm -rf .coverage coverage.xml .mypy_cache/ .nox/ .pytest_cache/ .ruff_cache/ dist/ htmlcov/ site/ 39 | 40 | # Source: https://www.client9.com/self-documenting-makefiles/ 41 | .PHONY: help 42 | help: ## Show help message 43 | @awk -F ':|##' '/^[^\t].+?:.*?##/ {\ 44 | printf "\033[36m%-40s\033[0m %s\n", $$1, $$NF \ 45 | }' $(MAKEFILE_LIST) 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pymonzo 2 | [![Package Version](https://img.shields.io/pypi/v/pymonzo)][pypi pymonzo] 3 | [![Supported Python Versions](https://img.shields.io/pypi/pyversions/pymonzo)][pypi pymonzo] 4 | [![Read the Docs](https://img.shields.io/readthedocs/pymonzo)][rtfd pymonzo] 5 | [![Codecov](https://img.shields.io/codecov/c/github/pawelad/pymonzo)][codecov pymonzo] 6 | [![License](https://img.shields.io/pypi/l/pymonzo)][license] 7 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)][black] 8 | [![py.typed](https://img.shields.io/badge/py-typed-FFD43B)][py.typed] 9 | 10 | Modern Python API client for [Monzo] public [API][monzo api docs]. 11 | 12 | - Works on Python 3.9+ 13 | - Fully type annotated 14 | - Explicitly defined and validated API schemas (via [Pydantic]) 15 | - Sensible defaults (don't specify account / pot ID if you only have one active) 16 | - Easy authentication (with automatic access token refreshing) 17 | - (Optional) [Rich] support for pretty printing 18 | 19 | For example usage, feel free to take a look at [pawelad/monz][github monz]. 20 | 21 | --- 22 | 23 | This project is not officially affiliated with [Monzo]. 24 | 25 | ## Installation 26 | From [PyPI] (ideally, inside a [virtualenv]): 27 | 28 | ```console 29 | $ python -m pip install pymonzo 30 | ``` 31 | 32 | ## Quick start 33 | Here's an example of what `pymonzo` can do: 34 | 35 | ```pycon 36 | >>> from pymonzo import MonzoAPI 37 | >>> monzo_api = MonzoAPI() 38 | >>> accounts = monzo_api.accounts.list() 39 | >>> len(accounts) 40 | 2 41 | >>> # Only one active account, so we don't need to pass it explicitly 42 | >>> monzo_api.balance.get() 43 | MonzoBalance(balance=75000, total_balance=95012, currency='GBP', spend_today=0, balance_including_flexible_savings=95012, local_currency='', local_exchange_rate=0, local_spend=[]) 44 | >>> from pymonzo.utils import n_days_ago 45 | >>> transactions = monzo_api.transactions.list(since=n_days_ago(5)) 46 | >>> len(transactions) 47 | 8 48 | ``` 49 | 50 | ## Authors 51 | Developed and maintained by [Paweł Adamczak][pawelad]. 52 | 53 | Source code is available at [GitHub][github pymonzo]. 54 | 55 | If you'd like to contribute, please take a look at the 56 | [contributing guide]. 57 | 58 | Released under [Mozilla Public License 2.0][license]. 59 | 60 | 61 | [black]: https://github.com/psf/black 62 | [codecov pymonzo]: https://app.codecov.io/github/pawelad/pymonzo 63 | [contributing guide]: ./CONTRIBUTING.md 64 | [github monz]: https://github.com/pawelad/monz 65 | [github pymonzo]: https://github.com/pawelad/pymonzo 66 | [license]: ./LICENSE 67 | [monzo api docs]: https://docs.monzo.com/ 68 | [monzo developer tools]: https://developers.monzo.com/ 69 | [monzo]: https://monzo.com/ 70 | [pawelad]: https://pawelad.me/ 71 | [py.typed]: https://mortifex.xyz/py-typed 72 | [pydantic]: https://github.com/pydantic/pydantic 73 | [pypi pymonzo]: https://pypi.org/project/pymonzo/ 74 | [pypi]: https://pypi.org/ 75 | [rich]: https://github.com/Textualize/rich 76 | [rtfd pymonzo]: https://pymonzo.rtfd.io/ 77 | [virtualenv]: https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/ 78 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | {% include-markdown '../CHANGELOG.md' %} 2 | -------------------------------------------------------------------------------- /docs/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | {% include-markdown '../.github/CODE_OF_CONDUCT.md' %} 2 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | {% include-markdown '../CONTRIBUTING.md' %} 2 | 3 | 4 | [contributor code of conduct]: code_of_conduct.md 5 | -------------------------------------------------------------------------------- /docs/gen_ref_pages.py: -------------------------------------------------------------------------------- 1 | """Generate the code reference pages and navigation. 2 | 3 | Source: https://mkdocstrings.github.io/recipes/ 4 | """ 5 | from pathlib import Path 6 | 7 | import mkdocs_gen_files 8 | 9 | nav = mkdocs_gen_files.Nav() 10 | 11 | for path in sorted(Path("src").rglob("*.py")): 12 | module_path = path.relative_to("src").with_suffix("") 13 | doc_path = path.relative_to("src").with_suffix(".md") 14 | full_doc_path = Path("reference", doc_path) 15 | 16 | parts = tuple(module_path.parts) 17 | 18 | if parts[-1] == "__init__": 19 | parts = parts[:-1] 20 | doc_path = doc_path.with_name("index.md") 21 | full_doc_path = full_doc_path.with_name("index.md") 22 | elif parts[-1] == "__main__": 23 | continue 24 | 25 | nav[parts] = doc_path.as_posix() 26 | 27 | with mkdocs_gen_files.open(full_doc_path, "w") as fd: 28 | ident = ".".join(parts) 29 | fd.write(f"::: {ident}") 30 | 31 | mkdocs_gen_files.set_edit_path(full_doc_path, path) 32 | 33 | with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: 34 | nav_file.writelines(nav.build_literate_nav()) 35 | -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | Before starting, please take note of these warnings from [Monzo API docs]: 3 | 4 | !!! warning "The Monzo Developer API is not suitable for building public applications" 5 | 6 | You may only connect to your own account or those of a small set of users you 7 | explicitly allow. Please read our [blog post](https://monzo.com/blog/2017/05/11/api-update/) 8 | for more detail. 9 | 10 | !!! warning "Strong Customer Authentication" 11 | 12 | After a user has authenticated, your client can fetch all of their transactions, 13 | and after 5 minutes, it can only sync the last 90 days of transactions. If you 14 | need the user’s entire transaction history, you should consider fetching and 15 | storing it right after authentication. 16 | 17 | ## Installation 18 | From [PyPI] (ideally, inside a [virtualenv]): 19 | 20 | ```console 21 | $ python -m pip install pymonzo 22 | ``` 23 | 24 | ## Authentication 25 | Monzo API implements OAuth 2.0 authorization code grant type. To use it, you need 26 | to first create an OAuth client in Monzo [developer tools][monzo developer tools]. 27 | 28 | You should set the "Redirect URLs" to `http://localhost:6600/pymonzo` and set the 29 | client as confidential if you want the access token to be refreshed automatically 30 | (name, description and logo don't really matter). 31 | 32 | That should give you a client ID and client secret, which you need to pass to 33 | [`pymonzo.MonzoAPI.authorize`][] function: 34 | 35 | ```pycon 36 | >>> from pymonzo import MonzoAPI 37 | >>> MonzoAPI.authorize( 38 | client_id="oauth2client_***", 39 | client_secret="mnzconf.***", 40 | ) 41 | 2022-09-15 20:21.37 [info ] Please visit this URL to authorize: https://auth.monzo.com/?response_type=code&client_id=oauth2client_***&redirect_uri=http%3A%2F%2Flocalhost%3A6600%2Fpymonzo&state=PY5VAKZwwrdOz8qyzzEojb90vFp78S 42 | ``` 43 | 44 | This should open a new web browser tab (if it didn't, go to the link from the 45 | log message) that will let you authorize the OAuth client you just created. If 46 | everything goes well, you should be redirected to `http://localhost:6600/pymonzo` 47 | and greeted with `Monzo OAuth authorization complete.` message. 48 | 49 | Note that you might need to open your mobile app to allow full access to your account. 50 | 51 | That's it! The access token is saved locally at `~/.pymonzo` and - as long as you set 52 | the OAuth client as confidential - should be refreshed automatically when it expires. 53 | 54 | ```pycon 55 | >>> from pymonzo import MonzoAPI 56 | >>> monzo_api = MonzoAPI() 57 | >>> monzo_api.whoami() 58 | MonzoWhoAmI(authenticated=True, client_id='oauth2client_***', user_id='user_***') 59 | ``` 60 | 61 | ## Usage 62 | All implemented API endpoints are grouped into resources and 'mounted' to the 63 | [`pymonzo.MonzoAPI`][] class: 64 | 65 | ```pycon 66 | >>> from pymonzo import MonzoAPI 67 | >>> monzo_api = MonzoAPI() 68 | >>> accounts = monzo_api.accounts.list() 69 | >>> # If you have only one active account, you don't need to pass account ID 70 | >>> balance = monzo_api.balance.get(account_id=accounts[0].id) 71 | >>> pots = monzo_api.pots.list() 72 | >>> # Similarly, if you have only one active pot, you don't need to pass pot ID 73 | >>> monzo_api.pots.deposit(amount=5, pot_id=pots[0].id) 74 | >>> # Remember that you can fetch all transactions only within 5 minutes of being authenticated 75 | >>> transactions = monzo_api.transactions.list() 76 | ``` 77 | 78 | You can find all mounted resources, implemented endpoints and their arguments by 79 | looking at [`pymonzo.MonzoAPI`][] docs. 80 | 81 | 82 | [monzo developer tools]: https://developers.monzo.com/ 83 | [monzo api docs]: https://docs.monzo.com/ 84 | [pypi]: https://pypi.org/ 85 | [virtualenv]: https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/ 86 | -------------------------------------------------------------------------------- /docs/implemented_endpoints.md: -------------------------------------------------------------------------------- 1 | # Implemented Endpoints 2 | Currently, only transaction receipts endpoints are **not** implemented: 3 | 4 | - [x] `GET /ping/whoami` 5 | - [x] `GET /accounts` 6 | - [x] `GET /balance` 7 | - [x] `GET /pots` 8 | - [x] `PUT /pots/$pot_id/deposit` 9 | - [x] `PUT /pots/$pot_id/withdraw` 10 | - [x] `GET /transactions` 11 | - [x] `GET /transactions/$transaction_id` 12 | - [x] `PATCH /transactions/$transaction_id` 13 | - [x] `POST /feed` 14 | - [x] `POST /attachment/upload` 15 | - [x] `POST /attachment/register` 16 | - [x] `POST /attachment/deregister` 17 | - [ ] `GET /transaction-receipts` 18 | - [ ] `PUT /transaction-receipts` 19 | - [ ] `DELETE /transaction-receipts` 20 | - [x] `GET /webhooks` 21 | - [x] `POST /webhooks` 22 | - [x] `DELETE /webhooks/$webhook_id` 23 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | {% include-markdown '../README.md' %} 2 | 3 | 4 | [contributing guide]: contributing.md 5 | [license]: license.md 6 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | ``` 4 | {% include '../LICENSE' %} 5 | ``` 6 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: "pymonzo" 2 | site_description: "Modern Python API client for Monzo public API" 3 | site_url: "https://pymonzo.pawelad.dev/" 4 | site_author: "Paweł Adamczak" 5 | repo_name: "pawelad/pymonzo" 6 | repo_url: "https://github.com/pawelad/pymonzo" 7 | 8 | watch: 9 | - "README.md" 10 | - "CHANGELOG.md" 11 | - "CONTRIBUTING.md" 12 | - "LICENSE" 13 | - ".github/CODE_OF_CONDUCT.md" 14 | - "src/pymonzo" 15 | 16 | nav: 17 | - Home: 18 | - Overview: "index.md" 19 | - Getting Started: "getting_started.md" 20 | - Implemented Endpoints: "implemented_endpoints.md" 21 | - Changelog: "changelog.md" 22 | - License: "license.md" 23 | - Development: 24 | - Contributing Guide: "contributing.md" 25 | - Code of Conduct: "code_of_conduct.md" 26 | - Code Reference: "reference/" 27 | 28 | theme: 29 | name: "material" 30 | palette: 31 | - media: "(prefers-color-scheme: light)" 32 | scheme: "default" 33 | primary: "pink" 34 | toggle: 35 | icon: "material/lightbulb-outline" 36 | name: "Switch to dark mode" 37 | - media: "(prefers-color-scheme: dark)" 38 | scheme: "slate" 39 | primary: "pink" 40 | toggle: 41 | icon: "material/lightbulb" 42 | name: "Switch to light mode" 43 | features: 44 | - "navigation.tabs" 45 | 46 | plugins: 47 | - "search" 48 | - "include-markdown" 49 | - "mkdocstrings" 50 | - gen-files: 51 | scripts: 52 | - "docs/gen_ref_pages.py" 53 | - literate-nav: 54 | nav_file: "SUMMARY.md" 55 | - "section-index" 56 | 57 | markdown_extensions: 58 | - "admonition" 59 | - "pymdownx.details" 60 | - "pymdownx.magiclink" 61 | - "pymdownx.superfences" 62 | - pymdownx.tasklist: 63 | custom_checkbox: true 64 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """pymonzo Nox sessions.""" 2 | 3 | import os 4 | 5 | import nox 6 | 7 | nox.options.reuse_existing_virtualenvs = True 8 | nox.options.error_on_external_run = True 9 | 10 | DEFAULT_PATHS = ["src/", "tests/", "noxfile.py"] 11 | 12 | 13 | @nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"]) 14 | def tests(session: nox.Session) -> None: 15 | """Run tests.""" 16 | dirs = session.posargs or ["tests/"] 17 | 18 | session.install(".[tests]") 19 | 20 | session.run("coverage", "run", "-m", "pytest", *dirs) 21 | 22 | if os.environ.get("CI") != "true": 23 | session.notify("coverage_report") 24 | 25 | 26 | @nox.session() 27 | def coverage_report(session: nox.Session) -> None: 28 | """Report coverage. Can only be run after `tests` session.""" 29 | session.install("coverage[toml]") 30 | 31 | session.run("coverage", "combine") 32 | session.run("coverage", "xml") 33 | session.run("coverage", "report") 34 | 35 | 36 | @nox.session() 37 | def docs(session: nox.Session) -> None: 38 | """Build docs.""" 39 | session.install(".[docs]") 40 | 41 | session.run("mkdocs", "build", "--strict") 42 | 43 | 44 | @nox.session() 45 | def code_style_checks(session: nox.Session) -> None: 46 | """Check code style.""" 47 | dirs = session.posargs or DEFAULT_PATHS 48 | 49 | session.install("black", "isort", "ruff", "interrogate") 50 | 51 | session.run("black", "--check", "--diff", *dirs) 52 | session.run("isort", "--check", "--diff", *dirs) 53 | session.run("ruff", "check", "--diff", *dirs) 54 | session.run("interrogate", *dirs) 55 | 56 | 57 | @nox.session() 58 | def type_checks(session: nox.Session) -> None: 59 | """Run type checks.""" 60 | dirs = session.posargs or DEFAULT_PATHS 61 | 62 | session.install(".[dev]") 63 | 64 | session.run("mypy", *dirs) 65 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "pymonzo" 7 | description = "Modern Python API client for Monzo public API." 8 | authors = [{name = "Paweł Adamczak", email = "pawel.ad@gmail.com"}] 9 | readme = "README.md" 10 | requires-python = ">=3.9" 11 | license = {file = "LICENSE"} 12 | keywords = ["monzo", "api"] 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | "Typing :: Typed", 26 | ] 27 | dynamic = ["version"] 28 | 29 | dependencies = [ 30 | "Authlib", 31 | "httpx<0.28.0", # https://github.com/lundberg/respx/issues/277 32 | "pydantic-settings", 33 | "pydantic>2", 34 | "typing_extensions; python_version<'3.11'", 35 | ] 36 | 37 | [project.optional-dependencies] 38 | tests = [ 39 | "coverage[toml]", 40 | "freezegun", 41 | "polyfactory", 42 | "pytest", 43 | "pytest-mock", 44 | "pytest-recording", 45 | "python-dotenv", 46 | "respx", 47 | "vcrpy-encrypt", 48 | ] 49 | docs = [ 50 | "mkdocs", 51 | "mkdocs-gen-files", 52 | "mkdocs-include-markdown-plugin", 53 | "mkdocs-literate-nav", 54 | "mkdocs-material", 55 | "mkdocs-section-index", 56 | "mkdocstrings[python]", 57 | ] 58 | dev = [ 59 | "pymonzo[tests,docs]", 60 | # Code style 61 | "black", 62 | "interrogate", 63 | "isort", 64 | "ruff", 65 | # Type checking 66 | "mypy<1.11.0", # https://github.com/python/mypy/issues/17535 67 | "rich", 68 | # Misc 69 | "flit", 70 | "nox", 71 | ] 72 | 73 | [project.urls] 74 | Homepage = "https://pymonzo.pawelad.dev/" 75 | Documentation = "https://pymonzo.readthedocs.io/" 76 | GitHub = "https://github.com/pawelad/pymonzo" 77 | Issues = "https://github.com/pawelad/pymonzo/issues" 78 | 79 | [tool.black] 80 | target-version = ["py39"] 81 | 82 | [tool.coverage.run] 83 | branch = true 84 | parallel = true 85 | source = ["src"] 86 | 87 | [tool.coverage.report] 88 | show_missing = true 89 | exclude_lines = [ 90 | "if TYPE_CHECKING:", 91 | # TODO: Test optional `rich` support 92 | "if RICH_AVAILABLE:", 93 | ] 94 | 95 | [tool.interrogate] 96 | ignore-init-module = true 97 | ignore-nested-classes = true 98 | fail-under = 95 99 | 100 | [tool.isort] 101 | profile = "black" 102 | src_paths = ["src"] 103 | 104 | [tool.mypy] 105 | python_version = "3.9" 106 | mypy_path = "src" 107 | plugins = [ 108 | "pydantic.mypy", 109 | ] 110 | 111 | [[tool.mypy.overrides]] 112 | module = [ 113 | "authlib.integrations.base_client", 114 | "authlib.integrations.httpx_client", 115 | "vcr", 116 | "vcrpy_encrypt", 117 | ] 118 | ignore_missing_imports = true 119 | 120 | [tool.pytest.ini_options] 121 | minversion = "6.0" 122 | addopts = "-ra --import-mode=importlib --verbose" 123 | testpaths = "tests" 124 | pythonpath = "src" 125 | 126 | [tool.ruff] 127 | target-version = "py39" 128 | 129 | [tool.ruff.lint] 130 | select = [ 131 | "A", # flake8-builtins 132 | "ANN", # flake8-annotations 133 | "B", # flake8-bugbear 134 | "C4", # flake8-comprehensions 135 | "C90", # mccabe 136 | "D", # pydocstyle 137 | "DJ", # flake8-django 138 | "E", # flake8 139 | "F", # flake8 140 | "N", # pep8-naming 141 | "PT", # flake8-pytest-style 142 | "S", # flake8-bandit 143 | "SIM", # flake8-simplify 144 | "T20", # flake8-print 145 | "TRY", # tryceratops 146 | "UP", # pyupgrade 147 | ] 148 | ignore = [ 149 | "A003", # Class attribute is shadowing a python builtin 150 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `**kwargs` 151 | "D104", # Missing docstring in public package 152 | "D106", # Missing docstring in public nested class 153 | "N818", # Exception name should be named with an Error suffix 154 | "S101", # Use of `assert` detected 155 | "TRY003", # Avoid specifying long messages outside the exception class 156 | ] 157 | 158 | [tool.ruff.lint.pydocstyle] 159 | convention = "google" 160 | 161 | [tool.ruff.lint.flake8-pytest-style] 162 | fixture-parentheses = true 163 | mark-parentheses = true 164 | -------------------------------------------------------------------------------- /src/pymonzo/__init__.py: -------------------------------------------------------------------------------- 1 | """Modern Python API client for [Monzo] public [API][Monzo API]. 2 | 3 | It exposes a [`pymonzo.MonzoAPI`][] class that can be used to access implemented 4 | endpoints. HTTP requests are made with [httpx], data parsing and validation is done 5 | with [pydantic]. 6 | 7 | [Monzo API]: https://docs.monzo.com/ 8 | [Monzo]: https://monzo.com/ 9 | [httpx]: https://github.com/encode/httpx 10 | [pydantic]: https://github.com/pydantic/pydantic 11 | """ 12 | 13 | from pymonzo.client import MonzoAPI # noqa 14 | 15 | __title__ = "pymonzo" 16 | __description__ = "Modern Python API client for Monzo public API." 17 | __version__ = "2.2.2.dev0" 18 | __url__ = "https://github.com/pawelad/pymonzo" 19 | __author__ = "Paweł Adamczak" 20 | __email__ = "pawel.ad@gmail.com" 21 | __license__ = "MPL-2.0" 22 | -------------------------------------------------------------------------------- /src/pymonzo/accounts/__init__.py: -------------------------------------------------------------------------------- 1 | """pymonzo `accounts` package. 2 | 3 | Note: 4 | Monzo API docs: https://docs.monzo.com/#accounts 5 | """ 6 | 7 | from .enums import MonzoAccountCurrency, MonzoAccountType # noqa 8 | from .resources import AccountsResource # noqa 9 | from .schemas import MonzoAccount, MonzoAccountOwner # noqa 10 | -------------------------------------------------------------------------------- /src/pymonzo/accounts/enums.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'accounts' related enums.""" 2 | 3 | from enum import Enum 4 | 5 | 6 | class MonzoAccountType(str, Enum): 7 | """Monzo API 'account type' enum. 8 | 9 | Note: 10 | Currently undocumented in Monzo API docs. 11 | """ 12 | 13 | UK_PREPAID = "uk_prepaid" 14 | UK_RETAIL = "uk_retail" 15 | UK_REWARDS = "uk_rewards" 16 | UK_BUSINESS = "uk_business" 17 | UK_LOAN = "uk_loan" 18 | UK_MONZO_FLEX = "uk_monzo_flex" 19 | UK_RETAIL_JOINT = "uk_retail_joint" 20 | 21 | 22 | class MonzoAccountCurrency(str, Enum): 23 | """Monzo API 'account currency' enum. 24 | 25 | Note: 26 | Currently undocumented in Monzo API docs. 27 | """ 28 | 29 | GBP = "GBP" 30 | -------------------------------------------------------------------------------- /src/pymonzo/accounts/resources.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'accounts' resource.""" 2 | 3 | from dataclasses import dataclass, field 4 | 5 | from pymonzo.accounts.schemas import MonzoAccount 6 | from pymonzo.exceptions import CannotDetermineDefaultAccount 7 | from pymonzo.resources import BaseResource 8 | 9 | 10 | @dataclass 11 | class AccountsResource(BaseResource): 12 | """Monzo API 'accounts' resource. 13 | 14 | Note: 15 | Monzo API docs: https://docs.monzo.com/#accounts 16 | """ 17 | 18 | _cached_accounts: list[MonzoAccount] = field(default_factory=list) 19 | 20 | def get_default_account(self) -> MonzoAccount: 21 | """If the user has only one active account, treat it as the default account. 22 | 23 | Returns: 24 | User's active account. 25 | 26 | Raises: 27 | CannotDetermineDefaultAccount: If user has more than one active account. 28 | """ 29 | accounts = self.list() 30 | 31 | # If there is only one account, return it 32 | if len(accounts) == 1: 33 | return accounts[0] 34 | 35 | # Otherwise check if there is only one active (non-closed) account 36 | active_accounts = [account for account in accounts if not account.closed] 37 | 38 | if len(active_accounts) == 1: 39 | return active_accounts[0] 40 | 41 | raise CannotDetermineDefaultAccount( 42 | "Cannot determine default account. " 43 | "You need to explicitly pass an 'account_id' argument." 44 | ) 45 | 46 | def list(self, *, refresh: bool = False) -> list[MonzoAccount]: 47 | """Return a list of user's Monzo accounts. 48 | 49 | It's often used when deciding whether to require explicit account ID 50 | or use the only active one, so we cache the response by default. 51 | 52 | Note: 53 | Monzo API docs: https://docs.monzo.com/#list-accounts 54 | 55 | Arguments: 56 | refresh: Whether to refresh the cached list of accounts. 57 | 58 | Returns: 59 | A list of user's Monzo accounts. 60 | """ 61 | if not refresh and self._cached_accounts: 62 | return self._cached_accounts 63 | 64 | endpoint = "/accounts" 65 | response = self._get_response(method="get", endpoint=endpoint) 66 | 67 | accounts = [MonzoAccount(**account) for account in response.json()["accounts"]] 68 | self._cached_accounts = accounts 69 | 70 | return accounts 71 | -------------------------------------------------------------------------------- /src/pymonzo/accounts/schemas.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'accounts' related schemas.""" 2 | 3 | from datetime import datetime 4 | from typing import Optional, Union 5 | 6 | from pydantic import BaseModel, ConfigDict, Field 7 | 8 | from pymonzo.accounts.enums import MonzoAccountCurrency, MonzoAccountType 9 | 10 | # Optional `rich` support 11 | try: 12 | from textwrap import wrap 13 | 14 | from rich.table import Table 15 | 16 | # Optional `babel` support 17 | try: 18 | from babel.dates import format_datetime 19 | except ImportError: 20 | from pymonzo.utils import format_datetime # type: ignore 21 | 22 | except ImportError: 23 | RICH_AVAILABLE = False 24 | else: 25 | RICH_AVAILABLE = True 26 | 27 | 28 | class MonzoAccountOwner(BaseModel): 29 | """API schema for an 'account owner' object. 30 | 31 | Note: 32 | Currently undocumented in Monzo API docs. 33 | 34 | Attributes: 35 | user_id: The ID of the user. 36 | preferred_name: Name preferred by the user. 37 | preferred_first_name: First name preferred by the user. 38 | """ 39 | 40 | model_config = ConfigDict(extra="allow") 41 | 42 | # Undocumented in API docs 43 | user_id: Optional[str] = None 44 | preferred_name: Optional[str] = None 45 | preferred_first_name: Optional[str] = None 46 | 47 | 48 | class MonzoAccount(BaseModel): 49 | """API schema for an 'account' object. 50 | 51 | Most attributes are currently undocumented in Monzo API docs. 52 | 53 | Note: 54 | Monzo API docs: https://docs.monzo.com/#accounts 55 | 56 | Attributes: 57 | id: The ID of the account. 58 | description: Account description. 59 | created: Account creation date. 60 | closed: Whether account is closed. 61 | type: Account type. 62 | currency: Account currency. 63 | country_code: Account country code. 64 | owners: Account owners. 65 | account_number: Account number. 66 | sort_code: Account sort code. 67 | payment_details: Account payment details. 68 | """ 69 | 70 | model_config = ConfigDict(extra="allow") 71 | 72 | id: str 73 | description: str 74 | created: datetime 75 | 76 | # Undocumented in Monzo API docs 77 | closed: Optional[bool] = None 78 | type: Union[MonzoAccountType, str, None] = Field( 79 | default=None, 80 | union_mode="left_to_right", 81 | ) 82 | currency: Union[MonzoAccountCurrency, str, None] = Field( 83 | default=None, 84 | union_mode="left_to_right", 85 | ) 86 | country_code: Optional[str] = None 87 | owners: Optional[list[MonzoAccountOwner]] = None 88 | 89 | # Only present in retail (non-prepaid) accounts 90 | account_number: Optional[str] = None 91 | sort_code: Optional[str] = None 92 | payment_details: Optional[dict] = None 93 | 94 | if RICH_AVAILABLE: 95 | 96 | def __rich__(self) -> Table: 97 | """Pretty printing support for `rich`.""" 98 | grid = Table.grid(padding=(0, 5)) 99 | grid.title = f"Account '{self.id}' ({self.country_code})" 100 | grid.title_style = "bold green" 101 | grid.add_column(style="bold cyan") 102 | grid.add_column(style="" if not self.closed else "dim") 103 | grid.add_row("ID:", self.id) 104 | grid.add_row("Description:", self.description) 105 | if self.currency: 106 | grid.add_row("Currency:", self.currency) 107 | if self.account_number: 108 | grid.add_row("Account Number:", self.account_number) 109 | if self.sort_code: 110 | grid.add_row("Sort Code:", "-".join(wrap(self.sort_code, 2))) 111 | if self.type: 112 | grid.add_row("Type:", self.type) 113 | grid.add_row("Closed:", "Yes" if self.closed else "No") 114 | grid.add_row("Created:", format_datetime(self.created)) 115 | 116 | return grid 117 | -------------------------------------------------------------------------------- /src/pymonzo/attachments/__init__.py: -------------------------------------------------------------------------------- 1 | """pymonzo `attachments` package. 2 | 3 | Note: 4 | Monzo API docs: https://docs.monzo.com/#attachments 5 | """ 6 | 7 | from .resources import AttachmentsResource # noqa 8 | from .schemas import MonzoAttachment, MonzoAttachmentResponse # noqa 9 | -------------------------------------------------------------------------------- /src/pymonzo/attachments/resources.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'attachments' resource.""" 2 | 3 | from pymonzo.attachments.schemas import MonzoAttachment, MonzoAttachmentResponse 4 | from pymonzo.resources import BaseResource 5 | 6 | 7 | class AttachmentsResource(BaseResource): 8 | """Monzo API 'attachments' resource. 9 | 10 | Note: 11 | Monzo API docs: https://docs.monzo.com/#attachments 12 | """ 13 | 14 | def upload( 15 | self, 16 | *, 17 | file_name: str, 18 | file_type: str, 19 | content_length: int, 20 | ) -> MonzoAttachmentResponse: 21 | """Upload an attachment. 22 | 23 | The response will include a `file_url` which will be the URL of the resulting 24 | file, and an `upload_url` to which the file should be uploaded to. 25 | 26 | Note: 27 | Monzo API docs: https://docs.monzo.com/#upload-attachment 28 | 29 | Arguments: 30 | file_name: The name of the file to be uploaded. 31 | file_type: The content type of the file. 32 | content_length: The HTTP Content-Length of the upload request body, 33 | in bytes. 34 | 35 | Returns: 36 | Response with `file_url` which will be the URL of the resulting file, 37 | and an `upload_url` to which the file should be uploaded to. 38 | """ 39 | endpoint = "/attachment/upload" 40 | data = { 41 | "file_name": file_name, 42 | "file_type": file_type, 43 | "content_length": content_length, 44 | } 45 | response = self._get_response(method="post", endpoint=endpoint, data=data) 46 | 47 | attachment_response = MonzoAttachmentResponse(**response.json()) 48 | 49 | return attachment_response 50 | 51 | def register( 52 | self, 53 | transaction_id: str, 54 | *, 55 | file_url: str, 56 | file_type: str, 57 | ) -> MonzoAttachment: 58 | """Register uploaded image to an attachment. 59 | 60 | Note: 61 | Monzo API docs: https://docs.monzo.com/#register-attachment 62 | 63 | Arguments: 64 | transaction_id: The ID of the transaction to associate the attachment with. 65 | file_url: The URL of the uploaded attachment. 66 | file_type: The content type of the attachment. 67 | 68 | Returns: 69 | A Monzo attachment. 70 | """ 71 | endpoint = "/attachment/register" 72 | data = { 73 | "external_id": transaction_id, 74 | "file_url": file_url, 75 | "file_type": file_type, 76 | } 77 | response = self._get_response(method="post", endpoint=endpoint, data=data) 78 | 79 | attachment = MonzoAttachment(**response.json()["attachment"]) 80 | 81 | return attachment 82 | 83 | def deregister(self, attachment_id: str) -> dict: 84 | """Deregister an attachment. 85 | 86 | Note: 87 | Monzo API docs: https://docs.monzo.com/#deregister-attachment 88 | 89 | Arguments: 90 | attachment_id: The ID of the attachment to deregister. 91 | 92 | Returns: 93 | API response. 94 | """ 95 | endpoint = "/attachment/deregister" 96 | data = {"id": attachment_id} 97 | response = self._get_response(method="post", endpoint=endpoint, data=data) 98 | 99 | return response.json() 100 | -------------------------------------------------------------------------------- /src/pymonzo/attachments/schemas.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'attachments' related schemas.""" 2 | 3 | from datetime import datetime 4 | 5 | from pydantic import BaseModel, ConfigDict 6 | 7 | 8 | class MonzoAttachment(BaseModel): 9 | """API schema for an 'attachment' object. 10 | 11 | Note: 12 | Monzo API docs: https://docs.monzo.com/#attachments 13 | 14 | Attributes: 15 | id: The ID of the attachment. 16 | user_id: The ID of the user who owns this attachment. 17 | external_id: The ID of the transaction to associate the attachment with. 18 | file_url: The URL at which the attachment is available. 19 | file_type: The content type of the attachment. 20 | created: The timestamp in UTC when the attachment was created. 21 | """ 22 | 23 | model_config = ConfigDict(extra="allow") 24 | 25 | id: str 26 | user_id: str 27 | external_id: str 28 | file_url: str 29 | file_type: str 30 | created: datetime 31 | 32 | 33 | class MonzoAttachmentResponse(BaseModel): 34 | """API schema for an 'attachment upload' API response. 35 | 36 | Note: 37 | Monzo API docs: https://docs.monzo.com/#upload-attachment 38 | 39 | Attributes: 40 | file_url: The URL of the file once it has been uploaded. 41 | upload_url: The URL to `POST` the file to when uploading. 42 | """ 43 | 44 | model_config = ConfigDict(extra="allow") 45 | 46 | file_url: str 47 | upload_url: str 48 | -------------------------------------------------------------------------------- /src/pymonzo/balance/__init__.py: -------------------------------------------------------------------------------- 1 | """pymonzo `balance` package. 2 | 3 | Note: 4 | Monzo API docs: https://docs.monzo.com/#balance 5 | """ 6 | 7 | from .resources import BalanceResource # noqa 8 | from .schemas import MonzoBalance # noqa 9 | -------------------------------------------------------------------------------- /src/pymonzo/balance/resources.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'balance' resource.""" 2 | 3 | from typing import Optional 4 | 5 | from pymonzo.balance.schemas import MonzoBalance 6 | from pymonzo.resources import BaseResource 7 | 8 | 9 | class BalanceResource(BaseResource): 10 | """Monzo API 'balance' resource. 11 | 12 | Note: 13 | Monzo API docs: https://docs.monzo.com/#balance 14 | """ 15 | 16 | def get(self, account_id: Optional[str] = None) -> MonzoBalance: 17 | """Return account balance information. 18 | 19 | Note: 20 | Monzo API docs: https://docs.monzo.com/#read-balance 21 | 22 | Arguments: 23 | account_id: The ID of the account. Can be omitted if user has only one 24 | active account. 25 | 26 | Returns: 27 | Monzo account balance information. 28 | 29 | Raises: 30 | CannotDetermineDefaultAccount: If no account ID was passed and default 31 | account cannot be determined. 32 | """ 33 | if not account_id: 34 | account_id = self.client.accounts.get_default_account().id 35 | 36 | endpoint = "/balance" 37 | params = {"account_id": account_id} 38 | response = self._get_response(method="get", endpoint=endpoint, params=params) 39 | 40 | balance = MonzoBalance(**response.json()) 41 | 42 | return balance 43 | -------------------------------------------------------------------------------- /src/pymonzo/balance/schemas.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'balance' related schemas.""" 2 | 3 | from typing import Optional 4 | 5 | from pydantic import BaseModel, ConfigDict 6 | 7 | # Optional `rich` support 8 | try: 9 | from rich.table import Table 10 | 11 | # Optional `babel` support 12 | try: 13 | from babel.numbers import format_currency 14 | except ImportError: 15 | from pymonzo.utils import format_currency # type: ignore 16 | 17 | except ImportError: 18 | RICH_AVAILABLE = False 19 | else: 20 | RICH_AVAILABLE = True 21 | 22 | 23 | class MonzoBalance(BaseModel): 24 | """API schema for a 'balance' object. 25 | 26 | Note: 27 | Monzo API docs: https://docs.monzo.com/#balance 28 | 29 | Attributes: 30 | balance: The currently available balance of the account, as a 64bit integer in 31 | minor units of the currency, eg. pennies for GBP, or cents for EUR and USD. 32 | total_balance: The sum of the currently available balance of the account 33 | and the combined total of all the user's pots. 34 | currency: The ISO 4217 currency code. 35 | spend_today: The amount spent from this account today (considered from approx 36 | 4am onwards), as a 64bit integer in minor units of the currency. 37 | balance_including_flexible_savings: Whether balance includes flexible 38 | savings pots. 39 | local_currency: Local currency. 40 | local_exchange_rate: Local exchange rate. 41 | local_spend: Local spend. 42 | """ 43 | 44 | model_config = ConfigDict(extra="allow") 45 | 46 | balance: int 47 | total_balance: int 48 | currency: str 49 | spend_today: int 50 | 51 | # Undocumented in Monzo API docs 52 | balance_including_flexible_savings: Optional[int] = None 53 | local_currency: Optional[str] = None 54 | local_exchange_rate: Optional[float] = None 55 | local_spend: Optional[list] = None 56 | 57 | if RICH_AVAILABLE: 58 | 59 | def __rich__(self) -> Table: 60 | """Pretty printing support for `rich`.""" 61 | balance = format_currency(self.balance / 100, self.currency) 62 | total_balance = format_currency(self.total_balance / 100, self.currency) 63 | spend_today = format_currency(self.spend_today / 100, self.currency) 64 | 65 | grid = Table.grid(padding=(0, 5)) 66 | grid.add_column(style="bold magenta") 67 | grid.add_column() 68 | grid.add_row("Balance:", balance) 69 | grid.add_row("Total balance:", total_balance) 70 | grid.add_row("Currency:", self.currency) 71 | grid.add_row("Spend today:", spend_today) 72 | if self.local_currency: 73 | grid.add_row("Local currency:", self.local_currency) 74 | if self.local_exchange_rate: 75 | grid.add_row("Local exchange rate:", str(self.local_exchange_rate)) 76 | 77 | return grid 78 | -------------------------------------------------------------------------------- /src/pymonzo/client.py: -------------------------------------------------------------------------------- 1 | """pymonzo API client code.""" 2 | 3 | import webbrowser 4 | from json import JSONDecodeError 5 | from pathlib import Path 6 | from typing import Any, Optional 7 | from urllib.parse import urlparse 8 | 9 | from authlib.integrations.base_client import OAuthError 10 | from authlib.integrations.httpx_client import OAuth2Client 11 | 12 | from pymonzo.accounts import AccountsResource 13 | from pymonzo.attachments import AttachmentsResource 14 | from pymonzo.balance import BalanceResource 15 | from pymonzo.exceptions import MonzoAPIError, NoSettingsFile 16 | from pymonzo.feed import FeedResource 17 | from pymonzo.pots import PotsResource 18 | from pymonzo.settings import PyMonzoSettings 19 | from pymonzo.transactions import TransactionsResource 20 | from pymonzo.utils import get_authorization_response_url 21 | from pymonzo.webhooks import WebhooksResource 22 | from pymonzo.whoami import WhoAmIResource 23 | 24 | 25 | class MonzoAPI: 26 | """Monzo public API client. 27 | 28 | To use it, you need to create a new OAuth client in [Monzo Developer Portal]. 29 | The `Redirect URLs` should be set to `http://localhost:6600/pymonzo` and 30 | `Confidentiality` should be set to `Confidential` if you'd like to automatically 31 | refresh the access token when it expires. 32 | 33 | You can now use `Client ID` and `Client secret` in [`pymonzo.MonzoAPI.authorize`][] 34 | to finish the OAuth 2 'Authorization Code Flow' and get the API access token 35 | (which is by default saved to disk and refreshed when expired). 36 | 37 | [Monzo Developer Portal]: https://developers.monzo.com/ 38 | 39 | Note: 40 | Monzo API docs: https://docs.monzo.com/ 41 | """ 42 | 43 | api_url = "https://api.monzo.com" 44 | authorization_endpoint = "https://auth.monzo.com/" 45 | token_endpoint = "https://api.monzo.com/oauth2/token" # noqa 46 | settings_path = Path.home() / ".pymonzo" 47 | 48 | def __init__(self, access_token: Optional[str] = None) -> None: 49 | """Initialize Monzo API client and mount all resources. 50 | 51 | It expects [`pymonzo.MonzoAPI.authorize`][] to be called beforehand, so 52 | it can load the local settings file containing the API access token. You 53 | can also explicitly pass the `access_token`, but it won't be able to 54 | automatically refresh it once it expires. 55 | 56 | Arguments: 57 | access_token: OAuth access token. You can obtain it (and by default, save 58 | it to disk, so it can refresh automatically) by running 59 | [`pymonzo.MonzoAPI.authorize`][]. Alternatively, you can get a 60 | temporary access token from the [Monzo Developer Portal]. 61 | 62 | [Monzo Developer Portal]: https://developers.monzo.com/ 63 | 64 | Raises: 65 | NoSettingsFile: When the access token wasn't passed explicitly and the 66 | settings file couldn't be loaded. 67 | 68 | """ 69 | if access_token: 70 | self._settings = PyMonzoSettings( 71 | token={"access_token": access_token}, 72 | ) 73 | else: 74 | try: 75 | self._settings = PyMonzoSettings.load_from_disk(self.settings_path) 76 | except (FileNotFoundError, JSONDecodeError) as e: 77 | raise NoSettingsFile( 78 | "No settings file found. You need to either run " 79 | "`MonzoAPI.authorize(client_id, client_secret)` " 80 | "to get the authorization token (and save it to disk), " 81 | "or explicitly pass the `access_token`." 82 | ) from e 83 | 84 | self.session = OAuth2Client( 85 | client_id=self._settings.client_id, 86 | client_secret=self._settings.client_secret, 87 | token=self._settings.token, 88 | authorization_endpoint=self.authorization_endpoint, 89 | token_endpoint=self.token_endpoint, 90 | token_endpoint_auth_method="client_secret_post", # noqa 91 | update_token=self._update_token, 92 | base_url=self.api_url, 93 | ) 94 | 95 | # This is a shortcut to the underlying method 96 | self.whoami = WhoAmIResource(client=self).whoami 97 | """ 98 | Mounted Monzo `whoami` endpoint. For more information see 99 | [`pymonzo.whoami.WhoAmIResource.whoami`][]. 100 | """ 101 | 102 | self.accounts = AccountsResource(client=self) 103 | """ 104 | Mounted Monzo `accounts` resource. For more information see 105 | [`pymonzo.accounts.AccountsResource`][]. 106 | """ 107 | 108 | self.attachments = AttachmentsResource(client=self) 109 | """ 110 | Mounted Monzo `attachments` resource. For more information see 111 | [`pymonzo.attachments.AttachmentsResource`][]. 112 | """ 113 | 114 | self.balance = BalanceResource(client=self) 115 | """ 116 | Mounted Monzo `balance` resource. For more information see 117 | [`pymonzo.balance.BalanceResource`][]. 118 | """ 119 | 120 | self.feed = FeedResource(client=self) 121 | """ 122 | Mounted Monzo `feed` resource. For more information see 123 | [`pymonzo.feed.FeedResource`][]. 124 | """ 125 | 126 | self.pots = PotsResource(client=self) 127 | """ 128 | Mounted Monzo `pots` resource. For more information see 129 | [`pymonzo.pots.PotsResource`][]. 130 | """ 131 | 132 | self.transactions = TransactionsResource(client=self) 133 | """ 134 | Mounted Monzo `transactions` resource. For more information see 135 | [`pymonzo.transactions.TransactionsResource`][]. 136 | """ 137 | 138 | self.webhooks = WebhooksResource(client=self) 139 | """ 140 | Mounted Monzo `webhooks` resource. For more information see 141 | [`pymonzo.webhooks.WebhooksResource`][]. 142 | """ 143 | 144 | @classmethod 145 | def authorize( 146 | cls, 147 | client_id: str, 148 | client_secret: str, 149 | *, 150 | save_to_disk: bool = True, 151 | redirect_uri: str = "http://localhost:6600/pymonzo", 152 | ) -> dict: 153 | """Use OAuth 2 'Authorization Code Flow' to get Monzo API access token. 154 | 155 | By default, it also saves the token to disk, so it can be loaded during 156 | [`pymonzo.MonzoAPI`][] initialization. 157 | 158 | Note: 159 | Monzo API docs: https://docs.monzo.com/#authentication 160 | 161 | Arguments: 162 | client_id: OAuth client ID. 163 | client_secret: OAuth client secret. 164 | save_to_disk: Whether to save the token to disk. 165 | redirect_uri: Redirect URI specified in OAuth client. 166 | 167 | Returns: 168 | OAuth token. 169 | """ 170 | client = OAuth2Client( 171 | client_id=client_id, 172 | client_secret=client_secret, 173 | redirect_uri=redirect_uri, 174 | token_endpoint_auth_method="client_secret_post", # noqa 175 | ) 176 | url, state = client.create_authorization_url(cls.authorization_endpoint) 177 | 178 | print(f"Please visit this URL to authorize: {url}") # noqa 179 | webbrowser.open(url) 180 | 181 | # Start a webserver and wait for the callback 182 | parsed_url = urlparse(redirect_uri) 183 | assert parsed_url.hostname is not None 184 | assert parsed_url.port is not None 185 | authorization_response = get_authorization_response_url( 186 | host=parsed_url.hostname, 187 | port=parsed_url.port, 188 | ) 189 | 190 | try: 191 | token = client.fetch_token( 192 | url=cls.token_endpoint, 193 | authorization_response=authorization_response, 194 | ) 195 | except (OAuthError, JSONDecodeError) as e: 196 | raise MonzoAPIError("Error while fetching API access token") from e 197 | 198 | # Save settings to disk 199 | if save_to_disk: 200 | settings = PyMonzoSettings( 201 | client_id=client_id, 202 | client_secret=client_secret, 203 | token=token, 204 | ) 205 | settings.save_to_disk(cls.settings_path) 206 | 207 | return token 208 | 209 | def _update_token(self, token: dict, **kwargs: Any) -> None: 210 | """Update settings with refreshed access token and save it to disk. 211 | 212 | Arguments: 213 | token: OAuth access token. 214 | **kwargs: Extra kwargs. 215 | """ 216 | self._settings.token = token 217 | if self.settings_path.exists(): 218 | self._settings.save_to_disk(self.settings_path) 219 | -------------------------------------------------------------------------------- /src/pymonzo/exceptions.py: -------------------------------------------------------------------------------- 1 | """pymonzo exceptions.""" 2 | 3 | 4 | class PyMonzoError(Exception): 5 | """Base pymonzo exception.""" 6 | 7 | 8 | class NoSettingsFile(PyMonzoError): 9 | """No settings file found.""" 10 | 11 | 12 | class CannotDetermineDefaultAccount(PyMonzoError): 13 | """Cannot determine default account.""" 14 | 15 | 16 | class CannotDetermineDefaultPot(PyMonzoError): 17 | """Cannot determine default pot.""" 18 | 19 | 20 | class MonzoAPIError(PyMonzoError): 21 | """Catch all Monzo API error.""" 22 | 23 | 24 | class MonzoAccessDenied(MonzoAPIError): 25 | """Access to Monzo API has been denied.""" 26 | -------------------------------------------------------------------------------- /src/pymonzo/feed/__init__.py: -------------------------------------------------------------------------------- 1 | """pymonzo `feed` package. 2 | 3 | Note: 4 | Monzo API docs: https://docs.monzo.com/#feed-items 5 | """ 6 | 7 | from .resources import FeedResource # noqa 8 | from .schemas import MonzoBasicFeedItem # noqa 9 | -------------------------------------------------------------------------------- /src/pymonzo/feed/resources.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'feed' resource.""" 2 | 3 | from typing import Optional 4 | 5 | from pymonzo.feed.schemas import MonzoBasicFeedItem 6 | from pymonzo.resources import BaseResource 7 | 8 | 9 | class FeedResource(BaseResource): 10 | """Monzo API 'feed' resource. 11 | 12 | Note: 13 | Monzo API docs: https://docs.monzo.com/#feed-items 14 | """ 15 | 16 | def create( 17 | self, 18 | feed_item: MonzoBasicFeedItem, 19 | account_id: Optional[str] = None, 20 | *, 21 | url: Optional[str] = None, 22 | ) -> dict: 23 | """Create a feed item. 24 | 25 | Note: 26 | Monzo API docs: https://docs.monzo.com/#create-feed-item 27 | 28 | Arguments: 29 | feed_item: Type of feed item. Currently only basic is supported. 30 | account_id: The account to create a feed item for. Can be omitted if 31 | user has only one active account. 32 | url: A URL to open when the feed item is tapped. If no URL is provided, 33 | the app will display a fallback view based on the title & body. 34 | 35 | Returns: 36 | API response. 37 | 38 | Raises: 39 | CannotDetermineDefaultAccount: If no account ID was passed and default 40 | account cannot be determined. 41 | """ 42 | if not account_id: 43 | account_id = self.client.accounts.get_default_account().id 44 | 45 | data = { 46 | "account_id": account_id, 47 | "type": "basic", 48 | } 49 | 50 | for key, value in feed_item.model_dump(exclude_none=True).items(): 51 | data[f"params[{key}]"] = value 52 | 53 | if url: 54 | data["url"] = url 55 | 56 | endpoint = "/feed" 57 | response = self._get_response(method="post", endpoint=endpoint, data=data) 58 | 59 | return response.json() 60 | -------------------------------------------------------------------------------- /src/pymonzo/feed/schemas.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'feed' related schemas.""" 2 | 3 | from typing import Optional 4 | 5 | from pydantic import BaseModel, ConfigDict, HttpUrl 6 | 7 | 8 | class MonzoBasicFeedItem(BaseModel): 9 | """API schema for a 'basic feed item' object. 10 | 11 | Note: 12 | Monzo API docs: https://docs.monzo.com/#feed-items 13 | 14 | Attributes: 15 | title: The title to display. 16 | image_url: URL of the image to display. This will be displayed as an icon 17 | in the feed, and on the expanded page if no url has been provided. 18 | body: The body text of the feed item. 19 | background_color: Hex value for the background colour of the feed item in the 20 | format #RRGGBB. Defaults to standard app colours (ie. white background). 21 | title_color: Hex value for the colour of the title text in the format #RRGGBB. 22 | Defaults to standard app colours. 23 | body_color: Hex value for the colour of the body text in the format #RRGGBB. 24 | Defaults to standard app colours. 25 | """ 26 | 27 | model_config = ConfigDict(extra="allow") 28 | 29 | title: str 30 | image_url: HttpUrl 31 | body: str 32 | background_color: Optional[str] = None 33 | title_color: Optional[str] = None 34 | body_color: Optional[str] = None 35 | -------------------------------------------------------------------------------- /src/pymonzo/pots/__init__.py: -------------------------------------------------------------------------------- 1 | """pymonzo `pots` package. 2 | 3 | Note: 4 | Monzo API docs: https://monzo.com/docs/#pots 5 | """ 6 | 7 | from .resources import PotsResource # noqa 8 | from .schemas import MonzoPot # noqa 9 | -------------------------------------------------------------------------------- /src/pymonzo/pots/resources.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'pots' resource.""" 2 | 3 | from dataclasses import dataclass, field 4 | from secrets import token_urlsafe 5 | from typing import Optional, Union 6 | 7 | from pymonzo.exceptions import CannotDetermineDefaultPot 8 | from pymonzo.pots.schemas import MonzoPot 9 | from pymonzo.resources import BaseResource 10 | 11 | 12 | @dataclass 13 | class PotsResource(BaseResource): 14 | """Monzo API 'pots' resource. 15 | 16 | Note: 17 | Monzo API docs: https://monzo.com/docs/#pots 18 | """ 19 | 20 | _cached_pots: dict[str, list[MonzoPot]] = field(default_factory=dict) 21 | 22 | def get_default_pot(self, account_id: Optional[str] = None) -> MonzoPot: 23 | """If the user has only one (active) pot, treat it as the default pot. 24 | 25 | Arguments: 26 | account_id: The ID of the account. Can be omitted if user has only one 27 | active account. 28 | 29 | Returns: 30 | User's active pot. 31 | 32 | Raises: 33 | CannotDetermineDefaultPot: If user has more than one active pot. 34 | CannotDetermineDefaultAccount: If no account ID was passed and default 35 | account cannot be determined. 36 | """ 37 | if not account_id: 38 | account_id = self.client.accounts.get_default_account().id 39 | 40 | pots = self.list(account_id) 41 | 42 | # If there is only one pot, return it 43 | if len(pots) == 1: 44 | return pots[0] 45 | 46 | # Otherwise check if there is only one active (non-deleted) pot 47 | active_pots = [pot for pot in pots if not pot.deleted] 48 | 49 | if len(active_pots) == 1: 50 | return active_pots[0] 51 | 52 | raise CannotDetermineDefaultPot( 53 | "Cannot determine default pot. " 54 | "You need to explicitly pass an 'pot_id' argument." 55 | ) 56 | 57 | def list( 58 | self, 59 | account_id: Optional[str] = None, 60 | *, 61 | refresh: bool = False, 62 | ) -> list[MonzoPot]: 63 | """Return a list of user's pots. 64 | 65 | It's often used when deciding whether to require explicit pot ID 66 | or use the only active one, so we cache the response by default. 67 | 68 | Note: 69 | Monzo API docs: https://docs.monzo.com/#list-pots 70 | 71 | Arguments: 72 | account_id: The ID of the account. Can be omitted if user has only one 73 | active account. 74 | refresh: Whether to refresh the cached list of pots. 75 | 76 | Returns: 77 | A list of user's pots. 78 | 79 | Raises: 80 | CannotDetermineDefaultAccount: If no account ID was passed and default 81 | account cannot be determined. 82 | """ 83 | if not account_id: 84 | account_id = self.client.accounts.get_default_account().id 85 | 86 | if not refresh and self._cached_pots.get(account_id): 87 | return self._cached_pots[account_id] 88 | 89 | endpoint = "/pots" 90 | params = {"current_account_id": account_id} 91 | response = self._get_response(method="get", endpoint=endpoint, params=params) 92 | 93 | pots = [MonzoPot(**pot) for pot in response.json()["pots"]] 94 | self._cached_pots[account_id] = pots 95 | 96 | return pots 97 | 98 | def deposit( 99 | self, 100 | amount: Union[int, float], 101 | pot_id: Optional[str] = None, 102 | *, 103 | account_id: Optional[str] = None, 104 | dedupe_id: Optional[str] = None, 105 | ) -> MonzoPot: 106 | """Move money from an account to a pot. 107 | 108 | Note: 109 | Monzo API docs: https://docs.monzo.com/#deposit-into-a-pot 110 | 111 | Arguments: 112 | amount: The amount to deposit, as a 64bit integer in minor units of 113 | the currency, eg. pennies for GBP, or cents for EUR and USD. 114 | pot_id: The ID of the pot to deposit into. 115 | account_id: The ID of the account to withdraw from. Can be omitted if 116 | user has only one active account. 117 | dedupe_id: A unique string used to de-duplicate deposits. Ensure this 118 | remains static between retries to ensure only one deposit is created. 119 | If omitted, a random 16 character string will be generated. 120 | 121 | Returns: 122 | A Monzo pot. 123 | 124 | Raises: 125 | CannotDetermineDefaultPot: If user has more than one active pot. 126 | CannotDetermineDefaultAccount: If no account ID was passed and default 127 | account cannot be determined. 128 | """ 129 | if not account_id: 130 | account_id = self.client.accounts.get_default_account().id 131 | 132 | if not pot_id: 133 | pot_id = self.get_default_pot(account_id).id 134 | 135 | if not dedupe_id: 136 | dedupe_id = token_urlsafe(16) 137 | 138 | endpoint = f"/pots/{pot_id}/deposit" 139 | data = { 140 | "source_account_id": account_id, 141 | "amount": amount, 142 | "dedupe_id": dedupe_id, 143 | } 144 | response = self._get_response(method="put", endpoint=endpoint, data=data) 145 | 146 | pot = MonzoPot(**response.json()) 147 | 148 | return pot 149 | 150 | def withdraw( 151 | self, 152 | amount: Union[int, float], 153 | pot_id: Optional[str] = None, 154 | *, 155 | account_id: Optional[str] = None, 156 | dedupe_id: Optional[str] = None, 157 | ) -> MonzoPot: 158 | """Withdraw money from a pot to an account. 159 | 160 | Note: 161 | Monzo API docs: https://docs.monzo.com/#withdraw-from-a-pot 162 | 163 | Arguments: 164 | amount: The amount to deposit, as a 64bit integer in minor units of 165 | the currency, eg. pennies for GBP, or cents for EUR and USD. 166 | pot_id: The ID of the pot to withdraw from. 167 | account_id: The ID of the account to deposit into. Can be omitted if 168 | user has only one active account. 169 | dedupe_id: A unique string used to de-duplicate deposits. Ensure this 170 | remains static between retries to ensure only one deposit is created. 171 | If omitted, a random 16 character string will be generated. 172 | 173 | Returns: 174 | A Monzo pot. 175 | 176 | Raises: 177 | CannotDetermineDefaultPot: If user has more than one active pot. 178 | CannotDetermineDefaultAccount: If no account ID was passed and default 179 | account cannot be determined. 180 | """ 181 | if not account_id: 182 | account_id = self.client.accounts.get_default_account().id 183 | 184 | if not pot_id: 185 | pot_id = self.get_default_pot(account_id).id 186 | 187 | if not dedupe_id: 188 | dedupe_id = token_urlsafe(16) 189 | 190 | endpoint = f"/pots/{pot_id}/withdraw" 191 | data = { 192 | "destination_account_id": account_id, 193 | "amount": amount, 194 | "dedupe_id": dedupe_id, 195 | } 196 | response = self._get_response(method="put", endpoint=endpoint, data=data) 197 | 198 | pot = MonzoPot(**response.json()) 199 | 200 | return pot 201 | -------------------------------------------------------------------------------- /src/pymonzo/pots/schemas.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'pots' related schemas.""" 2 | 3 | from datetime import datetime 4 | from typing import Optional 5 | 6 | from pydantic import BaseModel, ConfigDict 7 | 8 | # Optional `rich` support 9 | try: 10 | from rich.table import Table 11 | 12 | # Optional `babel` support 13 | try: 14 | from babel.dates import format_datetime 15 | from babel.numbers import format_currency 16 | except ImportError: 17 | from pymonzo.utils import format_currency, format_datetime # type: ignore 18 | 19 | except ImportError: 20 | RICH_AVAILABLE = False 21 | else: 22 | RICH_AVAILABLE = True 23 | 24 | 25 | class MonzoPot(BaseModel): 26 | """API schema for a 'pot' object. 27 | 28 | Note: 29 | Monzo API docs: https://docs.monzo.com/#pots 30 | 31 | Attributes: 32 | id: The ID of the pot. 33 | name: Pot name. 34 | style: The pot background image. 35 | balance: Pot balance. 36 | currency: Pot currency. 37 | created: When this pot was created. 38 | updated: When this pot was last updated. 39 | deleted: Whether this pot is deleted. The API will be updated soon to not 40 | return deleted pots. 41 | goal_amount: Pot goal account. 42 | type: Pot type. 43 | product_id: Product ID 44 | current_account_id: Current account ID. 45 | cover_image_url: Cover image URL. 46 | isa_wrapper: ISA wrapper. 47 | round_up: Whether to use transfer money from rounding up transactions to 48 | the pot. You can only switch on round ups for one pot at a time. 49 | round_up_multiplier: Rounding up multiplier. 50 | is_tax_pot: Whether the pot is taxed. 51 | locked: Whether the pot is locked. 52 | available_for_bills: Whether the pot is available for bills. 53 | has_virtual_cards: Whether the pot has linked virtual cards. 54 | """ 55 | 56 | model_config = ConfigDict(extra="allow") 57 | 58 | id: str 59 | name: str 60 | style: str 61 | balance: int 62 | currency: str 63 | created: datetime 64 | updated: datetime 65 | deleted: bool 66 | 67 | # Undocumented in Monzo API docs 68 | goal_amount: Optional[int] = None 69 | type: Optional[str] = None 70 | product_id: Optional[str] = None 71 | current_account_id: Optional[str] = None 72 | cover_image_url: Optional[str] = None 73 | isa_wrapper: Optional[str] = None 74 | round_up: Optional[bool] = None 75 | round_up_multiplier: Optional[int] = None 76 | is_tax_pot: Optional[bool] = None 77 | locked: Optional[bool] = None 78 | available_for_bills: Optional[bool] = None 79 | has_virtual_cards: Optional[bool] = None 80 | 81 | if RICH_AVAILABLE: 82 | 83 | def __rich__(self) -> Table: 84 | """Pretty printing support for `rich`.""" 85 | balance = format_currency(self.balance / 100, self.currency) 86 | 87 | grid = Table.grid(padding=(0, 5)) 88 | grid.title = f"Pot '{self.name}' | {balance}" 89 | grid.title_style = "bold green" 90 | grid.add_column(style="bold cyan") 91 | grid.add_column(style="" if not self.deleted else "dim") 92 | grid.add_row("ID:", self.id) 93 | grid.add_row("Name:", self.name) 94 | grid.add_row("Balance:", balance) 95 | if self.goal_amount: 96 | goal_amount = format_currency(self.goal_amount / 100, self.currency) 97 | grid.add_row("Goal:", goal_amount) 98 | grid.add_row("Currency:", self.currency) 99 | if self.type: 100 | grid.add_row("Type:", self.type) 101 | grid.add_row("Deleted:", "Yes" if self.deleted else "No") 102 | if self.round_up: 103 | grid.add_row("Round up:", "Yes" if self.round_up else "No") 104 | if self.round_up_multiplier: 105 | grid.add_row("Round up multiplier:", str(self.round_up_multiplier)) 106 | if self.locked: 107 | grid.add_row("Locked:", "Yes" if self.locked else "No") 108 | grid.add_row("Created:", format_datetime(self.created)) 109 | grid.add_row("Updated:", format_datetime(self.updated)) 110 | 111 | return grid 112 | -------------------------------------------------------------------------------- /src/pymonzo/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelad/pymonzo/b1bcd6391b066276fb8f464f62f63ffc26f53da2/src/pymonzo/py.typed -------------------------------------------------------------------------------- /src/pymonzo/resources.py: -------------------------------------------------------------------------------- 1 | """pymonzo base API resource related code.""" 2 | 3 | import json 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Optional 6 | 7 | import httpx 8 | from httpx import codes 9 | 10 | from pymonzo.exceptions import MonzoAccessDenied, MonzoAPIError 11 | 12 | if TYPE_CHECKING: 13 | from pymonzo.client import MonzoAPI 14 | 15 | 16 | @dataclass 17 | class BaseResource: 18 | """Base Monzo API resource class. 19 | 20 | Attributes: 21 | client: Monzo API client instance. 22 | """ 23 | 24 | client: "MonzoAPI" 25 | 26 | def _get_response( 27 | self, 28 | method: str, 29 | endpoint: str, 30 | params: Optional[dict] = None, 31 | data: Optional[dict] = None, 32 | ) -> httpx.Response: 33 | """Handle HTTP requests and catch API errors. 34 | 35 | Arguments: 36 | method: HTTP method. 37 | endpoint: HTTP endpoint. 38 | params: URL query parameters. 39 | data: form encoded data. 40 | 41 | Returns: 42 | HTTP response. 43 | 44 | Raises: 45 | MonzoAccessDenied: When access to Monzo API was denied. 46 | MonzoAPIError: When Monzo API returned an error. 47 | """ 48 | httpx_kwargs = {"params": params} 49 | if method in ["post", "put", "patch"]: 50 | httpx_kwargs["data"] = data 51 | 52 | response = getattr(self.client.session, method)(endpoint, **httpx_kwargs) 53 | 54 | if response.status_code == codes.FORBIDDEN: 55 | raise MonzoAccessDenied( 56 | "Monzo API access denied (HTTP 403 Forbidden). " 57 | "Make sure to (re)authenticate the OAuth app on your mobile device." 58 | ) 59 | 60 | try: 61 | response.raise_for_status() 62 | except httpx.HTTPStatusError as e: 63 | try: 64 | content = response.json() 65 | except json.decoder.JSONDecodeError: 66 | content = {} 67 | 68 | error = content.get("message") 69 | code = content.get("code") 70 | 71 | if error and code: 72 | msg = f"{error} ({code})" 73 | else: 74 | msg = f"Something went wrong: {e}" 75 | 76 | raise MonzoAPIError(msg) from e 77 | 78 | return response 79 | -------------------------------------------------------------------------------- /src/pymonzo/settings.py: -------------------------------------------------------------------------------- 1 | """pymonzo settings related code.""" 2 | 3 | import json 4 | import os 5 | import sys 6 | from functools import partial 7 | from pathlib import Path 8 | from typing import Optional, Union 9 | 10 | from pydantic_settings import BaseSettings, SettingsConfigDict 11 | 12 | if sys.version_info < (3, 11): 13 | from typing_extensions import Self 14 | else: 15 | from typing import Self 16 | 17 | 18 | class PyMonzoSettings(BaseSettings): 19 | """pymonzo settings schema. 20 | 21 | Attributes: 22 | token: OAuth token. For more information see [`pymonzo.MonzoAPI.authorize`][]. 23 | client_id: OAuth client ID. 24 | client_secret: OAuth client secret. 25 | """ 26 | 27 | model_config = SettingsConfigDict(env_prefix="pymonzo_") 28 | 29 | token: dict[str, Union[str, int]] 30 | client_id: Optional[str] = None 31 | client_secret: Optional[str] = None 32 | 33 | @classmethod 34 | def load_from_disk(cls, settings_path: Path) -> Self: 35 | """Load pymonzo settings from disk. 36 | 37 | Arguments: 38 | settings_path: Settings file path. 39 | 40 | Returns: 41 | Loaded pymonzo settings. 42 | """ 43 | with open(settings_path) as f: 44 | settings = json.load(f) 45 | 46 | return cls(**settings) 47 | 48 | def save_to_disk(self, settings_path: Path) -> None: 49 | """Save pymonzo settings on disk. 50 | 51 | Arguments: 52 | settings_path: Settings file path. 53 | """ 54 | # Make sure the file is not publicly accessible 55 | # Source: https://github.com/python/cpython/issues/73400 56 | os.umask(0o077) 57 | with open(settings_path, "w", opener=partial(os.open, mode=0o600)) as f: 58 | f.write(self.model_dump_json(indent=2)) 59 | -------------------------------------------------------------------------------- /src/pymonzo/transactions/__init__.py: -------------------------------------------------------------------------------- 1 | """pymonzo `transactions` package. 2 | 3 | Note: 4 | Monzo API docs: https://docs.monzo.com/#transactions 5 | """ 6 | 7 | from .enums import MonzoTransactionCategory, MonzoTransactionDeclineReason # noqa 8 | from .resources import TransactionsResource # noqa 9 | from .schemas import ( # noqa 10 | MonzoTransaction, 11 | MonzoTransactionCounterparty, 12 | MonzoTransactionMerchant, 13 | MonzoTransactionMerchantAddress, 14 | ) 15 | -------------------------------------------------------------------------------- /src/pymonzo/transactions/enums.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'transactions' related enums.""" 2 | 3 | from enum import Enum 4 | 5 | 6 | class MonzoTransactionDeclineReason(str, Enum): 7 | """Monzo API 'transaction decline reason' enum. 8 | 9 | Note: 10 | Monzo API docs: https://docs.monzo.com/#transactions 11 | """ 12 | 13 | INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS" 14 | CARD_INACTIVE = "CARD_INACTIVE" 15 | CARD_BLOCKED = "CARD_BLOCKED" 16 | INVALID_CVC = "INVALID_CVC" 17 | OTHER = "OTHER" 18 | 19 | # Undocumented in Monzo API docs 20 | CARD_CLOSED = "CARD_CLOSED" 21 | CARD_EXPIRED = "CARD_EXPIRED" 22 | INVALID_EXPIRY_DATE = "INVALID_EXPIRY_DATE" 23 | INVALID_PIN = "INVALID_PIN" 24 | SCA_NOT_AUTHENTICATED_CARD_NOT_PRESENT = "SCA_NOT_AUTHENTICATED_CARD_NOT_PRESENT" 25 | STRONG_CUSTOMER_AUTHENTICATION_REQUIRED = "STRONG_CUSTOMER_AUTHENTICATION_REQUIRED" 26 | AUTHENTICATION_REJECTED_BY_CARDHOLDER = "AUTHENTICATION_REJECTED_BY_CARDHOLDER" 27 | 28 | 29 | class MonzoTransactionCategory(str, Enum): 30 | """Monzo API 'transaction category' enum. 31 | 32 | Note: 33 | Monzo API docs: https://docs.monzo.com/#transactions 34 | """ 35 | 36 | GENERAL = "general" 37 | EATING_OUT = "eating_out" 38 | EXPENSES = "expenses" 39 | TRANSPORT = "transport" 40 | CASH = "cash" 41 | BILLS = "bills" 42 | ENTERTAINMENT = "entertainment" 43 | SHOPPING = "shopping" 44 | HOLIDAYS = "holidays" 45 | GROCERIES = "groceries" 46 | 47 | # Undocumented in Monzo API docs 48 | INCOME = "income" 49 | SAVINGS = "savings" 50 | TRANSFERS = "transfers" 51 | -------------------------------------------------------------------------------- /src/pymonzo/transactions/resources.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'transactions' resource.""" 2 | 3 | from datetime import datetime 4 | from typing import Optional 5 | 6 | from pymonzo.resources import BaseResource 7 | from pymonzo.transactions.schemas import MonzoTransaction 8 | 9 | 10 | class TransactionsResource(BaseResource): 11 | """Monzo API 'transactions' resource. 12 | 13 | Note: 14 | Monzo API docs: https://docs.monzo.com/#transactions 15 | """ 16 | 17 | def get( 18 | self, 19 | transaction_id: str, 20 | *, 21 | expand_merchant: bool = False, 22 | ) -> MonzoTransaction: 23 | """Return single transaction. 24 | 25 | Note: 26 | Monzo API docs: https://docs.monzo.com/#retrieve-transaction 27 | 28 | Arguments: 29 | transaction_id: The ID of the transaction. 30 | expand_merchant: Whether to return expanded merchant information. 31 | 32 | Returns: 33 | A Monzo transaction. 34 | """ 35 | endpoint = f"/transactions/{transaction_id}" 36 | params = {} 37 | if expand_merchant: 38 | params["expand[]"] = "merchant" 39 | 40 | response = self._get_response(method="get", endpoint=endpoint, params=params) 41 | 42 | transaction = MonzoTransaction(**response.json()["transaction"]) 43 | 44 | return transaction 45 | 46 | def annotate( 47 | self, 48 | transaction_id: str, 49 | metadata: dict[str, str], 50 | ) -> MonzoTransaction: 51 | """Annotate transaction with extra metadata. 52 | 53 | Note: 54 | Monzo API docs: https://docs.monzo.com/#annotate-transaction 55 | 56 | Arguments: 57 | transaction_id: The ID of the transaction. 58 | metadata: Include each key you would like to modify. To delete a key, 59 | set its value to an empty string. 60 | 61 | Returns: 62 | Annotated Monzo transaction. 63 | """ 64 | endpoint = f"/transactions/{transaction_id}" 65 | data = {f"metadata[{key}]": value for key, value in metadata.items()} 66 | 67 | response = self._get_response(method="patch", endpoint=endpoint, data=data) 68 | 69 | transaction = MonzoTransaction(**response.json()["transaction"]) 70 | 71 | return transaction 72 | 73 | def list( 74 | self, 75 | account_id: Optional[str] = None, 76 | *, 77 | expand_merchant: bool = False, 78 | since: Optional[datetime] = None, 79 | before: Optional[datetime] = None, 80 | limit: Optional[int] = None, 81 | ) -> list[MonzoTransaction]: 82 | """Return a list of account transactions. 83 | 84 | You can only fetch all transactions within 5 minutes of authentication. 85 | After that, you can query your last 90 days. 86 | 87 | Note: 88 | Monzo API docs: https://docs.monzo.com/#list-transactions 89 | 90 | Arguments: 91 | account_id: The ID of the account. Can be omitted if user has only one 92 | active account. 93 | expand_merchant: Whether to return expanded merchant information. 94 | since: Filter transactions by start time. 95 | before: Filter transactions by end time. 96 | limit: Limits the number of results per-page. Maximum: 100. 97 | 98 | Returns: 99 | List of Monzo transactions. 100 | 101 | Raises: 102 | CannotDetermineDefaultAccount: If no account ID was passed and default 103 | account cannot be determined. 104 | """ 105 | if not account_id: 106 | account_id = self.client.accounts.get_default_account().id 107 | 108 | endpoint = "/transactions" 109 | params = {"account_id": account_id} 110 | 111 | if expand_merchant: 112 | params["expand[]"] = "merchant" 113 | 114 | if since: 115 | params["since"] = since.strftime("%Y-%m-%dT%H:%M:%SZ") 116 | 117 | if before: 118 | params["before"] = before.strftime("%Y-%m-%dT%H:%M:%SZ") 119 | 120 | if limit: 121 | params["limit"] = str(limit) 122 | 123 | response = self._get_response(method="get", endpoint=endpoint, params=params) 124 | 125 | transactions = [ 126 | MonzoTransaction(**transaction) 127 | for transaction in response.json()["transactions"] 128 | ] 129 | 130 | return transactions 131 | -------------------------------------------------------------------------------- /src/pymonzo/transactions/schemas.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'transactions' related schemas.""" 2 | 3 | from datetime import datetime 4 | from typing import Optional, Union 5 | 6 | from pydantic import BaseModel, ConfigDict, Field, field_validator 7 | 8 | from pymonzo.transactions.enums import ( 9 | MonzoTransactionCategory, 10 | MonzoTransactionDeclineReason, 11 | ) 12 | from pymonzo.utils import empty_dict_to_none, empty_str_to_none 13 | 14 | # Optional `rich` support 15 | try: 16 | from rich.table import Table 17 | 18 | # Optional `babel` support 19 | try: 20 | from babel.dates import format_datetime 21 | from babel.numbers import format_currency 22 | except ImportError: 23 | from pymonzo.utils import format_currency, format_datetime # type: ignore 24 | 25 | except ImportError: 26 | RICH_AVAILABLE = False 27 | else: 28 | RICH_AVAILABLE = True 29 | 30 | 31 | class MonzoTransactionMerchantAddress(BaseModel): 32 | """API schema for a 'transaction merchant address' object. 33 | 34 | Note: 35 | Monzo API docs: https://docs.monzo.com/#transactions 36 | 37 | Attributes: 38 | address: Merchant address. 39 | city: Merchant city. 40 | country: Merchant country. 41 | latitude: Merchant latitude. 42 | longitude: Merchant longitude. 43 | postcode: Merchant postcode. 44 | region: Merchant region. 45 | """ 46 | 47 | address: str 48 | city: str 49 | country: str 50 | latitude: float 51 | longitude: float 52 | postcode: str 53 | region: str 54 | 55 | # Undocumented in API docs 56 | formatted: Optional[str] = None 57 | short_formatted: Optional[str] = None 58 | zoom_level: Optional[int] = None 59 | approximate: Optional[bool] = None 60 | 61 | 62 | class MonzoTransactionMerchant(BaseModel): 63 | """API schema for a 'transaction merchant' object. 64 | 65 | Note: 66 | Monzo API docs: https://docs.monzo.com/#transactions 67 | 68 | Attributes: 69 | id: The ID of the merchant. 70 | group_id: Merchant group ID. 71 | name: Merchant name. 72 | logo: Merchant logo URL. 73 | address: Merchant address. 74 | emoji: Merchant emoji. 75 | category: The category can be set for each transaction by the user. Over 76 | time we learn which merchant goes in which category and auto-assign 77 | the category of a transaction. If the user hasn't set a category, we'll 78 | return the default category of the merchant on this transactions. Top-ups 79 | have category mondo. Valid values are general, eating_out, expenses, 80 | transport, cash, bills, entertainment, shopping, holidays, groceries. 81 | address: Merchant address 82 | created: Merchant creation date. 83 | """ 84 | 85 | model_config = ConfigDict(extra="allow") 86 | 87 | id: str 88 | group_id: str 89 | name: str 90 | logo: str 91 | emoji: str 92 | category: Union[MonzoTransactionCategory, str] = Field(union_mode="left_to_right") 93 | address: MonzoTransactionMerchantAddress 94 | 95 | # Undocumented in API docs 96 | online: Optional[bool] = None 97 | atm: Optional[bool] = None 98 | disable_feedback: Optional[bool] = None 99 | metadata: Optional[dict[str, str]] = None 100 | suggested_tags: Optional[str] = None 101 | 102 | # Visible in API docs, not present in the API 103 | created: Optional[datetime] = None 104 | 105 | if RICH_AVAILABLE: 106 | 107 | def __rich__(self) -> Table: 108 | """Pretty printing support for `rich`.""" 109 | grid = Table.grid(padding=(0, 5)) 110 | grid.title = f"{self.emoji} | {self.name}" 111 | grid.title_style = "bold yellow" 112 | grid.add_column(style="bold cyan") 113 | grid.add_column() 114 | grid.add_row("ID:", self.id) 115 | grid.add_row("Group ID:", self.group_id) 116 | grid.add_row("Name:", self.name) 117 | grid.add_row("Address:", self.address.short_formatted) 118 | grid.add_row("Category:", self.category) 119 | if self.online: 120 | grid.add_row("Online:", "Yes") 121 | if self.atm: 122 | grid.add_row("ATM:", "Yes") 123 | 124 | return grid 125 | 126 | 127 | class MonzoTransactionCounterparty(BaseModel): 128 | """API schema for a 'transaction counterparty' object. 129 | 130 | Note: 131 | This is undocumented in the Monzo API docs: https://docs.monzo.com/#transactions 132 | 133 | Attributes: 134 | user_id: Monzo internal User ID of the other party. 135 | name: The name of the other party. 136 | sort_code: The sort code of the other party. 137 | account_number: The account number of the other party. 138 | """ 139 | 140 | # Undocumented in API docs 141 | user_id: Optional[str] = None 142 | name: Optional[str] = None 143 | preferred_name: Optional[str] = None 144 | sort_code: Optional[str] = None 145 | account_number: Optional[str] = None 146 | 147 | if RICH_AVAILABLE: 148 | 149 | def __rich__(self) -> Table: 150 | """Pretty printing support for `rich`.""" 151 | grid = Table.grid(padding=(0, 5)) 152 | grid.title = f"{self.name}" 153 | grid.title_style = "bold yellow" 154 | grid.add_column(style="bold cyan") 155 | grid.add_column() 156 | if self.user_id: 157 | grid.add_row("ID:", self.user_id) 158 | if self.name: 159 | grid.add_row("Name:", self.name) 160 | if self.sort_code: 161 | grid.add_row("Sort Code:", self.sort_code) 162 | if self.account_number: 163 | grid.add_row("Account Number:", self.account_number) 164 | 165 | return grid 166 | 167 | 168 | class MonzoTransaction(BaseModel): 169 | """API schema for a 'transaction' object. 170 | 171 | Note: 172 | Monzo API docs: https://docs.monzo.com/#transactions 173 | 174 | Attributes: 175 | amount: The amount of the transaction in minor units of currency. For example 176 | pennies in the case of GBP. A negative amount indicates a debit (most 177 | card transactions will have a negative amount) 178 | created: Transaction creation date. 179 | currency: Transaction currency 180 | description: Transaction description. 181 | id: The ID of the transaction. 182 | merchant: This contains the `merchant_id of` the merchant that this transaction 183 | was made at. If you pass `?expand[]=merchant` in your request URL, it 184 | will contain lots of information about the merchant. 185 | metadata: Transaction metadata. 186 | notes: Transaction notes. 187 | is_load: Top-ups to an account are represented as transactions with a positive 188 | amount and `is_load = true`. Other transactions such as refunds, reversals 189 | or chargebacks may have a positive amount but `is_load = false`. 190 | settled: The timestamp at which the transaction settled. In most cases, this 191 | happens 24-48 hours after created. If this field is an empty string, 192 | the transaction is authorised but not yet "complete." 193 | category: The category can be set for each transaction by the user. Over 194 | time we learn which merchant goes in which category and auto-assign 195 | the category of a transaction. If the user hasn't set a category, we'll 196 | return the default category of the merchant on this transactions. Top-ups 197 | have category mondo. Valid values are general, eating_out, expenses, 198 | transport, cash, bills, entertainment, shopping, holidays, groceries. 199 | decline_reason: This is only present on declined transactions. 200 | """ 201 | 202 | model_config = ConfigDict(extra="allow") 203 | 204 | amount: int 205 | created: datetime 206 | currency: str 207 | description: str 208 | id: str 209 | merchant: Union[MonzoTransactionMerchant, str, None] 210 | metadata: dict[str, str] 211 | notes: str 212 | is_load: bool 213 | settled: Optional[datetime] 214 | category: Union[MonzoTransactionCategory, str, None] = Field( 215 | default=None, 216 | union_mode="left_to_right", 217 | ) 218 | decline_reason: Union[MonzoTransactionDeclineReason, str, None] = Field( 219 | default=None, 220 | union_mode="left_to_right", 221 | ) 222 | 223 | # Undocumented in the API Documentation 224 | counterparty: Optional[MonzoTransactionCounterparty] = None 225 | 226 | @field_validator("settled", mode="before") 227 | @classmethod 228 | def empty_str_to_none(cls, v: str) -> Optional[str]: 229 | """Convert empty strings to `None`.""" 230 | return empty_str_to_none(v) 231 | 232 | @field_validator("counterparty", mode="before") 233 | @classmethod 234 | def empty_dict_to_none(cls, v: dict) -> Optional[dict]: 235 | """Convert empty dict to `None`.""" 236 | return empty_dict_to_none(v) 237 | 238 | if RICH_AVAILABLE: 239 | 240 | def __rich__(self) -> Table: 241 | """Pretty printing support for `rich`.""" 242 | amount = format_currency(self.amount / 100, self.currency) 243 | amount_color = "green" if self.amount > 0 else "red" 244 | 245 | grid = Table.grid(padding=(0, 5)) 246 | grid.title = f"{amount} | {self.description}" 247 | grid.title_style = ( 248 | f"bold {amount_color}" 249 | if not self.decline_reason 250 | else f"bold {amount_color} dim" 251 | ) 252 | grid.add_column(style="bold cyan") 253 | grid.add_column( 254 | style="" if not self.decline_reason else "dim", 255 | max_width=50, 256 | ) 257 | grid.add_row("ID:", self.id) 258 | grid.add_row("Description:", self.description) 259 | grid.add_row("Amount:", amount) 260 | grid.add_row("Currency:", self.currency) 261 | grid.add_row("Category:", self.category) 262 | if self.notes: 263 | grid.add_row("Notes:", self.notes) 264 | if self.decline_reason: 265 | grid.add_row("Decline reason:", self.decline_reason) 266 | grid.add_row("Created:", format_datetime(self.created)) 267 | grid.add_row("Settled:", format_datetime(self.settled)) 268 | if isinstance(self.merchant, MonzoTransactionMerchant): 269 | grid.add_row("Merchant:", self.merchant) 270 | 271 | return grid 272 | -------------------------------------------------------------------------------- /src/pymonzo/utils.py: -------------------------------------------------------------------------------- 1 | """pymonzo utils.""" 2 | 3 | import locale 4 | from datetime import datetime, timedelta 5 | from typing import Any, Callable 6 | from wsgiref.simple_server import make_server 7 | from wsgiref.util import request_uri 8 | 9 | 10 | def n_days_ago(n: int) -> datetime: 11 | """Return datetime that was `n` days ago. 12 | 13 | Arguments: 14 | n: Number of days to go back. 15 | 16 | Returns: 17 | Datetime that was `n` days ago. 18 | """ 19 | today = datetime.now() 20 | delta = timedelta(days=n) 21 | return today - delta 22 | 23 | 24 | def empty_str_to_none(v: Any) -> Any: 25 | """Return `None` passed value is an empty string, otherwise do nothing. 26 | 27 | Arguments: 28 | v: Value to check. 29 | 30 | Returns: 31 | Passed value or `None` if it's an empty string. 32 | """ 33 | if isinstance(v, str) and v == "": 34 | return None 35 | 36 | return v 37 | 38 | 39 | def empty_dict_to_none(v: Any) -> Any: 40 | """Return `None` if the passed value is an empty dict, otherwise do nothing. 41 | 42 | Arguments: 43 | v: Value to check. 44 | 45 | Returns: 46 | Passed value or `None` if it's an empty dict. 47 | """ 48 | if isinstance(v, dict) and not bool(v): 49 | return None 50 | 51 | return v 52 | 53 | 54 | def format_datetime(dt: datetime) -> str: 55 | """Format passed `datetime` in user locale. 56 | 57 | Used as a fallback when `babel` isn't available. 58 | 59 | Arguments: 60 | dt: Datetime to format. 61 | 62 | Returns: 63 | Passed `datetime` formatted in user locale. 64 | """ 65 | return dt.strftime(locale.nl_langinfo(locale.D_T_FMT)) 66 | 67 | 68 | def format_currency(amount: float, currency: str) -> str: 69 | """Format passed amount with two decimal places. 70 | 71 | Used as a fallback when `babel` isn't available. 72 | 73 | Arguments: 74 | amount: Amount of money. 75 | currency: Money currency. Unused here, but needed to match the signature 76 | with `babel.numbers.format_currency`. 77 | 78 | Returns: 79 | Passed amount with two decimal places. 80 | """ 81 | return f"{amount:.2f}" 82 | 83 | 84 | class WSGIApp: 85 | """Bare-bones WSGI app made for retrieving the OAuth callback.""" 86 | 87 | last_request_uri = "" 88 | 89 | def __call__( 90 | self, 91 | environ: dict, 92 | start_response: Callable[[str, list[tuple[str, str]]], None], 93 | ) -> list[bytes]: 94 | """Implement WSGI interface and save the URL of the callback.""" 95 | start_response("200 OK", [("Content-type", "text/plain; charset=utf-8")]) 96 | self.last_request_uri = request_uri(environ) 97 | msg = "Monzo OAuth authorization complete." 98 | return [msg.encode("utf-8")] 99 | 100 | 101 | def get_authorization_response_url(host: str, port: int) -> str: 102 | """Get OAuth authorization response URL. 103 | 104 | It's done by creating a bare-bones HTTP server and retrieving a single request, 105 | the OAuth callback. 106 | 107 | Arguments: 108 | host: temporary HTTP server host name. 109 | port: temporary HTTP server port. 110 | 111 | Returns: 112 | URL of the OAuth authorization response. 113 | """ 114 | wsgi_app = WSGIApp() 115 | with make_server(host, port, wsgi_app) as server: # type: ignore 116 | server.handle_request() 117 | 118 | return wsgi_app.last_request_uri 119 | -------------------------------------------------------------------------------- /src/pymonzo/webhooks/__init__.py: -------------------------------------------------------------------------------- 1 | """pymonzo `webhooks` package. 2 | 3 | Note: 4 | Monzo API docs: https://docs.monzo.com/#webhooks 5 | """ 6 | 7 | from .resources import WebhooksResource # noqa 8 | from .schemas import ( # noqa 9 | MonzoWebhook, 10 | MonzoWebhookEvent, 11 | MonzoWebhookTransactionEvent, 12 | ) 13 | -------------------------------------------------------------------------------- /src/pymonzo/webhooks/resources.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'webhooks' resource.""" 2 | 3 | from typing import Optional 4 | 5 | from pymonzo.resources import BaseResource 6 | from pymonzo.webhooks.schemas import MonzoWebhook 7 | 8 | 9 | class WebhooksResource(BaseResource): 10 | """Monzo API 'webhooks' resource. 11 | 12 | Note: 13 | Monzo API docs: https://docs.monzo.com/#webhooks 14 | """ 15 | 16 | def list(self, account_id: Optional[str] = None) -> list[MonzoWebhook]: 17 | """List all webhooks. 18 | 19 | Note: 20 | Monzo API docs: https://docs.monzo.com/#list-webhooks 21 | 22 | Arguments: 23 | account_id: The account to list registered webhooks for. Can be omitted 24 | if user has only one active account. 25 | 26 | Returns: 27 | List of Monzo webhooks. 28 | 29 | Raises: 30 | CannotDetermineDefaultAccount: If no account ID was passed and default 31 | account cannot be determined. 32 | """ 33 | if not account_id: 34 | account_id = self.client.accounts.get_default_account().id 35 | 36 | endpoint = "/webhooks" 37 | params = {"account_id": account_id} 38 | 39 | response = self._get_response(method="get", endpoint=endpoint, params=params) 40 | 41 | webhooks = [MonzoWebhook(**webhook) for webhook in response.json()["webhooks"]] 42 | 43 | return webhooks 44 | 45 | def register( 46 | self, 47 | url: str, 48 | account_id: Optional[str] = None, 49 | ) -> MonzoWebhook: 50 | """Register a webhook. 51 | 52 | Note: 53 | Monzo API docs: https://docs.monzo.com/#registering-a-webhook 54 | 55 | Arguments: 56 | account_id: The account to receive notifications for. Can be omitted 57 | if user has only one active account. 58 | url: The URL we will send notifications to. 59 | 60 | Returns: 61 | Registered Monzo webhook. 62 | 63 | Raises: 64 | CannotDetermineDefaultAccount: If no account ID was passed and default 65 | account cannot be determined. 66 | """ 67 | if not account_id: 68 | account_id = self.client.accounts.get_default_account().id 69 | 70 | endpoint = "/webhooks" 71 | data = { 72 | "account_id": account_id, 73 | "url": url, 74 | } 75 | response = self._get_response(method="post", endpoint=endpoint, data=data) 76 | 77 | webhook = MonzoWebhook(**response.json()["webhook"]) 78 | 79 | return webhook 80 | 81 | def delete(self, webhook_id: str) -> dict: 82 | """Delete a webhook. 83 | 84 | Note: 85 | Monzo API docs: https://docs.monzo.com/#deleting-a-webhook 86 | 87 | Arguments: 88 | webhook_id: The ID of the webhook. 89 | 90 | Returns: 91 | API response. 92 | """ 93 | endpoint = f"/webhooks/{webhook_id}" 94 | 95 | response = self._get_response(method="delete", endpoint=endpoint) 96 | 97 | return response.json() 98 | -------------------------------------------------------------------------------- /src/pymonzo/webhooks/schemas.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'webhooks' related schemas.""" 2 | 3 | from pydantic import BaseModel, ConfigDict 4 | 5 | from pymonzo.transactions import MonzoTransactionMerchant 6 | 7 | 8 | class MonzoWebhook(BaseModel): 9 | """API schema for a 'webhook' object. 10 | 11 | Note: 12 | Monzo API docs: https://docs.monzo.com/#webhooks 13 | 14 | Attributes: 15 | id: The ID of the webhook. 16 | account_id: The account to receive notifications for. 17 | url: The URL we will send notifications to. 18 | """ 19 | 20 | model_config = ConfigDict(extra="allow") 21 | 22 | id: str 23 | account_id: str 24 | url: str 25 | 26 | 27 | class MonzoWebhookTransactionEvent(BaseModel): 28 | """API schema for a 'webhook event' object. 29 | 30 | For some reason it seems slight different from 31 | [`pymonzo.transactions.MonzoTransaction`][]. 32 | 33 | Note: 34 | Monzo API docs: https://docs.monzo.com/#transaction-created 35 | 36 | Attributes: 37 | account_id: The ID of the account. 38 | amount: The amount of the transaction in minor units of currency. For example 39 | pennies in the case of GBP. A negative amount indicates a debit (most 40 | card transactions will have a negative amount) 41 | created: Transaction creation date. 42 | currency: Transaction currency 43 | description: Transaction description. 44 | id: The ID of the transaction. 45 | category: The category can be set for each transaction by the user. 46 | is_load: Top-ups to an account are represented as transactions with a positive 47 | amount and `is_load = true`. Other transactions such as refunds, reversals 48 | or chargebacks may have a positive amount but `is_load = false`. 49 | settled: The timestamp at which the transaction settled. In most cases, this 50 | happens 24-48 hours after created. If this field is an empty string, 51 | the transaction is authorised but not yet "complete." 52 | merchant: Merchant information. 53 | """ 54 | 55 | model_config = ConfigDict(extra="allow") 56 | 57 | account_id: str 58 | amount: int 59 | created: str 60 | currency: str 61 | description: str 62 | id: str 63 | category: str 64 | is_load: bool 65 | settled: str 66 | merchant: MonzoTransactionMerchant 67 | 68 | 69 | class MonzoWebhookEvent(BaseModel): 70 | """API schema for a 'webhook event' object. 71 | 72 | Note: 73 | Monzo API docs: https://docs.monzo.com/#transaction-created 74 | 75 | Attributes: 76 | type: Webhook event type. 77 | data: Webhook event data. 78 | """ 79 | 80 | model_config = ConfigDict(extra="allow") 81 | 82 | type: str 83 | data: MonzoWebhookTransactionEvent 84 | -------------------------------------------------------------------------------- /src/pymonzo/whoami/__init__.py: -------------------------------------------------------------------------------- 1 | """pymonzo `whoami` package. 2 | 3 | Note: 4 | Monzo API docs: https://docs.monzo.com/#authenticating-requests 5 | """ 6 | 7 | from .resources import WhoAmIResource # noqa 8 | from .schemas import MonzoWhoAmI # noqa 9 | -------------------------------------------------------------------------------- /src/pymonzo/whoami/resources.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'whoami' resource.""" 2 | 3 | from pymonzo.resources import BaseResource 4 | from pymonzo.whoami.schemas import MonzoWhoAmI 5 | 6 | 7 | class WhoAmIResource(BaseResource): 8 | """Monzo API 'whoami' resource. 9 | 10 | Note: 11 | Monzo API docs: https://docs.monzo.com/#authenticating-requests 12 | """ 13 | 14 | def whoami(self) -> MonzoWhoAmI: 15 | """Return information about the access token. 16 | 17 | Note: 18 | Monzo API docs: https://docs.monzo.com/#authenticating-requests 19 | 20 | Returns: 21 | Information about the access token. 22 | """ 23 | endpoint = "/ping/whoami" 24 | response = self._get_response(method="get", endpoint=endpoint) 25 | 26 | who_am_i = MonzoWhoAmI(**response.json()) 27 | 28 | return who_am_i 29 | -------------------------------------------------------------------------------- /src/pymonzo/whoami/schemas.py: -------------------------------------------------------------------------------- 1 | """Monzo API 'whoami' related schemas.""" 2 | 3 | from pydantic import BaseModel, ConfigDict 4 | 5 | # Optional `rich` support 6 | try: 7 | from rich.table import Table 8 | except ImportError: 9 | RICH_AVAILABLE = False 10 | else: 11 | RICH_AVAILABLE = True 12 | 13 | 14 | class MonzoWhoAmI(BaseModel): 15 | """API schema for a 'whoami' object. 16 | 17 | Note: 18 | Monzo API docs: https://docs.monzo.com/#authenticating-requests 19 | 20 | Attributes: 21 | authenticated: Whether the user is authenticated. 22 | client_id: Client ID. 23 | user_id: User ID. 24 | """ 25 | 26 | model_config = ConfigDict(extra="allow") 27 | 28 | authenticated: bool 29 | client_id: str 30 | user_id: str 31 | 32 | if RICH_AVAILABLE: 33 | 34 | def __rich__(self) -> Table: 35 | """Pretty printing support for `rich`.""" 36 | grid = Table.grid(padding=(0, 5)) 37 | grid.add_column(style="bold yellow") 38 | grid.add_column() 39 | grid.add_row("Authenticated:", "Yes" if self.authenticated else "No") 40 | grid.add_row("Client ID:", self.client_id) 41 | grid.add_row("User ID:", self.user_id) 42 | 43 | return grid 44 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelad/pymonzo/b1bcd6391b066276fb8f464f62f63ffc26f53da2/tests/__init__.py -------------------------------------------------------------------------------- /tests/pymonzo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelad/pymonzo/b1bcd6391b066276fb8f464f62f63ffc26f53da2/tests/pymonzo/__init__.py -------------------------------------------------------------------------------- /tests/pymonzo/cassettes/test_accounts/TestAccountsResource.test_list_vcr.yaml.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelad/pymonzo/b1bcd6391b066276fb8f464f62f63ffc26f53da2/tests/pymonzo/cassettes/test_accounts/TestAccountsResource.test_list_vcr.yaml.enc -------------------------------------------------------------------------------- /tests/pymonzo/cassettes/test_balance/TestBalanceResource.test_get_vcr.yaml.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelad/pymonzo/b1bcd6391b066276fb8f464f62f63ffc26f53da2/tests/pymonzo/cassettes/test_balance/TestBalanceResource.test_get_vcr.yaml.enc -------------------------------------------------------------------------------- /tests/pymonzo/cassettes/test_pots/TestPotsResource.test_list_vcr.yaml.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelad/pymonzo/b1bcd6391b066276fb8f464f62f63ffc26f53da2/tests/pymonzo/cassettes/test_pots/TestPotsResource.test_list_vcr.yaml.enc -------------------------------------------------------------------------------- /tests/pymonzo/cassettes/test_whoami/TestWhoAmIResource.test_whoami_vcr.yaml.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelad/pymonzo/b1bcd6391b066276fb8f464f62f63ffc26f53da2/tests/pymonzo/cassettes/test_whoami/TestWhoAmIResource.test_whoami_vcr.yaml.enc -------------------------------------------------------------------------------- /tests/pymonzo/conftest.py: -------------------------------------------------------------------------------- 1 | """pymonzo pytest configuration and utils.""" 2 | 3 | import os 4 | 5 | import pytest 6 | from dotenv import load_dotenv 7 | from vcr import VCR 8 | from vcrpy_encrypt import BaseEncryptedPersister 9 | 10 | from pymonzo import MonzoAPI 11 | 12 | load_dotenv() 13 | 14 | VCRPY_ENCRYPTION_KEY = os.getenv("VCRPY_ENCRYPTION_KEY", "").encode("UTF-8") 15 | 16 | 17 | class PyMonzoEncryptedPersister(BaseEncryptedPersister): 18 | """Custom VCR persister that encrypts cassettes.""" 19 | 20 | encryption_key: bytes = VCRPY_ENCRYPTION_KEY 21 | 22 | 23 | def pytest_recording_configure(config: pytest.Config, vcr: VCR) -> None: 24 | """Register custom VCR persister that encrypts cassettes.""" 25 | vcr.register_persister(PyMonzoEncryptedPersister) 26 | 27 | 28 | @pytest.fixture(scope="module") 29 | def monzo_api() -> MonzoAPI: 30 | """Return a `MonzoAPI` instance.""" 31 | return MonzoAPI(access_token="FIXTURE_TEST_TOKEN") # noqa 32 | -------------------------------------------------------------------------------- /tests/pymonzo/test_accounts.py: -------------------------------------------------------------------------------- 1 | """Test `pymonzo.accounts` module.""" 2 | 3 | import os 4 | 5 | import httpx 6 | import pytest 7 | import respx 8 | from polyfactory.factories.pydantic_factory import ModelFactory 9 | from pytest_mock import MockerFixture 10 | 11 | from pymonzo import MonzoAPI 12 | from pymonzo.accounts import ( 13 | AccountsResource, 14 | MonzoAccount, 15 | MonzoAccountCurrency, 16 | MonzoAccountType, 17 | ) 18 | from pymonzo.exceptions import CannotDetermineDefaultAccount 19 | 20 | 21 | class MonzoAccountFactory(ModelFactory[MonzoAccount]): 22 | """Factory for `MonzoAccount` schema.""" 23 | 24 | 25 | # TODO: What should be resources fixture scope? 26 | # With `module`, `_cached_accounts` value persisted between functions / tests. 27 | @pytest.fixture() 28 | def accounts_resource(monzo_api: MonzoAPI) -> AccountsResource: 29 | """Initialize `AccountsResource` resource with `monzo_api` fixture.""" 30 | return AccountsResource(client=monzo_api) 31 | 32 | 33 | class TestAccountsResource: 34 | """Test `AccountsResource` class.""" 35 | 36 | def test_get_default_account( 37 | self, 38 | mocker: MockerFixture, 39 | accounts_resource: AccountsResource, 40 | ) -> None: 41 | """Account is presented as default if there is only one (active) account.""" 42 | # Mock `.list()` method 43 | mocked_accounts_list = mocker.patch.object(accounts_resource, "list") 44 | 45 | active_account1 = MonzoAccountFactory.build(closed=False) 46 | active_account2 = MonzoAccountFactory.build(closed=False) 47 | 48 | closed_account1 = MonzoAccountFactory.build(closed=True) 49 | closed_account2 = MonzoAccountFactory.build(closed=True) 50 | 51 | # No accounts 52 | mocked_accounts_list.return_value = [] 53 | 54 | with pytest.raises(CannotDetermineDefaultAccount): 55 | accounts_resource.get_default_account() 56 | 57 | mocked_accounts_list.assert_called_once_with() 58 | mocked_accounts_list.reset_mock() 59 | 60 | # One account, none active 61 | mocked_accounts_list.return_value = [closed_account1] 62 | 63 | default_account = accounts_resource.get_default_account() 64 | 65 | mocked_accounts_list.assert_called_once_with() 66 | mocked_accounts_list.reset_mock() 67 | 68 | assert default_account == closed_account1 69 | assert default_account.id == closed_account1.id 70 | assert default_account.closed is True 71 | 72 | # One account, one active 73 | mocked_accounts_list.return_value = [active_account1] 74 | 75 | default_account = accounts_resource.get_default_account() 76 | 77 | mocked_accounts_list.assert_called_once_with() 78 | mocked_accounts_list.reset_mock() 79 | 80 | assert default_account == active_account1 81 | assert default_account.id == active_account1.id 82 | assert default_account.closed is False 83 | 84 | # Two accounts, none active 85 | mocked_accounts_list.return_value = [closed_account1, closed_account2] 86 | 87 | with pytest.raises(CannotDetermineDefaultAccount): 88 | accounts_resource.get_default_account() 89 | 90 | mocked_accounts_list.assert_called_once_with() 91 | mocked_accounts_list.reset_mock() 92 | 93 | # Two accounts, one active 94 | mocked_accounts_list.return_value = [closed_account1, active_account1] 95 | 96 | default_account = accounts_resource.get_default_account() 97 | 98 | mocked_accounts_list.assert_called_once_with() 99 | mocked_accounts_list.reset_mock() 100 | 101 | assert default_account == active_account1 102 | assert default_account.id == active_account1.id 103 | assert default_account.closed is False 104 | 105 | # Two accounts, two active 106 | mocked_accounts_list.return_value = [active_account1, active_account2] 107 | 108 | with pytest.raises(CannotDetermineDefaultAccount): 109 | accounts_resource.get_default_account() 110 | 111 | mocked_accounts_list.assert_called_once_with() 112 | mocked_accounts_list.reset_mock() 113 | 114 | def test_list_respx( 115 | self, 116 | mocker: MockerFixture, 117 | respx_mock: respx.MockRouter, 118 | accounts_resource: AccountsResource, 119 | ) -> None: 120 | """Correct API response is sent, API response is parsed into expected schema.""" 121 | account = MonzoAccountFactory.build(payment_details=None) 122 | # Account `type` and `currency` should be converted to en enum 123 | account2 = MonzoAccountFactory.build( 124 | payment_details=None, 125 | type=MonzoAccountType.UK_RETAIL, 126 | currency=MonzoAccountCurrency.GBP, 127 | ) 128 | # Account `type` and `currency` should be taken as in as a string 129 | account3 = MonzoAccountFactory.build( 130 | payment_details=None, 131 | type="TEST_TYPE", 132 | currency="TEST_CURRENCY", 133 | ) 134 | # Account `type` should be taken as in as a string, but `currency` should be 135 | # converted to en enum because of casting priority 136 | account4 = MonzoAccountFactory.build( 137 | payment_details=None, 138 | type="TEST_TYPE", 139 | currency="GBP", 140 | ) 141 | 142 | mocked_route = respx_mock.get("/accounts").mock( 143 | return_value=httpx.Response( 144 | 200, 145 | json={ 146 | "accounts": [ 147 | account.model_dump(mode="json"), 148 | account2.model_dump(mode="json"), 149 | account3.model_dump(mode="json"), 150 | account4.model_dump(mode="json"), 151 | ] 152 | }, 153 | ) 154 | ) 155 | 156 | accounts_list_response = accounts_resource.list() 157 | 158 | assert isinstance(accounts_list_response, list) 159 | for item in accounts_list_response: 160 | assert isinstance(item, MonzoAccount) 161 | assert accounts_list_response == [account, account2, account3, account4] 162 | assert mocked_route.called 163 | 164 | assert accounts_list_response[1].type == MonzoAccountType.UK_RETAIL 165 | assert accounts_list_response[1].currency == MonzoAccountCurrency.GBP 166 | 167 | assert accounts_list_response[2].type == "TEST_TYPE" 168 | assert accounts_list_response[2].currency == "TEST_CURRENCY" 169 | 170 | assert accounts_list_response[3].type == "TEST_TYPE" 171 | assert accounts_list_response[3].currency == MonzoAccountCurrency.GBP 172 | 173 | @pytest.mark.vcr() 174 | @pytest.mark.skipif( 175 | not bool(os.getenv("VCRPY_ENCRYPTION_KEY")), 176 | reason="`VCRPY_ENCRYPTION_KEY` is not available on GitHub PRs.", 177 | ) 178 | def test_list_vcr( 179 | self, 180 | mocker: MockerFixture, 181 | accounts_resource: AccountsResource, 182 | ) -> None: 183 | """API response is parsed into expected schema.""" 184 | assert accounts_resource._cached_accounts == [] 185 | _get_response_spy = mocker.spy(accounts_resource, "_get_response") 186 | 187 | accounts_list = accounts_resource.list() 188 | 189 | _get_response_spy.assert_called_once() 190 | _get_response_spy.reset_mock() 191 | 192 | assert isinstance(accounts_list, list) 193 | 194 | for account in accounts_list: 195 | assert isinstance(account, MonzoAccount) 196 | 197 | # Check that response was cached 198 | assert accounts_resource._cached_accounts == accounts_list 199 | 200 | accounts_list2 = accounts_resource.list() 201 | 202 | _get_response_spy.assert_not_called() 203 | _get_response_spy.reset_mock() 204 | 205 | assert accounts_list2 is accounts_list 206 | 207 | # Using `refresh` should force using cache reload 208 | accounts_list3 = accounts_resource.list(refresh=True) 209 | 210 | _get_response_spy.assert_called_once() 211 | _get_response_spy.reset_mock() 212 | 213 | assert accounts_list3 == accounts_list 214 | 215 | assert isinstance(accounts_list3, list) 216 | 217 | for account in accounts_list3: 218 | assert isinstance(account, MonzoAccount) 219 | -------------------------------------------------------------------------------- /tests/pymonzo/test_attachments.py: -------------------------------------------------------------------------------- 1 | """Test `pymonzo.attachments` module.""" 2 | 3 | import httpx 4 | import pytest 5 | import respx 6 | from polyfactory.factories.pydantic_factory import ModelFactory 7 | 8 | from pymonzo import MonzoAPI 9 | from pymonzo.attachments import ( 10 | AttachmentsResource, 11 | MonzoAttachment, 12 | MonzoAttachmentResponse, 13 | ) 14 | 15 | 16 | class MonzoAttachmentFactory(ModelFactory[MonzoAttachment]): 17 | """Factory for `MonzoAttachment` schema.""" 18 | 19 | 20 | class MonzoAttachmentResponseFactory(ModelFactory[MonzoAttachmentResponse]): 21 | """Factory for `MonzoAttachmentResponse` schema.""" 22 | 23 | 24 | @pytest.fixture(scope="module") 25 | def attachments_resource(monzo_api: MonzoAPI) -> AttachmentsResource: 26 | """Initialize `AttachmentsResource` resource with `monzo_api` fixture.""" 27 | return AttachmentsResource(client=monzo_api) 28 | 29 | 30 | class TestAttachmentsResource: 31 | """Test `AttachmentsResource` class.""" 32 | 33 | @pytest.mark.respx(base_url=MonzoAPI.api_url) 34 | def test_upload_respx( 35 | self, 36 | respx_mock: respx.MockRouter, 37 | attachments_resource: AttachmentsResource, 38 | ) -> None: 39 | """Correct API response is sent, API response is parsed into expected schema.""" 40 | file_name = "TEST_FILE_NAME" 41 | file_type = "TEST_FILE_TYPE" 42 | content_length = 1 43 | attachment_response = MonzoAttachmentResponseFactory.build() 44 | 45 | mocked_route = respx_mock.post( 46 | "/attachment/upload", 47 | data={ 48 | "file_name": file_name, 49 | "file_type": file_type, 50 | "content_length": content_length, 51 | }, 52 | ).mock( 53 | return_value=httpx.Response( 54 | 200, 55 | json=attachment_response.model_dump(mode="json"), 56 | ) 57 | ) 58 | 59 | attachment_upload_response = attachments_resource.upload( 60 | file_name=file_name, 61 | file_type=file_type, 62 | content_length=content_length, 63 | ) 64 | 65 | assert attachment_upload_response == attachment_response 66 | assert mocked_route.called 67 | 68 | @pytest.mark.respx(base_url=MonzoAPI.api_url) 69 | def test_register_respx( 70 | self, 71 | respx_mock: respx.MockRouter, 72 | attachments_resource: AttachmentsResource, 73 | ) -> None: 74 | """Correct API response is sent, API response is parsed into expected schema.""" 75 | transaction_id = "TEST_TRANSACTION_ID" 76 | file_url = "TEST_FILE_URL" 77 | file_type = "TEST_FILE_TYPE" 78 | attachment = MonzoAttachmentFactory.build() 79 | 80 | mocked_route = respx_mock.post( 81 | "/attachment/register", 82 | data={ 83 | "external_id": transaction_id, 84 | "file_url": file_url, 85 | "file_type": file_type, 86 | }, 87 | ).mock( 88 | return_value=httpx.Response( 89 | 200, 90 | json={"attachment": attachment.model_dump(mode="json")}, 91 | ) 92 | ) 93 | 94 | attachment_register_response = attachments_resource.register( 95 | transaction_id, 96 | file_url=file_url, 97 | file_type=file_type, 98 | ) 99 | 100 | assert attachment_register_response == attachment 101 | assert mocked_route.called 102 | 103 | @pytest.mark.respx(base_url=MonzoAPI.api_url) 104 | def test_deregister_respx( 105 | self, 106 | respx_mock: respx.MockRouter, 107 | attachments_resource: AttachmentsResource, 108 | ) -> None: 109 | """Correct API response is sent, API response is parsed into expected schema.""" 110 | attachment_id = "TEST_ATTACHMENT_ID" 111 | 112 | mocked_route = respx_mock.post( 113 | "/attachment/deregister", 114 | data={"id": attachment_id}, 115 | ).mock(return_value=httpx.Response(200, json={})) 116 | 117 | attachment_deregister_response = attachments_resource.deregister(attachment_id) 118 | 119 | assert attachment_deregister_response == {} 120 | assert mocked_route.called 121 | -------------------------------------------------------------------------------- /tests/pymonzo/test_balance.py: -------------------------------------------------------------------------------- 1 | """Test `pymonzo.balance` module.""" 2 | 3 | import os 4 | 5 | import httpx 6 | import pytest 7 | import respx 8 | from polyfactory.factories.pydantic_factory import ModelFactory 9 | from pytest_mock import MockerFixture 10 | 11 | from pymonzo import MonzoAPI 12 | from pymonzo.balance import BalanceResource, MonzoBalance 13 | 14 | from .test_accounts import MonzoAccountFactory 15 | 16 | 17 | class MonzoBalanceFactory(ModelFactory[MonzoBalance]): 18 | """Factory for `MonzoBalance` schema.""" 19 | 20 | # This is undocumented in Monzo API, and doesn't return anything for my account, 21 | # so I don't know its schema 22 | local_spend: list = [] 23 | 24 | 25 | @pytest.fixture(scope="module") 26 | def balance_resource(monzo_api: MonzoAPI) -> BalanceResource: 27 | """Initialize `BalanceResource` resource with `monzo_api` fixture.""" 28 | return BalanceResource(client=monzo_api) 29 | 30 | 31 | class TestBalanceResource: 32 | """Test `BalanceResource` class.""" 33 | 34 | @pytest.mark.respx(base_url=MonzoAPI.api_url) 35 | def test_get_respx( 36 | self, 37 | mocker: MockerFixture, 38 | respx_mock: respx.MockRouter, 39 | balance_resource: BalanceResource, 40 | ) -> None: 41 | """Correct API response is sent, API response is parsed into expected schema.""" 42 | balance = MonzoBalanceFactory.build() 43 | 44 | account = MonzoAccountFactory.build() 45 | mocked_get_default_account = mocker.patch.object( 46 | balance_resource.client.accounts, 47 | "get_default_account", 48 | ) 49 | mocked_get_default_account.return_value = account 50 | 51 | mocked_route = respx_mock.get( 52 | "/balance", params={"account_id": account.id} 53 | ).mock( 54 | return_value=httpx.Response( 55 | 200, 56 | json=balance.model_dump(mode="json"), 57 | ) 58 | ) 59 | 60 | balance_get_response = balance_resource.get() 61 | 62 | mocked_get_default_account.assert_called_once_with() 63 | mocked_get_default_account.reset_mock() 64 | 65 | assert isinstance(balance_get_response, MonzoBalance) 66 | assert balance_get_response == balance 67 | assert mocked_route.called 68 | 69 | # Explicitly passed account ID 70 | account_id = "TEST_ACCOUNT_ID" 71 | 72 | mocked_route = respx_mock.get( 73 | "/balance", params={"account_id": account_id} 74 | ).mock( 75 | return_value=httpx.Response( 76 | 200, 77 | json=balance.model_dump(mode="json"), 78 | ) 79 | ) 80 | 81 | balance_get_response = balance_resource.get(account_id=account_id) 82 | 83 | mocked_get_default_account.assert_not_called() 84 | mocked_get_default_account.reset_mock() 85 | 86 | assert isinstance(balance_get_response, MonzoBalance) 87 | assert balance_get_response == balance 88 | assert mocked_route.called 89 | 90 | @pytest.mark.vcr() 91 | @pytest.mark.skipif( 92 | not bool(os.getenv("VCRPY_ENCRYPTION_KEY")), 93 | reason="`VCRPY_ENCRYPTION_KEY` is not available on GitHub PRs.", 94 | ) 95 | def test_get_vcr(self, balance_resource: BalanceResource) -> None: 96 | """API response is parsed into expected schema.""" 97 | balance = balance_resource.get() 98 | 99 | assert isinstance(balance, MonzoBalance) 100 | -------------------------------------------------------------------------------- /tests/pymonzo/test_client.py: -------------------------------------------------------------------------------- 1 | """Test `pymonzo.client` module.""" 2 | 3 | import json 4 | import types 5 | from pathlib import Path 6 | 7 | import pytest 8 | from pytest_mock import MockerFixture 9 | 10 | from pymonzo.accounts import AccountsResource 11 | from pymonzo.attachments import AttachmentsResource 12 | from pymonzo.balance import BalanceResource 13 | from pymonzo.client import MonzoAPI 14 | from pymonzo.exceptions import NoSettingsFile 15 | from pymonzo.feed import FeedResource 16 | from pymonzo.pots import PotsResource 17 | from pymonzo.transactions import TransactionsResource 18 | from pymonzo.webhooks import WebhooksResource 19 | 20 | 21 | class TestMonzoAPI: 22 | """Test `MonzoAPI` class.""" 23 | 24 | def test_init(self, tmp_path: Path, mocker: MockerFixture) -> None: 25 | """Client is initialized with settings loaded from disk.""" 26 | settings_path = tmp_path / "pymonzo_test" 27 | MonzoAPI.settings_path = settings_path 28 | 29 | # Settings file doesn't exist 30 | with pytest.raises(NoSettingsFile, match=r"No settings file found.*"): 31 | MonzoAPI() 32 | 33 | # Settings file exists but is empty 34 | with open(settings_path, "w") as f: 35 | f.write("") 36 | 37 | with pytest.raises(NoSettingsFile, match=r"No settings file found.*"): 38 | MonzoAPI() 39 | 40 | # Settings file exists 41 | settings = { 42 | "client_id": "TEST_CLIENT_ID", 43 | "client_secret": "TEST_CLIENT_SECRET", 44 | "token": { 45 | "access_token": "TEST_ACCESS_TOKEN", 46 | }, 47 | } 48 | 49 | with open(settings_path, "w") as f: 50 | json.dump(settings, f, indent=4) 51 | 52 | mocked_OAuth2Client = mocker.patch( # noqa 53 | "pymonzo.client.OAuth2Client", 54 | autospec=True, 55 | ) 56 | 57 | monzo_api = MonzoAPI() 58 | 59 | assert monzo_api._settings.model_dump() == settings 60 | assert monzo_api.session is mocked_OAuth2Client.return_value 61 | 62 | mocked_OAuth2Client.assert_called_once_with( 63 | client_id=settings["client_id"], 64 | client_secret=settings["client_secret"], 65 | token=settings["token"], 66 | authorization_endpoint=monzo_api.authorization_endpoint, 67 | token_endpoint=monzo_api.token_endpoint, 68 | token_endpoint_auth_method="client_secret_post", # noqa 69 | update_token=monzo_api._update_token, 70 | base_url=monzo_api.api_url, 71 | ) 72 | 73 | # This is a shortcut to the underlying method 74 | assert isinstance(monzo_api.whoami, types.MethodType) 75 | 76 | assert isinstance(monzo_api.accounts, AccountsResource) 77 | assert monzo_api.accounts.client is monzo_api 78 | 79 | assert isinstance(monzo_api.attachments, AttachmentsResource) 80 | assert monzo_api.attachments.client is monzo_api 81 | 82 | assert isinstance(monzo_api.balance, BalanceResource) 83 | assert monzo_api.balance.client is monzo_api 84 | 85 | assert isinstance(monzo_api.feed, FeedResource) 86 | assert monzo_api.feed.client is monzo_api 87 | 88 | assert isinstance(monzo_api.pots, PotsResource) 89 | assert monzo_api.pots.client is monzo_api 90 | 91 | assert isinstance(monzo_api.transactions, TransactionsResource) 92 | assert monzo_api.transactions.client is monzo_api 93 | 94 | assert isinstance(monzo_api.webhooks, WebhooksResource) 95 | assert monzo_api.webhooks.client is monzo_api 96 | 97 | def test_init_with_arguments(self, mocker: MockerFixture) -> None: 98 | """Client is initialized with settings from passed arguments.""" 99 | access_token = "EXPLICIT_TEST_ACCESS_TOKEN" # noqa 100 | 101 | mocked_OAuth2Client = mocker.patch( # noqa 102 | "pymonzo.client.OAuth2Client", 103 | autospec=True, 104 | ) 105 | 106 | monzo_api = MonzoAPI(access_token=access_token) 107 | 108 | assert monzo_api._settings.model_dump() == { 109 | "client_id": None, 110 | "client_secret": None, 111 | "token": {"access_token": access_token}, 112 | } 113 | assert monzo_api.session is mocked_OAuth2Client.return_value 114 | 115 | mocked_OAuth2Client.assert_called_once_with( 116 | client_id=None, 117 | client_secret=None, 118 | token={"access_token": access_token}, 119 | authorization_endpoint=monzo_api.authorization_endpoint, 120 | token_endpoint=monzo_api.token_endpoint, 121 | token_endpoint_auth_method="client_secret_post", # noqa 122 | update_token=monzo_api._update_token, 123 | base_url=monzo_api.api_url, 124 | ) 125 | 126 | # This is a shortcut to the underlying method 127 | assert isinstance(monzo_api.whoami, types.MethodType) 128 | 129 | assert isinstance(monzo_api.accounts, AccountsResource) 130 | assert monzo_api.accounts.client is monzo_api 131 | 132 | assert isinstance(monzo_api.attachments, AttachmentsResource) 133 | assert monzo_api.attachments.client is monzo_api 134 | 135 | assert isinstance(monzo_api.balance, BalanceResource) 136 | assert monzo_api.balance.client is monzo_api 137 | 138 | assert isinstance(monzo_api.feed, FeedResource) 139 | assert monzo_api.feed.client is monzo_api 140 | 141 | assert isinstance(monzo_api.pots, PotsResource) 142 | assert monzo_api.pots.client is monzo_api 143 | 144 | assert isinstance(monzo_api.transactions, TransactionsResource) 145 | assert monzo_api.transactions.client is monzo_api 146 | 147 | assert isinstance(monzo_api.webhooks, WebhooksResource) 148 | assert monzo_api.webhooks.client is monzo_api 149 | 150 | def test_authorize(self, tmp_path: Path, mocker: MockerFixture) -> None: 151 | """Auth flow is executed to get API access token.""" 152 | settings_path = tmp_path / "pymonzo_test" 153 | MonzoAPI.settings_path = settings_path 154 | 155 | client_id = "TEST_CLIENT_ID" 156 | client_secret = "TEST_CLIENT_SECRET" # noqa 157 | redirect_uri = "http://localhost:666/pymonzo" 158 | url = "TEST_URL" 159 | state = "TEST_STATE" 160 | test_token = {"access_token": "TEST_TOKEN"} 161 | 162 | mocked_OAuth2Client = mocker.patch( # noqa 163 | "pymonzo.client.OAuth2Client", 164 | autospec=True, 165 | ) 166 | mocked_create_authorization_url = ( 167 | mocked_OAuth2Client.return_value.create_authorization_url 168 | ) 169 | mocked_create_authorization_url.return_value = url, state 170 | mocked_fetch_token = mocked_OAuth2Client.return_value.fetch_token 171 | mocked_fetch_token.return_value = test_token 172 | 173 | mocked_open = mocker.patch("pymonzo.client.webbrowser.open", autospec=True) 174 | 175 | mocked_get_authorization_response_url = mocker.patch( 176 | "pymonzo.client.get_authorization_response_url", 177 | autospec=True, 178 | ) 179 | 180 | token = MonzoAPI.authorize( 181 | client_id=client_id, 182 | client_secret=client_secret, 183 | redirect_uri=redirect_uri, 184 | ) 185 | 186 | mocked_OAuth2Client.assert_called_once_with( 187 | client_id=client_id, 188 | client_secret=client_secret, 189 | redirect_uri=redirect_uri, 190 | token_endpoint_auth_method="client_secret_post", # noqa 191 | ) 192 | 193 | mocked_create_authorization_url.assert_called_once_with( 194 | MonzoAPI.authorization_endpoint 195 | ) 196 | 197 | mocked_open.assert_called_once_with(url) 198 | 199 | mocked_get_authorization_response_url.assert_called_once_with( 200 | host="localhost", 201 | port=666, 202 | ) 203 | 204 | mocked_fetch_token.assert_called_once_with( 205 | url=MonzoAPI.token_endpoint, 206 | authorization_response=mocked_get_authorization_response_url.return_value, 207 | ) 208 | 209 | assert token == test_token 210 | 211 | # Settings are saved to disk 212 | with open(settings_path) as f: 213 | loaded_settings = json.load(f) 214 | 215 | assert loaded_settings == { 216 | "client_id": client_id, 217 | "client_secret": client_secret, 218 | "token": token, 219 | } 220 | 221 | def test_update_token(self, tmp_path: Path, monzo_api: MonzoAPI) -> None: 222 | """Settings are updated and saved to the disk.""" 223 | # TODO: For some reason this doesn't work: 224 | # `save_to_disk_spy = mocker.spy(monzo_api._settings, "save_to_disk")` 225 | settings_path = tmp_path / "pymonzo_test" 226 | new_token = {"access_token": "NEW_TEST_TOKEN"} 227 | 228 | monzo_api._settings.save_to_disk(settings_path) 229 | 230 | monzo_api.settings_path = settings_path 231 | monzo_api._update_token(new_token) 232 | 233 | assert monzo_api._settings.token == new_token 234 | 235 | with open(settings_path) as f: 236 | loaded_settings = json.load(f) 237 | 238 | assert loaded_settings["token"] == new_token 239 | -------------------------------------------------------------------------------- /tests/pymonzo/test_feed.py: -------------------------------------------------------------------------------- 1 | """Test `pymonzo.feed` module.""" 2 | 3 | import httpx 4 | import pytest 5 | import respx 6 | from polyfactory.factories.pydantic_factory import ModelFactory 7 | from pytest_mock import MockerFixture 8 | 9 | from pymonzo import MonzoAPI 10 | from pymonzo.feed import FeedResource, MonzoBasicFeedItem 11 | 12 | from .test_accounts import MonzoAccountFactory 13 | 14 | 15 | class MonzoBasicFeedItemFactory(ModelFactory[MonzoBasicFeedItem]): 16 | """Factory for `MonzoBasicFeedItem` schema.""" 17 | 18 | 19 | @pytest.fixture(scope="module") 20 | def feed_resource(monzo_api: MonzoAPI) -> FeedResource: 21 | """Initialize `FeedResource` resource with `monzo_api` fixture.""" 22 | return FeedResource(client=monzo_api) 23 | 24 | 25 | class TestFeedResource: 26 | """Test `FeedResource` class.""" 27 | 28 | @pytest.mark.respx(base_url=MonzoAPI.api_url) 29 | def test_create_respx( 30 | self, 31 | mocker: MockerFixture, 32 | respx_mock: respx.MockRouter, 33 | feed_resource: FeedResource, 34 | ) -> None: 35 | """Correct API response is sent, API response is parsed into expected schema.""" 36 | feed_item = MonzoBasicFeedItemFactory.build() 37 | 38 | account = MonzoAccountFactory.build() 39 | mocked_get_default_account = mocker.patch.object( 40 | feed_resource.client.accounts, 41 | "get_default_account", 42 | ) 43 | mocked_get_default_account.return_value = account 44 | 45 | data = { 46 | "account_id": account.id, 47 | "type": "basic", 48 | "params[title]": feed_item.title, 49 | "params[image_url]": feed_item.image_url, 50 | "params[body]": feed_item.body, 51 | } 52 | optional_data = { 53 | "params[background_color]": feed_item.background_color, 54 | "params[title_color]": feed_item.title_color, 55 | "params[body_color]": feed_item.body_color, 56 | } 57 | for key, value in optional_data.items(): 58 | if value is not None: 59 | data[key] = str(value) 60 | 61 | mocked_route = respx_mock.post("/feed", data=data).mock( 62 | return_value=httpx.Response(200, json={}) 63 | ) 64 | 65 | feed_create_response = feed_resource.create(feed_item=feed_item) 66 | 67 | mocked_get_default_account.assert_called_once_with() 68 | mocked_get_default_account.reset_mock() 69 | 70 | assert feed_create_response == {} 71 | assert mocked_route.called 72 | 73 | # Explicitly passed account ID and URL 74 | account_id = "TEST_ACCOUNT_ID" 75 | url = "TEST_URL" 76 | 77 | data = { 78 | "account_id": account_id, 79 | "type": "basic", 80 | "url": url, 81 | "params[title]": feed_item.title, 82 | "params[image_url]": feed_item.image_url, 83 | "params[body]": feed_item.body, 84 | } 85 | optional_data = { 86 | "params[background_color]": feed_item.background_color, 87 | "params[title_color]": feed_item.title_color, 88 | "params[body_color]": feed_item.body_color, 89 | } 90 | for key, value in optional_data.items(): 91 | if value is not None: 92 | data[key] = str(value) 93 | 94 | mocked_route = respx_mock.post("/feed", data=data).mock( 95 | return_value=httpx.Response(200, json={}) 96 | ) 97 | 98 | feed_create_response = feed_resource.create( 99 | feed_item=feed_item, 100 | account_id=account_id, 101 | url=url, 102 | ) 103 | 104 | mocked_get_default_account.assert_not_called() 105 | mocked_get_default_account.reset_mock() 106 | 107 | assert feed_create_response == {} 108 | assert mocked_route.called 109 | -------------------------------------------------------------------------------- /tests/pymonzo/test_pots.py: -------------------------------------------------------------------------------- 1 | """Test `pymonzo.pots` module.""" 2 | 3 | import os 4 | 5 | import httpx 6 | import pytest 7 | import respx 8 | from polyfactory.factories.pydantic_factory import ModelFactory 9 | from pytest_mock import MockerFixture 10 | 11 | from pymonzo import MonzoAPI 12 | from pymonzo.exceptions import CannotDetermineDefaultPot 13 | from pymonzo.pots import MonzoPot, PotsResource 14 | 15 | from .test_accounts import MonzoAccountFactory 16 | 17 | 18 | class MonzoPotFactory(ModelFactory[MonzoPot]): 19 | """Factory for `MonzoPot` schema.""" 20 | 21 | 22 | @pytest.fixture(scope="module") 23 | def pots_resource(monzo_api: MonzoAPI) -> PotsResource: 24 | """Initialize `PotsResource` resource with `monzo_api` fixture.""" 25 | return PotsResource(client=monzo_api) 26 | 27 | 28 | class TestPotsResource: 29 | """Test `PotsResource` class.""" 30 | 31 | def test_get_default_pot( 32 | self, 33 | mocker: MockerFixture, 34 | pots_resource: PotsResource, 35 | ) -> None: 36 | """Pot is presented as default if there is only one (active) pot.""" 37 | # Set up a default account 38 | account = MonzoAccountFactory.build() 39 | 40 | mocked_get_default_account = mocker.patch.object( 41 | pots_resource.client.accounts, 42 | "get_default_account", 43 | ) 44 | mocked_get_default_account.return_value = account 45 | 46 | # Mock `.list()` method 47 | mocked_pots_list = mocker.patch.object(pots_resource, "list") 48 | 49 | active_pot = MonzoPotFactory.build(deleted=False) 50 | active_pot2 = MonzoPotFactory.build(deleted=False) 51 | deleted_pot = MonzoPotFactory.build(deleted=True) 52 | deleted_pot2 = MonzoPotFactory.build(deleted=True) 53 | 54 | # No pots 55 | mocked_pots_list.return_value = [] 56 | 57 | with pytest.raises(CannotDetermineDefaultPot): 58 | pots_resource.get_default_pot() 59 | 60 | mocked_get_default_account.assert_called_once_with() 61 | mocked_get_default_account.reset_mock() 62 | 63 | mocked_pots_list.assert_called_once_with(account.id) 64 | mocked_pots_list.reset_mock() 65 | 66 | # One account, none active 67 | mocked_pots_list.return_value = [deleted_pot] 68 | 69 | default_pot = pots_resource.get_default_pot() 70 | 71 | mocked_get_default_account.assert_called_once_with() 72 | mocked_get_default_account.reset_mock() 73 | 74 | mocked_pots_list.assert_called_once_with(account.id) 75 | mocked_pots_list.reset_mock() 76 | 77 | assert default_pot == deleted_pot 78 | assert default_pot.id == deleted_pot.id 79 | assert default_pot.deleted is True 80 | 81 | # One account, one active 82 | mocked_pots_list.return_value = [active_pot] 83 | 84 | default_pot = pots_resource.get_default_pot() 85 | 86 | mocked_get_default_account.assert_called_once_with() 87 | mocked_get_default_account.reset_mock() 88 | 89 | mocked_pots_list.assert_called_once_with(account.id) 90 | mocked_pots_list.reset_mock() 91 | 92 | assert default_pot == active_pot 93 | assert default_pot.id == active_pot.id 94 | assert default_pot.deleted is False 95 | 96 | # Two accounts, none active 97 | mocked_pots_list.return_value = [deleted_pot, deleted_pot2] 98 | 99 | with pytest.raises(CannotDetermineDefaultPot): 100 | pots_resource.get_default_pot() 101 | 102 | mocked_get_default_account.assert_called_once_with() 103 | mocked_get_default_account.reset_mock() 104 | 105 | mocked_pots_list.assert_called_once_with(account.id) 106 | mocked_pots_list.reset_mock() 107 | 108 | # Two accounts, one active 109 | mocked_pots_list.return_value = [deleted_pot, active_pot] 110 | 111 | default_pot = pots_resource.get_default_pot() 112 | 113 | mocked_get_default_account.assert_called_once_with() 114 | mocked_get_default_account.reset_mock() 115 | 116 | mocked_pots_list.assert_called_once_with(account.id) 117 | mocked_pots_list.reset_mock() 118 | 119 | assert default_pot == active_pot 120 | assert default_pot.id == active_pot.id 121 | assert default_pot.deleted is False 122 | 123 | # Two accounts, two active 124 | mocked_pots_list.return_value = [active_pot, active_pot2] 125 | 126 | with pytest.raises(CannotDetermineDefaultPot): 127 | pots_resource.get_default_pot() 128 | 129 | mocked_get_default_account.assert_called_once_with() 130 | mocked_get_default_account.reset_mock() 131 | 132 | mocked_pots_list.assert_called_once_with(account.id) 133 | mocked_pots_list.reset_mock() 134 | 135 | @pytest.mark.vcr() 136 | @pytest.mark.skipif( 137 | not bool(os.getenv("VCRPY_ENCRYPTION_KEY")), 138 | reason="`VCRPY_ENCRYPTION_KEY` is not available on GitHub PRs.", 139 | ) 140 | def test_list_vcr(self, pots_resource: PotsResource) -> None: 141 | """API response is parsed into expected schema.""" 142 | pots_list = pots_resource.list() 143 | 144 | assert isinstance(pots_list, list) 145 | for pot in pots_list: 146 | assert isinstance(pot, MonzoPot) 147 | 148 | @pytest.mark.respx(base_url=MonzoAPI.api_url) 149 | def test_deposit_respx( 150 | self, 151 | mocker: MockerFixture, 152 | respx_mock: respx.MockRouter, 153 | pots_resource: PotsResource, 154 | ) -> None: 155 | """Correct API response is sent, API response is parsed into expected schema.""" 156 | # Mock `get_default_account()` 157 | account = MonzoAccountFactory.build() 158 | mocked_get_default_account = mocker.patch.object( 159 | pots_resource.client.accounts, 160 | "get_default_account", 161 | ) 162 | mocked_get_default_account.return_value = account 163 | 164 | # Mock `get_default_pot()` 165 | pot = MonzoPotFactory.build() 166 | mocked_get_default_pot = mocker.patch.object( 167 | pots_resource, 168 | "get_default_pot", 169 | ) 170 | mocked_get_default_pot.return_value = pot 171 | 172 | # Mock `token_urlsafe` 173 | token = "TEST_TOKEN" # noqa 174 | mocked_token_urlsafe = mocker.patch( 175 | "pymonzo.pots.resources.token_urlsafe", 176 | autospec=True, 177 | ) 178 | mocked_token_urlsafe.return_value = token 179 | 180 | # Mock `httpx` 181 | amount = 42 182 | endpoint = f"/pots/{pot.id}/deposit" 183 | data = { 184 | "source_account_id": account.id, 185 | "amount": amount, 186 | "dedupe_id": token, 187 | } 188 | mocked_route = respx_mock.put(endpoint, data=data).mock( 189 | return_value=httpx.Response( 190 | 200, 191 | json=pot.model_dump(mode="json"), 192 | ) 193 | ) 194 | 195 | pots_deposit_response = pots_resource.deposit(amount) 196 | 197 | mocked_get_default_account.assert_called_once_with() 198 | mocked_get_default_account.reset_mock() 199 | 200 | mocked_get_default_pot.assert_called_once_with(account.id) 201 | mocked_get_default_pot.reset_mock() 202 | 203 | mocked_token_urlsafe.assert_called_once_with(16) 204 | mocked_token_urlsafe.reset_mock() 205 | 206 | assert pots_deposit_response == pot 207 | assert mocked_route.called 208 | 209 | # Explicitly passed account ID, pot ID and dedupe ID 210 | account_id = "TEST_ACCOUNT_ID" 211 | pot_id = "TEST_POT_ID" 212 | dedupe_id = "TEST_DEDUPE_ID" 213 | 214 | amount = 42 215 | endpoint = f"/pots/{pot_id}/deposit" 216 | data = { 217 | "source_account_id": account_id, 218 | "amount": amount, 219 | "dedupe_id": dedupe_id, 220 | } 221 | mocked_route = respx_mock.put(endpoint, data=data).mock( 222 | return_value=httpx.Response( 223 | 200, 224 | json=pot.model_dump(mode="json"), 225 | ) 226 | ) 227 | 228 | pots_deposit_response = pots_resource.deposit( 229 | amount=amount, 230 | pot_id=pot_id, 231 | account_id=account_id, 232 | dedupe_id=dedupe_id, 233 | ) 234 | 235 | mocked_get_default_account.assert_not_called() 236 | mocked_get_default_account.reset_mock() 237 | 238 | mocked_get_default_pot.assert_not_called() 239 | mocked_token_urlsafe.reset_mock() 240 | 241 | mocked_token_urlsafe.assert_not_called() 242 | mocked_token_urlsafe.reset_mock() 243 | 244 | assert pots_deposit_response == pot 245 | assert mocked_route.called 246 | 247 | @pytest.mark.respx(base_url=MonzoAPI.api_url) 248 | def test_withdraw_respx( 249 | self, 250 | mocker: MockerFixture, 251 | respx_mock: respx.MockRouter, 252 | pots_resource: PotsResource, 253 | ) -> None: 254 | """Correct API response is sent, API response is parsed into expected schema.""" 255 | # Mock `get_default_account()` 256 | account = MonzoAccountFactory.build() 257 | mocked_get_default_account = mocker.patch.object( 258 | pots_resource.client.accounts, 259 | "get_default_account", 260 | ) 261 | mocked_get_default_account.return_value = account 262 | 263 | # Mock `get_default_pot()` 264 | pot = MonzoPotFactory.build() 265 | mocked_get_default_pot = mocker.patch.object( 266 | pots_resource, 267 | "get_default_pot", 268 | ) 269 | mocked_get_default_pot.return_value = pot 270 | 271 | # Mock `token_urlsafe` 272 | token = "TEST_TOKEN" # noqa 273 | mocked_token_urlsafe = mocker.patch( 274 | "pymonzo.pots.resources.token_urlsafe", 275 | autospec=True, 276 | ) 277 | mocked_token_urlsafe.return_value = token 278 | 279 | # Mock `httpx` 280 | amount = 42 281 | endpoint = f"/pots/{pot.id}/withdraw" 282 | data = { 283 | "destination_account_id": account.id, 284 | "amount": amount, 285 | "dedupe_id": token, 286 | } 287 | mocked_route = respx_mock.put(endpoint, data=data).mock( 288 | return_value=httpx.Response( 289 | 200, 290 | json=pot.model_dump(mode="json"), 291 | ) 292 | ) 293 | 294 | pots_deposit_response = pots_resource.withdraw(amount) 295 | 296 | mocked_get_default_account.assert_called_once_with() 297 | mocked_get_default_account.reset_mock() 298 | 299 | mocked_get_default_pot.assert_called_once_with(account.id) 300 | mocked_get_default_pot.reset_mock() 301 | 302 | mocked_token_urlsafe.assert_called_once_with(16) 303 | mocked_token_urlsafe.reset_mock() 304 | 305 | assert pots_deposit_response == pot 306 | assert mocked_route.called 307 | 308 | # Explicitly passed account ID, pot ID and dedupe ID 309 | account_id = "TEST_ACCOUNT_ID" 310 | pot_id = "TEST_POT_ID" 311 | dedupe_id = "TEST_DEDUPE_ID" 312 | 313 | amount = 42 314 | endpoint = f"/pots/{pot_id}/withdraw" 315 | data = { 316 | "destination_account_id": account_id, 317 | "amount": amount, 318 | "dedupe_id": dedupe_id, 319 | } 320 | mocked_route = respx_mock.put(endpoint, data=data).mock( 321 | return_value=httpx.Response( 322 | 200, 323 | json=pot.model_dump(mode="json"), 324 | ) 325 | ) 326 | 327 | pots_deposit_response = pots_resource.withdraw( 328 | amount=amount, 329 | pot_id=pot_id, 330 | account_id=account_id, 331 | dedupe_id=dedupe_id, 332 | ) 333 | 334 | mocked_get_default_account.assert_not_called() 335 | mocked_get_default_account.reset_mock() 336 | 337 | mocked_get_default_pot.assert_not_called() 338 | mocked_token_urlsafe.reset_mock() 339 | 340 | mocked_token_urlsafe.assert_not_called() 341 | mocked_token_urlsafe.reset_mock() 342 | 343 | assert pots_deposit_response == pot 344 | assert mocked_route.called 345 | -------------------------------------------------------------------------------- /tests/pymonzo/test_resources.py: -------------------------------------------------------------------------------- 1 | """Test `pymonzo.resources` module.""" 2 | 3 | import httpx 4 | import pytest 5 | import respx 6 | 7 | from pymonzo import MonzoAPI 8 | from pymonzo.exceptions import MonzoAccessDenied, MonzoAPIError 9 | from pymonzo.resources import BaseResource 10 | 11 | 12 | @pytest.fixture(scope="module") 13 | def base_resource(monzo_api: MonzoAPI) -> BaseResource: 14 | """Initialize `BaseResource` resource with `monzo_api` fixture.""" 15 | return BaseResource(client=monzo_api) 16 | 17 | 18 | class TestBaseResource: 19 | """Test `BaseResource` class.""" 20 | 21 | def test__get_response( 22 | self, respx_mock: respx.MockRouter, base_resource: BaseResource 23 | ) -> None: 24 | """Correct request is sent, response errors are raised.""" 25 | params = { 26 | "foo": "TEST_FOO", 27 | "bar": "TEST_BAR", 28 | "n": "42", 29 | } 30 | data = {"response": "TEST_RESPONSE"} 31 | 32 | mocked_route = respx_mock.post("/foo/bar", params=params).mock( 33 | return_value=httpx.Response(200, json=data) 34 | ) 35 | 36 | response = base_resource._get_response( 37 | method="post", 38 | endpoint="/foo/bar", 39 | params=params, 40 | ) 41 | 42 | assert response.json() == data 43 | assert mocked_route.called 44 | 45 | # HTTP 403 46 | mocked_route = respx_mock.get("/http403").mock(return_value=httpx.Response(403)) 47 | 48 | with pytest.raises( 49 | MonzoAccessDenied, 50 | match=r"Monzo API access denied \(HTTP 403 Forbidden\). .*", 51 | ): 52 | base_resource._get_response(method="get", endpoint="/http403") 53 | 54 | assert mocked_route.called 55 | 56 | # HTTP 404 with JSON response 57 | mocked_route = respx_mock.get("/http404").mock( 58 | return_value=httpx.Response( 59 | 404, 60 | json={"code": "404", "message": "Error message"}, 61 | ) 62 | ) 63 | 64 | with pytest.raises(MonzoAPIError, match=r"Error message \(404\)"): 65 | base_resource._get_response(method="get", endpoint="/http404") 66 | 67 | assert mocked_route.called 68 | 69 | # HTTP 500 70 | mocked_route = respx_mock.get("/http500").mock(return_value=httpx.Response(500)) 71 | 72 | with pytest.raises(MonzoAPIError, match=r"Something went wrong: .*"): 73 | base_resource._get_response(method="get", endpoint="/http500") 74 | 75 | assert mocked_route.called 76 | -------------------------------------------------------------------------------- /tests/pymonzo/test_settings.py: -------------------------------------------------------------------------------- 1 | """Test `pymonzo.settings` module.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from polyfactory.factories.pydantic_factory import ModelFactory 7 | 8 | from pymonzo.settings import PyMonzoSettings 9 | 10 | 11 | class PyMonzoSettingsFactory(ModelFactory[PyMonzoSettings]): 12 | """Factory for `PyMonzoSettings` schema.""" 13 | 14 | 15 | class TestPyMonzoSettings: 16 | """Test `PyMonzoSettings` class.""" 17 | 18 | def test_load_from_disk(self, tmp_path: Path) -> None: 19 | """Settings are loaded from disk.""" 20 | # Save manually 21 | settings = { 22 | "client_id": "TEST_CLIENT_ID", 23 | "client_secret": "TEST_CLIENT_SECRET", 24 | "token": { 25 | "access_token": "TEST_ACCESS_TOKEN", 26 | }, 27 | } 28 | 29 | settings_path = tmp_path / ".pymonzo-test" 30 | with open(settings_path, "w") as f: 31 | json.dump(settings, f, indent=4) 32 | 33 | # Load 34 | loaded_settings = PyMonzoSettings.load_from_disk(settings_path) 35 | 36 | # TODO: Why does `mypy` fail here with 37 | # `Argument 1 to "PyMonzoSettings" has incompatible type` 38 | assert loaded_settings == PyMonzoSettings(**settings) # type: ignore 39 | 40 | def test_save_to_disk(self, tmp_path: Path) -> None: 41 | """Settings are saved to disk.""" 42 | # Save 43 | settings = PyMonzoSettingsFactory.build() 44 | 45 | settings_path = tmp_path / ".pymonzo-test" 46 | settings.save_to_disk(settings_path) 47 | 48 | # Load manually 49 | with open(settings_path) as f: 50 | loaded_settings = json.load(f) 51 | 52 | assert loaded_settings == settings.model_dump(mode="json") 53 | -------------------------------------------------------------------------------- /tests/pymonzo/test_transactions.py: -------------------------------------------------------------------------------- 1 | """Test `pymonzo.transactions` module.""" 2 | 3 | from datetime import datetime 4 | 5 | import httpx 6 | import pytest 7 | import respx 8 | from polyfactory.factories.pydantic_factory import ModelFactory 9 | from pytest_mock import MockerFixture 10 | 11 | from pymonzo import MonzoAPI 12 | from pymonzo.transactions import ( 13 | MonzoTransaction, 14 | MonzoTransactionCounterparty, 15 | MonzoTransactionMerchant, 16 | TransactionsResource, 17 | ) 18 | 19 | from .test_accounts import MonzoAccountFactory 20 | 21 | 22 | class MonzoTransactionFactory(ModelFactory[MonzoTransaction]): 23 | """Factory for `MonzoTransaction` schema.""" 24 | 25 | 26 | class MonzoTransactionMerchantFactory(ModelFactory[MonzoTransactionMerchant]): 27 | """Factory for `MonzoTransactionMerchant` schema.""" 28 | 29 | 30 | class MonzoTransactionCounterpartyFactory(ModelFactory[MonzoTransactionCounterparty]): 31 | """Factory for `MonzoTransactionCounterparty` schema.""" 32 | 33 | 34 | @pytest.fixture(scope="module") 35 | def transactions_resource(monzo_api: MonzoAPI) -> TransactionsResource: 36 | """Initialize `TransactionsResource` resource with `monzo_api` fixture.""" 37 | return TransactionsResource(client=monzo_api) 38 | 39 | 40 | class TestTransactionsResource: 41 | """Test `TransactionsResource` class.""" 42 | 43 | def test_get_respx( 44 | self, 45 | respx_mock: respx.MockRouter, 46 | transactions_resource: TransactionsResource, 47 | ) -> None: 48 | """Correct API response is sent, API response is parsed into expected schema.""" 49 | transaction = MonzoTransactionFactory.build(merchant="TEST_MERCHANT") 50 | 51 | mocked_route = respx_mock.get(f"/transactions/{transaction.id}").mock( 52 | return_value=httpx.Response( 53 | 200, 54 | json={"transaction": transaction.model_dump(mode="json")}, 55 | ) 56 | ) 57 | 58 | transaction_response = transactions_resource.get(transaction.id) 59 | 60 | assert isinstance(transaction_response, MonzoTransaction) 61 | assert transaction_response == transaction 62 | assert mocked_route.called 63 | 64 | # Expand merchant 65 | merchant = MonzoTransactionMerchantFactory.build() 66 | transaction = MonzoTransactionFactory.build(merchant=merchant) 67 | 68 | mocked_route = respx_mock.get( 69 | f"/transactions/{transaction.id}", params={"expand[]": "merchant"} 70 | ).mock( 71 | return_value=httpx.Response( 72 | 200, 73 | json={"transaction": transaction.model_dump(mode="json")}, 74 | ) 75 | ) 76 | 77 | transaction_response = transactions_resource.get( 78 | transaction.id, 79 | expand_merchant=True, 80 | ) 81 | 82 | assert isinstance(transaction_response, MonzoTransaction) 83 | assert isinstance(transaction_response.merchant, MonzoTransactionMerchant) 84 | assert transaction_response == transaction 85 | assert mocked_route.called 86 | 87 | # Custom transaction `category` and `decline_reason` 88 | transaction = MonzoTransactionFactory.build( 89 | merchant="TEST_MERCHANT", 90 | category="TEST_CATEGORY", 91 | decline_reason="TEST_DECLINE_REASON", 92 | ) 93 | 94 | mocked_route = respx_mock.get(f"/transactions/{transaction.id}").mock( 95 | return_value=httpx.Response( 96 | 200, 97 | json={"transaction": transaction.model_dump(mode="json")}, 98 | ) 99 | ) 100 | 101 | transaction_response = transactions_resource.get(transaction.id) 102 | 103 | assert isinstance(transaction_response, MonzoTransaction) 104 | assert transaction_response == transaction 105 | assert mocked_route.called 106 | 107 | assert transaction_response.category == "TEST_CATEGORY" 108 | assert transaction_response.decline_reason == "TEST_DECLINE_REASON" 109 | 110 | # Counterparty details are present 111 | counterparty = MonzoTransactionCounterpartyFactory.build() 112 | transaction = MonzoTransactionFactory.build( 113 | merchant="TEST_MERCHANT", 114 | counterparty=counterparty, 115 | ) 116 | 117 | mocked_route = respx_mock.get(f"/transactions/{transaction.id}").mock( 118 | return_value=httpx.Response( 119 | 200, 120 | json={"transaction": transaction.model_dump(mode="json")}, 121 | ) 122 | ) 123 | 124 | transaction_response = transactions_resource.get(transaction.id) 125 | 126 | assert isinstance(transaction_response, MonzoTransaction) 127 | assert isinstance( 128 | transaction_response.counterparty, MonzoTransactionCounterparty 129 | ) 130 | assert transaction_response == transaction 131 | assert mocked_route.called 132 | 133 | def test_annotate_respx( 134 | self, 135 | respx_mock: respx.MockRouter, 136 | transactions_resource: TransactionsResource, 137 | ) -> None: 138 | """Correct API response is sent, API response is parsed into expected schema.""" 139 | transaction = MonzoTransactionFactory.build(merchant="TEST_MERCHANT") 140 | metadata = { 141 | "foo": "TEST_FOO", 142 | "bar": "TEST_BAR", 143 | } 144 | data = { 145 | "metadata[foo]": "TEST_FOO", 146 | "metadata[bar]": "TEST_BAR", 147 | } 148 | 149 | mocked_route = respx_mock.patch( 150 | f"/transactions/{transaction.id}", 151 | data=data, 152 | ).mock( 153 | return_value=httpx.Response( 154 | 200, 155 | json={"transaction": transaction.model_dump(mode="json")}, 156 | ) 157 | ) 158 | 159 | transaction_response = transactions_resource.annotate(transaction.id, metadata) 160 | 161 | assert isinstance(transaction_response, MonzoTransaction) 162 | assert transaction_response == transaction 163 | assert mocked_route.called 164 | 165 | def test_list_respx( 166 | self, 167 | mocker: MockerFixture, 168 | respx_mock: respx.MockRouter, 169 | transactions_resource: TransactionsResource, 170 | ) -> None: 171 | """Correct API response is sent, API response is parsed into expected schema.""" 172 | transaction = MonzoTransactionFactory.build(merchant="TEST_MERCHANT") 173 | transaction2 = MonzoTransactionFactory.build(merchant="TEST_MERCHANT") 174 | 175 | account = MonzoAccountFactory.build() 176 | mocked_get_default_account = mocker.patch.object( 177 | transactions_resource.client.accounts, 178 | "get_default_account", 179 | ) 180 | mocked_get_default_account.return_value = account 181 | 182 | mocked_route = respx_mock.get( 183 | "/transactions", params={"account_id": account.id} 184 | ).mock( 185 | return_value=httpx.Response( 186 | 200, 187 | json={ 188 | "transactions": [ 189 | transaction.model_dump(mode="json"), 190 | transaction2.model_dump(mode="json"), 191 | ] 192 | }, 193 | ) 194 | ) 195 | 196 | transactions_list_response = transactions_resource.list() 197 | 198 | mocked_get_default_account.assert_called_once_with() 199 | mocked_get_default_account.reset_mock() 200 | 201 | assert isinstance(transactions_list_response, list) 202 | for item in transactions_list_response: 203 | assert isinstance(item, MonzoTransaction) 204 | assert transactions_list_response == [transaction, transaction2] 205 | assert mocked_route.called 206 | 207 | # Explicitly passed account ID and params 208 | account_id = "TEST_ACCOUNT_ID" 209 | since = datetime(2022, 1, 14) 210 | before = datetime(2022, 1, 14) 211 | limit = 42 212 | 213 | mocked_route = respx_mock.get( 214 | "/transactions", 215 | params={ 216 | "account_id": account_id, 217 | "since": since.strftime("%Y-%m-%dT%H:%M:%SZ"), 218 | "before": before.strftime("%Y-%m-%dT%H:%M:%SZ"), 219 | "limit": "42", 220 | }, 221 | ).mock( 222 | return_value=httpx.Response( 223 | 200, 224 | json={"transactions": [transaction.model_dump(mode="json")]}, 225 | ) 226 | ) 227 | 228 | transactions_list_response = transactions_resource.list( 229 | account_id=account_id, 230 | since=since, 231 | before=before, 232 | limit=limit, 233 | ) 234 | 235 | mocked_get_default_account.assert_not_called() 236 | mocked_get_default_account.reset_mock() 237 | 238 | assert isinstance(transactions_list_response, list) 239 | for item in transactions_list_response: 240 | assert isinstance(item, MonzoTransaction) 241 | assert transactions_list_response == [transaction] 242 | assert mocked_route.called 243 | -------------------------------------------------------------------------------- /tests/pymonzo/test_utils.py: -------------------------------------------------------------------------------- 1 | """Test `pymonzo.utils` module.""" 2 | 3 | from datetime import datetime 4 | from typing import Any 5 | 6 | import pytest 7 | from freezegun import freeze_time 8 | 9 | from pymonzo.utils import empty_dict_to_none, empty_str_to_none, n_days_ago 10 | 11 | 12 | @pytest.mark.parametrize( 13 | ("today", "n", "output"), 14 | [ 15 | (datetime(2024, 1, 14), 5, datetime(2024, 1, 9)), 16 | (datetime(2024, 1, 14), 10, datetime(2024, 1, 4)), 17 | (datetime(2024, 1, 14), 15, datetime(2023, 12, 30)), 18 | ], 19 | ) 20 | def test_n_days_ago(today: datetime, n: int, output: datetime) -> None: 21 | """Should subtract passed number of days from today's date.""" 22 | with freeze_time(today): 23 | assert n_days_ago(n) == output 24 | 25 | 26 | @pytest.mark.parametrize( 27 | ("value", "output"), 28 | [ 29 | ("", None), 30 | ("Lorem ipsum", "Lorem ipsum"), 31 | ("TEST", "TEST"), 32 | ({"foo": 1, "bar": True}, {"foo": 1, "bar": True}), 33 | (1, 1), 34 | ], 35 | ) 36 | def test_empty_str_to_none(value: Any, output: Any) -> None: 37 | """Should return `None` if value is an empty string, do nothing otherwise.""" 38 | assert empty_str_to_none(value) == output 39 | 40 | 41 | @pytest.mark.parametrize( 42 | ("value", "output"), 43 | [ 44 | ({}, None), 45 | ({"foo": 1, "bar": True}, {"foo": 1, "bar": True}), 46 | ("", ""), 47 | ("Lorem ipsum", "Lorem ipsum"), 48 | (1, 1), 49 | ], 50 | ) 51 | def test_empty_dict_to_none(value: Any, output: Any) -> None: 52 | """Should return `None` if value is an empty dict, do nothing otherwise.""" 53 | assert empty_dict_to_none(value) == output 54 | -------------------------------------------------------------------------------- /tests/pymonzo/test_webhooks.py: -------------------------------------------------------------------------------- 1 | """Test `pymonzo.webhooks` module.""" 2 | 3 | import httpx 4 | import pytest 5 | import respx 6 | from polyfactory.factories.pydantic_factory import ModelFactory 7 | from pytest_mock import MockerFixture 8 | 9 | from pymonzo import MonzoAPI 10 | from pymonzo.webhooks import MonzoWebhook, WebhooksResource 11 | 12 | from .test_accounts import MonzoAccountFactory 13 | 14 | 15 | class MonzoWebhookFactory(ModelFactory[MonzoWebhook]): 16 | """Factory for `MonzoWebhook` schema.""" 17 | 18 | 19 | @pytest.fixture(scope="module") 20 | def webhooks_resource(monzo_api: MonzoAPI) -> WebhooksResource: 21 | """Initialize `WebhooksResource` resource with `monzo_api` fixture.""" 22 | return WebhooksResource(client=monzo_api) 23 | 24 | 25 | class TestWebhooksResource: 26 | """Test `WebhooksResource` class.""" 27 | 28 | @pytest.mark.respx(base_url=MonzoAPI.api_url) 29 | def test_list_respx( 30 | self, 31 | mocker: MockerFixture, 32 | respx_mock: respx.MockRouter, 33 | webhooks_resource: WebhooksResource, 34 | ) -> None: 35 | """Correct API response is sent, API response is parsed into expected schema.""" 36 | webhook = MonzoWebhookFactory.build() 37 | webhook2 = MonzoWebhookFactory.build() 38 | 39 | account = MonzoAccountFactory.build() 40 | mocked_get_default_account = mocker.patch.object( 41 | webhooks_resource.client.accounts, 42 | "get_default_account", 43 | ) 44 | mocked_get_default_account.return_value = account 45 | 46 | mocked_route = respx_mock.get( 47 | "/webhooks", 48 | params={"account_id": account.id}, 49 | ).mock( 50 | return_value=httpx.Response( 51 | 200, 52 | json={ 53 | "webhooks": [ 54 | webhook.model_dump(mode="json"), 55 | webhook2.model_dump(mode="json"), 56 | ] 57 | }, 58 | ) 59 | ) 60 | 61 | webhooks_list_response = webhooks_resource.list() 62 | 63 | mocked_get_default_account.assert_called_once_with() 64 | 65 | assert isinstance(webhooks_list_response, list) 66 | for item in webhooks_list_response: 67 | assert isinstance(item, MonzoWebhook) 68 | 69 | assert webhooks_list_response == [webhook, webhook2] 70 | assert mocked_route.called 71 | 72 | # Explicitly passed account ID 73 | account_id = "TEST_ACCOUNT_ID" 74 | 75 | mocked_route = respx_mock.get( 76 | "/webhooks", 77 | params={"account_id": account_id}, 78 | ).mock( 79 | return_value=httpx.Response( 80 | 200, 81 | json={"webhooks": [webhook.model_dump(mode="json")]}, 82 | ) 83 | ) 84 | 85 | webhooks_list_response = webhooks_resource.list(account_id=account_id) 86 | 87 | assert isinstance(webhooks_list_response, list) 88 | for item in webhooks_list_response: 89 | assert isinstance(item, MonzoWebhook) 90 | 91 | assert webhooks_list_response == [webhook] 92 | assert mocked_route.called 93 | 94 | @pytest.mark.respx(base_url=MonzoAPI.api_url) 95 | def test_register_respx( 96 | self, 97 | mocker: MockerFixture, 98 | respx_mock: respx.MockRouter, 99 | webhooks_resource: WebhooksResource, 100 | ) -> None: 101 | """Correct API response is sent, API response is parsed into expected schema.""" 102 | webhook = MonzoWebhookFactory.build() 103 | url = "TEST_URL" 104 | 105 | account = MonzoAccountFactory.build() 106 | mocked_get_default_account = mocker.patch.object( 107 | webhooks_resource.client.accounts, 108 | "get_default_account", 109 | ) 110 | mocked_get_default_account.return_value = account 111 | 112 | data = { 113 | "account_id": account.id, 114 | "url": url, 115 | } 116 | 117 | mocked_route = respx_mock.post("/webhooks", data=data).mock( 118 | return_value=httpx.Response( 119 | 200, 120 | json={"webhook": webhook.model_dump(mode="json")}, 121 | ) 122 | ) 123 | 124 | webhooks_register_response = webhooks_resource.register(url=url) 125 | 126 | mocked_get_default_account.assert_called_once_with() 127 | mocked_get_default_account.reset_mock() 128 | 129 | assert webhooks_register_response == webhook 130 | assert mocked_route.called 131 | 132 | # Explicitly passed account ID 133 | account_id = "TEST_ACCOUNT_ID" 134 | data = { 135 | "account_id": account_id, 136 | "url": url, 137 | } 138 | 139 | mocked_route = respx_mock.post("/webhooks", data=data).mock( 140 | return_value=httpx.Response( 141 | 200, 142 | json={"webhook": webhook.model_dump(mode="json")}, 143 | ) 144 | ) 145 | 146 | webhooks_register_response = webhooks_resource.register( 147 | url=url, 148 | account_id=account_id, 149 | ) 150 | 151 | mocked_get_default_account.assert_not_called() 152 | mocked_get_default_account.reset_mock() 153 | 154 | assert webhooks_register_response == webhook 155 | assert mocked_route.called 156 | 157 | @pytest.mark.respx(base_url=MonzoAPI.api_url) 158 | def test_delete_respx( 159 | self, 160 | respx_mock: respx.MockRouter, 161 | webhooks_resource: WebhooksResource, 162 | ) -> None: 163 | """Correct API response is sent, API response is parsed into expected schema.""" 164 | webhook_id = "TEST_WEBHOOK_ID" 165 | 166 | mocked_route = respx_mock.delete(f"/webhooks/{webhook_id}").mock( 167 | return_value=httpx.Response(200, json={}) 168 | ) 169 | 170 | webhooks_delete_response = webhooks_resource.delete(webhook_id) 171 | 172 | assert webhooks_delete_response == {} 173 | assert mocked_route.called 174 | -------------------------------------------------------------------------------- /tests/pymonzo/test_whoami.py: -------------------------------------------------------------------------------- 1 | """Test `pymonzo.whoami` module.""" 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | from pymonzo import MonzoAPI 8 | from pymonzo.whoami import MonzoWhoAmI, WhoAmIResource 9 | 10 | 11 | @pytest.fixture(scope="module") 12 | def whoami_resource(monzo_api: MonzoAPI) -> WhoAmIResource: 13 | """Initialize `WhoAmIResource` resource with `monzo_api` fixture.""" 14 | return WhoAmIResource(client=monzo_api) 15 | 16 | 17 | class TestWhoAmIResource: 18 | """Test `WhoAmIResource` class.""" 19 | 20 | @pytest.mark.vcr() 21 | @pytest.mark.skipif( 22 | not bool(os.getenv("VCRPY_ENCRYPTION_KEY")), 23 | reason="`VCRPY_ENCRYPTION_KEY` is not available on GitHub PRs.", 24 | ) 25 | def test_whoami_vcr(self, whoami_resource: WhoAmIResource) -> None: 26 | """API response is parsed into expected schema.""" 27 | whoami = whoami_resource.whoami() 28 | 29 | assert isinstance(whoami, MonzoWhoAmI) 30 | --------------------------------------------------------------------------------