diff --git a/.flake8 b/.flake8 index 80676bc..a0c643a 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,4 @@ [flake8] per-file-ignores = __init__.py:F401 +exclude = .git,__pycache__,build,dist,venv,WooCommerce.egg-info +max-line-length = 120 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82f9239..49ca0d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.6, 3.7, 3.8, 3.9, 3.10] steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 2b50e6d..3f56409 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ sample.py .vscode/ env/ .pytest_cache/ +venv/ +.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d08df1d..9ea69f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.1] - 2022-09-17 +### Added + +- Added `session` creation to the API object +- Added `Retry` to the API object + ## [3.0.0] - 2021-03-13 ### Removed - Removed support to legacy Python versions, now supports Python 3.6+. @@ -77,7 +83,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release. -[Unreleased]: https://github.com/woocommerce/wc-api-python/compare/3.0.0...HEAD [3.0.0]: https://github.com/woocommerce/wc-api-python/compare/2.1.1...3.0.0 [2.1.1]: https://github.com/woocommerce/wc-api-python/compare/2.0.1...2.1.1 [2.1.0]: https://github.com/woocommerce/wc-api-python/compare/2.0.0...2.1.0 diff --git a/README.rst b/README.rst index 38d507b..e7fac04 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,8 @@ -WooCommerce API - Python Client +WooCommerce API Unofficial - Python Client =============================== +**Having no feedback from the creator of wc-api-python, I have forked the project to add the session functionality of requests and the Retry functionality** + A Python wrapper for the WooCommerce REST API. Easily interact with the WooCommerce REST API using this library. .. image:: https://github.com/woocommerce/wc-api-python/actions/workflows/ci.yml/badge.svg?branch=trunk @@ -15,7 +17,7 @@ Installation .. code-block:: bash - pip install woocommerce + pip install -U git+https://github.com//@tag Getting started --------------- @@ -62,7 +64,11 @@ Options +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ | ``oauth_timestamp`` | ``integer`` | no | Custom timestamp for requests made with oAuth1.0a | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``wp_api`` | ``bool`` | no | Set to ``False`` in order to use the legacy WooCommerce API (deprecated) | +| ``retries`` | ``int`` | no | Set to ``3`` in order to retry 3 times before exiting. | ++-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ +| ``backoff_factor`` | ``float`` | no | Set to ``0.3``. Change how long the processes will sleep between failed requests (exponential). | ++-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ +| ``status_forcelist`` | ``list`` | no | Set to ``[500, 502, 503, 504, 429]`` List of status on which we have to try again | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ Methods diff --git a/requirements-test.txt b/requirements-test.txt deleted file mode 100644 index 950b2c5..0000000 --- a/requirements-test.txt +++ /dev/null @@ -1,4 +0,0 @@ --r requirements.txt -httmock==1.4.0 -pytest==6.2.2 -flake8==3.8.4 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9d84d35..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -requests==2.25.1 diff --git a/requirements/requirements.txt b/requirements/requirements.txt new file mode 100644 index 0000000..d15ce5a --- /dev/null +++ b/requirements/requirements.txt @@ -0,0 +1 @@ +requests==2.28.1 diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 0000000..c4080ee --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,5 @@ +-r requirements.txt +httmock==1.4.0 +pytest==7.1.3 +flake8==5.0.4 +black==22.8.0 diff --git a/setup.py b/setup.py index 9dae917..7adf9f9 100644 --- a/setup.py +++ b/setup.py @@ -2,15 +2,17 @@ # -*- coding: utf-8 -*- """ Setup module """ -from setuptools import setup import os import re +from setuptools import setup # Get version from __init__.py file VERSION = "" with open("woocommerce/__init__.py", "r") as fd: - VERSION = re.search(r"^__version__\s*=\s*['\"]([^\"]*)['\"]", fd.read(), re.MULTILINE).group(1) + VERSION = re.search( + r"^__version__\s*=\s*['\"]([^\"]*)['\"]", fd.read(), re.MULTILINE + ).group(1) if not VERSION: raise RuntimeError("Cannot find version information") @@ -26,18 +28,14 @@ version=VERSION, description="A Python wrapper for the WooCommerce REST API", long_description=README, - author="Claudio Sanches @ Automattic", + author="Claudio Sanches & Antoine C", author_email="claudio+pypi@automattic.com", url="https://github.com/woocommerce/wc-api-python", license="MIT License", - packages=[ - "woocommerce" - ], + packages=["woocommerce"], include_package_data=True, - platforms=['any'], - install_requires=[ - "requests" - ], + platforms=["any"], + install_requires=["requests"], python_requires=">=3.6", classifiers=[ "Development Status :: 5 - Production/Stable", @@ -49,12 +47,13 @@ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", - "Topic :: Software Development :: Libraries :: Python Modules" + "Programming Language :: Python :: 3.10", + "Topic :: Software Development :: Libraries :: Python Modules", ], - keywords='woocommerce rest api', + keywords="woocommerce rest api", project_urls={ - 'Documentation': 'https://woocommerce.github.io/woocommerce-rest-api-docs/?python#libraries-and-tools', - 'Source': 'https://github.com/woocommerce/wc-api-python', - 'Tracker': 'https://github.com/woocommerce/wc-api-python/issues', + "Documentation": "https://woocommerce.github.io/woocommerce-rest-api-docs/?python#libraries-and-tools", + "Source": "https://github.com/woocommerce/wc-api-python", + "Tracker": "https://github.com/woocommerce/wc-api-python/issues", }, ) diff --git a/test_api.py b/test_api.py index c11759e..b5d77df 100644 --- a/test_api.py +++ b/test_api.py @@ -1,8 +1,10 @@ """ API Tests """ import unittest + +from httmock import HTTMock, all_requests + import woocommerce from woocommerce import oauth -from httmock import all_requests, HTTMock class WooCommerceTestCase(unittest.TestCase): @@ -14,39 +16,39 @@ def setUp(self): self.api = woocommerce.API( url="http://woo.test", consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret + consumer_secret=self.consumer_secret, ) def test_version(self): - """ Test default version """ + """Test default version""" api = woocommerce.API( url="https://woo.test", consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret + consumer_secret=self.consumer_secret, ) self.assertEqual(api.version, "wc/v3") def test_non_ssl(self): - """ Test non-ssl """ + """Test non-ssl""" api = woocommerce.API( url="http://woo.test", consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret + consumer_secret=self.consumer_secret, ) self.assertFalse(api.is_ssl) def test_with_ssl(self): - """ Test ssl """ + """Test ssl""" api = woocommerce.API( url="https://woo.test", consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret + consumer_secret=self.consumer_secret, ) self.assertTrue(api.is_ssl, True) def test_with_timeout(self): - """ Test timeout """ + """Test timeout""" api = woocommerce.API( url="https://woo.test", consumer_key=self.consumer_key, @@ -57,9 +59,8 @@ def test_with_timeout(self): @all_requests def woo_test_mock(*args, **kwargs): - """ URL Mock """ - return {'status_code': 200, - 'content': 'OK'} + """URL Mock""" + return {"status_code": 200, "content": "OK"} with HTTMock(woo_test_mock): # call requests @@ -67,12 +68,12 @@ def woo_test_mock(*args, **kwargs): self.assertEqual(status, 200) def test_get(self): - """ Test GET requests """ + """Test GET requests""" + @all_requests def woo_test_mock(*args, **kwargs): - """ URL Mock """ - return {'status_code': 200, - 'content': 'OK'} + """URL Mock""" + return {"status_code": 200, "content": "OK"} with HTTMock(woo_test_mock): # call requests @@ -80,24 +81,25 @@ def woo_test_mock(*args, **kwargs): self.assertEqual(status, 200) def test_get_with_parameters(self): - """ Test GET requests w/ url params """ + """Test GET requests w/ url params""" + @all_requests def woo_test_mock(*args, **kwargs): - return {'status_code': 200, - 'content': 'OK'} + return {"status_code": 200, "content": "OK"} with HTTMock(woo_test_mock): # call requests - status = self.api.get("products", params={"per_page": 10, "page": 1, "offset": 0}).status_code + status = self.api.get( + "products", params={"per_page": 10, "page": 1, "offset": 0} + ).status_code self.assertEqual(status, 200) def test_get_with_requests_kwargs(self): - """ Test GET requests w/ optional requests-module kwargs """ + """Test GET requests w/ optional requests-module kwargs""" @all_requests def woo_test_mock(*args, **kwargs): - return {'status_code': 200, - 'content': 'OK'} + return {"status_code": 200, "content": "OK"} with HTTMock(woo_test_mock): # call requests @@ -105,12 +107,12 @@ def woo_test_mock(*args, **kwargs): self.assertEqual(status, 200) def test_post(self): - """ Test POST requests """ + """Test POST requests""" + @all_requests def woo_test_mock(*args, **kwargs): - """ URL Mock """ - return {'status_code': 201, - 'content': 'OK'} + """URL Mock""" + return {"status_code": 201, "content": "OK"} with HTTMock(woo_test_mock): # call requests @@ -118,12 +120,12 @@ def woo_test_mock(*args, **kwargs): self.assertEqual(status, 201) def test_put(self): - """ Test PUT requests """ + """Test PUT requests""" + @all_requests def woo_test_mock(*args, **kwargs): - """ URL Mock """ - return {'status_code': 200, - 'content': 'OK'} + """URL Mock""" + return {"status_code": 200, "content": "OK"} with HTTMock(woo_test_mock): # call requests @@ -131,12 +133,12 @@ def woo_test_mock(*args, **kwargs): self.assertEqual(status, 200) def test_delete(self): - """ Test DELETE requests """ + """Test DELETE requests""" + @all_requests def woo_test_mock(*args, **kwargs): - """ URL Mock """ - return {'status_code': 200, - 'content': 'OK'} + """URL Mock""" + return {"status_code": 200, "content": "OK"} with HTTMock(woo_test_mock): # call requests @@ -144,18 +146,27 @@ def woo_test_mock(*args, **kwargs): self.assertEqual(status, 200) def test_oauth_sorted_params(self): - """ Test order of parameters for OAuth signature """ + """Test order of parameters for OAuth signature""" + def check_sorted(keys, expected): params = oauth.OrderedDict() for key in keys: - params[key] = '' + params[key] = "" ordered = list(oauth.OAuth.sorted_params(params).keys()) self.assertEqual(ordered, expected) - check_sorted(['a', 'b'], ['a', 'b']) - check_sorted(['b', 'a'], ['a', 'b']) - check_sorted(['a', 'b[a]', 'b[b]', 'b[c]', 'c'], ['a', 'b[a]', 'b[b]', 'b[c]', 'c']) - check_sorted(['a', 'b[c]', 'b[a]', 'b[b]', 'c'], ['a', 'b[c]', 'b[a]', 'b[b]', 'c']) - check_sorted(['d', 'b[c]', 'b[a]', 'b[b]', 'c'], ['b[c]', 'b[a]', 'b[b]', 'c', 'd']) - check_sorted(['a1', 'b[c]', 'b[a]', 'b[b]', 'a2'], ['a1', 'a2', 'b[c]', 'b[a]', 'b[b]']) + check_sorted(["a", "b"], ["a", "b"]) + check_sorted(["b", "a"], ["a", "b"]) + check_sorted( + ["a", "b[a]", "b[b]", "b[c]", "c"], ["a", "b[a]", "b[b]", "b[c]", "c"] + ) + check_sorted( + ["a", "b[c]", "b[a]", "b[b]", "c"], ["a", "b[c]", "b[a]", "b[b]", "c"] + ) + check_sorted( + ["d", "b[c]", "b[a]", "b[b]", "c"], ["b[c]", "b[a]", "b[b]", "c", "d"] + ) + check_sorted( + ["a1", "b[c]", "b[a]", "b[b]", "a2"], ["a1", "a2", "b[c]", "b[a]", "b[b]"] + ) diff --git a/woocommerce/__init__.py b/woocommerce/__init__.py index 15edcc8..801df30 100644 --- a/woocommerce/__init__.py +++ b/woocommerce/__init__.py @@ -10,8 +10,8 @@ """ __title__ = "woocommerce" -__version__ = "3.0.0" -__author__ = "Claudio Sanches @ Automattic" +__version__ = "3.0.1" +__author__ = "Claudio Sanches & Antoine C" __license__ = "MIT" from woocommerce.api import API diff --git a/woocommerce/api.py b/woocommerce/api.py index a97c901..f1aeb80 100644 --- a/woocommerce/api.py +++ b/woocommerce/api.py @@ -5,20 +5,22 @@ """ __title__ = "woocommerce-api" -__version__ = "3.0.0" -__author__ = "Claudio Sanches @ Automattic" +__version__ = "3.0.1" +__author__ = "Claudio Sanches & Antoine C" __license__ = "MIT" -from requests import request from json import dumps as jsonencode from time import time -from woocommerce.oauth import OAuth -from requests.auth import HTTPBasicAuth from urllib.parse import urlencode +from .oauth import OAuth +from requests import session +from requests.adapters import HTTPAdapter, Retry +from requests.auth import HTTPBasicAuth + class API(object): - """ API Class """ + """API Class""" def __init__(self, url, consumer_key, consumer_secret, **kwargs): self.url = url @@ -30,14 +32,41 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): self.timeout = kwargs.get("timeout", 5) self.verify_ssl = kwargs.get("verify_ssl", True) self.query_string_auth = kwargs.get("query_string_auth", False) - self.user_agent = kwargs.get("user_agent", f"WooCommerce-Python-REST-API/{__version__}") + self.user_agent = kwargs.get( + "user_agent", f"WooCommerce-Python-REST-API/{__version__}" + ) + self.retries = kwargs.get("retries", 3) + self.backoff_factor = kwargs.get("backoff_factor", 0.3) + self.status_forcelist = kwargs.get( + "status_forcelist", [500, 502, 503, 504, 429] + ) + self.session = self.__requests_retry_session() def __is_ssl(self): - """ Check if url use HTTPS """ + """Check if url use HTTPS""" return self.url.startswith("https") + def __requests_retry_session(self): + """create a session and link a Retry adapter to it""" + s = session() + retry = Retry( + total=self.retries, + read=self.retries, + connect=self.retries, + backoff_factor=self.backoff_factor, + status_forcelist=self.status_forcelist, + ) + adapter = HTTPAdapter(max_retries=retry) + + if self.is_ssl: + s.mount("https://", adapter) + else: + s.mount("http://", adapter) + + return s + def __get_url(self, endpoint): - """ Get URL for requests """ + """Get URL for requests""" url = self.url api = "wc-api" @@ -50,46 +79,45 @@ def __get_url(self, endpoint): return f"{url}{api}/{self.version}/{endpoint}" def __get_oauth_url(self, url, method, **kwargs): - """ Generate oAuth1.0a URL """ + """Generate oAuth1.0a URL""" oauth = OAuth( url=url, consumer_key=self.consumer_key, consumer_secret=self.consumer_secret, version=self.version, method=method, - oauth_timestamp=kwargs.get("oauth_timestamp", int(time())) + oauth_timestamp=kwargs.get("oauth_timestamp", int(time())), ) return oauth.get_oauth_url() def __request(self, method, endpoint, data, params=None, **kwargs): - """ Do requests """ + """Do requests""" if params is None: params = {} url = self.__get_url(endpoint) auth = None - headers = { - "user-agent": f"{self.user_agent}", - "accept": "application/json" - } + headers = {"user-agent": f"{self.user_agent}", "accept": "application/json"} if self.is_ssl is True and self.query_string_auth is False: auth = HTTPBasicAuth(self.consumer_key, self.consumer_secret) elif self.is_ssl is True and self.query_string_auth is True: - params.update({ - "consumer_key": self.consumer_key, - "consumer_secret": self.consumer_secret - }) + params.update( + { + "consumer_key": self.consumer_key, + "consumer_secret": self.consumer_secret, + } + ) else: encoded_params = urlencode(params) url = f"{url}?{encoded_params}" url = self.__get_oauth_url(url, method, **kwargs) if data is not None: - data = jsonencode(data, ensure_ascii=False).encode('utf-8') + data = jsonencode(data, ensure_ascii=False).encode("utf-8") headers["content-type"] = "application/json;charset=utf-8" - return request( + return self.session.request( method=method, url=url, verify=self.verify_ssl, @@ -98,25 +126,25 @@ def __request(self, method, endpoint, data, params=None, **kwargs): data=data, timeout=self.timeout, headers=headers, - **kwargs + **kwargs, ) def get(self, endpoint, **kwargs): - """ Get requests """ + """Get requests""" return self.__request("GET", endpoint, None, **kwargs) def post(self, endpoint, data, **kwargs): - """ POST requests """ + """POST requests""" return self.__request("POST", endpoint, data, **kwargs) def put(self, endpoint, data, **kwargs): - """ PUT requests """ + """PUT requests""" return self.__request("PUT", endpoint, data, **kwargs) def delete(self, endpoint, **kwargs): - """ DELETE requests """ + """DELETE requests""" return self.__request("DELETE", endpoint, None, **kwargs) def options(self, endpoint, **kwargs): - """ OPTIONS requests """ + """OPTIONS requests""" return self.__request("OPTIONS", endpoint, None, **kwargs) diff --git a/woocommerce/oauth.py b/woocommerce/oauth.py index 62557c0..def1f4d 100644 --- a/woocommerce/oauth.py +++ b/woocommerce/oauth.py @@ -5,21 +5,21 @@ """ __title__ = "woocommerce-oauth" -__version__ = "3.0.0" -__author__ = "Claudio Sanches @ Automattic" +__version__ = "3.0.1" +__author__ = "Claudio Sanches & Antoine C" __license__ = "MIT" -from time import time -from random import randint -from hmac import new as HMAC -from hashlib import sha1, sha256 from base64 import b64encode from collections import OrderedDict -from urllib.parse import urlencode, quote, unquote, parse_qsl, urlparse +from hashlib import sha1, sha256 +from hmac import new as HMAC +from random import randint +from time import time +from urllib.parse import parse_qsl, quote, unquote, urlencode, urlparse class OAuth(object): - """ API Class """ + """API Class""" def __init__(self, url, consumer_key, consumer_secret, **kwargs): self.url = url @@ -30,11 +30,11 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): self.timestamp = kwargs.get("oauth_timestamp", int(time())) def get_oauth_url(self): - """ Returns the URL with OAuth params """ + """Returns the URL with OAuth params""" params = OrderedDict() if "?" in self.url: - url = self.url[:self.url.find("?")] + url = self.url[: self.url.find("?")] for key, value in parse_qsl(urlparse(self.url).query): params[key] = value else: @@ -51,15 +51,17 @@ def get_oauth_url(self): return f"{url}?{query_string}" def generate_oauth_signature(self, params, url): - """ Generate OAuth Signature """ + """Generate OAuth Signature""" if "oauth_signature" in params.keys(): del params["oauth_signature"] base_request_uri = quote(url, "") params = self.sorted_params(params) params = self.normalize_parameters(params) - query_params = ["{param_key}%3D{param_value}".format(param_key=key, param_value=value) - for key, value in params.items()] + query_params = [ + "{param_key}%3D{param_value}".format(param_key=key, param_value=value) + for key, value in params.items() + ] query_string = "%26".join(query_params) string_to_sign = f"{self.method}&{base_request_uri}&{query_string}" @@ -69,9 +71,7 @@ def generate_oauth_signature(self, params, url): consumer_secret += "&" hash_signature = HMAC( - consumer_secret.encode(), - str(string_to_sign).encode(), - sha256 + consumer_secret.encode(), str(string_to_sign).encode(), sha256 ).digest() return b64encode(hash_signature).decode("utf-8").replace("\n", "") @@ -79,23 +79,23 @@ def generate_oauth_signature(self, params, url): @staticmethod def sorted_params(params): ordered = OrderedDict() - base_keys = sorted(set(k.split('[')[0] for k in params.keys())) + base_keys = sorted(set(k.split("[")[0] for k in params.keys())) for base in base_keys: for key in params.keys(): - if key == base or key.startswith(base + '['): + if key == base or key.startswith(base + "["): ordered[key] = params[key] return ordered @staticmethod def normalize_parameters(params): - """ Normalize parameters """ + """Normalize parameters""" params = params or {} normalized_parameters = OrderedDict() def get_value_like_as_php(val): - """ Prepare value for quote """ + """Prepare value for quote""" try: base = basestring except NameError: @@ -122,10 +122,6 @@ def get_value_like_as_php(val): @staticmethod def generate_nonce(): - """ Generate nonce number """ - nonce = ''.join([str(randint(0, 9)) for i in range(8)]) - return HMAC( - nonce.encode(), - "secret".encode(), - sha1 - ).hexdigest() + """Generate nonce number""" + nonce = "".join([str(randint(0, 9)) for i in range(8)]) + return HMAC(nonce.encode(), "secret".encode(), sha1).hexdigest()