Compare commits

..

10 commits

Author SHA1 Message Date
Brett Smith
f65e353cd9 typing: Add PEP 561 py.typed file. 2021-03-31 16:46:43 -04:00
Brett Smith
c4561a0026 setup: Move test dependencies into tox configuration.
pytest-runner, and generally doing this through setuptools, is deprecated.
A little sad, but understandable. Handle tests in tox as recommended.
2021-03-31 16:15:36 -04:00
Brett Smith
85f1aa66a6 setup: Exclude tests from find packages. 2021-03-31 16:14:58 -04:00
Brett Smith
f3c5c92b49 setup: Remove low-value platforms metadata. 2021-03-31 16:14:45 -04:00
Brett Smith
09754b9788 setup: Convert to building with pyproject.toml. 2021-03-31 12:10:47 -04:00
Brett Smith
b0d5fe5aa8 setup: Specify dependency on PyYAML.
This should've always been here, it was an oversight.
2020-11-24 09:24:03 -05:00
Brett Smith
1b0740ad5d cliquery: Dump individual objects as an array.
This provides nicer output when querying multiple objects in one run.
2020-11-24 09:22:59 -05:00
Brett Smith
86b8eddc2e client: More specific return type for PayPalAPIClient.get_transaction(). 2020-11-24 09:22:40 -05:00
Brett Smith
c625a57aad README: Small improvements throughout. 2020-11-24 09:22:24 -05:00
Brett Smith
efaeb53e91 client: iter_transactions() supports date ranges longer than a month.
This works by extracting the date-crawling code from get_transaction() to a
separate method. Now iter_transactions() will similarly make API requests
with different month-long date ranges until covers the entire date range the
user requested.
2020-11-19 15:38:13 -05:00
10 changed files with 164 additions and 91 deletions

View file

@ -14,4 +14,4 @@ The ``paypal-query`` tool is implemented in ``cliquery.py``. Other submodules li
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.

View file

@ -1,3 +1,4 @@
include LICENSE.txt
include *.rst
include tests/*.py
include paypal_rest/py.typed

View file

@ -9,7 +9,7 @@ Introduction
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]
client_id = ...
@ -21,7 +21,7 @@ To see an overview of transactions over a time period::
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::
@ -32,10 +32,12 @@ The PayPal API does not let you look up an individual transaction by ID; you hav
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.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):
... # 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``.

View file

@ -19,6 +19,7 @@ import datetime
import enum
import logging
import math
import operator
import urllib.parse as urlparse
import requests
@ -285,6 +286,47 @@ class PayPalAPIClient:
]
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(
self,
subscription_id: str,
@ -301,7 +343,7 @@ class PayPalAPIClient:
end_date: Optional[datetime.datetime]=None,
start_date: Optional[datetime.datetime]=None,
fields: TransactionFields=TransactionFields.TRANSACTION,
) -> APIResponse:
) -> Transaction:
"""Find and return a transaction by its id
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:
# The API only goes back three years
start_date = now - datetime.timedelta(days=365 * 3)
date_diff = datetime.timedelta(days=30)
response: APIResponse = {'transaction_details': None}
while end_date > start_date and not response['transaction_details']:
search_start = max(end_date - date_diff, start_date)
response = self._get_json('/v1/reporting/transactions', {
for params in self._iter_date_params(end_date, start_date, {
'transaction_id': transaction_id,
'fields': fields.param_value(),
'start_date': search_start.isoformat(timespec='seconds'),
'end_date': end_date.isoformat(timespec='seconds'),
})
end_date = search_start
if response['transaction_details']:
return Transaction(response['transaction_details'][0])
else:
raise ValueError(f"transaction {transaction_id!r} not found")
}):
response = self._get_json('/v1/reporting/transactions', params)
if response['transaction_details']:
return Transaction(response['transaction_details'][0])
raise ValueError(f"transaction {transaction_id!r} not found")
def iter_transactions(
self,
@ -351,10 +387,9 @@ class PayPalAPIClient:
``fields`` is a TransactionsFields object that flags the information to
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(),
'start_date': start_date.isoformat(timespec='seconds'),
'end_date': end_date.isoformat(timespec='seconds'),
}):
for txn_source in page['transaction_details']:
yield Transaction(txn_source)
for page in self._iter_pages('/v1/reporting/transactions', params):
for txn_source in page['transaction_details']:
yield Transaction(txn_source)

View file

@ -189,7 +189,7 @@ def main(
paypal_obj = paypal.get_transaction(
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
entry_point = cliutil.make_entry_point(__name__, PROGNAME)

0
paypal_rest/py.typed Normal file
View file

3
pyproject.toml Normal file
View file

@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools >= 40.6.0", "wheel"]
build-backend = "setuptools.build_meta"

View file

@ -1,6 +1,20 @@
[aliases]
test=pytest
typecheck=pytest --addopts="--mypy paypal_rest"
[metadata]
name = paypal_rest
version = 1.0.4
author = Software Freedom Conservancy
author_email = info@sfconservancy.org
description = Library to access PayPals 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]
disallow_any_unimported = False
@ -13,14 +27,39 @@ warn_return_any = False
warn_unreachable = 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]
filterwarnings =
ignore::DeprecationWarning:^socks$
[tox:tox]
envlist = py36,py37
[testenv]
commands =
./setup.py test
./setup.py typecheck

View file

@ -1,61 +1,7 @@
#!/usr/bin/env python3
"""setup.py - paypal_rest installation script"""
# 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/>.
"""setup.py - setuptools compatibility shim"""
from pathlib import Path
from setuptools import setup
import setuptools
README_PATH = Path(__file__).with_name('README.rst')
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',
],
},
)
if __name__ == "__main__":
setuptools.setup()

View file

@ -26,7 +26,7 @@ from paypal_rest import client as client_mod
from paypal_rest import transaction as txn_mod
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():
txn_id = 'TYPETEST123456789'
@ -87,6 +87,53 @@ def test_transaction_fields_formatting(fields):
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', [
'BAD_REQUEST',
'UNAUTHORIZED',