diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index 7d2459b..0000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,20 +0,0 @@ -[bumpversion] -current_version = 0.1.0 -commit = True -tag = True - -[bumpversion:file:setup.py] -search = version='{current_version}' -replace = version='{new_version}' - -[bumpversion:file:README.rst] -search = v{current_version}. -replace = v{new_version}. - -[bumpversion:file:docs/conf.py] -search = version = release = '{current_version}' -replace = version = release = '{new_version}' - -[bumpversion:file:src/momoapi_python/__init__.py] -search = __version__ = '{current_version}' -replace = __version__ = '{new_version}' diff --git a/.cookiecutterrc b/.cookiecutterrc deleted file mode 100644 index b04ae28..0000000 --- a/.cookiecutterrc +++ /dev/null @@ -1,54 +0,0 @@ -# This file exists so you can easily regenerate your project. -# -# `cookiepatcher` is a convenient shim around `cookiecutter` -# for regenerating projects (it will generate a .cookiecutterrc -# automatically for any template). To use it: -# -# pip install cookiepatcher -# cookiepatcher gh:ionelmc/cookiecutter-pylibrary project-path -# -# See: -# https://pypi.python.org/pypi/cookiepatcher -# -# Alternatively, you can run: -# -# cookiecutter --overwrite-if-exists --config-file=project-path/.cookiecutterrc gh:ionelmc/cookiecutter-pylibrary - -default_context: - - _template: 'gh:ionelmc/cookiecutter-pylibrary' - appveyor: 'no' - c_extension_function: 'longest' - c_extension_module: '_momoapi_python' - c_extension_optional: 'yes' - c_extension_support: 'no' - codacy: 'no' - codeclimate: 'no' - codecov: 'no' - command_line_interface: 'click' - command_line_interface_bin_name: 'momoapi' - coveralls: 'yes' - distribution_name: 'momoapi-python' - email: 'mugisha@sparkpl.ug' - full_name: 'Moses Mugisha' - github_username: 'mossplix' - landscape: 'no' - license: 'MIT license' - linter: 'flake8' - package_name: 'momoapi_python' - project_name: 'momoapi-python' - project_short_description: 'An example package. Generated with cookiecutter-pylibrary.' - release_date: 'today' - repo_name: 'python-momoapi' - requiresio: 'no' - scrutinizer: 'no' - sphinx_docs: 'yes' - sphinx_doctest: 'no' - sphinx_theme: 'sphinx-py3doc-enhanced-theme' - test_matrix_configurator: 'yes' - test_matrix_separate_coverage: 'no' - test_runner: 'pytest' - travis: 'no' - version: '0.1.0' - website: 'http://sparkpl.ug' - year: 'now' diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/pull_request_template.md b/.github/ISSUE_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000..ee2c216 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/pull_request_template.md @@ -0,0 +1,25 @@ +**IMPORTANT: Please do not create a Pull Request without creating an issue first.** + +*Any change needs to be discussed before proceeding. Failure to do so may result in the rejection of the pull request.* + +Please provide enough information so that others can review your pull request: + + + +Explain the **details** for making this change. What existing problem does the pull request solve? + + + +**Test plan (required)** + +Demonstrate the code is solid. Example: The exact commands you ran and their output, screenshots / videos if the pull request changes UI. + + + +**Code formatting** + + + +**Closing issues** + +Put `closes #XXXX` in your comment to auto-close the issue that your PR fixes (if such). diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..6c076d3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,49 @@ +language: python +cache: + directories: + - "$HOME/.cache/pip" + - "$HOME/.pyenv" +matrix: + include: + - os: linux + dist: trusty + sudo: false + env: TOXENV=flake8,coveralls + python: '3.6' + - os: linux + dist: trusty + sudo: false + env: TOXENV=py27-cover + python: '2.7' + - os: linux + dist: trusty + sudo: false + python: '3.5' + env: TOXENV=py35-cover + - os: linux + dist: trusty + sudo: false + python: '3.6' + env: TOXENV=py36-cover + - os: linux + dist: xenial + sudo: required + python: '3.7' + env: TOXENV=py37-cover +script: +- pip install tox +- tox +install: +- python setup.py install +deploy: + provider: pypi + user: sparkplug + on: + tags: true + password: + secure: AABEEwfWWBSb91c20ceFOUnriS9q+f+j/p0gSVsI8MvRES5lPsov9MGaiA6bf9hxLt9xIThpbY4AtwW4Y3U46tosPLW9kyeTgMINRAXmb/uU7h7hH9sTqmsZsSDwzJo/IdkqUszJmTlH/Km/6Ly3EDown/Hdd0FjTj/wJ3Z3PfkIR6GItz/h5QnIw8lx37PJhjE91keQqPxh6dvf8ewNz+f7QjxrFXy+O53pFwF2fpHmGTFa0MPBuRvBhi3fdn8dxKVZ5FI4tj6FBp8fPWoY5vOz6xRSDJ6W75IZPk24r7p4qH8oLRtCOcR+8kntftyrthaUacmFuWJWqlUSCU5p65qgJs4x3M1zzMutfCGPWHLnY/jhqpuAlu2QefBRc0vmVzs+Z/ieZSFHbjVuDrem9S1rt4HgCj7+VozQvLuz1LTWlOa/jh0phCK4+2BOeN20NNegjxNrLruz6kW9kneACvra+ppum0E8BRwknFkTmUPrLtnKd53h+lKZ7My7FRK8mjzXkj6RoOTjPoBZsDbvccXBc7ta8DlB07D9HGmYXwy2jCAAV3xdJy5Dwkhv2B7ck+g2xAHo/FdqmyLkhyh8BIZI6ixFbCGbwqN5sLFQ85+NDnfWA+HTh7UC1N6FNt4KBcnvx8Ho7cE/5piUPvhxw89nAH2beicnDz0ooWqKcn4= +after_success: +- test $TRAVIS_BRANCH = "master" && .travis/semver.sh +env: + global: + secure: BfFLRnrlUwkU7FJui6XIrbN/nmxWkuDaGFrXLcqKSzWQmIwq8T/xIU2yje+wpjUx3mfErWXUgbf8EK/A83OwDgwDvZ4vlBojWA/aTKFUHtW7S7Ix1rTQbuxIzhEpBg2elIQzuKcH9hccGSzpcC6TqeuIWu/Soog7bofO6LT8SXqoRRPZnXgm07pKyJ/8wbUlJ34Ktoje5056zRsmEriFVf4/Qv6YciDzXtPbTRDCy+gXSAOFnr5y7d6Z8SQ3T5eki17X80Yn12nK0fymkTwbBi1F51dHkqSVYB/J6uBI5+K3c80gIw/sqhiHpk2WhfCB6ju/xAlHa9cSPKggJSPeX/v2Ujfk/7wDbH7mlBKsSisWKlQ/8oOd51aiXuHKP4yrj85URhxUNHoehYMg2JyQILTICFcSf4/AlbuEWFYtH34TBS+fF56kMxjHAdDCEX/i3VfAfAER3xsFTCTmQgvyZprPkekQOj5Cy1U+V5oPXRs94h0DqYbsMip1E1cbvevmjCzP3B2UZ/00mlp21a2OtNioxXaptgLsZjNS2kahc1yhDu6L3k2QZwxc58KdgL2vP+t7aQIcGO7gYimmn6GvXiDOjojXAVp0FPIHLnjNVHyqbQa6cma14sANKRZfyfuaGg3z763I7sdc5Su1iufZoD/+QRR4zrtyv3itfEvUBPo= diff --git a/.travis/semver.sh b/.travis/semver.sh new file mode 100755 index 0000000..8bdcdd8 --- /dev/null +++ b/.travis/semver.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +export SEMVER_LAST_TAG=$(git describe --abbrev=0 --tags 2>/dev/null) +export SEMVER_RELEASE_LEVEL=$(git log --oneline -1 --pretty=%B | cat | tr -d '\n' | cut -d "[" -f2 | cut -d "]" -f1) + +if [ -z $SEMVER_LAST_TAG ]; then + >&2 echo "No tags defined" + SEMVER_LAST_TAG="0.0.1" +fi + +if [ -n $SEMVER_RELEASE_LEVEL ]; then + git clone https://github.com/fsaintjacques/semver-tool /tmp/semver &> /dev/null + SEMVER_NEW_TAG=$(/tmp/semver/src/semver bump $SEMVER_RELEASE_LEVEL $SEMVER_LAST_TAG) + git tag $SEMVER_NEW_TAG &> /dev/null + git push https://${GH_TOKEN}@github.com/sparkplug/momoapi-python.git --tags &> /dev/null + echo $SEMVER_NEW_TAG +else + >&2 echo "No release level defined" +fi + +exit 0 \ No newline at end of file diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000..0343a4e --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,8 @@ + +Authors +======= + +* Moses Mugisha +* Ray Besiga + +For [Sparkplug](http://sparkpl.ug) \ No newline at end of file diff --git a/AUTHORS.rst b/AUTHORS.rst deleted file mode 100644 index 71ef76b..0000000 --- a/AUTHORS.rst +++ /dev/null @@ -1,5 +0,0 @@ - -Authors -======= - -* Moses Mugisha - http://sparkpl.ug diff --git a/CHANGELOG.rst b/CHANGELOG.md similarity index 100% rename from CHANGELOG.rst rename to CHANGELOG.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..be573c9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,200 @@ +# Contributing + +First off, thank you for considering contributing to this Python MTN MoMo Library. It's people like you that make it such a great tool. Contributions are welcome, and they are greatly appreciated! + +## Where do I go from here? + +If you've noticed a bug or have a question that doesn't belong on the +[Spectrum](https://spectrum.chat/momo-api-developers/) or [Stack Overflow](https://stackoverflow.com/), [search the issue tracker](https://github.com/sparkplug/momoapi-python/issues) to see if +someone else in the community has already created a ticket. If not, go ahead and +[make one](https://github.com/sparkplug/momoapi-python/issues/new/choose)! + + + +## Fork & create a branch + +If there is something you think you can fix, then fork the [repo](https://github.com/sparkplug/momoapi-python) and create a branch with a descriptive name. + +A good branch name would be (where issue #32 is the ticket you're working on): + +```sh +git checkout -b 32-add-swahili-translations +``` + +## Get the test suite running + +This library has a comprehensive test suite, which can be run using the `tox` command: + +To view all test environments + +```sh +$ tox -l +``` +To run the tests for Python 2.7 + +```sh +$ tox -e py27-cover +``` + +To run the tests for Python 3.4 + +```sh +$ tox -e py34-cover +``` + +To run a subset of tests:: + +```sh +tox -e envname -- pytest -k test_myfeature +``` + +To run all the test environments in *parallel*, you need to `pip install detox`: + +```sh +detox +``` + +## Bugs and Fixes + +### Did you find a bug? + +* **Ensure the bug was not already reported** by [searching all issues](https://github.com/sparkplug/momoapi-python/issues). + +* If you're unable to find an open issue addressing the problem, + [open a new one](https://github.com/sparkplug/momoapi-python/issues/new/choose). Be sure to include a **title and clear + description**, as much relevant information as possible, and a **code sample** + or an **executable test case** demonstrating the expected behavior that is not + occurring. + +* If possible, use the relevant bug report templates to create the issue. + Make the necessary changes to demonstrate the issue, and **paste the content into the + issue description** + +### Implement your fix or feature + +At this point, you're ready to make your changes! Feel free to ask for help; +everyone is a beginner at first :smile_cat: + +If you are proposing a feature: + +* Explain in detail how it would work. +* Keep the scope as narrow as possible, to make it easier to implement. +* Remember that this is a volunteer-driven project, and that code contributions are welcome :) + +If you would like to send us feedback, simply [file an issue](https://github.com/sparkplug/momoapi-python/issues/new/choose). + +## Local Development + +To set up `python-momoapi` for local development: + +1. Fork the repo. Look for the "Fork" button in the Github UI. +2. Clone your fork locally: + +```sh +git clone https://github.com/your_name_here/momoapi-python.git +``` + +3. Create a branch for local development: +```sh +git checkout -b name-of-your-bugfix-or-feature +``` + +Now you can make your changes locally. + +4. When you're done making changes, run all the checks, doc builder and spell checker with `tox`. +```sh +tox +``` +Make sure Tox is installed by following the instructions [here](http://tox.readthedocs.io/en/latest/install.html) + +5. Commit your changes and push your branch to GitHub:: + +```sh +git add . +git commit -m "Your detailed description of your changes." +git push origin name-of-your-bugfix-or-feature +``` + +6. Submit a pull request through the GitHub website. + +## Pull Request Guidelines + +### Make a Pull Request + +At this point, you should switch back to your master branch and make sure it's +up to date with `momoapi-python`'s master branch: + +```sh +git remote add upstream https://github.com/sparkplug/momoapi-python.git +git checkout master +git pull upstream master +``` + +Then update your feature branch from your local copy of master, and push it! + +```sh +git checkout 32-add-swahili-translations +git rebase master +git push --set-upstream origin 32-add-swahili-translations +``` + +Finally, go to GitHub and make a Pull Request :D + +TravisCI will run our test suite against all supported Python versions. We care +about quality, so your PR won't be merged until all tests pass. It's unlikely, +but it's possible that your changes pass tests in one Python version but fail in +another. In that case, you'll have to setup your development environment to use your Python version, and investigate what's going on! + +### Keeping your Pull Request updated + +If a maintainer asks you to "rebase" your PR, they're saying that a lot of code has changed, and that you need to update your branch so it's easier to merge. + +To learn more about rebasing in Git, there are a lot of [good](https://www.atlassian.com/git/tutorials/rewriting-history/git-rebase) [resources](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) but here's the suggested workflow: + +```sh +git checkout 32-add-swahili-translations +git pull --rebase upstream master +git push --force-with-lease 32-add-swahili-translations +``` + +### Merging a PR (maintainers only) + +A PR can only be merged into master by a maintainer if: + +* It is passing CI. +* It has been approved by at least one maintainers. If it was a maintainer who opened the PR, only one extra approval is needed. +* It has no requested changes. +* It is up to date with current master. + +Any maintainer is allowed to merge a PR if all of these conditions are met. + +### Shipping a release (maintainers only) + +Maintainers need to do the following to push out a release: + +* Make sure all pull requests are in and that changelog is current +* Update version and changelog with new version number using semver +* If it's not a patch level release, create a stable branch for that release, + otherwise switch to the stable branch corresponding to the patch release you + want to ship: + + ```sh + git checkout master + git fetch momoapi-python + git rebase momoapi-python/master + # If the release is 2.1.x then this should be: 2-1-stable + git checkout -b N-N-stable + git push momoapi-python N-N-stable:N-N-stable + ``` + +Before you make a Pull Request, make sure of the following: + +1. Make sure your tests pass. Run `tox` beforehand. +2. Update documentation where necessary. +3. Note changes to `CHANGELOG.md`. +4. Add yourself to `AUTHORS.md`. + +## Improvements + +This library could always use more documentation, whether as part of the official docs, in docstrings, or even in blog posts and articles. We look forward to add them to our RESOURCES file. + diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst deleted file mode 100644 index 28de1ce..0000000 --- a/CONTRIBUTING.rst +++ /dev/null @@ -1,90 +0,0 @@ -============ -Contributing -============ - -Contributions are welcome, and they are greatly appreciated! Every -little bit helps, and credit will always be given. - -Bug reports -=========== - -When `reporting a bug `_ please include: - - * Your operating system name and version. - * Any details about your local setup that might be helpful in troubleshooting. - * Detailed steps to reproduce the bug. - -Documentation improvements -========================== - -momoapi-python could always use more documentation, whether as part of the -official momoapi-python docs, in docstrings, or even on the web in blog posts, -articles, and such. - -Feature requests and feedback -============================= - -The best way to send feedback is to file an issue at https://github.com/mossplix/python-momoapi/issues. - -If you are proposing a feature: - -* Explain in detail how it would work. -* Keep the scope as narrow as possible, to make it easier to implement. -* Remember that this is a volunteer-driven project, and that code contributions are welcome :) - -Development -=========== - -To set up `python-momoapi` for local development: - -1. Fork `python-momoapi `_ - (look for the "Fork" button). -2. Clone your fork locally:: - - git clone git@github.com:your_name_here/python-momoapi.git - -3. Create a branch for local development:: - - git checkout -b name-of-your-bugfix-or-feature - - Now you can make your changes locally. - -4. When you're done making changes, run all the checks, doc builder and spell checker with `tox `_ one command:: - - tox - -5. Commit your changes and push your branch to GitHub:: - - git add . - git commit -m "Your detailed description of your changes." - git push origin name-of-your-bugfix-or-feature - -6. Submit a pull request through the GitHub website. - -Pull Request Guidelines ------------------------ - -If you need some code review or feedback while you're developing the code just make the pull request. - -For merging, you should: - -1. Include passing tests (run ``tox``) [1]_. -2. Update documentation when there's new API, functionality etc. -3. Add a note to ``CHANGELOG.rst`` about the changes. -4. Add yourself to ``AUTHORS.rst``. - -.. [1] If you don't have all the necessary python versions available locally you can rely on Travis - it will - `run the tests `_ for each change you add in the pull request. - - It will be slower though ... - -Tips ----- - -To run a subset of tests:: - - tox -e envname -- pytest -k test_myfeature - -To run all the test environments in *parallel* (you need to ``pip install detox``):: - - detox diff --git a/MANIFEST.in b/MANIFEST.in index 3ae9b54..f68def1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,18 +1,14 @@ -graft docs + graft src graft ci graft tests -include .bumpversion.cfg -include .coveragerc -include .cookiecutterrc -include .editorconfig -include AUTHORS.rst -include CHANGELOG.rst -include CONTRIBUTING.rst +include AUTHORS.md +include CHANGELOG.md +include CONTRIBUTING.md include LICENSE -include README.rst +include README.md include tox.ini .travis.yml appveyor.yml diff --git a/README.md b/README.md index 573bbd4..5e46eed 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,192 @@ -## Installation +# MTN MoMo API Python Client -First, ensure that the you have a virtual environment enabled before you proceed. You can create one and activate as follows: -Creation of virtualenv: -```bash -$ virtualenv -p python3 -``` +**🛑 This repository is no longer actively maintained.** -Activate the virtualenv: +As of July 14, 2025, this project is no longer under active development. This means: +* No new features will be added. +* Bugs will not be fixed. +* Pull requests will not be reviewed or merged. +* Issues will not be addressed. -```bash -$ source /bin/activate -``` +We appreciate your interest and contributions. +**Thank you.** -Next, we install from source: +
-```bash -$ python setup.py install -``` +Power your apps with our MTN MoMo API +
+ Join our active, engaged community:
+ Website + | + Spectrum +

