├── .github └── workflows │ ├── python-publish.yml │ ├── ruff.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── payjp ├── __init__.py ├── api_requestor.py ├── error.py ├── example.py ├── http_client.py ├── resource.py ├── test │ ├── __init__.py │ ├── helper.py │ ├── test_http_client.py │ ├── test_integration.py │ ├── test_requestor.py │ └── test_resources.py └── version.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── setup.py └── tox.ini /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | ruff: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Set up Python 11 | uses: actions/setup-python@v4 12 | with: 13 | python-version: '3.13' 14 | cache: 'pip' 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install ruff 19 | - name: Lint with ruff 20 | run: | 21 | ruff check . 22 | - name: Format with ruff 23 | run: | 24 | ruff format --check . -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - run: pip install tox 21 | - name: Test 22 | run: tox -e py 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | .DS_Store 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | bin/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | .eggs/ 17 | eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # Installer logs 28 | pip-log.txt 29 | pip-delete-this-directory.txt 30 | 31 | # Unit test / coverage reports 32 | htmlcov/ 33 | .tox/ 34 | .coverage 35 | .cache 36 | nosetests.xml 37 | coverage.xml 38 | 39 | # Translations 40 | *.mo 41 | 42 | # Mr Developer 43 | .idea 44 | .mr.developer.cfg 45 | .project 46 | .pydevproject 47 | 48 | # Rope 49 | .ropeproject 50 | 51 | # Django stuff: 52 | *.log 53 | *.pot 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010-2011 Stripe (http://stripe.com) 4 | Copyright (c) 2018 PAY, Inc. (https://pay.co.jp/) 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PAY.JP for Python 2 | 3 | [![Build Status](https://github.com/payjp/payjp-python/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/payjp/payjp-python/actions) 4 | 5 | ## Installation 6 | 7 | Install from PyPi using [pip](http://www.pip-installer.org/en/latest/), a 8 | package manager for Python. 9 | 10 | pip install payjp 11 | 12 | Or, you can [download the source code 13 | (ZIP)](https://github.com/payjp/payjp-python/zipball/master "payjp-python 14 | source code") for `payjp-python`, and then run: 15 | 16 | python setup.py install 17 | 18 | ## Documentation 19 | 20 | Please see our official [documentation](https://pay.jp/docs/api). 21 | 22 | ## Dependencies 23 | 24 | - Python 3.8 or later 25 | - requests 26 | 27 | Install dependencies from using [pip](http://www.pip-installer.org/en/latest/): 28 | 29 | pip install -r requirements.txt 30 | -------------------------------------------------------------------------------- /payjp/__init__.py: -------------------------------------------------------------------------------- 1 | # PAY.JP Python bindings 2 | 3 | # Configuration variables 4 | 5 | api_key = None 6 | api_base = "https://api.pay.jp" 7 | api_version = None 8 | 9 | max_retry = 0 10 | retry_initial_delay = 2 11 | retry_max_delay = 32 12 | 13 | # TODO include Card? 14 | __all__ = [ 15 | "Account", 16 | "Card", 17 | "Charge", 18 | "Customer", 19 | "Event", 20 | "Plan", 21 | "Subscription", 22 | "Token", 23 | "Transfer", 24 | "Statement", 25 | "Term", 26 | "Balance", 27 | "ThreeDSecureRequest", 28 | ] 29 | 30 | # Resource 31 | from payjp.resource import ( # noqa 32 | Account, 33 | Charge, 34 | Customer, 35 | Event, 36 | Plan, 37 | Subscription, 38 | Token, 39 | Transfer, 40 | Statement, 41 | Term, 42 | Balance, 43 | ThreeDSecureRequest, 44 | ) 45 | -------------------------------------------------------------------------------- /payjp/api_requestor.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import base64 4 | import calendar 5 | import datetime 6 | import json 7 | import logging 8 | import platform 9 | import random 10 | import time 11 | from urllib.parse import urlencode, urlsplit, urlunsplit 12 | 13 | import payjp 14 | 15 | from . import ( 16 | error, 17 | http_client, 18 | version, 19 | ) 20 | 21 | logger = logging.getLogger("payjp") 22 | 23 | 24 | class APIRequestor(object): 25 | def __init__(self, key=None, client=None, api_base=None, account=None): 26 | if api_base: 27 | self.api_base = api_base 28 | else: 29 | self.api_base = payjp.api_base 30 | self.api_key = key 31 | self.payjp_account = account 32 | 33 | self._client = client or http_client.new_default_http_client() 34 | 35 | def _get_retry_delay(self, retry_count): 36 | """Get retry delay seconds. 37 | 38 | Based on "Exponential backoff with equal jitter" algorithm. 39 | https://aws.amazon.com/jp/blogs/architecture/exponential-backoff-and-jitter/ 40 | """ 41 | wait = min(payjp.retry_max_delay, payjp.retry_initial_delay * 2**retry_count) 42 | return wait / 2 + random.uniform(0, wait / 2) 43 | 44 | def request(self, method, url, params=None, headers=None): 45 | max_retry = payjp.max_retry or 0 46 | for i in range(max_retry + 1): 47 | body, code, my_api_key = self.request_raw( 48 | method.lower(), url, params, headers 49 | ) 50 | if code != 429: 51 | break 52 | elif i != max_retry: 53 | wait = self._get_retry_delay(i) 54 | logger.debug("Retry after %s seconds." % wait) 55 | time.sleep(wait) 56 | 57 | response = self.interpret_response(body, code) 58 | return response, my_api_key 59 | 60 | def handle_api_error(self, body, code, response): 61 | try: 62 | err = response["error"] 63 | except (KeyError, TypeError): 64 | raise error.APIError( 65 | "Invalid response object from API: %r (HTTP response code " 66 | "was %d)" % (body, code), 67 | body, 68 | code, 69 | response, 70 | ) 71 | 72 | if code in [400, 404]: 73 | raise error.InvalidRequestError( 74 | err.get("message"), err.get("param"), body, code, response 75 | ) 76 | elif code == 401: 77 | raise error.AuthenticationError(err.get("message"), body, code, response) 78 | elif code == 402: 79 | raise error.CardError( 80 | err.get("message"), 81 | err.get("param"), 82 | err.get("code"), 83 | body, 84 | code, 85 | response, 86 | ) 87 | else: 88 | raise error.APIError(err.get("message"), body, code, response) 89 | 90 | def request_raw(self, method, url, params=None, supplied_headers=None): 91 | from payjp import api_version 92 | 93 | if self.api_key: 94 | my_api_key = self.api_key 95 | else: 96 | from payjp import api_key 97 | 98 | my_api_key = api_key 99 | 100 | if my_api_key is None: 101 | raise error.AuthenticationError( 102 | "No API key provided. (HINT: set your API key using " 103 | '"payjp.api_key = "). You can generate API keys ' 104 | "from the Payjp web interface. See https://docs.pay.jp" 105 | "for details, or email support@pay.jp if you have any " 106 | "questions." 107 | ) 108 | 109 | abs_url = "%s%s" % (self.api_base, url) 110 | 111 | encoded_params = urlencode(list(_api_encode(params or {}))) 112 | 113 | if method in ("get", "delete"): 114 | if params: 115 | abs_url = _build_api_url(abs_url, encoded_params) 116 | post_data = None 117 | elif method == "post": 118 | post_data = encoded_params 119 | else: 120 | raise error.APIConnectionError("Unrecognized HTTP method %r." % (method,)) 121 | 122 | ua = { 123 | "bindings_version": version.VERSION, 124 | "lang": "python", 125 | "publisher": "payjp", 126 | "httplib": self._client.name, 127 | } 128 | 129 | for attr, func in [ 130 | ["lang_version", platform.python_version], 131 | ["platform", platform.platform], 132 | ["uname", lambda: " ".join(platform.uname())], 133 | ]: 134 | try: 135 | val = func() 136 | except Exception as e: 137 | val = "!! %s" % (e,) 138 | ua[attr] = val 139 | 140 | encoded_api_key = str( 141 | base64.b64encode(bytes("".join([my_api_key, ":"]), "utf-8")), "utf-8" 142 | ) 143 | 144 | headers = { 145 | "X-Payjp-Client-User-Agent": json.dumps(ua), 146 | "User-Agent": "Payjp/v1 PythonBindings/%s" % (version.VERSION,), 147 | "Authorization": "Basic %s" % encoded_api_key, 148 | } 149 | 150 | if self.payjp_account: 151 | headers["Payjp-Account"] = self.payjp_account 152 | 153 | if method == "post": 154 | headers["Content-Type"] = "application/x-www-form-urlencoded" 155 | 156 | if api_version is not None: 157 | headers["Payjp-Version"] = api_version 158 | 159 | if supplied_headers is not None: 160 | for key, value in supplied_headers.items(): 161 | headers[key] = value 162 | 163 | body, code = self._client.request(method, abs_url, headers, post_data) 164 | 165 | logger.info("%s %s %d", method.upper(), abs_url, code) 166 | logger.debug( 167 | "API request to %s returned (response code, response body) of (%d, %r)", 168 | abs_url, 169 | code, 170 | body, 171 | ) 172 | 173 | return body, code, my_api_key 174 | 175 | def interpret_response(self, body, code): 176 | try: 177 | if hasattr(body, "decode"): 178 | body = body.decode("utf-8") 179 | response = json.loads(body) 180 | except Exception: 181 | raise error.APIError( 182 | "Invalid response body from API: %s " 183 | "(HTTP response code was %d)" % (body, code), 184 | body, 185 | code, 186 | ) 187 | if not (200 <= code < 300): 188 | self.handle_api_error(body, code, response) 189 | 190 | return response 191 | 192 | 193 | def _encode_datetime(dttime): 194 | if dttime.tzinfo and dttime.tzinfo.utcoffset(dttime) is not None: 195 | utc_timestamp = calendar.timegm(dttime.utctimetuple()) 196 | else: 197 | utc_timestamp = time.mktime(dttime.timetuple()) 198 | 199 | return int(utc_timestamp) 200 | 201 | 202 | def _api_encode(data): 203 | for key, value in data.items(): 204 | if value is None: 205 | continue 206 | elif hasattr(value, "payjp_id"): 207 | yield (key, value.payjp_id) 208 | elif isinstance(value, list) or isinstance(value, tuple): 209 | for subvalue in value: 210 | yield ("%s[]" % (key,), subvalue) 211 | elif isinstance(value, dict): 212 | subdict = dict( 213 | ("%s[%s]" % (key, subkey), subvalue) 214 | for subkey, subvalue in value.items() 215 | ) 216 | for subkey, subvalue in _api_encode(subdict): 217 | yield (subkey, subvalue) 218 | elif isinstance(value, datetime.datetime): 219 | yield (key, _encode_datetime(value)) 220 | else: 221 | yield (key, value) 222 | 223 | 224 | def _build_api_url(url, query): 225 | scheme, netloc, path, base_query, fragment = urlsplit(url) 226 | 227 | if base_query: 228 | query = "%s&%s" % (base_query, query) 229 | 230 | return urlunsplit((scheme, netloc, path, query, fragment)) 231 | -------------------------------------------------------------------------------- /payjp/error.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | 4 | class PayjpException(Exception): 5 | def __init__(self, message=None, http_body=None, http_status=None, json_body=None): 6 | super(PayjpException, self).__init__(message) 7 | 8 | if http_body and hasattr(http_body, "decode"): 9 | try: 10 | http_body = http_body.decode("utf-8") 11 | except Exception: 12 | http_body = ( 13 | "" 14 | ) 15 | 16 | self.http_body = http_body 17 | 18 | self.http_status = http_status 19 | self.json_body = json_body 20 | 21 | 22 | class APIError(PayjpException): 23 | pass 24 | 25 | 26 | class APIConnectionError(PayjpException): 27 | pass 28 | 29 | 30 | class CardError(PayjpException): 31 | def __init__( 32 | self, message, param, code, http_body=None, http_status=None, json_body=None 33 | ): 34 | super(CardError, self).__init__(message, http_body, http_status, json_body) 35 | self.param = param 36 | self.code = code 37 | 38 | 39 | class AuthenticationError(PayjpException): 40 | pass 41 | 42 | 43 | class InvalidRequestError(PayjpException): 44 | def __init__( 45 | self, message, param, http_body=None, http_status=None, json_body=None 46 | ): 47 | super(InvalidRequestError, self).__init__( 48 | message, http_body, http_status, json_body 49 | ) 50 | self.param = param 51 | -------------------------------------------------------------------------------- /payjp/example.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import payjp 4 | 5 | payjp.api_key = "sk_test_c62fade9d045b54cd76d7036" 6 | 7 | print("Attempting charge...") 8 | 9 | resp = payjp.Charge.create( 10 | amount=10, 11 | currency="jpy", 12 | card={"number": "4242424242424242", "exp_month": 12, "exp_year": 2018}, 13 | description="a TIROL Choco", 14 | ) 15 | 16 | print(resp) 17 | print(("Success: %r") % (resp,)) 18 | -------------------------------------------------------------------------------- /payjp/http_client.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import textwrap 4 | 5 | import requests 6 | 7 | from payjp import error 8 | 9 | 10 | def new_default_http_client(*args, **kwargs): 11 | impl = RequestsClient 12 | 13 | return impl(*args, **kwargs) 14 | 15 | 16 | class HTTPClient(object): 17 | def request(self, method, url, headers, post_data=None): 18 | raise NotImplementedError("HTTPClient subclasses must implement `request`") 19 | 20 | 21 | class RequestsClient(HTTPClient): 22 | name = "requests" 23 | 24 | def request(self, method, url, headers, post_data=None): 25 | kwargs = {} 26 | 27 | try: 28 | try: 29 | result = requests.request( 30 | method, url, headers=headers, data=post_data, timeout=80, **kwargs 31 | ) 32 | except TypeError as e: 33 | raise TypeError( 34 | "Warning: It looks like your installed version of the " 35 | '"requests" library is not compatible with Payjp\'s ' 36 | "usage thereof. (HINT: The most likely cause is that " 37 | 'your "requests" library is out of date. You can fix ' 38 | 'that by running "pip install -U requests".) The ' 39 | "underlying error was: %s" % (e,) 40 | ) 41 | 42 | # This causes the content to actually be read, which could cause 43 | # e.g. a socket timeout. TODO: The other fetch methods probably 44 | # are susceptible to the same and should be updated. 45 | content = result.content 46 | status_code = result.status_code 47 | except Exception as e: 48 | # Would catch just requests.exceptions.RequestException, but can 49 | # also raise ValueError, RuntimeError, etc. 50 | self._handle_request_error(e) 51 | return content, status_code 52 | 53 | def _handle_request_error(self, e): 54 | if isinstance(e, requests.exceptions.RequestException): 55 | msg = ( 56 | "Unexpected error communicating with Payjp. " 57 | "If this problem persists, let us know at " 58 | "support@payjp.com." 59 | ) 60 | err = "%s: %s" % (type(e).__name__, str(e)) 61 | else: 62 | msg = ( 63 | "Unexpected error communicating with Payjp. " 64 | "It looks like there's probably a configuration " 65 | "issue locally. If this problem persists, let us " 66 | "know at support@payjp.com." 67 | ) 68 | err = "A %s was raised" % (type(e).__name__,) 69 | if str(e): 70 | err += " with error message %s" % (str(e),) 71 | else: 72 | err += " with no error message" 73 | msg = textwrap.fill(msg) + "\n\n(Network error: %s)" % (err,) 74 | raise error.APIConnectionError(msg) 75 | -------------------------------------------------------------------------------- /payjp/resource.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import json 4 | import logging 5 | import sys 6 | from urllib.parse import quote_plus 7 | 8 | from payjp import api_requestor, error 9 | 10 | logger = logging.getLogger("payjp") 11 | 12 | 13 | def convert_to_payjp_object(resp, api_key, account, api_base=None): 14 | types = { 15 | "account": Account, 16 | "card": Card, 17 | "charge": Charge, 18 | "customer": Customer, 19 | "event": Event, 20 | "plan": Plan, 21 | "subscription": Subscription, 22 | "token": Token, 23 | "transfer": Transfer, 24 | "statement": Statement, 25 | "list": ListObject, 26 | "term": Term, 27 | "balance": Balance, 28 | "three_d_secure_request": ThreeDSecureRequest, 29 | } 30 | 31 | if isinstance(resp, list): 32 | return [convert_to_payjp_object(i, api_key, account, api_base) for i in resp] 33 | elif isinstance(resp, dict) and not isinstance(resp, PayjpObject): 34 | resp = resp.copy() 35 | klass_name = resp.get("object") 36 | if isinstance(klass_name, str): 37 | klass = types.get(klass_name, PayjpObject) 38 | else: 39 | klass = PayjpObject 40 | return klass.construct_from( 41 | resp, api_key, payjp_account=account, api_base=api_base 42 | ) 43 | else: 44 | return resp 45 | 46 | 47 | def _compute_diff(current, previous): 48 | if isinstance(current, dict): 49 | previous = previous or {} 50 | diff = current.copy() 51 | for key in set(previous.keys()) - set(diff.keys()): 52 | diff[key] = "" 53 | return diff 54 | return current if current is not None else "" 55 | 56 | 57 | def _serialize_list(array, previous): 58 | array = array or [] 59 | previous = previous or [] 60 | params = {} 61 | 62 | for i, v in enumerate(array): 63 | previous_item = previous[i] if len(previous) > i else None 64 | if hasattr(v, "serialize"): 65 | params[str(i)] = v.serialize(previous_item) 66 | else: 67 | params[str(i)] = _compute_diff(v, previous_item) 68 | 69 | return params 70 | 71 | 72 | class PayjpObject(dict): 73 | def __init__( 74 | self, id=None, api_key=None, payjp_account=None, api_base=None, **kwargs 75 | ): 76 | super(PayjpObject, self).__init__() 77 | 78 | self._unsaved_values = set() 79 | self._transient_values = set() 80 | 81 | self._retrieve_params = kwargs 82 | self._previous = None 83 | self._api_base = api_base 84 | 85 | object.__setattr__(self, "api_key", api_key) 86 | object.__setattr__(self, "payjp_account", payjp_account) 87 | 88 | if id: 89 | self["id"] = id 90 | 91 | def __setattr__(self, k, v): 92 | if k[0] == "_" or k in self.__dict__: 93 | return super(PayjpObject, self).__setattr__(k, v) 94 | else: 95 | self[k] = v 96 | 97 | def __getattr__(self, k): 98 | if k[0] == "_": 99 | raise AttributeError(k) 100 | 101 | try: 102 | return self[k] 103 | except KeyError as err: 104 | raise AttributeError(*err.args) 105 | 106 | def __setitem__(self, k, v): 107 | if v == "": 108 | raise ValueError( 109 | "You cannot set %s to an empty string. " 110 | "We interpret empty strings as None in requests." 111 | "You may set %s.%s = None to delete the property" % (k, str(self), k) 112 | ) 113 | 114 | super(PayjpObject, self).__setitem__(k, v) 115 | 116 | # Allows for unpickling in Python 3.x 117 | if not hasattr(self, "_unsaved_values"): 118 | self._unsaved_values = set() 119 | 120 | self._unsaved_values.add(k) 121 | 122 | def __getitem__(self, k): 123 | try: 124 | return super(PayjpObject, self).__getitem__(k) 125 | except KeyError as err: 126 | if k in self._transient_values: 127 | raise KeyError( 128 | "%r. HINT: The %r attribute was set in the past." 129 | "It was then wiped when refreshing the object with " 130 | "the result returned by PAY.JP's API, probably as a " 131 | "result of a save(). The attributes currently " 132 | "available on this object are: %s" % (k, k, ", ".join(self.keys())) 133 | ) 134 | else: 135 | raise err 136 | 137 | def __delitem__(self, k): 138 | raise TypeError( 139 | "You cannot delete attributes on a PayjpObject. " 140 | "To unset a property, set it to None." 141 | ) 142 | 143 | @classmethod 144 | def construct_from(cls, values, key, payjp_account=None, api_base=None): 145 | instance = cls(values.get("id"), api_key=key, payjp_account=payjp_account) 146 | instance.refresh_from( 147 | values, api_key=key, payjp_account=payjp_account, api_base=api_base 148 | ) 149 | return instance 150 | 151 | def refresh_from( 152 | self, values, api_key=None, partial=False, payjp_account=None, api_base=None 153 | ): 154 | self.api_key = api_key or getattr(values, "api_key", None) 155 | self.payjp_account = payjp_account or getattr(values, "payjp_account", None) 156 | if self.api_base is not None: 157 | self._api_base = api_base 158 | 159 | # Wipe old state before setting new. This is useful for e.g. 160 | # updating a customer, where there is no persistent card 161 | # parameter. Mark those values which don't persist as transient 162 | if partial: 163 | self._unsaved_values = self._unsaved_values - set(values) 164 | else: 165 | removed = set(self.keys()) - set(values) 166 | self._transient_values = self._transient_values | removed 167 | self._unsaved_values = set() 168 | self.clear() 169 | 170 | self._transient_values = self._transient_values - set(values) 171 | 172 | for k, v in values.items(): 173 | super(PayjpObject, self).__setitem__( 174 | k, convert_to_payjp_object(v, api_key, payjp_account, api_base) 175 | ) 176 | 177 | self._previous = values 178 | 179 | def serialize(self, previous): 180 | params = {} 181 | unsaved_keys = self._unsaved_values or set() 182 | previous = previous or self._previous or {} 183 | 184 | for k, v in self.items(): 185 | if k == "id" or (isinstance(k, str) and k.startswith("_")): 186 | continue 187 | elif isinstance(v, APIResource): 188 | continue 189 | elif hasattr(v, "serialize"): 190 | params[k] = v.serialize(previous.get(k, None)) 191 | elif k in unsaved_keys: 192 | params[k] = _compute_diff(v, previous.get(k, None)) 193 | elif k == "additional_owners" and v is not None: 194 | params[k] = _serialize_list(v, previous.get(k, None)) 195 | 196 | return params 197 | 198 | def api_base(self): 199 | return self._api_base 200 | 201 | def request(self, method, url, params=None, headers=None): 202 | if params is None: 203 | params = self._retrieve_params 204 | requestor = api_requestor.APIRequestor( 205 | key=self.api_key, api_base=self.api_base(), account=self.payjp_account 206 | ) 207 | response, api_key = requestor.request(method, url, params, headers) 208 | 209 | return convert_to_payjp_object( 210 | response, api_key, self.payjp_account, self.api_base() 211 | ) 212 | 213 | def __repr__(self): 214 | ident_parts = [type(self).__name__] 215 | 216 | if isinstance(self.get("object"), str): 217 | ident_parts.append(self.get("object")) 218 | 219 | if isinstance(self.get("id"), str): 220 | ident_parts.append("id=%s" % (self.get("id"),)) 221 | 222 | unicode_repr = "<%s at %s> JSON: %s" % ( 223 | " ".join(ident_parts), 224 | hex(id(self)), 225 | str(self), 226 | ) 227 | 228 | if sys.version_info[0] < 3: 229 | return unicode_repr.encode("utf-8") 230 | else: 231 | return unicode_repr 232 | 233 | def __str__(self): 234 | return json.dumps(self, sort_keys=True, indent=2) 235 | 236 | 237 | class ListObject(PayjpObject): 238 | def all(self, **params): 239 | return self.request("get", self["url"], params) 240 | 241 | def create(self, **params): 242 | # TODO divide into another parent class 243 | if ( 244 | hasattr(self, "object") 245 | and self.object == "list" 246 | and hasattr(self, "count") 247 | and self.count > 0 248 | and isinstance(self.data[0], Subscription) 249 | ): 250 | raise NotImplementedError( 251 | "Can't create a subscription via customer object. " 252 | "Use payjp.Subscription.create({'customer_id'}) instead." 253 | ) 254 | return self.request("post", self["url"], params) 255 | 256 | def retrieve(self, id, **params): 257 | base = self.get("url") 258 | extn = quote_plus(id) 259 | url = "%s/%s" % (base, extn) 260 | 261 | return self.request("get", url, params) 262 | 263 | 264 | class APIResource(PayjpObject): 265 | @classmethod 266 | def class_name(cls): 267 | return str(quote_plus(cls.__name__.lower())) 268 | 269 | @classmethod 270 | def class_url(cls): 271 | cls_name = cls.class_name() 272 | return "/v1/{0}s".format(cls_name) 273 | 274 | def instance_url(self): 275 | id = self.get("id") 276 | if not id: 277 | raise error.InvalidRequestError( 278 | "Could not create instance url without it's id", None 279 | ) 280 | 281 | base = self.class_url() 282 | ext = quote_plus(id) 283 | return "{0}/{1}".format(base, ext) 284 | 285 | @classmethod 286 | def retrieve(cls, id, api_key=None, payjp_account=None, api_base=None, **kwargs): 287 | instance = cls(id, api_key, payjp_account, api_base, **kwargs) 288 | instance.refresh() 289 | return instance 290 | 291 | def refresh(self): 292 | self.refresh_from(self.request("get", self.instance_url())) 293 | return self 294 | 295 | 296 | class ListableAPIResource(APIResource): 297 | @classmethod 298 | def all(cls, api_key=None, payjp_account=None, api_base=None, **params): 299 | requestor = api_requestor.APIRequestor( 300 | api_key, account=payjp_account, api_base=api_base 301 | ) 302 | url = cls.class_url() 303 | response, api_key = requestor.request("get", url, params) 304 | return convert_to_payjp_object(response, api_key, payjp_account, api_base) 305 | 306 | 307 | class CreateableAPIResource(APIResource): 308 | @classmethod 309 | def create(cls, api_key=None, payjp_account=None, headers=None, **params): 310 | requestor = api_requestor.APIRequestor(api_key, account=payjp_account) 311 | url = cls.class_url() 312 | response, api_key = requestor.request("post", url, params, headers) 313 | return convert_to_payjp_object(response, api_key, payjp_account) 314 | 315 | 316 | class UpdateableAPIResource(APIResource): 317 | def save(self): 318 | updated_params = self.serialize(None) 319 | 320 | if updated_params: 321 | self.refresh_from(self.request("post", self.instance_url(), updated_params)) 322 | else: 323 | logger.debug("Trying to save already saved object %r", self) 324 | return self 325 | 326 | 327 | class DeletableAPIResource(APIResource): 328 | def delete(self, **params): 329 | self.refresh_from(self.request("delete", self.instance_url(), params)) 330 | return self 331 | 332 | 333 | # resources 334 | 335 | 336 | class Token(CreateableAPIResource): 337 | def tds_finish(self, **kwargs): 338 | url = self.instance_url() + "/tds_finish" 339 | self.refresh_from(self.request("post", url, kwargs)) 340 | return self 341 | 342 | 343 | class Charge(CreateableAPIResource, ListableAPIResource, UpdateableAPIResource): 344 | def capture(self, **kwargs): 345 | url = self.instance_url() + "/capture" 346 | self.refresh_from(self.request("post", url, kwargs)) 347 | return self 348 | 349 | def refund(self, **kwargs): 350 | url = self.instance_url() + "/refund" 351 | self.refresh_from(self.request("post", url, kwargs)) 352 | return self 353 | 354 | def reauth(self, **kwargs): 355 | url = self.instance_url() + "/reauth" 356 | self.refresh_from(self.request("post", url, kwargs)) 357 | return self 358 | 359 | def tds_finish(self, **kwargs): 360 | url = self.instance_url() + "/tds_finish" 361 | self.refresh_from(self.request("post", url, kwargs)) 362 | return self 363 | 364 | 365 | class Event(ListableAPIResource): 366 | pass 367 | 368 | 369 | class Customer( 370 | CreateableAPIResource, 371 | UpdateableAPIResource, 372 | ListableAPIResource, 373 | DeletableAPIResource, 374 | ): 375 | def charges(self, **params): 376 | params["customer"] = self.id 377 | charges = Charge.all(self.api_key, params) 378 | return charges 379 | 380 | 381 | class Plan( 382 | CreateableAPIResource, 383 | DeletableAPIResource, 384 | UpdateableAPIResource, 385 | ListableAPIResource, 386 | ): 387 | pass 388 | 389 | 390 | class Account(APIResource): 391 | @classmethod 392 | def retrieve( 393 | cls, id=None, api_key=None, payjp_account=None, api_base=None, **params 394 | ): 395 | instance = cls(id, api_key, payjp_account, api_base, **params) 396 | instance.refresh() 397 | return instance 398 | 399 | def instance_url(self): 400 | id = self.get("id") 401 | if not id: 402 | return "/v1/accounts" 403 | base = self.class_url() 404 | extn = quote_plus(id) 405 | return "%s/%s" % (base, extn) 406 | 407 | 408 | class Card(UpdateableAPIResource, DeletableAPIResource): 409 | def instance_url(self): 410 | extn = quote_plus(self.id) 411 | if hasattr(self, "customer"): 412 | base = Customer.class_url() 413 | owner_extn = quote_plus(self.customer) 414 | 415 | else: 416 | raise error.InvalidRequestError( 417 | "Could not determine whether card_id %s is " 418 | "attached to a customer " 419 | "or a recipient." % self.id, 420 | "id", 421 | ) 422 | 423 | return "%s/%s/cards/%s" % (base, owner_extn, extn) 424 | 425 | @classmethod 426 | def retrieve(cls, id, api_key=None, payjp_account=None, api_base=None, **params): 427 | raise NotImplementedError( 428 | "Can't retrieve a card without a customer ID." 429 | "Use customer.cards.retrieve('card_id') or " 430 | "recipient.cards.retrieve('card_id') instead." 431 | ) 432 | 433 | 434 | class Subscription( 435 | CreateableAPIResource, 436 | DeletableAPIResource, 437 | UpdateableAPIResource, 438 | ListableAPIResource, 439 | ): 440 | def pause(self, **kwargs): 441 | url = self.instance_url() + "/pause" 442 | self.refresh_from(self.request("post", url, kwargs)) 443 | return self 444 | 445 | def resume(self, **kwargs): 446 | url = self.instance_url() + "/resume" 447 | self.refresh_from(self.request("post", url, kwargs)) 448 | return self 449 | 450 | def cancel(self, **kwargs): 451 | url = self.instance_url() + "/cancel" 452 | self.refresh_from(self.request("post", url, kwargs)) 453 | return self 454 | 455 | 456 | class Transfer(ListableAPIResource): 457 | pass 458 | 459 | 460 | class Statement(ListableAPIResource): 461 | def statement_urls(self, **kwargs): 462 | url = self.instance_url() + "/statement_urls" 463 | self.refresh_from(self.request("post", url, kwargs)) 464 | return self 465 | 466 | 467 | class Term(ListableAPIResource): 468 | pass 469 | 470 | 471 | class Balance(ListableAPIResource): 472 | def __init__(self, *args, **kwargs): 473 | super().__init__(*args, **kwargs) 474 | object.__setattr__(self, "statement_urls", self.__statement_urls) 475 | 476 | def __statement_urls(self, *args, **kwargs): 477 | return self.__class__.statement_urls(self.get("id"), *args, **kwargs) 478 | 479 | @classmethod 480 | def statement_urls( 481 | cls, id, api_key=None, payjp_account=None, api_base=None, **params 482 | ): 483 | requestor = api_requestor.APIRequestor( 484 | api_key, account=payjp_account, api_base=api_base 485 | ) 486 | url = cls.class_url() + f"/{id}/statement_urls" 487 | response, api_key = requestor.request("post", url, params) 488 | return convert_to_payjp_object(response, api_key, payjp_account, api_base) 489 | 490 | 491 | class ThreeDSecureRequest(CreateableAPIResource, ListableAPIResource): 492 | @classmethod 493 | def class_name(cls): 494 | return "three_d_secure_request" 495 | -------------------------------------------------------------------------------- /payjp/test/__init__.py: -------------------------------------------------------------------------------- 1 | import pkgutil 2 | import unittest 3 | 4 | 5 | def all_names(): 6 | for _, modname, _ in pkgutil.iter_modules(__path__): 7 | if modname.startswith("test_"): 8 | yield "payjp.test." + modname 9 | 10 | 11 | def all(): 12 | return unittest.defaultTestLoader.loadTestsFromNames(all_names()) 13 | 14 | 15 | def unit(): 16 | unit_names = [name for name in all_names() if "integration" not in name] 17 | return unittest.defaultTestLoader.loadTestsFromNames(unit_names) 18 | -------------------------------------------------------------------------------- /payjp/test/helper.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import random 4 | import re 5 | import string 6 | import unittest 7 | 8 | from mock import Mock, patch 9 | 10 | import payjp 11 | 12 | NOW = datetime.datetime.now() 13 | 14 | DUMMY_CARD = { 15 | "number": "4242424242424242", 16 | "exp_month": NOW.month, 17 | "exp_year": NOW.year + 4, 18 | } 19 | 20 | DUMMY_CHARGE = {"amount": 100, "currency": "jpy", "card": DUMMY_CARD} 21 | 22 | DUMMY_PLAN = { 23 | "amount": 2000, 24 | "interval": "month", 25 | "name": "Amazing Gold Plan", 26 | "currency": "jpy", 27 | "id": ( 28 | "payjp-test-gold-" 29 | + "".join(random.choice(string.ascii_lowercase) for x in range(10)) 30 | ), 31 | } 32 | 33 | DUMMY_TRANSFER = {"amount": 400, "currency": "jpy", "recipient": "self"} 34 | 35 | 36 | class PayjpTestCase(unittest.TestCase): 37 | RESTORE_ATTRIBUTES = ( 38 | "api_version", 39 | "api_key", 40 | "max_retry", 41 | "retry_initial_delay", 42 | "retry_max_delay", 43 | ) 44 | 45 | def setUp(self): 46 | super(PayjpTestCase, self).setUp() 47 | 48 | self._payjp_original_attributes = {} 49 | 50 | for attr in self.RESTORE_ATTRIBUTES: 51 | self._payjp_original_attributes[attr] = getattr(payjp, attr) 52 | 53 | api_base = os.environ.get("PAYJP_API_BASE") 54 | if api_base: 55 | payjp.api_base = api_base 56 | payjp.api_key = os.environ.get( 57 | "PAYJP_API_KEY", "sk_test_c62fade9d045b54cd76d7036" 58 | ) 59 | 60 | def tearDown(self): 61 | super(PayjpTestCase, self).tearDown() 62 | 63 | for attr in self.RESTORE_ATTRIBUTES: 64 | setattr(payjp, attr, self._payjp_original_attributes[attr]) 65 | 66 | # Python < 2.7 compatibility 67 | def assertRaisesRegexp(self, exception, regexp, callable, *args, **kwargs): 68 | try: 69 | callable(*args, **kwargs) 70 | except exception as err: 71 | if regexp is None: 72 | return True 73 | 74 | if isinstance(regexp, str): 75 | regexp = re.compile(regexp) 76 | if not regexp.search(str(err)): 77 | raise self.failureException( 78 | '"%s" does not match "%s"' % (regexp.pattern, str(err)) 79 | ) 80 | else: 81 | raise self.failureException("%s was not raised" % (exception.__name__,)) 82 | 83 | 84 | class PayjpUnitTestCase(PayjpTestCase): 85 | REQUEST_LIBRARIES = ["requests"] 86 | 87 | def setUp(self): 88 | super(PayjpUnitTestCase, self).setUp() 89 | 90 | self.request_patchers = {} 91 | self.request_mocks = {} 92 | for lib in self.REQUEST_LIBRARIES: 93 | patcher = patch("payjp.http_client.%s" % (lib,)) 94 | 95 | self.request_mocks[lib] = patcher.start() 96 | self.request_patchers[lib] = patcher 97 | 98 | def tearDown(self): 99 | super(PayjpUnitTestCase, self).tearDown() 100 | 101 | for patcher in self.request_patchers.values(): 102 | patcher.stop() 103 | 104 | 105 | class PayjpApiTestCase(PayjpTestCase): 106 | def setUp(self): 107 | super(PayjpApiTestCase, self).setUp() 108 | 109 | self.requestor_patcher = patch("payjp.api_requestor.APIRequestor") 110 | self.requestor_class_mock = self.requestor_patcher.start() 111 | self.requestor_mock = self.requestor_class_mock.return_value 112 | 113 | def tearDown(self): 114 | super(PayjpApiTestCase, self).tearDown() 115 | 116 | self.requestor_patcher.stop() 117 | 118 | def mock_response(self, res): 119 | self.requestor_mock.request = Mock(return_value=(res, "reskey")) 120 | 121 | 122 | class MyResource(payjp.resource.APIResource): 123 | pass 124 | 125 | 126 | class MyListable(payjp.resource.ListableAPIResource): 127 | pass 128 | 129 | 130 | class MyCreatable(payjp.resource.CreateableAPIResource): 131 | pass 132 | 133 | 134 | class MyUpdateable(payjp.resource.UpdateableAPIResource): 135 | pass 136 | 137 | 138 | class MyDeletable(payjp.resource.DeletableAPIResource): 139 | pass 140 | 141 | 142 | class MyComposite( 143 | payjp.resource.ListableAPIResource, 144 | payjp.resource.CreateableAPIResource, 145 | payjp.resource.UpdateableAPIResource, 146 | payjp.resource.DeletableAPIResource, 147 | ): 148 | pass 149 | -------------------------------------------------------------------------------- /payjp/test/test_http_client.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import unittest 4 | import warnings 5 | 6 | from mock import Mock 7 | 8 | import payjp 9 | from payjp.test.helper import PayjpUnitTestCase 10 | 11 | VALID_API_METHODS = ("get", "post", "delete") 12 | 13 | 14 | class HttpClientTests(PayjpUnitTestCase): 15 | def setUp(self): 16 | super(HttpClientTests, self).setUp() 17 | 18 | self.original_filters = warnings.filters[:] 19 | warnings.simplefilter("ignore") 20 | 21 | def tearDown(self): 22 | warnings.filters = self.original_filters 23 | 24 | super(HttpClientTests, self).tearDown() 25 | 26 | def check_default(self, none_libs, expected): 27 | for lib in none_libs: 28 | setattr(payjp.http_client, lib, None) 29 | 30 | inst = payjp.http_client.new_default_http_client() 31 | 32 | self.assertTrue(isinstance(inst, expected)) 33 | 34 | def test_new_default_http_client_requests(self): 35 | self.check_default((), payjp.http_client.RequestsClient) 36 | 37 | 38 | class ClientTestBase: 39 | @property 40 | def request_mock(self): 41 | return self.request_mocks[self.request_client.name] 42 | 43 | @property 44 | def valid_url(self, path="/foo"): 45 | return "https://api.pay.jp%s" % (path,) 46 | 47 | def make_request(self, method, url, headers, post_data): 48 | client = self.request_client() 49 | return client.request(method, url, headers, post_data) 50 | 51 | def mock_response(self, body, code): 52 | raise NotImplementedError("You must implement this in your test subclass") 53 | 54 | def mock_error(self, error): 55 | raise NotImplementedError("You must implement this in your test subclass") 56 | 57 | def check_call(self, meth, abs_url, headers, params): 58 | raise NotImplementedError("You must implement this in your test subclass") 59 | 60 | def test_request(self): 61 | self.mock_response(self.request_mock, '{"foo": "baz"}', 200) 62 | 63 | for meth in VALID_API_METHODS: 64 | abs_url = self.valid_url 65 | data = "" 66 | 67 | if meth != "post": 68 | abs_url = "%s?%s" % (abs_url, data) 69 | data = None 70 | 71 | headers = {"my-header": "header val"} 72 | 73 | body, code = self.make_request(meth, abs_url, headers, data) 74 | 75 | self.assertEqual(200, code) 76 | self.assertEqual('{"foo": "baz"}', body) 77 | 78 | self.check_call(self.request_mock, meth, abs_url, data, headers) 79 | 80 | def test_exception(self): 81 | self.mock_error(self.request_mock) 82 | self.assertRaises( 83 | payjp.error.APIConnectionError, 84 | self.make_request, 85 | "get", 86 | self.valid_url, 87 | {}, 88 | None, 89 | ) 90 | 91 | 92 | class RequestsClientTests(PayjpUnitTestCase, ClientTestBase): 93 | request_client = payjp.http_client.RequestsClient 94 | 95 | def mock_response(self, mock, body, code): 96 | result = Mock() 97 | result.content = body 98 | result.status_code = code 99 | 100 | mock.request = Mock(return_value=result) 101 | 102 | def mock_error(self, mock): 103 | mock.exceptions.RequestException = Exception 104 | mock.request.side_effect = mock.exceptions.RequestException() 105 | 106 | def check_call(self, mock, meth, url, post_data, headers): 107 | mock.request.assert_called_with( 108 | meth, url, headers=headers, data=post_data, timeout=80 109 | ) 110 | 111 | 112 | if __name__ == "__main__": 113 | unittest.main() 114 | -------------------------------------------------------------------------------- /payjp/test/test_integration.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import unittest 4 | 5 | import payjp 6 | from payjp.test.helper import DUMMY_CARD, NOW, PayjpTestCase 7 | 8 | 9 | class AuthenticationErrorTest(PayjpTestCase): 10 | def test_invalid_credentials(self): 11 | key = payjp.api_key 12 | try: 13 | payjp.api_key = "invalid" 14 | payjp.Customer.create() 15 | except payjp.error.AuthenticationError as e: 16 | self.assertEqual(401, e.http_status) 17 | self.assertTrue(isinstance(e.http_body, str)) 18 | self.assertTrue(isinstance(e.json_body, dict)) 19 | finally: 20 | payjp.api_key = key 21 | 22 | 23 | class CardErrorTest(PayjpTestCase): 24 | def test_invalid_card_props(self): 25 | EXPIRED_CARD = DUMMY_CARD.copy() 26 | EXPIRED_CARD["exp_month"] = NOW.month 27 | EXPIRED_CARD["exp_year"] = NOW.year 28 | try: 29 | payjp.Charge.create(amount=100, currency="jpy", card=EXPIRED_CARD) 30 | except payjp.error.InvalidRequestError as e: 31 | self.assertEqual(400, e.http_status) 32 | self.assertTrue(isinstance(e.http_body, str)) 33 | self.assertTrue(isinstance(e.json_body, dict)) 34 | 35 | 36 | class InvalidRequestErrorTest(PayjpTestCase): 37 | def test_nonexistent_object(self): 38 | try: 39 | payjp.Charge.retrieve("invalid") 40 | except payjp.error.InvalidRequestError as e: 41 | self.assertEqual(404, e.http_status) 42 | self.assertTrue(isinstance(e.http_body, str)) 43 | self.assertTrue(isinstance(e.json_body, dict)) 44 | 45 | def test_invalid_data(self): 46 | try: 47 | payjp.Charge.create() 48 | except payjp.error.InvalidRequestError as e: 49 | self.assertEqual(400, e.http_status) 50 | self.assertTrue(isinstance(e.http_body, str)) 51 | self.assertTrue(isinstance(e.json_body, dict)) 52 | 53 | 54 | if __name__ == "__main__": 55 | unittest.main() 56 | -------------------------------------------------------------------------------- /payjp/test/test_requestor.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import base64 4 | import datetime 5 | import unittest 6 | from urllib.parse import parse_qsl, urlsplit 7 | 8 | from mock import Mock, patch 9 | 10 | import payjp 11 | from payjp.test.helper import PayjpUnitTestCase 12 | 13 | VALID_API_METHODS = ("get", "post", "delete") 14 | 15 | 16 | class GMT1(datetime.tzinfo): 17 | def utcoffset(self, dt): 18 | return datetime.timedelta(hours=1) 19 | 20 | def dst(self, dt): 21 | return datetime.timedelta(0) 22 | 23 | def tzname(self, dt): 24 | return "Europe/Prague" 25 | 26 | 27 | class APIHeaderMatcher(object): 28 | EXP_KEYS = ["X-Payjp-Client-User-Agent", "User-Agent", "Authorization"] 29 | METHOD_EXTRA_KEYS = {"post": ["Content-Type"]} 30 | 31 | def __init__(self, api_key=None, extra={}, request_method=None): 32 | self.request_method = request_method 33 | if api_key is not None: 34 | self.api_key = self._encode(api_key) 35 | else: 36 | self.api_key = self._encode(payjp.api_key) 37 | self.extra = extra 38 | 39 | def __eq__(self, other): 40 | return ( 41 | self._keys_match(other) 42 | and self._auth_match(other) 43 | and self._extra_match(other) 44 | ) 45 | 46 | def _encode(self, api_key): 47 | return str(base64.b64encode(bytes("".join([api_key, ":"]), "utf-8")), "utf-8") 48 | 49 | def _keys_match(self, other): 50 | expected_keys = self.EXP_KEYS + list(self.extra.keys()) 51 | if ( 52 | self.request_method is not None 53 | and self.request_method in self.METHOD_EXTRA_KEYS 54 | ): 55 | expected_keys.extend(self.METHOD_EXTRA_KEYS[self.request_method]) 56 | 57 | return sorted(other.keys()) == sorted(expected_keys) 58 | 59 | def _auth_match(self, other): 60 | return other["Authorization"] == "Basic %s" % (self.api_key,) 61 | 62 | def _extra_match(self, other): 63 | for k, v in self.extra.items(): 64 | if other[k] != v: 65 | return False 66 | 67 | return True 68 | 69 | 70 | class QueryMatcher(object): 71 | def __init__(self, expected): 72 | self.expected = sorted(expected) 73 | 74 | def __eq__(self, other): 75 | query = urlsplit(other).query or other 76 | 77 | parsed = parse_qsl(query) 78 | return self.expected == sorted(parsed) 79 | 80 | 81 | class UrlMatcher(object): 82 | def __init__(self, expected): 83 | self.exp_parts = urlsplit(expected) 84 | 85 | def __eq__(self, other): 86 | other_parts = urlsplit(other) 87 | 88 | for part in ("scheme", "netloc", "path", "fragment"): 89 | expected = getattr(self.exp_parts, part) 90 | actual = getattr(other_parts, part) 91 | if expected != actual: 92 | print((('Expected %s "%s" but got "%s"') % (part, expected, actual))) 93 | return False 94 | 95 | q_matcher = QueryMatcher(parse_qsl(self.exp_parts.query)) 96 | return q_matcher == other 97 | 98 | 99 | class APIRequestorRequestTests(PayjpUnitTestCase): 100 | ENCODE_INPUTS = { 101 | "dict": { 102 | "astring": "bar", 103 | "anint": 5, 104 | "anull": None, 105 | "adatetime": datetime.datetime(2013, 1, 1, tzinfo=GMT1()), 106 | "atuple": (1, 2), 107 | "adict": {"foo": "bar", "boz": 5}, 108 | "alist": ["foo", "bar"], 109 | }, 110 | "list": [1, "foo", "baz"], 111 | "string": "boo", 112 | "unicode": "\u1234", 113 | "datetime": datetime.datetime(2013, 1, 1, second=1, tzinfo=GMT1()), 114 | "none": None, 115 | } 116 | 117 | ENCODE_EXPECTATIONS = { 118 | "dict": [ 119 | ("%s[astring]", "bar"), 120 | ("%s[anint]", 5), 121 | ("%s[adatetime]", 1356994800), 122 | ("%s[adict][foo]", "bar"), 123 | ("%s[adict][boz]", 5), 124 | ("%s[alist][]", "foo"), 125 | ("%s[alist][]", "bar"), 126 | ("%s[atuple][]", 1), 127 | ("%s[atuple][]", 2), 128 | ], 129 | "list": [ 130 | ("%s[]", 1), 131 | ("%s[]", "foo"), 132 | ("%s[]", "baz"), 133 | ], 134 | "string": [("%s", "boo")], 135 | "unicode": [("%s", "\u1234")], 136 | "datetime": [("%s", 1356994801)], 137 | "none": [], 138 | } 139 | 140 | def setUp(self): 141 | super(APIRequestorRequestTests, self).setUp() 142 | 143 | self.http_client = Mock(payjp.http_client.HTTPClient) 144 | self.http_client.name = "mockclient" 145 | 146 | self.requestor = payjp.api_requestor.APIRequestor(client=self.http_client) 147 | 148 | def mock_response(self, return_body, return_code, requestor=None): 149 | if not requestor: 150 | requestor = self.requestor 151 | 152 | self.http_client.request = Mock(return_value=(return_body, return_code)) 153 | 154 | def check_call( 155 | self, meth, abs_url=None, headers=None, post_data=None, requestor=None 156 | ): 157 | if not abs_url: 158 | abs_url = "https://api.pay.jp%s" % (self.valid_path,) 159 | if not requestor: 160 | requestor = self.requestor 161 | if not headers: 162 | headers = APIHeaderMatcher(request_method=meth) 163 | 164 | self.http_client.request.assert_called_with(meth, abs_url, headers, post_data) 165 | 166 | @property 167 | def valid_path(self): 168 | return "/foo" 169 | 170 | def encoder_check(self, key): 171 | stk_key = "my%s" % (key,) 172 | 173 | value = self.ENCODE_INPUTS[key] 174 | expectation = [(k % (stk_key,), v) for k, v in self.ENCODE_EXPECTATIONS[key]] 175 | 176 | stk = [] 177 | fn = getattr(payjp.api_requestor.APIRequestor, "encode_%s" % (key,)) 178 | fn(stk, stk_key, value) 179 | 180 | if isinstance(value, dict): 181 | expectation.sort() 182 | stk.sort() 183 | 184 | self.assertEqual(expectation, stk) 185 | 186 | def _test_encode_naive_datetime(self): 187 | stk = [] 188 | 189 | payjp.api_requestor.APIRequestor.encode_datetime( 190 | stk, "test", datetime.datetime(2013, 1, 1) 191 | ) 192 | 193 | # Naive datetimes will encode differently depending on your system 194 | # local time. Since we don't know the local time of your system, 195 | # we just check that naive encodings are within 24 hours of correct. 196 | self.assertTrue(60 * 60 * 24 > abs(stk[0][1] - 1356994800)) 197 | 198 | def test_param_encoding(self): 199 | self.mock_response("{}", 200) 200 | 201 | self.requestor.request("get", "", self.ENCODE_INPUTS) 202 | 203 | expectation = [] 204 | for type_, values in self.ENCODE_EXPECTATIONS.items(): 205 | expectation.extend([(k % (type_,), str(v)) for k, v in values]) 206 | 207 | self.check_call("get", QueryMatcher(expectation)) 208 | 209 | def test_dictionary_list_encoding(self): 210 | params = { 211 | "foo": { 212 | "0": { 213 | "bar": "bat", 214 | } 215 | } 216 | } 217 | encoded = list(payjp.api_requestor._api_encode(params)) 218 | key, value = encoded[0] 219 | 220 | self.assertEqual("foo[0][bar]", key) 221 | self.assertEqual("bat", value) 222 | 223 | def test_url_construction(self): 224 | CASES = ( 225 | ("https://api.pay.jp?foo=bar", "", {"foo": "bar"}), 226 | ("https://api.pay.jp?foo=bar", "?", {"foo": "bar"}), 227 | ("https://api.pay.jp", "", {}), 228 | ( 229 | "https://api.pay.jp/%20spaced?foo=bar%24&baz=5", 230 | "/%20spaced?foo=bar%24", 231 | {"baz": "5"}, 232 | ), 233 | ("https://api.pay.jp?foo=bar&foo=bar", "?foo=bar", {"foo": "bar"}), 234 | ) 235 | 236 | for expected, url, params in CASES: 237 | self.mock_response("{}", 200) 238 | 239 | self.requestor.request("get", url, params) 240 | 241 | self.check_call("get", expected) 242 | 243 | def test_empty_methods(self): 244 | for meth in VALID_API_METHODS: 245 | self.mock_response("{}", 200) 246 | 247 | body, key = self.requestor.request(meth, self.valid_path, {}) 248 | 249 | if meth == "post": 250 | post_data = "" 251 | else: 252 | post_data = None 253 | 254 | self.check_call(meth, post_data=post_data) 255 | self.assertEqual({}, body) 256 | 257 | def test_methods_with_params_and_response(self): 258 | for meth in VALID_API_METHODS: 259 | self.mock_response('{"foo": "bar", "baz": 6}', 200) 260 | 261 | params = { 262 | "alist": [1, 2, 3], 263 | "adict": {"frobble": "bits"}, 264 | "adatetime": datetime.datetime(2013, 1, 1, tzinfo=GMT1()), 265 | } 266 | encoded = ( 267 | "adict%5Bfrobble%5D=bits&adatetime=1356994800&" 268 | "alist%5B%5D=1&alist%5B%5D=2&alist%5B%5D=3" 269 | ) 270 | 271 | body, key = self.requestor.request(meth, self.valid_path, params) 272 | self.assertEqual({"foo": "bar", "baz": 6}, body) 273 | 274 | if meth == "post": 275 | self.check_call(meth, post_data=QueryMatcher(parse_qsl(encoded))) 276 | else: 277 | abs_url = "https://api.pay.jp%s?%s" % (self.valid_path, encoded) 278 | self.check_call(meth, abs_url=UrlMatcher(abs_url)) 279 | 280 | def test_uses_headers(self): 281 | self.mock_response("{}", 200) 282 | self.requestor.request("get", self.valid_path, {}, {"foo": "bar"}) 283 | self.check_call("get", headers=APIHeaderMatcher(extra={"foo": "bar"})) 284 | 285 | def test_uses_instance_key(self): 286 | key = "fookey" 287 | requestor = payjp.api_requestor.APIRequestor(key, client=self.http_client) 288 | 289 | self.mock_response("{}", 200, requestor=requestor) 290 | 291 | body, used_key = requestor.request("get", self.valid_path, {}) 292 | 293 | self.check_call( 294 | "get", 295 | headers=APIHeaderMatcher(key, request_method="get"), 296 | requestor=requestor, 297 | ) 298 | self.assertEqual(key, used_key) 299 | 300 | def test_passes_api_version(self): 301 | payjp.api_version = "fooversion" 302 | 303 | self.mock_response("{}", 200) 304 | 305 | body, key = self.requestor.request("get", self.valid_path, {}) 306 | 307 | self.check_call( 308 | "get", 309 | headers=APIHeaderMatcher( 310 | extra={"Payjp-Version": "fooversion"}, request_method="get" 311 | ), 312 | ) 313 | 314 | def test_uses_instance_account(self): 315 | account = "acct_foo" 316 | requestor = payjp.api_requestor.APIRequestor( 317 | account=account, client=self.http_client 318 | ) 319 | 320 | self.mock_response("{}", 200, requestor=requestor) 321 | 322 | requestor.request("get", self.valid_path, {}) 323 | 324 | self.check_call( 325 | "get", 326 | requestor=requestor, 327 | headers=APIHeaderMatcher( 328 | extra={"Payjp-Account": account}, request_method="get" 329 | ), 330 | ) 331 | 332 | def test_fails_without_api_key(self): 333 | payjp.api_key = None 334 | 335 | self.assertRaises( 336 | payjp.error.AuthenticationError, 337 | self.requestor.request, 338 | "get", 339 | self.valid_path, 340 | {}, 341 | ) 342 | 343 | def test_not_found(self): 344 | self.mock_response('{"error": {}}', 404) 345 | 346 | self.assertRaises( 347 | payjp.error.InvalidRequestError, 348 | self.requestor.request, 349 | "get", 350 | self.valid_path, 351 | {}, 352 | ) 353 | 354 | def test_authentication_error(self): 355 | self.mock_response('{"error": {}}', 401) 356 | 357 | self.assertRaises( 358 | payjp.error.AuthenticationError, 359 | self.requestor.request, 360 | "get", 361 | self.valid_path, 362 | {}, 363 | ) 364 | 365 | def test_card_error(self): 366 | self.mock_response('{"error": {}}', 402) 367 | 368 | self.assertRaises( 369 | payjp.error.CardError, self.requestor.request, "get", self.valid_path, {} 370 | ) 371 | 372 | def test_too_many_request_error(self): 373 | self.mock_response('{"error": {}}', 429) 374 | 375 | self.assertRaises( 376 | payjp.error.APIError, self.requestor.request, "get", self.valid_path, {} 377 | ) 378 | 379 | def test_server_error(self): 380 | self.mock_response('{"error": {}}', 500) 381 | 382 | self.assertRaises( 383 | payjp.error.APIError, self.requestor.request, "get", self.valid_path, {} 384 | ) 385 | 386 | def test_invalid_json(self): 387 | self.mock_response("{", 200) 388 | 389 | self.assertRaises( 390 | payjp.error.APIError, self.requestor.request, "get", self.valid_path, {} 391 | ) 392 | 393 | def test_invalid_method(self): 394 | self.assertRaises( 395 | payjp.error.APIConnectionError, self.requestor.request, "foo", "bar" 396 | ) 397 | 398 | 399 | class APIRequestorRetryTest(PayjpUnitTestCase): 400 | def setUp(self): 401 | super(APIRequestorRetryTest, self).setUp() 402 | self.return_values = [] 403 | 404 | def return_value_generator(): 405 | for status in self.return_values: 406 | yield ( 407 | '{{"error": {{"status": {status}, "message": "test"}}}}'.format( 408 | status=status 409 | ), 410 | status, 411 | "sk_live_aaa", 412 | ) 413 | 414 | gen = return_value_generator() 415 | 416 | def request_raw(*args, **kw): 417 | return next(gen) 418 | 419 | self.request_raw_patch = patch( 420 | "payjp.api_requestor.APIRequestor.request_raw", request_raw 421 | ) 422 | 423 | self.requestor = payjp.api_requestor.APIRequestor() 424 | 425 | def test_retry_disabled(self): 426 | payjp.max_retry = 0 427 | payjp.retry_initial_delay = 0.1 428 | self.return_values = [499, 599] # returns 599 at 2nd try 429 | with self.request_raw_patch: 430 | with self.assertRaises(payjp.error.APIError) as error: 431 | self.requestor.request("get", "/test", {}) 432 | 433 | self.assertEqual(error.exception.http_status, 499) 434 | 435 | def test_no_retry(self): 436 | payjp.max_retry = 2 437 | payjp.retry_initial_delay = 0.1 438 | self.return_values = [599, 429, 429, 429] # returns 599 at first try 439 | with self.request_raw_patch: 440 | with self.assertRaises(payjp.error.APIError) as error: 441 | self.requestor.request("get", "/test", {}) 442 | 443 | self.assertEqual(error.exception.http_status, 599) 444 | 445 | def test_full_retry(self): 446 | """Returns 429 after exceeds max retry""" 447 | payjp.max_retry = 2 448 | payjp.retry_initial_delay = 0.1 449 | self.return_values = [ 450 | 429, 451 | 429, 452 | 429, 453 | 200, 454 | ] # first try + 2 retries + unexpected 200 455 | with self.request_raw_patch: 456 | with self.assertRaises(payjp.error.APIError) as error: 457 | self.requestor.request("get", "/test", {}) 458 | 459 | self.assertEqual(error.exception.http_status, 429) 460 | 461 | def test_success_at_halfway_of_retries(self): 462 | payjp.max_retry = 5 463 | payjp.retry_initial_delay = 0.1 464 | self.return_values = [ 465 | 429, 466 | 599, 467 | 429, 468 | 429, 469 | 429, 470 | ] # returns not 429 status at 2nd try 471 | with self.request_raw_patch: 472 | with self.assertRaises(payjp.error.APIError) as error: 473 | self.requestor.request("get", "/test", {}) 474 | 475 | self.assertEqual(error.exception.http_status, 599) 476 | 477 | 478 | class APIRequestorRetryIntervalTest(PayjpUnitTestCase): 479 | def setUp(self): 480 | super(APIRequestorRetryIntervalTest, self).setUp() 481 | self.requestor = payjp.api_requestor.APIRequestor() 482 | 483 | def test_retry_initial_delay(self): 484 | payjp.retry_initial_delay = 2 485 | self.assertTrue(1 <= self.requestor._get_retry_delay(0) <= 2) 486 | self.assertTrue(2 <= self.requestor._get_retry_delay(1) <= 4) 487 | self.assertTrue(4 <= self.requestor._get_retry_delay(2) <= 8) 488 | # cap 489 | self.assertTrue(16 <= self.requestor._get_retry_delay(4) <= 32) 490 | self.assertTrue(16 <= self.requestor._get_retry_delay(10) <= 32) 491 | 492 | 493 | if __name__ == "__main__": 494 | unittest.main() 495 | -------------------------------------------------------------------------------- /payjp/test/test_resources.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import pickle 4 | import unittest 5 | 6 | import payjp 7 | import payjp.resource 8 | from payjp.test.helper import ( 9 | DUMMY_CARD, 10 | DUMMY_CHARGE, 11 | DUMMY_PLAN, 12 | NOW, 13 | MyCreatable, 14 | MyDeletable, 15 | MyListable, 16 | MyResource, 17 | MyUpdateable, 18 | PayjpApiTestCase, 19 | PayjpUnitTestCase, 20 | ) 21 | 22 | 23 | class PayjpObjectTests(PayjpUnitTestCase): 24 | def test_initializes_with_parameters(self): 25 | obj = payjp.resource.PayjpObject("foo", "bar", myparam=5, yourparam="boo") 26 | 27 | self.assertEqual("foo", obj.id) 28 | self.assertEqual("bar", obj.api_key) 29 | 30 | def test_access(self): 31 | obj = payjp.resource.PayjpObject("myid", "mykey", myparam=5) 32 | 33 | # Empty 34 | self.assertRaises(AttributeError, getattr, obj, "myattr") 35 | self.assertRaises(KeyError, obj.__getitem__, "myattr") 36 | self.assertEqual("def", obj.get("myattr", "def")) 37 | self.assertEqual(None, obj.get("myattr")) 38 | 39 | # Setters 40 | obj.myattr = "myval" 41 | obj["myitem"] = "itval" 42 | self.assertEqual("sdef", obj.setdefault("mydef", "sdef")) 43 | 44 | # Getters 45 | self.assertEqual("myval", obj.setdefault("myattr", "sdef")) 46 | self.assertEqual("myval", obj.myattr) 47 | self.assertEqual("myval", obj["myattr"]) 48 | self.assertEqual("myval", obj.get("myattr")) 49 | 50 | self.assertEqual(["id", "myattr", "mydef", "myitem"], sorted(obj.keys())) 51 | self.assertEqual(["itval", "myid", "myval", "sdef"], sorted(obj.values())) 52 | 53 | # Illegal operations 54 | self.assertRaises(ValueError, setattr, obj, "foo", "") 55 | self.assertRaises(TypeError, obj.__delitem__, "myattr") 56 | 57 | def test_refresh_from(self): 58 | obj = payjp.resource.PayjpObject.construct_from( 59 | { 60 | "foo": "bar", 61 | "trans": "me", 62 | }, 63 | "mykey", 64 | ) 65 | 66 | self.assertEqual("mykey", obj.api_key) 67 | self.assertEqual("bar", obj.foo) 68 | self.assertEqual("me", obj["trans"]) 69 | self.assertEqual(None, obj.payjp_account) 70 | 71 | obj.refresh_from( 72 | { 73 | "foo": "baz", 74 | "johnny": 5, 75 | }, 76 | "key2", 77 | payjp_account="acct_foo", 78 | ) 79 | 80 | self.assertEqual(5, obj.johnny) 81 | self.assertEqual("baz", obj.foo) 82 | self.assertRaises(AttributeError, getattr, obj, "trans") 83 | self.assertEqual("key2", obj.api_key) 84 | self.assertEqual("acct_foo", obj.payjp_account) 85 | 86 | obj.refresh_from({"trans": 4, "metadata": {"amount": 42}}, "key2", True) 87 | 88 | self.assertEqual("baz", obj.foo) 89 | self.assertEqual(4, obj.trans) 90 | 91 | def test_passing_nested_refresh(self): 92 | obj = payjp.resource.PayjpObject.construct_from( 93 | { 94 | "foos": { 95 | "type": "list", 96 | "data": [{"id": "nested"}], 97 | } 98 | }, 99 | "key", 100 | payjp_account="acct_foo", 101 | ) 102 | 103 | nested = obj.foos.data[0] 104 | 105 | self.assertEqual("key", obj.api_key) 106 | self.assertEqual("nested", nested.id) 107 | self.assertEqual("key", nested.api_key) 108 | self.assertEqual("acct_foo", nested.payjp_account) 109 | 110 | def check_invoice_data(self, data): 111 | # Check rough structure 112 | self.assertEqual(20, len(data.keys())) 113 | self.assertEqual(3, len(data["lines"].keys())) 114 | self.assertEqual(0, len(data["lines"]["invoiceitems"])) 115 | self.assertEqual(1, len(data["lines"]["subscriptions"])) 116 | 117 | # Check various data types 118 | self.assertEqual(1338238728, data["date"]) 119 | self.assertEqual(None, data["next_payment_attempt"]) 120 | self.assertEqual(False, data["livemode"]) 121 | self.assertEqual("month", data["lines"]["subscriptions"][0]["plan"]["interval"]) 122 | 123 | def test_repr(self): 124 | obj = payjp.resource.PayjpObject("foo", "bar", myparam=5) 125 | 126 | obj["object"] = "\u4e00boo\u1f00" 127 | 128 | res = repr(obj) 129 | 130 | self.assertTrue("=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.ruff] 6 | target-version = "py38" 7 | line-length = 88 8 | 9 | [tool.ruff.lint] 10 | select = [ 11 | "E", 12 | "F", 13 | "I", 14 | "W", 15 | ] 16 | ignore = ["E501"] 17 | 18 | [tool.ruff.lint.isort] 19 | known-first-party = ["payjp"] 20 | 21 | [tool.ruff.format] 22 | quote-style = "double" 23 | indent-style = "space" 24 | line-ending = "auto" -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pytest>=7.0.0 3 | mock>=1.0.1 4 | ruff>=0.1.0 5 | tox>=4.0.0 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from setuptools import setup 4 | 5 | install_requires = [] 6 | 7 | if sys.version_info < (3, 8): 8 | raise DeprecationWarning( 9 | "Python versions below 3.8 are no longer supported by PAY.JP. Please use Python 3.8 or higher." 10 | ) 11 | 12 | install_requires.append("requests >= 2.7.0") 13 | 14 | setup( 15 | name="payjp", 16 | version="1.6.1", 17 | description="PAY.JP python bindings", 18 | author="PAY.JP", 19 | author_email="support@pay.jp", 20 | packages=["payjp", "payjp.test"], 21 | url="https://github.com/payjp/payjp-python", 22 | install_requires=install_requires, 23 | python_requires=">=3.0", 24 | ) 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = pypy3, py36, py37, py38, py39 8 | 9 | [testenv] 10 | deps = 11 | requests>=2.7.0 12 | mock>=1.0.1 13 | pytest 14 | commands = pytest {posargs} # pytestを使用してテストを実行 15 | 16 | [testenv:ruff] 17 | deps = ruff 18 | skip_install = true 19 | commands = 20 | ruff check . 21 | ruff format --check . 22 | --------------------------------------------------------------------------------