diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 0000000..7082973 --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,9 @@ +version = 1 + +test_patterns = ["tests/**"] + +[[analyzers]] +name = "python" + + [analyzers.meta] + runtime_version = "3.x.x" \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8ae8c4c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +Please provide a clear and concise description of what the bug is. +**Important: please make sure you remove your API key from the code, if it is there.** + +**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. Windows 11, Ubuntu 24, etc.] + - ParityVend Library Version [e.g. 1.0.0] + +**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..3f6f1f2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? If yes, please describe.** +A clear and concise description of your feature suggestion. + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68f64b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +install_deps.bat +env.bat +run_tests.bat +run_tests38.bat +run_tests39.bat +run_tests310.bat +run_tests311.bat +run_tests312.bat +run_tests313.bat +run_testspypy.bat + +__pycache__/ +*.py[cod] + +.pytest_cache +.vscode \ No newline at end of file diff --git a/CHANGELOG.MD b/CHANGELOG.MD new file mode 100644 index 0000000..e5f181d --- /dev/null +++ b/CHANGELOG.MD @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.0.0] - 2024-03-15 + +### Added + +- Initial release of the library. +- Implemented core functionality for all backend endpoints of the ParityVend API. + +### Changed + +- N/A (Initial release) + +### Deprecated + +- N/A (Initial release) + +### Removed + +- N/A (Initial release) + +### Fixed + +- N/A (Initial release) + +### Security + +- N/A (Initial release) \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4fa95a4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,131 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via email address, "help AT ambeteco DOT com". All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e125968 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,113 @@ +# Contributing to ParityVend Python API + +Thank you for considering contributing to the ParityVend Python API! We value your time and efforts to help us improve and grow. This document will guide you through the contribution process. Whether it's a bug fix, new feature, or documentation improvement, your help is invaluable in making this project better. + +## Code of Conduct + +Before contributing, please ensure you have read and understood our [Code of Conduct](https://github.com/ParityVend/parityvend_api_python/blob/main/CODE_OF_CONDUCT.md). We are committed to providing a welcoming and inclusive experience for everyone. + +## Getting Help + +If you have any questions, need assistance, or want to discuss ideas for improvements, feel free to reach out to us at "help AT ambeteco DOT com". We strive to respond promptly and provide helpful guidance. + +## Development Setup + +To get started with development, make sure you have the following prerequisites installed: + +- Python (version 3.8 or higher) +- Pip (Python package installer) + +### Step 1: Clone the Repository + +First, clone the repository to your local machine: + +```sh +git clone https://github.com/ParityVend/parityvend_api_python.git +cd python_api +``` + +### Step 2: Set up a Virtual Environment (Optional but Recommended) + +It's a good practice to create a virtual environment to isolate project dependencies and avoid conflicts with other Python projects on your system. You can use the built-in `venv` module or a tool like `virtualenv`. + +#### Using `venv` + +```sh +python3 -m venv env +source env/bin/activate # On Windows, use `env\Scripts\activate` +``` + +#### Using `virtualenv` + +```sh +pip install virtualenv +virtualenv env +source env/bin/activate # On Windows, use `env\Scripts\activate` +``` + +### Step 3: Install Dependencies + +With the virtual environment activated (if you chose to use one), install the project dependencies: + +```sh +pip install -r requirements.txt +pip install -r requirements_dev.txt +``` + +### Step 4: Run Tests + +Before making any changes, ensure that the existing tests pass on your system: + +```sh +pytest -rP tests +``` + +**Note:** To run the tests successfully, you will need to obtain a valid ParityVend API key and set the following environment variables: + +- `parityvend_secret_key` +- `parityvend_secret_key_free` + +For this, run: + +```sh +export PARITYVEND_SECRET_KEY='your-secret-key' +export PARITYVEND_SECRET_KEY_FREE='your-free-key' +``` + +Or, for Windows: +```cmd +set "parityvend_secret_key=your-secret-key" +set "parityvend_secret_key_free=your-free-key" +``` + +### Step 5: Make Changes + +Now you're ready to start making changes to the codebase! Follow best practices for Python development, write tests for new features or bug fixes, and ensure that all existing tests pass before submitting a pull request. + +## Submitting a Pull Request + +1. Fork the repository and create a new branch for your changes. +2. Make your changes and commit them with descriptive commit messages. +3. Push your changes to your forked repository. +4. Create a pull request on the main repository, describing your changes and the motivation behind them. + +We'll review your pull request as soon as possible and provide feedback or merge it into the main codebase. + +## Code Style and Guidelines + +To ensure consistency and maintainability, please follow the established code style and guidelines for this project: + +* Use Ruff for code formatting and linting. +* Follow the PEP 8 style guide for Python code. +* Write clear and concise commit messages. +* Keep the codebase clean and well-documented. +* Ensure backward compatibility when making changes. + +## Other Ways to Contribute +There are many ways to contribute to the project beyond writing code: + +* Report Bugs: If you discover a bug, create an issue on the project's GitHub repository with detailed steps to reproduce the issue. +* Improve Documentation: Help us improve the project's documentation by suggesting edits, fixing typos, or writing additional documentation for new features. +* Suggest New Features: Share your ideas on new functionality that you would want to see in the ParityVend Python API. + +Thank you for your interest in contributing to the ParityVend Python API! We appreciate your efforts to make this project better. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f3f453 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 American Best Technologies Company, LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..2ea6006 --- /dev/null +++ b/README.MD @@ -0,0 +1,443 @@ +# ParityVend API Python Library + +**Welcome to the ParityVend API Python Library!** This library simplifies the process of integrating ParityVend's location-based pricing into your Python applications, allowing you to expand your business and take it to a global level. + +[![ParityVend API Python Library - cover](https://github.com/ParityVend/parityvend_api_python/blob/main/images/_cover_gh.png?raw=true)](https://www.ambeteco.com/ParityVend/) + +ParityVend is a powerful tool that helps businesses go global by offering smart pricing that adapts to each visitor's purchasing power based on their location. By using ParityVend, you can customize the prices of your products to match the economic diversity of the global market, just like industry giants Netflix, Google, Microsoft, Apple, and Spotify do. This approach ensures that your products are competitively priced in various countries, helping you expand your customer base, increase sales, and optimize profits. + +The ParityVend API Python library helps you interact with the ParityVend API more easily. It provides ready-to-use functions that act as a wrapper for the ParityVend API's backend endpoints. This wrapper simplifies the use of ParityVend's features in your Python backends, microservices, or other server-side applications. + +By using this library, you can take advantage of ParityVend's powerful API features to: + +- adjust your prices based on visitors' locations. +- implement anti-abuse systems to protect against fraudulent activities like VPN, proxy, and TOR. +- get information about your customers' locations. + +You can find more integration ideas in the ParityVend documentation: [ParityVend integration ideas](https://www.ambeteco.com/ParityVend/docs/api_integration_tutorial.html#integration-ideas). + +**Key features include:** + +- Easy Integration: Simplified access to the ParityVend API endpoints. +- Asynchronous Support: Provides both synchronous and asynchronous handlers. +- Simplified API Usage: The library abstracts away the complexities of the raw API, allowing you to focus on your application logic. +- Pythonic responses: Providing convenient response types, such as `Response`, `Country`, `Discounts`, etc. instead of raw JSON. + +## Documentation + +For the ParityVend API Python library, please refer to ['quick start' section](https://github.com/ParityVend/parityvend_api_python?tab=readme-ov-file#quick-start) + +For general documentation on the ParityVend API, please refer to [https://www.ambeteco.com/ParityVend/docs/api_reference.html](https://www.ambeteco.com/ParityVend/docs/api_reference.html) + +## Requirements and Dependencies + +The library is cross-platform. It is compatible with all Python versions 3.8 to 3.13 (including support for PyPy). The ParityVend API Python library uses `requests` for the synchronous handler and `aiohttp` for the asynchronous handler, as well as `cachetools` for providing memoization. + +## Getting Started + +To use the ParityVend API, you need to obtain an API key, also known as a `private_key`. ParityVend offers a generous free plan that allows you to get your API key without any cost. + +### Free Plan + +The free plan provides the following benefits: + +- 7,500 requests per month +- Full access to the API and its functionality (except for the currency exchange endpoint and anti-abuse systems) +- No "demo-like" restrictions or limitations + +### Getting the Free API Key + +To get your free API key, follow these steps: + +1. Visit the [ParityVend Pricing Page](https://www.ambeteco.com/ParityVend/pricing/). +2. Sign up for the free plan by providing the required information. +3. Create a new project. Your API key (`private_key`) will be issued and made available to you. + +### Paid Plans + +If you require more monthly requests or need access to the currency exchange endpoint and advanced anti-abuse systems, you can consider upgrading to one of the paid plans offered by ParityVend. + +To explore the paid plan options and pricing details, visit the [ParityVend Pricing Page](https://www.ambeteco.com/ParityVend/pricing/). + +With your API key (`private_key`) in hand, you can start integrating the ParityVend API into your applications and benefiting from its features and functionality. + +### Installation + +**Pre-built Package** + +If you simply want to use the library without modifying the source code, install it using pip: + +```bash +pip install --upgrade parityvend_api +``` + +**Installation from Source** + +To install from the source code (useful for development or customization): + +```bash +python setup.py install +``` + +### Quick Start + +The library provides two main handlers that provide convenient access to the ParityVend API: `ParityVendAPI` and `AsyncParityVendAPI`, which need to be initiated with your account's private API key, available in the [Dashboard](https://www.ambeteco.com/ParityVend/dash/). + +- `ParityVendAPI`: This class is designed for synchronous interactions with the ParityVend API (via `requests`). +- `AsyncParityVendAPI`: Provides asynchronous functions for improved performance (via `aiohttp`). + +Check out the [examples folder](https://github.com/ParityVend/parityvend_api_python/tree/main/examples) for more, or get started with these simple snippets below. + +**Get country information from an IP Address** +View the full documentation for this API endpoint here: [get-country-from-ip]() + +```python +>>> from parityvend_api import ParityVendAPI +>>> parityvend = ParityVendAPI('your_private_key') +>>> ip_address = '190.206.117.0' # an example IP from Venezuela +>>> country = parityvend.get_country_from_ip(ip_address) +>>> country +Country('VE') +>>> country.name # you can use the dot notation to access data +'Venezuela' +>>> country.code +'VE' +>>> country.emoji_flag +'🇻🇪' +>>> country.currency_code +'VES' +>>> country.currency_symbol +'Bs.' +>>> country.currency_localized +'VESBs.' +>>> country['name'] # you can also use dictionary lookups +'Venezuela' +``` + +**Get discount information from an IP Address** +View the full documentation for this API endpoint here: [get-discount-from-ip]() + +```python +>>> from parityvend_api import ParityVendAPI +>>> parityvend = ParityVendAPI('your_private_key') +>>> ip_address = '190.206.117.0' # an example IP from Venezuela +>>> response = parityvend.get_discount_from_ip(ip_address) +>>> response +Response({'status': 'ok', 'discount': 0.4, 'discount_str': '40.00%', 'coupon_code': 'example_coupon', 'country': Country('VE'), 'currency': {'code': 'VES', 'symbol': 'Bs.', 'localized_symbol': 'VESBs.', 'conversion_rate': 36.092756259083146}}) +>>> response.discount +0.4 +>>> response.currency.code +'VES' +>>> response['coupon_code'] +'example_coupon' +``` + +**Get discount banner from an IP Address** +View the full documentation for this API endpoint here: [get-banner-from-ip]() + +```python +>>> from parityvend_api import ParityVendAPI +>>> parityvend = ParityVendAPI('your_private_key') +>>> ip_address = '190.206.117.0' # an example IP from Venezuela +>>> banner = parityvend.get_banner_from_ip(ip_address) +>>> print(banner) +We're committed to fair pricing worldwide and support Venezuela's purchasing power. Enjoy a 40.00% discount with code example_coupon. Happy shopping! +``` + +**Get discount information with the HTML banner from an IP Address** +View the full documentation for this API endpoint here: [get-discount-with-html-from-ip]() + +```python +>>> from parityvend_api import ParityVendAPI +>>> parityvend = ParityVendAPI('your_private_key') +>>> ip_address = '190.206.117.0' # an example IP from Venezuela +>>> response = parityvend.get_discount_with_html_from_ip(ip_address) +>>> response +Response({'status': 'ok', 'html': 'We\'re committed to fair pricing worldwide and support Venezuela\'s purchasing power. Enjoy a 40.00% discount with code example_coupon. Happy shopping!', 'discount': 0.4, 'discount_str': '40.00%', 'coupon_code': 'example_coupon', 'country': Country('VE'), 'currency': {'code': 'VES', 'symbol': 'Bs.', 'localized_symbol': 'VESBs.', 'conversion_rate': 36.092756259083146}}) +>>> print(response.html) +We're committed to fair pricing worldwide and support Venezuela's purchasing power. Enjoy a 40.00% discount with code example_coupon. Happy shopping! +>>> response.discount_str +'40.00%' +>>> +``` + +**Get your account quota information** +View the full documentation for this API endpoint here: [get-quota-info]() + +```python +>>> from parityvend_api import ParityVendAPI +>>> parityvend = ParityVendAPI('your_private_key') +>>> response = parityvend.get_quota_info() +>>> response +Response({'status': 'ok', 'quota_limit': 1000000, 'quota_used': 4716, 'quota_left': 995284}) +>>> response.quota_limit +1000000 +>>> response['quota_used'] +4716 +>>> +``` + +**Get all the discounts for the current project** +View the full documentation for this API endpoint here: [get-discounts-info]() + +```python +>>> from parityvend_api import ParityVendAPI +>>> parityvend = ParityVendAPI('your_private_key') +>>> response = parityvend.get_discounts_info() +>>> response +Response({'status': 'ok', 'discounts': Discounts({'AC': Discount(Country('AC'), '', 0.0), 'AD': Discount(Country('AD'), '', 0.0), 'AE': Discount(Country('AE'), '', 0.0), 'AF': Discount(Country('AF'), 'example_coupon', 0.7), 'AG': Discount(Country('AG'), 'example_coupon', 0.2), 'AI': Discount(Country('AI'), 'example_coupon', 0.2), 'AL': Discount(Country('AL'), ... })}) # discounts information for all 255 countries +>>> +>>> response.discounts['VE'] # access individual countries with key lookups +Discount(Country('VE'), 'example_coupon', 0.4) +>>> response.discounts['US'] +Discount(Country('US'), '', 0.0) +>>> # or via the bound method: +>>> response.discounts.get_discount_by_country('CA') +Discount(Country('CA'), '', 0.0) +>>> +>>> # the discount object has the discount itself, as well as coupon code and country information +>>> response.discounts['VE'].discount_str +'40.00%' +>>> response.discounts['VE'].coupon_code +'example_coupon' +>>> response.discounts['VE'].country +Country('VE') +>>> +``` + +**Get the currency exchange rates (only for accounts with paid plans)** +View the full documentation for this API endpoint here: [get-exchange-rate-info]() + +```python +>>> from parityvend_api import ParityVendAPI +>>> parityvend = ParityVendAPI('your_private_key') +>>> response = parityvend.get_exchange_rate_info() +>>> response +Response({'status': 'ok', 'rates': Response({'AED': 3.6728503704071045, 'AFN': 72.5033950805664, 'ALL': 95.3699951171875, 'AMD': 404.7422790527344, 'ANG': 1.803326964378357, 'AOA': 828.7760620117188, 'ARS': 819.7839965820312, 'AUD': 1.5208089351654053, 'AWG': 1.8025002479553223, 'AZN': 1.6963270902633667, 'BAM': 1.7974870204925537, 'BBD': 2.020319700241089, 'BDT': 109.81781005859375, 'BGN': 1.7990400791168213, 'BHD': 0.3769211173057556, 'BIF': 2859.500244140625, ... })}) +>>> response.rates.EUR +0.9195351004600525 +>>> response.rates.GBP +0.7895551323890686 +>>> +>>> # you can also change the base currency +>>> response_eur = parityvend.get_exchange_rate_info(base_currency='EUR') +>>> response_eur.rates.USD +1.0875060558319092 +>>> response_eur.rates.EUR +1.0 +>>> +``` + +**Some utility functions and miscellaneous stuff:** + +```python +>>> from parityvend_api import env_get, get_country_by_code, COUNTRIES +>>> +>>> COUNTRIES # a dict of country ISO codes to a pre-created 'Country' object +{'AC': Country('AC'), 'AD': Country('AD'), 'AE': Country('AE'), 'AF': Country('AF'), 'AG': Country('AG'), ..., 'ZM': Country('ZM'), 'ZW': Country('ZW'), 'XX': Country('XX')} +>>> COUNTRIES['GB'] +Country('GB') +>>> COUNTRIES['AU'] +Country('AU') +>>> +>>> +>>> get_country_by_code('CA') # get the 'Country' object from the ISO code +Country('CA') +>>> country = get_country_by_code('US') +>>> country.name +'United States of America' +>>> +>>> +>>> # get an environment variable if it exists, or return the default value +>>> env_get('some_env_variable', 'default value') +'default value' +>>> +``` + +### The `base_currency` argument + +The functions `get_discount_from_ip`, `get_banner_from_ip`, `get_discount_with_html_from_ip`, and `get_exchange_rate_info` all have an optional keyword argument called `base_currency`. This argument allows you to specify a base currency for calculating the exchange rate of the IP's local currency. The default value for `base_currency` is `USD`. + +```python +>>> ... +>>> # set the `base_currency` to 'EUR'. By default, it's set to 'USD' +>>> response_eur = parityvend.get_discount_from_ip(ip_address, base_currency='EUR') +>>> response_eur +Response({'status': 'ok', 'discount': 0.4, 'discount_str': '40.00%', 'coupon_code': 'example_coupon', 'country': Country('VE'), 'currency': Response({'code': 'VES', 'symbol': 'Bs.', 'localized_symbol': 'VESBs.', 'conversion_rate': 39.25109100341797})}) +>>> print(f'1 EUR is {response_eur.currency.conversion_rate} {response_eur.currency.code}') +1 EUR is 39.25109100341797 VES +>>> +>>> response_gbp = parityvend.get_discount_from_ip(ip_address, base_currency='GBP') +>>> response_gbp +Response({'status': 'ok', 'discount': 0.4, 'discount_str': '40.00%', 'coupon_code': 'example_coupon', 'country': Country('VE'), 'currency': {'code': 'VES', 'symbol': 'Bs.', 'localized_symbol': 'VESBs.', 'conversion_rate': 45.71277583460257}}) +>>> print(f'1 GBP is {response_gbp.currency.conversion_rate} {response_eur.currency.code}') +1 GBP is 45.71277583460257 VES +>>> +``` + +### Asynchronous support + +An asynchronous handler, `AsyncParityVendAPI`, can be used in the same way as the synchronous handler: + +```python +from parityvend_api import AsyncParityVendAPI +import asyncio + +async def run(): + parityvend = AsyncParityVendAPI("your private key") + ip_address = "190.206.117.0" # an example IP from Venezuela + country = await parityvend.get_country_from_ip(ip_address) + print(repr(country)) # will print 'Country("VE")' + await parityvend.deinit() # don't forget to close the session + +loop = asyncio.get_event_loop().run_until_complete(run()) +``` + +### The `timeout` and `cache` Keyword Arguments + +Each function in the library accepts two optional keyword arguments: `timeout` and `cache`. These arguments allow you to customize the behavior of the API requests and the caching mechanism on a per-call basis. + +#### Using the `timeout` argument + +The `timeout` argument specifies the maximum amount of time (in seconds) to wait for the API request to complete before raising a timeout error. By default, all functions in the library have `timeout=None`, which means that the operating system's default timeout value will be used. + +You can override this default behavior by passing a specific value (in seconds) to the `timeout` argument. For example: + +```python +parityvend.get_country_from_ip('8.8.8.8', timeout=5) # set a 5-second timeout +``` + +Setting an appropriate timeout value can help prevent your application from getting stuck indefinitely waiting for a response from the API. + +#### Using the `cache` argument + +The `cache` argument allows you to enable or disable the caching mechanism for a specific API call. All functions in the library have `cache=True` by default, which means that they will return a cached response if it's available in the cache. This behavior helps optimize your API quota usage and reduce response times. + +However, there may be situations where you want to bypass the cache and always fetch fresh data from the API. In such cases, you can set `cache=False` when calling the function. + +One exception to the default behavior is the `get_quota_info` function, which has `cache=False` by default. This is because quota information is expected to change frequently, and serving stale cached data could lead to inaccurate quota calculations. + +```python +parityvend.get_country_from_ip('8.8.8.8', cache=False) # Bypass the cache +``` + +### The `Response` object + +The functions in this library return a `Response` object that contains all fields listed in the [ParityVend API documentation](https://www.ambeteco.com/ParityVend/docs/api_reference.html#backend-primary-endpoints) for the given endpoint, along with some additional properties and functionality. You can access the response properties using dot notation or dictionary-style lookup methods. The `"country"` and `"discounts"` items in the response are automatically converted to auxiliary `Country` and `Discounts` objects, respectively. These objects provide additional methods and attributes for ease of use. For example: + +```python +... +>>> r = parityvend.get_discount_from_ip("190.206.117.0") +>>> r # the Response object looks like this: +Response({'status': 'ok', 'discount': 0.4, 'discount_str': '40.00%', 'coupon_code': 'example_coupon', 'country': Country('VE'), 'currency': Response({'code': 'VES', 'symbol': 'Bs.', 'localized_symbol': 'VESBs.', 'conversion_rate': 36.092756259083146})}) +>>> r.discount # access the data via dot notation... +0.4 +>>> r.country.name +'Venezuela' +>>> r.currency.conversion_rate +36.092756259083146 +>>> r['currency']['conversion_rate'] # ... or like a dictionary key lookup +36.092756259083146 +>>> +... +>>> # All the functions return Response objects: +>>> parityvend.get_discount_from_ip("190.206.117.0") +Response({'status': 'ok', 'discount': 0.4, 'discount_str': '40.00%', 'coupon_code': 'example_coupon', 'country': Country('VE'), 'currency': {'code': 'VES', 'symbol': 'Bs.', 'localized_symbol': 'VESBs.', 'conversion_rate': 36.092756259083146}}) +>>> parityvend.get_discount_with_html_from_ip("190.206.117.0") +Response({'status': 'ok', 'html': 'We\'re committed to fair pricing worldwide and support Venezuela\'s purchasing power. Enjoy a 40.00% discount with code example_coupon. Happy shopping!', 'discount': 0.4, 'discount_str': '40.00%', 'coupon_code': 'example_coupon', 'country': Country('VE'), 'currency': {'code': 'VES', 'symbol': 'Bs.', 'localized_symbol': 'VESBs.', 'conversion_rate': 36.092756259083146}}) +>>> parityvend.get_quota_info("190.206.117.0") +Response({'status': 'ok', 'quota_limit': 1000000, 'quota_used': 4188, 'quota_left': 995812}) +>>> +``` + +### Caching + +This library provides in-memory caching of API responses by default, using the [cachetools](https://cachetools.readthedocs.io/en/latest/) library. The caching mechanism employs a Least Recently Used (LRU) cache with a Time to Live (TTL) value. This means that cached values will be kept for a specified duration, and when the cache reaches its maximum size, the least recently used entries will be automatically removed to accommodate new ones. + +Caching helps optimize your API quota usage and reduces response times by serving cached data instead of making redundant API requests. However, it's important to note that cached data may become stale over time, so the cache should be invalidated or refreshed as needed, depending on your application's requirements. + +#### Default Caching Options + +By default, the following caching options are applied: + +- **Maximum Cache Size**: 4096 entries (using multiples of 2 for memory efficiency) +- **Time to Live (TTL)**: 24 hours (86,400 seconds) + +These default settings aim to provide a balance between cache performance and memory usage. However, you can modify these settings to suit your specific needs. + +#### Modifying Cache Options + +You can customize the cache behavior by setting the `cache_options` keyword argument when initializing the handler. The `cache_options` parameter should be a dictionary, where the keys are keyword arguments accepted by the `cachetools` library. For more advanced caching options and configurations, refer to the [cachetools documentation](https://cachetools.readthedocs.io/en/latest/). + +```python +>>> from parityvend_api import ParityVendAPI +>>> +>>> # the cache is enabled by default! you don't have to do anything. +>>> parityvend = ParityVendAPI("your private key") +>>> +>>> # let's measure the quota usage via the 'get_quota_info' endpoint +>>> quota_used = parityvend.get_quota_info().quota_used +>>> +>>> # these three calls will only send one request and return the cached response +>>> parityvend.get_country_from_ip("190.206.117.0") +Country('VE') +>>> parityvend.get_country_from_ip("190.206.117.0") +Country('VE') +>>> parityvend.get_country_from_ip("190.206.117.0") +Country('VE') +>>> +>>> new_quota_used = parityvend.get_quota_info().quota_used +>>> print(new_quota_used - quota_used) # this will show how much quota was used +1 +>>> # Thanks to the cache, only 1 request was sent, even though we called the function 3 times. +>>> +>>> # specify the cache options like this: +>>> ParityVendAPI( +... "your private key", +... cache_options={ +... "ttl": 60 * 60 * 8, +... "maxsize": 2048, +... }, +... ) +ParityVendAPI('...') +>>> +>>> +``` + +### Modifying Request Options + +The library uses the popular `requests` (for synchronous requests) and `aiohttp` (for asynchronous requests) libraries under the hood to make API calls. You can modify the behavior of these requests by setting the `request_options` keyword argument when initializing the handler. + +The `request_options` parameter should be a dictionary where the keys are keyword arguments accepted by the `requests` or `aiohttp` library, depending on whether you're using the synchronous or asynchronous handler. These options will be passed to each API request made by the handler, allowing you to customize various aspects of the requests, such as headers, timeouts, proxies, and more. For a complete list of available options and their descriptions, please refer to the documentation of the `requests` library (for synchronous handlers) or the `aiohttp` library (for asynchronous handlers). + +```python +>>> from parityvend_api import ParityVendAPI +>>> +>>> parityvend = ParityVendAPI("your private key", request_options={ + "headers": { + "User-Agent": "just an example" + } +}) +``` + +## Contributing + +Contributions to the ParityVend API Python Library are welcome and encouraged! We appreciate any feedback, bug reports, or feature requests that can help improve the library and make it more useful for the community. + +Please refer to the [`CONTRIBUTING.md`](https://github.com/ParityVend/parityvend_api_python/blob/main/CONTRIBUTING.md) file for more detailed guidelines on how to contribute, including the environment setup, coding standards, and testing requirements. + +## License + +The ParityVend API Python Library is released under the [Apache 2.0 License](https://www.apache.org/licenses/LICENSE-2.0). This means that you are free to use, modify, and distribute this library in your commercial or non-commercial software products, as long as you comply with the terms of the license. + +The Apache 2.0 License is a permissive open-source license that allows you to: + +- Use the library in your projects, both commercial and non-commercial. +- Modify the library's source code to suit your needs. +- Distribute the library or your modified versions of it. + +However, you must include a copy of the Apache 2.0 License and the copyright notice in any distribution of the library or modified versions of it. + +## Support + +If you encounter any issues or challenges during the integration process, we recommend referring to the ParityVend documentation as your first resource. It provides comprehensive guidance to help you troubleshoot and resolve any errors you may encounter. If you require further support, our team is always ready to assist you. Feel free to [contact us](https://www.ambeteco.com/ParityVend/contact-us/) for personalized assistance to ensure a seamless integration experience, or view the [FAQ section](https://www.ambeteco.com/ParityVend/support/) on the ParityVend Support page. \ No newline at end of file diff --git a/SECURITY.MD b/SECURITY.MD new file mode 100644 index 0000000..cf8a99b --- /dev/null +++ b/SECURITY.MD @@ -0,0 +1,43 @@ +# Security Policy for ParityVend API + +Welcome to the ParityVend API Security Policy. We take the security of our software seriously, and we appreciate your help in keeping our services safe for everyone. + +## Reporting a Vulnerability + +The security of ParityVend API is a top priority. If you believe you have found a security vulnerability in any supported version, please report it to us so we can work together to improve the security of ParityVend API. + +### How to Report a Vulnerability + +To report a security issue, please follow the steps below: + +1. Send an email to "tech AT ambeteco DOT com". +2. Include "Security Vulnerability Report" in the subject line. +3. Provide a detailed description of the vulnerability, including the following where applicable: + - The version(s) affected. + - A description of the vulnerability and its potential impact. + - Steps to reproduce or a proof-of-concept (PoC). + - Any relevant screenshots or supporting information. + +We kindly ask that you do not publicly disclose the vulnerability until we have had a reasonable amount of time to investigate and address it. + +### What to Expect After Reporting + +We will acknowledge your report within 5 business days and provide an estimated timeline for addressing the vulnerability. We may follow up with additional questions or requests for more information as we investigate the issue. + +Once the vulnerability has been resolved, we will credit you in our release notes and security advisories, unless you prefer to remain anonymous. + +## Security Update Policy + +When a vulnerability is discovered and deemed significant, we will release a patch as soon as possible, depending on the complexity of the fix. We will also provide a detailed report on our GitHub repository explaining the vulnerability, the fix, and steps for users to update. + +## Secure Development Practices + +We strive to follow secure development practices and regularly review our code for potential vulnerabilities. However, no software is perfect, and we appreciate the efforts of the security research community in helping us identify and address any issues. + +Please note that we reserve the right not to address vulnerabilities that we deem to be low-risk or impractical to fix. In such cases, we will provide an explanation for our decision. + +## Contact Us + +For any general queries related to the security policy, please contact us at "tech AT ambeteco DOT com". + +Thank you for supporting the security of ParityVend API. \ No newline at end of file diff --git a/examples/get_country_async.py b/examples/get_country_async.py new file mode 100644 index 0000000..7586df7 --- /dev/null +++ b/examples/get_country_async.py @@ -0,0 +1,46 @@ +import asyncio +from parityvend_api import AsyncParityVendAPI, env_get + + +async def main(): + parityvend = AsyncParityVendAPI(env_get("PARITYVEND_SECRET_KEY", "")) + + country = await parityvend.get_country_from_ip( + "190.206.117.0" + ) # example Venezuela IP + print(country) + + print("Code:", country.code) # prints 'VE' + print("Name:", country.name) # prints 'Venezuela' + print("Currency code:", country.currency_code) # prints 'VES' + print("Currency localized:", country.currency_localized) # prints 'VESBs.' + print("Currency symbol:", country.currency_symbol) # prints 'Bs.' + + invalid_country = await parityvend.get_country_from_ip("8.8.8.8") + print('"8.8.8.8" is:', invalid_country) + + await parityvend.deinit() # don't forget to close the session + + +asyncio.get_event_loop().run_until_complete(main()) + +# Output varies based on ParityVend account type (if the 'anti-VPN' feature is available): +# Paid account (with anti-VPN): 'Country Unknown (XX)' +# Free account (without anti-VPN): 'Country United States of America (US)' + +""" + +Example "Get country" (async) +Identifies the country from the given IP-address and prints it. + +--- Expected output --- +Country Venezuela (VE) +Code: VE +Name: Venezuela +Currency code: VES +Currency localized: VESBs. +Currency symbol: Bs. +"8.8.8.8" is: Country Unknown (XX) # will be printed on a paid account +"8.8.8.8" is: Country United States of America (US) # will be printed on a free account + +""" diff --git a/examples/get_country_sync.py b/examples/get_country_sync.py new file mode 100644 index 0000000..19a55fa --- /dev/null +++ b/examples/get_country_sync.py @@ -0,0 +1,36 @@ +from parityvend_api import ParityVendAPI, env_get + +parityvend = ParityVendAPI(env_get("PARITYVEND_SECRET_KEY", "")) + +country = parityvend.get_country_from_ip("190.206.117.0") # example Venezuela IP +print(country) + +print("Code:", country.code) # prints 'VE' +print("Name:", country.name) # prints 'Venezuela' +print("Currency code:", country.currency_code) # prints 'VES' +print("Currency localized:", country.currency_localized) # prints 'VESBs.' +print("Currency symbol:", country.currency_symbol) # prints 'Bs.' + +invalid_country = parityvend.get_country_from_ip("8.8.8.8") +print('"8.8.8.8" is:', invalid_country) + +# Output varies based on ParityVend account type (if the 'anti-VPN' feature is available): +# Paid account (with anti-VPN): 'Country Unknown (XX)' +# Free account (without anti-VPN): 'Country United States of America (US)' + +""" + +Example "Get country" (sync) +Identifies the country from the given IP-address and prints it. + +--- Expected output --- +Country Venezuela (VE) +Code: VE +Name: Venezuela +Currency code: VES +Currency localized: VESBs. +Currency symbol: Bs. +"8.8.8.8" is: Country Unknown (XX) # will be printed on a paid account +"8.8.8.8" is: Country United States of America (US) # will be printed on a free account + +""" diff --git a/examples/get_discount_async.py b/examples/get_discount_async.py new file mode 100644 index 0000000..d74e979 --- /dev/null +++ b/examples/get_discount_async.py @@ -0,0 +1,54 @@ +import asyncio +from parityvend_api import AsyncParityVendAPI, env_get + + +async def main(): + parityvend = AsyncParityVendAPI(env_get("PARITYVEND_SECRET_KEY", "")) + + discount = await parityvend.get_discount_from_ip( + "190.206.117.0" + ) # example Venezuela IP + print(discount) + + # You can access the items via dot notation or dictionary key lookups + print(discount.discount_str) + print(discount["discount_str"]) # all three will print "40.00%" + print(discount.get("discount_str")) + + print(discount.currency.code) + print(discount["currency"]["code"]) # both will print 'VES' + + await parityvend.deinit() # don't forget to close the session + + +asyncio.get_event_loop().run_until_complete(main()) + + +""" + +Example "Get discount" (async) +Identifies the country from the given IP-address and returns the configured discount. + +--- Expected output --- +Response( + { + "status": "ok", + "discount": 0.4, + "discount_str": "40.00%", + "coupon_code": "example_coupon", + "country": Country("VE"), + "currency": { + "code": "VES", + "symbol": "Bs.", + "localized_symbol": "VESBs.", + "conversion_rate": 36.092756259083146, + }, + } +) +40.00% +40.00% +40.00% +VES +VES + +""" diff --git a/examples/get_discount_sync.py b/examples/get_discount_sync.py new file mode 100644 index 0000000..daf2db4 --- /dev/null +++ b/examples/get_discount_sync.py @@ -0,0 +1,43 @@ +from parityvend_api import ParityVendAPI, env_get + +parityvend = ParityVendAPI(env_get("PARITYVEND_SECRET_KEY", "")) + +discount = parityvend.get_discount_from_ip("190.206.117.0") # example Venezuela IP +print(discount) + +# You can access the items via dot notation or dictionary key lookups +print(discount.discount_str) +print(discount["discount_str"]) # all three will print "40.00%" +print(discount.get("discount_str")) + +print(discount.currency.code) +print(discount["currency"]["code"]) # both will print 'VES' + +""" + +Example "Get discount" (sync) +Identifies the country from the given IP-address and returns the configured discount. + +--- Expected output --- +Response( + { + "status": "ok", + "discount": 0.4, + "discount_str": "40.00%", + "coupon_code": "example_coupon", + "country": Country("VE"), + "currency": { + "code": "VES", + "symbol": "Bs.", + "localized_symbol": "VESBs.", + "conversion_rate": 36.092756259083146, + }, + } +) +40.00% +40.00% +40.00% +VES +VES + +""" diff --git a/examples/get_discount_w_currency_async.py b/examples/get_discount_w_currency_async.py new file mode 100644 index 0000000..a388418 --- /dev/null +++ b/examples/get_discount_w_currency_async.py @@ -0,0 +1,76 @@ +import asyncio +from parityvend_api import AsyncParityVendAPI, env_get + + +async def main(): + parityvend = AsyncParityVendAPI(env_get("PARITYVEND_SECRET_KEY", "")) + + # example Venezuela IP + ip = "190.206.117.0" + + # by default, get the conversion rate with USD as base + discount_usd = await parityvend.get_discount_from_ip(ip) + print("USD discount:", discount_usd) + + # the base currency can be changed + discount_gbp = await parityvend.get_discount_from_ip(ip, base_currency="GBP") + print("GBP discount:", discount_gbp) + + print() + + print( + f"Conversion rate of 1 USD to {discount_usd.currency.code}:", + discount_usd.currency.conversion_rate, + ) + print( + f"Conversion rate of 1 GBP to {discount_gbp.currency.code}:", + discount_gbp.currency.conversion_rate, + ) + + await parityvend.deinit() # don't forget to close the session + + +asyncio.get_event_loop().run_until_complete(main()) + + +""" + +Example "Get discount with custom base currency" (async) +Identifies the country from the given IP-address and returns the configured discount with the custom base currency. + +--- Expected output --- +USD discount: Response( + { + "status": "ok", + "discount": 0.4, + "discount_str": "40.00%", + "coupon_code": "example_coupon", + "country": Country("VE"), + "currency": { + "code": "VES", + "symbol": "Bs.", + "localized_symbol": "VESBs.", + "conversion_rate": 36.092756259083146, + }, + } +) +GBP discount: Response( + { + "status": "ok", + "discount": 0.4, + "discount_str": "40.00%", + "coupon_code": "example_coupon", + "country": Country("VE"), + "currency": { + "code": "VES", + "symbol": "Bs.", + "localized_symbol": "VESBs.", + "conversion_rate": 45.71277583460257, + }, + } +) + +Conversion rate of 1 USD to VES: 36.092756259083146 +Conversion rate of 1 GBP to VES: 45.71277583460257 + +""" diff --git a/examples/get_discount_w_currency_sync.py b/examples/get_discount_w_currency_sync.py new file mode 100644 index 0000000..830f04f --- /dev/null +++ b/examples/get_discount_w_currency_sync.py @@ -0,0 +1,68 @@ +from parityvend_api import ParityVendAPI, env_get + +parityvend = ParityVendAPI(env_get("PARITYVEND_SECRET_KEY", "")) + +# example Venezuela IP +ip = "190.206.117.0" + +# by default, get the conversion rate with USD as base +discount_usd = parityvend.get_discount_from_ip(ip) +print("USD discount:", discount_usd) + +# the base currency can be changed +discount_gbp = parityvend.get_discount_from_ip(ip, base_currency="GBP") +print("GBP discount:", discount_gbp) + +print() + +print( + f"Conversion rate of 1 USD to {discount_usd.currency.code}:", + discount_usd.currency.conversion_rate, +) +print( + f"Conversion rate of 1 GBP to {discount_gbp.currency.code}:", + discount_gbp.currency.conversion_rate, +) + + +""" + +Example "Get discount with custom base currency" (sync) +Identifies the country from the given IP-address and returns the configured discount with the custom base currency. + +--- Expected output --- +USD discount: Response( + { + "status": "ok", + "discount": 0.4, + "discount_str": "40.00%", + "coupon_code": "example_coupon", + "country": Country("VE"), + "currency": { + "code": "VES", + "symbol": "Bs.", + "localized_symbol": "VESBs.", + "conversion_rate": 36.092756259083146, + }, + } +) +GBP discount: Response( + { + "status": "ok", + "discount": 0.4, + "discount_str": "40.00%", + "coupon_code": "example_coupon", + "country": Country("VE"), + "currency": { + "code": "VES", + "symbol": "Bs.", + "localized_symbol": "VESBs.", + "conversion_rate": 45.71277583460257, + }, + } +) + +Conversion rate of 1 USD to VES: 36.092756259083146 +Conversion rate of 1 GBP to VES: 45.71277583460257 + +""" diff --git a/examples/hello_world_async.py b/examples/hello_world_async.py new file mode 100644 index 0000000..2d270b4 --- /dev/null +++ b/examples/hello_world_async.py @@ -0,0 +1,21 @@ +from parityvend_api import AsyncParityVendAPI, env_get +import asyncio + + +async def main(): + parityvend = AsyncParityVendAPI(env_get("PARITYVEND_SECRET_KEY", "")) + print(await parityvend.get_quota_info()) + await parityvend.deinit() # close the session when you finish working + + +asyncio.get_event_loop().run_until_complete(main()) + +""" + +Example "Hello World" (async) +Gets your ParityVend account quota information and prints it. + +--- Expected output --- +Response({'status': 'ok', 'quota_limit': 1000000, 'quota_used': 3121, 'quota_left': 996879}) + +""" diff --git a/examples/hello_world_sync.py b/examples/hello_world_sync.py new file mode 100644 index 0000000..7b5b703 --- /dev/null +++ b/examples/hello_world_sync.py @@ -0,0 +1,14 @@ +from parityvend_api import ParityVendAPI, env_get + +parityvend = ParityVendAPI(env_get("PARITYVEND_SECRET_KEY", "")) +print(parityvend.get_quota_info()) + +""" + +Example "Hello World" (sync) +Gets your ParityVend account quota information and prints it. + +--- Expected output --- +Response({'status': 'ok', 'quota_limit': 1000000, 'quota_used': 3121, 'quota_left': 996879}) + +""" diff --git a/images/_cover_gh.png b/images/_cover_gh.png new file mode 100644 index 0000000..70647c1 Binary files /dev/null and b/images/_cover_gh.png differ diff --git a/parityvend_api/__init__.py b/parityvend_api/__init__.py new file mode 100644 index 0000000..b59dca5 --- /dev/null +++ b/parityvend_api/__init__.py @@ -0,0 +1,13 @@ +from .handler import ParityVendAPI +from .handler_async import AsyncParityVendAPI +from .utils import env_get +from .objects import COUNTRIES, get_country_by_code +from .exceptions import QuotaExceededError, APIError, ProcessingError, ConnectionError + +# ParityVend API - Official Python Library +# View API reference at https://www.ambeteco.com/ParityVend/docs/api_reference.html +# View full docs at https://www.ambeteco.com/ParityVend/docs/index.html + +# ParityVend API - Official Python Library +# View API reference at https://www.ambeteco.com/ParityVend/docs/api_reference.html +# View full docs at https://www.ambeteco.com/ParityVend/docs/index.html diff --git a/parityvend_api/cache/__init__.py b/parityvend_api/cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/parityvend_api/cache/default.py b/parityvend_api/cache/default.py new file mode 100644 index 0000000..427a246 --- /dev/null +++ b/parityvend_api/cache/default.py @@ -0,0 +1,20 @@ +import cachetools + +from .interface import CacheInterface + + +class DefaultCache(CacheInterface): + def __init__(self, **cache_options): + self.cache = cachetools.TTLCache(**cache_options) + + def __contains__(self, key): + return self.cache.__contains__(key) + + def __setitem__(self, key, value): + return self.cache.__setitem__(key, value) + + def __getitem__(self, key): + return self.cache.__getitem__(key) + + def __delitem__(self, key): + return self.cache.__delitem__(key) diff --git a/parityvend_api/cache/interface.py b/parityvend_api/cache/interface.py new file mode 100644 index 0000000..202f9de --- /dev/null +++ b/parityvend_api/cache/interface.py @@ -0,0 +1,19 @@ +import abc + + +class CacheInterface(metaclass=abc.ABCMeta): + @abc.abstractmethod + def __contains__(self, key): + raise NotImplementedError + + @abc.abstractmethod + def __setitem__(self, key, value): + raise NotImplementedError + + @abc.abstractmethod + def __getitem__(self, key): + raise NotImplementedError + + @abc.abstractmethod + def __delitem__(self, key): + raise NotImplementedError diff --git a/parityvend_api/config.py b/parityvend_api/config.py new file mode 100644 index 0000000..a33fa54 --- /dev/null +++ b/parityvend_api/config.py @@ -0,0 +1,2 @@ +API_URL = "https://api.parityvend.com" +VERSION = "1.0.0" diff --git a/parityvend_api/data.py b/parityvend_api/data.py new file mode 100644 index 0000000..d7ee964 --- /dev/null +++ b/parityvend_api/data.py @@ -0,0 +1,275 @@ +from typing import Dict, Tuple + +COUNTRIES_META: Dict[ + str, + Tuple[str, str, str, str, str], +] = { + "AC": ("Ascension Island", "🇦🇨", "SHP", "£", "SHP£"), + "AD": ("Andorra", "🇦🇩", "EUR", "€", "€"), + "AE": ("United Arab Emirates", "🇦🇪", "AED", "د.إ", "د.إ"), + "AF": ("Afghanistan", "🇦🇫", "AFN", "؋", "AFN"), + "AG": ("Antigua and Barbuda", "🇦🇬", "XCD", "$", "XCD$"), + "AI": ("Anguilla", "🇦🇮", "XCD", "$", "XCD$"), + "AL": ("Albania", "🇦🇱", "ALL", "L", "ALL"), + "AM": ("Armenia", "🇦🇲", "AMD", "֏", "AMD"), + "AN": ("Netherlands Antilles", "🇦🇳", "ANG", "ƒ", "ANGƒ"), + "AO": ("Angola", "🇦🇴", "AOA", "Kz", "AOA"), + "AQ": ("Antarctica", "🇦🇶", "USD", "$", "USD$"), + "AR": ("Argentina", "🇦🇷", "ARS", "$", "ARS$"), + "AS": ("American Samoa", "🇦🇸", "USD", "$", "USD$"), + "AT": ("Austria", "🇦🇹", "EUR", "€", "€"), + "AU": ("Australia", "🇦🇺", "AUD", "$", "AUD$"), + "AW": ("Aruba", "🇦🇼", "AWG", "ƒ", "AWGƒ"), + "AX": ("Åland Islands", "🇦🇽", "EUR", "€", "€"), + "AZ": ("Azerbaijan", "🇦🇿", "AZN", "₼", "AZN₼"), + "BA": ("Bosnia and Herzegovina", "🇧🇦", "BAM", "KM", "BAM"), + "BB": ("Barbados", "🇧🇧", "BBD", "$", "BBD$"), + "BD": ("Bangladesh", "🇧🇩", "BDT", "৳", "BDT৳"), + "BE": ("Belgium", "🇧🇪", "EUR", "€", "€"), + "BF": ("Burkina Faso", "🇧🇫", "XOF", "CFA", "XOFCFA"), + "BG": ("Bulgaria", "🇧🇬", "BGN", "лв", "BGNлв"), + "BH": ("Bahrain", "🇧🇭", "BHD", ".د.ب", "BHD.د.ب"), + "BI": ("Burundi", "🇧🇮", "BIF", "FBu", "BIF"), + "BJ": ("Benin", "🇧🇯", "XOF", "CFA", "XOFCFA"), + "BL": ("Saint Barthélemy", "🇧🇱", "EUR", "€", "€"), + "BM": ("Bermuda", "🇧🇲", "BMD", "$", "BMD$"), + "BN": ("Brunei Darussalam", "🇧🇳", "BND", "$", "BND$"), + "BO": ("Bolivia", "🇧🇴", "BOB", "Bs.", "BOB"), + "BQ": ("Bonaire, Sint Eustatius and Saba", "🇧🇶", "EUR", "€", "€"), + "BR": ("Brazil", "🇧🇷", "BRL", "R$", "BRLR$"), + "BS": ("Bahamas", "🇧🇸", "BSD", "$", "BSD$"), + "BT": ("Bhutan", "🇧🇹", "BTN", "Nu.", "BTN"), + "BV": ("Bouvet Island", "🇧🇻", "NOK", "kr", "NOKkr"), + "BW": ("Botswana", "🇧🇼", "BWP", "P", "BWPP"), + "BY": ("Belarus", "🇧🇾", "BYN", "Br", "BYNBr"), + "BZ": ("Belize", "🇧🇿", "BZD", "$", "BZD$"), + "CA": ("Canada", "🇨🇦", "CAD", "$", "CAD$"), + "CC": ("Cocos (Keeling) Islands", "🇨🇨", "AUD", "$", "AUD$"), + "CD": ("Democratic Republic of the Congo", "🇨🇩", "CDF", "FC", "CDF"), + "CF": ("Central African Republic", "🇨🇫", "XAF", "₣", "XAF₣"), + "CG": ("Republic of the Congo", "🇨🇬", "XAF", "₣", "XAF₣"), + "CH": ("Switzerland", "🇨🇭", "CHF", "CHF", "CHF"), + "CI": ("Côte d'Ivoire", "🇨🇮", "XOF", "CFA", "XOFCFA"), + "CK": ("Cook Islands", "🇨🇰", "NZD", "$", "NZD$"), + "CL": ("Chile", "🇨🇱", "CLP", "$", "CLP$"), + "CM": ("Cameroon", "🇨🇲", "XAF", "₣", "XAF₣"), + "CN": ("China", "🇨🇳", "CNY", "¥", "CNY¥"), + "CO": ("Colombia", "🇨🇴", "COP", "$", "COP$"), + "CR": ("Costa Rica", "🇨🇷", "CRC", "₡", "CRC₡"), + "CS": ("Serbia and Montenegro", "🇷🇸", "RSD", "дин.", "RSDдин."), + "CU": ("Cuba", "🇨🇺", "CUP", "$", "CUP$"), + "CV": ("Cabo Verde", "🇨🇻", "CVE", "$", "CVE$"), + "CW": ("Curaçao", "🇨🇼", "ANG", "ƒ", "ANGƒ"), + "CX": ("Christmas Island", "🇨🇽", "AUD", "$", "AUD$"), + "CY": ("Cyprus", "🇨🇾", "EUR", "€", "€"), + "CZ": ("Czech Republic", "🇨🇿", "CZK", "Kč", "CZKKč"), + "DE": ("Germany", "🇩🇪", "EUR", "€", "€"), + "DJ": ("Djibouti", "🇩🇯", "DJF", "Fdj", "DJFFdj"), + "DK": ("Denmark", "🇩🇰", "DKK", "kr", "DKKkr"), + "DM": ("Dominica", "🇩🇲", "XCD", "$", "XCD$"), + "DO": ("Dominican Republic", "🇩🇴", "DOP", "$", "DOP$"), + "DZ": ("Algeria", "🇩🇿", "DZD", "د.ج", "DZDد.ج"), + "EC": ("Ecuador", "🇪🇨", "USD", "$", "USD$"), + "EE": ("Estonia", "🇪🇪", "EUR", "€", "€"), + "EG": ("Egypt", "🇪🇬", "EGP", "£", "EGP£"), + "EH": ("Western Sahara", "🇪🇭", "MAD", "د.م.", "MADد.م."), + "ER": ("Eritrea", "🇪🇷", "ERN", "Nfk", "ERN"), + "ES": ("Spain", "🇪🇸", "EUR", "€", "€"), + "ET": ("Ethiopia", "🇪🇹", "ETB", "Br", "ETBBr"), + "FI": ("Finland", "🇫🇮", "EUR", "€", "€"), + "FJ": ("Fiji", "🇫🇯", "FJD", "$", "FJD$"), + "FK": ("Falkland Islands (Malvinas)", "🇫🇰", "FKP", "£", "FKP£"), + "FM": ("Federated States of Micronesia", "🇫🇲", "USD", "$", "USD$"), + "FO": ("Faroe Islands", "🇫🇴", "DKK", "kr", "DKKkr"), + "FR": ("France", "🇫🇷", "EUR", "€", "€"), + "GA": ("Gabon", "🇬🇦", "XAF", "₣", "XAF₣"), + "GB": ("United Kingdom", "🇬🇧", "GBP", "£", "GBP£"), + "GD": ("Grenada", "🇬🇩", "XCD", "$", "XCD$"), + "GE": ("Georgia", "🇬🇪", "GEL", "₾", "GEL₾"), + "GF": ("French Guiana", "🇬🇫", "EUR", "€", "€"), + "GG": ("Guernsey", "🇬🇬", "GBP", "£", "GBP£"), + "GH": ("Ghana", "🇬🇭", "GHS", "₵", "GHS₵"), + "GI": ("Gibraltar", "🇬🇮", "GIP", "£", "GIP£"), + "GL": ("Greenland", "🇬🇱", "DKK", "kr", "DKKkr"), + "GM": ("Gambia", "🇬🇲", "GMD", "D", "GMDD"), + "GN": ("Guinea", "🇬🇳", "GNF", "FG", "GNFFG"), + "GP": ("Guadeloupe", "🇬🇵", "EUR", "€", "€"), + "GQ": ("Equatorial Guinea", "🇬🇶", "XAF", "₣", "XAF₣"), + "GR": ("Greece", "🇬🇷", "EUR", "€", "€"), + "GS": ( + "South Georgia and the South Sandwich Islands", + "🇬🇸", + "GBP", + "£", + "GBP£", + ), + "GT": ("Guatemala", "🇬🇹", "GTQ", "Q", "GTQQ"), + "GU": ("Guam", "🇬🇺", "USD", "$", "USD$"), + "GW": ("Guinea-Bissau", "🇬🇼", "XOF", "CFA", "XOFCFA"), + "GY": ("Guyana", "🇬🇾", "GYD", "$", "GYD$"), + "HK": ("Hong Kong", "🇭🇰", "HKD", "$", "HKD$"), + "HM": ("Heard Island and McDonald Islands", "🇭🇲", "AUD", "$", "AUD$"), + "HN": ("Honduras", "🇭🇳", "HNL", "L", "HNLL"), + "HR": ("Croatia", "🇭🇷", "HRK", "kn", "HRKkn"), + "HT": ("Haiti", "🇭🇹", "HTG", "G", "HTGG"), + "HU": ("Hungary", "🇭🇺", "HUF", "Ft", "HUF"), + "IC": ("Canary Islands", "🇮🇨", "EUR", "€", "€"), + "ID": ("Indonesia", "🇮🇩", "IDR", "Rp", "IDRRp"), + "IE": ("Ireland", "🇮🇪", "EUR", "€", "€"), + "IL": ("Israel", "🇮🇱", "ILS", "₪", "ILS₪"), + "IM": ("Isle of Man", "🇮🇲", "GBP", "£", "GBP£"), + "IN": ("India", "🇮🇳", "INR", "₹", "INR₹"), + "IO": ("British Indian Ocean Territory", "🇮🇴", "USD", "$", "USD$"), + "IQ": ("Iraq", "🇮🇶", "IQD", "ع.د", "IQDع.د"), + "IR": ("Iran", "🇮🇷", "IRR", "﷼", "IRR﷼"), + "IS": ("Iceland", "🇮🇸", "ISK", "kr", "ISKkr"), + "IT": ("Italy", "🇮🇹", "EUR", "€", "€"), + "JE": ("Jersey", "🇯🇪", "GBP", "£", "GBP£"), + "JM": ("Jamaica", "🇯🇲", "JMD", "$", "JMD$"), + "JO": ("Jordan", "🇯🇴", "JOD", "د.ا", "JODد.ا"), + "JP": ("Japan", "🇯🇵", "JPY", "¥", "JPY¥"), + "KE": ("Kenya", "🇰🇪", "KES", "KSh", "KESKSh"), + "KG": ("Kyrgyzstan", "🇰🇬", "KGS", "с", "KGSс"), + "KH": ("Cambodia", "🇰🇭", "KHR", "៛", "KHR៛"), + "KI": ("Kiribati", "🇰🇮", "AUD", "$", "AUD$"), + "KM": ("Comoros", "🇰🇲", "KMF", "CF", "KMFCF"), + "KN": ("Saint Kitts and Nevis", "🇰🇳", "XCD", "$", "XCD$"), + "KP": ("North Korea", "🇰🇵", "KPW", "₩", "KPW₩"), + "KR": ("South Korea", "🇰🇷", "KRW", "₩", "KRW₩"), + "KW": ("Kuwait", "🇰🇼", "KWD", "د.ك", "KWDد.ك"), + "KY": ("Cayman Islands", "🇰🇾", "KYD", "$", "KYD$"), + "KZ": ("Kazakhstan", "🇰🇿", "KZT", "₸", "KZT₸"), + "LA": ("Laos", "🇱🇦", "LAK", "₭", "LAK₭"), + "LB": ("Lebanon", "🇱🇧", "LBP", "ل.ل", "LBPl.ل"), + "LC": ("Saint Lucia", "🇱🇨", "XCD", "$", "XCD$"), + "LI": ("Liechtenstein", "🇱🇮", "CHF", "CHF", "CHF"), + "LK": ("Sri Lanka", "🇱🇰", "LKR", "රු", "LKRRs"), + "LR": ("Liberia", "🇱🇷", "LRD", "$", "LRD$"), + "LS": ("Lesotho", "🇱🇸", "LSL", "L", "LSLL"), + "LT": ("Lithuania", "🇱🇹", "EUR", "€", "€"), + "LU": ("Luxembourg", "🇱🇺", "EUR", "€", "€"), + "LV": ("Latvia", "🇱🇻", "EUR", "€", "€"), + "LY": ("Libya", "🇱🇾", "LYD", "ل.د", "LYDل.د"), + "MA": ("Morocco", "🇲🇦", "MAD", "د.م.", "MADد.م."), + "MC": ("Monaco", "🇲🇨", "EUR", "€", "€"), + "MD": ("Moldova", "🇲🇩", "MDL", "L", "MDLL"), + "ME": ("Montenegro", "🇲🇪", "EUR", "€", "€"), + "MF": ("Saint Martin (French part)", "🇲🇫", "EUR", "€", "€"), + "MG": ("Madagascar", "🇲🇬", "MGA", "Ar", "MGAAr"), + "MH": ("Marshall Islands", "🇲🇭", "USD", "$", "USD$"), + "MK": ("North Macedonia", "🇲🇰", "MKD", "ден", "MKDден"), + "ML": ("Mali", "🇲🇱", "XOF", "CFA", "XOFCFA"), + "MM": ("Myanmar", "🇲🇲", "MMK", "K", "MMKK"), + "MN": ("Mongolia", "🇲🇳", "MNT", "₮", "MNT₮"), + "MO": ("Macao", "🇲🇴", "MOP", "MOP$", "MOPMOP$"), + "MP": ("Northern Mariana Islands", "🇲🇵", "USD", "$", "USD$"), + "MQ": ("Martinique", "🇲🇶", "EUR", "€", "€"), + "MR": ("Mauritania", "🇲🇷", "MRU", "UM", "MRUUM"), + "MS": ("Montserrat", "🇲🇸", "XCD", "$", "XCD$"), + "MT": ("Malta", "🇲🇹", "EUR", "€", "€"), + "MU": ("Mauritius", "🇲🇺", "MUR", "₨", "MUR₨"), + "MV": ("Maldives", "🇲🇻", "MVR", "ރ.", "MVRރ."), + "MW": ("Malawi", "🇲🇼", "MWK", "MK", "MWKMK"), + "MX": ("Mexico", "🇲🇽", "MXN", "$", "MXN$"), + "MY": ("Malaysia", "🇲🇾", "MYR", "RM", "MYRRM"), + "MZ": ("Mozambique", "🇲🇿", "MZN", "MT", "MZNMt"), + "NA": ("Namibia", "🇳🇦", "NAD", "$", "NAD$"), + "NC": ("New Caledonia", "🇳🇨", "XPF", "₣", "XPF₣"), + "NE": ("Niger", "🇳🇪", "XOF", "CFA", "XOFCFA"), + "NF": ("Norfolk Island", "🇳🇫", "AUD", "$", "AUD$"), + "NG": ("Nigeria", "🇳🇬", "NGN", "₦", "NGN₦"), + "NI": ("Nicaragua", "🇳🇮", "NIO", "C$", "NIOC$"), + "NL": ("Netherlands", "🇳🇱", "EUR", "€", "€"), + "NO": ("Norway", "🇳🇴", "NOK", "kr", "NOKkr"), + "NP": ("Nepal", "🇳🇵", "NPR", "₨", "NPR₨"), + "NR": ("Nauru", "🇳🇷", "AUD", "$", "AUD$"), + "NU": ("Niue", "🇳🇺", "NZD", "$", "NZD$"), + "NZ": ("New Zealand", "🇳🇿", "NZD", "$", "NZD$"), + "OM": ("Oman", "🇴🇲", "OMR", "ر.ع.", "OMRر.ع."), + "PA": ("Panama", "🇵🇦", "PAB", "B/.", "PABB/."), + "PE": ("Peru", "🇵🇪", "PEN", "S/.", "PENS/."), + "PF": ("French Polynesia", "🇵🇫", "XPF", "₣", "XPF₣"), + "PG": ("Papua New Guinea", "🇵🇬", "PGK", "K", "PGKK"), + "PH": ("Philippines", "🇵🇭", "PHP", "₱", "PHP₱"), + "PK": ("Pakistan", "🇵🇰", "PKR", "₨", "PKR₨"), + "PL": ("Poland", "🇵🇱", "PLN", "zł", "PLNzł"), + "PM": ("Saint Pierre and Miquelon", "🇵🇲", "EUR", "€", "€"), + "PN": ("Pitcairn", "🇵🇳", "NZD", "$", "NZD$"), + "PR": ("Puerto Rico", "🇵🇷", "USD", "$", "USD$"), + "PS": ("Palestine", "🇵🇸", "ILS", "₪", "ILS₪"), + "PT": ("Portugal", "🇵🇹", "EUR", "€", "€"), + "PW": ("Palau", "🇵🇼", "USD", "$", "USD$"), + "PY": ("Paraguay", "🇵🇾", "PYG", "₲", "PYG₲"), + "QA": ("Qatar", "🇶🇦", "QAR", "ر.ق", "QARر.ق"), + "RE": ("Réunion", "🇷🇪", "EUR", "€", "€"), + "RO": ("Romania", "🇷🇴", "RON", "lei", "RONlei"), + "RS": ("Serbia", "🇷🇸", "RSD", "дин.", "RSDдин."), + "RU": ("Russia", "🇷🇺", "RUB", "₽", "RUB₽"), + "RW": ("Rwanda", "🇷🇼", "RWF", "FRw", "RWFFRw"), + "SA": ("Saudi Arabia", "🇸🇦", "SAR", "ر.س", "SARر.س"), + "SB": ("Solomon Islands", "🇸🇧", "SBD", "$", "SBD$"), + "SC": ("Seychelles", "🇸🇨", "SCR", "₨", "SCR₨"), + "SD": ("Sudan", "🇸🇩", "SDG", "ج.س.", "SDGج.س."), + "SE": ("Sweden", "🇸🇪", "SEK", "kr", "SEKkr"), + "SG": ("Singapore", "🇸🇬", "SGD", "$", "SGD$"), + "SH": ( + "Saint Helena, Ascension and Tristan da Cunha", + "🇸🇭", + "SHP", + "£", + "SHP£", + ), + "SI": ("Slovenia", "🇸🇮", "EUR", "€", "€"), + "SJ": ("Svalbard and Jan Mayen", "🇸🇯", "NOK", "kr", "NOKkr"), + "SK": ("Slovakia", "🇸🇰", "EUR", "€", "€"), + "SL": ("Sierra Leone", "🇸🇱", "SLL", "Le", "SLLLe"), + "SM": ("San Marino", "🇸🇲", "EUR", "€", "€"), + "SN": ("Senegal", "🇸🇳", "XOF", "CFA", "XOFCFA"), + "SO": ("Somalia", "🇸🇴", "SOS", "S", "SOSS"), + "SR": ("Suriname", "🇸🇷", "SRD", "$", "SRD$"), + "SS": ("South Sudan", "🇸🇸", "SSP", "£", "SSP£"), + "ST": ("Sao Tome and Principe", "🇸🇹", "STN", "Db", "STNDb"), + "SV": ("El Salvador", "🇸🇻", "USD", "$", "USD$"), + "SX": ("Sint Maarten (Dutch part)", "🇸🇽", "ANG", "ƒ", "ANGƒ"), + "SY": ("Syria", "🇸🇾", "SYP", "ل.س", "SYPl.س"), + "SZ": ("Eswatini", "🇸🇿", "SZL", "L", "SZLL"), + "TA": ("Tristan da Cunha", "🇹🇦", "SHP", "£", "SHP£"), + "TC": ("Turks and Caicos Islands", "🇹🇨", "USD", "$", "USD$"), + "TD": ("Chad", "🇹🇩", "XAF", "₣", "XAF₣"), + "TF": ("French Southern Territories", "🇹🇫", "EUR", "€", "€"), + "TG": ("Togo", "🇹🇬", "XOF", "CFA", "XOFCFA"), + "TH": ("Thailand", "🇹🇭", "THB", "฿", "THB฿"), + "TJ": ("Tajikistan", "🇹🇯", "TJS", "ЅМ", "TJSЅМ"), + "TK": ("Tokelau", "🇹🇰", "NZD", "$", "NZD$"), + "TL": ("Timor-Leste", "🇹🇱", "USD", "$", "USD$"), + "TM": ("Turkmenistan", "🇹🇲", "TMT", "m", "TMTm"), + "TN": ("Tunisia", "🇹🇳", "TND", "د.ت", "TNDد.ت"), + "TO": ("Tonga", "🇹🇴", "TOP", "T$", "TOPT$"), + "TR": ("Turkey", "🇹🇷", "TRY", "₺", "TRY₺"), + "TT": ("Trinidad and Tobago", "🇹🇹", "TTD", "TT$", "TTD$"), + "TV": ("Tuvalu", "🇹🇻", "AUD", "$", "AUD$"), + "TW": ("Taiwan", "🇹🇼", "TWD", "NT$", "TWDNT$"), + "TZ": ("Tanzania", "🇹🇿", "TZS", "TSh", "TZSTSh"), + "UA": ("Ukraine", "🇺🇦", "UAH", "₴", "UAH₴"), + "UG": ("Uganda", "🇺🇬", "UGX", "USh", "UGXUSh"), + "UM": ("United States Minor Outlying Islands", "🇺🇲", "USD", "$", "USD$"), + "US": ("United States of America", "🇺🇸", "USD", "$", "USD$"), + "UY": ("Uruguay", "🇺🇾", "UYU", "$", "UYU$"), + "UZ": ("Uzbekistan", "🇺🇿", "UZS", "с", "UZSс"), + "VA": ("Vatican City", "🇻🇦", "EUR", "€", "€"), + "VC": ("Saint Vincent and the Grenadines", "🇻🇨", "XCD", "$", "XCD$"), + "VE": ("Venezuela", "🇻🇪", "VES", "Bs.", "VESBs."), + "VG": ("British Virgin Islands", "🇻🇬", "USD", "$", "USD$"), + "VI": ("U.S. Virgin Islands", "🇻🇮", "USD", "$", "USD$"), + "VN": ("Vietnam", "🇻🇳", "VND", "₫", "VND₫"), + "VU": ("Vanuatu", "🇻🇺", "VUV", "VT", "VUVVT"), + "WF": ("Wallis and Futuna", "🇼🇫", "XPF", "₣", "XPF₣"), + "WS": ("Samoa", "🇼🇸", "WST", "T", "WSTT"), + "XK": ("Kosovo", "🇽🇰", "ALL", "L", "ALL"), + "YE": ("Yemen", "🇾🇪", "YER", "﷼", "YER﷼"), + "YT": ("Mayotte", "🇾🇹", "EUR", "€", "€"), + "ZA": ("South Africa", "🇿🇦", "ZAR", "R", "ZARR"), + "ZM": ("Zambia", "🇿🇲", "ZMW", "ZK", "ZMWZK"), + "ZW": ("Zimbabwe", "🇿🇼", "ZWL", "$", "ZWL$"), + "XX": ("Unknown", "", "", "", ""), +} diff --git a/parityvend_api/exceptions.py b/parityvend_api/exceptions.py new file mode 100644 index 0000000..f7a4d46 --- /dev/null +++ b/parityvend_api/exceptions.py @@ -0,0 +1,22 @@ +class QuotaExceededError(Exception): + """Error indicating that users monthly request quota has been passed.""" + + pass + + +class APIError(Exception): + """Error indicating that an API error has occurred (meaning something not expected happened, like a 50x-error).""" + + pass + + +class ProcessingError(Exception): + """Error indicating that an API processing error has occurred (like input data is invalid)""" + + pass + + +class ConnectionError(Exception): + """Error indicating that some sort of an internet connection error has occurred.""" + + pass diff --git a/parityvend_api/handler.py b/parityvend_api/handler.py new file mode 100644 index 0000000..7639efc --- /dev/null +++ b/parityvend_api/handler.py @@ -0,0 +1,468 @@ +import json +import logging +from ipaddress import IPv4Address, IPv6Address +from typing import Callable, Optional, Union + +import requests + +from .cache.default import DefaultCache +from .cache.interface import CacheInterface +from .config import API_URL +from .exceptions import APIError, ConnectionError, ProcessingError, QuotaExceededError +from .objects import COUNTRIES, Country, Discounts, Response + +logger = logging.getLogger("parityvend") + + +class ParityVendAPI: + def __init__( + self, + private_key: str, + request_options: Optional[dict] = None, + cache_instance: Optional[CacheInterface] = None, + cache_options: Optional[dict] = None, + json_loads: Optional[Callable[[str], dict]] = None, + cache_on_error: bool = True, + log_api_errors: bool = True, + raise_exc_on_error: bool = True, + ): + """ + Initialize the ParityVendAPI object. + + Args: + private_key (str): Your ParityVend API private key. + request_options (Optional[dict], optional): Additional options to pass to the requests library. Defaults to None. + cache_instance (Optional[CacheInterface], optional): An instance of a custom cache implementation. Defaults to None. + cache_options (Optional[dict], optional): Options to pass to the default cache implementation. Defaults to None. + json_loads (Optional[Callable[[str], dict]], optional): A custom function to use for loading JSON data. Defaults to None. + cache_on_error (bool, optional): Whether to cache API responses on error. Defaults to True. + log_api_errors (bool, optional): Whether to log API errors. Defaults to True. + raise_exc_on_error (bool, optional): Whether to raise an exception on API errors. Defaults to True. + """ + self.private_key: str = private_key + + self.request_options: dict = self.get_default_request_options() + if request_options: + self.request_options.update(request_options) + + if cache_instance: + self.cache: CacheInterface = cache_instance + else: + self.cache_options: dict = self.get_default_cache_options() + if cache_options: + self.cache_options.update(cache_options) + + self.cache: CacheInterface = DefaultCache(**self.cache_options) + + self.json_loads: Callable[[str], dict] = json.loads + if json_loads: + self.json_loads: Callable[[str], dict] = json_loads + + self.session: requests.Session = requests.Session() + + self.cache_on_error: bool = cache_on_error + self.log_api_errors: bool = log_api_errors + self.raise_exc_on_error: bool = raise_exc_on_error + + def __repr__(self) -> str: + return f"ParityVendAPI('{self.private_key[:6]}...')" + + @staticmethod + def get_default_request_options() -> dict: + """ + Get the default request options for the API client. + + Returns: + dict: A dictionary containing the default request options. + """ + return { + "headers": { + "User-Agent": "Python ParityVend API Client/1.0.0 (+https://www.ambeteco.com/ParityVend/docs/index.html)" + } + } + + @staticmethod + def get_default_cache_options() -> dict: + """ + Get the default cache options for the API client. + + Returns: + dict: A dictionary containing the default cache options. + """ + return {"maxsize": 4096, "ttl": 24 * 60 * 60} + + def api_request( + self, method: str, url: str, request_options: dict + ) -> Union[dict, str, None]: + """ + Make a request to the ParityVend API. + + Args: + method (str): The HTTP method to use (e.g., 'get', 'post', 'put', 'delete'). + url (str): The URL to send the request to. + request_options (dict): Additional options to pass to the requests library. + + Raises: + APIError: If the API returns a non-200 status code or an invalid JSON payload. + ConnectionError: If there is an error connecting to the API. + ProcessingError: If there is an error with the input data. + + Returns: + Union[dict, str, None]: The response from the API, either as a dictionary (for JSON responses), a string (for non-JSON responses), or None (if there was an error). + """ + try: + r = self.session.request(method, url, **request_options) + + if r.status_code != 200: + logger.error( + f"ParityVend API ({method.upper()}: {url}) returned non-200 status code ({r.status_code=}). See API response below:\n{r.text}\n" + ) + raise APIError("ParityVend API returned non-200 status code.") + + if r.headers["Content-Type"] == "application/json": + result = self.json_loads(r.text) + + if self.raise_exc_on_error and result["status"] == "error": + raise ProcessingError( + f"ParityVend API ({url}) returned error:\n{result}\n" + ) + + return result + return r.text + + except requests.exceptions.RequestException: + raise ConnectionError( + "Not able to reach the ParityVend API. Check your internet connection." + ) + + except json.JSONDecodeError: + logger.error( + f"ParityVend API ({method.upper()}: {url}) returned invalid JSON payload ({r.status_code=}). See API response below:\n{r.text}\n" + ) + raise APIError("ParityVend API returned invalid JSON payload.") + + def base_call( + self, + method: str, + endpoint_name: str, + path: str, + input_vars: dict, + cache_key: tuple, + timeout: Union[int, float, None] = None, + cache: bool = True, + ) -> Union[dict, str, None]: + """ + Make a base call to the ParityVend API. + + Args: + method (str): The HTTP method to use (e.g., 'get', 'post', 'put', 'delete'). + endpoint_name (str): The name of the endpoint being called. + path (str): The path to the endpoint, including any placeholders for variables. + input_vars (dict): A dictionary of variables to substitute into the path. + cache_key (tuple): A tuple representing the cache key for the request. + timeout (Union[int, float, None], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Raises: + QuotaExceededError: If the API returns an 'over_quota' error. + + Returns: + Union[dict, str, None]: The response from the API, either as a dictionary (for JSON responses), a string (for non-JSON responses), or None (if there was an error). + """ + try: + if cache: + cached_response = self.cache[cache_key] + return cached_response + except KeyError: + pass + + request_options = {**self.request_options} + if isinstance(timeout, (int, float)): + request_options["timeout"] = timeout + + variables = { + "private_key": self.private_key, + **input_vars, + } + + formatted_path = path.format_map(variables) + url = f"{API_URL}{formatted_path}" + result = self.api_request(method, url, request_options) + + if not result: + return + + if isinstance(result, str): + self.cache[cache_key] = result + return result + + if result.get("error_name") == "over_quota": + raise QuotaExceededError( + "Your account has exceeded the quota. Upgrade your billing plan to continue. View more information: https://www.ambeteco.com/ParityVend/docs/debugging_guide.html#over-quota" + ) + + if result.get("status") == "error": + if self.log_api_errors: + logger.error( + f"ParityVend API ({endpoint_name}) returned error:\n{result}\n" + ) + + if self.cache_on_error and result.get("error_name") in ( + "not_identifed", + "incorrect_request", + ): + self.cache[cache_key] = result + else: + self.cache[cache_key] = result + + return result + + def get_country_from_ip( + self, + ip: Union[str, bytes, IPv4Address, IPv6Address], + timeout: Optional[Union[int, float]] = None, + cache: bool = True, + ) -> Country: + """ + Get the country associated with an IP address. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-country-from-ip-(private_key)- + + Args: + ip (Union[str, bytes, IPv4Address, IPv6Address]): The IP address to look up. + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Returns: + Country: An object representing the country associated with the IP address. + """ + ip = self.auto_convert_ip(ip) + + result = self.base_call( + "get", + "get-country-from-ip", + "/backend/get-country-from-ip/{private_key}/{ip}/", + {"ip": ip}, + ("get-country-from-ip", ip), + timeout, + cache, + ) + + return COUNTRIES[result["country"]] + + def get_discount_from_ip( + self, + ip: Union[str, bytes, IPv4Address, IPv6Address], + base_currency: Union[str, bytes] = "USD", + timeout: Optional[Union[int, float]] = None, + cache: bool = True, + ) -> Response: + """ + Get the discount information associated with an IP address. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-discount-from-ip-(private_key)-(opt.-base_currency)- + + Args: + ip (Union[str, bytes, IPv4Address, IPv6Address]): The IP address to look up. + base_currency (Union[str, bytes], optional): The base currency to use for exchange rates. Defaults to "USD". + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Returns: + Response: An object containing the discount information for the IP address. + """ + ip = self.auto_convert_ip(ip) + base_currency = self.auto_convert_to_str(base_currency).upper() + + result = self.base_call( + "get", + "get-discount-from-ip", + "/backend/get-discount-from-ip/{private_key}/{ip}/{base_currency}/", + {"ip": ip, "base_currency": base_currency}, + ("get-discount-from-ip", ip, base_currency), + timeout, + cache, + ) + + if result["country"]: + result["country"] = COUNTRIES[result["country"]["code"]] + + return Response(result) + + def get_banner_from_ip( + self, + ip: Union[str, bytes, IPv4Address, IPv6Address], + base_currency: Union[str, bytes] = "USD", + timeout: Optional[Union[int, float]] = None, + cache: bool = True, + ) -> Union[str, Response]: + """ + Get an HTML banner for the discount information associated with an IP address. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-banner-from-ip-(private_key)-(opt.-base_currency)- + + Args: + ip (Union[str, bytes, IPv4Address, IPv6Address]): The IP address to look up. + base_currency (Union[str, bytes], optional): The base currency to use for exchange rates. Defaults to "USD". + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Returns: + Union[str, Response]: Either a string containing the HTML banner, or a Response object if no banner is available. + """ + ip = self.auto_convert_ip(ip) + base_currency = self.auto_convert_to_str(base_currency).upper() + + result = self.base_call( + "get", + "get-banner-from-ip", + "/backend/get-banner-from-ip/{private_key}/{ip}/{base_currency}/", + {"ip": ip, "base_currency": base_currency}, + ("get-banner-from-ip", ip, base_currency), + timeout, + cache, + ) + + if isinstance(result, str): + return result + return Response(result) + + def get_discount_with_html_from_ip( + self, + ip: Union[str, bytes, IPv4Address, IPv6Address], + base_currency: Union[str, bytes] = "USD", + timeout: Optional[Union[int, float]] = None, + cache: bool = True, + ) -> Response: + """ + Get the discount information and the HTML banner associated with an IP address. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-discount-with-html-from-ip-(private_key)-(opt.-base_currency)- + + Args: + ip (Union[str, bytes, IPv4Address, IPv6Address]): The IP address to look up. + base_currency (Union[str, bytes], optional): The base currency to use for exchange rates. Defaults to "USD". + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Returns: + Response: An object containing the discount information and HTML banner for the IP address. + """ + ip = self.auto_convert_ip(ip) + base_currency = self.auto_convert_to_str(base_currency).upper() + + result = self.base_call( + "get", + "get-discount-with-html-from-ip", + "/backend/get-discount-with-html-from-ip/{private_key}/{ip}/{base_currency}/", + {"ip": ip, "base_currency": base_currency}, + ("get-discount-with-html-from-ip", ip, base_currency), + timeout, + cache, + ) + + if result["country"]: + result["country"] = COUNTRIES[result["country"]["code"]] + + return Response(result) + + def get_quota_info( + self, timeout: Optional[Union[int, float]] = None, cache: bool = False + ) -> Response: + """ + Get information about the account's API quota. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-quota-info-(private_key)- + + Args: + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to False. + + Returns: + Response: An object containing the account's quota information. + """ + result = self.base_call( + "get", + "get-quota-info", + "/backend/get-quota-info/{private_key}/", + {}, + ("get-quota-info",), + timeout, + cache, + ) + + return Response(result) + + def get_discounts_info( + self, timeout: Optional[Union[int, float]] = None, cache: bool = True + ) -> Response: + """ + Get information about all the discounts configured for the current project. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-discounts-info-(private_key)- + + Args: + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Returns: + Response: An object containing the discount information for the current project. + """ + result = self.base_call( + "get", + "get-discounts-info", + "/backend/get-discounts-info/{private_key}/", + {}, + ("get-discounts-info",), + timeout, + cache, + ) + + result["discounts"] = Discounts(result["discounts"]) + return Response(result) + + def get_exchange_rate_info( + self, + base_currency: Union[str, bytes] = "USD", + timeout: Optional[Union[int, float]] = None, + cache: bool = True, + ) -> Response: + """ + Get the current exchange rates. Only available to accounts on paid plans. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-exchange-rate-info-(private_key)-(opt.-base_currency)- + + Args: + base_currency (Union[str, bytes], optional): The base currency to use for exchange rates. Defaults to "USD". + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Returns: + Response: An object containing the exchange rates information. + """ + base_currency = self.auto_convert_to_str(base_currency).upper() + + result = self.base_call( + "get", + "get-exchange-rate-info", + "/backend/get-exchange-rate-info/{private_key}/{base_currency}/", + {"base_currency": base_currency}, + ("get-exchange-rate-info", base_currency), + timeout, + cache, + ) + + return Response(result) + + @staticmethod + def auto_convert_ip(ip: Union[str, bytes, IPv4Address, IPv6Address]) -> str: + if isinstance(ip, str): + return ip + + if isinstance(ip, bytes): + return ip.decode("u8") + + if isinstance(ip, (IPv4Address, IPv6Address)): + return ip.exploded + + raise TypeError( + f'"ip" is of invalid type "{type(ip)}". "str", "bytes", "ipaddress.IPv4Address" or "ipaddress.IPv6Address" was expected.' + ) + + @staticmethod + def auto_convert_to_str(text: Union[str, bytes]) -> str: + if isinstance(text, str): + return text + + if isinstance(text, bytes): + return text.decode("u8") + + raise TypeError( + f'"{text}" is of invalid type "{type(text)}". "str" or "bytes" was expected.' + ) diff --git a/parityvend_api/handler_async.py b/parityvend_api/handler_async.py new file mode 100644 index 0000000..62a4e19 --- /dev/null +++ b/parityvend_api/handler_async.py @@ -0,0 +1,446 @@ +import asyncio +import json +import logging +import platform +from ipaddress import IPv4Address, IPv6Address +from typing import Callable, Optional, Union + +import aiohttp +import aiohttp.client + +from .cache.default import DefaultCache +from .cache.interface import CacheInterface +from .config import API_URL +from .exceptions import APIError, ConnectionError, ProcessingError, QuotaExceededError +from .handler import ParityVendAPI +from .objects import COUNTRIES, Country, Discounts, Response + +if platform.system() == "Windows": + # https://stackoverflow.com/questions/63860576/asyncio-event-loop-is-closed-when-using-asyncio-run + # patching to prevent "RuntimeError: Event loop is closed" + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +logger = logging.getLogger("parityvend") + + +class AsyncParityVendAPI(ParityVendAPI): + def __init__( + self, + private_key: str, + request_options: Optional[dict] = None, + cache_instance: Optional[CacheInterface] = None, + cache_options: Optional[dict] = None, + json_loads: Optional[Callable[[str], dict]] = None, + cache_on_error: bool = True, + log_api_errors: bool = True, + raise_exc_on_error: bool = True, + ): + """ + Initialize the AsyncParityVendAPI object. + + Args: + private_key (str): Your ParityVend API private key. + request_options (Optional[dict], optional): Additional options to pass to the aiohttp library. Defaults to None. + cache_instance (Optional[CacheInterface], optional): An instance of a custom cache implementation. Defaults to None. + cache_options (Optional[dict], optional): Options to pass to the default cache implementation. Defaults to None. + json_loads (Optional[Callable[[str], dict]], optional): A custom function to use for loading JSON data. Defaults to None. + cache_on_error (bool, optional): Whether to cache API responses on error. Defaults to True. + log_api_errors (bool, optional): Whether to log API errors. Defaults to True. + raise_exc_on_error (bool, optional): Whether to raise an exception on API errors. Defaults to True. + """ + self.private_key: str = private_key + + self.request_options: dict = self.get_default_request_options() + if request_options: + self.request_options.update(request_options) + + if cache_instance: + self.cache: CacheInterface = cache_instance + else: + self.cache_options: dict = self.get_default_cache_options() + if cache_options: + self.cache_options.update(cache_options) + + self.cache: CacheInterface = DefaultCache(**self.cache_options) + + self.json_loads: Callable[[str], dict] = json.loads + if json_loads: + self.json_loads: Callable[[str], dict] = json_loads + + self.session: Optional[aiohttp.ClientSession] = None + + self.cache_on_error: bool = cache_on_error + self.log_api_errors: bool = log_api_errors + self.raise_exc_on_error: bool = raise_exc_on_error + + async def init(self): + self._ensure_aiohttp_ready() + + async def deinit(self): + if self.session: + await self.session.close() + self.session = None + + async def api_request( + self, method: str, url: str, request_options: dict + ) -> Union[dict, str, None]: + """ + Make a request to the ParityVend API. + + Args: + method (str): The HTTP method to use (e.g., 'get', 'post', 'put', 'delete'). + url (str): The URL to send the request to. + request_options (dict): Additional options to pass to the aiohttp library. + + Raises: + APIError: If the API returns a non-200 status code or an invalid JSON payload. + ConnectionError: If there is an error connecting to the API. + ProcessingError: If there is an error with the input data. + + Returns: + Union[dict, str, None]: The response from the API, either as a dictionary (for JSON responses), a string (for non-JSON responses), or None (if there was an error). + """ + self._ensure_aiohttp_ready() + + try: + async with aiohttp.client._RequestContextManager( + self.session._request(method.upper(), url, **request_options) + ) as r: + if r.status != 200: + logger.error( + f"ParityVend API ({method.upper()}: {url}) returned non-200 status code ({r.status_code=}). See API response below:\n{await r.text()}\n" + ) + raise APIError("ParityVend API returned non-200 status code.") + + if r.headers["Content-Type"] == "application/json": + response_text = await r.text() + result = self.json_loads(response_text) + + if self.raise_exc_on_error and result["status"] == "error": + raise ProcessingError( + f"ParityVend API ({url}) returned error:\n{result}\n" + ) + + return result + return await r.text() + + except (aiohttp.ClientError, asyncio.TimeoutError): + raise ConnectionError( + "Not able to reach the ParityVend API. Check your internet connection." + ) + + except json.JSONDecodeError: + logger.error( + f"ParityVend API ({method.upper()}: {url}) returned invalid JSON payload ({r.status_code=}). See API response below:\n{await r.text()}\n" + ) + raise APIError("ParityVend API returned invalid JSON payload.") + + async def base_call( + self, + method: str, + endpoint_name: str, + path: str, + input_vars: dict, + cache_key: tuple, + timeout: Union[int, float, None] = None, + cache: bool = True, + ) -> Union[dict, str, None]: + """ + Make a base call to the ParityVend API. + + Args: + method (str): The HTTP method to use (e.g., 'get', 'post', 'put', 'delete'). + endpoint_name (str): The name of the endpoint being called. + path (str): The path to the endpoint, including any placeholders for variables. + input_vars (dict): A dictionary of variables to substitute into the path. + cache_key (tuple): A tuple representing the cache key for the request. + timeout (Union[int, float, None], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Raises: + QuotaExceededError: If the API returns an 'over_quota' error. + + Returns: + Union[dict, str, None]: The response from the API, either as a dictionary (for JSON responses), a string (for non-JSON responses), or None (if there was an error). + """ + try: + if cache: + cached_response = self.cache[cache_key] + return cached_response + except KeyError: + pass + + request_options = {**self.request_options} + if isinstance(timeout, (int, float)): + request_options["timeout"] = timeout + + # warning: aiohttp currently has bug related to the timeout + # a timeout raised can lead to a fail, + # which will raise an exception "Task was destroyed but it is pending!" + # see these: https://github.com/aio-libs/aiohttp/issues/7072 + # https://github.com/aio-libs/aiohttp/pull/7608 + + variables = { + "private_key": self.private_key, + **input_vars, + } + + formatted_path = path.format_map(variables) + url = f"{API_URL}{formatted_path}" + result = await self.api_request(method, url, request_options) + + if not result: + return + + if isinstance(result, str): + self.cache[cache_key] = result + return result + + if result.get("error_name") == "over_quota": + raise QuotaExceededError( + "Your account has exceeded the quota. Upgrade your billing plan to continue. View more information: https://www.ambeteco.com/ParityVend/docs/debugging_guide.html#over-quota" + ) + + if result.get("status") == "error": + if self.log_api_errors: + logger.error( + f"ParityVend API ({endpoint_name}) returned error:\n{result}\n" + ) + + if self.cache_on_error and result.get("error_name") in ( + "not_identifed", + "incorrect_request", + ): + self.cache[cache_key] = result + else: + self.cache[cache_key] = result + + return result + + async def get_country_from_ip( + self, + ip: Union[str, bytes, IPv4Address, IPv6Address], + timeout: Optional[Union[int, float]] = None, + cache: bool = True, + ) -> Country: + """ + Get the country associated with an IP address. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-country-from-ip-(private_key)- + + Args: + ip (Union[str, bytes, IPv4Address, IPv6Address]): The IP address to look up. + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Returns: + Country: An object representing the country associated with the IP address. + """ + ip = self.auto_convert_ip(ip) + + result = await self.base_call( + "get", + "get-country-from-ip", + "/backend/get-country-from-ip/{private_key}/{ip}/", + {"ip": ip}, + ("get-country-from-ip", ip), + timeout, + cache, + ) + return COUNTRIES[result["country"]] + + async def get_discount_from_ip( + self, + ip: Union[str, bytes, IPv4Address, IPv6Address], + base_currency: Union[str, bytes] = "USD", + timeout: Optional[Union[int, float]] = None, + cache: bool = True, + ) -> Response: + """ + Get the discount information associated with an IP address. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-discount-from-ip-(private_key)-(opt.-base_currency)- + + Args: + ip (Union[str, bytes, IPv4Address, IPv6Address]): The IP address to look up. + base_currency (Union[str, bytes], optional): The base currency to use for exchange rates. Defaults to "USD". + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Returns: + Response: An object containing the discount information for the IP address. + """ + ip = self.auto_convert_ip(ip) + base_currency = self.auto_convert_to_str(base_currency).upper() + + result = await self.base_call( + "get", + "get-discount-from-ip", + "/backend/get-discount-from-ip/{private_key}/{ip}/{base_currency}/", + {"ip": ip, "base_currency": base_currency}, + ("get-discount-from-ip", ip, base_currency), + timeout, + cache, + ) + + if result["country"]: + result["country"] = COUNTRIES[result["country"]["code"]] + + return Response(result) + + async def get_banner_from_ip( + self, + ip: Union[str, bytes, IPv4Address, IPv6Address], + base_currency: Union[str, bytes] = "USD", + timeout: Optional[Union[int, float]] = None, + cache: bool = True, + ) -> Union[str, Response]: + """ + Get an HTML banner for the discount information associated with an IP address. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-banner-from-ip-(private_key)-(opt.-base_currency)- + + Args: + ip (Union[str, bytes, IPv4Address, IPv6Address]): The IP address to look up. + base_currency (Union[str, bytes], optional): The base currency to use for exchange rates. Defaults to "USD". + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Returns: + Union[str, Response]: Either a string containing the HTML banner, or a Response object if no banner is available. + """ + ip = self.auto_convert_ip(ip) + base_currency = self.auto_convert_to_str(base_currency).upper() + + result = await self.base_call( + "get", + "get-banner-from-ip", + "/backend/get-banner-from-ip/{private_key}/{ip}/{base_currency}/", + {"ip": ip, "base_currency": base_currency}, + ("get-banner-from-ip", ip, base_currency), + timeout, + cache, + ) + + if isinstance(result, str): + return result + return Response(result) + + async def get_discount_with_html_from_ip( + self, + ip: Union[str, bytes, IPv4Address, IPv6Address], + base_currency: Union[str, bytes] = "USD", + timeout: Optional[Union[int, float]] = None, + cache: bool = True, + ) -> Response: + """ + Get the discount information and the HTML banner associated with an IP address. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-discount-with-html-from-ip-(private_key)-(opt.-base_currency)- + + Args: + ip (Union[str, bytes, IPv4Address, IPv6Address]): The IP address to look up. + base_currency (Union[str, bytes], optional): The base currency to use for exchange rates. Defaults to "USD". + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Returns: + Response: An object containing the discount information and HTML banner for the IP address. + """ + ip = self.auto_convert_ip(ip) + base_currency = self.auto_convert_to_str(base_currency).upper() + + result = await self.base_call( + "get", + "get-discount-with-html-from-ip", + "/backend/get-discount-with-html-from-ip/{private_key}/{ip}/{base_currency}/", + {"ip": ip, "base_currency": base_currency}, + ("get-discount-with-html-from-ip", ip, base_currency), + timeout, + cache, + ) + + if result["country"]: + result["country"] = COUNTRIES[result["country"]["code"]] + + return Response(result) + + async def get_quota_info( + self, timeout: Optional[Union[int, float]] = None, cache: bool = False + ) -> Response: + """ + Get information about the account's API quota. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-quota-info-(private_key)- + + Args: + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to False. + + Returns: + Response: An object containing the account's quota information. + """ + result = await self.base_call( + "get", + "get-quota-info", + "/backend/get-quota-info/{private_key}/", + {}, + ("get-quota-info",), + timeout, + cache, + ) + + return Response(result) + + async def get_discounts_info( + self, timeout: Optional[Union[int, float]] = None, cache: bool = True + ) -> Response: + """ + Get information about all the discounts configured for the current project. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-discounts-info-(private_key)- + + Args: + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Returns: + Response: An object containing the discount information for the current project. + """ + result = await self.base_call( + "get", + "get-discounts-info", + "/backend/get-discounts-info/{private_key}/", + {}, + ("get-discounts-info",), + timeout, + cache, + ) + + result["discounts"] = Discounts(result["discounts"]) + return Response(result) + + async def get_exchange_rate_info( + self, + base_currency: Union[str, bytes] = "USD", + timeout: Optional[Union[int, float]] = None, + cache: bool = True, + ) -> Response: + """ + Get the current exchange rates. Only available to accounts on paid plans. View the API docs here: https://www.ambeteco.com/ParityVend/docs/api_reference.html#get--backend-get-exchange-rate-info-(private_key)-(opt.-base_currency)- + + Args: + base_currency (Union[str, bytes], optional): The base currency to use for exchange rates. Defaults to "USD". + timeout (Optional[Union[int, float]], optional): The timeout value for the request. Defaults to None. + cache (bool, optional): Whether to cache the response. Defaults to True. + + Returns: + Response: An object containing the exchange rates information. + """ + base_currency = self.auto_convert_to_str(base_currency).upper() + + result = await self.base_call( + "get", + "get-exchange-rate-info", + "/backend/get-exchange-rate-info/{private_key}/{base_currency}/", + {"base_currency": base_currency}, + ("get-exchange-rate-info", base_currency), + timeout, + cache, + ) + + return Response(result) + + def _ensure_aiohttp_ready(self): + if self.session: + return + + self.session = aiohttp.ClientSession() diff --git a/parityvend_api/objects.py b/parityvend_api/objects.py new file mode 100644 index 0000000..4705b25 --- /dev/null +++ b/parityvend_api/objects.py @@ -0,0 +1,285 @@ +import functools +from typing import Dict, Optional, Union, Sequence + +from .data import COUNTRIES_META + + +@functools.total_ordering +class Country(dict): + """ + A class representing a country and its associated metadata. + + This class inherits from the `dict` class and provides an object-oriented interface ('dot notation') for + accessing the country-related data, such as name, currency, and emoji flag. + + Args: + code (Union[str, bytes]): The code representing the country (e.g., "US", "FR", "JP"). + + Raises: + ValueError: If an invalid country code is provided. + + Attributes: + code (str): The ISO code representing the country. + name (str): The name of the country. + emoji_flag (str): The emoji flag representing the country. + currency_code (str): The code representing the country's currency. + currency_symbol (str): The symbol representing the country's currency. + currency_localized (str): The localized representation of the country's currency. + """ + + __slots__ = ( + "code", + "name", + "emoji_flag", + "currency_code", + "currency_symbol", + "currency_localized", + ) + + def __init__(self, code: Union[str, bytes]): + try: + if isinstance(code, bytes): + code = code.decode("utf8") + code = code.upper() + meta = COUNTRIES_META[code] + except (KeyError, TypeError, ValueError): + raise ValueError( + f'"Country" object received invalid country code "{code}".' + ) + + name, emoji_flag, currency_code, currency_symbol, currency_localized = meta + + self.code: str = code + self.name: str = name + self.emoji_flag: str = emoji_flag + self.currency_code: str = currency_code + self.currency_symbol: str = currency_symbol + self.currency_localized: str = currency_localized + + super(Country, self).__init__( + { + "code": code, + "name": name, + "emoji_flag": emoji_flag, + "currency_code": currency_code, + "currency_symbol": currency_symbol, + "currency_localized": currency_localized, + } + ) + + def __repr__(self) -> str: + return f"Country({self.code!r})" + + def __str__(self) -> str: + return f"Country {self.name} ({self.code})" + + @staticmethod + def _is_valid_operand(other) -> bool: + return hasattr(other, "code") and isinstance(other, Country) + + def __eq__(self, other) -> bool: + if not self._is_valid_operand(other): + return NotImplemented + return self.code == other.code + + def __lt__(self, other) -> bool: + if not self._is_valid_operand(other): + return NotImplemented + return self.code < other.code + + +class Response(dict): + """ + A class representing a JSON response from an API. + + This class inherits from the `dict` class and provides an additional way to access + its keys and nested dictionaries as attributes (via 'dot notation'). + + Args: + *args: Positional arguments, typically dictionaries or other objects that can be converted to dictionaries. + **kwargs: Keyword arguments, typically key-value pairs that will be added to the dictionary. + """ + + def __init__(self, *args, **kwargs): + super(Response, self).__init__(*args, **kwargs) + + for arg in args: + if isinstance(arg, dict) and not isinstance(arg, Discounts): + for k, v in arg.items(): + if isinstance(v, dict): + arg[k] = Response(v) + + if kwargs: + for k, v in kwargs.items(): + if isinstance(v, dict): + kwargs[k] = Response(v) + + def __getattr__(self, attr): + """ + Allows accessing dictionary keys as attributes ('dot notation'). + """ + value = self[attr] + + if isinstance(value, dict) and not isinstance(value, Discounts): + return Response(value) + else: + return value + + def __repr__(self) -> str: + return f"Response({super(Response, self).__repr__()})" + + +@functools.total_ordering +class Discount(dict): + """ + A class representing a discount and its associated metadata. + + This class inherits from the `dict` class and provides an object-oriented interface ('dot notation') for + accessing and managing discount-related data, such as the discount value, coupon code, and associated country. + + Args: + country (Country): The `Country` object associated with the discount. + coupon_code (Optional[str]): The coupon code associated with the discount. + discount (Optional[float]): The discount value as a float between 0 and 1. + raw_discount (Optional[Sequence]): A tuple containing the coupon code and discount value. + + Attributes: + discount (float): The discount value as a float between 0 and 1. + discount_str (str): The discount value as a string formatted as a percentage ('40.00%'). + coupon_code (str): The coupon code associated with the discount. + country (Country): The `Country` object associated with the discount. + raw_discount (Optional[Sequence]): The raw discount data used to initialize the object. + """ + + __slots__ = ( + "discount", + "discount_str", + "coupon_code", + "country", + "raw_discount", + ) + + def __init__( + self, + country: Country, + coupon_code: Optional[str] = None, + discount: Optional[float] = None, + raw_discount: Optional[Sequence] = None, + ): + self.raw_discount = raw_discount + + if raw_discount: + if isinstance(raw_discount, Discount): + coupon_code = raw_discount.coupon_code + discount = raw_discount.discount + else: + coupon_code, discount = raw_discount + discount_str = f"{discount:.2%}" + + self.coupon_code: str = coupon_code + self.discount: float = discount + self.discount_str: str = discount_str + else: + self.discount: float = 0.0 + self.discount_str: str = "0.00%" + self.coupon_code: str = "" + + self.country: Country = country + + super(Discount, self).__init__( + { + "discount": self.discount, + "discount_str": self.discount_str, + "coupon_code": self.coupon_code, + "country": self.country, + "raw_discount": self.raw_discount, + } + ) + + def __repr__(self) -> str: + return f"Discount({self.country!r}, {self.coupon_code!r}, {self.discount!r})" + + @staticmethod + def _is_valid_operand(other) -> bool: + return hasattr(other, "country") and isinstance(other, Discount) + + def __eq__(self, other) -> bool: + if not self._is_valid_operand(other): + return NotImplemented + return self.country["code"] == other.country["code"] + + def __lt__(self, other) -> bool: + if not self._is_valid_operand(other): + return NotImplemented + return self.country["code"] < other.country["code"] + + +class Discounts(dict): + """ + A class representing a collection of `Discount` objects. + + This class inherits from the `dict` class and provides a convenient way to manage and access + discounts associated with different countries (via 'dot notation'). + + Args: + raw_discounts (dict): A dictionary containing raw discount data, where the keys are country codes + and the values are tuples of (coupon_code, discount_value). + """ + + def __init__(self, raw_discounts: dict): + self.raw_discounts: dict = raw_discounts + + discounts = { + key: Discount(get_country_by_code(key), raw_discount=value) + for key, value in raw_discounts.items() + } + + self.discounts: dict = discounts + super(Discounts, self).__init__(discounts) + + def get_discount_by_country(self, country: Union[Country, str, bytes]) -> Discount: + """ + Retrieves the `Discount` object associated with the given country. + + Args: + country (Union[Country, str, bytes]): The country for which to retrieve the discount. + Can be a `Country` object, a country code string, or bytes. + + Returns: + Discount: The `Discount` object associated with the given country. + """ + country = self.auto_convert_country(country) + return self.discounts[country] + + @staticmethod + def auto_convert_country(country: Union[Country, str, bytes]) -> Country: + if isinstance(country, Country): + return country["code"] + + if isinstance(country, bytes): + country = country.decode("utf8") + + return country.upper() + + def __repr__(self) -> str: + return f"Discounts({self.discounts!r})" + + +def get_country_by_code(country_code: Union[str, bytes]) -> Country: + """ + Retrieves the `Country` object associated with the given country code. + + Args: + country_code (Union[str, bytes]): The country code for which to retrieve the `Country` object. + + Returns: + Country: The `Country` object associated with the given country code. + """ + if isinstance(country_code, bytes): + country_code = country_code.decode("utf8") + + country_code = country_code.upper() + return COUNTRIES.get(country_code) + + +COUNTRIES: Dict[str, Country] = {key: Country(key) for key in COUNTRIES_META} diff --git a/parityvend_api/utils.py b/parityvend_api/utils.py new file mode 100644 index 0000000..782b2e6 --- /dev/null +++ b/parityvend_api/utils.py @@ -0,0 +1,16 @@ +import os + + +def env_get(var_name, default): + """Retrieves an environment variable with a default value. + + Args: + var_name (str): The name of the environment variable to retrieve. + default (str): The default value to return if the environment variable + is not set. + + Returns: + str: The value of the environment variable if it exists, otherwise + the default value. + """ + return os.environ.get(var_name, default) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4810eae --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests==2.31.0 +cachetools==5.3.3 +aiohttp==3.9.3 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..c580601 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,2 @@ +pytest==8.1.1 +pytest-asyncio==0.23.5.post1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..441b005 --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +from setuptools import setup + +from parityvend_api.config import VERSION + +long_description = """ +The official Python library for ParityVend API. + +parityvend_api prides itself on being the most reliable, accurate, and in-depth source of IP address data available anywhere. +We process terabytes of data to produce our custom IP geolocation, company, carrier and IP type data sets. +You can visit our developer docs at https://parityvend_api.io/developers. +""" + +setup( + name="parityvend_api", + version=VERSION, + description="Official Python library for ParityVend API", + long_description=long_description, + url="https://github.com/parityvend/api_python", + author="ParityVend", + author_email="help@ambeteco.com", + license="Apache License 2.0", + packages=["parityvend_api", "parityvend_api.cache"], + install_requires=["requests", "cachetools", "aiohttp<=4"], + include_package_data=True, + zip_safe=False, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/country_test.py b/tests/country_test.py new file mode 100644 index 0000000..2bbe765 --- /dev/null +++ b/tests/country_test.py @@ -0,0 +1,58 @@ +import pytest +from parityvend_api.objects import Country +from parityvend_api import COUNTRIES, get_country_by_code + + +def test_init(): + assert [{key == country.code for key, country in COUNTRIES.items()}] + + +def test_meta(): + us = Country("US") + + assert ( + us + == Country("us") + == Country(b"US") + == get_country_by_code("US") + == get_country_by_code("us") + == get_country_by_code(b"US") + == COUNTRIES["US"] + ) + + assert us != Country("CA") + + with pytest.raises(ValueError): + Country("11") + + assert us.code == "US" + assert us.name == "United States of America" + assert us.emoji_flag == "🇺🇸" + assert us.currency_code == "USD" + assert us.currency_symbol == "$" + assert us.currency_localized == "USD$" + assert repr(us) == "Country('US')" + assert str(us) == "Country United States of America (US)" + + assert sorted([Country("US"), Country("UA"), Country("AU")]) == [ + Country("AU"), + Country("UA"), + Country("US"), + ] + + +def test_eq(): + assert Country("US") == get_country_by_code("US") == get_country_by_code(b"US") + + assert [ + { + country.code == get_country_by_code(country.code) + for key, country in COUNTRIES.items() + } + ] + + +def test_getattr_fail(): + country = Country("US") + with pytest.raises(AttributeError): + country.blah diff --git a/tests/default_cache_test.py b/tests/default_cache_test.py new file mode 100644 index 0000000..eb402a5 --- /dev/null +++ b/tests/default_cache_test.py @@ -0,0 +1,17 @@ +from parityvend_api.cache.default import DefaultCache + + +def _get_new_cache(): + return DefaultCache(maxsize=4, ttl=8) + + +def test_contains(): + cache = _get_new_cache() + cache["foo"] = "bar" + assert "foo" in cache + + +def test_get(): + cache = _get_new_cache() + cache["foo"] = "bar" + assert cache["foo"] == "bar" diff --git a/tests/details_test.py b/tests/details_test.py new file mode 100644 index 0000000..61322b6 --- /dev/null +++ b/tests/details_test.py @@ -0,0 +1,39 @@ +import pytest +from parityvend_api.objects import Response + + +def test_init(): + response = Response({"foo": "bar"}) + assert response.foo == "bar" == response["foo"] + + +def test_getattr_fail(): + response = Response({"foo": "bar"}) + with pytest.raises(KeyError): + response.blah + + +def test_all(): + data = {"foo": "bar", "ham": "eggs"} + response = Response(data) + assert tuple(response.items()) == tuple(data.items()) + + +def test_nested_init(): + response = Response({"foo": {"bar": "baz"}}) + assert response.foo == {"bar": "baz"} == response["foo"] + + +def test_nested_getattr_fail(): + response = Response({"foo": {"bar": "baz"}}) + with pytest.raises(KeyError): + response.blah + + with pytest.raises(KeyError): + response.foo.blah + + +def test_nested_all(): + data = {"foo": {"bar": "baz"}, "ham": "eggs"} + response = Response(data) + assert tuple(response.items()) == tuple(data.items()) diff --git a/tests/handler_async_test.py b/tests/handler_async_test.py new file mode 100644 index 0000000..a0656f8 --- /dev/null +++ b/tests/handler_async_test.py @@ -0,0 +1,469 @@ +import json +from ipaddress import IPv4Address, IPv6Address + +import pytest +import aiohttp + +from parityvend_api import AsyncParityVendAPI +from parityvend_api.cache.default import DefaultCache +from parityvend_api.exceptions import ProcessingError +from parityvend_api.objects import Country, Response, Discounts, Discount +from tests.variables import ( + google_ipv4, + invalid_secret_key, + ipv4_switzerland, + ipv4_zimbabwe, + ipv6_vpn_cn, + ipv6_zimbabwe, + secret_key, + secret_key_free, +) + + +@pytest.mark.asyncio +async def test_init(): + parityvend = AsyncParityVendAPI(secret_key) + + assert parityvend.private_key == secret_key + assert isinstance(parityvend.cache, DefaultCache) + + assert parityvend.request_options == { + "headers": { + "User-Agent": "Python ParityVend API Client/1.0.0 (+https://www.ambeteco.com/ParityVend/docs/index.html)" + } + } + + assert parityvend.json_loads == json.loads + assert parityvend.session is None + await parityvend.init() + assert isinstance(parityvend.session, aiohttp.ClientSession) + await parityvend.deinit() + assert parityvend.session is None + + +@pytest.mark.asyncio +async def test_get_country_from_ip(): + parityvend = AsyncParityVendAPI(secret_key) + parityvend_free = AsyncParityVendAPI(secret_key_free) + + assert await parityvend.get_country_from_ip(google_ipv4) == Country("XX") + assert await parityvend.get_country_from_ip(ipv6_vpn_cn) == Country("XX") + assert await parityvend.get_country_from_ip(ipv4_zimbabwe) == Country("ZW") + assert await parityvend.get_country_from_ip(ipv6_zimbabwe) == Country("ZW") + + assert await parityvend.get_country_from_ip(IPv4Address(google_ipv4)) == Country( + "XX" + ) + assert await parityvend.get_country_from_ip(IPv6Address(ipv6_vpn_cn)) == Country( + "XX" + ) + assert await parityvend.get_country_from_ip(IPv4Address(ipv4_zimbabwe)) == Country( + "ZW" + ) + assert await parityvend.get_country_from_ip(IPv6Address(ipv6_zimbabwe)) == Country( + "ZW" + ) + + assert await parityvend_free.get_country_from_ip(google_ipv4) == Country("US") + assert await parityvend_free.get_country_from_ip(ipv6_vpn_cn) == Country("CN") + assert await parityvend_free.get_country_from_ip(ipv4_zimbabwe) == Country("ZW") + assert await parityvend_free.get_country_from_ip(ipv6_zimbabwe) == Country("ZW") + + await parityvend.deinit() + await parityvend_free.deinit() + + +@pytest.mark.asyncio +async def test_cache_quota(): + parityvend = AsyncParityVendAPI(secret_key) + + initial_quota = await parityvend.get_quota_info(cache=False) + assert await parityvend.get_country_from_ip(ipv4_zimbabwe, cache=False) == Country( + "ZW" + ) + new_quota = await parityvend.get_quota_info(cache=False) + + assert new_quota["quota_used"] > initial_quota["quota_used"] + assert new_quota["status"] == "ok" + assert isinstance(new_quota["quota_limit"], int) + assert isinstance(new_quota["quota_used"], int) + assert isinstance(new_quota["quota_left"], int) + + await parityvend.deinit() + + +@pytest.mark.asyncio +async def test_get_discounts_info(): + parityvend = AsyncParityVendAPI(secret_key) + + discounts_info = await parityvend.get_discounts_info() + discounts = discounts_info["discounts"] + + assert isinstance(discounts, Discounts) + + assert isinstance(discounts["AC"], Discount) + assert discounts["AC"].country == Country("AC") == discounts["AC"]["country"] + assert discounts["AC"].discount == 0.0 == discounts["AC"]["discount"] + assert discounts["AC"].discount_str == "0.00%" == discounts["AC"]["discount_str"] + assert discounts["AC"].coupon_code == "" == discounts["AC"]["coupon_code"] + + assert discounts["ZW"].country == Country("ZW") == discounts["ZW"]["country"] + assert discounts["ZW"].discount == 0.7 == discounts["ZW"]["discount"] + assert discounts["ZW"].discount_str == "70.00%" == discounts["ZW"]["discount_str"] + assert ( + discounts["ZW"].coupon_code + == "example_coupon" + == discounts["ZW"]["coupon_code"] + ) + + assert discounts["SV"].country == Country("SV") == discounts["SV"]["country"] + assert discounts["SV"].discount == 0.5 == discounts["SV"]["discount"] + assert discounts["SV"].discount_str == "50.00%" == discounts["SV"]["discount_str"] + assert ( + discounts["SV"].coupon_code + == "example_coupon" + == discounts["SV"]["coupon_code"] + ) + + assert discounts.get_discount_by_country("AC") == discounts["AC"] + assert discounts.get_discount_by_country("ZW") == discounts["ZW"] + assert discounts.get_discount_by_country("SV") == discounts["SV"] + + await parityvend.deinit() + + +@pytest.mark.asyncio +async def test_get_discount_from_ip(): + parityvend = AsyncParityVendAPI(secret_key) + response = await parityvend.get_discount_from_ip(ipv4_zimbabwe) + response_ipv6 = await parityvend.get_discount_from_ip(ipv6_zimbabwe) + + parityvend_free = AsyncParityVendAPI(secret_key_free) + response_free = await parityvend_free.get_discount_from_ip(ipv4_zimbabwe) + response_free_ipv6 = await parityvend_free.get_discount_from_ip(ipv6_zimbabwe) + + assert response == response_free == response_ipv6 == response_free_ipv6 + + assert response["status"] == "ok" + assert response["discount"] == 0.7 + assert response["discount_str"] == "70.00%" + assert response["coupon_code"] == "example_coupon" + assert response["country"] == Country("ZW") + + assert response["currency"]["code"] == "ZWL" + assert response["currency"]["symbol"] == "$" + assert response["currency"]["localized_symbol"] == "ZWL$" + assert response["currency"]["conversion_rate"] > 0 + + await parityvend.deinit() + await parityvend_free.deinit() + + +@pytest.mark.asyncio +async def test_get_discount_from_ip_currency(): + parityvend = AsyncParityVendAPI(secret_key) + + response = await parityvend.get_discount_from_ip(ipv4_zimbabwe) + response_eur = await parityvend.get_discount_from_ip(ipv4_zimbabwe, "EUR") + + assert ( + response["currency"]["conversion_rate"] + != response_eur["currency"]["conversion_rate"] + ) + + await parityvend.deinit() + + +@pytest.mark.asyncio +async def test_get_discount_with_html_from_ip(): + parityvend = AsyncParityVendAPI(secret_key) + response = await parityvend.get_discount_with_html_from_ip(ipv4_zimbabwe) + response_ipv6 = await parityvend.get_discount_with_html_from_ip(ipv6_zimbabwe) + + parityvend_free = AsyncParityVendAPI(secret_key_free) + response_free = await parityvend_free.get_discount_with_html_from_ip(ipv4_zimbabwe) + response_free_ipv6 = await parityvend_free.get_discount_with_html_from_ip( + ipv6_zimbabwe + ) + + assert response == response_ipv6 + assert response_free == response_free_ipv6 + + assert "Zimbabwe" in response["html"] + assert "Zimbabwe" in response_free["html"] + + assert "70.00%" in response["html"] + assert "70.00%" in response_free["html"] + + assert response["status"] == "ok" + assert response["discount"] == 0.7 + assert response["discount_str"] == "70.00%" + assert response["coupon_code"] == "example_coupon" + assert response["country"] == Country("ZW") + + assert response["currency"]["code"] == "ZWL" + assert response["currency"]["symbol"] == "$" + assert response["currency"]["localized_symbol"] == "ZWL$" + assert response["currency"]["conversion_rate"] > 0 + + assert response_free["status"] == "ok" + assert response_free["discount"] == 0.7 + assert response_free["discount_str"] == "70.00%" + assert response_free["coupon_code"] == "example_coupon" + assert response_free["country"] == Country("ZW") + + assert response_free["currency"]["code"] == "ZWL" + assert response_free["currency"]["symbol"] == "$" + assert response_free["currency"]["localized_symbol"] == "ZWL$" + assert response_free["currency"]["conversion_rate"] > 0 + + await parityvend.deinit() + await parityvend_free.deinit() + + +@pytest.mark.asyncio +async def test_get_discount_with_html_from_ip_currency(): + parityvend = AsyncParityVendAPI(secret_key) + + response = await parityvend.get_discount_with_html_from_ip(ipv4_zimbabwe) + response_eur = await parityvend.get_discount_with_html_from_ip(ipv4_zimbabwe, "EUR") + + assert ( + response["currency"]["conversion_rate"] + != response_eur["currency"]["conversion_rate"] + ) + + await parityvend.deinit() + + +@pytest.mark.asyncio +async def test_get_banner_from_ip(): + parityvend = AsyncParityVendAPI(secret_key) + response = await parityvend.get_banner_from_ip(ipv4_zimbabwe) + response_ipv6 = await parityvend.get_banner_from_ip(ipv6_zimbabwe) + + parityvend_free = AsyncParityVendAPI(secret_key_free) + response_free = await parityvend_free.get_banner_from_ip(ipv4_zimbabwe) + response_free_ipv6 = await parityvend_free.get_banner_from_ip(ipv6_zimbabwe) + + assert response == response_ipv6 + assert response_free == response_free_ipv6 + + assert "This fair pricing is powered by" in response_free + assert "This fair pricing is powered by" not in response + + assert "Zimbabwe" in response + assert "Zimbabwe" in response_free + + assert "70.00%" in response + assert "70.00%" in response_free + + await parityvend.deinit() + await parityvend_free.deinit() + + +@pytest.mark.asyncio +async def test_invalid_private_key(): + parityvend = AsyncParityVendAPI(invalid_secret_key) + + with pytest.raises(ProcessingError): + await parityvend.get_discount_from_ip(ipv4_zimbabwe) + + await parityvend.deinit() + + +@pytest.mark.asyncio +async def test_get_discount_from_ip_none(): + parityvend = AsyncParityVendAPI(secret_key) + + response = await parityvend.get_discount_from_ip(google_ipv4) + response_vpn_cn = await parityvend.get_discount_from_ip(ipv6_vpn_cn) + response_ch = await parityvend.get_discount_from_ip(ipv4_switzerland) + + parityvend_free = AsyncParityVendAPI(secret_key_free) + response_free = await parityvend_free.get_discount_from_ip(google_ipv4) + response_free_ch = await parityvend_free.get_discount_from_ip(ipv4_switzerland) + + response["currency"].pop("conversion_rate") + response_ch["currency"].pop("conversion_rate") + response_free["currency"].pop("conversion_rate") + response_free_ch["currency"].pop("conversion_rate") + + assert response == { + "status": "ok", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("US"), + "currency": {"code": "USD", "symbol": "$", "localized_symbol": "USD$"}, + } + assert response_ch == { + "status": "ok", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("CH"), + "currency": {"code": "CHF", "symbol": "CHF", "localized_symbol": "CHF"}, + } + assert response_free == { + "status": "ok", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("US"), + "currency": {"code": "USD", "symbol": "$", "localized_symbol": "USD$"}, + } + assert response_free_ch == { + "status": "ok", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("CH"), + "currency": {"code": "CHF", "symbol": "CHF", "localized_symbol": "CHF"}, + } + + assert response_vpn_cn == Response( + { + "status": "ok", + "discount": None, + "discount_str": None, + "coupon_code": None, + "country": {}, + "currency": {}, + } + ) + + await parityvend.deinit() + await parityvend_free.deinit() + + +@pytest.mark.asyncio +async def test_get_discount_with_html_from_ip_none(): + parityvend = AsyncParityVendAPI(secret_key) + + response = await parityvend.get_discount_with_html_from_ip(google_ipv4) + response_vpn_cn = await parityvend.get_discount_with_html_from_ip(ipv6_vpn_cn) + response_ch = await parityvend.get_discount_with_html_from_ip(ipv4_switzerland) + + parityvend_free = AsyncParityVendAPI(secret_key_free) + response_free = await parityvend_free.get_discount_with_html_from_ip(google_ipv4) + response_free_ch = await parityvend_free.get_discount_with_html_from_ip( + ipv4_switzerland + ) + + response["currency"].pop("conversion_rate") + response_ch["currency"].pop("conversion_rate") + response_free["currency"].pop("conversion_rate") + response_free_ch["currency"].pop("conversion_rate") + + assert response == { + "status": "ok", + "html": "", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("US"), + "currency": {"code": "USD", "symbol": "$", "localized_symbol": "USD$"}, + } + assert response_ch == { + "status": "ok", + "html": "", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("CH"), + "currency": {"code": "CHF", "symbol": "CHF", "localized_symbol": "CHF"}, + } + assert response_free == { + "status": "ok", + "html": "", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("US"), + "currency": {"code": "USD", "symbol": "$", "localized_symbol": "USD$"}, + } + assert response_free_ch == { + "status": "ok", + "html": "", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("CH"), + "currency": {"code": "CHF", "symbol": "CHF", "localized_symbol": "CHF"}, + } + + assert response_vpn_cn == Response( + { + "status": "ok", + "html": None, + "discount": None, + "discount_str": None, + "coupon_code": None, + "country": {}, + "currency": {}, + } + ) + + await parityvend.deinit() + await parityvend_free.deinit() + + +@pytest.mark.asyncio +async def test_get_banner_from_ip_none(): + parityvend = AsyncParityVendAPI(secret_key) + + response = await parityvend.get_banner_from_ip(google_ipv4) + response_vpn_cn = await parityvend.get_banner_from_ip(ipv6_vpn_cn) + response_ch = await parityvend.get_banner_from_ip(ipv4_switzerland) + + parityvend_free = AsyncParityVendAPI(secret_key_free) + response_free = await parityvend_free.get_banner_from_ip(google_ipv4) + response_free_ch = await parityvend_free.get_banner_from_ip(ipv4_switzerland) + + assert ( + response + == response_ch + == response_vpn_cn + == response_free + == response_free_ch + == Response( + { + "status": "ok", + "html": None, + "discount": None, + "discount_str": None, + "coupon_code": None, + "country": {}, + "currency": {}, + } + ) + ) + + await parityvend.deinit() + await parityvend_free.deinit() + + +@pytest.mark.asyncio +async def test_get_exchange_rate_info(): + parityvend = AsyncParityVendAPI(secret_key) + + response = await parityvend.get_exchange_rate_info() + rates = response["rates"] + + response_eur = await parityvend.get_exchange_rate_info("EUR") + rates_eur = response_eur["rates"] + + assert "USD" in rates + assert "EUR" in rates + assert "GBP" in rates + assert rates_eur["EUR"] == 1.0 + assert rates["USD"] == 1.0 + assert (isinstance(i, float) for i in rates.values()) + + with pytest.raises(ProcessingError): + parityvend_free = AsyncParityVendAPI(secret_key_free) + await parityvend_free.get_exchange_rate_info() + + await parityvend.deinit() + await parityvend_free.deinit() diff --git a/tests/handler_test.py b/tests/handler_test.py new file mode 100644 index 0000000..772222d --- /dev/null +++ b/tests/handler_test.py @@ -0,0 +1,410 @@ +import json +from ipaddress import IPv4Address, IPv6Address + +import pytest +import requests + +from parityvend_api import ParityVendAPI +from parityvend_api.cache.default import DefaultCache +from parityvend_api.exceptions import ProcessingError, ConnectionError +from parityvend_api.objects import Country, Response, Discounts, Discount +from tests.variables import ( + google_ipv4, + invalid_secret_key, + ipv4_switzerland, + ipv4_zimbabwe, + ipv6_vpn_cn, + ipv6_zimbabwe, + secret_key, + secret_key_free, +) + + +def test_init(): + parityvend = ParityVendAPI(secret_key) + + assert parityvend.private_key == secret_key + assert isinstance(parityvend.cache, DefaultCache) + + assert parityvend.request_options == { + "headers": { + "User-Agent": "Python ParityVend API Client/1.0.0 (+https://www.ambeteco.com/ParityVend/docs/index.html)" + } + } + + assert parityvend.json_loads == json.loads + assert isinstance(parityvend.session, requests.Session) + + +def test_get_country_from_ip_timeout(): + parityvend = ParityVendAPI(secret_key) + + with pytest.raises(ConnectionError): + parityvend.get_country_from_ip(google_ipv4, cache=False, timeout=1 * 10**-8) + + +def test_get_country_from_ip(): + parityvend = ParityVendAPI(secret_key) + parityvend_free = ParityVendAPI(secret_key_free) + + assert parityvend.get_country_from_ip(google_ipv4) == Country("XX") + assert parityvend.get_country_from_ip(ipv6_vpn_cn) == Country("XX") + assert parityvend.get_country_from_ip(ipv4_zimbabwe) == Country("ZW") + assert parityvend.get_country_from_ip(ipv6_zimbabwe) == Country("ZW") + + assert parityvend.get_country_from_ip(IPv4Address(google_ipv4)) == Country("XX") + assert parityvend.get_country_from_ip(IPv6Address(ipv6_vpn_cn)) == Country("XX") + assert parityvend.get_country_from_ip(IPv4Address(ipv4_zimbabwe)) == Country("ZW") + assert parityvend.get_country_from_ip(IPv6Address(ipv6_zimbabwe)) == Country("ZW") + + assert parityvend_free.get_country_from_ip(google_ipv4) == Country("US") + assert parityvend_free.get_country_from_ip(ipv6_vpn_cn) == Country("CN") + assert parityvend_free.get_country_from_ip(ipv4_zimbabwe) == Country("ZW") + assert parityvend_free.get_country_from_ip(ipv6_zimbabwe) == Country("ZW") + + +def test_cache_quota(): + parityvend = ParityVendAPI(secret_key) + + initial_quota = parityvend.get_quota_info(cache=False) + assert parityvend.get_country_from_ip(ipv4_zimbabwe, cache=False) == Country("ZW") + new_quota = parityvend.get_quota_info(cache=False) + + assert new_quota["quota_used"] > initial_quota["quota_used"] + assert new_quota["status"] == "ok" + assert isinstance(new_quota["quota_limit"], int) + assert isinstance(new_quota["quota_used"], int) + assert isinstance(new_quota["quota_left"], int) + + +def test_get_discounts_info(): + parityvend = ParityVendAPI(secret_key) + + discounts_info = parityvend.get_discounts_info() + discounts = discounts_info["discounts"] + + assert isinstance(discounts, Discounts) + + assert isinstance(discounts["AC"], Discount) + assert discounts["AC"].country == Country("AC") == discounts["AC"]["country"] + assert discounts["AC"].discount == 0.0 == discounts["AC"]["discount"] + assert discounts["AC"].discount_str == "0.00%" == discounts["AC"]["discount_str"] + assert discounts["AC"].coupon_code == "" == discounts["AC"]["coupon_code"] + + assert discounts["ZW"].country == Country("ZW") == discounts["ZW"]["country"] + assert discounts["ZW"].discount == 0.7 == discounts["ZW"]["discount"] + assert discounts["ZW"].discount_str == "70.00%" == discounts["ZW"]["discount_str"] + assert ( + discounts["ZW"].coupon_code + == "example_coupon" + == discounts["ZW"]["coupon_code"] + ) + + assert discounts["SV"].country == Country("SV") == discounts["SV"]["country"] + assert discounts["SV"].discount == 0.5 == discounts["SV"]["discount"] + assert discounts["SV"].discount_str == "50.00%" == discounts["SV"]["discount_str"] + assert ( + discounts["SV"].coupon_code + == "example_coupon" + == discounts["SV"]["coupon_code"] + ) + + assert discounts.get_discount_by_country("AC") == discounts["AC"] + assert discounts.get_discount_by_country("ZW") == discounts["ZW"] + assert discounts.get_discount_by_country("SV") == discounts["SV"] + + +def test_get_discount_from_ip(): + parityvend = ParityVendAPI(secret_key) + response = parityvend.get_discount_from_ip(ipv4_zimbabwe) + response_ipv6 = parityvend.get_discount_from_ip(ipv6_zimbabwe) + + parityvend_free = ParityVendAPI(secret_key_free) + response_free = parityvend_free.get_discount_from_ip(ipv4_zimbabwe) + response_free_ipv6 = parityvend_free.get_discount_from_ip(ipv6_zimbabwe) + + assert response == response_free == response_ipv6 == response_free_ipv6 + + assert response["status"] == "ok" + assert response["discount"] == 0.7 + assert response["discount_str"] == "70.00%" + assert response["coupon_code"] == "example_coupon" + assert response["country"] == Country("ZW") + + assert response["currency"]["code"] == "ZWL" + assert response["currency"]["symbol"] == "$" + assert response["currency"]["localized_symbol"] == "ZWL$" + assert response["currency"]["conversion_rate"] > 0 + + +def test_get_discount_from_ip_currency(): + parityvend = ParityVendAPI(secret_key) + + response = parityvend.get_discount_from_ip(ipv4_zimbabwe) + response_eur = parityvend.get_discount_from_ip(ipv4_zimbabwe, "EUR") + + assert ( + response["currency"]["conversion_rate"] + != response_eur["currency"]["conversion_rate"] + ) + + +def test_get_discount_with_html_from_ip(): + parityvend = ParityVendAPI(secret_key) + response = parityvend.get_discount_with_html_from_ip(ipv4_zimbabwe) + response_ipv6 = parityvend.get_discount_with_html_from_ip(ipv6_zimbabwe) + + parityvend_free = ParityVendAPI(secret_key_free) + response_free = parityvend_free.get_discount_with_html_from_ip(ipv4_zimbabwe) + response_free_ipv6 = parityvend_free.get_discount_with_html_from_ip(ipv6_zimbabwe) + + assert response == response_ipv6 + assert response_free == response_free_ipv6 + + assert "Zimbabwe" in response["html"] + assert "Zimbabwe" in response_free["html"] + + assert "70.00%" in response["html"] + assert "70.00%" in response_free["html"] + + assert response["status"] == "ok" + assert response["discount"] == 0.7 + assert response["discount_str"] == "70.00%" + assert response["coupon_code"] == "example_coupon" + assert response["country"] == Country("ZW") + + assert response["currency"]["code"] == "ZWL" + assert response["currency"]["symbol"] == "$" + assert response["currency"]["localized_symbol"] == "ZWL$" + assert response["currency"]["conversion_rate"] > 0 + + assert response_free["status"] == "ok" + assert response_free["discount"] == 0.7 + assert response_free["discount_str"] == "70.00%" + assert response_free["coupon_code"] == "example_coupon" + assert response_free["country"] == Country("ZW") + + assert response_free["currency"]["code"] == "ZWL" + assert response_free["currency"]["symbol"] == "$" + assert response_free["currency"]["localized_symbol"] == "ZWL$" + assert response_free["currency"]["conversion_rate"] > 0 + + +def test_get_discount_with_html_from_ip_currency(): + parityvend = ParityVendAPI(secret_key) + + response = parityvend.get_discount_with_html_from_ip(ipv4_zimbabwe) + response_eur = parityvend.get_discount_with_html_from_ip(ipv4_zimbabwe, "EUR") + + assert ( + response["currency"]["conversion_rate"] + != response_eur["currency"]["conversion_rate"] + ) + + +def test_get_banner_from_ip(): + parityvend = ParityVendAPI(secret_key) + response = parityvend.get_banner_from_ip(ipv4_zimbabwe) + response_ipv6 = parityvend.get_banner_from_ip(ipv6_zimbabwe) + + parityvend_free = ParityVendAPI(secret_key_free) + response_free = parityvend_free.get_banner_from_ip(ipv4_zimbabwe) + response_free_ipv6 = parityvend_free.get_banner_from_ip(ipv6_zimbabwe) + + assert response == response_ipv6 + assert response_free == response_free_ipv6 + + assert "This fair pricing is powered by" in response_free + assert "This fair pricing is powered by" not in response + + assert "Zimbabwe" in response + assert "Zimbabwe" in response_free + + assert "70.00%" in response + assert "70.00%" in response_free + + +def test_invalid_private_key(): + parityvend = ParityVendAPI(invalid_secret_key) + + with pytest.raises(ProcessingError): + response = parityvend.get_discount_from_ip(ipv4_zimbabwe) + + +def test_get_discount_from_ip_none(): + parityvend = ParityVendAPI(secret_key) + + response = parityvend.get_discount_from_ip(google_ipv4) + response_vpn_cn = parityvend.get_discount_from_ip(ipv6_vpn_cn) + response_ch = parityvend.get_discount_from_ip(ipv4_switzerland) + + parityvend_free = ParityVendAPI(secret_key_free) + response_free = parityvend_free.get_discount_from_ip(google_ipv4) + response_free_ch = parityvend_free.get_discount_from_ip(ipv4_switzerland) + + response["currency"].pop("conversion_rate") + response_ch["currency"].pop("conversion_rate") + response_free["currency"].pop("conversion_rate") + response_free_ch["currency"].pop("conversion_rate") + + assert response == { + "status": "ok", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("US"), + "currency": {"code": "USD", "symbol": "$", "localized_symbol": "USD$"}, + } + assert response_ch == { + "status": "ok", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("CH"), + "currency": {"code": "CHF", "symbol": "CHF", "localized_symbol": "CHF"}, + } + assert response_free == { + "status": "ok", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("US"), + "currency": {"code": "USD", "symbol": "$", "localized_symbol": "USD$"}, + } + assert response_free_ch == { + "status": "ok", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("CH"), + "currency": {"code": "CHF", "symbol": "CHF", "localized_symbol": "CHF"}, + } + + assert response_vpn_cn == Response( + { + "status": "ok", + "discount": None, + "discount_str": None, + "coupon_code": None, + "country": {}, + "currency": {}, + } + ) + + +def test_get_discount_with_html_from_ip_none(): + parityvend = ParityVendAPI(secret_key) + + response = parityvend.get_discount_with_html_from_ip(google_ipv4) + response_vpn_cn = parityvend.get_discount_with_html_from_ip(ipv6_vpn_cn) + response_ch = parityvend.get_discount_with_html_from_ip(ipv4_switzerland) + + parityvend_free = ParityVendAPI(secret_key_free) + response_free = parityvend_free.get_discount_with_html_from_ip(google_ipv4) + response_free_ch = parityvend_free.get_discount_with_html_from_ip(ipv4_switzerland) + + response["currency"].pop("conversion_rate") + response_ch["currency"].pop("conversion_rate") + response_free["currency"].pop("conversion_rate") + response_free_ch["currency"].pop("conversion_rate") + + assert response == { + "status": "ok", + "html": "", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("US"), + "currency": {"code": "USD", "symbol": "$", "localized_symbol": "USD$"}, + } + assert response_ch == { + "status": "ok", + "html": "", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("CH"), + "currency": {"code": "CHF", "symbol": "CHF", "localized_symbol": "CHF"}, + } + assert response_free == { + "status": "ok", + "html": "", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("US"), + "currency": {"code": "USD", "symbol": "$", "localized_symbol": "USD$"}, + } + assert response_free_ch == { + "status": "ok", + "html": "", + "discount": 0.0, + "discount_str": "0.00%", + "coupon_code": "", + "country": Country("CH"), + "currency": {"code": "CHF", "symbol": "CHF", "localized_symbol": "CHF"}, + } + + assert response_vpn_cn == Response( + { + "status": "ok", + "html": None, + "discount": None, + "discount_str": None, + "coupon_code": None, + "country": {}, + "currency": {}, + } + ) + + +def test_get_banner_from_ip_none(): + parityvend = ParityVendAPI(secret_key) + + response = parityvend.get_banner_from_ip(google_ipv4) + response_vpn_cn = parityvend.get_banner_from_ip(ipv6_vpn_cn) + response_ch = parityvend.get_banner_from_ip(ipv4_switzerland) + + parityvend_free = ParityVendAPI(secret_key_free) + response_free = parityvend_free.get_banner_from_ip(google_ipv4) + response_free_ch = parityvend_free.get_banner_from_ip(ipv4_switzerland) + + assert ( + response + == response_ch + == response_vpn_cn + == response_free + == response_free_ch + == Response( + { + "status": "ok", + "html": None, + "discount": None, + "discount_str": None, + "coupon_code": None, + "country": {}, + "currency": {}, + } + ) + ) + + +def test_get_exchange_rate_info(): + parityvend = ParityVendAPI(secret_key) + + response = parityvend.get_exchange_rate_info() + rates = response["rates"] + + response_eur = parityvend.get_exchange_rate_info("EUR") + rates_eur = response_eur["rates"] + + assert "USD" in rates + assert "EUR" in rates + assert "GBP" in rates + assert rates_eur["EUR"] == 1.0 + assert rates["USD"] == 1.0 + assert (isinstance(i, float) for i in rates.values()) + + with pytest.raises(ProcessingError): + parityvend_free = ParityVendAPI(secret_key_free) + parityvend_free.get_exchange_rate_info() diff --git a/tests/handler_utils_test.py b/tests/handler_utils_test.py new file mode 100644 index 0000000..6dcdb64 --- /dev/null +++ b/tests/handler_utils_test.py @@ -0,0 +1,18 @@ +from ipaddress import IPv4Address, IPv6Address +from parityvend_api import ParityVendAPI + + +def test_auto_convert_ip(): + assert ParityVendAPI.auto_convert_ip("8.8.8.8") == "8.8.8.8" + assert ParityVendAPI.auto_convert_ip(b"8.8.8.8") == "8.8.8.8" + assert ParityVendAPI.auto_convert_ip(IPv4Address("8.8.8.8")) == "8.8.8.8" + + assert ParityVendAPI.auto_convert_ip("2c0f:f758::") == "2c0f:f758::" + assert ParityVendAPI.auto_convert_ip(b"2c0f:f758::") == "2c0f:f758::" + + assert ( + ParityVendAPI.auto_convert_ip( + IPv6Address("2c0f:f758:0000:0000:0000:0000:0000:0000") + ) + == "2c0f:f758:0000:0000:0000:0000:0000:0000" + ) diff --git a/tests/init_test.py b/tests/init_test.py new file mode 100644 index 0000000..101112f --- /dev/null +++ b/tests/init_test.py @@ -0,0 +1,29 @@ +import pytest +from parityvend_api import AsyncParityVendAPI, ParityVendAPI +from tests.variables import secret_key, invalid_secret_key + + +def test_async_handler_empty(): + with pytest.raises(TypeError): + AsyncParityVendAPI() + + +def test_handler_empty(): + with pytest.raises(TypeError): + ParityVendAPI() + + +def test_handler(): + assert isinstance(ParityVendAPI(secret_key), ParityVendAPI) + + +def test_async_handler(): + assert isinstance(AsyncParityVendAPI(secret_key), ParityVendAPI) + + +def test_handler_invalid(): + assert isinstance(ParityVendAPI(invalid_secret_key), ParityVendAPI) + + +def test_async_handler_invalid(): + assert isinstance(AsyncParityVendAPI(invalid_secret_key), ParityVendAPI) diff --git a/tests/variables.py b/tests/variables.py new file mode 100644 index 0000000..c20c322 --- /dev/null +++ b/tests/variables.py @@ -0,0 +1,14 @@ +from parityvend_api import env_get + +secret_key = env_get("parityvend_secret_key", "") +secret_key_free = env_get("parityvend_secret_key_free", "") +invalid_secret_key = "some-invalid-secret-key" + +ipv4_zimbabwe = "102.128.79.255" +ipv6_zimbabwe = "2c0f:f758::" +ipv4_switzerland = "102.129.143.0" + +ipv4_venezuela = "190.206.117.0" + +ipv6_vpn_cn = "2001:251::" +google_ipv4 = "8.8.8.8"