+
-## Requirements -* Python 2.7+ or Python 3.4+ (PyPy supported) +[![Build Status](https://travis-ci.com/sparkplug/momoapi-python.svg?branch=master)](https://travis-ci.com/sparkplug/momoapi-node) +[![Latest Version](https://img.shields.io/pypi/v/tox-travis.svg)](https://badge.fury.io/js/mtn-momo) +[![Coverage Status](https://coveralls.io/repos/github/sparkplug/momoapi-python/badge.svg?branch=master)](https://coveralls.io/github/sparkplug/momoapi-python?branch=master) +[![Join the community on Spectrum](https://withspectrum.github.io/badge/badge.svg)](https://spectrum.chat/momo-api-developers/) # Usage -Time to use the library. The goal is to create a `User ID` and `API Secret`. To do this, the API key from your profile on the MTN MoMo dashboard is needed. The library has a commandline app that helps you do just that when you enter the details as prompted on the commandline. +## Installation + +Add the latest version of the library to your project using pip: ```bash -$ momoapi + $ pip install mtnmomo +``` + +This library supports Python 2.7+ or Python 3.4+ (PyPy supported) + +# Sandbox Environment + +## Creating a sandbox environment API user + +Next, we need to get the `User ID` and `User Secret` and to do this we shall need to use the Primary Key for the Product to which we are subscribed, as well as specify a host. The library ships with a commandline application that helps to create sandbox credentials. It assumes you have created an account on `https://momodeveloper.mtn.com` and have your `Ocp-Apim-Subscription-Key`. + +```bash +## within the project, on the command line. In this example, our domain is akabbo.ug +$ mtnmomo $ providerCallBackHost: https://akabbo.ug $ Ocp-Apim-Subscription-Key: f83xx8d8xx6749f19a26e2265aeadbcdeg ``` -where `providerCallBackHost` is your callback host and `Ocp-Apim-Subscription-Key` is your API key for the specific product to which you are subscribed. The `API Key` is unique to the product and you will need an `API Key` for each product you use. You should get the following response. +The `providerCallBackHost` is your callback host and `Ocp-Apim-Subscription-Key` is your API key for the specific product to which you are subscribed. The `API Key` is unique to the product and you will need an `API Key` for each product you use. You should get a response similar to the following: ```bash Here is your User Id and API secret : {'apiKey': 'b0431db58a9b41faa8f5860230xxxxxx', 'UserId': '053c6dea-dd68-xxxx-xxxx-c830dac9f401'} +``` + +These are the credentials we shall use for the sandbox environment. In production, these credentials are provided for you on the MTN OVA management dashboard after KYC requirements are met. + +## Configuration + +Before we can fully utilize the library, we need to specify global configurations. The global configuration must contain the following: + +* `BASE_URL`: An optional base url to the MTN Momo API. By default the staging base url will be used +* `ENVIRONMENT`: Optional environment, either "sandbox" or "production". Default is 'sandbox' +* `CALLBACK_HOST`: The domain where you webhooks urls are hosted. This is mandatory. + +Once you have specified the global variables, you can now provide the product-specific variables. Each MoMo API product requires its own authentication details i.e its own `Subscription Key`, `User ID` and `User Secret`, also sometimes refered to as the `API Secret`. As such, we have to configure subscription keys for each product you will be using. + +The full list of configuration options can be seen in the example below: + ```python + config = { + "ENVIRONMENT": os.environ.get("ENVIRONMENT"), + "BASE_URL": os.environ.get("BASE_URL"), + "CALLBACK_HOST": os.environ.get("CALLBACK_HOST"), # Mandatory. + "COLLECTION_PRIMARY_KEY": os.environ.get("COLLECTION_PRIMARY_KEY"), + "COLLECTION_USER_ID": os.environ.get("COLLECTION_USER_ID"), + "COLLECTION_API_SECRET": os.environ.get("COLLECTION_API_SECRET"), + "REMITTANCE_USER_ID": os.environ.get("REMITTANCE_USER_ID"), + "REMITTANCE_API_SECRET": os.environ.get("REMITTANCE_API_SECRET"), + "REMITTANCE_PRIMARY_KEY": os.envieon.get("REMITTANCE_PRIMARY_KEY"), + "DISBURSEMENT_USER_ID": os.environ.get("DISBURSEMENT_USER_ID"), + "DISBURSEMENT_API_SECRET": os.environ.get("DISBURSEMENTS_API_SECRET"), + "DISBURSEMENT_PRIMARY_KEY": os.environ.get("DISBURSEMENT_PRIMARY_KEY"), +} ``` -## Let's make calls. +You will only need to configure the variables for the product(s) you will be using. -We shall now import the library onto the commandline. Let us try to make a collection request. +## Collections + +The collections client can be created with the following paramaters. Note that the `COLLECTION_USER_ID` and `COLLECTION_API_SECRET` for production are provided on the MTN OVA dashboard; + +* `COLLECTION_PRIMARY_KEY`: Primary Key for the `Collection` product on the developer portal. +* `COLLECTION_USER_ID`: For sandbox, use the one generated with the `mtnmomo` command. +* `COLLECTION_API_SECRET`: For sandbox, use the one generated with the `mtnmomo` command. + +You can create a collection client with the following: ```python -from momoapi.client import MomoApi -client = MomoApi(APIKEY,USERID,APISECRET) -ref=client.requestToPay("256772123456", "600", "123456789", note="dd", message="dd", currency="EUR", environment="sandbox") +import os +from mtnmomo.collection import Collection + +client = Collection({ + "COLLECTION_USER_ID": os.environ.get("COLLECTION_USER_ID"), + "COLLECTION_API_SECRET": os.environ.get("COLLECTION_API_SECRET"), + "COLLECTION_PRIMARY_KEY": os.environ.get("COLLECTION_PRIMARY_KEY"), + }) ``` -So, what just happened? We create a client on the commandline, and made a `requestToPay` transaction. How do we know this happened? Still on the commandline, input `ref` +### Methods + +1. `requestToPay`: This operation is used to request a payment from a consumer (Payer). The payer will be asked to authorize the payment. The transaction is executed once the payer has authorized the payment. The transaction will be in status PENDING until it is authorized or declined by the payer or it is timed out by the system. Status of the transaction can be validated by using `getTransactionStatus`. + +2. `getTransactionStatus`: Retrieve transaction information using the `transactionId` returned by `requestToPay`. You can invoke it at intervals until the transaction fails or succeeds. If the transaction has failed, it will throw an appropriate error. + +3. `getBalance`: Get the balance of the account. + +4. `isPayerActive`: check if an account holder is registered and active in the system. + +### Sample Code ```python ->>> ref +import os +from mtnmomo.collection import Collection + +client = Collection({ + "COLLECTION_USER_ID": os.environ.get("COLLECTION_USER_ID"), + "COLLECTION_API_SECRET": os.environ.get("COLLECTION_API_SECRET"), + "COLLECTION_PRIMARY_KEY": os.environ.get("COLLECTION_PRIMARY_KEY"), +}) + +client.requestToPay( + mobile="256772123456", amount="600", external_id="123456789", payee_note="dd", payer_message="dd", currency="EUR") ``` -You should see a response similar to this: +## Disbursement + +The Disbursements client can be created with the following paramaters. Note that the `DISBURSEMENT_USER_ID` and `DISBURSEMENT_API_SECRET` for production are provided on the MTN OVA dashboard; + +* `DISBURSEMENT_PRIMARY_KEY`: Primary Key for the `Disbursement` product on the developer portal. +* `DISBURSEMENT_USER_ID`: For sandbox, use the one generated with the `mtnmomo` command. +* `DISBURSEMENT_API_SECRET`: For sandbox, use the one generated with the `mtnmomo` command. + +You can create a disbursements client with the following ```python ->>> ref -{'transaction_ref': '33a9d94b-6828-4879-xxxx-e0ecb946d465'} +import os +from mtnmomo.disbursement import Disbursement + +client = Disbursement({ + "DISBURSEMENT_USER_ID": os.environ.get("DISBURSEMENT_USER_ID"), + "DISBURSEMENT_API_SECRET": os.environ.get("DISBURSEMENT_API_SECRET"), + "DISBURSEMENT_PRIMARY_KEY": os.environ.get("DISBURSEMENT_PRIMARY_KEY"), +}) ``` -We can then use this `Transaction Reference` to get the status of the `Transaction` + +### Methods + +1. `transfer`: Used to transfer an amount from the owner’s account to a payee account. Status of the transaction can be validated by using the `getTransactionStatus` method. + +2. `getTransactionStatus`: Retrieve transaction information using the `transactionId` returned by `transfer`. You can invoke it at intervals until the transaction fails or succeeds. + +2. `getBalance`: Get your account balance. + +3. `isPayerActive`: This method is used to check if an account holder is registered and active in the system. + +#### Sample Code ```python ->>> client.getTransactionStatus('33a9d94b-6828-4879-xxxx-e0ecb946d465') -{'financialTransactionId': '1854386795', 'externalId': '123456789', 'amount': '600', 'currency': 'EUR', 'payer': {'partyIdType': 'MSISDN', 'partyId': '256794631873'}, 'payerMessage': 'dd', 'payeeNote': 'dd', 'status': 'SUCCESSFUL'} +import os +from mtnmomo.disbursement import Disbursement + +client = Disbursement({ + "DISBURSEMENT_USER_ID": os.environ.get("DISBURSEMENT_USER_ID"), + "DISBURSEMENT_API_SECRET": os.environ.get("DISBURSEMENT_API_SECRET"), + "DISBURSEMENT_PRIMARY_KEY": os.environ.get("DISBURSEMENT_PRIMARY_KEY"), +}) + +client.transfer(amount="600", mobile="256772123456", external_id="123456789", payee_note="dd", payer_message="dd", currency="EUR") + ``` -Voila! +Thank you. diff --git a/RESOURCES.md b/RESOURCES.md new file mode 100644 index 0000000..e2899d7 --- /dev/null +++ b/RESOURCES.md @@ -0,0 +1,11 @@ +# Resources + +Here, we keep a list of resources for use of the library + +## Official Docs + +* [Developer Portal](https://momodeveloper.mtn.com/api-documentation) + +## Articles + + diff --git a/ci/templates/tox.ini b/ci/templates/tox.ini deleted file mode 100644 index ef65edb..0000000 --- a/ci/templates/tox.ini +++ /dev/null @@ -1,119 +0,0 @@ -[tox] -envlist = - clean, - check, - docs, -{% for env in tox_environments|sort %} - {{ env }}, -{% endfor %} - report - -[testenv] -basepython = - {docs,spell}: {env:TOXPYTHON:python2.7} - {bootstrap,clean,check,report,coveralls}: {env:TOXPYTHON:python3} -setenv = - PYTHONPATH={toxinidir}/tests - PYTHONUNBUFFERED=yes -passenv = - * -deps = - pytest - pytest-travis-fold -commands = - {posargs:pytest -vv --ignore=src} - -[testenv:bootstrap] -deps = - jinja2 - matrix -skip_install = true -commands = - python ci/bootstrap.py - -[testenv:check] -deps = - docutils - check-manifest - flake8 - readme-renderer - pygments - isort -skip_install = true -commands = - python setup.py check --strict --metadata --restructuredtext - check-manifest {toxinidir} - flake8 src tests setup.py - isort --verbose --check-only --diff --recursive src tests setup.py - - -[testenv:spell] -setenv = - SPELLCHECK=1 -commands = - sphinx-build -b spelling docs dist/docs -skip_install = true -deps = - -r{toxinidir}/docs/requirements.txt - sphinxcontrib-spelling - pyenchant - -[testenv:docs] -deps = - -r{toxinidir}/docs/requirements.txt -commands = - sphinx-build {posargs:-E} -b html docs dist/docs - sphinx-build -b linkcheck docs dist/docs - -[testenv:coveralls] -deps = - coveralls -skip_install = true -commands = - coveralls [] - - - -[testenv:report] -deps = coverage -skip_install = true -usedevelop = false -commands = - coverage report - coverage html - -[testenv:clean] -commands = coverage erase -skip_install = true -usedevelop = false -deps = coverage - -{% for env, config in tox_environments|dictsort %} -[testenv:{{ env }}] -basepython = {env:TOXPYTHON:{{ env.split("-")[0] if env.startswith("pypy") else "python{0[2]}.{0[3]}".format(env) }}} -{% if config.cover or config.env_vars %} -setenv = - {[testenv]setenv} -{% endif %} -{% for var in config.env_vars %} - {{ var }} -{% endfor %} -{% if config.cover %} -usedevelop = true -commands = - {posargs:pytest --cov --cov-report=term-missing -vv} -{% endif %} -{% if config.cover or config.deps %} -deps = - {[testenv]deps} -{% endif %} -{% if config.cover %} - pytest-cov -{% endif %} -{% for dep in config.deps %} - {{ dep }} -{% endfor %} - -{% endfor %} - - diff --git a/docs/authors.rst b/docs/authors.rst deleted file mode 100644 index e122f91..0000000 --- a/docs/authors.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../AUTHORS.rst diff --git a/docs/changelog.rst b/docs/changelog.rst deleted file mode 100644 index 565b052..0000000 --- a/docs/changelog.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../CHANGELOG.rst diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index c138046..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -import os - - -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.autosummary', - 'sphinx.ext.coverage', - 'sphinx.ext.doctest', - 'sphinx.ext.extlinks', - 'sphinx.ext.ifconfig', - 'sphinx.ext.napoleon', - 'sphinx.ext.todo', - 'sphinx.ext.viewcode', -] -if os.getenv('SPELLCHECK'): - extensions += 'sphinxcontrib.spelling', - spelling_show_suggestions = True - spelling_lang = 'en_US' - -source_suffix = '.rst' -master_doc = 'index' -project = 'momoapi-python' -year = '2018' -author = 'Moses Mugisha' -copyright = '{0}, {1}'.format(year, author) -version = release = '0.1.0' - -pygments_style = 'trac' -templates_path = ['.'] -extlinks = { - 'issue': ('https://github.com/mossplix/python-momoapi/issues/%s', '#'), - 'pr': ('https://github.com/mossplix/python-momoapi/pull/%s', 'PR #'), -} -import sphinx_py3doc_enhanced_theme -html_theme = "sphinx_py3doc_enhanced_theme" -html_theme_path = [sphinx_py3doc_enhanced_theme.get_html_theme_path()] -html_theme_options = { - 'githuburl': 'https://github.com/mossplix/python-momoapi/' -} - -html_use_smartypants = True -html_last_updated_fmt = '%b %d, %Y' -html_split_index = False -html_sidebars = { - '**': ['searchbox.html', 'globaltoc.html', 'sourcelink.html'], -} -html_short_title = '%s-%s' % (project, version) - -napoleon_use_ivar = True -napoleon_use_rtype = False -napoleon_use_param = False diff --git a/docs/contributing.rst b/docs/contributing.rst deleted file mode 100644 index e582053..0000000 --- a/docs/contributing.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../CONTRIBUTING.rst diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 40f35b5..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,22 +0,0 @@ -======== -Contents -======== - -.. toctree:: - :maxdepth: 2 - - readme - installation - usage - reference/index - contributing - authors - changelog - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - diff --git a/docs/installation.rst b/docs/installation.rst deleted file mode 100644 index 40917b1..0000000 --- a/docs/installation.rst +++ /dev/null @@ -1,7 +0,0 @@ -============ -Installation -============ - -At the command line:: - - pip install momoapi-python diff --git a/docs/readme.rst b/docs/readme.rst deleted file mode 100644 index 72a3355..0000000 --- a/docs/readme.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../README.rst diff --git a/docs/reference/index.rst b/docs/reference/index.rst deleted file mode 100644 index 209ab35..0000000 --- a/docs/reference/index.rst +++ /dev/null @@ -1,7 +0,0 @@ -Reference -========= - -.. toctree:: - :glob: - - momoapi_python* diff --git a/docs/reference/momoapi_python.rst b/docs/reference/momoapi_python.rst deleted file mode 100644 index aad0d9e..0000000 --- a/docs/reference/momoapi_python.rst +++ /dev/null @@ -1,9 +0,0 @@ -momoapi_python -============== - -.. testsetup:: - - from momoapi_python import * - -.. automodule:: momoapi_python - :members: diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index ef4a013..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -sphinx>=1.3 -sphinx-py3doc-enhanced-theme --e . diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt deleted file mode 100644 index f95eb78..0000000 --- a/docs/spelling_wordlist.txt +++ /dev/null @@ -1,11 +0,0 @@ -builtin -builtins -classmethod -staticmethod -classmethods -staticmethods -args -kwargs -callstack -Changelog -Indices diff --git a/docs/usage.rst b/docs/usage.rst deleted file mode 100644 index 85f3876..0000000 --- a/docs/usage.rst +++ /dev/null @@ -1,7 +0,0 @@ -===== -Usage -===== - -To use momoapi-python in a project:: - - import momoapi_python diff --git a/setup.cfg b/setup.cfg index ae9171f..4395759 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,6 +7,7 @@ max-line-length = 140 exclude = */migrations/* [tool:pytest] +pep8maxlinelength = 80 norecursedirs = .git .tox @@ -20,15 +21,15 @@ python_files = *_test.py tests.py addopts = - -ra + -ra --strict - --ignore=docs/conf.py --ignore=setup.py --ignore=ci --ignore=.eggs --doctest-modules --doctest-glob=\*.rst --tb=short + [isort] force_single_line = True @@ -80,6 +81,9 @@ coverage_flags = cover: true nocov: false +[aliases] +test=pytest + environment_variables = - diff --git a/setup.py b/setup.py index e584712..812252c 100644 --- a/setup.py +++ b/setup.py @@ -24,14 +24,15 @@ def read(*names, **kwargs): setup( - name='momoapi', - version='0.1.0', + name='mtnmomo', + version='3.0.1', license='MIT license', description='Python wrapper for the MTN MoMo API.', long_description='%s\n%s' % ( re.compile('^.. start-badges.*^.. end-badges', re.M | re.S).sub('', read('README.md')), - re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst')) + re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.md')) ), + long_description_content_type='text/markdown', author='Sparkplug', author_email='mugisha@sparkpl.ug', url='https://github.com/sparkplug/momoapi-python', @@ -65,20 +66,32 @@ def read(*names, **kwargs): ], keywords=[ 'MoMo API', 'MoMo API Python Wrapper', 'MoMo API Python', + + + ], install_requires=[ - 'click', - 'requests' + 'requests == 2.21.0', + 'Click==7.0', + 'phonenumbers' + # eg: 'aspectlib==1.1.1', 'six>=1.7', ], - extras_require={ - # eg: - # 'rst': ['docutils>=0.11'], - # ':python_version=="2.6"': ['argparse'], - }, + + setup_requires=["pytest-runner", "pytest-cov"], + + + extras_require={'test': ['pytest', 'pytest-watch', 'tox', + 'pytest-cov', + 'pytest-pep8', + 'pytest-cov', + 'pytest-sugar', + 'mock', + 'pytest-instafail', + 'pytest-bdd'], "dev": ["semver"]}, entry_points={ 'console_scripts': [ - 'momoapi = momoapi.cli:main', + 'mtnmomo = mtnmomo.cli:main', ] }, ) diff --git a/src/momoapi/client.py b/src/momoapi/client.py deleted file mode 100644 index 81896d0..0000000 --- a/src/momoapi/client.py +++ /dev/null @@ -1,180 +0,0 @@ -import requests -import textwrap -import json -import uuid -from .errors import APIError -from requests import Request, Session -from requests.auth import AuthBase -import base64 -from requests.auth import HTTPBasicAuth -from requests._internal_utils import to_native_string - - -class Response: - - def __init__(self, body, code, headers): - self.body = body - self.code = code - self.headers = headers - self.data = body - - -class MoMoAuth(AuthBase): - """Attaches Authentication to the given Request object.""" - - def __init__(self, token): - - self.token = token - - def __call__(self, r): - # modify and return the request - - r.headers['Authorization'] = "Bearer "+to_native_string(self.token) - return r - - -class MomoApi(object): - - def __init__(self, auth_key, user_id, api_secret, base_url="https://ericssonbasicapi2.azure-api.net", ** kwargs): - super(MomoApi, self).__init__(**kwargs) - self._session = Session() - self.api_secret = api_secret - self.user_id = user_id - self.auth_key = auth_key - self.base_url = base_url - - def request(self, method, url, headers, post_data=None): - self.authToken = self.getAuthToken().json()["access_token"] - request = Request(method, url, data=json.dumps(post_data), headers=headers, auth=MoMoAuth("%s" % self.authToken)) - - prepped = self._session.prepare_request(request) - - resp = self._session.send(prepped, - verify=False - ) - return self.interpret_response(resp) - - def interpret_response(self, resp): - rcode = resp.status_code - rheaders = resp.headers - - try: - rbody = resp.json() - except json.decoder.JSONDecodeError: - rbody = resp.text - resp = Response(rbody, rcode, rheaders) - - if not (200 <= rcode < 300): - self.handle_error_response(rbody, rcode, resp.text, rheaders) - - return resp - - def handle_error_response(self, rbody, rcode, resp, rheaders): - try: - error_data = resp['error'] - except (KeyError, TypeError): - raise APIError( - "Invalid response object from API: %r (HTTP response code " - "was %d)" % (rbody, rcode), - rbody, rcode, resp) - - raise "" - - def request_headers(self, api_key, method): - headers = {} - - return headers - - def getAuthToken(self): - data = json.dumps({}) - auth = "%s:%s" % (self.user_id, self.api_secret) - bs64 = base64.b64encode(auth.encode()) - headers = { - "Content-Type": "application/json", - - "Ocp-Apim-Subscription-Key": "%s" % self.auth_key - } - r = requests.post(self.base_url+"/colection/token/", - auth=HTTPBasicAuth(self.user_id, self.api_secret), data=data, headers=headers) - return r - - def requestToPay(self, mobile, amount, product_id, note="", message="", currency="EUR", environment="sandbox"): - ref = str(uuid.uuid4()) - data = {"payer": {"partyIdType": "MSISDN", "partyId": mobile}, "payeeNote": note, - "payerMessage": message, "externalId": product_id, "currency": currency, "amount": amount} - headers = { - "X-Target-Environment": environment, - "Content-Type": "application/json", - "X-Reference-Id": ref, - "Ocp-Apim-Subscription-Key": self.auth_key - - - } - url = self.base_url+"/colection/v1_0/requesttopay" - res = self.request("POST", url, headers, data) - return {"transaction_ref": ref} - - def getBalance(self, environment="sandbox"): - headers = { - "X-Target-Environment": environment, - "Content-Type": "application/json", - "Ocp-Apim-Subscription-Key": self.auth_key - } - url = self.base_url+"/colection/v1_0/account/balance" - res = self.request("GET", url, headers) - return res.json() - - def getTransactionStatus(self, transaction_id, environment="sandbox"): - - headers = { - "X-Target-Environment": environment, - "Content-Type": "application/json", - "Ocp-Apim-Subscription-Key": self.auth_key - } - url = self.base_url+"/colection/v1_0/requesttopay/"+transaction_id - res = self.request("GET", url, headers) - return res.json() - - def transfer(self, amount, mobile, note="", message="", currency="EUR", environment="sandbox"): - external_ref = str(uuid.uuid4()) - data = { - "amount": amount, - "currency": currency, - "externalId": external_ref, - "payee": { - "partyIdType": "MSISDN", - "partyId": mobile - }, - "payerMessage": message, - "payeeNote": note - } - headers = { - "X-Target-Environment": environment, - "Content-Type": "application/json", - "Ocp-Apim-Subscription-Key": self.auth_key - } - url = self.base_url+"/v1_0/transfer" - res = self.request("POST", url, headers, data) - return {"transaction_ref": external_ref} - - @classmethod - def generateToken(self, host, api_user, api_key, base_url, environment="sandbox"): - data = {"providerCallbackHost": host} - - headers = { - "Content-Type": "application/json", - "Ocp-Apim-Subscription-Key": api_key, - "X-Target-Environment": environment, - } - - url = base_url+"/v1_0/apiuser/%s/apikey" % api_user - - res = requests.post(url, data=json.dumps({}), headers=headers) - print(res) - - return res.json() - - def close(self): - if self._session is not None: - print("closing!") - self._session.close() diff --git a/src/momoapi/errors.py b/src/momoapi/errors.py deleted file mode 100644 index 42dde61..0000000 --- a/src/momoapi/errors.py +++ /dev/null @@ -1,194 +0,0 @@ - - -class MomoError(Exception): - def __init__(self, message=None, http_body=None, http_status=None, - json_body=None, headers=None, code=None): - super(MomoError, self).__init__(message) - - if http_body and hasattr(http_body, 'decode'): - try: - http_body = http_body.decode('utf-8') - except BaseException: - http_body = ('" - if self.request_id is not None: - return u"Request {0}: {1}".format(self.request_id, msg) - else: - return msg - - # Returns the underlying `Exception` (base class) message - - @property - def user_message(self): - return self._message - - def __repr__(self): - return '%s(message=%r, http_status=%r, request_id=%r)' % ( - self.__class__.__name__, - self._message, - self.http_status, - self.request_id) - - - -class APIError(MomoError): - pass - -class APIConnectionError(MomoError): - pass - -class AuthenticationError(MomoError): - pass - - -class PermissionError(MomoError): - pass - -class PreapprovalError(MomoError): - pass - -class RequestToPayError(MomoError): - pass - -class TransferError(MomoError): - pass - -class GeneralError(MomoError): - pass - - -ERROR_CODES= [ - { - "http_code": 409, - "response_code":None, - "description": "Duplicated Reference Id. Cannot create new resource", - "error_type": "generic" - - }, - { - "http_code": 404, - "response_code":None, - "description": "Reference Id not found. Requested resource does not exist", - "error_type": "generic" - - }, - { - "http_code": 400, - "response_code":None, - "description": "Bad request. Request does not follow the specification.", - "error_type": "generic" - - }, - { - "http_code": 401, - "response_code":None, - "description": "Authentication failed. Credentials not valid", - "error_type": "generic" - - }, - - { - "http_code": 500, - "response_code":"NOT_ALLOWED", - "description": "Authorization failed. User does not have permission.", - "error_type": "generic" - - }, - - { - "http_code": 500, - "response_code":"NOT_ALLOWED_TARGET_ENVIRONMENT", - "description": "Not allowed target environment", - "error_type": "generic" - - }, - - { - "http_code": 500, - "response_code":"INVALID_CALLBACK_URL_HOST", - "description": "Callback URL with different host name then configured for API User", - "error_type": "generic" - - }, - - { - "http_code": 500, - "response_code":"INVALID_CURRENCY", - "description": "Currency not supported on the requested account", - "error_type": "generic" - - }, - - { - "http_code": 500, - "response_code":"INTERNAL_PROCESSING_ERROR", - "description": "Default error code used when there is no specific error mapping.", - "error_type": "generic" - - }, - - { - "http_code": 500, - "response_code":"SERVICE_UNAVAILABLE", - "description": "Service temporary unavailable, try again later", - "error_type": "generic" - - }, - - { - "http_code": 500, - "response_code":"PAYER_NOT_FOUND", - "description": "Payer not found", - "error_type": "preapproval" - - }, - - { - "http_code": 500, - "response_code":"PAYEE_NOT_ALLOWED_TO_RECEIVE", - "description": "Payee cannot receive funds due to e.g. transfer limit.", - "error_type": "request_to_pay" - - }, - - { - "http_code": 500, - "response_code":"NOT_ENOUGH_FUNDS", - "description": "Not enough funds on payer account", - "error_type": "transfer" - - }, - { - "http_code": 500, - "response_code":"PAYER_LIMIT_REACHED", - "description": "Not allowed to end due to Payer limit reached", - "error_type": "transfer" - - }, - - { - "http_code": 500, - "response_code":"PAYEE_NOT_FOUND", - "description": "Payee not found. Account holder is not registered", - "error_type": "transfer" - - }, - - { - "http_code": 404, - "response_code":None, - "description": "Account holder is not found", - "error_type": "account" - - } -] diff --git a/src/momoapi/__init__.py b/src/mtnmomo/__init__.py similarity index 100% rename from src/momoapi/__init__.py rename to src/mtnmomo/__init__.py diff --git a/src/momoapi/__main__.py b/src/mtnmomo/__main__.py similarity index 75% rename from src/momoapi/__main__.py rename to src/mtnmomo/__main__.py index 572ff8d..203d83d 100644 --- a/src/momoapi/__main__.py +++ b/src/mtnmomo/__main__.py @@ -1,5 +1,5 @@ """ -Entrypoint module, in case you use `python -mmomoapi_python`. +Entrypoint module, in case you use `python -mtnmomo_python`. Why does this file exist, and why __main__? For more info, read: @@ -8,7 +8,7 @@ - https://docs.python.org/2/using/cmdline.html#cmdoption-m - https://docs.python.org/3/using/cmdline.html#cmdoption-m """ -from momoapi.cli import main +from mtnmomo.cli import main if __name__ == "__main__": main() diff --git a/src/momoapi/cli.py b/src/mtnmomo/cli.py similarity index 72% rename from src/momoapi/cli.py rename to src/mtnmomo/cli.py index 09f14d2..09c9b65 100644 --- a/src/momoapi/cli.py +++ b/src/mtnmomo/cli.py @@ -14,28 +14,32 @@ Also see (1) from http://click.pocoo.org/5/setuptools/#setuptools-integration """ +import json +import time +import uuid + import click import requests -import uuid -import time -import json def generateToken(host, key): data = {"providerCallbackHost": host} - token = "%s" % uuid.uuid4() - + token = str(uuid.uuid4()) headers = { - "X-Reference-Id": "%s" % token, + "X-Reference-Id": token, "Content-Type": "application/json", "Ocp-Apim-Subscription-Key": key } - r = requests.post("https://ericssonbasicapi2.azure-api.net/v1_0/apiuser", data=json.dumps(data), headers=headers) + requests.post( + "https://ericssonbasicapi2.azure-api.net/v1_0/apiuser", + data=json.dumps(data), + headers=headers) time.sleep(5) del headers["X-Reference-Id"] - url = "https://ericssonbasicapi2.azure-api.net/v1_0/apiuser/%s/apikey" % token + url = "https://ericssonbasicapi2.azure-api.net/v1_0/apiuser/{0}/apikey".format( + token) res = requests.post(url, data=json.dumps({}), headers=headers) @@ -46,11 +50,17 @@ def generateToken(host, key): ret["UserId"] = token ret["APISecret"] = rr["apiKey"] - return "Here is your User Id and API secret : %s" % ret + return "Here is your User Id and API secret : {0}".format(ret) @click.command() -@click.option('--provider', prompt="providerCallBackHost", help='providerCallBackHost') -@click.option('--key', prompt="Ocp-Apim-Subscription-Key", help='Ocp-Apim-Subscription-Key') +@click.option( + '--provider', + prompt="providerCallBackHost", + help='providerCallBackHost') +@click.option( + '--key', + prompt="Ocp-Apim-Subscription-Key", + help='Ocp-Apim-Subscription-Key') def main(provider, key): click.echo(generateToken(provider, key)) diff --git a/src/mtnmomo/client.py b/src/mtnmomo/client.py new file mode 100644 index 0000000..2740009 --- /dev/null +++ b/src/mtnmomo/client.py @@ -0,0 +1,193 @@ +""" +Base implementation of the MTN API client + +@author: Moses Mugisha +""" +import json +try: + from json.decoder import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError + +import requests +from requests import Request, Session +from requests._internal_utils import to_native_string +from requests.auth import AuthBase +from requests.auth import HTTPBasicAuth + + +from .config import MomoConfig +from .errors import APIError +from .utils import requests_retry_session + + +class Response: + + def __init__(self, body, code, headers): + self.body = body + self.code = code + self.headers = headers + self.data = body + + +class MoMoAuth(AuthBase): + """Attaches Authentication to the given Request object.""" + + def __init__(self, token): + + self.token = token + + def __call__(self, r): + # modify and return the request + + r.headers['Authorization'] = "Bearer " + to_native_string(self.token) + return r + + +class ClientInterface(): + def getAuthToken(self): + raise NotImplementedError + + def getBalance(self): + raise NotImplementedError + + def getTransactionStatus(self): + raise NotImplementedError + + +class Client(ClientInterface): + def getAuthToken(self): + return super(Client, self).getAuthToken() + + def getBalance(self): + return super(Client, self).getBalance() + + def getTransactionStatus(self): + return super(Client, self).getTransactionStatus() + + +class MomoApi(ClientInterface, object): + + def __init__( + self, + config, + ** kwargs): + super(MomoApi, self).__init__(**kwargs) + self._session = Session() + self._config = MomoConfig(config) + + @property + def config(self): + return self._config + + def request(self, method, url, headers, post_data=None): + self.authToken = self.getAuthToken().json()["access_token"] + request = Request( + method, + url, + data=json.dumps(post_data), + headers=headers, + auth=MoMoAuth(self.authToken)) + + prepped = self._session.prepare_request(request) + + resp = requests_retry_session(sesssion=self._session).send(prepped, + verify=False + ) + return self.interpret_response(resp) + + def interpret_response(self, resp): + rcode = resp.status_code + rheaders = resp.headers + + try: + rbody = resp.json() + except JSONDecodeError: + rbody = resp.text + resp = Response(rbody, rcode, rheaders) + + if not (200 <= rcode < 300): + self.handle_error_response(rbody, rcode, resp.text, rheaders) + + return resp + + def handle_error_response(self, rbody, rcode, resp, rheaders): + + raise APIError( + "Invalid response object from API: {0} (HTTP response code " + "was {1})".format(rbody, rcode), + rbody, rcode, resp) + + def request_headers(self, api_key, method): + headers = {} + + return headers + + def getAuthToken(self, product, url, subscription_key): + data = json.dumps({}) + headers = { + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": subscription_key + } + response = requests.post( + + "{0}{1}".format(self.config.baseUrl, url), + auth=HTTPBasicAuth( + self.config.userId(product), + self.config.APISecret(product)), + data=data, + headers=headers) + return response + + def getBalance(self, url, subscription_key): + headers = { + "X-Target-Environment": self.config.environment, + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": subscription_key + } + url = "{0}{1}".format(self.config.baseUrl, url) + res = self.request("GET", url, headers) + return res.json() + + def getTransactionStatus( + self, + transaction_id, + url, + subscription_key, + ** kwargs): + + headers = { + "X-Target-Environment": self.config.environment, + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": subscription_key + } + _url = self.config.baseUrl + url + transaction_id + res = self.request("GET", _url, headers) + return res.json() + + @classmethod + def generateToken( + cls, + host, + api_user, + api_key, + base_url, + environment="sandbox", + **kwargs): + data = {"providerCallbackHost": host} + + headers = { + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": api_key, + "X-Target-Environment": environment, + } + + url = base_url + "/v1_0/apiuser/{0}/apikey".format(api_user) + + res = requests.post(url, data=json.dumps(data), headers=headers) + + return res.json() + + def close(self): + if self._session is not None: + self._session.close() diff --git a/src/mtnmomo/collection.py b/src/mtnmomo/collection.py new file mode 100644 index 0000000..ee52b29 --- /dev/null +++ b/src/mtnmomo/collection.py @@ -0,0 +1,64 @@ +import uuid + +from .client import MomoApi +from .utils import validate_phone_number + + +class Collection(MomoApi, object): + def getAuthToken(self): + """ + Create an access token which can then be used + to authorize and authenticate towards the other end-points of the API + """ + url = "/collection/token/" + response = super(Collection, self).getAuthToken( + "COLLECTION", url, super(Collection, self).config.collectionsKey) + return response + + def getBalance(self): + url = "/collection/v1_0/account/balance" + + return super(Collection, self).getBalance(url, super(Collection, self).config.collectionsKey) + + def getTransactionStatus( + self, + transaction_id, + **kwargs): + url = "/collection/v1_0/requesttopay/" + + return super(Collection, self).getTransactionStatus( + transaction_id, url, super(Collection, self).config.collectionsKey) + + def requestToPay( + self, + mobile, + amount, + external_id, + payee_note="", + payer_message="", + currency="EUR", + **kwargs): + # type: (String,String,String,String,String,String,String) -> json + ref = str(uuid.uuid4()) + data = { + "payer": { + "partyIdType": "MSISDN", + "partyId": validate_phone_number(mobile)}, + "payeeNote": payee_note, + "payerMessage": payer_message, + "externalId": external_id, + "currency": currency, + "amount": str(amount)} + headers = { + "X-Target-Environment": super(Collection, self).config.environment, + "Content-Type": "application/json", + "X-Reference-Id": ref, + "Ocp-Apim-Subscription-Key": super(Collection, self).config.collectionsKey + + + } + if kwargs.get("callback_url"): + headers["X-Callback-Url"] = kwargs.get("callback_url") + url = "{0}/collection/v1_0/requesttopay".format(super(Collection, self).config.baseUrl) + self.request("POST", url, headers, data) + return {"transaction_ref": ref} diff --git a/src/mtnmomo/config.py b/src/mtnmomo/config.py new file mode 100644 index 0000000..60c6912 --- /dev/null +++ b/src/mtnmomo/config.py @@ -0,0 +1,100 @@ +from .utils import validate_uuid +from .errors import ConfigurationError + + +class MomoConfig(object): + + def __init__(self, conf): + """ + + config={ + + ENVIRONMENT: os.environ.get("ENVIRONMENT"), + BASE_URL: os.environ.get("BASE_URL"), + CALLBACK_HOST: os.environ.get("CALLBACK_HOST"), + COLLECTION_PRIMARY_KEY: os.environ.get("COLLECTION_PRIMARY_KEY"), + COLLECTION_USER_ID: os.environ.get("COLLECTION_USER_ID"), + COLLECTION_API_SECRET: os.environ.get("COLLECTION_API_SECRET"), + + REMITTANCE_USER_ID: os.environ.get("REMITTANCE_USER_ID"), + REMITTANCE_API_SECRET: os.environ.get("REMITTANCE_API_SECRET"), + REMITTANCE_PRIMARY_KEY: os.environ.get("REMITTANCE_PRIMARY_KEY") + + DISBURSEMENT_USER_ID: os.environ.get("DISBURSEMENT_USER_ID"), + DISBURSEMENT_API_SECRET: os.environ.get("DISBURSEMENTS_API_SECRET"), + DISBURSEMENT_PRIMARY_KEY: os.environ.get("DISBURSEMENT_PRIMARY_KEY"), + + + } + + + """ + self._config = conf + + def get_property(self, property_name): + if property_name not in self._config.keys(): + return None + return self._config[property_name] + + def userId(self, product): + key = self.get_property('{0}_USER_ID'.format(product)) + + if not key: + raise ConfigurationError( + "{0}_USER_ID is missing in the configuration".format(product)) + else: + return validate_uuid(key) + + def APISecret(self, product): + key = self.get_property('{0}_API_SECRET'.format(product)) + + if not key: + raise ConfigurationError( + "{0}_API_SECRET is missing in the configuration".format(product)) + else: + return key + + @property + def environment(self): + return self.get_property('ENVIRONMENT') or "sandbox" + + @property + def baseUrl(self): + return self.get_property( + 'BASE_URL') or "https://ericssonbasicapi2.azure-api.net" + + @property + def callbackHost(self): + key = self.get_property('CALLBACK_HOST') + if not key: + raise ConfigurationError( + "CALLBACK_HOST is missing in the configuration") + else: + return key + + @property + def collectionsKey(self): + key = self.get_property('COLLECTION_PRIMARY_KEY') + if not key: + raise ConfigurationError( + "COLLECTION_PRIMARY_KEY is missing in the configuration") + else: + return validate_uuid(key) + + @property + def disbursementsKey(self): + key = self.get_property('DISBURSEMENT_PRIMARY_KEY') + if not key: + raise ConfigurationError( + "DISBURSEMENT_PRIMARY_KEY is missing in the configuration") + else: + return validate_uuid(key) + + @property + def remittencesKey(self): + key = self.get_property('REMITTANCE_PRIMARY_KEY') + if not key: + raise ConfigurationError( + "REMITTANCE_PRIMARY_KEY is missing in the configuration") + else: + return validate_uuid(key) diff --git a/src/mtnmomo/disbursement.py b/src/mtnmomo/disbursement.py new file mode 100644 index 0000000..4472b95 --- /dev/null +++ b/src/mtnmomo/disbursement.py @@ -0,0 +1,62 @@ +from .client import MomoApi +import uuid +from .utils import validate_phone_number + + +class Disbursement(MomoApi, object): + + def getAuthToken(self): + """ + Create an access token which can then be used to authorize and authenticate towards the other end-points of the API. + """ + url = "/disbursement/token/" + response = super(Disbursement, self).getAuthToken( + "DISBURSEMENT", url, super(Disbursement, self).config.disbursementsKey) + return response + + def getBalance(self): + url = "/disbursement/v1_0/account/balance" + + return super(Disbursement, self).getBalance(url, super(Disbursement, self).config.disbursementsKey) + + def getTransactionStatus( + self, + transaction_id, + **kwargs): + url = "/disbursement/v1_0/transfer/" + + return super(Disbursement, self).getTransactionStatus( + transaction_id, url, super(Disbursement, self).config.disbursementsKey) + + def transfer( + self, + amount, + mobile, + external_id, + payee_note="", + payer_message="", + currency="EUR", + **kwargs): + ref = str(uuid.uuid4()) + data = { + "amount": str(amount), + "currency": currency, + "externalId": external_id, + "payee": { + "partyIdType": "MSISDN", + "partyId": validate_phone_number(mobile) + }, + "payerMessage": payer_message, + "payeeNote": payee_note + } + headers = { + "X-Target-Environment": super(Disbursement, self).config.environment, + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": super(Disbursement, self).config.disbursementsKey, + "X-Reference-Id": ref, + } + if kwargs.get("callback_url"): + headers["X-Callback-Url"] = kwargs.get("callback_url") + url = super(Disbursement, self).config.baseUrl + "/disbursement/v1_0/transfer" + self.request("POST", url, headers, data) + return {"transaction_ref": ref} diff --git a/src/mtnmomo/errors.py b/src/mtnmomo/errors.py new file mode 100644 index 0000000..19a7dc7 --- /dev/null +++ b/src/mtnmomo/errors.py @@ -0,0 +1,80 @@ + + +class MomoError(Exception): + def __init__(self, message=None, http_body=None, http_status=None, + json_body=None, headers=None, code=None): + super(MomoError, self).__init__(message) + + if http_body and hasattr(http_body, 'decode'): + try: + http_body = http_body.decode('utf-8') + except BaseException: + http_body = ('" + if self.request_id is not None: + return u"Request {0}: {1}".format(self.request_id, msg) + else: + return msg + + # Returns the underlying `Exception` (base class) message + + @property + def user_message(self): + return self._message + + def __repr__(self): + return '{0}(message={1}, http_status={2}, request_id={3})'.format( + self.__class__.__name__, + self._message, + self.http_status, + self.request_id) + + +class APIError(MomoError): + pass + + +class APIConnectionError(MomoError): + pass + + +class AuthenticationError(MomoError): + pass + + +class PermissionError(MomoError): + pass + + +class PreapprovalError(MomoError): + pass + + +class RequestToPayError(MomoError): + pass + + +class TransferError(MomoError): + pass + + +class GeneralError(MomoError): + pass + + +class ValidationError(Exception): + pass + + +class ConfigurationError(Exception): + pass diff --git a/src/mtnmomo/remittance.py b/src/mtnmomo/remittance.py new file mode 100644 index 0000000..c575fc8 --- /dev/null +++ b/src/mtnmomo/remittance.py @@ -0,0 +1,89 @@ +from .client import MomoApi +import uuid +from .utils import validate_phone_number + + +class Remittance(MomoApi, object): + def getAuthToken(self): + """Generate access token which can then be use to authorize and authenticate towards the other end-points of the API""" + + url = "/remittance/token/" + response = super(Remittance, self).getAuthToken( + "REMITTANCE", url, super(Remittance, self).config.remittencesKey) + return response + + def getBalance(self): + url = "/remittance/v1_0/account/balance" + return super(Remittance, self).getBalance(url, super(Remittance, self).config.remittencesKey) + + def getTransactionStatus( + self, + transaction_id, + **kwargs): + """ + get the status of a transfer + """ + url = "/remittance/v1_0/transfer/" + + return super(Remittance, self).getTransactionStatus( + transaction_id, url, super(Remittance, self).config.remittencesKey) + + def transfer( + self, + amount, + mobile, + external_id, + payer_message, + payee_note, + currency="EUR", + **kwargs): + """ + Transfer operation is used to transfer an amount from the own account to + a payee account + + """ + + ref = str(uuid.uuid4()) + data = { + "amount": str(amount), + "currency": currency, + "externalId": external_id, + "payee": { + "partyIdType": "MSISDN", + "partyId": validate_phone_number(mobile)}, + + "payerMessage": payer_message, + "payeeNote": payee_note + } + + headers = { + "X-Target-Environment": super(Remittance, self).config.environment, + "Content-Type": "application/json", + "X-Reference-Id": ref, + "Ocp-Apim-Subscription-Key": super(Remittance, self).config.remittencesKey + + } + + if kwargs.get("callback_url"): + headers["X-Callback-Url"] = kwargs.get("callback_url") + + url = "{0}/remittance/v1_0/transfer".format(super(Remittance, self).config.baseUrl) + self.request("POST", url, headers, data) + return {"transaction_ref": ref} + + def isActive(self, mobile): + """ + Operation is used to check if an account holder is registered and + active in the system + + """ + + headers = { + "X-Target-Environment": self.config.environment, + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": super(Remittance, self).config.remittencesKey + } + url = "{0}/remittance/v1_0/accountholder/MSISDN/{1}/active".format( + super(Remittance, self).config.baseUrl, mobile) + res = self.request("GET", url, headers) + return res.json() diff --git a/src/mtnmomo/utils.py b/src/mtnmomo/utils.py new file mode 100644 index 0000000..a34b0eb --- /dev/null +++ b/src/mtnmomo/utils.py @@ -0,0 +1,187 @@ +from uuid import UUID +import phonenumbers +from phonenumbers import carrier + +import requests +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry +from .errors import ValidationError + +ERROR_CODES = [ + { + "http_code": 409, + "response_code": None, + "description": "Duplicated Reference Id. Cannot create new resource", + "error_type": "generic" + + }, + { + "http_code": 404, + "response_code": None, + "description": "Reference Id not found. Requested resource does not exist", + "error_type": "generic" + + }, + { + "http_code": 400, + "response_code": None, + "description": "Bad request. Request does not follow the specification.", + "error_type": "generic" + + }, + { + "http_code": 401, + "response_code": None, + "description": "Authentication failed. Credentials not valid", + "error_type": "generic" + + }, + + { + "http_code": 500, + "response_code": "NOT_ALLOWED", + "description": "Authorization failed. User does not have permission.", + "error_type": "generic" + + }, + + { + "http_code": 500, + "response_code": "NOT_ALLOWED_TARGET_ENVIRONMENT", + "description": "Not allowed target environment", + "error_type": "generic" + + }, + + { + "http_code": 500, + "response_code": "INVALID_CALLBACK_URL_HOST", + "description": "Callback URL with different host name then configured for API User", + "error_type": "generic" + + }, + + { + "http_code": 500, + "response_code": "INVALID_CURRENCY", + "description": "Currency not supported on the requested account", + "error_type": "generic" + + }, + + { + "http_code": 500, + "response_code": "INTERNAL_PROCESSING_ERROR", + "description": "Default error code used when there is no specific error mapping.", + "error_type": "generic" + + }, + + { + "http_code": 500, + "response_code": "SERVICE_UNAVAILABLE", + "description": "Service temporary unavailable, try again later", + "error_type": "generic" + + }, + + { + "http_code": 500, + "response_code": "PAYER_NOT_FOUND", + "description": "Payer not found", + "error_type": "preapproval" + + }, + + { + "http_code": 500, + "response_code": "PAYEE_NOT_ALLOWED_TO_RECEIVE", + "description": "Payee cannot receive funds due to e.g. transfer limit.", + "error_type": "request_to_pay" + + }, + + { + "http_code": 500, + "response_code": "NOT_ENOUGH_FUNDS", + "description": "Not enough funds on payer account", + "error_type": "transfer" + + }, + { + "http_code": 500, + "response_code": "PAYER_LIMIT_REACHED", + "description": "Not allowed to end due to Payer limit reached", + "error_type": "transfer" + + }, + + { + "http_code": 500, + "response_code": "PAYEE_NOT_FOUND", + "description": "Payee not found. Account holder is not registered", + "error_type": "transfer" + + }, + + { + "http_code": 404, + "response_code": None, + "description": "Account holder is not found", + "error_type": "account" + + } +] + + +def requests_retry_session( + retries=3, + backoff_factor=0.3, + status_forcelist=(502, 504), + session=None, + **kwargs +): + session = session or requests.Session() + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist, + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount('http://', adapter) + session.mount('https://', adapter) + return session + + +def validate_phone_number(number): + obj = phonenumbers.parse(number, "UG") + if not phonenumbers.is_valid_number(obj): + raise ValidationError("Invalid Phone number {0}".format(number)) + if (carrier.name_for_number(obj, "en") != "MTN"): + raise ValidationError( + "{0}: Only MTN is supported at the moment".format(number)) + return "256{0}".format(obj.national_number) + + +def validate_number(number): + number_types = (int, float) + if not type(number) in number_types: + raise ValidationError("{0}: Must be a number".format(number)) + return number + + +def validate_string(_string): + if not isinstance(_string, str): + raise ValidationError("{0}: Must be a string".format(_string)) + return _string + + +def validate_uuid(_string): + try: + UUID(_string, version=4) + except ValueError: + raise ValidationError( + "{0}: Must be a valid uuid4 string".format(_string)) + return _string diff --git a/src/momoapi/resources/account.py b/tests/__init__.py similarity index 100% rename from src/momoapi/resources/account.py rename to tests/__init__.py diff --git a/tests/integration/features/collections.feature b/tests/integration/features/collections.feature new file mode 100644 index 0000000..c1da4b1 --- /dev/null +++ b/tests/integration/features/collections.feature @@ -0,0 +1,40 @@ +Feature: Collections + Scenario: Request a payment from a consumer (Payer) + Given I have a valid user_id, auth_secret, and collections subscription key + When I request for a payment with the following payment details + | note | amount | message | mobile | product_id | + | test payment | 600 | message | 0782631873 | 0001 | + + And I check for transaction Status + Then It should be successful + + Scenario: Failed Transfer + Given I have a valid user_id, auth_secret, and collections subscription key + When I enter the following payment details + | note | amount | message | mobile | product_id | + | test payment | 600 | message | 0782631873 | 0001 | + + And I check for transaction Status + Then It should be successful + + Scenario: Wrong Currency + Given I have a valid user_id, auth_secret, and collections subscription key + When I enter the following payment details + | note | amount | message | mobile | product_id | + | test payment | 600 | message | 0782631873 | 0001 | + + And I check for transaction Status + Then It should be successful + + Scenario: Non Mtn mobile + Given I have a valid user_id, auth_secret, and collections subscription key + When I enter the following payment details + | note | amount | message | mobile | product_id | + | test payment | 600 | message | 0782631873 | 0001 | + + And I check for transaction Status + Then It should be successful + + + + diff --git a/tests/integration/features/disbursements.feature b/tests/integration/features/disbursements.feature new file mode 100644 index 0000000..1610f0a --- /dev/null +++ b/tests/integration/features/disbursements.feature @@ -0,0 +1,9 @@ +Feature: Disbursements + Scenario: Transfer Money to another account + Given I have a valid user_id, auth_secret, and disbursements subscription key + When I transfer with the following payment details + | note | amount | message | mobile | product_id | + | test payment | 600 | message | 0782631873 | 0001 | + + And I check for transaction Status + Then It should be successful \ No newline at end of file diff --git a/tests/integration/features/remittances.feature b/tests/integration/features/remittances.feature new file mode 100644 index 0000000..6d8871d --- /dev/null +++ b/tests/integration/features/remittances.feature @@ -0,0 +1,9 @@ +Feature: Remmittences + Scenario: Move money from one account to another + Given I have a valid user_id, auth_secret, and remittence subscription key + When I transfer with the following details + | note | amount | message | mobile | product_id | + | test payment | 600 | message | 0782631873 | 0001 | + + And I check for transaction Status + Then It should be successful \ No newline at end of file diff --git a/tests/integration/features/sandbox.feature b/tests/integration/features/sandbox.feature new file mode 100644 index 0000000..21cd727 --- /dev/null +++ b/tests/integration/features/sandbox.feature @@ -0,0 +1,16 @@ +Feature: As a new library User, I should be able to create an account on the MOMO sandbox + + Scenario: Adding new account + Given a user with the domain sparkpl.ug and subscription key 99e9cb10e8c04ea0b788334dc6346f13 + When I run the command "mtnmomo" + And I fill in the "providerCallBackHost" with "sparkpl.ug" + And I fill in the "Ocp-Apim-Subscription-Key" with "99e9cb10e8c04ea0b788334dc6346f13" + Then I should get back the apiKey + + Scenario: Wrong subscription Key + Given a user with the domain sparkpl.ug and subscription key 99e9cb10e8c04ea0b788334dc6346f13dg + When I run the command "mtnmomo" + And I fill in the "providerCallBackHost" with "sparkpl.ug" + And I fill in the "Ocp-Apim-Subscription-Key" with "f83xx8d8xx6749f19a26e2265aeadg" + Then I should get the message "Access denied due to invalid subscription key" + diff --git a/tests/integration/test_collection.py b/tests/integration/test_collection.py new file mode 100644 index 0000000..0863bbc --- /dev/null +++ b/tests/integration/test_collection.py @@ -0,0 +1,49 @@ +from pytest_bdd import scenario, given, when, then, parsers +import re +import os +import pytest +from click.testing import CliRunner +from mtnmomo.collection import Collection + + +pytest.globalDict = {} + + +@scenario('features/collections.feature', 'Request a payment from a consumer (Payer)') +def test_collections(): + pass + + +@given("I have a valid user_id, auth_secret, and collections subscription key") +def user_credentials(): + config = { + "COLLECTION_USER_ID": os.environ.get("COLLECTION_USER_ID"), + "COLLECTION_API_SECRET": os.environ.get("COLLECTION_API_SECRET"), + "COLLECTION_PRIMARY_KEY": os.environ.get("COLLECTION_PRIMARY_KEY"), + + } + client = Collection(config) + pytest.globalDict["client"] = client + + +@when("I request for a payment with the following payment details\n| note | amount | message | mobile | product_id |\n| test payment | 600 | message | 0782631873 | 0001 |") +def successful_request_to_pay(): + + ref = pytest.globalDict["client"].requestToPay( + mobile="256772123456", amount="600", external_id="123456789", payee_note="dd", payer_message="dd", + currency="EUR") + pytest.globalDict["ref"] = ref + + +@when("I check for transaction Status") +def check_transaction_status(): + status = pytest.globalDict["client"].getTransactionStatus(pytest.globalDict["ref"]["transaction_ref"]) + pytest.globalDict["status"] = status + assert isinstance(status, dict) + assert "amount" in status.keys() + assert "currency" in status.keys() + + +@then("It should be successful") +def successful_transaction(): + assert pytest.globalDict["status"]["status"] == "SUCCESSFUL" diff --git a/tests/integration/test_disbursement.py b/tests/integration/test_disbursement.py new file mode 100644 index 0000000..0d88508 --- /dev/null +++ b/tests/integration/test_disbursement.py @@ -0,0 +1,50 @@ +from pytest_bdd import scenario, given, when, then, parsers +import re +import os +import pytest +from click.testing import CliRunner +from mtnmomo.cli import generateToken +from mtnmomo.disbursement import Disbursement + +pytest.globalDict = {} + + +@scenario('features/disbursements.feature', 'Transfer Money to another account') +def test_disbursements(): + pass + + +@given("I have a valid user_id, auth_secret, and disbursements subscription key") +def user_credentials(): + config = { + "DISBURSEMENT_USER_ID": os.environ.get("DISBURSEMENT_USER_ID"), + "DISBURSEMENT_API_SECRET": os.environ.get("DISBURSEMENT_API_SECRET"), + "DISBURSEMENT_PRIMARY_KEY": os.environ.get("DISBURSEMENT_PRIMARY_KEY"), + } + client = Disbursement(config) + pytest.globalDict["client"] = client + + +@when("I transfer with the following payment details\n| note | amount | message | mobile | product_id |\n| test payment | 600 | message | 0782631873 | 0001 |") +def successful_transfer(): + ref = pytest.globalDict["client"].transfer( + amount="600", mobile="256772123456", external_id="123456789", payee_note="dd", payer_message="dd", + currency="EUR") + pytest.globalDict["ref"] = ref + + assert isinstance(ref, dict) + assert "transaction_ref" in ref.keys() + + +@when("I check for transaction Status") +def check_transaction_status(): + status = pytest.globalDict["client"].getTransactionStatus(pytest.globalDict["ref"]["transaction_ref"]) + pytest.globalDict["status"] = status + assert isinstance(status, dict) + assert "amount" in status.keys() + assert "currency" in status.keys() + + +@then("It should be successful") +def sucessful_transaction(): + assert pytest.globalDict["status"]["status"] == "SUCCESSFUL" diff --git a/tests/integration/test_remittance.py b/tests/integration/test_remittance.py new file mode 100644 index 0000000..3b7c50c --- /dev/null +++ b/tests/integration/test_remittance.py @@ -0,0 +1,51 @@ +from pytest_bdd import scenario, given, when, then, parsers +import re +import os +import pytest +from click.testing import CliRunner + +from mtnmomo.cli import generateToken +from mtnmomo.remittance import Remittance + +pytest.globalDict = {} + + +@scenario('features/remittances.feature', 'Move money from one account to another') +def test_remittences(): + pass + + +@given("I have a valid user_id, auth_secret, and remittence subscription key") +def user_credentials(): + config = { + "REMITTANCE_USER_ID": os.environ.get("REMITTANCE_USER_ID"), + "REMITTANCE_API_SECRET": os.environ.get("REMITTANCE_API_SECRET"), + "REMITTANCE_PRIMARY_KEY": os.environ.get("REMITTANCE_PRIMARY_KEY"), + } + client = Remittance(config) + pytest.globalDict["client"] = client + + +@when("I transfer with the following details\n| note | amount | message | mobile | product_id |\n| test payment | 600 | message | 0782631873 | 0001 |") +def successful_transfer(): + ref = pytest.globalDict["client"].transfer( + amount="600", mobile="256772123456", external_id="123456789", payee_note="dd", payer_message="dd", + currency="EUR") + pytest.globalDict["ref"] = ref + + assert isinstance(ref, dict) + assert "transaction_ref" in ref.keys() + + +@when("I check for transaction Status") +def check_transaction_status(): + status = pytest.globalDict["client"].getTransactionStatus(pytest.globalDict["ref"]["transaction_ref"]) + pytest.globalDict["status"] = status + assert isinstance(status, dict) + assert "amount" in status.keys() + assert "currency" in status.keys() + + +@then("It should be successful") +def sucessful_transaction(): + assert pytest.globalDict["status"]["status"] == "SUCCESSFUL" diff --git a/tests/integration/test_sandbox.py b/tests/integration/test_sandbox.py new file mode 100644 index 0000000..af93243 --- /dev/null +++ b/tests/integration/test_sandbox.py @@ -0,0 +1,57 @@ +from pytest_bdd import scenario, given, when, then, parsers +import re +import pytest +from click.testing import CliRunner +from mtnmomo.cli import generateToken + +pytest.globalDict = {} + + +@scenario('features/sandbox.feature', 'Adding new account') +def test_sandbox_provisioning(): + pass + + +@given(parsers.re(r'a user with the domain (?P[a-zA-Z0-9\.]+) and subscription key (?P\w+)'), + converters=dict(domain=str, api_key=str)) +def provisioning_user(domain, api_key): + + pytest.globalDict['domain'] = domain + pytest.globalDict['api_key'] = api_key + return + + +@when('I run the command "mtnmomo"') +def run_command(): + return + + +@when('I fill in the "providerCallBackHost" with "sparkpl.ug"') +def fill_domain(): + return + + +@when('I fill in the "Ocp-Apim-Subscription-Key" with "99e9cb10e8c04ea0b788334dc6346f13"') +def fill_wrong_value(): + return + + +@when('I fill in the "Ocp-Apim-Subscription-Key" with "f83xx8d8xx6749f19a26e2265aeadbcdeg"') +def fill_key(): + return + + +@then('I should get the message "Access denied due to invalid subscription key"') +def wrong_key(key="f83xx8d8xx6749f19a26e2265aeadbcdeg"): + host = pytest.globalDict["domain"] + result = generateToken(host, key) + assert "Access denied due to invalid subscription key" in result + + +@then('I should get back the apiKey') +def get_Key(): + + host = pytest.globalDict["domain"] + key = pytest.globalDict["api_key"] + result = generateToken(host, key) + assert "Here is your User Id and API secret" in result diff --git a/tests/test_momoapi.py b/tests/test_momoapi.py deleted file mode 100644 index e230dfc..0000000 --- a/tests/test_momoapi.py +++ /dev/null @@ -1,12 +0,0 @@ - -from click.testing import CliRunner - -from momoapi_python.cli import main - - -def test_main(): - runner = CliRunner() - result = runner.invoke(main, []) - - assert result.output == '()\n' - assert result.exit_code == 0 diff --git a/tests/unitests/__init__.py b/tests/unitests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unitests/test_collection.py b/tests/unitests/test_collection.py new file mode 100644 index 0000000..9da520e --- /dev/null +++ b/tests/unitests/test_collection.py @@ -0,0 +1,98 @@ +import unittest +import pytest +import types +try: + from unittest import mock +except ImportError: + import mock + +from requests import Request, Session + +from .utils import mocked_requests_get, mocked_requests_post, mocked_requests_session +from mtnmomo.errors import ValidationError +from mtnmomo.client import MomoApi +from mtnmomo.collection import Collection + + +class TestCollections(unittest.TestCase): + + @mock.patch('requests.post', side_effect=mocked_requests_post) + def setUp(self, mock_get): + self.config = { + "COLLECTION_USER_ID": "0555e303-ae5b-4052-a77b-6d284cfc669c", + "COLLECTION_API_SECRET": "API_SECRET", + "COLLECTION_PRIMARY_KEY": "0555e303-ae5b-4052-a77b-6d284cfc669c", + # "DISBURSEMENTS_PRIMARY_KEY": "0555e303-ae5b-4052-a77b-6d284cfc669c", + # "REMITTENCES_PRIMARY_KEY": "0555e303-ae5b-4052-a77b-6d284cfc669c" + } + client = Collection(self.config) + self.client = client + + def tearDown(self): + pass + # self.widget.dispose() + #self.widget = None + + @mock.patch('requests.get', side_effect=mocked_requests_get) + def test_client_instantiate(self, mock_get): + + client = Collection(self.config) + + #request_mock.assert_requested("post", "/v1/accounts") + assert isinstance(client, Collection) + + @mock.patch('requests.get', side_effect=mocked_requests_get) + def test_invalid_uuid(self, mock_get): + #client = MomoApi("APIKEY", "USERID", "APISECRET") + with self.assertRaises(ValidationError): + config = self.config + config["COLLECTION_PRIMARY_KEY"] = "invalid key" + client = Collection(config) + client.getAuthToken() + + @mock.patch('requests.get', side_effect=mocked_requests_get) + def test_invalid_mobile(self, mock_get): + #client = MomoApi("APIKEY", "USERID", "APISECRET") + with self.assertRaises(ValidationError): + ref = self.client.requestToPay(mobile="256712123456", amount="600", + external_id="123456789", payee_note="dd", payer_message="dd", currency="EUR") + with self.assertRaises(ValidationError): + ref = self.client.requestToPay(mobile="254712123456", amount="600", + external_id="123456789", payee_note="dd", payer_message="dd", currency="EUR") + + @mock.patch.object(MomoApi, "request", side_effect=mocked_requests_session) + def test_request_to_pay(self, mock_get): + + ref = self.client.requestToPay(mobile="256772123456", amount="600", external_id="123456789", payee_note="dd", + payer_message="dd", currency="EUR") + + assert isinstance(ref, dict) + assert "transaction_ref" in ref.keys() + + @mock.patch.object(MomoApi, "request", side_effect=mocked_requests_session) + def test_get_balance(self, mock_get): + balance = self.client.getBalance() + assert isinstance(balance, dict) + assert "availableBalance" in balance.keys() + assert "currency" in balance.keys() + + # @mock.patch('requests.get', side_effect=mocked_requests_get) + @mock.patch.object(MomoApi, "request", side_effect=mocked_requests_session) + def test_get_transaction_status(self, mock_get): + status = self.client.getTransactionStatus("dummy") + assert isinstance(status, dict) + assert "amount" in status.keys() + assert "currency" in status.keys() + + # @mock.patch.object(MomoApi, "request", side_effect=mocked_requests_session) + # def test_transfer(self, mock): + # ref = self.client.transfer("600", "256772123456", note="dd", + # message="dd", currency="EUR", environment="sandbox") + #assert isinstance(ref, dict) + #assert "transaction_ref" in ref.keys() + + @mock.patch('requests.post', side_effect=mocked_requests_post) + def test_generate_token(self, mock): + res = MomoApi.generateToken("dummy_host", "dummy_user", "dummy_key", "dummy_base") + assert isinstance(res, dict) + assert "apiKey" in res.keys() diff --git a/tests/unitests/test_disbursement.py b/tests/unitests/test_disbursement.py new file mode 100644 index 0000000..c384b77 --- /dev/null +++ b/tests/unitests/test_disbursement.py @@ -0,0 +1,84 @@ +import unittest +import pytest +import types +try: + from unittest import mock +except ImportError: + import mock +from requests import Request, Session + +from .utils import mocked_requests_get, mocked_requests_post, mocked_requests_session +from mtnmomo.errors import ValidationError +from mtnmomo.client import MomoApi +from mtnmomo.disbursement import Disbursement + + +class TestDisbursements(unittest.TestCase): + + @mock.patch('requests.post', side_effect=mocked_requests_post) + def setUp(self, mock_get): + self.config = { + "DISBURSEMENT_USER_ID": "USER_ID", + "DISBURSEMENT_API_SECRET": "API_SECRET", + # "COLLECTIONS_PRIMARY_KEY": "0555e303-ae5b-4052-a77b-6d284cfc669c", + "DISBURSEMENT_PRIMARY_KEY": "0555e303-ae5b-4052-a77b-6d284cfc669c", + # "REMITTENCES_PRIMARY_KEY": "0555e303-ae5b-4052-a77b-6d284cfc669c" + } + client = Disbursement(self.config) + self.client = client + + def tearDown(self): + pass + # self.widget.dispose() + #self.widget = None + + @mock.patch('requests.get', side_effect=mocked_requests_get) + def test_client_instantiate(self, mock_get): + + client = Disbursement(self.config) + + #request_mock.assert_requested("post", "/v1/accounts") + assert isinstance(client, Disbursement) + + @mock.patch('requests.get', side_effect=mocked_requests_get) + def test_invalid_uuid(self, mock_get): + #client = MomoApi("APIKEY", "USERID", "APISECRET") + with self.assertRaises(ValidationError): + config = self.config + config["COLLECTION_PRIMARY_KEY"] = "invalid key" + client = Disbursement(config) + client.getAuthToken() + + @mock.patch('requests.get', side_effect=mocked_requests_get) + def test_invalid_mobile(self, mock_get): + #client = MomoApi("APIKEY", "USERID", "APISECRET") + with self.assertRaises(ValidationError): + ref = self.client.transfer(amount="600", mobile="2567721234569", external_id="123456789", payee_note="dd", + payer_message="dd", currency="EUR") + with self.assertRaises(ValidationError): + ref = self.client.transfer(amount="600", mobile="256712123456", external_id="123456789", payee_note="dd", + payer_message="dd", currency="EUR") + + @mock.patch.object(MomoApi, "request", side_effect=mocked_requests_session) + def test_transfer(self, mock_get): + + ref = self.client.transfer(amount="600", mobile="256772123456", external_id="123456789", payee_note="dd", + payer_message="dd", currency="EUR") + + assert isinstance(ref, dict) + assert "transaction_ref" in ref.keys() + + @mock.patch.object(MomoApi, "request", side_effect=mocked_requests_session) + def test_get_balance(self, mock_get): + balance = self.client.getBalance() + assert isinstance(balance, dict) + assert "availableBalance" in balance.keys() + assert "currency" in balance.keys() + + # @mock.patch('requests.get', side_effect=mocked_requests_get) + @mock.patch.object(MomoApi, "request", side_effect=mocked_requests_session) + def test_get_transaction_status(self, mock_get): + status = self.client.getTransactionStatus("dummy") + assert isinstance(status, dict) + assert "amount" in status.keys() + assert "currency" in status.keys() diff --git a/tests/unitests/test_remittance.py b/tests/unitests/test_remittance.py new file mode 100644 index 0000000..978e8a1 --- /dev/null +++ b/tests/unitests/test_remittance.py @@ -0,0 +1,86 @@ +import unittest +import pytest +import types +try: + from unittest import mock +except ImportError: + import mock +from requests import Request, Session + +from mtnmomo.client import MomoApi +from mtnmomo.remittance import Remittance +from mtnmomo.errors import ValidationError + +from .utils import mocked_requests_get, mocked_requests_post, mocked_requests_session + + +class TestRemittences(unittest.TestCase): + + @mock.patch('requests.post', side_effect=mocked_requests_post) + def setUp(self, mock_get): + self.config = { + "REMITTANCE_USER_ID": "USER_ID", + "REMITTANCE_API_SECRET": "API_SECRET", + + # "COLLECTIONS_PRIMARY_KEY": "0555e303-ae5b-4052-a77b-6d284cfc669c", + # "DISBURSEMENTS_PRIMARY_KEY": "0555e303-ae5b-4052-a77b-6d284cfc669c", + "REMITTANCE_PRIMARY_KEY": "0555e303-ae5b-4052-a77b-6d284cfc669c" + } + client = Remittance(self.config) + self.client = client + + def tearDown(self): + pass + # self.widget.dispose() + #self.widget = None + + @mock.patch('requests.get', side_effect=mocked_requests_get) + def test_client_instantiate(self, mock_get): + + client = Remittance(self.config) + + #request_mock.assert_requested("post", "/v1/accounts") + assert isinstance(client, Remittance) + + @mock.patch('requests.get', side_effect=mocked_requests_get) + def test_invalid_uuid(self, mock_get): + #client = MomoApi("APIKEY", "USERID", "APISECRET") + with self.assertRaises(ValidationError): + config = self.config + config["REMITTANCE_PRIMARY_KEY"] = "invalid key" + client = Remittance(config) + client.getAuthToken() + + @mock.patch('requests.get', side_effect=mocked_requests_get) + def test_invalid_mobile(self, mock_get): + #client = MomoApi("APIKEY", "USERID", "APISECRET") + with self.assertRaises(ValidationError): + ref = self.client.transfer(amount="600", mobile="256712123456", external_id="123456789", payee_note="dd", + payer_message="dd", currency="EUR") + with self.assertRaises(ValidationError): + ref = self.client.transfer(amount="600", mobile="256712123456", external_id="123456789", payee_note="dd", + payer_message="dd", currency="EUR") + + @mock.patch.object(MomoApi, "request", side_effect=mocked_requests_session) + def test_transfer(self, mock_get): + + ref = self.client.transfer(amount="600", mobile="256772123456", external_id="123456789", payee_note="dd", + payer_message="dd", currency="EUR") + + assert isinstance(ref, dict) + assert "transaction_ref" in ref.keys() + + @mock.patch.object(MomoApi, "request", side_effect=mocked_requests_session) + def test_get_balance(self, mock_get): + balance = self.client.getBalance() + assert isinstance(balance, dict) + assert "availableBalance" in balance.keys() + assert "currency" in balance.keys() + + # @mock.patch('requests.get', side_effect=mocked_requests_get) + @mock.patch.object(MomoApi, "request", side_effect=mocked_requests_session) + def test_get_transaction_status(self, mock_get): + status = self.client.getTransactionStatus("dummy") + assert isinstance(status, dict) + assert "amount" in status.keys() + assert "currency" in status.keys() diff --git a/tests/unitests/utils.py b/tests/unitests/utils.py new file mode 100644 index 0000000..d4eed6a --- /dev/null +++ b/tests/unitests/utils.py @@ -0,0 +1,75 @@ +import requests +import unittest +try: + from unittest import mock +except ImportError: + import mock + + +class MockResponse: + def __init__(self, json_data, status_code, headers={}): + self.json_data = json_data + self.status_code = status_code + self.headers = headers + + def json(self): + return self.json_data + + +def mocked_requests_get(*args, **kwargs): + if "/requesttopay" in args[0]: + return MockResponse({ + "amount": 100, + "currency": "UGX", + "financialTransactionId": 23503452, + "externalId": 947354, + "payer": { + "partyIdType": "MSISDN", + "partyId": 4656473839 + }, + "status": "SUCCESSFUL" + }, 200) + + return MockResponse(None, 404) + + +def mocked_requests_post(*args, **kwargs): + + if '/collection/token/' in args[0]: + return MockResponse({"access_token": "token"}, 200) + elif "/collection/v1_0/requesttopay" in args[0]: + return MockResponse({"key2": "value2"}, 200) + elif "apiuser" in args[0] and "apikey" in args[0]: + return MockResponse({ + "apiKey": "dummykey" + }, 200) + + return MockResponse(None, 404) + + +def mocked_requests_session(*args, **kwargs): + if '/token/' in args[1]: + return MockResponse({"access_token": "token"}, 200) + elif '/balance' in args[1]: + return MockResponse({ + "availableBalance": "500", + "currency": "UGX" + }, 200) + elif "/requesttopay" in args[1] and args[0] == 'POST': + return MockResponse({}, 200) + elif "transfer" in args[1] and args[0] == 'POST': + return MockResponse({}, 200) + elif ("/requesttopay" in args[1] or "/transfer" in args[1]) and args[0] == 'GET': + return MockResponse({ + "amount": 100, + "currency": "UGX", + "financialTransactionId": 23503452, + "externalId": 947354, + "payer": { + "partyIdType": "MSISDN", + "partyId": 4656473839 + }, + "status": "SUCCESSFUL" + }, 200) + else: + return MockResponse({}, 200) diff --git a/tox.ini b/tox.ini index 44a1f16..8583991 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = clean, - check, - docs, + mypy, + flake8, py27-cover, py27-nocov, py34-cover, @@ -13,75 +13,56 @@ envlist = py36-nocov, py37-cover, py37-nocov, - pypy-cover, - pypy-nocov, - pypy3-cover, - pypy3-nocov, - report + report, + + +skip_missing_interpreters = {env:TOX_SKIP_MISSING_INTERPRETERS:True} [testenv] basepython = - {docs,spell}: {env:TOXPYTHON:python2.7} - {bootstrap,clean,check,report,coveralls}: {env:TOXPYTHON:python3} + {clean,report,coveralls}: {env:TOXPYTHON:python3} setenv = PYTHONPATH={toxinidir}/tests PYTHONUNBUFFERED=yes -passenv = - * deps = pytest pytest-travis-fold + pytest-bdd + mock commands = {posargs:pytest -vv --ignore=src} -[testenv:bootstrap] -deps = - jinja2 - matrix -skip_install = true -commands = - python ci/bootstrap.py -[testenv:check] -deps = - docutils - check-manifest - flake8 - readme-renderer - pygments - isort -skip_install = true -commands = - python setup.py check --strict --metadata --restructuredtext - check-manifest {toxinidir} - flake8 src tests setup.py - isort --verbose --check-only --diff --recursive src tests setup.py +passenv = + COLLECTION_* REMITTANCE_* DISBURSEMENT_* TRAVIS TRAVIS_* + -[testenv:spell] -setenv = - SPELLCHECK=1 -commands = - sphinx-build -b spelling docs dist/docs -skip_install = true -deps = - -r{toxinidir}/docs/requirements.txt - sphinxcontrib-spelling - pyenchant -[testenv:docs] +[testenv:coveralls] deps = - -r{toxinidir}/docs/requirements.txt + coveralls + PyYAML +skip_install = true commands = - sphinx-build {posargs:-E} -b html docs dist/docs - sphinx-build -b linkcheck docs dist/docs + coveralls +passenv = TRAVIS TRAVIS_* -[testenv:coveralls] -deps = - coveralls +[testenv:lint] skip_install = true +basepython = python3.6 commands = - coveralls [] + flake8 colander setup.py + black --check --diff colander setup.py + python setup.py sdist --dist-dir {distdir} + twine check {distdir}/* + check-manifest +deps = + flake8 + black + readme_renderer[md] + check-manifest + twine @@ -109,6 +90,14 @@ commands = deps = {[testenv]deps} pytest-cov + pytest-pep8 + pytest-sugar + pytest-instafail + wltrace + pytest + coverage + pytest-bdd + mock [testenv:py27-nocov] basepython = {env:TOXPYTHON:python2.7} @@ -169,33 +158,107 @@ deps = [testenv:py37-nocov] basepython = {env:TOXPYTHON:python3.7} -[testenv:pypy-cover] -basepython = {env:TOXPYTHON:pypy} -setenv = - {[testenv]setenv} -usedevelop = true -commands = - {posargs:pytest --cov --cov-report=term-missing -vv} + +## +# Flake8 linting +## + +[testenv:flake8] + +skip_install = True + deps = - {[testenv]deps} - pytest-cov + flake8==3.6.0 + flake8-bugbear==18.8.0 + flake8-docstrings==1.3.0 + flake8-import-order==0.18 + flake8-mutable==1.2.0 + flake8-pep3101==1.2.1 + pep8-naming==0.7.0 + mccabe==0.6.1 -[testenv:pypy-nocov] -basepython = {env:TOXPYTHON:pypy} +basepython = python3.7 -[testenv:pypy3-cover] -basepython = {env:TOXPYTHON:pypy3} -setenv = - {[testenv]setenv} -usedevelop = true commands = - {posargs:pytest --cov --cov-report=term-missing -vv} + flake8 {posargs:src/mtnmomo} + +[flake8] + +select = B,C,E,F,I,N,S,W + +disable-noqa = True +show-source = True +doctests = True + +# Codes: http://flake8.pycqa.org/en/latest/user/error-codes.html +ignore = + # multiple spaces before operator + E221, + + #camel case + N802, + + # too many blank lines + E303, + + # expected 2 blank lines after class or function definition + E305, + + # function name should be lowercase + N802, + + # argument name should be lowercase + N803, + + # first argument of a method should be named 'self' + N805, + + # variable in function should be lowercase + N806, + + # lowercase imported as non lowercase + N812, + + # line break after binary operator (W503 and W504 are opposites) + W504, + + I201, + + I100, + + + +## +# Mypy linting +## + +[testenv:mypy] + +basepython = python3.7 + +skip_install = True + deps = - {[testenv]deps} - pytest-cov + mypy==0.650 + +commands = + + "mypy" --config-file="{toxinidir}/tox.ini" {posargs:src} + + +[mypy] -[testenv:pypy3-nocov] -basepython = {env:TOXPYTHON:pypy3} +# Global settings +warn_redundant_casts = True +warn_unused_ignores = True +strict_optional = True +show_column_numbers = True +# Module default settings +# disallow_untyped_calls = True +disallow_untyped_defs = True +# warn_return_any = True +# Need some stub files to get rid of this +ignore_missing_imports = True