Compare commits
10 commits
e7342c582e
...
f65e353cd9
Author | SHA1 | Date | |
---|---|---|---|
|
f65e353cd9 | ||
|
c4561a0026 | ||
|
85f1aa66a6 | ||
|
f3c5c92b49 | ||
|
09754b9788 | ||
|
b0d5fe5aa8 | ||
|
1b0740ad5d | ||
|
86b8eddc2e | ||
|
c625a57aad | ||
|
efaeb53e91 |
10 changed files with 164 additions and 91 deletions
2
CODE.rst
2
CODE.rst
|
@ -14,4 +14,4 @@ The ``paypal-query`` tool is implemented in ``cliquery.py``. Other submodules li
|
||||||
Running tests
|
Running tests
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
Run ``./setup.py test`` to run unit tests. Run ``./setup.py typecheck`` to run the type checker. Run ``tox`` to run both on all supported Pythons.
|
Run ``pytest`` to run unit tests. Run ``mypy paypal_rest`` to run the type checker. Run ``tox`` to run both on all supported Pythons.
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
include LICENSE.txt
|
include LICENSE.txt
|
||||||
include *.rst
|
include *.rst
|
||||||
include tests/*.py
|
include tests/*.py
|
||||||
|
include paypal_rest/py.typed
|
||||||
|
|
10
README.rst
10
README.rst
|
@ -9,7 +9,7 @@ Introduction
|
||||||
paypal-query tool
|
paypal-query tool
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
This library includes a command line tool, ``paypal-query``, to quickly get information from the API; provide an illustration of using the library; and help with debugging. To use it, first write a configuration file ``~/.config/paypal_rest/config.ini`` with your client credentials from PayPal::
|
This library includes a command line tool, ``paypal-query``, to quickly get information from the API; provide an illustration of using the library; and help with debugging. To use it, first write a configuration file ``~/.config/paypal_rest/config.ini`` with your REST API app credentials from PayPal::
|
||||||
|
|
||||||
[query]
|
[query]
|
||||||
client_id = ...
|
client_id = ...
|
||||||
|
@ -21,7 +21,7 @@ To see an overview of transactions over a time period::
|
||||||
|
|
||||||
paypal-query [--begin DATETIME] [--end DATETIME]
|
paypal-query [--begin DATETIME] [--end DATETIME]
|
||||||
|
|
||||||
Specify all datetimes in ISO8601 format: ``YYYY-MM-DDTHH:MM:SS``. You can omit any part of the time, or the whole thing. You can also add a timezone offset, like ``-04:00`` or ``+01:00``, or ``Z`` for UTC.
|
Specify all datetimes in ISO8601 format: ``YYYY-MM-DDTHH:MM:SS``. You can stop at any divider and omit the rest. You can also add a timezone offset, like ``-04:00`` or ``+01:00``, or ``Z`` for UTC.
|
||||||
|
|
||||||
To see details of a specific transaction or subscription::
|
To see details of a specific transaction or subscription::
|
||||||
|
|
||||||
|
@ -32,10 +32,12 @@ The PayPal API does not let you look up an individual transaction by ID; you hav
|
||||||
Library quickstart
|
Library quickstart
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
Create a ``paypal_rest.client.PayPalAPIClient`` using one of the classmethod constructors, then call its methods and handle the results::
|
Create a ``paypal_rest.PayPalAPIClient`` using one of the classmethod constructors, then call its methods and handle the results::
|
||||||
|
|
||||||
config = configparser.ConfigParser()
|
config = configparser.ConfigParser()
|
||||||
config.read(os.path.expanduser('~/.config/paypal_rest/config.ini'))
|
config.read(os.path.expanduser('~/.config/paypal_rest/config.ini'))
|
||||||
paypal = paypal_rest.client.PayPalAPIClient.from_config(config['query'])
|
paypal = paypal_rest.PayPalAPIClient.from_config(config['query'])
|
||||||
for txn in paypal.iter_transactions(start_date, end_date):
|
for txn in paypal.iter_transactions(start_date, end_date):
|
||||||
... # txn is a paypal_rest.transaction.Transaction object you can query.
|
... # txn is a paypal_rest.transaction.Transaction object you can query.
|
||||||
|
|
||||||
|
For more details, refer to the pydoc for ``paypal_rest.PayPalAPIClient`` and ``paypal_rest.transaction.Transaction``.
|
||||||
|
|
|
@ -19,6 +19,7 @@ import datetime
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import operator
|
||||||
import urllib.parse as urlparse
|
import urllib.parse as urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
@ -285,6 +286,47 @@ class PayPalAPIClient:
|
||||||
]
|
]
|
||||||
self.logger.error(" — ".join(parts))
|
self.logger.error(" — ".join(parts))
|
||||||
|
|
||||||
|
def _iter_date_params(
|
||||||
|
self,
|
||||||
|
start_date: datetime.datetime,
|
||||||
|
end_date: datetime.datetime,
|
||||||
|
params: Optional[Params]=None,
|
||||||
|
) -> Iterator[Params]:
|
||||||
|
"""Generate parameters with date windows to cover a wider span
|
||||||
|
|
||||||
|
The PayPal transaction search API only allows the ``start_date`` and
|
||||||
|
``end_date`` to be a month apart. Given a user's desired date range and
|
||||||
|
other API parameters, this method generates parameters with different
|
||||||
|
date pairs to cover the entire date range.
|
||||||
|
|
||||||
|
Normally the method keeps incrementing ``start_date`` until the user's
|
||||||
|
desired ``end_date`` is reached. If the ``start_date`` if later than the
|
||||||
|
``end_date``, instead it works backwards: it uses the ``start_date``
|
||||||
|
argument as the first ``end_date`` parameter, and keeps decrementing it
|
||||||
|
until the ``start_date`` parameter reaches the other end of the range.
|
||||||
|
"""
|
||||||
|
if start_date > end_date:
|
||||||
|
key1 = 'end_date'
|
||||||
|
key2 = 'start_date'
|
||||||
|
days_sign = operator.neg
|
||||||
|
pred = operator.gt
|
||||||
|
limit_func = max
|
||||||
|
else:
|
||||||
|
key1 = 'start_date'
|
||||||
|
key2 = 'end_date'
|
||||||
|
days_sign = operator.pos
|
||||||
|
pred = operator.lt
|
||||||
|
limit_func = min
|
||||||
|
retval = collections.ChainMap(params or {}).new_child()
|
||||||
|
retval[key1] = start_date.isoformat(timespec='seconds')
|
||||||
|
next_date = start_date
|
||||||
|
date_diff = datetime.timedelta(days=days_sign(30))
|
||||||
|
while pred(next_date, end_date):
|
||||||
|
next_date = limit_func(next_date + date_diff, end_date)
|
||||||
|
retval[key2] = next_date.isoformat(timespec='seconds')
|
||||||
|
yield retval
|
||||||
|
retval[key1] = retval[key2]
|
||||||
|
|
||||||
def get_subscription(
|
def get_subscription(
|
||||||
self,
|
self,
|
||||||
subscription_id: str,
|
subscription_id: str,
|
||||||
|
@ -301,7 +343,7 @@ class PayPalAPIClient:
|
||||||
end_date: Optional[datetime.datetime]=None,
|
end_date: Optional[datetime.datetime]=None,
|
||||||
start_date: Optional[datetime.datetime]=None,
|
start_date: Optional[datetime.datetime]=None,
|
||||||
fields: TransactionFields=TransactionFields.TRANSACTION,
|
fields: TransactionFields=TransactionFields.TRANSACTION,
|
||||||
) -> APIResponse:
|
) -> Transaction:
|
||||||
"""Find and return a transaction by its id
|
"""Find and return a transaction by its id
|
||||||
|
|
||||||
The PayPal API does not provide a way to look up transactions solely by
|
The PayPal API does not provide a way to look up transactions solely by
|
||||||
|
@ -323,21 +365,15 @@ class PayPalAPIClient:
|
||||||
if start_date is None:
|
if start_date is None:
|
||||||
# The API only goes back three years
|
# The API only goes back three years
|
||||||
start_date = now - datetime.timedelta(days=365 * 3)
|
start_date = now - datetime.timedelta(days=365 * 3)
|
||||||
date_diff = datetime.timedelta(days=30)
|
|
||||||
response: APIResponse = {'transaction_details': None}
|
response: APIResponse = {'transaction_details': None}
|
||||||
while end_date > start_date and not response['transaction_details']:
|
for params in self._iter_date_params(end_date, start_date, {
|
||||||
search_start = max(end_date - date_diff, start_date)
|
|
||||||
response = self._get_json('/v1/reporting/transactions', {
|
|
||||||
'transaction_id': transaction_id,
|
'transaction_id': transaction_id,
|
||||||
'fields': fields.param_value(),
|
'fields': fields.param_value(),
|
||||||
'start_date': search_start.isoformat(timespec='seconds'),
|
}):
|
||||||
'end_date': end_date.isoformat(timespec='seconds'),
|
response = self._get_json('/v1/reporting/transactions', params)
|
||||||
})
|
if response['transaction_details']:
|
||||||
end_date = search_start
|
return Transaction(response['transaction_details'][0])
|
||||||
if response['transaction_details']:
|
raise ValueError(f"transaction {transaction_id!r} not found")
|
||||||
return Transaction(response['transaction_details'][0])
|
|
||||||
else:
|
|
||||||
raise ValueError(f"transaction {transaction_id!r} not found")
|
|
||||||
|
|
||||||
def iter_transactions(
|
def iter_transactions(
|
||||||
self,
|
self,
|
||||||
|
@ -351,10 +387,9 @@ class PayPalAPIClient:
|
||||||
``fields`` is a TransactionsFields object that flags the information to
|
``fields`` is a TransactionsFields object that flags the information to
|
||||||
include in returned Transactions.
|
include in returned Transactions.
|
||||||
"""
|
"""
|
||||||
for page in self._iter_pages('/v1/reporting/transactions', {
|
for params in self._iter_date_params(start_date, end_date, {
|
||||||
'fields': fields.param_value(),
|
'fields': fields.param_value(),
|
||||||
'start_date': start_date.isoformat(timespec='seconds'),
|
|
||||||
'end_date': end_date.isoformat(timespec='seconds'),
|
|
||||||
}):
|
}):
|
||||||
for txn_source in page['transaction_details']:
|
for page in self._iter_pages('/v1/reporting/transactions', params):
|
||||||
yield Transaction(txn_source)
|
for txn_source in page['transaction_details']:
|
||||||
|
yield Transaction(txn_source)
|
||||||
|
|
|
@ -189,7 +189,7 @@ def main(
|
||||||
paypal_obj = paypal.get_transaction(
|
paypal_obj = paypal.get_transaction(
|
||||||
paypal_id, args.end_date, args.start_date, args.transaction_fields,
|
paypal_id, args.end_date, args.start_date, args.transaction_fields,
|
||||||
)
|
)
|
||||||
yaml.dump(paypal_obj, stdout, Dumper=YAMLDumper)
|
yaml.dump([paypal_obj], stdout, Dumper=YAMLDumper)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
||||||
|
|
0
paypal_rest/py.typed
Normal file
0
paypal_rest/py.typed
Normal file
3
pyproject.toml
Normal file
3
pyproject.toml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools >= 40.6.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
55
setup.cfg
55
setup.cfg
|
@ -1,6 +1,20 @@
|
||||||
[aliases]
|
[metadata]
|
||||||
test=pytest
|
name = paypal_rest
|
||||||
typecheck=pytest --addopts="--mypy paypal_rest"
|
version = 1.0.4
|
||||||
|
author = Software Freedom Conservancy
|
||||||
|
author_email = info@sfconservancy.org
|
||||||
|
description = Library to access PayPal’s REST API
|
||||||
|
license = GNU AGPLv3+
|
||||||
|
license_file = LICENSE.txt
|
||||||
|
long_description = file: README.rst
|
||||||
|
long_description_content_type = text/x-rst; charset=UTF-8
|
||||||
|
project_urls =
|
||||||
|
Documentation = %(url)s
|
||||||
|
Source = %(url)s
|
||||||
|
url = https://k.sfconservancy.org/NPO-Accounting/paypal_rest
|
||||||
|
|
||||||
|
[bdist_wheel]
|
||||||
|
universal = 1
|
||||||
|
|
||||||
[mypy]
|
[mypy]
|
||||||
disallow_any_unimported = False
|
disallow_any_unimported = False
|
||||||
|
@ -13,14 +27,39 @@ warn_return_any = False
|
||||||
warn_unreachable = True
|
warn_unreachable = True
|
||||||
warn_unused_configs = True
|
warn_unused_configs = True
|
||||||
|
|
||||||
|
[options]
|
||||||
|
include_package_data = True
|
||||||
|
install_requires =
|
||||||
|
iso8601>=0.1
|
||||||
|
oauthlib>=2.0
|
||||||
|
pyxdg>=0.2
|
||||||
|
PyYAML>=3.0
|
||||||
|
requests>=2.0
|
||||||
|
requests-oauthlib>=1.0
|
||||||
|
packages = find:
|
||||||
|
python_requires = >=3.6
|
||||||
|
|
||||||
|
[options.entry_points]
|
||||||
|
console_scripts =
|
||||||
|
paypal-query = paypal_rest.cliquery:entry_point
|
||||||
|
|
||||||
|
[options.packages.find]
|
||||||
|
exclude =
|
||||||
|
tests
|
||||||
|
|
||||||
|
[testenv]
|
||||||
|
deps =
|
||||||
|
mypy>=0.770
|
||||||
|
pytest>=3.0
|
||||||
|
pytest-mypy
|
||||||
|
|
||||||
|
commands =
|
||||||
|
pytest
|
||||||
|
pytest --mypy paypal_rest
|
||||||
|
|
||||||
[tool:pytest]
|
[tool:pytest]
|
||||||
filterwarnings =
|
filterwarnings =
|
||||||
ignore::DeprecationWarning:^socks$
|
ignore::DeprecationWarning:^socks$
|
||||||
|
|
||||||
[tox:tox]
|
[tox:tox]
|
||||||
envlist = py36,py37
|
envlist = py36,py37
|
||||||
|
|
||||||
[testenv]
|
|
||||||
commands =
|
|
||||||
./setup.py test
|
|
||||||
./setup.py typecheck
|
|
||||||
|
|
62
setup.py
62
setup.py
|
@ -1,61 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""setup.py - paypal_rest installation script"""
|
"""setup.py - setuptools compatibility shim"""
|
||||||
# Copyright © 2020 Brett Smith
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
from pathlib import Path
|
import setuptools
|
||||||
from setuptools import setup
|
|
||||||
|
|
||||||
README_PATH = Path(__file__).with_name('README.rst')
|
if __name__ == "__main__":
|
||||||
|
setuptools.setup()
|
||||||
with README_PATH.open() as readme_file:
|
|
||||||
long_description = readme_file.read()
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name='paypal_rest',
|
|
||||||
version='1.0.0',
|
|
||||||
author='Software Freedom Conservancy',
|
|
||||||
author_email='info@sfconservancy.org',
|
|
||||||
license='GNU AGPLv3+',
|
|
||||||
url='https://k.sfconservancy.org/NPO-Accounting/paypal_rest',
|
|
||||||
description="Library to access PayPal's REST API",
|
|
||||||
long_description=long_description,
|
|
||||||
|
|
||||||
python_requires='>=3.6',
|
|
||||||
install_requires=[
|
|
||||||
'iso8601>=0.1', # Debian:python3-iso8601
|
|
||||||
'oauthlib>=2.0', # Debian:python3-oauthlib
|
|
||||||
'pyxdg>=0.2', # Debian:python3-xdg
|
|
||||||
'requests>=2.0', # Debian:python3-requests
|
|
||||||
'requests-oauthlib>=1.0', # Debian:python3-requests-oauthlib
|
|
||||||
],
|
|
||||||
setup_requires=[
|
|
||||||
'pytest-mypy',
|
|
||||||
'pytest-runner', # Debian:python3-pytest-runner
|
|
||||||
],
|
|
||||||
tests_require=[
|
|
||||||
'mypy>=0.770', # Debian:python3-mypy
|
|
||||||
'pytest', # Debian:python3-pytest
|
|
||||||
],
|
|
||||||
|
|
||||||
packages=[
|
|
||||||
'paypal_rest',
|
|
||||||
],
|
|
||||||
entry_points={
|
|
||||||
'console_scripts': [
|
|
||||||
'paypal-query = paypal_rest.cliquery:entry_point',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
|
@ -26,7 +26,7 @@ from paypal_rest import client as client_mod
|
||||||
from paypal_rest import transaction as txn_mod
|
from paypal_rest import transaction as txn_mod
|
||||||
|
|
||||||
START_DATE = datetime.datetime(2020, 10, 1, 12, tzinfo=datetime.timezone.utc)
|
START_DATE = datetime.datetime(2020, 10, 1, 12, tzinfo=datetime.timezone.utc)
|
||||||
END_DATE = START_DATE.replace(month=START_DATE.month + 1)
|
END_DATE = START_DATE.replace(day=25)
|
||||||
|
|
||||||
def test_transaction_type():
|
def test_transaction_type():
|
||||||
txn_id = 'TYPETEST123456789'
|
txn_id = 'TYPETEST123456789'
|
||||||
|
@ -87,6 +87,53 @@ def test_transaction_fields_formatting(fields):
|
||||||
for name in session._requests[0].params.get('fields', '').split(',')
|
for name in session._requests[0].params.get('fields', '').split(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('days_diff', [10, 45, 89])
|
||||||
|
def test_iter_transactions_date_window(days_diff):
|
||||||
|
start_date = START_DATE
|
||||||
|
end_date = start_date + datetime.timedelta(days=days_diff)
|
||||||
|
session = MockSession(
|
||||||
|
MockResponse({'page': 1, 'total_pages': 1, 'transaction_details': []}),
|
||||||
|
infinite=True,
|
||||||
|
)
|
||||||
|
paypal = client_mod.PayPalAPIClient(session)
|
||||||
|
assert not any(paypal.iter_transactions(start_date, end_date))
|
||||||
|
req_count = len(session._requests)
|
||||||
|
assert req_count == ((days_diff // 30) + 1)
|
||||||
|
start_str = start_date.isoformat(timespec='seconds')
|
||||||
|
end_str = end_date.isoformat(timespec='seconds')
|
||||||
|
prev_end = start_str
|
||||||
|
for number, request in enumerate(session._requests, 1):
|
||||||
|
assert request.params['start_date'] == prev_end
|
||||||
|
if number == req_count:
|
||||||
|
assert request.params['end_date'] == end_str
|
||||||
|
else:
|
||||||
|
assert prev_end < request.params['end_date'] < end_str
|
||||||
|
prev_end = request.params['end_date']
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('days_diff', [10, 45, 89])
|
||||||
|
def test_get_transactions_date_window(days_diff):
|
||||||
|
end_date = END_DATE
|
||||||
|
start_date = end_date - datetime.timedelta(days=days_diff)
|
||||||
|
session = MockSession(
|
||||||
|
MockResponse({'page': 1, 'total_pages': 1, 'transaction_details': []}),
|
||||||
|
infinite=True,
|
||||||
|
)
|
||||||
|
paypal = client_mod.PayPalAPIClient(session)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
paypal.get_transaction('DATEWINDOW1234567', end_date, start_date)
|
||||||
|
req_count = len(session._requests)
|
||||||
|
assert req_count == ((days_diff // 30) + 1)
|
||||||
|
start_str = start_date.isoformat(timespec='seconds')
|
||||||
|
end_str = end_date.isoformat(timespec='seconds')
|
||||||
|
prev_start = end_str
|
||||||
|
for number, request in enumerate(session._requests, 1):
|
||||||
|
assert request.params['end_date'] == prev_start
|
||||||
|
if number == req_count:
|
||||||
|
assert request.params['start_date'] == start_str
|
||||||
|
else:
|
||||||
|
assert prev_start > request.params['start_date'] > start_str
|
||||||
|
prev_start = request.params['start_date']
|
||||||
|
|
||||||
@pytest.mark.parametrize('name', [
|
@pytest.mark.parametrize('name', [
|
||||||
'BAD_REQUEST',
|
'BAD_REQUEST',
|
||||||
'UNAUTHORIZED',
|
'UNAUTHORIZED',
|
||||||
|
|
Loading…
Reference in a new issue