From dca7a3e46ec5d268ee300c4fcf206157028af053 Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 29 Sep 2016 01:00:18 +1000 Subject: [PATCH 001/129] initial fork differentiation --- README.rst | 131 +++++++++++++------------ setup.py | 8 +- tests.py | 16 +-- {woocommerce => wordpress}/__init__.py | 8 +- {woocommerce => wordpress}/api.py | 27 ++--- {woocommerce => wordpress}/oauth.py | 6 +- 6 files changed, 103 insertions(+), 93 deletions(-) rename {woocommerce => wordpress}/__init__.py (66%) rename {woocommerce => wordpress}/api.py (83%) rename {woocommerce => wordpress}/oauth.py (97%) diff --git a/README.rst b/README.rst index 052331c..d6e0236 100644 --- a/README.rst +++ b/README.rst @@ -1,55 +1,103 @@ -WooCommerce API - Python Client +Wordpress API - Python Client =============================== -A Python wrapper for the WooCommerce REST API. Easily interact with the WooCommerce REST API using this library. +A Python wrapper for the Wordpress REST API that also works on the WooCommerce REST API v1-3 and WooCommerce WP-API v1. +Forked from the Wordpress API written by Claudio Sanches @ WooThemes and modified to work with Wordpress: https://github.com/woocommerce/wc-api-python -.. image:: https://secure.travis-ci.org/woothemes/wc-api-python.svg - :target: http://travis-ci.org/woothemes/wc-api-python +I created this fork because I prefer the way that the wc-api-python client interfaces with +the Wordpress API compared to the existing python client, https://pypi.python.org/pypi/wordpress_json +which does not support OAuth authentication, only Basic Authentication (very unsecure) -.. image:: https://img.shields.io/pypi/v/woocommerce.svg - :target: https://pypi.python.org/pypi/WooCommerce +Roadmap +------- + +- [x] Create initial fork +- [ ] Implement 3-legged OAuth on Wordpress client + +Requirements +------------ + +Your site should have the following plugins installed on your wordpress site: + +- **WP REST API** (recommended version: 2.0+) +- **WP REST API - OAuth 1.0a Server** (https://github.com/WP-API/OAuth1) +- **WP REST API - Meta Endpoints** (optional) Installation ------------ +Download this repo and use setuptools to install the package + .. code-block:: bash - pip install woocommerce + pip install setuptools + git clone https://github.com/derwentx/wp-api-python + python setup.py install Getting started --------------- -Generate API credentials (Consumer Key & Consumer Secret) following this instructions http://docs.woothemes.com/document/woocommerce-rest-api/. +Generate API credentials (Consumer Key & Consumer Secret) following these instructions: http://v2.wp-api.org/guide/authentication/ -Check out the WooCommerce API endpoints and data that can be manipulated in http://woothemes.github.io/woocommerce-rest-api-docs/. +Check out the Wordpress API endpoints and data that can be manipulated in http://v2.wp-api.org/reference/. Setup ----- +Setup for the old Wordpress API: + +.. code-block:: python + + from wordpress import API + + wpapi = API( + url="http://example.com", + consumer_key="XXXXXXXXXXXX", + consumer_secret="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + api="wp-json", + version=None + ) + +Setup for the new WP REST API v2: + +.. code-block:: python + + #... + + wpapi = API( + url="http://example.com", + consumer_key="XXXXXXXXXXXX", + consumer_secret="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + api="wp-json", + version="wp/v2" + ) + Setup for the old WooCommerce API v3: .. code-block:: python - from woocommerce import API + #... wcapi = API( url="http://example.com", consumer_key="ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", consumer_secret="cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + api="wc-api", + version="v3" ) Setup for the new WP REST API integration (WooCommerce 2.6 or later): .. code-block:: python - from woocommerce import API + #... wcapi = API( url="http://example.com", consumer_key="ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", consumer_secret="cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - wp_api=True, + api="wp-json", version="wc/v1" ) @@ -59,15 +107,15 @@ Options +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ | Option | Type | Required | Description | +=======================+=============+==========+=======================================================================================================+ -| ``url`` | ``string`` | yes | Your Store URL, example: http://woo.dev/ | +| ``url`` | ``string`` | yes | Your Store URL, example: http://wp.dev/ | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ | ``consumerKey`` | ``string`` | yes | Your API consumer key | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ | ``consumerSecret`` | ``string`` | yes | Your API consumer secret | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``wp_api`` | ``bool`` | no | Allow requests to the WP REST API (WooCommerce 2.6 or later) | +| ``api`` | ``string`` | no | Allow requests to chose which api to use, defaults to ``wp-json``, can be arbitrary eg ``wc-api`` or ``oembed`` | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``version`` | ``string`` | no | API version, default is ``v3`` | +| ``version`` | ``string`` | no | API version, default is ``wp/v2``, can be ``wp/v2`` if using ``wp-api`` | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ | ``timeout`` | ``integer`` | no | Connection timeout, default is ``5`` | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ @@ -82,7 +130,7 @@ Methods +--------------+----------------+------------------------------------------------------------------+ | Params | Type | Description | +==============+================+==================================================================+ -| ``endpoint`` | ``string`` | WooCommerce API endpoint, example: ``customers`` or ``order/12`` | +| ``endpoint`` | ``string`` | Wordpress API endpoint, example: ``posts`` or ``user/12`` | +--------------+----------------+------------------------------------------------------------------+ | ``data`` | ``dictionary`` | Data that will be converted to JSON | +--------------+----------------+------------------------------------------------------------------+ @@ -121,7 +169,7 @@ Example of returned data: .. code-block:: bash - >>> r = wcapi.get("products") + >>> r = wpapi.get("posts") >>> r.status_code 200 >>> r.headers['content-type'] @@ -129,56 +177,15 @@ Example of returned data: >>> r.encoding 'UTF-8' >>> r.text - u'{"products":[{"title":"Flying Ninja","id":70,...' // Json text + u'{"posts":[{"title":"Flying Ninja","id":70,...' // Json text >>> r.json() - {u'products': [{u'sold_individually': False,... // Dictionary data + {u'posts': [{u'sold_individually': False,... // Dictionary data Changelog --------- -1.2.0 - 2016/06/22 -~~~~~~~~~~~~~~~~~~ - -- Added option ``query_string_auth`` to allow Basic Auth as query strings. - -1.1.1 - 2016/06/03 -~~~~~~~~~~~~~~~~~~ - -- Fixed oAuth signature for WP REST API. - -1.1.0 - 2016/05/09 -~~~~~~~~~~~~~~~~~~ - -- Added support for WP REST API. -- Added method to do HTTP OPTIONS requests. - -1.0.5 - 2015/12/07 -~~~~~~~~~~~~~~~~~~ - -- Fixed oAuth filters sorting. - -1.0.4 - 2015/09/25 -~~~~~~~~~~~~~~~~~~ - -- Implemented ``timeout`` argument for ``API`` class. - -1.0.3 - 2015/08/07 -~~~~~~~~~~~~~~~~~~ - -- Forced utf-8 encoding on ``API.__request()`` to avoid ``UnicodeDecodeError`` - -1.0.2 - 2015/08/05 -~~~~~~~~~~~~~~~~~~ - -- Fixed handler for query strings - -1.0.1 - 2015/07/13 -~~~~~~~~~~~~~~~~~~ - -- Fixed support for Python 2.6 - -1.0.1 - 2015/07/12 +1.2.0 - 2016/09/28 ~~~~~~~~~~~~~~~~~~ -- Initial version +- Initial fork diff --git a/setup.py b/setup.py index 169e6df..5fdf0f1 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ # Get version from __init__.py file VERSION = "" -with open("woocommerce/__init__.py", "r") as fd: +with open("wordpress/__init__.py", "r") as fd: VERSION = re.search(r"^__version__\s*=\s*['\"]([^\"]*)['\"]", fd.read(), re.MULTILINE).group(1) if not VERSION: @@ -22,15 +22,15 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( - name="WooCommerce", + name="Wordpress", version=VERSION, - description="A Python wrapper for the WooCommerce REST API", + description="A Python wrapper for the Wordpress REST API", long_description=README, author="Claudio Sanches @ WooThemes", url="https://github.com/woothemes/wc-api-python", license="MIT License", packages=[ - "woocommerce" + "wordpress" ], include_package_data=True, platforms=['any'], diff --git a/tests.py b/tests.py index ddae9df..a942cbf 100644 --- a/tests.py +++ b/tests.py @@ -1,17 +1,17 @@ """ API Tests """ import unittest -import woocommerce -from woocommerce import oauth +import wordpress +from wordpress import oauth from httmock import all_requests, HTTMock -class WooCommerceTestCase(unittest.TestCase): +class WordpressTestCase(unittest.TestCase): """Test case for the client methods.""" def setUp(self): self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" self.consumer_secret = "cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" - self.api = woocommerce.API( + self.api = wordpress.API( url="http://woo.test", consumer_key=self.consumer_key, consumer_secret=self.consumer_secret @@ -19,7 +19,7 @@ def setUp(self): def test_version(self): """ Test default version """ - api = woocommerce.API( + api = wordpress.API( url="https://woo.test", consumer_key=self.consumer_key, consumer_secret=self.consumer_secret @@ -29,7 +29,7 @@ def test_version(self): def test_non_ssl(self): """ Test non-ssl """ - api = woocommerce.API( + api = wordpress.API( url="http://woo.test", consumer_key=self.consumer_key, consumer_secret=self.consumer_secret @@ -38,7 +38,7 @@ def test_non_ssl(self): def test_with_ssl(self): """ Test non-ssl """ - api = woocommerce.API( + api = wordpress.API( url="https://woo.test", consumer_key=self.consumer_key, consumer_secret=self.consumer_secret @@ -47,7 +47,7 @@ def test_with_ssl(self): def test_with_timeout(self): """ Test non-ssl """ - api = woocommerce.API( + api = wordpress.API( url="https://woo.test", consumer_key=self.consumer_key, consumer_secret=self.consumer_secret, diff --git a/woocommerce/__init__.py b/wordpress/__init__.py similarity index 66% rename from woocommerce/__init__.py rename to wordpress/__init__.py index 76fd0f3..e01b74d 100644 --- a/woocommerce/__init__.py +++ b/wordpress/__init__.py @@ -1,17 +1,17 @@ # -*- coding: utf-8 -*- """ -woocommerce +wordpress ~~~~~~~~~~~~~~~ -A Python wrapper for WooCommerce API. +A Python wrapper for Wordpress REST API. :copyright: (c) 2015 by WooThemes. :license: MIT, see LICENSE for details. """ -__title__ = "woocommerce" +__title__ = "wordpress" __version__ = "1.2.0" __author__ = "Claudio Sanches @ WooThemes" __license__ = "MIT" -from woocommerce.api import API +from wordpress.api import API diff --git a/woocommerce/api.py b/wordpress/api.py similarity index 83% rename from woocommerce/api.py rename to wordpress/api.py index 31d0e49..807f362 100644 --- a/woocommerce/api.py +++ b/wordpress/api.py @@ -1,17 +1,17 @@ # -*- coding: utf-8 -*- """ -WooCommerce API Class +Wordpress API Class """ -__title__ = "woocommerce-api" +__title__ = "wordpress-api" __version__ = "1.2.0" __author__ = "Claudio Sanches @ WooThemes" __license__ = "MIT" from requests import request from json import dumps as jsonencode -from woocommerce.oauth import OAuth +from wordpress.oauth import OAuth class API(object): @@ -21,8 +21,8 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): self.url = url self.consumer_key = consumer_key self.consumer_secret = consumer_secret - self.wp_api = kwargs.get("wp_api", False) - self.version = kwargs.get("version", "v3") + self.api = kwargs.get("api", "wp-json") + self.version = kwargs.get("version", "wp/v2") self.is_ssl = self.__is_ssl() self.timeout = kwargs.get("timeout", 5) self.verify_ssl = kwargs.get("verify_ssl", True) @@ -35,15 +35,18 @@ def __is_ssl(self): def __get_url(self, endpoint): """ Get URL for requests """ url = self.url - api = "wc-api" - if url.endswith("/") is False: - url = "%s/" % url + if url.endswith("/"): + url = url[:-1] #take last char off - if self.wp_api: - api = "wp-json" + url_components = [ + url, + self.api, + self.version, + endpoint + ] - return "%s%s/%s/%s" % (url, api, self.version, endpoint) + return "/".join(component for component in url_components if component) def __get_oauth_url(self, url, method): """ Generate oAuth1.0a URL """ @@ -63,7 +66,7 @@ def __request(self, method, endpoint, data): auth = None params = {} headers = { - "user-agent": "WooCommerce API Client-Python/%s" % __version__, + "user-agent": "Wordpress API Client-Python/%s" % __version__, "content-type": "application/json;charset=utf-8", "accept": "application/json" } diff --git a/woocommerce/oauth.py b/wordpress/oauth.py similarity index 97% rename from woocommerce/oauth.py rename to wordpress/oauth.py index a53739f..e2ca1cf 100644 --- a/woocommerce/oauth.py +++ b/wordpress/oauth.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- """ -WooCommerce OAuth1.0a Class +Wordpress OAuth1.0a Class """ -__title__ = "woocommerce-oauth" +__title__ = "wordpress-oauth" __version__ = "1.2.0" __author__ = "Claudio Sanches @ WooThemes" __license__ = "MIT" @@ -34,7 +34,7 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): self.url = url self.consumer_key = consumer_key self.consumer_secret = consumer_secret - self.version = kwargs.get("version", "v3") + self.version = kwargs.get("version", "wc/v2") self.method = kwargs.get("method", "GET") def get_oauth_url(self): From 5a89d2c17e7cb5e75cb628b74389e6794bf43225 Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 29 Sep 2016 01:05:59 +1000 Subject: [PATCH 002/129] =?UTF-8?q?=F0=9F=92=84readme:=20table=20and=20ext?= =?UTF-8?q?ra=20roadmap=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index d6e0236..e8b6e76 100644 --- a/README.rst +++ b/README.rst @@ -13,6 +13,7 @@ Roadmap - [x] Create initial fork - [ ] Implement 3-legged OAuth on Wordpress client +- [ ] Implement iterator for convent access to API items Requirements ------------ @@ -113,7 +114,7 @@ Options +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ | ``consumerSecret`` | ``string`` | yes | Your API consumer secret | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``api`` | ``string`` | no | Allow requests to chose which api to use, defaults to ``wp-json``, can be arbitrary eg ``wc-api`` or ``oembed`` | +| ``api`` | ``string`` | no | Determines which api to use, defaults to ``wp-json``, can be arbitrary: ``wc-api``, ``oembed`` | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ | ``version`` | ``string`` | no | API version, default is ``wp/v2``, can be ``wp/v2`` if using ``wp-api`` | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ From ad62608fa1c5f08f4b03001e306463efbe18d433 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 21 Oct 2016 20:21:54 +1100 Subject: [PATCH 003/129] Successfully discovers and acquires access token and passes all tests --- README.rst | 4 +- tests.py | 267 ++++++++++++++++++++++++++++++++++++++++- wordpress/__init__.py | 3 + wordpress/api.py | 126 +++++++++---------- wordpress/helpers.py | 74 ++++++++++++ wordpress/oauth.py | 199 +++++++++++++++++++----------- wordpress/transport.py | 71 +++++++++++ 7 files changed, 607 insertions(+), 137 deletions(-) create mode 100644 wordpress/helpers.py create mode 100644 wordpress/transport.py diff --git a/README.rst b/README.rst index 79f239c..d21191c 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ Wordpress API - Python Client =============================== A Python wrapper for the Wordpress REST API that also works on the WooCommerce REST API v1-3 and WooCommerce WP-API v1. -Forked from the Wordpress API written by Claudio Sanches @ WooThemes and modified to work with Wordpress: https://github.com/woocommerce/wc-api-python +Forked from the excellent Wordpress API written by Claudio Sanches @ WooThemes and modified to work with Wordpress: https://github.com/woocommerce/wc-api-python I created this fork because I prefer the way that the wc-api-python client interfaces with the Wordpress API compared to the existing python client, https://pypi.python.org/pypi/wordpress_json @@ -116,7 +116,7 @@ Options +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ | ``api`` | ``string`` | no | Determines which api to use, defaults to ``wp-json``, can be arbitrary: ``wc-api``, ``oembed`` | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``version`` | ``string`` | no | API version, default is ``wp/v2``, can be ``wp/v2`` if using ``wp-api`` | +| ``version`` | ``string`` | no | API version, default is ``wp/v2``, can be ``v3`` or ``wc/v1`` if using ``wc-api`` | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ | ``timeout`` | ``integer`` | no | Connection timeout, default is ``5`` | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ diff --git a/tests.py b/tests.py index a942cbf..7902607 100644 --- a/tests.py +++ b/tests.py @@ -1,8 +1,15 @@ """ API Tests """ import unittest +from httmock import all_requests, HTTMock, urlmatch +from collections import OrderedDict + import wordpress from wordpress import oauth -from httmock import all_requests, HTTMock +from wordpress import __default_api_version__, __default_api__ +from wordpress.helpers import UrlUtils, SeqUtils, StrUtils +from wordpress.transport import API_Requests_Wrapper +from wordpress.api import API +from wordpress.oauth import OAuth class WordpressTestCase(unittest.TestCase): @@ -17,6 +24,16 @@ def setUp(self): consumer_secret=self.consumer_secret ) + def test_api(self): + """ Test default API """ + api = wordpress.API( + url="https://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret + ) + + self.assertEqual(api.namespace, __default_api__) + def test_version(self): """ Test default version """ api = wordpress.API( @@ -25,7 +42,7 @@ def test_version(self): consumer_secret=self.consumer_secret ) - self.assertEqual(api.version, "v3") + self.assertEqual(api.version, __default_api_version__) def test_non_ssl(self): """ Test non-ssl """ @@ -134,3 +151,249 @@ def check_sorted(keys, expected): 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]']) + +class HelperTestcase(unittest.TestCase): + def test_url_is_ssl(self): + self.assertTrue(UrlUtils.is_ssl("https://woo.test:8888")) + self.assertFalse(UrlUtils.is_ssl("http://woo.test:8888")) + + def test_url_substitute_query(self): + self.assertEqual( + UrlUtils.substitute_query("https://woo.test:8888/sdf?param=value", "newparam=newvalue"), + "https://woo.test:8888/sdf?newparam=newvalue" + ) + self.assertEqual( + UrlUtils.substitute_query("https://woo.test:8888/sdf?param=value"), + "https://woo.test:8888/sdf" + ) + self.assertEqual( + UrlUtils.substitute_query( + "https://woo.test:8888/sdf?param=value", + "newparam=newvalue&othernewparam=othernewvalue" + ), + "https://woo.test:8888/sdf?newparam=newvalue&othernewparam=othernewvalue" + ) + self.assertEqual( + UrlUtils.substitute_query( + "https://woo.test:8888/sdf?param=value", + "newparam=newvalue&othernewparam=othernewvalue" + ), + "https://woo.test:8888/sdf?newparam=newvalue&othernewparam=othernewvalue" + ) + + def test_url_join_components(self): + self.assertEqual( + 'https://woo.test:8888/wp-json', + UrlUtils.join_components(['https://woo.test:8888/', '', 'wp-json']) + ) + self.assertEqual( + 'https://woo.test:8888/wp-json/wp/v2', + UrlUtils.join_components(['https://woo.test:8888/', 'wp-json', 'wp/v2']) + ) + + def test_url_get_php_value(self): + self.assertEqual( + '1', + UrlUtils.get_value_like_as_php(True) + ) + self.assertEqual( + '', + UrlUtils.get_value_like_as_php(False) + ) + self.assertEqual( + 'asd', + UrlUtils.get_value_like_as_php('asd') + ) + self.assertEqual( + '1', + UrlUtils.get_value_like_as_php(1) + ) + self.assertEqual( + '1', + UrlUtils.get_value_like_as_php(1.0) + ) + self.assertEqual( + '1.1', + UrlUtils.get_value_like_as_php(1.1) + ) + + + def test_seq_filter_true(self): + self.assertEquals( + ['a', 'b', 'c', 'd'], + SeqUtils.filter_true([None, 'a', False, 'b', 'c','d']) + ) + + def test_str_remove_tail(self): + self.assertEqual( + 'sdf', + StrUtils.remove_tail('sdf/','/') + ) + +class TransportTestcases(unittest.TestCase): + def setUp(self): + self.requester = API_Requests_Wrapper( + url='https://woo.test:8888/', + api='wp-json', + api_version='wp/v2' + ) + + def test_api_url(self): + self.assertEqual( + 'https://woo.test:8888/wp-json', + self.requester.api_url + ) + + def test_endpoint_url(self): + self.assertEqual( + 'https://woo.test:8888/wp-json/wp/v2/posts', + self.requester.endpoint_url('posts') + ) + + def test_request(self): + + @all_requests + def woo_test_mock(*args, **kwargs): + """ URL Mock """ + return {'status_code': 200, + 'content': 'OK'} + + with HTTMock(woo_test_mock): + # call requests + response = self.requester.request("GET", "https://woo.test:8888/wp-json/wp/v2/posts") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.request.url, 'https://woo.test:8888/wp-json/wp/v2/posts') + +class OAuthTestcases(unittest.TestCase): + def setUp(self): + self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + self.consumer_secret = "cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + self.api = wordpress.API( + url="http://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret + ) + + # def test_get_sign(self): + # message = "POST&http%3A%2F%2Flocalhost%3A8888%2Fwordpress%2Foauth1%2Frequest&oauth_callback%3Dlocalhost%253A8888%252Fwordpress%26oauth_consumer_key%3DLCLwTOfxoXGh%26oauth_nonce%3D85285179173071287531477036693%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1477036693%26oauth_version%3D1.0" + # signature_method = 'HMAC-SHA1' + # sig_key = 'k7zLzO3mF75Xj65uThpAnNvQHpghp4X1h5N20O8hCbz2kfJq&' + # sig = OAuth.get_sign(message, signature_method, sig_key) + # expected_sig = '8T93S/PDOrEd+N9cm84EDvsPGJ4=' + # self.assertEqual(sig, expected_sig) + + def test_normalize_params(self): + params = dict([('oauth_callback', 'localhost:8888/wordpress'), ('oauth_consumer_key', 'LCLwTOfxoXGh'), ('oauth_nonce', '45474014077032100721477037582'), ('oauth_signature_method', 'HMAC-SHA1'), ('oauth_timestamp', 1477037582), ('oauth_version', '1.0')]) + expected_normalized_params = "oauth_callback=localhost%3A8888%2Fwordpress&oauth_consumer_key=LCLwTOfxoXGh&oauth_nonce=45474014077032100721477037582&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1477037582&oauth_version=1.0" + normalized_params = OAuth.normalize_params(params) + self.assertEqual(expected_normalized_params, normalized_params) + + def generate_oauth_signature(self): + base_url = "http://localhost:8888/wordpress/" + api_name = 'wc-api' + api_ver = 'v3' + endpoint = 'products/99' + signature_method = "HAMC-SHA1" + consumer_key = "ck_681c2be361e415519dce4b65ee981682cda78bc6" + consumer_secret = "cs_b11f652c39a0afd3752fc7bb0c56d60d58da5877" + + wcapi = API( + url=base_url, + consumer_key=consumer_key, + consumer_secret=consumer_secret, + api=api_name, + version=api_ver, + signature_method=signature_method + ) + + endpoint_url = UrlUtils.join_components([base_url, api_name, api_ver, endpoint]) + + params = OrderedDict() + params["oauth_consumer_key"] = consumer_key + params["oauth_timestamp"] = "1477041328" + params["oauth_nonce"] = "166182658461433445531477041328" + params["oauth_signature_method"] = signature_method + params["oauth_version"] = "1.0" + params["oauth_callback"] = 'localhost:8888/wordpress' + + sig = wcapi.oauth.generate_oauth_signature("POST", params, endpoint_url) + expected_sig = "517qNKeq/vrLZGj2UH7+q8ILWAg=" + self.assertEqual(sig, expected_sig) + +class OAuth3LegTestcases(unittest.TestCase): + def setUp(self): + self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + self.consumer_secret = "cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + self.api = API( + url="http://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret, + oauth1a_3leg=True, + wp_user='test_user', + wp_pass='test_pass', + callback='http://127.0.0.1/oauth1_callback' + ) + + @urlmatch(path=r'.*wp-json.*') + def woo_api_mock(*args, **kwargs): + """ URL Mock """ + return { + 'status_code': 200, + 'content': """ + { + "name": "Wordpress", + "description": "Just another WordPress site", + "url": "http://localhost:8888/wordpress", + "home": "http://localhost:8888/wordpress", + "namespaces": [ + "wp/v2", + "oembed/1.0", + "wc/v1" + ], + "authentication": { + "oauth1": { + "request": "http://localhost:8888/wordpress/oauth1/request", + "authorize": "http://localhost:8888/wordpress/oauth1/authorize", + "access": "http://localhost:8888/wordpress/oauth1/access", + "version": "0.1" + } + } + } + """ + } + + @urlmatch(path=r'.*oauth.*') + def woo_authentication_mock(*args, **kwargs): + """ URL Mock """ + return { + 'status_code':200, + 'content':"""oauth_token=XXXXXXXXXXXX&oauth_token_secret=YYYYYYYYYYYY""" + } + + def test_auth_discovery(self): + + with HTTMock(self.woo_api_mock): + # call requests + authentication = self.api.oauth.authentication + self.assertEquals( + authentication, + { + "oauth1": { + "request": "http://localhost:8888/wordpress/oauth1/request", + "authorize": "http://localhost:8888/wordpress/oauth1/authorize", + "access": "http://localhost:8888/wordpress/oauth1/access", + "version": "0.1" + } + } + ) + + def test_request_access_token(self): + + with HTTMock(self.woo_api_mock): + authentication = self.api.oauth.authentication + self.assertTrue(authentication) + + with HTTMock(self.woo_authentication_mock): + access_token, access_token_secret = self.api.oauth.request_access_token() + self.assertEquals(access_token, ['XXXXXXXXXXXX']) + self.assertEquals(access_token_secret, ['YYYYYYYYYYYY']) diff --git a/wordpress/__init__.py b/wordpress/__init__.py index e01b74d..93f6630 100644 --- a/wordpress/__init__.py +++ b/wordpress/__init__.py @@ -14,4 +14,7 @@ __author__ = "Claudio Sanches @ WooThemes" __license__ = "MIT" +__default_api_version__ = "wp/v2" +__default_api__ = "wp-json" + from wordpress.api import API diff --git a/wordpress/api.py b/wordpress/api.py index 807f362..a1d9970 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -5,94 +5,96 @@ """ __title__ = "wordpress-api" -__version__ = "1.2.0" -__author__ = "Claudio Sanches @ WooThemes" -__license__ = "MIT" from requests import request from json import dumps as jsonencode -from wordpress.oauth import OAuth +from wordpress.oauth import OAuth, OAuth_3Leg +from wordpress.transport import API_Requests_Wrapper class API(object): """ API Class """ def __init__(self, url, consumer_key, consumer_secret, **kwargs): - self.url = url - self.consumer_key = consumer_key - self.consumer_secret = consumer_secret - self.api = kwargs.get("api", "wp-json") - self.version = kwargs.get("version", "wp/v2") - self.is_ssl = self.__is_ssl() - self.timeout = kwargs.get("timeout", 5) - self.verify_ssl = kwargs.get("verify_ssl", True) - self.query_string_auth = kwargs.get("query_string_auth", False) - - def __is_ssl(self): - """ Check if url use HTTPS """ - return self.url.startswith("https") - - def __get_url(self, endpoint): - """ Get URL for requests """ - url = self.url - - if url.endswith("/"): - url = url[:-1] #take last char off - - url_components = [ - url, - self.api, - self.version, - endpoint - ] - - return "/".join(component for component in url_components if component) - - def __get_oauth_url(self, url, method): - """ Generate oAuth1.0a URL """ - oauth = OAuth( - url=url, - consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret, - version=self.version, - method=method + + self.requester = API_Requests_Wrapper(url=url, **kwargs) + + oauth_kwargs = dict( + requester=self.requester, + consumer_key=consumer_key, + consumer_secret=consumer_secret, ) - return oauth.get_oauth_url() + if kwargs.get('oauth1a_3leg'): + self.oauth1a_3leg = kwargs['oauth1a_3leg'] + self.wp_user = kwargs['wp_user'] + self.wp_pass = kwargs['wp_pass'] + oauth_kwargs['callback'] = kwargs['callback'] + self.oauth = OAuth_3Leg( **oauth_kwargs ) + else: + self.oauth = OAuth( **oauth_kwargs ) + + @property + def timeout(self): + return self.requester.timeout + + @property + def query_string_auth(self): + return self.requester.query_string_auth + + @property + def namespace(self): + return self.requester.api + + @property + def version(self): + return self.requester.api_version + + @property + def verify_ssl(self): + return self.requester.verify_ssl + + @property + def is_ssl(self): + return self.requester.is_ssl + + @property + def consumer_key(self): + return self.oauth.consumer_key + + @property + def consumer_secret(self): + return self.oauth.consumer_secret + + @property + def callback(self): + return self.oauth.callback def __request(self, method, endpoint, data): """ Do requests """ - url = self.__get_url(endpoint) + endpoint_url = self.requester.endpoint_url(endpoint) auth = None params = {} - headers = { - "user-agent": "Wordpress API Client-Python/%s" % __version__, - "content-type": "application/json;charset=utf-8", - "accept": "application/json" - } - - if self.is_ssl is True and self.query_string_auth is False: - auth = (self.consumer_key, self.consumer_secret) - elif self.is_ssl is True and self.query_string_auth is True: + + if self.requester.is_ssl is True and self.requester.query_string_auth is False: + auth = (self.oauth.consumer_key, self.oauth.consumer_secret) + elif self.requester.is_ssl is True and self.requester.query_string_auth is True: params = { - "consumer_key": self.consumer_key, - "consumer_secret": self.consumer_secret + "consumer_key": self.oauth.consumer_key, + "consumer_secret": self.oauth.consumer_secret } else: - url = self.__get_oauth_url(url, method) + endpoint_url = self.oauth.get_oauth_url(endpoint_url, method) if data is not None: data = jsonencode(data, ensure_ascii=False).encode('utf-8') - return request( + return self.requester.request( method=method, - url=url, - verify=self.verify_ssl, + url=endpoint_url, auth=auth, params=params, - data=data, - timeout=self.timeout, - headers=headers + data=data ) def get(self, endpoint): diff --git a/wordpress/helpers.py b/wordpress/helpers.py new file mode 100644 index 0000000..569d155 --- /dev/null +++ b/wordpress/helpers.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- + +""" +Wordpress Hellpers Class +""" + +__title__ = "wordpress-requests" + +import posixpath + +try: + from urllib.parse import urlencode, quote, unquote, parse_qsl, urlparse, urlunparse + from urllib.parse import ParseResult as URLParseResult +except ImportError: + from urllib import urlencode, quote, unquote + from urlparse import parse_qsl, urlparse, urlunparse + from urlparse import ParseResult as URLParseResult + +class StrUtils(object): + @classmethod + def remove_tail(cls, string, tail): + if string.endswith(tail): + return string[:-len(tail)] + +class SeqUtils(object): + @classmethod + def filter_true(cls, seq): + return [item for item in seq if item] + +class UrlUtils(object): + @classmethod + def substitute_query(cls, url, query_string=None): + """ Replaces the query string in the url with the provided string or + removes the query string if none is provided """ + if not query_string: + query_string = '' + + urlparse_result = urlparse(url) + + return urlunparse(URLParseResult( + scheme=urlparse_result.scheme, + netloc=urlparse_result.netloc, + path=urlparse_result.path, + params=urlparse_result.params, + query=query_string, + fragment=urlparse_result.fragment + )) + + @classmethod + def is_ssl(cls, url): + return urlparse(url).scheme == 'https' + + @classmethod + def join_components(cls, components): + return reduce(posixpath.join, SeqUtils.filter_true(components)) + + @staticmethod + def get_value_like_as_php(val): + """ Prepare value for quote """ + try: + base = basestring + except NameError: + base = (str, bytes) + + if isinstance(val, base): + return val + elif isinstance(val, bool): + return "1" if val else "" + elif isinstance(val, int): + return str(val) + elif isinstance(val, float): + return str(int(val)) if val % 1 == 0 else str(val) + else: + return "" diff --git a/wordpress/oauth.py b/wordpress/oauth.py index e2ca1cf..c019f50 100644 --- a/wordpress/oauth.py +++ b/wordpress/oauth.py @@ -5,87 +5,109 @@ """ __title__ = "wordpress-oauth" -__version__ = "1.2.0" -__author__ = "Claudio Sanches @ WooThemes" -__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 +import binascii +import webbrowser + try: - from urllib.parse import urlencode, quote, unquote, parse_qsl, urlparse + from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse + from urllib.parse import ParseResult as URLParseResult except ImportError: from urllib import urlencode, quote, unquote - from urlparse import parse_qsl, urlparse + from urlparse import parse_qs, parse_qsl, urlparse, urlunparse + from urlparse import ParseResult as URLParseResult try: from collections import OrderedDict except ImportError: from ordereddict import OrderedDict +from wordpress.helpers import UrlUtils class OAuth(object): + oauth_version = '1.0' + """ API Class """ - def __init__(self, url, consumer_key, consumer_secret, **kwargs): - self.url = url + def __init__(self, requester, consumer_key, consumer_secret, **kwargs): + self.requester = requester self.consumer_key = consumer_key self.consumer_secret = consumer_secret - self.version = kwargs.get("version", "wc/v2") - self.method = kwargs.get("method", "GET") + self.signature_method = kwargs.get('signature_method', 'HMAC-SHA1') - def get_oauth_url(self): - """ Returns the URL with OAuth params """ - params = OrderedDict() + @property + def api_version(self): + return self.requester.api_version + + @property + def api_namespace(self): + return self.requester.api + + def add_params_sign(self, method, url, params): + """ Adds the params to a given url, signs the url with secret and returns a signed url """ + urlparse_result = urlparse(url) - if "?" in self.url: - url = self.url[:self.url.find("?")] - for key, value in parse_qsl(urlparse(self.url).query): + if urlparse_result.query: + for key, value in parse_qsl(urlparse_result.query): params[key] = value - else: - url = self.url - params["oauth_consumer_key"] = self.consumer_key - params["oauth_timestamp"] = int(time()) - params["oauth_nonce"] = self.generate_nonce() - params["oauth_signature_method"] = "HMAC-SHA256" - params["oauth_signature"] = self.generate_oauth_signature(params, url) + params["oauth_signature"] = self.generate_oauth_signature(method, params, UrlUtils.substitute_query(url)) query_string = urlencode(params) - return "%s?%s" % (url, query_string) + return UrlUtils.substitute_query(url, query_string) - def generate_oauth_signature(self, params, url): + def get_oauth_url(self, endpoint_url, method): + """ Returns the URL with OAuth params """ + params = OrderedDict() + params["oauth_consumer_key"] = self.consumer_key + params["oauth_timestamp"] = self.generate_timestamp() + params["oauth_nonce"] = self.generate_nonce() + params["oauth_signature_method"] = self.signature_method + + return self.add_params_sign(method, endpoint_url, params) + + def generate_oauth_signature(self, method, params, url): """ 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_string = "%26".join(query_params) - string_to_sign = "%s&%s&%s" % (self.method, base_request_uri, query_string) + query_string = quote( self.normalize_params(params), safe='~') + string_to_sign = "&".join([method, base_request_uri, query_string]) - consumer_secret = str(self.consumer_secret) - if self.version not in ["v1", "v2"]: - consumer_secret += "&" + if self.api_namespace == 'wc-api' \ + and self.api_version in ["v1", "v2"]: + key = self.consumer_secret + else: + if hasattr(self, 'oauth_token_secret'): + oauth_token_secret = getattr(self, 'oauth_token_secret') + else: + oauth_token_secret = '' + key = "&".join([self.consumer_secret, oauth_token_secret]) - hash_signature = HMAC( - consumer_secret.encode(), - str(string_to_sign).encode(), - sha256 - ).digest() + if self.signature_method == 'HMAC-SHA1': + hmac_mod = sha1 + elif self.signature_method == 'HMAC-SHA256': + hmac_mod = sha256 + else: + raise UserWarning("Unknown signature_method") - return b64encode(hash_signature).decode("utf-8").replace("\n", "") + sig = HMAC(key, string_to_sign, hmac_mod) + sig_b64 = binascii.b2a_base64(sig.digest())[:-1] + # print "string_to_sign: ", string_to_sign + # print "key: ", key + # print "sig_b64: ", sig_b64 + return sig_b64 - @staticmethod - def sorted_params(params): + @classmethod + def sorted_params(cls, params): ordered = OrderedDict() base_keys = sorted(set(k.split('[')[0] for k in params.keys())) @@ -96,37 +118,15 @@ def sorted_params(params): return ordered - @staticmethod - def normalize_parameters(params): + @classmethod + def normalize_params(cls, params): """ Normalize parameters """ - params = params or {} - normalized_parameters = OrderedDict() - - def get_value_like_as_php(val): - """ Prepare value for quote """ - try: - base = basestring - except NameError: - base = (str, bytes) - - if isinstance(val, base): - return val - elif isinstance(val, bool): - return "1" if val else "" - elif isinstance(val, int): - return str(val) - elif isinstance(val, float): - return str(int(val)) if val % 1 == 0 else str(val) - else: - return "" - - for key, value in params.items(): - value = get_value_like_as_php(value) - key = quote(unquote(str(key))).replace("%", "%25") - value = quote(unquote(str(value))).replace("%", "%25") - normalized_parameters[key] = value + return urlencode(cls.sorted_params(params)) - return normalized_parameters + @staticmethod + def generate_timestamp(): + """ Generate timestamp """ + return int(time()) @staticmethod def generate_nonce(): @@ -137,3 +137,60 @@ def generate_nonce(): "secret".encode(), sha1 ).hexdigest() + +class OAuth_3Leg(OAuth): + """ Provides 3 legged OAuth1a, mostly based off this: http://www.lexev.org/en/2015/oauth-step-step/""" + + oauth_version = '1.0A' + + def __init__(self, requester, consumer_key, consumer_secret, callback, **kwargs): + super(OAuth_3Leg, self).__init__(requester, consumer_key, consumer_secret, **kwargs) + self.callback = callback + self._authentication = None + self.access_token = None + self.access_token_secret = None + + @property + def authentication(self): + if not self._authentication: + self._authentication = self.discover_auth() + return self._authentication + + def discover_auth(self): + """ Discovers the location of authentication resourcers from the API""" + discovery_url = self.requester.api_url + + response = self.requester.request('GET', discovery_url) + response_json = response.json() + + assert \ + response_json['authentication'], \ + "resopnse should include location of authentication resources, resopnse: %s" % response.text() + + return response_json['authentication'] + + def request_access_token(self): + params = OrderedDict() + params["oauth_consumer_key"] = self.consumer_key + params["oauth_timestamp"] = self.generate_timestamp() + params["oauth_nonce"] = self.generate_nonce() + params["oauth_signature_method"] = self.signature_method + params["oauth_callback"] = self.callback + # params["oauth_version"] = self.oauth_version + + request_token_url = self.authentication['oauth1']['request'] + request_token_url = self.add_params_sign("GET", request_token_url, params) + + response = self.requester.request("GET", request_token_url) + resp_content = parse_qs(response.text) + + try: + self.access_token = resp_content['oauth_token'] + self.access_token_secret = resp_content['oauth_token_secret'] + except: + raise UserWarning("Could not parse access_token or access_token_secret in response from %s : %s" \ + % (repr(response.request.url), repr(response.text))) + + return self.access_token, self.access_token_secret + + # def get_user_confirmation(self): diff --git a/wordpress/transport.py b/wordpress/transport.py new file mode 100644 index 0000000..256d831 --- /dev/null +++ b/wordpress/transport.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + +""" +Wordpress Requests Class +""" + +__title__ = "wordpress-requests" + +from requests import request +from json import dumps as jsonencode + +try: + from urllib.parse import urlencode, quote, unquote, parse_qsl, urlparse, urlunparse + from urllib.parse import ParseResult as URLParseResult +except ImportError: + from urllib import urlencode, quote, unquote + from urlparse import parse_qsl, urlparse, urlunparse + from urlparse import ParseResult as URLParseResult + +from wordpress import __version__ +from wordpress import __default_api_version__ +from wordpress import __default_api__ +from wordpress.helpers import SeqUtils, UrlUtils + + +class API_Requests_Wrapper(object): + """ provides a wrapper for making requests that handles session info """ + def __init__(self, url, **kwargs): + self.url = url + self.api = kwargs.get("api", __default_api__) + self.api_version = kwargs.get("version", __default_api_version__) + 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.headers = { + "user-agent": "Wordpress API Client-Python/%s" % __version__, + "content-type": "application/json;charset=utf-8", + "accept": "application/json" + } + + @property + def is_ssl(self): + return UrlUtils.is_ssl(self.url) + + @property + def api_url(self): + return UrlUtils.join_components([ + self.url, + self.api + ]) + + def endpoint_url(self, endpoint): + return UrlUtils.join_components([ + self.url, + self.api, + self.api_version, + endpoint + ]) + + def request(self, method, url, auth=None, params=None, data=None): + request_kwargs = dict( + method=method, + url=url, + verify=self.verify_ssl, + timeout=self.timeout, + headers=self.headers + ) + if auth is not None: request_kwargs['auth'] = auth + if params is not None: request_kwargs['params'] = params + if data is not None: request_kwargs['data'] = data + return request(**request_kwargs) From f0b0115cecabfd876208d7e047dcbcd0ccc14d86 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 21 Oct 2016 20:30:42 +1100 Subject: [PATCH 004/129] fixed typo in test case --- tests.py | 36 ++++++++++++++++++++++++++++++++++-- wordpress/oauth.py | 2 ++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/tests.py b/tests.py index 7902607..85d3c15 100644 --- a/tests.py +++ b/tests.py @@ -288,12 +288,12 @@ def test_normalize_params(self): normalized_params = OAuth.normalize_params(params) self.assertEqual(expected_normalized_params, normalized_params) - def generate_oauth_signature(self): + def test_generate_oauth_signature(self): base_url = "http://localhost:8888/wordpress/" api_name = 'wc-api' api_ver = 'v3' endpoint = 'products/99' - signature_method = "HAMC-SHA1" + signature_method = "HMAC-SHA1" consumer_key = "ck_681c2be361e415519dce4b65ee981682cda78bc6" consumer_secret = "cs_b11f652c39a0afd3752fc7bb0c56d60d58da5877" @@ -320,6 +320,38 @@ def generate_oauth_signature(self): expected_sig = "517qNKeq/vrLZGj2UH7+q8ILWAg=" self.assertEqual(sig, expected_sig) + # def generate_oauth_signature(self): + # base_url = "http://localhost:8888/wordpress/" + # api_name = 'wc-api' + # api_ver = 'v3' + # endpoint = 'products/99' + # signature_method = "HAMC-SHA1" + # consumer_key = "ck_681c2be361e415519dce4b65ee981682cda78bc6" + # consumer_secret = "cs_b11f652c39a0afd3752fc7bb0c56d60d58da5877" + # + # wcapi = API( + # url=base_url, + # consumer_key=consumer_key, + # consumer_secret=consumer_secret, + # api=api_name, + # version=api_ver, + # signature_method=signature_method + # ) + # + # endpoint_url = UrlUtils.join_components([base_url, api_name, api_ver, endpoint]) + # + # params = OrderedDict() + # params["oauth_consumer_key"] = consumer_key + # params["oauth_timestamp"] = "1477041328" + # params["oauth_nonce"] = "166182658461433445531477041328" + # params["oauth_signature_method"] = signature_method + # params["oauth_version"] = "1.0" + # params["oauth_callback"] = 'localhost:8888/wordpress' + # + # sig = wcapi.oauth.generate_oauth_signature("POST", params, endpoint_url) + # expected_sig = "517qNKeq/vrLZGj2UH7+q8ILWAg=" + # self.assertEqual(sig, expected_sig) + class OAuth3LegTestcases(unittest.TestCase): def setUp(self): self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" diff --git a/wordpress/oauth.py b/wordpress/oauth.py index c019f50..6df2f21 100644 --- a/wordpress/oauth.py +++ b/wordpress/oauth.py @@ -194,3 +194,5 @@ def request_access_token(self): return self.access_token, self.access_token_secret # def get_user_confirmation(self): + # + # authorize_url = self.authentication['oauth1']['authorize'] From 78b40295f1a94e8367d535b5043e982029eda6d1 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 21 Oct 2016 20:48:43 +1100 Subject: [PATCH 005/129] better test cases, renamed access token to request token for clarity --- tests.py | 10 ++++++++-- wordpress/helpers.py | 10 ++++++++++ wordpress/oauth.py | 24 ++++++++++++++++-------- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/tests.py b/tests.py index 85d3c15..0f8b4ca 100644 --- a/tests.py +++ b/tests.py @@ -181,6 +181,12 @@ def test_url_substitute_query(self): "https://woo.test:8888/sdf?newparam=newvalue&othernewparam=othernewvalue" ) + def test_url_add_query(self): + self.assertEqual( + "https://woo.test:8888/sdf?param=value&newparam=newvalue", + UrlUtils.add_query("https://woo.test:8888/sdf?param=value", 'newparam', 'newvalue') + ) + def test_url_join_components(self): self.assertEqual( 'https://woo.test:8888/wp-json', @@ -419,13 +425,13 @@ def test_auth_discovery(self): } ) - def test_request_access_token(self): + def test_get_request_token(self): with HTTMock(self.woo_api_mock): authentication = self.api.oauth.authentication self.assertTrue(authentication) with HTTMock(self.woo_authentication_mock): - access_token, access_token_secret = self.api.oauth.request_access_token() + access_token, access_token_secret = self.api.oauth.get_request_token() self.assertEquals(access_token, ['XXXXXXXXXXXX']) self.assertEquals(access_token_secret, ['YYYYYYYYYYYY']) diff --git a/wordpress/helpers.py b/wordpress/helpers.py index 569d155..2d412f6 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -46,6 +46,16 @@ def substitute_query(cls, url, query_string=None): fragment=urlparse_result.fragment )) + @classmethod + def add_query(cls, url, new_key, new_value): + """ adds a query parameter to the given url """ + new_query_item = '='.join([quote(new_key, safe='[]'), quote(new_value)]) + new_query_string = "&".join(SeqUtils.filter_true([ + urlparse(url).query, + new_query_item + ])) + return cls.substitute_query(url, new_query_string) + @classmethod def is_ssl(cls, url): return urlparse(url).scheme == 'https' diff --git a/wordpress/oauth.py b/wordpress/oauth.py index 6df2f21..0dfd9e3 100644 --- a/wordpress/oauth.py +++ b/wordpress/oauth.py @@ -121,7 +121,11 @@ def sorted_params(cls, params): @classmethod def normalize_params(cls, params): """ Normalize parameters """ - return urlencode(cls.sorted_params(params)) + params = cls.sorted_params(params) + params = OrderedDict( + [(key, UrlUtils.get_value_like_as_php(value)) for key, value in params.items()] + ) + return urlencode(params) @staticmethod def generate_timestamp(): @@ -147,6 +151,8 @@ def __init__(self, requester, consumer_key, consumer_secret, callback, **kwargs) super(OAuth_3Leg, self).__init__(requester, consumer_key, consumer_secret, **kwargs) self.callback = callback self._authentication = None + self.request_token = None + self.request_token_secret = None self.access_token = None self.access_token_secret = None @@ -169,7 +175,7 @@ def discover_auth(self): return response_json['authentication'] - def request_access_token(self): + def get_request_token(self): params = OrderedDict() params["oauth_consumer_key"] = self.consumer_key params["oauth_timestamp"] = self.generate_timestamp() @@ -185,14 +191,16 @@ def request_access_token(self): resp_content = parse_qs(response.text) try: - self.access_token = resp_content['oauth_token'] - self.access_token_secret = resp_content['oauth_token_secret'] + self.request_token = resp_content['oauth_token'] + self.request_token_secret = resp_content['oauth_token_secret'] except: - raise UserWarning("Could not parse access_token or access_token_secret in response from %s : %s" \ + raise UserWarning("Could not parse request_token or request_token_secret in response from %s : %s" \ % (repr(response.request.url), repr(response.text))) - return self.access_token, self.access_token_secret - + return self.request_token, self.request_token_secret + # # def get_user_confirmation(self): - # # authorize_url = self.authentication['oauth1']['authorize'] + # authorize_url = UrlUtils.add_query(authorize_url, 'oauth_token', self.request_token) + # + # return self.requester.request("GET", authorize_url) From ece11af8b9b04e9d4a1e37a117d6802eabc480fb Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 22 Oct 2016 13:17:14 +1100 Subject: [PATCH 006/129] completes full oauth three-legged to get access token! --- tests.py | 61 +++++---- wordpress/api.py | 4 +- wordpress/helpers.py | 11 +- wordpress/oauth.py | 291 ++++++++++++++++++++++++++++++++++++----- wordpress/transport.py | 19 ++- 5 files changed, 319 insertions(+), 67 deletions(-) diff --git a/tests.py b/tests.py index 0f8b4ca..86a2a62 100644 --- a/tests.py +++ b/tests.py @@ -272,12 +272,21 @@ def woo_test_mock(*args, **kwargs): class OAuthTestcases(unittest.TestCase): def setUp(self): - self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" - self.consumer_secret = "cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" - self.api = wordpress.API( - url="http://woo.test", + self.base_url = "http://localhost:8888/wordpress/" + self.api_name = 'wc-api' + self.api_ver = 'v3' + self.endpoint = 'products/99' + self.signature_method = "HMAC-SHA1" + self.consumer_key = "ck_681c2be361e415519dce4b65ee981682cda78bc6" + self.consumer_secret = "cs_b11f652c39a0afd3752fc7bb0c56d60d58da5877" + + self.wcapi = API( + url=self.base_url, consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret + consumer_secret=self.consumer_secret, + api=self.api_name, + version=self.api_ver, + signature_method=self.signature_method ) # def test_get_sign(self): @@ -288,6 +297,20 @@ def setUp(self): # expected_sig = '8T93S/PDOrEd+N9cm84EDvsPGJ4=' # self.assertEqual(sig, expected_sig) + def test_get_sign_key(self): + self.assertEqual( + self.wcapi.oauth.get_sign_key(self.consumer_secret), + "%s&" % self.consumer_secret + ) + + oauth_token_secret = "PNW9j1yBki3e7M7EqB5qZxbe9n5tR6bIIefSMQ9M2pdyRI9g" + + self.assertEqual( + self.wcapi.oauth.get_sign_key(self.consumer_secret, oauth_token_secret), + "%s&%s" % (self.consumer_secret, oauth_token_secret) + ) + + def test_normalize_params(self): params = dict([('oauth_callback', 'localhost:8888/wordpress'), ('oauth_consumer_key', 'LCLwTOfxoXGh'), ('oauth_nonce', '45474014077032100721477037582'), ('oauth_signature_method', 'HMAC-SHA1'), ('oauth_timestamp', 1477037582), ('oauth_version', '1.0')]) expected_normalized_params = "oauth_callback=localhost%3A8888%2Fwordpress&oauth_consumer_key=LCLwTOfxoXGh&oauth_nonce=45474014077032100721477037582&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1477037582&oauth_version=1.0" @@ -295,34 +318,18 @@ def test_normalize_params(self): self.assertEqual(expected_normalized_params, normalized_params) def test_generate_oauth_signature(self): - base_url = "http://localhost:8888/wordpress/" - api_name = 'wc-api' - api_ver = 'v3' - endpoint = 'products/99' - signature_method = "HMAC-SHA1" - consumer_key = "ck_681c2be361e415519dce4b65ee981682cda78bc6" - consumer_secret = "cs_b11f652c39a0afd3752fc7bb0c56d60d58da5877" - - wcapi = API( - url=base_url, - consumer_key=consumer_key, - consumer_secret=consumer_secret, - api=api_name, - version=api_ver, - signature_method=signature_method - ) - endpoint_url = UrlUtils.join_components([base_url, api_name, api_ver, endpoint]) + endpoint_url = UrlUtils.join_components([self.base_url, self.api_name, self.api_ver, self.endpoint]) params = OrderedDict() - params["oauth_consumer_key"] = consumer_key + params["oauth_consumer_key"] = self.consumer_key params["oauth_timestamp"] = "1477041328" params["oauth_nonce"] = "166182658461433445531477041328" - params["oauth_signature_method"] = signature_method + params["oauth_signature_method"] = self.signature_method params["oauth_version"] = "1.0" params["oauth_callback"] = 'localhost:8888/wordpress' - sig = wcapi.oauth.generate_oauth_signature("POST", params, endpoint_url) + sig = self.wcapi.oauth.generate_oauth_signature("POST", params, endpoint_url) expected_sig = "517qNKeq/vrLZGj2UH7+q8ILWAg=" self.assertEqual(sig, expected_sig) @@ -433,5 +440,5 @@ def test_get_request_token(self): with HTTMock(self.woo_authentication_mock): access_token, access_token_secret = self.api.oauth.get_request_token() - self.assertEquals(access_token, ['XXXXXXXXXXXX']) - self.assertEquals(access_token_secret, ['YYYYYYYYYYYY']) + self.assertEquals(access_token, 'XXXXXXXXXXXX') + self.assertEquals(access_token_secret, 'YYYYYYYYYYYY') diff --git a/wordpress/api.py b/wordpress/api.py index a1d9970..5817f60 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -27,9 +27,9 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): if kwargs.get('oauth1a_3leg'): self.oauth1a_3leg = kwargs['oauth1a_3leg'] - self.wp_user = kwargs['wp_user'] - self.wp_pass = kwargs['wp_pass'] oauth_kwargs['callback'] = kwargs['callback'] + oauth_kwargs['wp_user'] = kwargs['wp_user'] + oauth_kwargs['wp_pass'] = kwargs['wp_pass'] self.oauth = OAuth_3Leg( **oauth_kwargs ) else: self.oauth = OAuth( **oauth_kwargs ) diff --git a/wordpress/helpers.py b/wordpress/helpers.py index 2d412f6..3b908f0 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -16,6 +16,9 @@ from urlparse import parse_qsl, urlparse, urlunparse from urlparse import ParseResult as URLParseResult +from bs4 import BeautifulSoup + + class StrUtils(object): @classmethod def remove_tail(cls, string, tail): @@ -49,7 +52,8 @@ def substitute_query(cls, url, query_string=None): @classmethod def add_query(cls, url, new_key, new_value): """ adds a query parameter to the given url """ - new_query_item = '='.join([quote(new_key, safe='[]'), quote(new_value)]) + new_query_item = '%s=%s' % (quote(str(new_key)), quote(str(new_value))) + # new_query_item = '='.join([quote(new_key), quote(new_value)]) new_query_string = "&".join(SeqUtils.filter_true([ urlparse(url).query, new_query_item @@ -82,3 +86,8 @@ def get_value_like_as_php(val): return str(int(val)) if val % 1 == 0 else str(val) else: return "" + + @staticmethod + def beautify_response(response): + """ Returns a beautified response in the default locale """ + return BeautifulSoup(response.text, 'lxml').prettify().encode(errors='backslashreplace') diff --git a/wordpress/oauth.py b/wordpress/oauth.py index 0dfd9e3..4190a2e 100644 --- a/wordpress/oauth.py +++ b/wordpress/oauth.py @@ -13,7 +13,8 @@ from base64 import b64encode import binascii import webbrowser - +import requests +from bs4 import BeautifulSoup try: from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse @@ -30,6 +31,7 @@ from wordpress.helpers import UrlUtils + class OAuth(object): oauth_version = '1.0' @@ -49,15 +51,28 @@ def api_version(self): def api_namespace(self): return self.requester.api - def add_params_sign(self, method, url, params): - """ Adds the params to a given url, signs the url with secret and returns a signed url """ + def get_sign_key(self, consumer_secret, request_token_secret=None): + if self.api_namespace == 'wc-api' \ + and self.api_version in ["v1", "v2"]: + key = consumer_secret + else: + if not request_token_secret: + request_token_secret = '' + key = "&".join([consumer_secret, request_token_secret]) + return key + + def add_params_sign(self, method, url, params, key=None): + """ Adds the params to a given url, signs the url with key if provided, + otherwise generates key automatically and returns a signed url """ urlparse_result = urlparse(url) if urlparse_result.query: for key, value in parse_qsl(urlparse_result.query): params[key] = value - params["oauth_signature"] = self.generate_oauth_signature(method, params, UrlUtils.substitute_query(url)) + if "oauth_signature" in params.keys(): + del params["oauth_signature"] + params["oauth_signature"] = self.generate_oauth_signature(method, params, UrlUtils.substitute_query(url), key) query_string = urlencode(params) @@ -73,24 +88,15 @@ def get_oauth_url(self, endpoint_url, method): return self.add_params_sign(method, endpoint_url, params) - def generate_oauth_signature(self, method, params, url): + def generate_oauth_signature(self, method, params, url, key=None): """ Generate OAuth Signature """ - if "oauth_signature" in params.keys(): - del params["oauth_signature"] base_request_uri = quote(url, "") query_string = quote( self.normalize_params(params), safe='~') string_to_sign = "&".join([method, base_request_uri, query_string]) - if self.api_namespace == 'wc-api' \ - and self.api_version in ["v1", "v2"]: - key = self.consumer_secret - else: - if hasattr(self, 'oauth_token_secret'): - oauth_token_secret = getattr(self, 'oauth_token_secret') - else: - oauth_token_secret = '' - key = "&".join([self.consumer_secret, oauth_token_secret]) + if key is None: + key = self.get_sign_key(self.consumer_secret) if self.signature_method == 'HMAC-SHA1': hmac_mod = sha1 @@ -150,18 +156,58 @@ class OAuth_3Leg(OAuth): def __init__(self, requester, consumer_key, consumer_secret, callback, **kwargs): super(OAuth_3Leg, self).__init__(requester, consumer_key, consumer_secret, **kwargs) self.callback = callback + self.wp_user = kwargs.get('wp_user') + self.wp_pass = kwargs.get('wp_pass') self._authentication = None - self.request_token = None + self._request_token = None self.request_token_secret = None - self.access_token = None + self._oauth_verifier = None + self._access_token = None self.access_token_secret = None @property def authentication(self): + """ This is an object holding the authentication links discovered from the API + automatically generated if accessed before generated """ if not self._authentication: self._authentication = self.discover_auth() return self._authentication + @property + def oauth_verifier(self): + """ This is the verifier string used in authentication + automatically generated if accessed before generated """ + if not self._oauth_verifier: + self._oauth_verifier = self.get_verifier() + return self._oauth_verifier + + @property + def request_token(self): + """ This is the oauth_token used in requesting an access_token + automatically generated if accessed before generated """ + if not self._request_token: + self.get_request_token() + return self._request_token + + @property + def access_token(self): + """ This is the oauth_token used to sign requests to protected resources + automatically generated if accessed before generated """ + if not self._access_token: + self.get_access_token() + return self._access_token + + def get_oauth_url(self, endpoint_url, method): + """ Returns the URL with OAuth params """ + params = OrderedDict() + params["oauth_consumer_key"] = self.consumer_key + params["oauth_timestamp"] = self.generate_timestamp() + params["oauth_nonce"] = self.generate_nonce() + params["oauth_signature_method"] = self.signature_method + params["oauth_token"] = self.access_token + + return self.add_params_sign(method, endpoint_url, params) + def discover_auth(self): """ Discovers the location of authentication resourcers from the API""" discovery_url = self.requester.api_url @@ -171,11 +217,15 @@ def discover_auth(self): assert \ response_json['authentication'], \ - "resopnse should include location of authentication resources, resopnse: %s" % response.text() + "resopnse should include location of authentication resources, resopnse: %s" \ + % UrlUtils.beautify_response(response) - return response_json['authentication'] + self._authentication = response_json['authentication'] + + return self._authentication def get_request_token(self): + """ Uses the request authentication link to get an oauth_token for requesting an access token """ params = OrderedDict() params["oauth_consumer_key"] = self.consumer_key params["oauth_timestamp"] = self.generate_timestamp() @@ -187,20 +237,197 @@ def get_request_token(self): request_token_url = self.authentication['oauth1']['request'] request_token_url = self.add_params_sign("GET", request_token_url, params) - response = self.requester.request("GET", request_token_url) + response = self.requester.get(request_token_url) resp_content = parse_qs(response.text) try: - self.request_token = resp_content['oauth_token'] - self.request_token_secret = resp_content['oauth_token_secret'] + self._request_token = resp_content['oauth_token'][0] + self.request_token_secret = resp_content['oauth_token_secret'][0] except: raise UserWarning("Could not parse request_token or request_token_secret in response from %s : %s" \ - % (repr(response.request.url), repr(response.text))) - - return self.request_token, self.request_token_secret - # - # def get_user_confirmation(self): - # authorize_url = self.authentication['oauth1']['authorize'] - # authorize_url = UrlUtils.add_query(authorize_url, 'oauth_token', self.request_token) - # - # return self.requester.request("GET", authorize_url) + % (repr(response.request.url), UrlUtils.beautify_response(response))) + + return self._request_token, self.request_token_secret + + def get_form_info(self, response, form_id): + """ parses a form specified by a given form_id in the response, + extracts form data and form action """ + + assert response.status_code is 200 + response_soup = BeautifulSoup(response.text, "lxml") + form_soup = response_soup.select_one('form#%s' % form_id) + assert \ + form_soup, "unable to find form with id=%s in %s " \ + % (form_id, (response_soup.prettify()).encode('ascii', errors='backslashreplace')) + # print "login form: \n", form_soup.prettify() + + action = form_soup.get('action') + assert \ + action, "action should be provided by form: %s" \ + % (form_soup.prettify()).encode('ascii', errors='backslashreplace') + + form_data = OrderedDict() + for input_soup in form_soup.select('input') + form_soup.select('button'): + print "input, class:%5s, id=%5s, name=%5s, value=%s" % ( + input_soup.get('class'), + input_soup.get('id'), + input_soup.get('name'), + input_soup.get('value') + ) + name = input_soup.get('name') + if not name: + continue + value = input_soup.get('value') + if name not in form_data: + form_data[name] = [] + form_data[name].append(value) + + print "form data: %s" % str(form_data) + return action, form_data + + def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): + """ pretends to be a browser, uses the authorize auth link, submits user creds to WP login form to get + verifier string from access token """ + + if request_token is None: + request_token = self.request_token + if wp_user is None and self.wp_user: + wp_user = self.wp_user + if wp_pass is None and self.wp_pass: + wp_pass = self.wp_pass + + authorize_url = self.authentication['oauth1']['authorize'] + authorize_url = UrlUtils.add_query(authorize_url, 'oauth_token', request_token) + + # we're using a different session from the usual API calls + # (I think the headers are incompatible?) + + # self.requester.get(authorize_url) + authorize_session = requests.Session() + + login_form_response = authorize_session.get(authorize_url) + try: + login_form_action, login_form_data = self.get_form_info(login_form_response, 'loginform') + except AssertionError, e: + #try to parse error + login_form_soup = BeautifulSoup(login_form_response.text, 'lxml') + error = login_form_soup.select_one('div#login_error') + if error and "invalid token" in error.string.lower(): + raise UserWarning("Invalid token: %s" % repr(request_token)) + else: + raise UserWarning( + "could not parse login form. Site is misbehaving. Original error: %s " \ + % str(e) + ) + + for name, values in login_form_data.items(): + if name == 'log': + login_form_data[name] = wp_user + elif name == 'pwd': + login_form_data[name] = wp_pass + else: + login_form_data[name] = values[0] + + assert 'log' in login_form_data, 'input for user login did not appear on form' + assert 'pwd' in login_form_data, 'input for user password did not appear on form' + + print "submitting login form to %s : %s" % (login_form_action, str(login_form_data)) + + confirmation_response = authorize_session.post(login_form_action, data=login_form_data, allow_redirects=True) + try: + authorize_form_action, authorize_form_data = self.get_form_info(confirmation_response, 'oauth1_authorize_form') + except AssertionError, e: + #try to parse error + # print "STATUS_CODE: %s" % str(confirmation_response.status_code) + if confirmation_response.status_code != 200: + raise UserWarning("Response was not a 200, it was a %s. original error: %s" \ + % (str(confirmation_response.status_code)), str(e)) + # print "HEADERS: %s" % str(confirmation_response.headers) + confirmation_soup = BeautifulSoup(confirmation_response.text, 'lxml') + error = confirmation_soup.select_one('div#login_error') + # print "ERROR: %s" % repr(error) + if error and "invalid token" in error.string.lower(): + raise UserWarning("Invalid token: %s" % repr(request_token)) + else: + raise UserWarning( + "could not parse login form. Site is misbehaving. Original error: %s " \ + % str(e) + ) + + for name, values in authorize_form_data.items(): + if name == 'wp-submit': + assert \ + 'authorize' in values, \ + "apparently no authorize button, only %s" % str(values) + authorize_form_data[name] = 'authorize' + else: + authorize_form_data[name] = values[0] + + assert 'wp-submit' in login_form_data, 'authorize button did not appear on form' + + final_response = authorize_session.post(authorize_form_action, data=authorize_form_data, allow_redirects=False) + + assert \ + final_response.status_code == 302, \ + "was not redirected by authorize screen, was %d instead. something went wrong" \ + % final_response.status_code + assert 'location' in final_response.headers, "redirect did not provide redirect location in header" + + final_location = final_response.headers['location'] + + # At this point we can chose to follow the redirect if the user wants, + # or just parse the verifier out of the redirect url. + # open to suggestions if anyone has any :) + + final_location_queries = parse_qs(urlparse(final_location).query) + + assert \ + 'oauth_verifier' in final_location_queries, \ + "oauth verifier not provided in final redirect: %s" % final_location + + self._oauth_verifier = final_location_queries['oauth_verifier'][0] + return self._oauth_verifier + + def get_access_token(self, oauth_verifier=None): + """ Uses the access authentication link to get an access token """ + + if oauth_verifier is None: + oauth_verifier = self.oauth_verifier + + params = OrderedDict() + params["oauth_consumer_key"] = self.consumer_key + params['oauth_token'] = self.request_token + params["oauth_timestamp"] = self.generate_timestamp() + params["oauth_nonce"] = self.generate_nonce() + params["oauth_signature_method"] = self.signature_method + params['oauth_verifier'] = oauth_verifier + params["oauth_callback"] = self.callback + + sign_key = self.get_sign_key(self.consumer_secret, self.request_token_secret) + # print "request_token_secret:", self.request_token_secret + + # print "SIGNING WITH KEY:", repr(sign_key) + + access_token_url = self.authentication['oauth1']['access'] + access_token_url = self.add_params_sign("POST", access_token_url, params, sign_key) + + access_response = self.requester.post(access_token_url) + + assert \ + access_response.status_code == 200, \ + "Access request did not return 200, returned %s. HTML: %s" % ( + access_response.status_code, + UrlUtils.beautify_response(access_response) + ) + + # + access_response_queries = parse_qs(access_response.text) + + try: + self._access_token = access_response_queries['oauth_token'][0] + self.access_token_secret = access_response_queries['oauth_token_secret'][0] + except: + raise UserWarning("Could not parse access_token or access_token_secret in response from %s : %s" \ + % (repr(access_response.request.url), UrlUtils.beautify_response(access_response))) + + return self._access_token, self.access_token_secret diff --git a/wordpress/transport.py b/wordpress/transport.py index 256d831..04fb37d 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -6,7 +6,7 @@ __title__ = "wordpress-requests" -from requests import request +from requests import Request, Session from json import dumps as jsonencode try: @@ -22,7 +22,6 @@ from wordpress import __default_api__ from wordpress.helpers import SeqUtils, UrlUtils - class API_Requests_Wrapper(object): """ provides a wrapper for making requests that handles session info """ def __init__(self, url, **kwargs): @@ -37,6 +36,7 @@ def __init__(self, url, **kwargs): "content-type": "application/json;charset=utf-8", "accept": "application/json" } + self.session = Session() @property def is_ssl(self): @@ -57,15 +57,24 @@ def endpoint_url(self, endpoint): endpoint ]) - def request(self, method, url, auth=None, params=None, data=None): + def request(self, method, url, auth=None, params=None, data=None, **kwargs): request_kwargs = dict( method=method, url=url, + headers=self.headers, verify=self.verify_ssl, timeout=self.timeout, - headers=self.headers ) + request_kwargs.update(kwargs) if auth is not None: request_kwargs['auth'] = auth if params is not None: request_kwargs['params'] = params if data is not None: request_kwargs['data'] = data - return request(**request_kwargs) + return self.session.request( + **request_kwargs + ) + + def get(self, *args, **kwargs): + return self.request("GET", *args, **kwargs) + + def post(self, *args, **kwargs): + return self.request("POST", *args, **kwargs) From 34073be9e2e3304f32239b122132395033cab274 Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 22 Oct 2016 14:27:59 +1100 Subject: [PATCH 007/129] it actually fucking works!!! WHAT THE FUCK?! --- tests.py | 36 +++++++++++++++++++++----- wordpress/api.py | 10 +++++-- wordpress/helpers.py | 14 +++++++++- wordpress/oauth.py | 59 +++++++++++++++++++++++++++++++----------- wordpress/transport.py | 3 ++- 5 files changed, 96 insertions(+), 26 deletions(-) diff --git a/tests.py b/tests.py index 86a2a62..d52d0df 100644 --- a/tests.py +++ b/tests.py @@ -236,6 +236,17 @@ def test_str_remove_tail(self): StrUtils.remove_tail('sdf/','/') ) + def test_str_remove_head(self): + self.assertEqual( + 'sdf', + StrUtils.remove_head('/sdf', '/') + ) + + self.assertEqual( + 'sdf', + StrUtils.decapitate('sdf', '/') + ) + class TransportTestcases(unittest.TestCase): def setUp(self): self.requester = API_Requests_Wrapper( @@ -303,13 +314,6 @@ def test_get_sign_key(self): "%s&" % self.consumer_secret ) - oauth_token_secret = "PNW9j1yBki3e7M7EqB5qZxbe9n5tR6bIIefSMQ9M2pdyRI9g" - - self.assertEqual( - self.wcapi.oauth.get_sign_key(self.consumer_secret, oauth_token_secret), - "%s&%s" % (self.consumer_secret, oauth_token_secret) - ) - def test_normalize_params(self): params = dict([('oauth_callback', 'localhost:8888/wordpress'), ('oauth_consumer_key', 'LCLwTOfxoXGh'), ('oauth_nonce', '45474014077032100721477037582'), ('oauth_signature_method', 'HMAC-SHA1'), ('oauth_timestamp', 1477037582), ('oauth_version', '1.0')]) @@ -415,6 +419,24 @@ def woo_authentication_mock(*args, **kwargs): 'content':"""oauth_token=XXXXXXXXXXXX&oauth_token_secret=YYYYYYYYYYYY""" } + def test_get_sign_key(self): + oauth_token_secret = "PNW9j1yBki3e7M7EqB5qZxbe9n5tR6bIIefSMQ9M2pdyRI9g" + + key = self.api.oauth.get_sign_key(self.consumer_secret, oauth_token_secret) + self.assertEqual( + key, + "%s&%s" % (self.consumer_secret, oauth_token_secret) + ) + self.assertEqual(type(key), type("")) + + key = self.api.oauth.get_sign_key(None, oauth_token_secret) + self.assertEqual( + key, + oauth_token_secret + ) + self.assertEqual(type(key), type("")) + + def test_auth_discovery(self): with HTTMock(self.woo_api_mock): diff --git a/wordpress/api.py b/wordpress/api.py index 5817f60..be71de3 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -10,7 +10,7 @@ from json import dumps as jsonencode from wordpress.oauth import OAuth, OAuth_3Leg from wordpress.transport import API_Requests_Wrapper - +from wordpress.helpers import UrlUtils class API(object): """ API Class """ @@ -89,7 +89,7 @@ def __request(self, method, endpoint, data): if data is not None: data = jsonencode(data, ensure_ascii=False).encode('utf-8') - return self.requester.request( + response = self.requester.request( method=method, url=endpoint_url, auth=auth, @@ -97,6 +97,12 @@ def __request(self, method, endpoint, data): data=data ) + assert \ + response.status_code in [200, 201], "API call returned %s: %s" \ + % (str(response.status_code), UrlUtils.beautify_response(response)) + + return response + def get(self, endpoint): """ Get requests """ return self.__request("GET", endpoint, None) diff --git a/wordpress/helpers.py b/wordpress/helpers.py index 3b908f0..d470f93 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -23,7 +23,19 @@ class StrUtils(object): @classmethod def remove_tail(cls, string, tail): if string.endswith(tail): - return string[:-len(tail)] + string = string[:-len(tail)] + return string + + @classmethod + def remove_head(cls, string, head): + if string.startswith(head): + string = string[len(head):] + return string + + + @classmethod + def decapitate(cls, *args, **kwargs): + return cls.remove_head(*args, **kwargs) class SeqUtils(object): @classmethod diff --git a/wordpress/oauth.py b/wordpress/oauth.py index 4190a2e..aef9adf 100644 --- a/wordpress/oauth.py +++ b/wordpress/oauth.py @@ -51,14 +51,15 @@ def api_version(self): def api_namespace(self): return self.requester.api - def get_sign_key(self, consumer_secret, request_token_secret=None): + def get_sign_key(self, consumer_secret): + "gets consumer_secret and turns it into a string suitable for signing" + consumer_secret = str(consumer_secret) if consumer_secret else '' if self.api_namespace == 'wc-api' \ and self.api_version in ["v1", "v2"]: + # special conditions for wc-api v1-2 key = consumer_secret else: - if not request_token_secret: - request_token_secret = '' - key = "&".join([consumer_secret, request_token_secret]) + key = "%s&" % consumer_secret return key def add_params_sign(self, method, url, params, key=None): @@ -105,10 +106,10 @@ def generate_oauth_signature(self, method, params, url, key=None): else: raise UserWarning("Unknown signature_method") + # print "string_to_sign: ", repr(string_to_sign) + # print "key: ", repr(key) sig = HMAC(key, string_to_sign, hmac_mod) sig_b64 = binascii.b2a_base64(sig.digest())[:-1] - # print "string_to_sign: ", string_to_sign - # print "key: ", key # print "sig_b64: ", sig_b64 return sig_b64 @@ -197,8 +198,24 @@ def access_token(self): self.get_access_token() return self._access_token + def get_sign_key(self, consumer_secret, oauth_token_secret=None): + "gets consumer_secret and oauth_token_secret and turns it into a string suitable for signing" + if not oauth_token_secret: + key = super(OAuth_3Leg, self).get_sign_key(consumer_secret) + else: + oauth_token_secret = str(oauth_token_secret) if oauth_token_secret else '' + consumer_secret = str(consumer_secret) if consumer_secret else '' + # oauth_token_secret has been specified + if not consumer_secret: + key = str(oauth_token_secret) + else: + key = "&".join([consumer_secret, oauth_token_secret]) + return key + def get_oauth_url(self, endpoint_url, method): """ Returns the URL with OAuth params """ + assert self.access_token, "need a valid access token for this step" + params = OrderedDict() params["oauth_consumer_key"] = self.consumer_key params["oauth_timestamp"] = self.generate_timestamp() @@ -206,7 +223,11 @@ def get_oauth_url(self, endpoint_url, method): params["oauth_signature_method"] = self.signature_method params["oauth_token"] = self.access_token - return self.add_params_sign(method, endpoint_url, params) + sign_key = self.get_sign_key(self.consumer_secret, self.access_token_secret) + + print "signing with key: %s" % sign_key + + return self.add_params_sign(method, endpoint_url, params, sign_key) def discover_auth(self): """ Discovers the location of authentication resourcers from the API""" @@ -226,6 +247,8 @@ def discover_auth(self): def get_request_token(self): """ Uses the request authentication link to get an oauth_token for requesting an access token """ + assert self.consumer_key, "need a valid consumer_key for this step" + params = OrderedDict() params["oauth_consumer_key"] = self.consumer_key params["oauth_timestamp"] = self.generate_timestamp() @@ -268,12 +291,12 @@ def get_form_info(self, response, form_id): form_data = OrderedDict() for input_soup in form_soup.select('input') + form_soup.select('button'): - print "input, class:%5s, id=%5s, name=%5s, value=%s" % ( - input_soup.get('class'), - input_soup.get('id'), - input_soup.get('name'), - input_soup.get('value') - ) + # print "input, class:%5s, id=%5s, name=%5s, value=%s" % ( + # input_soup.get('class'), + # input_soup.get('id'), + # input_soup.get('name'), + # input_soup.get('value') + # ) name = input_soup.get('name') if not name: continue @@ -282,7 +305,7 @@ def get_form_info(self, response, form_id): form_data[name] = [] form_data[name].append(value) - print "form data: %s" % str(form_data) + # print "form data: %s" % str(form_data) return action, form_data def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): @@ -291,6 +314,8 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): if request_token is None: request_token = self.request_token + assert request_token, "need a valid request_token for this step" + if wp_user is None and self.wp_user: wp_user = self.wp_user if wp_pass is None and self.wp_pass: @@ -331,7 +356,7 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): assert 'log' in login_form_data, 'input for user login did not appear on form' assert 'pwd' in login_form_data, 'input for user password did not appear on form' - print "submitting login form to %s : %s" % (login_form_action, str(login_form_data)) + # print "submitting login form to %s : %s" % (login_form_action, str(login_form_data)) confirmation_response = authorize_session.post(login_form_action, data=login_form_data, allow_redirects=True) try: @@ -393,6 +418,9 @@ def get_access_token(self, oauth_verifier=None): if oauth_verifier is None: oauth_verifier = self.oauth_verifier + assert oauth_verifier, "Need an oauth verifier to perform this step" + assert self.request_token, "Need a valid request_token to perform this step" + params = OrderedDict() params["oauth_consumer_key"] = self.consumer_key @@ -404,6 +432,7 @@ def get_access_token(self, oauth_verifier=None): params["oauth_callback"] = self.callback sign_key = self.get_sign_key(self.consumer_secret, self.request_token_secret) + # sign_key = self.get_sign_key(None, self.request_token_secret) # print "request_token_secret:", self.request_token_secret # print "SIGNING WITH KEY:", repr(sign_key) diff --git a/wordpress/transport.py b/wordpress/transport.py index 04fb37d..eb71b6e 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -20,7 +20,7 @@ from wordpress import __version__ from wordpress import __default_api_version__ from wordpress import __default_api__ -from wordpress.helpers import SeqUtils, UrlUtils +from wordpress.helpers import SeqUtils, UrlUtils, StrUtils class API_Requests_Wrapper(object): """ provides a wrapper for making requests that handles session info """ @@ -50,6 +50,7 @@ def api_url(self): ]) def endpoint_url(self, endpoint): + endpoint = StrUtils.decapitate(endpoint, '/') return UrlUtils.join_components([ self.url, self.api, From f5b4646e2527b21e435fa52a21b3d225a8c87e9d Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 24 Oct 2016 14:54:54 +1100 Subject: [PATCH 008/129] PAGINATION WORKS --- tests.py | 299 ++++++++++++++++++++++++++++++++++++++++++--- wordpress/api.py | 14 ++- wordpress/oauth.py | 218 ++++++++++++++++++++++----------- 3 files changed, 442 insertions(+), 89 deletions(-) diff --git a/tests.py b/tests.py index d52d0df..ecd0032 100644 --- a/tests.py +++ b/tests.py @@ -11,6 +11,13 @@ from wordpress.api import API from wordpress.oauth import OAuth +try: + from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse + from urllib.parse import ParseResult as URLParseResult +except ImportError: + from urllib import urlencode, quote, unquote + from urlparse import parse_qs, parse_qsl, urlparse, urlunparse + from urlparse import ParseResult as URLParseResult class WordpressTestCase(unittest.TestCase): """Test case for the client methods.""" @@ -135,6 +142,7 @@ def woo_test_mock(*args, **kwargs): status = self.api.delete("products").status_code self.assertEqual(status, 200) + @unittest.skip("going by RRC 5849 sorting instead") def test_oauth_sorted_params(self): """ Test order of parameters for OAuth signature """ def check_sorted(keys, expected): @@ -282,6 +290,7 @@ def woo_test_mock(*args, **kwargs): self.assertEqual(response.request.url, 'https://woo.test:8888/wp-json/wp/v2/posts') class OAuthTestcases(unittest.TestCase): + def setUp(self): self.base_url = "http://localhost:8888/wordpress/" self.api_name = 'wc-api' @@ -300,6 +309,149 @@ def setUp(self): signature_method=self.signature_method ) + # RFC EXAMPLE 1 DATA: https://tools.ietf.org/html/draft-hammer-oauth-10#section-1.2 + + self.rfc1_api_url = 'https://photos.example.net/' + self.rfc1_consumer_key = 'dpf43f3p2l4k3l03' + self.rfc1_consumer_secret = 'kd94hf93k423kf44' + self.rfc1_oauth_token = 'hh5s93j4hdidpola' + self.rfc1_signature_method = 'HMAC-SHA1' + self.rfc1_callback = 'http://printer.example.com/ready' + self.rfc1_api = API( + url=self.rfc1_api_url, + consumer_key=self.rfc1_consumer_key, + consumer_secret=self.rfc1_consumer_secret, + api='', + version='', + callback=self.rfc1_callback, + wp_user='', + wp_pass='', + oauth1a_3leg=True + ) + self.rfc1_request_method = 'POST' + self.rfc1_request_target_url = 'https://photos.example.net/initiate' + self.rfc1_request_timestamp = '137131200' + self.rfc1_request_nonce = 'wIjqoS' + self.rfc1_request_params = [ + ('oauth_consumer_key', self.rfc1_consumer_key), + ('oauth_signature_method', self.rfc1_signature_method), + ('oauth_timestamp', self.rfc1_request_timestamp), + ('oauth_nonce', self.rfc1_request_nonce), + ('oauth_callback', self.rfc1_callback), + ] + self.rfc1_request_signature = '74KNZJeDHnMBp0EMJ9ZHt/XKycU=' + + + # RFC EXAMPLE 3 DATA: https://tools.ietf.org/html/draft-hammer-oauth-10#section-3.4.1 + self.rfc3_method = "GET" + self.rfc3_target_url = 'http://example.com/request' + self.rfc3_params_raw = [ + ('b5', r"=%3D"), + ('a3', "a"), + ('c@', ""), + ('a2', 'r b'), + ('oauth_consumer_key', '9djdj82h48djs9d2'), + ('oauth_token', 'kkk9d7dh3k39sjv7'), + ('oauth_signature_method', 'HMAC-SHA1'), + ('oauth_timestamp', 137131201), + ('oauth_nonce', '7d8f3e4a'), + ('c2', ''), + ('a3', '2 q') + ] + self.rfc3_params_encoded = [ + ('b5', r"%3D%253D"), + ('a3', "a"), + ('c%40', ""), + ('a2', r"r%20b"), + ('oauth_consumer_key', '9djdj82h48djs9d2'), + ('oauth_token', 'kkk9d7dh3k39sjv7'), + ('oauth_signature_method', 'HMAC-SHA1'), + ('oauth_timestamp', '137131201'), + ('oauth_nonce', '7d8f3e4a'), + ('c2', ''), + ('a3', r"2%20q") + ] + self.rfc3_params_sorted = [ + ('a2', r"r%20b"), + ('a3', r"2%20q"), + ('a3', "a"), + ('b5', r"%3D%253D"), + ('c%40', ""), + ('c2', ''), + ('oauth_consumer_key', '9djdj82h48djs9d2'), + ('oauth_nonce', '7d8f3e4a'), + ('oauth_signature_method', 'HMAC-SHA1'), + ('oauth_timestamp', '137131201'), + ('oauth_token', 'kkk9d7dh3k39sjv7'), + ] + self.rfc3_param_string = r"a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9djdj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1&oauth_timestamp=137131201&oauth_token=kkk9d7dh3k39sjv7" + self.rfc3_base_string = r"GET&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q%26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk9d7dh3k39sjv7" + + # test data taken from : https://dev.twitter.com/oauth/overview/creating-signatures + + self.twitter_api_url = "https://api.twitter.com/" + self.twitter_consumer_secret = "kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw" + self.twitter_consumer_key = "xvz1evFS4wEEPTGEFPHBog" + self.twitter_signature_method = "HMAC-SHA1" + self.twitter_api = API( + url=self.twitter_api_url, + consumer_key=self.twitter_consumer_key, + consumer_secret=self.twitter_consumer_secret, + api='', + version='1', + signature_method=self.twitter_signature_method, + ) + + self.twitter_method = "POST" + self.twitter_target_url = "https://api.twitter.com/1/statuses/update.json?include_entities=true" + self.twitter_params_raw = [ + ("status", "Hello Ladies + Gentlemen, a signed OAuth request!"), + ("include_entities", "true"), + ("oauth_consumer_key", self.twitter_consumer_key), + ("oauth_nonce", "kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg"), + ("oauth_signature_method", self.twitter_signature_method), + ("oauth_timestamp", "1318622958"), + ("oauth_token", "370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb"), + ("oauth_version", "1.0"), + ] + self.twitter_param_string = r"include_entities=true&oauth_consumer_key=xvz1evFS4wEEPTGEFPHBog&oauth_nonce=kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1318622958&oauth_token=370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb&oauth_version=1.0&status=Hello%20Ladies%20%2B%20Gentlemen%2C%20a%20signed%20OAuth%20request%21" + self.twitter_signature_base_string = r"POST&https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521" + self.twitter_token_secret = 'LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE' + self.twitter_signing_key = 'kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw&LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE' + self.twitter_oauth_signature = 'tnnArxj06cWHq44gCs1OSKk/jLY=' + + self.lexev_consumer_key='your_app_key' + self.lexev_consumer_secret='your_app_secret' + self.lexev_callback='http://127.0.0.1/oauth1_callback' + self.lexev_signature_method='HMAC-SHA1' + self.lexev_version='1.0' + self.lexev_api = API( + url='https://bitbucket.org/', + api='api', + version='1.0', + consumer_key=self.lexev_consumer_key, + consumer_secret=self.lexev_consumer_secret, + signature_method=self.lexev_signature_method, + callback=self.lexev_callback, + wp_user='', + wp_pass='', + oauth1a_3leg=True + ) + self.lexev_request_method='POST' + self.lexev_request_url='https://bitbucket.org/api/1.0/oauth/request_token' + self.lexev_request_nonce='27718007815082439851427366369' + self.lexev_request_timestamp='1427366369' + self.lexev_request_params=[ + ('oauth_callback',self.lexev_callback), + ('oauth_consumer_key',self.lexev_consumer_key), + ('oauth_nonce',self.lexev_request_nonce), + ('oauth_signature_method',self.lexev_signature_method), + ('oauth_timestamp',self.lexev_request_timestamp), + ('oauth_version',self.lexev_version), + ] + self.lexev_request_signature=r"iPdHNIu4NGOjuXZ+YCdPWaRwvJY=" + self.lexev_resource_url='https://api.bitbucket.org/1.0/repositories/st4lk/django-articles-transmeta/branches' + # def test_get_sign(self): # message = "POST&http%3A%2F%2Flocalhost%3A8888%2Fwordpress%2Foauth1%2Frequest&oauth_callback%3Dlocalhost%253A8888%252Fwordpress%26oauth_consumer_key%3DLCLwTOfxoXGh%26oauth_nonce%3D85285179173071287531477036693%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1477036693%26oauth_version%3D1.0" # signature_method = 'HMAC-SHA1' @@ -314,16 +466,127 @@ def test_get_sign_key(self): "%s&" % self.consumer_secret ) + self.assertEqual( + self.wcapi.oauth.get_sign_key(self.twitter_consumer_secret, self.twitter_token_secret), + self.twitter_signing_key + ) + # @unittest.skip("changed order of parms to fit wordpress api") def test_normalize_params(self): - params = dict([('oauth_callback', 'localhost:8888/wordpress'), ('oauth_consumer_key', 'LCLwTOfxoXGh'), ('oauth_nonce', '45474014077032100721477037582'), ('oauth_signature_method', 'HMAC-SHA1'), ('oauth_timestamp', 1477037582), ('oauth_version', '1.0')]) - expected_normalized_params = "oauth_callback=localhost%3A8888%2Fwordpress&oauth_consumer_key=LCLwTOfxoXGh&oauth_nonce=45474014077032100721477037582&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1477037582&oauth_version=1.0" - normalized_params = OAuth.normalize_params(params) - self.assertEqual(expected_normalized_params, normalized_params) - + # params = dict([('oauth_callback', 'localhost:8888/wordpress'), ('oauth_consumer_key', 'LCLwTOfxoXGh'), ('oauth_nonce', '45474014077032100721477037582'), ('oauth_signature_method', 'HMAC-SHA1'), ('oauth_timestamp', 1477037582), ('oauth_version', '1.0')]) + # expected_normalized_params = "oauth_callback=localhost%3A8888%2Fwordpress&oauth_consumer_key=LCLwTOfxoXGh&oauth_nonce=45474014077032100721477037582&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1477037582&oauth_version=1.0" + # normalized_params = OAuth.normalize_params(params) + # self.assertEqual(expected_normalized_params, normalized_params) + + # TEST WITH RFC EXAMPLE 1 DATA + normalized_params = OAuth.normalize_params(self.rfc1_request_params) + # print "\nRFC1 NORMALIZED PARAMS: ", normalized_params, "\n" + + # TEST WITH RFC EXAMPLE 3 DATA + normalized_params = OAuth.normalize_params(self.rfc3_params_raw) + expected_normalized_params = self.rfc3_params_encoded + # print "\nn: %s\ne: %s" % (normalized_params, expected_normalized_params) + self.assertEqual(len(normalized_params), len(expected_normalized_params)) + for i in range(len(normalized_params)): + self.assertEqual(normalized_params[i], expected_normalized_params[i]) + self.assertEqual(normalized_params, expected_normalized_params) + + # TEST WITH LEXEV DATA: + normalized_params = OAuth.normalize_params(self.lexev_request_params) + print "\nLEXEV NORMALIZED PARAMS: ", normalized_params, "\n" + + + def test_sort_params(self): + # TEST WITH RFC EXAMPLE 3 DATA + sorted_params = OAuth.sorted_params(self.rfc3_params_encoded) + expected_sorted_params = self.rfc3_params_sorted + self.assertEqual(sorted_params, expected_sorted_params) + + def test_flatten_params(self): + # TEST WITH RFC EXAMPLE 1 DATA + flattened_params = OAuth.flatten_params(self.rfc1_request_params) + print flattened_params + + # TEST WITH RFC EXAMPLE 3 DATA + flattened_params = OAuth.flatten_params(self.rfc3_params_raw) + expected_flattened_params = self.rfc3_param_string + # print "\nn: %s\ne: %s" % (flattened_params, expected_flattened_params) + self.assertEqual(flattened_params, expected_flattened_params) + + # TEST WITH TWITTER DATA + flattened_params = OAuth.flatten_params(self.twitter_params_raw) + expected_flattened_params = self.twitter_param_string + # print "\nn: %s\ne: %s" % (flattened_params, expected_flattened_params) + self.assertEqual(flattened_params, expected_flattened_params) + + def test_get_signature_base_string(self): + # TEST WITH RFC EXAMPLE 3 DATA + rfc3_base_string = OAuth.get_signature_base_string( + self.rfc3_method, + self.rfc3_params_raw, + self.rfc3_target_url + ) + self.assertEqual(rfc3_base_string, self.rfc3_base_string) + + # TEST WITH TWITTER DATA + twitter_base_string = OAuth.get_signature_base_string( + self.twitter_method, + self.twitter_params_raw, + self.twitter_target_url + ) + self.assertEqual(twitter_base_string, self.twitter_signature_base_string) + + # @unittest.skip("changed order of parms to fit wordpress api") def test_generate_oauth_signature(self): - endpoint_url = UrlUtils.join_components([self.base_url, self.api_name, self.api_ver, self.endpoint]) + # endpoint_url = UrlUtils.join_components([self.base_url, self.api_name, self.api_ver, self.endpoint]) + # + # params = OrderedDict() + # params["oauth_consumer_key"] = self.consumer_key + # params["oauth_timestamp"] = "1477041328" + # params["oauth_nonce"] = "166182658461433445531477041328" + # params["oauth_signature_method"] = self.signature_method + # params["oauth_version"] = "1.0" + # params["oauth_callback"] = 'localhost:8888/wordpress' + # + # sig = self.wcapi.oauth.generate_oauth_signature("POST", params, endpoint_url) + # expected_sig = "517qNKeq/vrLZGj2UH7+q8ILWAg=" + # self.assertEqual(sig, expected_sig) + + # TEST WITH RFC EXAMPLE 1 DATA + + rfc1_request_signature = self.rfc1_api.oauth.generate_oauth_signature( + self.rfc1_request_method, + self.rfc1_request_params, + self.rfc1_request_target_url, + '%s&' % self.rfc1_consumer_secret + ) + self.assertEqual(rfc1_request_signature, self.rfc1_request_signature) + + # TEST WITH RFC EXAMPLE 3 DATA + + # TEST WITH TWITTER DATA + + twitter_signature = self.twitter_api.oauth.generate_oauth_signature( + self.twitter_method, + self.twitter_params_raw, + self.twitter_target_url, + self.twitter_signing_key + ) + self.assertEqual(twitter_signature, self.twitter_oauth_signature) + + # TEST WITH LEXEV DATA + + lexev_request_signature = self.lexev_api.oauth.generate_oauth_signature( + method=self.lexev_request_method, + params=self.lexev_request_params, + url=self.lexev_request_url + ) + self.assertEqual(lexev_request_signature, self.lexev_request_signature) + + + def test_add_params_sign(self): + endpoint_url = self.wcapi.requester.endpoint_url('products?page=2') params = OrderedDict() params["oauth_consumer_key"] = self.consumer_key @@ -333,9 +596,20 @@ def test_generate_oauth_signature(self): params["oauth_version"] = "1.0" params["oauth_callback"] = 'localhost:8888/wordpress' - sig = self.wcapi.oauth.generate_oauth_signature("POST", params, endpoint_url) - expected_sig = "517qNKeq/vrLZGj2UH7+q8ILWAg=" - self.assertEqual(sig, expected_sig) + signed_url = self.wcapi.oauth.add_params_sign("GET", endpoint_url, params) + + signed_url_params = parse_qsl(urlparse(signed_url).query) + # print signed_url_params + # self.assertEqual('page', signed_url_params[-1][0]) + self.assertIn('page', dict(signed_url_params)) + + # def test_get_oauth_url(self): + # request_oauth_url = self.rfc1_api.oauth.get_oauth_url(self.rfc1_request_target_url, self.rfc1_request_method) + # print request_oauth_url + + # def test_normalize_params(self): + + # def generate_oauth_signature(self): # base_url = "http://localhost:8888/wordpress/" @@ -429,13 +703,6 @@ def test_get_sign_key(self): ) self.assertEqual(type(key), type("")) - key = self.api.oauth.get_sign_key(None, oauth_token_secret) - self.assertEqual( - key, - oauth_token_secret - ) - self.assertEqual(type(key), type("")) - def test_auth_discovery(self): diff --git a/wordpress/api.py b/wordpress/api.py index be71de3..4f14ac8 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -23,6 +23,8 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): requester=self.requester, consumer_key=consumer_key, consumer_secret=consumer_secret, + force_nonce=kwargs.get('force_nonce'), + force_timestamp=kwargs.get('force_timestamp') ) if kwargs.get('oauth1a_3leg'): @@ -34,6 +36,10 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): else: self.oauth = OAuth( **oauth_kwargs ) + @property + def url(self): + return self.requester.url + @property def timeout(self): return self.requester.timeout @@ -98,8 +104,12 @@ def __request(self, method, endpoint, data): ) assert \ - response.status_code in [200, 201], "API call returned %s: %s" \ - % (str(response.status_code), UrlUtils.beautify_response(response)) + response.status_code in [200, 201], "API call to %s returned \nCODE: %s\n%s \nHEADERS: %s" % ( + response.request.url, + str(response.status_code), + UrlUtils.beautify_response(response), + str(response.headers) + ) return response diff --git a/wordpress/oauth.py b/wordpress/oauth.py index aef9adf..d342b60 100644 --- a/wordpress/oauth.py +++ b/wordpress/oauth.py @@ -34,6 +34,8 @@ class OAuth(object): oauth_version = '1.0' + force_nonce = None + force_timestamp = None """ API Class """ @@ -42,6 +44,8 @@ def __init__(self, requester, consumer_key, consumer_secret, **kwargs): self.consumer_key = consumer_key self.consumer_secret = consumer_secret self.signature_method = kwargs.get('signature_method', 'HMAC-SHA1') + self.force_timestamp = kwargs.get('force_timestamp') + self.force_nonce = kwargs.get('force_nonce') @property def api_version(self): @@ -51,50 +55,70 @@ def api_version(self): def api_namespace(self): return self.requester.api - def get_sign_key(self, consumer_secret): + def get_sign_key(self, consumer_secret, token_secret=None): "gets consumer_secret and turns it into a string suitable for signing" - consumer_secret = str(consumer_secret) if consumer_secret else '' + if not consumer_secret: + raise UserWarning("no consumer_secret provided") + token_secret = str(token_secret) if token_secret else '' if self.api_namespace == 'wc-api' \ and self.api_version in ["v1", "v2"]: # special conditions for wc-api v1-2 key = consumer_secret else: - key = "%s&" % consumer_secret + key = "%s&%s" % (consumer_secret, token_secret) return key - def add_params_sign(self, method, url, params, key=None): - """ Adds the params to a given url, signs the url with key if provided, - otherwise generates key automatically and returns a signed url """ + def add_params_sign(self, method, url, params, sign_key=None): + """ Adds the params to a given url, signs the url with sign_key if provided, + otherwise generates sign_key automatically and returns a signed url """ + if isinstance(params, dict): + params = params.items() + urlparse_result = urlparse(url) if urlparse_result.query: - for key, value in parse_qsl(urlparse_result.query): - params[key] = value + params += parse_qsl(urlparse_result.query) + # for key, value in parse_qsl(urlparse_result.query): + # params += [(key, value)] + + params = self.sorted_params(params) - if "oauth_signature" in params.keys(): - del params["oauth_signature"] - params["oauth_signature"] = self.generate_oauth_signature(method, params, UrlUtils.substitute_query(url), key) + params_without_signature = [] + for key, value in params: + if key != "oauth_signature": + params_without_signature.append((key, value)) - query_string = urlencode(params) + signature = self.generate_oauth_signature(method, params_without_signature, url, sign_key) + params = params_without_signature + [("oauth_signature", signature)] + + query_string = self.flatten_params(params) return UrlUtils.substitute_query(url, query_string) + def get_params(self): + return [ + ("oauth_consumer_key", self.consumer_key), + ("oauth_nonce", self.generate_nonce()), + ("oauth_signature_method", self.signature_method), + ("oauth_timestamp", self.generate_timestamp()), + ] + def get_oauth_url(self, endpoint_url, method): """ Returns the URL with OAuth params """ - params = OrderedDict() - params["oauth_consumer_key"] = self.consumer_key - params["oauth_timestamp"] = self.generate_timestamp() - params["oauth_nonce"] = self.generate_nonce() - params["oauth_signature_method"] = self.signature_method + params = self.get_params() return self.add_params_sign(method, endpoint_url, params) + @classmethod + def get_signature_base_string(cls, method, params, url): + base_request_uri = quote(UrlUtils.substitute_query(url), "") + query_string = quote( cls.flatten_params(params), '~') + return "&".join([method, base_request_uri, query_string]) + def generate_oauth_signature(self, method, params, url, key=None): """ Generate OAuth Signature """ - base_request_uri = quote(url, "") - query_string = quote( self.normalize_params(params), safe='~') - string_to_sign = "&".join([method, base_request_uri, query_string]) + string_to_sign = self.get_signature_base_string(method, params, url) if key is None: key = self.get_sign_key(self.consumer_secret) @@ -106,42 +130,69 @@ def generate_oauth_signature(self, method, params, url, key=None): else: raise UserWarning("Unknown signature_method") - # print "string_to_sign: ", repr(string_to_sign) - # print "key: ", repr(key) + # print "\nstring_to_sign: %s" % repr(string_to_sign) + # print "\nkey: %s" % repr(key) sig = HMAC(key, string_to_sign, hmac_mod) sig_b64 = binascii.b2a_base64(sig.digest())[:-1] - # print "sig_b64: ", sig_b64 + # print "\nsig_b64: %s" % sig_b64 return sig_b64 @classmethod def sorted_params(cls, params): - ordered = OrderedDict() - base_keys = sorted(set(k.split('[')[0] for k in params.keys())) + """ Sort parameters. works with RFC 5849 logic. params is a list of key, value pairs """ + if isinstance(params, dict): + params = params.items() - for base in base_keys: - for key in params.keys(): - if key == base or key.startswith(base + '['): - ordered[key] = params[key] + return sorted(params) + # ordered = [] + # base_keys = sorted(set(k.split('[')[0] for k, v in params)) + # + # for base in base_keys: + # for key, value in params: + # if key == base or key.startswith(base + '['): + # ordered.append((key, value)) + + # return ordered - return ordered + @classmethod + def normalize_str(cls, string): + return quote(string, '') @classmethod def normalize_params(cls, params): - """ Normalize parameters """ + """ Normalize parameters. works with RFC 5849 logic. params is a list of key, value pairs """ + if isinstance(params, dict): + params = params.items() + params = \ + [(cls.normalize_str(key), cls.normalize_str(UrlUtils.get_value_like_as_php(value))) \ + for key, value in params] + + # print "NORMALIZED: %s\n" % str(params.keys()) + # resposne = urlencode(params) + response = params + # print "RESPONSE: %s\n" % str(resposne.split('&')) + return response + + @classmethod + def flatten_params(cls, params): + if isinstance(params, dict): + params = params.items() + params = cls.normalize_params(params) params = cls.sorted_params(params) - params = OrderedDict( - [(key, UrlUtils.get_value_like_as_php(value)) for key, value in params.items()] - ) - return urlencode(params) + return "&".join(["%s=%s"%(key, value) for key, value in params]) - @staticmethod - def generate_timestamp(): + @classmethod + def generate_timestamp(cls): """ Generate timestamp """ + if cls.force_timestamp is not None: + return cls.force_timestamp return int(time()) - @staticmethod - def generate_nonce(): + @classmethod + def generate_nonce(cls): """ Generate nonce number """ + if cls.force_nonce is not None: + return cls.force_nonce nonce = ''.join([str(randint(0, 9)) for i in range(8)]) return HMAC( nonce.encode(), @@ -152,7 +203,7 @@ def generate_nonce(): class OAuth_3Leg(OAuth): """ Provides 3 legged OAuth1a, mostly based off this: http://www.lexev.org/en/2015/oauth-step-step/""" - oauth_version = '1.0A' + # oauth_version = '1.0A' def __init__(self, requester, consumer_key, consumer_secret, callback, **kwargs): super(OAuth_3Leg, self).__init__(requester, consumer_key, consumer_secret, **kwargs) @@ -198,37 +249,53 @@ def access_token(self): self.get_access_token() return self._access_token - def get_sign_key(self, consumer_secret, oauth_token_secret=None): - "gets consumer_secret and oauth_token_secret and turns it into a string suitable for signing" - if not oauth_token_secret: - key = super(OAuth_3Leg, self).get_sign_key(consumer_secret) - else: - oauth_token_secret = str(oauth_token_secret) if oauth_token_secret else '' - consumer_secret = str(consumer_secret) if consumer_secret else '' - # oauth_token_secret has been specified - if not consumer_secret: - key = str(oauth_token_secret) - else: - key = "&".join([consumer_secret, oauth_token_secret]) - return key + # def get_sign_key(self, consumer_secret, oauth_token_secret=None): + # "gets consumer_secret and oauth_token_secret and turns it into a string suitable for signing" + # if not oauth_token_secret: + # key = super(OAuth_3Leg, self).get_sign_key(consumer_secret) + # else: + # oauth_token_secret = str(oauth_token_secret) if oauth_token_secret else '' + # consumer_secret = str(consumer_secret) if consumer_secret else '' + # # oauth_token_secret has been specified + # if not consumer_secret: + # key = str(oauth_token_secret) + # else: + # key = "&".join([consumer_secret, oauth_token_secret]) + # return key def get_oauth_url(self, endpoint_url, method): """ Returns the URL with OAuth params """ assert self.access_token, "need a valid access token for this step" - params = OrderedDict() - params["oauth_consumer_key"] = self.consumer_key - params["oauth_timestamp"] = self.generate_timestamp() - params["oauth_nonce"] = self.generate_nonce() - params["oauth_signature_method"] = self.signature_method - params["oauth_token"] = self.access_token + params = self.get_params() + params += [ + ('oauth_callback', self.callback), + ('oauth_token', self.access_token) + ] sign_key = self.get_sign_key(self.consumer_secret, self.access_token_secret) - print "signing with key: %s" % sign_key - return self.add_params_sign(method, endpoint_url, params, sign_key) + # params = OrderedDict() + # params["oauth_consumer_key"] = self.consumer_key + # params["oauth_timestamp"] = self.generate_timestamp() + # params["oauth_nonce"] = self.generate_nonce() + # params["oauth_signature_method"] = self.signature_method + # params["oauth_token"] = self.access_token + # + # sign_key = self.get_sign_key(self.consumer_secret, self.access_token_secret) + # + # print "signing with key: %s" % sign_key + # + # return self.add_params_sign(method, endpoint_url, params, sign_key) + + # def get_params(self, get_access_token=False): + # params = super(OAuth_3Leg, self).get_params() + # if get_access_token: + # params.append(('oauth_token', self.access_token)) + # return params + def discover_auth(self): """ Discovers the location of authentication resourcers from the API""" discovery_url = self.requester.api_url @@ -249,12 +316,16 @@ def get_request_token(self): """ Uses the request authentication link to get an oauth_token for requesting an access token """ assert self.consumer_key, "need a valid consumer_key for this step" - params = OrderedDict() - params["oauth_consumer_key"] = self.consumer_key - params["oauth_timestamp"] = self.generate_timestamp() - params["oauth_nonce"] = self.generate_nonce() - params["oauth_signature_method"] = self.signature_method - params["oauth_callback"] = self.callback + params = self.get_params() + params += [ + ('oauth_callback', self.callback) + ] + # params = OrderedDict() + # params["oauth_consumer_key"] = self.consumer_key + # params["oauth_timestamp"] = self.generate_timestamp() + # params["oauth_nonce"] = self.generate_nonce() + # params["oauth_signature_method"] = self.signature_method + # params["oauth_callback"] = self.callback # params["oauth_version"] = self.oauth_version request_token_url = self.authentication['oauth1']['request'] @@ -421,13 +492,18 @@ def get_access_token(self, oauth_verifier=None): assert oauth_verifier, "Need an oauth verifier to perform this step" assert self.request_token, "Need a valid request_token to perform this step" + params = self.get_params() + params += [ + ('oauth_token', self.request_token), + ('oauth_verifier', self.oauth_verifier) + ] params = OrderedDict() - params["oauth_consumer_key"] = self.consumer_key + # params["oauth_consumer_key"] = self.consumer_key params['oauth_token'] = self.request_token - params["oauth_timestamp"] = self.generate_timestamp() - params["oauth_nonce"] = self.generate_nonce() - params["oauth_signature_method"] = self.signature_method + # params["oauth_timestamp"] = self.generate_timestamp() + # params["oauth_nonce"] = self.generate_nonce() + # params["oauth_signature_method"] = self.signature_method params['oauth_verifier'] = oauth_verifier params["oauth_callback"] = self.callback From 056e88c51cd723ec28889fc59890f3078500b665 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 24 Oct 2016 14:59:30 +1100 Subject: [PATCH 009/129] patched a bug in last commit, everything's working now :D --- tests.py | 4 ++-- wordpress/oauth.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests.py b/tests.py index ecd0032..08d9cd5 100644 --- a/tests.py +++ b/tests.py @@ -493,7 +493,7 @@ def test_normalize_params(self): # TEST WITH LEXEV DATA: normalized_params = OAuth.normalize_params(self.lexev_request_params) - print "\nLEXEV NORMALIZED PARAMS: ", normalized_params, "\n" + # print "\nLEXEV NORMALIZED PARAMS: ", normalized_params, "\n" def test_sort_params(self): @@ -505,7 +505,7 @@ def test_sort_params(self): def test_flatten_params(self): # TEST WITH RFC EXAMPLE 1 DATA flattened_params = OAuth.flatten_params(self.rfc1_request_params) - print flattened_params + # print flattened_params # TEST WITH RFC EXAMPLE 3 DATA flattened_params = OAuth.flatten_params(self.rfc3_params_raw) diff --git a/wordpress/oauth.py b/wordpress/oauth.py index d342b60..00f3b1f 100644 --- a/wordpress/oauth.py +++ b/wordpress/oauth.py @@ -498,14 +498,14 @@ def get_access_token(self, oauth_verifier=None): ('oauth_verifier', self.oauth_verifier) ] - params = OrderedDict() + # params = OrderedDict() # params["oauth_consumer_key"] = self.consumer_key - params['oauth_token'] = self.request_token + # params['oauth_token'] = self.request_token # params["oauth_timestamp"] = self.generate_timestamp() # params["oauth_nonce"] = self.generate_nonce() # params["oauth_signature_method"] = self.signature_method - params['oauth_verifier'] = oauth_verifier - params["oauth_callback"] = self.callback + # params['oauth_verifier'] = oauth_verifier + # params["oauth_callback"] = self.callback sign_key = self.get_sign_key(self.consumer_secret, self.request_token_secret) # sign_key = self.get_sign_key(None, self.request_token_secret) From 50a7955a7f5eef1b3c1535922039f90b64f577e7 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 24 Oct 2016 15:20:18 +1100 Subject: [PATCH 010/129] Removed RFC 5849 compliance because server is twitter style and doesn't like repeat parameters --- tests.py | 155 +++++++++++++++++++++++---------------------- wordpress/oauth.py | 23 ++++--- 2 files changed, 91 insertions(+), 87 deletions(-) diff --git a/tests.py b/tests.py index 08d9cd5..46e6a12 100644 --- a/tests.py +++ b/tests.py @@ -142,7 +142,7 @@ def woo_test_mock(*args, **kwargs): status = self.api.delete("products").status_code self.assertEqual(status, 200) - @unittest.skip("going by RRC 5849 sorting instead") + # @unittest.skip("going by RRC 5849 sorting instead") def test_oauth_sorted_params(self): """ Test order of parameters for OAuth signature """ def check_sorted(keys, expected): @@ -150,7 +150,8 @@ def check_sorted(keys, expected): for key in keys: params[key] = '' - ordered = list(oauth.OAuth.sorted_params(params).keys()) + params = oauth.OAuth.sorted_params(params) + ordered = [key for key, value in params] self.assertEqual(ordered, expected) check_sorted(['a', 'b'], ['a', 'b']) @@ -342,50 +343,50 @@ def setUp(self): self.rfc1_request_signature = '74KNZJeDHnMBp0EMJ9ZHt/XKycU=' - # RFC EXAMPLE 3 DATA: https://tools.ietf.org/html/draft-hammer-oauth-10#section-3.4.1 - self.rfc3_method = "GET" - self.rfc3_target_url = 'http://example.com/request' - self.rfc3_params_raw = [ - ('b5', r"=%3D"), - ('a3', "a"), - ('c@', ""), - ('a2', 'r b'), - ('oauth_consumer_key', '9djdj82h48djs9d2'), - ('oauth_token', 'kkk9d7dh3k39sjv7'), - ('oauth_signature_method', 'HMAC-SHA1'), - ('oauth_timestamp', 137131201), - ('oauth_nonce', '7d8f3e4a'), - ('c2', ''), - ('a3', '2 q') - ] - self.rfc3_params_encoded = [ - ('b5', r"%3D%253D"), - ('a3', "a"), - ('c%40', ""), - ('a2', r"r%20b"), - ('oauth_consumer_key', '9djdj82h48djs9d2'), - ('oauth_token', 'kkk9d7dh3k39sjv7'), - ('oauth_signature_method', 'HMAC-SHA1'), - ('oauth_timestamp', '137131201'), - ('oauth_nonce', '7d8f3e4a'), - ('c2', ''), - ('a3', r"2%20q") - ] - self.rfc3_params_sorted = [ - ('a2', r"r%20b"), - ('a3', r"2%20q"), - ('a3', "a"), - ('b5', r"%3D%253D"), - ('c%40', ""), - ('c2', ''), - ('oauth_consumer_key', '9djdj82h48djs9d2'), - ('oauth_nonce', '7d8f3e4a'), - ('oauth_signature_method', 'HMAC-SHA1'), - ('oauth_timestamp', '137131201'), - ('oauth_token', 'kkk9d7dh3k39sjv7'), - ] - self.rfc3_param_string = r"a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9djdj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1&oauth_timestamp=137131201&oauth_token=kkk9d7dh3k39sjv7" - self.rfc3_base_string = r"GET&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q%26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk9d7dh3k39sjv7" + # # RFC EXAMPLE 3 DATA: https://tools.ietf.org/html/draft-hammer-oauth-10#section-3.4.1 + # self.rfc3_method = "GET" + # self.rfc3_target_url = 'http://example.com/request' + # self.rfc3_params_raw = [ + # ('b5', r"=%3D"), + # ('a3', "a"), + # ('c@', ""), + # ('a2', 'r b'), + # ('oauth_consumer_key', '9djdj82h48djs9d2'), + # ('oauth_token', 'kkk9d7dh3k39sjv7'), + # ('oauth_signature_method', 'HMAC-SHA1'), + # ('oauth_timestamp', 137131201), + # ('oauth_nonce', '7d8f3e4a'), + # ('c2', ''), + # ('a3', '2 q') + # ] + # self.rfc3_params_encoded = [ + # ('b5', r"%3D%253D"), + # ('a3', "a"), + # ('c%40', ""), + # ('a2', r"r%20b"), + # ('oauth_consumer_key', '9djdj82h48djs9d2'), + # ('oauth_token', 'kkk9d7dh3k39sjv7'), + # ('oauth_signature_method', 'HMAC-SHA1'), + # ('oauth_timestamp', '137131201'), + # ('oauth_nonce', '7d8f3e4a'), + # ('c2', ''), + # ('a3', r"2%20q") + # ] + # self.rfc3_params_sorted = [ + # ('a2', r"r%20b"), + # # ('a3', r"2%20q"), # disallow multiple + # ('a3', "a"), + # ('b5', r"%3D%253D"), + # ('c%40', ""), + # ('c2', ''), + # ('oauth_consumer_key', '9djdj82h48djs9d2'), + # ('oauth_nonce', '7d8f3e4a'), + # ('oauth_signature_method', 'HMAC-SHA1'), + # ('oauth_timestamp', '137131201'), + # ('oauth_token', 'kkk9d7dh3k39sjv7'), + # ] + # self.rfc3_param_string = r"a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9djdj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1&oauth_timestamp=137131201&oauth_token=kkk9d7dh3k39sjv7" + # self.rfc3_base_string = r"GET&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q%26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk9d7dh3k39sjv7" # test data taken from : https://dev.twitter.com/oauth/overview/creating-signatures @@ -472,46 +473,46 @@ def test_get_sign_key(self): ) # @unittest.skip("changed order of parms to fit wordpress api") - def test_normalize_params(self): + # def test_normalize_params(self): # params = dict([('oauth_callback', 'localhost:8888/wordpress'), ('oauth_consumer_key', 'LCLwTOfxoXGh'), ('oauth_nonce', '45474014077032100721477037582'), ('oauth_signature_method', 'HMAC-SHA1'), ('oauth_timestamp', 1477037582), ('oauth_version', '1.0')]) # expected_normalized_params = "oauth_callback=localhost%3A8888%2Fwordpress&oauth_consumer_key=LCLwTOfxoXGh&oauth_nonce=45474014077032100721477037582&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1477037582&oauth_version=1.0" # normalized_params = OAuth.normalize_params(params) # self.assertEqual(expected_normalized_params, normalized_params) # TEST WITH RFC EXAMPLE 1 DATA - normalized_params = OAuth.normalize_params(self.rfc1_request_params) + # normalized_params = OAuth.normalize_params(self.rfc1_request_params) # print "\nRFC1 NORMALIZED PARAMS: ", normalized_params, "\n" # TEST WITH RFC EXAMPLE 3 DATA - normalized_params = OAuth.normalize_params(self.rfc3_params_raw) - expected_normalized_params = self.rfc3_params_encoded - # print "\nn: %s\ne: %s" % (normalized_params, expected_normalized_params) - self.assertEqual(len(normalized_params), len(expected_normalized_params)) - for i in range(len(normalized_params)): - self.assertEqual(normalized_params[i], expected_normalized_params[i]) - self.assertEqual(normalized_params, expected_normalized_params) + # normalized_params = OAuth.normalize_params(self.rfc3_params_raw) + # expected_normalized_params = self.rfc3_params_encoded + # # print "\nn: %s\ne: %s" % (normalized_params, expected_normalized_params) + # self.assertEqual(len(normalized_params), len(expected_normalized_params)) + # for i in range(len(normalized_params)): + # self.assertEqual(normalized_params[i], expected_normalized_params[i]) + # self.assertEqual(normalized_params, expected_normalized_params) # TEST WITH LEXEV DATA: - normalized_params = OAuth.normalize_params(self.lexev_request_params) + # normalized_params = OAuth.normalize_params(self.lexev_request_params) # print "\nLEXEV NORMALIZED PARAMS: ", normalized_params, "\n" - def test_sort_params(self): - # TEST WITH RFC EXAMPLE 3 DATA - sorted_params = OAuth.sorted_params(self.rfc3_params_encoded) - expected_sorted_params = self.rfc3_params_sorted - self.assertEqual(sorted_params, expected_sorted_params) + # def test_sort_params(self): + # # TEST WITH RFC EXAMPLE 3 DATA + # sorted_params = OAuth.sorted_params(self.rfc3_params_encoded) + # expected_sorted_params = self.rfc3_params_sorted + # self.assertEqual(sorted_params, expected_sorted_params) def test_flatten_params(self): - # TEST WITH RFC EXAMPLE 1 DATA - flattened_params = OAuth.flatten_params(self.rfc1_request_params) - # print flattened_params + # # TEST WITH RFC EXAMPLE 1 DATA + # flattened_params = OAuth.flatten_params(self.rfc1_request_params) + # # print flattened_params - # TEST WITH RFC EXAMPLE 3 DATA - flattened_params = OAuth.flatten_params(self.rfc3_params_raw) - expected_flattened_params = self.rfc3_param_string - # print "\nn: %s\ne: %s" % (flattened_params, expected_flattened_params) - self.assertEqual(flattened_params, expected_flattened_params) + # # TEST WITH RFC EXAMPLE 3 DATA + # flattened_params = OAuth.flatten_params(self.rfc3_params_raw) + # expected_flattened_params = self.rfc3_param_string + # # print "\nn: %s\ne: %s" % (flattened_params, expected_flattened_params) + # self.assertEqual(flattened_params, expected_flattened_params) # TEST WITH TWITTER DATA flattened_params = OAuth.flatten_params(self.twitter_params_raw) @@ -519,14 +520,14 @@ def test_flatten_params(self): # print "\nn: %s\ne: %s" % (flattened_params, expected_flattened_params) self.assertEqual(flattened_params, expected_flattened_params) - def test_get_signature_base_string(self): - # TEST WITH RFC EXAMPLE 3 DATA - rfc3_base_string = OAuth.get_signature_base_string( - self.rfc3_method, - self.rfc3_params_raw, - self.rfc3_target_url - ) - self.assertEqual(rfc3_base_string, self.rfc3_base_string) + # def test_get_signature_base_string(self): + # # TEST WITH RFC EXAMPLE 3 DATA + # rfc3_base_string = OAuth.get_signature_base_string( + # self.rfc3_method, + # self.rfc3_params_raw, + # self.rfc3_target_url + # ) + # self.assertEqual(rfc3_base_string, self.rfc3_base_string) # TEST WITH TWITTER DATA twitter_base_string = OAuth.get_signature_base_string( diff --git a/wordpress/oauth.py b/wordpress/oauth.py index 00f3b1f..3236c5c 100644 --- a/wordpress/oauth.py +++ b/wordpress/oauth.py @@ -140,19 +140,22 @@ def generate_oauth_signature(self, method, params, url, key=None): @classmethod def sorted_params(cls, params): """ Sort parameters. works with RFC 5849 logic. params is a list of key, value pairs """ + if isinstance(params, dict): params = params.items() - return sorted(params) - # ordered = [] - # base_keys = sorted(set(k.split('[')[0] for k, v in params)) - # - # for base in base_keys: - # for key, value in params: - # if key == base or key.startswith(base + '['): - # ordered.append((key, value)) - - # return ordered + # return sorted(params) + ordered = [] + base_keys = sorted(set(k.split('[')[0] for k, v in params)) + keys_seen = [] + for base in base_keys: + for key, value in params: + if key == base or key.startswith(base + '['): + if key not in keys_seen: + ordered.append((key, value)) + keys_seen.append(key) + + return ordered @classmethod def normalize_str(cls, string): From a7abd7cd065cd2ee482dad1d59803fdd3e8894c5 Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 5 Nov 2016 08:14:53 +1100 Subject: [PATCH 011/129] updated requirements, includes beautifulsoup --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index d090df9..2b3bfb3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ requests==2.7.0 ordereddict==1.1 +bs4 From b8db0f3bcca9cb3082b45d11a263d4e2199e4caf Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 13 Dec 2016 17:44:23 +1100 Subject: [PATCH 012/129] V1.2.1: added connection helpers and tests. bug fixes. update readme. fixed some edge cases where queries were out of order causing signature mismatch. updated --- README.rst | 31 +++++- setup.py | 2 +- tests.py | 222 ++++++++++++++++++++++++------------------ wordpress/__init__.py | 4 +- wordpress/api.py | 9 +- wordpress/helpers.py | 64 +++++++++++- 6 files changed, 226 insertions(+), 106 deletions(-) diff --git a/README.rst b/README.rst index d21191c..9b61a90 100644 --- a/README.rst +++ b/README.rst @@ -12,22 +12,32 @@ Roadmap ------- - [x] Create initial fork -- [ ] Implement 3-legged OAuth on Wordpress client +- [x] Implement 3-legged OAuth on Wordpress client - [ ] Implement iterator for convent access to API items Requirements ------------ -Your site should have the following plugins installed on your wordpress site: +You should have the following plugins installed on your wordpress site: - **WP REST API** (recommended version: 2.0+) - **WP REST API - OAuth 1.0a Server** (https://github.com/WP-API/OAuth1) - **WP REST API - Meta Endpoints** (optional) +The following python packages are also used by the package + +- **requests** +- **beautifulsoup** Installation ------------ +Install with pip + +.. code-block:: bash + + pip install wordpress-api + Download this repo and use setuptools to install the package .. code-block:: bash @@ -36,6 +46,15 @@ Download this repo and use setuptools to install the package git clone https://github.com/derwentx/wp-api-python python setup.py install +Testing +------- + +If you have installed from source, then you can test with unittest: + +.. code-block:: bash + + python -m unittest -v tests + Getting started --------------- @@ -57,7 +76,9 @@ Setup for the old Wordpress API: consumer_key="XXXXXXXXXXXX", consumer_secret="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" api="wp-json", - version=None + version=None, + wp_user="XXXX", + wp_pass="XXXX" ) Setup for the new WP REST API v2: @@ -71,7 +92,9 @@ Setup for the new WP REST API v2: consumer_key="XXXXXXXXXXXX", consumer_secret="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" api="wp-json", - version="wp/v2" + version="wp/v2", + wp_user="XXXX", + wp_pass="XXXX" ) Setup for the old WooCommerce API v3: diff --git a/setup.py b/setup.py index 5058385..83f21da 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( - name="Wordpress", + name="wordpress-api", version=VERSION, description="A Python wrapper for the Wordpress REST API", long_description=README, diff --git a/tests.py b/tests.py index 46e6a12..2a6fe2f 100644 --- a/tests.py +++ b/tests.py @@ -1,5 +1,9 @@ """ API Tests """ import unittest +import sys +import pdb +import functools +import traceback from httmock import all_requests, HTTMock, urlmatch from collections import OrderedDict @@ -10,6 +14,7 @@ from wordpress.transport import API_Requests_Wrapper from wordpress.api import API from wordpress.oauth import OAuth +import random try: from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse @@ -19,6 +24,21 @@ from urlparse import parse_qs, parse_qsl, urlparse, urlunparse from urlparse import ParseResult as URLParseResult +def debug_on(*exceptions): + if not exceptions: + exceptions = (AssertionError, ) + def decorator(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + try: + return f(*args, **kwargs) + except exceptions: + info = sys.exc_info() + traceback.print_exception(*info) + pdb.post_mortem(info[2]) + return wrapper + return decorator + class WordpressTestCase(unittest.TestCase): """Test case for the client methods.""" @@ -162,6 +182,10 @@ def check_sorted(keys, expected): check_sorted(['a1', 'b[c]', 'b[a]', 'b[b]', 'a2'], ['a1', 'a2', 'b[c]', 'b[a]', 'b[b]']) class HelperTestcase(unittest.TestCase): + def setUp(self): + self.test_url = "http://ich.local:8888/woocommerce/wc-api/v3/products?filter%5Blimit%5D=2&oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1481601370&page=2" + + def test_url_is_ssl(self): self.assertTrue(UrlUtils.is_ssl("https://woo.test:8888")) self.assertFalse(UrlUtils.is_ssl("http://woo.test:8888")) @@ -232,6 +256,42 @@ def test_url_get_php_value(self): UrlUtils.get_value_like_as_php(1.1) ) + def test_url_get_query_dict_singular(self): + result = UrlUtils.get_query_dict_singular(self.test_url) + self.assertEquals( + result, + { + 'filter[limit]': '2', + 'oauth_nonce': 'c4f2920b0213c43f2e8d3d3333168ec4a22222d1', + 'oauth_timestamp': '1481601370', + 'oauth_consumer_key': 'ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + 'oauth_signature_method': 'HMAC-SHA1', + 'oauth_signature': '3ibOjMuhj6JGnI43BQZGniigHh8=', + 'page': '2' + } + ) + + def test_url_get_query_singular(self): + result = UrlUtils.get_query_singular(self.test_url, 'oauth_consumer_key') + self.assertEqual( + result, + 'ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + ) + result = UrlUtils.get_query_singular(self.test_url, 'filter[limit]') + self.assertEqual( + str(result), + str(2) + ) + + def test_url_set_query_singular(self): + result = UrlUtils.set_query_singular(self.test_url, 'filter[limit]', 3) + expected = "http://ich.local:8888/woocommerce/wc-api/v3/products?filter%5Blimit%5D=3&oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1481601370&page=2" + self.assertEqual(result, expected) + + def test_url_del_query_singular(self): + result = UrlUtils.del_query_singular(self.test_url, 'filter[limit]') + expected = "http://ich.local:8888/woocommerce/wc-api/v3/products?oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1481601370&page=2" + self.assertEqual(result, expected) def test_seq_filter_true(self): self.assertEquals( @@ -472,64 +532,11 @@ def test_get_sign_key(self): self.twitter_signing_key ) - # @unittest.skip("changed order of parms to fit wordpress api") - # def test_normalize_params(self): - # params = dict([('oauth_callback', 'localhost:8888/wordpress'), ('oauth_consumer_key', 'LCLwTOfxoXGh'), ('oauth_nonce', '45474014077032100721477037582'), ('oauth_signature_method', 'HMAC-SHA1'), ('oauth_timestamp', 1477037582), ('oauth_version', '1.0')]) - # expected_normalized_params = "oauth_callback=localhost%3A8888%2Fwordpress&oauth_consumer_key=LCLwTOfxoXGh&oauth_nonce=45474014077032100721477037582&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1477037582&oauth_version=1.0" - # normalized_params = OAuth.normalize_params(params) - # self.assertEqual(expected_normalized_params, normalized_params) - - # TEST WITH RFC EXAMPLE 1 DATA - # normalized_params = OAuth.normalize_params(self.rfc1_request_params) - # print "\nRFC1 NORMALIZED PARAMS: ", normalized_params, "\n" - - # TEST WITH RFC EXAMPLE 3 DATA - # normalized_params = OAuth.normalize_params(self.rfc3_params_raw) - # expected_normalized_params = self.rfc3_params_encoded - # # print "\nn: %s\ne: %s" % (normalized_params, expected_normalized_params) - # self.assertEqual(len(normalized_params), len(expected_normalized_params)) - # for i in range(len(normalized_params)): - # self.assertEqual(normalized_params[i], expected_normalized_params[i]) - # self.assertEqual(normalized_params, expected_normalized_params) - - # TEST WITH LEXEV DATA: - # normalized_params = OAuth.normalize_params(self.lexev_request_params) - # print "\nLEXEV NORMALIZED PARAMS: ", normalized_params, "\n" - - - # def test_sort_params(self): - # # TEST WITH RFC EXAMPLE 3 DATA - # sorted_params = OAuth.sorted_params(self.rfc3_params_encoded) - # expected_sorted_params = self.rfc3_params_sorted - # self.assertEqual(sorted_params, expected_sorted_params) - def test_flatten_params(self): - # # TEST WITH RFC EXAMPLE 1 DATA - # flattened_params = OAuth.flatten_params(self.rfc1_request_params) - # # print flattened_params - - # # TEST WITH RFC EXAMPLE 3 DATA - # flattened_params = OAuth.flatten_params(self.rfc3_params_raw) - # expected_flattened_params = self.rfc3_param_string - # # print "\nn: %s\ne: %s" % (flattened_params, expected_flattened_params) - # self.assertEqual(flattened_params, expected_flattened_params) - - # TEST WITH TWITTER DATA flattened_params = OAuth.flatten_params(self.twitter_params_raw) expected_flattened_params = self.twitter_param_string - # print "\nn: %s\ne: %s" % (flattened_params, expected_flattened_params) self.assertEqual(flattened_params, expected_flattened_params) - # def test_get_signature_base_string(self): - # # TEST WITH RFC EXAMPLE 3 DATA - # rfc3_base_string = OAuth.get_signature_base_string( - # self.rfc3_method, - # self.rfc3_params_raw, - # self.rfc3_target_url - # ) - # self.assertEqual(rfc3_base_string, self.rfc3_base_string) - - # TEST WITH TWITTER DATA twitter_base_string = OAuth.get_signature_base_string( self.twitter_method, self.twitter_params_raw, @@ -600,50 +607,9 @@ def test_add_params_sign(self): signed_url = self.wcapi.oauth.add_params_sign("GET", endpoint_url, params) signed_url_params = parse_qsl(urlparse(signed_url).query) - # print signed_url_params # self.assertEqual('page', signed_url_params[-1][0]) self.assertIn('page', dict(signed_url_params)) - # def test_get_oauth_url(self): - # request_oauth_url = self.rfc1_api.oauth.get_oauth_url(self.rfc1_request_target_url, self.rfc1_request_method) - # print request_oauth_url - - # def test_normalize_params(self): - - - - # def generate_oauth_signature(self): - # base_url = "http://localhost:8888/wordpress/" - # api_name = 'wc-api' - # api_ver = 'v3' - # endpoint = 'products/99' - # signature_method = "HAMC-SHA1" - # consumer_key = "ck_681c2be361e415519dce4b65ee981682cda78bc6" - # consumer_secret = "cs_b11f652c39a0afd3752fc7bb0c56d60d58da5877" - # - # wcapi = API( - # url=base_url, - # consumer_key=consumer_key, - # consumer_secret=consumer_secret, - # api=api_name, - # version=api_ver, - # signature_method=signature_method - # ) - # - # endpoint_url = UrlUtils.join_components([base_url, api_name, api_ver, endpoint]) - # - # params = OrderedDict() - # params["oauth_consumer_key"] = consumer_key - # params["oauth_timestamp"] = "1477041328" - # params["oauth_nonce"] = "166182658461433445531477041328" - # params["oauth_signature_method"] = signature_method - # params["oauth_version"] = "1.0" - # params["oauth_callback"] = 'localhost:8888/wordpress' - # - # sig = wcapi.oauth.generate_oauth_signature("POST", params, endpoint_url) - # expected_sig = "517qNKeq/vrLZGj2UH7+q8ILWAg=" - # self.assertEqual(sig, expected_sig) - class OAuth3LegTestcases(unittest.TestCase): def setUp(self): self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" @@ -732,3 +698,71 @@ def test_get_request_token(self): access_token, access_token_secret = self.api.oauth.get_request_token() self.assertEquals(access_token, 'XXXXXXXXXXXX') self.assertEquals(access_token_secret, 'YYYYYYYYYYYY') + +class ApiTestCases(unittest.TestCase): + def setUp(self): + self.apiParams = { + 'url':'http://ich.local:8888/woocommerce/', + 'api':'wc-api', + 'version':'v3', + 'consumer_key':'ck_0297450a41484f27184d1a8a3275f9bab5b69143', + 'consumer_secret':'cs_68ef2cf6a708e1c6b30bfb2a38dc948b16bf46c0', + } + + # @unittest.skip("should only work on my machine") + @debug_on() + def test_APIGet(self): + wcapi = API(**self.apiParams) + response = wcapi.get('products') + # print UrlUtils.beautify_response(response) + self.assertIn(response.status_code, [200,201]) + + response_obj = response.json() + self.assertIn('products', response_obj) + self.assertEqual(len(response_obj['products']), 10) + # print "test_APIGet", response_obj + + # @unittest.skip("should only work on my machine") + @debug_on() + def test_APIGetWithSimpleQuery(self): + wcapi = API(**self.apiParams) + response = wcapi.get('products?page=2') + # print UrlUtils.beautify_response(response) + self.assertIn(response.status_code, [200,201]) + + response_obj = response.json() + self.assertIn('products', response_obj) + self.assertEqual(len(response_obj['products']), 10) + # print "test_ApiGenWithSimpleQuery", response_obj + + # @unittest.skip("should only work on my machine") + @debug_on() + def test_APIGetWithComplexQuery(self): + wcapi = API(**self.apiParams) + response = wcapi.get('products?page=2&filter%5Blimit%5D=2') + self.assertIn(response.status_code, [200,201]) + response_obj = response.json() + self.assertIn('products', response_obj) + self.assertEqual(len(response_obj['products']), 2) + + response = wcapi.get('products?oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&oauth_nonce=037470f3b08c9232b0888f52cb9d4685b44d8fd1&oauth_signature=wrKfuIjbwi%2BTHynAlTP4AssoPS0%3D&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1481606275&filter%5Blimit%5D=3') + self.assertIn(response.status_code, [200,201]) + response_obj = response.json() + self.assertIn('products', response_obj) + self.assertEqual(len(response_obj['products']), 3) + + def test_APIPutWithSimpleQuery(self): + wcapi = API(**self.apiParams) + nonce = str(random.random()) + response = wcapi.put('products/633?filter%5Blimit%5D=5', {"product":{"title":str(nonce)}}) + request_params = UrlUtils.get_query_dict_singular(response.request.url) + # print "\ntest_APIPutWithSimpleQuery" + # print "request url", response.request.url + # print "response", UrlUtils.beautify_response(response) + response_obj = response.json() + # print "response obj", response_obj + self.assertEqual(response_obj['product']['title'], str(nonce)) + self.assertEqual(request_params['filter[limit]'], str(5)) + +if __name__ == '__main__': + unittest.main() diff --git a/wordpress/__init__.py b/wordpress/__init__.py index 93f6630..90dcbc3 100644 --- a/wordpress/__init__.py +++ b/wordpress/__init__.py @@ -2,7 +2,7 @@ """ wordpress -~~~~~~~~~~~~~~~ +~~~~~~~~~ A Python wrapper for Wordpress REST API. :copyright: (c) 2015 by WooThemes. @@ -10,7 +10,7 @@ """ __title__ = "wordpress" -__version__ = "1.2.0" +__version__ = "1.2.1" __author__ = "Claudio Sanches @ WooThemes" __license__ = "MIT" diff --git a/wordpress/api.py b/wordpress/api.py index 4f14ac8..0388523 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -79,16 +79,17 @@ def callback(self): def __request(self, method, endpoint, data): """ Do requests """ endpoint_url = self.requester.endpoint_url(endpoint) + # endpoint_params = UrlUtils.get_query_dict_singular(endpoint_url) + endpoint_params = {} auth = None - params = {} if self.requester.is_ssl is True and self.requester.query_string_auth is False: auth = (self.oauth.consumer_key, self.oauth.consumer_secret) elif self.requester.is_ssl is True and self.requester.query_string_auth is True: - params = { + endpoint_params.update({ "consumer_key": self.oauth.consumer_key, "consumer_secret": self.oauth.consumer_secret - } + }) else: endpoint_url = self.oauth.get_oauth_url(endpoint_url, method) @@ -99,7 +100,7 @@ def __request(self, method, endpoint, data): method=method, url=endpoint_url, auth=auth, - params=params, + params=endpoint_params, data=data ) diff --git a/wordpress/helpers.py b/wordpress/helpers.py index d470f93..7785611 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -13,9 +13,11 @@ from urllib.parse import ParseResult as URLParseResult except ImportError: from urllib import urlencode, quote, unquote - from urlparse import parse_qsl, urlparse, urlunparse + from urlparse import parse_qs, parse_qsl, urlparse, urlunparse from urlparse import ParseResult as URLParseResult +from collections import OrderedDict + from bs4 import BeautifulSoup @@ -43,6 +45,66 @@ def filter_true(cls, seq): return [item for item in seq if item] class UrlUtils(object): + + @classmethod + def get_query_list(cls, url): + """ returns the list of queries in the url """ + return parse_qsl(urlparse(url).query) + + @classmethod + def get_query_dict_singular(cls, url): + """ return an ordered mapping from each key in the query string to a singular value """ + query_list = cls.get_query_list(url) + return OrderedDict(query_list) + # query_dict = parse_qs(urlparse(url).query) + # query_dict_singular = dict([ + # (key, value[0]) for key, value in query_dict.items() + # ]) + # return query_dict_singular + + @classmethod + def set_query_singular(cls, url, key, value): + """ Sets or overrides a single query in a url """ + query_dict_singular = cls.get_query_dict_singular(url) + # print "setting key %s to value %s" % (key, value) + query_dict_singular[key] = value + # print query_dict_singular + query_string = urlencode(query_dict_singular) + # print "new query string", query_string + return cls.substitute_query(url, query_string) + + @classmethod + def get_query_singular(cls, url, key, default=None): + """ Gets the value of a single query in a url """ + url_params = parse_qs(urlparse(url).query) + values = url_params.get(key, [default]) + assert len(values) == 1, "ambiguous value, could not get singular for key: %s" % key + return values[0] + + @classmethod + def del_query_singular(cls, url, key): + """ deletes a singular key from the query string """ + query_dict_singular = cls.get_query_dict_singular(url) + if key in query_dict_singular: + del query_dict_singular[key] + query_string = urlencode(query_dict_singular) + url = cls.substitute_query(url, query_string) + return url + + # @classmethod + # def split_url_query(cls, url): + # """ Splits a url, returning the url without query and the query as a dict """ + # parsed_result = urlparse(url) + # parsed_query_dict = parse_qs(parsed_result.query) + # split_url = cls.substitute_query(url) + # return split_url, parsed_query_dict + + @classmethod + def split_url_query_singular(cls, url): + query_dict_singular = cls.get_query_dict_singular(url) + split_url = cls.substitute_query(url) + return split_url, query_dict_singular + @classmethod def substitute_query(cls, url, query_string=None): """ Replaces the query string in the url with the provided string or From cb0f0b3e640ffde5f52e80215c05fcf7c5690c5d Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 13 Dec 2016 17:51:03 +1100 Subject: [PATCH 013/129] updated changelog --- README.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.rst b/README.rst index 9b61a90..cacbf0c 100644 --- a/README.rst +++ b/README.rst @@ -209,7 +209,15 @@ Example of returned data: Changelog --------- +1.2.1 - 2016/12/13 +~~~~~~~~~~~~~~~~~~ +- tested to handle complex queries like filter[limit] +- fix: Some edge cases where queries were out of order causing signature mismatch +- hardened helper and api classes and added corresponding test cases + 1.2.0 - 2016/09/28 ~~~~~~~~~~~~~~~~~~ - Initial fork +- Implemented 3-legged OAuth +- Tested with pagination \ No newline at end of file From e2e524d6e227a06c566eee3b6a91f17fbf53564d Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 23 Jan 2017 15:21:57 +1100 Subject: [PATCH 014/129] added extra information for WP v4.7 --- README.rst | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index cacbf0c..f59ca5b 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,8 @@ Wordpress API - Python Client =============================== -A Python wrapper for the Wordpress REST API that also works on the WooCommerce REST API v1-3 and WooCommerce WP-API v1. -Forked from the excellent Wordpress API written by Claudio Sanches @ WooThemes and modified to work with Wordpress: https://github.com/woocommerce/wc-api-python +A Python wrapper for the Wordpress REST API v1-2 that also works on the WooCommerce REST API v1-3 and WooCommerce WP-API v1. +Forked from the excellent Woocommerce API written by Claudio Sanches and modified to work with Wordpress: https://github.com/woocommerce/wc-api-python I created this fork because I prefer the way that the wc-api-python client interfaces with the Wordpress API compared to the existing python client, https://pypi.python.org/pypi/wordpress_json @@ -18,9 +18,11 @@ Roadmap Requirements ------------ +Wordpress version 4.7+ comes pre-installed with REST API v2, so you don't need to have the WP REST API plugin if you have the latest Wordpress. + You should have the following plugins installed on your wordpress site: -- **WP REST API** (recommended version: 2.0+) +- **WP REST API** (recommended version: 2.0+, only required for WP < v4.7) - **WP REST API - OAuth 1.0a Server** (https://github.com/WP-API/OAuth1) - **WP REST API - Meta Endpoints** (optional) @@ -60,6 +62,10 @@ Getting started Generate API credentials (Consumer Key & Consumer Secret) following these instructions: http://v2.wp-api.org/guide/authentication/ +Simply go to Users -> Applications and create an Application, e.g. "REST API". +Enter a callback URL that you will be able to remember later such as "http://example.com/oauth1_callback" (not really important for this client). +Store the resulting Key and Secret somewhere safe. + Check out the Wordpress API endpoints and data that can be manipulated in http://v2.wp-api.org/reference/. Setup From d72779ec0fa3fc3b75273c7df875d94e7199c7aa Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 23 Jan 2017 15:22:12 +1100 Subject: [PATCH 015/129] certain tests only execute on my machine --- tests.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests.py b/tests.py index 2a6fe2f..c60aaa7 100644 --- a/tests.py +++ b/tests.py @@ -15,6 +15,7 @@ from wordpress.api import API from wordpress.oauth import OAuth import random +import platform try: from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse @@ -699,7 +700,8 @@ def test_get_request_token(self): self.assertEquals(access_token, 'XXXXXXXXXXXX') self.assertEquals(access_token_secret, 'YYYYYYYYYYYY') -class ApiTestCases(unittest.TestCase): +@unittest.skipIf(platform.uname()[1] != "Ich.lan", "should only work on my machine") +class WCApiTestCases(unittest.TestCase): def setUp(self): self.apiParams = { 'url':'http://ich.local:8888/woocommerce/', @@ -708,8 +710,7 @@ def setUp(self): 'consumer_key':'ck_0297450a41484f27184d1a8a3275f9bab5b69143', 'consumer_secret':'cs_68ef2cf6a708e1c6b30bfb2a38dc948b16bf46c0', } - - # @unittest.skip("should only work on my machine") + @debug_on() def test_APIGet(self): wcapi = API(**self.apiParams) @@ -722,7 +723,6 @@ def test_APIGet(self): self.assertEqual(len(response_obj['products']), 10) # print "test_APIGet", response_obj - # @unittest.skip("should only work on my machine") @debug_on() def test_APIGetWithSimpleQuery(self): wcapi = API(**self.apiParams) @@ -735,7 +735,6 @@ def test_APIGetWithSimpleQuery(self): self.assertEqual(len(response_obj['products']), 10) # print "test_ApiGenWithSimpleQuery", response_obj - # @unittest.skip("should only work on my machine") @debug_on() def test_APIGetWithComplexQuery(self): wcapi = API(**self.apiParams) @@ -764,5 +763,9 @@ def test_APIPutWithSimpleQuery(self): self.assertEqual(response_obj['product']['title'], str(nonce)) self.assertEqual(request_params['filter[limit]'], str(5)) +class WPAPITestCases(unittest.TestCase): + pass + + if __name__ == '__main__': unittest.main() From de39a8ae12d4a8aaa74b66b07a6f0122657d62f7 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 23 Jan 2017 16:20:09 +1100 Subject: [PATCH 016/129] extra test cases specifically for WP --- tests.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests.py b/tests.py index c60aaa7..2278c07 100644 --- a/tests.py +++ b/tests.py @@ -763,8 +763,38 @@ def test_APIPutWithSimpleQuery(self): self.assertEqual(response_obj['product']['title'], str(nonce)) self.assertEqual(request_params['filter[limit]'], str(5)) +@unittest.skipIf(platform.uname()[1] != "Ich.lan", "should only work on my machine") class WPAPITestCases(unittest.TestCase): - pass + def setUp(self): + self.apiParams = { + 'url':'http://ich.local:8888/woocommerce/', + 'api':'wp-json', + 'version':'wp/v2', + 'consumer_key':'kGUDYhYPNTTq', + 'consumer_secret':'44fhpRsd0yo5deHaUSTZUtHgamrKwARzV8JUgTbGu61qrI0i', + 'callback':'http://127.0.0.1/oauth1_callback', + 'wp_user':'woocommerce', + 'wp_pass':'woocommerce', + 'oauth1a_3leg':True + } + + @debug_on() + def test_APIGet(self): + wpapi = API(**self.apiParams) + response = wpapi.get('users') + self.assertIn(response.status_code, [200,201]) + response_obj = response.json() + self.assertEqual(response_obj[0]['name'], 'woocommerce') + + def test_APIGetWithSimpleQuery(self): + wpapi = API(**self.apiParams) + response = wpapi.get('media?page=2') + # print UrlUtils.beautify_response(response) + self.assertIn(response.status_code, [200,201]) + + response_obj = response.json() + self.assertEqual(len(response_obj), 10) + # print "test_ApiGenWithSimpleQuery", response_obj if __name__ == '__main__': From 1860b90c32040b44156dc94d6ec0e01fe8ba310f Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 23 Jan 2017 16:20:41 +1100 Subject: [PATCH 017/129] compatibility with WP 4.7 ( see: 01b5000 ) --- wordpress/transport.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/wordpress/transport.py b/wordpress/transport.py index eb71b6e..0ebea5d 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -31,11 +31,6 @@ def __init__(self, url, **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.headers = { - "user-agent": "Wordpress API Client-Python/%s" % __version__, - "content-type": "application/json;charset=utf-8", - "accept": "application/json" - } self.session = Session() @property @@ -59,10 +54,17 @@ def endpoint_url(self, endpoint): ]) def request(self, method, url, auth=None, params=None, data=None, **kwargs): + headers = { + "user-agent": "Wordpress API Client-Python/%s" % __version__, + "accept": "application/json" + } + if data is not None: + headers["content-type"] = "application/json;charset=utf-8" + request_kwargs = dict( method=method, url=url, - headers=self.headers, + headers=headers, verify=self.verify_ssl, timeout=self.timeout, ) From 6ca81c9f435cd12107b758fbd9dbf809104b6463 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 17 Feb 2017 23:58:49 +1100 Subject: [PATCH 018/129] Hi Adrian --- tests.py | 6 ++++-- wordpress/api.py | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/tests.py b/tests.py index 2278c07..04e3e0c 100644 --- a/tests.py +++ b/tests.py @@ -700,7 +700,8 @@ def test_get_request_token(self): self.assertEquals(access_token, 'XXXXXXXXXXXX') self.assertEquals(access_token_secret, 'YYYYYYYYYYYY') -@unittest.skipIf(platform.uname()[1] != "Ich.lan", "should only work on my machine") +# @unittest.skipIf(platform.uname()[1] != "Ich.lan", "should only work on my machine") +@unittest.skip("Should only work on my machine") class WCApiTestCases(unittest.TestCase): def setUp(self): self.apiParams = { @@ -763,7 +764,8 @@ def test_APIPutWithSimpleQuery(self): self.assertEqual(response_obj['product']['title'], str(nonce)) self.assertEqual(request_params['filter[limit]'], str(5)) -@unittest.skipIf(platform.uname()[1] != "Ich.lan", "should only work on my machine") +# @unittest.skipIf(platform.uname()[1] != "Ich.lan", "should only work on my machine") +@unittest.skip("Should only work on my machine") class WPAPITestCases(unittest.TestCase): def setUp(self): self.apiParams = { diff --git a/wordpress/api.py b/wordpress/api.py index 0388523..eaba2d1 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -93,7 +93,20 @@ def __request(self, method, endpoint, data): else: endpoint_url = self.oauth.get_oauth_url(endpoint_url, method) - if data is not None: + # Bow before me mortals + # Before this statement got memed on it was: + # if data is not None: + # data = jsonencode(data, ensure_ascii=False).encode('utf-8') + + cond = (data is not None) + isTrue = True + trueStr = "True" + counter = 0 + for counter, condChar in enumerate(str(bool(cond))): + if counter >= len(trueStr) or condChar != trueStr[counter]: + isTrue = False + counter += 1 + if str(bool(isTrue)) == trueStr: data = jsonencode(data, ensure_ascii=False).encode('utf-8') response = self.requester.request( From cbb06725abb37e6eb37823e70770af9382e10109 Mon Sep 17 00:00:00 2001 From: James Brink Date: Wed, 31 May 2017 15:52:31 -0700 Subject: [PATCH 019/129] Fixed typo in README.rst Added missing comma in example snippet for creating wordpress API object. --- README.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index f59ca5b..168aa81 100644 --- a/README.rst +++ b/README.rst @@ -80,7 +80,7 @@ Setup for the old Wordpress API: wpapi = API( url="http://example.com", consumer_key="XXXXXXXXXXXX", - consumer_secret="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + consumer_secret="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", api="wp-json", version=None, wp_user="XXXX", @@ -96,7 +96,7 @@ Setup for the new WP REST API v2: wpapi = API( url="http://example.com", consumer_key="XXXXXXXXXXXX", - consumer_secret="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + consumer_secret="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", api="wp-json", version="wp/v2", wp_user="XXXX", @@ -112,7 +112,7 @@ Setup for the old WooCommerce API v3: wcapi = API( url="http://example.com", consumer_key="ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - consumer_secret="cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + consumer_secret="cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", api="wc-api", version="v3" ) @@ -226,4 +226,4 @@ Changelog - Initial fork - Implemented 3-legged OAuth -- Tested with pagination \ No newline at end of file +- Tested with pagination From f3726d0c1d1da5ddf59cfca7418be1a56975f11e Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 16 Jun 2017 16:50:41 +1000 Subject: [PATCH 020/129] rename oauth -> auth, better basic auth support support case where basic auth used in http, and when nonstandard port numbers given in header links --- tests.py | 46 ++++++------ wordpress/api.py | 69 ++++++++--------- wordpress/{oauth.py => auth.py} | 129 ++++++++++++++++++-------------- wordpress/helpers.py | 17 +++++ wordpress/transport.py | 14 ++++ 5 files changed, 156 insertions(+), 119 deletions(-) rename wordpress/{oauth.py => auth.py} (97%) diff --git a/tests.py b/tests.py index 04e3e0c..9c49d94 100644 --- a/tests.py +++ b/tests.py @@ -8,12 +8,12 @@ from collections import OrderedDict import wordpress -from wordpress import oauth +from wordpress import auth from wordpress import __default_api_version__, __default_api__ from wordpress.helpers import UrlUtils, SeqUtils, StrUtils from wordpress.transport import API_Requests_Wrapper from wordpress.api import API -from wordpress.oauth import OAuth +from wordpress.auth import OAuth import random import platform @@ -35,7 +35,7 @@ def wrapper(*args, **kwargs): return f(*args, **kwargs) except exceptions: info = sys.exc_info() - traceback.print_exception(*info) + traceback.print_exception(*info) pdb.post_mortem(info[2]) return wrapper return decorator @@ -167,11 +167,11 @@ def woo_test_mock(*args, **kwargs): def test_oauth_sorted_params(self): """ Test order of parameters for OAuth signature """ def check_sorted(keys, expected): - params = oauth.OrderedDict() + params = auth.OrderedDict() for key in keys: params[key] = '' - params = oauth.OAuth.sorted_params(params) + params = auth.OAuth.sorted_params(params) ordered = [key for key, value in params] self.assertEqual(ordered, expected) @@ -262,12 +262,12 @@ def test_url_get_query_dict_singular(self): self.assertEquals( result, { - 'filter[limit]': '2', - 'oauth_nonce': 'c4f2920b0213c43f2e8d3d3333168ec4a22222d1', - 'oauth_timestamp': '1481601370', - 'oauth_consumer_key': 'ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', - 'oauth_signature_method': 'HMAC-SHA1', - 'oauth_signature': '3ibOjMuhj6JGnI43BQZGniigHh8=', + 'filter[limit]': '2', + 'oauth_nonce': 'c4f2920b0213c43f2e8d3d3333168ec4a22222d1', + 'oauth_timestamp': '1481601370', + 'oauth_consumer_key': 'ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + 'oauth_signature_method': 'HMAC-SHA1', + 'oauth_signature': '3ibOjMuhj6JGnI43BQZGniigHh8=', 'page': '2' } ) @@ -524,12 +524,12 @@ def setUp(self): def test_get_sign_key(self): self.assertEqual( - self.wcapi.oauth.get_sign_key(self.consumer_secret), + self.wcapi.auth.get_sign_key(self.consumer_secret), "%s&" % self.consumer_secret ) self.assertEqual( - self.wcapi.oauth.get_sign_key(self.twitter_consumer_secret, self.twitter_token_secret), + self.wcapi.auth.get_sign_key(self.twitter_consumer_secret, self.twitter_token_secret), self.twitter_signing_key ) @@ -558,13 +558,13 @@ def test_generate_oauth_signature(self): # params["oauth_version"] = "1.0" # params["oauth_callback"] = 'localhost:8888/wordpress' # - # sig = self.wcapi.oauth.generate_oauth_signature("POST", params, endpoint_url) + # sig = self.wcapi.auth.generate_oauth_signature("POST", params, endpoint_url) # expected_sig = "517qNKeq/vrLZGj2UH7+q8ILWAg=" # self.assertEqual(sig, expected_sig) # TEST WITH RFC EXAMPLE 1 DATA - rfc1_request_signature = self.rfc1_api.oauth.generate_oauth_signature( + rfc1_request_signature = self.rfc1_api.auth.generate_oauth_signature( self.rfc1_request_method, self.rfc1_request_params, self.rfc1_request_target_url, @@ -576,7 +576,7 @@ def test_generate_oauth_signature(self): # TEST WITH TWITTER DATA - twitter_signature = self.twitter_api.oauth.generate_oauth_signature( + twitter_signature = self.twitter_api.auth.generate_oauth_signature( self.twitter_method, self.twitter_params_raw, self.twitter_target_url, @@ -586,7 +586,7 @@ def test_generate_oauth_signature(self): # TEST WITH LEXEV DATA - lexev_request_signature = self.lexev_api.oauth.generate_oauth_signature( + lexev_request_signature = self.lexev_api.auth.generate_oauth_signature( method=self.lexev_request_method, params=self.lexev_request_params, url=self.lexev_request_url @@ -605,7 +605,7 @@ def test_add_params_sign(self): params["oauth_version"] = "1.0" params["oauth_callback"] = 'localhost:8888/wordpress' - signed_url = self.wcapi.oauth.add_params_sign("GET", endpoint_url, params) + signed_url = self.wcapi.auth.add_params_sign("GET", endpoint_url, params) signed_url_params = parse_qsl(urlparse(signed_url).query) # self.assertEqual('page', signed_url_params[-1][0]) @@ -664,7 +664,7 @@ def woo_authentication_mock(*args, **kwargs): def test_get_sign_key(self): oauth_token_secret = "PNW9j1yBki3e7M7EqB5qZxbe9n5tR6bIIefSMQ9M2pdyRI9g" - key = self.api.oauth.get_sign_key(self.consumer_secret, oauth_token_secret) + key = self.api.auth.get_sign_key(self.consumer_secret, oauth_token_secret) self.assertEqual( key, "%s&%s" % (self.consumer_secret, oauth_token_secret) @@ -676,7 +676,7 @@ def test_auth_discovery(self): with HTTMock(self.woo_api_mock): # call requests - authentication = self.api.oauth.authentication + authentication = self.api.auth.authentication self.assertEquals( authentication, { @@ -692,11 +692,11 @@ def test_auth_discovery(self): def test_get_request_token(self): with HTTMock(self.woo_api_mock): - authentication = self.api.oauth.authentication + authentication = self.api.auth.authentication self.assertTrue(authentication) with HTTMock(self.woo_authentication_mock): - access_token, access_token_secret = self.api.oauth.get_request_token() + access_token, access_token_secret = self.api.auth.get_request_token() self.assertEquals(access_token, 'XXXXXXXXXXXX') self.assertEquals(access_token_secret, 'YYYYYYYYYYYY') @@ -711,7 +711,7 @@ def setUp(self): 'consumer_key':'ck_0297450a41484f27184d1a8a3275f9bab5b69143', 'consumer_secret':'cs_68ef2cf6a708e1c6b30bfb2a38dc948b16bf46c0', } - + @debug_on() def test_APIGet(self): wcapi = API(**self.apiParams) diff --git a/wordpress/api.py b/wordpress/api.py index eaba2d1..dfca368 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -8,7 +8,7 @@ from requests import request from json import dumps as jsonencode -from wordpress.oauth import OAuth, OAuth_3Leg +from wordpress.auth import OAuth, OAuth_3Leg, BasicAuth from wordpress.transport import API_Requests_Wrapper from wordpress.helpers import UrlUtils @@ -19,22 +19,26 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): self.requester = API_Requests_Wrapper(url=url, **kwargs) - oauth_kwargs = dict( + auth_kwargs = dict( requester=self.requester, consumer_key=consumer_key, consumer_secret=consumer_secret, - force_nonce=kwargs.get('force_nonce'), - force_timestamp=kwargs.get('force_timestamp') ) - - if kwargs.get('oauth1a_3leg'): - self.oauth1a_3leg = kwargs['oauth1a_3leg'] - oauth_kwargs['callback'] = kwargs['callback'] - oauth_kwargs['wp_user'] = kwargs['wp_user'] - oauth_kwargs['wp_pass'] = kwargs['wp_pass'] - self.oauth = OAuth_3Leg( **oauth_kwargs ) + if kwargs.get('basic_auth'): + self.auth = BasicAuth(**auth_kwargs) else: - self.oauth = OAuth( **oauth_kwargs ) + auth_kwargs.update(dict( + force_nonce=kwargs.get('force_nonce'), + force_timestamp=kwargs.get('force_timestamp') + )) + if kwargs.get('oauth1a_3leg'): + self.oauth1a_3leg = kwargs['oauth1a_3leg'] + auth_kwargs['callback'] = kwargs['callback'] + auth_kwargs['wp_user'] = kwargs['wp_user'] + auth_kwargs['wp_pass'] = kwargs['wp_pass'] + self.auth = OAuth_3Leg( **auth_kwargs ) + else: + self.auth = OAuth( **auth_kwargs ) @property def url(self): @@ -66,47 +70,36 @@ def is_ssl(self): @property def consumer_key(self): - return self.oauth.consumer_key + return self.auth.consumer_key @property def consumer_secret(self): - return self.oauth.consumer_secret + return self.auth.consumer_secret @property def callback(self): - return self.oauth.callback + return self.auth.callback def __request(self, method, endpoint, data): """ Do requests """ + endpoint_url = self.requester.endpoint_url(endpoint) # endpoint_params = UrlUtils.get_query_dict_singular(endpoint_url) endpoint_params = {} auth = None - if self.requester.is_ssl is True and self.requester.query_string_auth is False: - auth = (self.oauth.consumer_key, self.oauth.consumer_secret) - elif self.requester.is_ssl is True and self.requester.query_string_auth is True: - endpoint_params.update({ - "consumer_key": self.oauth.consumer_key, - "consumer_secret": self.oauth.consumer_secret - }) + if self.requester.is_ssl or isinstance(self.auth, BasicAuth): + if self.requester.query_string_auth: + endpoint_params.update({ + "consumer_key": self.auth.consumer_key, + "consumer_secret": self.auth.consumer_secret + }) + else: + auth = (self.auth.consumer_key, self.auth.consumer_secret) else: - endpoint_url = self.oauth.get_oauth_url(endpoint_url, method) - - # Bow before me mortals - # Before this statement got memed on it was: - # if data is not None: - # data = jsonencode(data, ensure_ascii=False).encode('utf-8') - - cond = (data is not None) - isTrue = True - trueStr = "True" - counter = 0 - for counter, condChar in enumerate(str(bool(cond))): - if counter >= len(trueStr) or condChar != trueStr[counter]: - isTrue = False - counter += 1 - if str(bool(isTrue)) == trueStr: + endpoint_url = self.auth.get_oauth_url(endpoint_url, method) + + if data is not None: data = jsonencode(data, ensure_ascii=False).encode('utf-8') response = self.requester.request( diff --git a/wordpress/oauth.py b/wordpress/auth.py similarity index 97% rename from wordpress/oauth.py rename to wordpress/auth.py index 3236c5c..a686334 100644 --- a/wordpress/oauth.py +++ b/wordpress/auth.py @@ -4,7 +4,7 @@ Wordpress OAuth1.0a Class """ -__title__ = "wordpress-oauth" +__title__ = "wordpress-auth" from time import time from random import randint @@ -32,7 +32,75 @@ from wordpress.helpers import UrlUtils -class OAuth(object): +class Auth(object): + """ Boilerplate for handling authentication stuff. """ + + def __init__(self, requester): + self.requester = requester + + @property + def api_version(self): + return self.requester.api_version + + @property + def api_namespace(self): + return self.requester.api + + @classmethod + def normalize_params(cls, params): + """ Normalize parameters. works with RFC 5849 logic. params is a list of key, value pairs """ + if isinstance(params, dict): + params = params.items() + params = \ + [(cls.normalize_str(key), cls.normalize_str(UrlUtils.get_value_like_as_php(value))) \ + for key, value in params] + + # print "NORMALIZED: %s\n" % str(params.keys()) + # resposne = urlencode(params) + response = params + # print "RESPONSE: %s\n" % str(resposne.split('&')) + return response + + @classmethod + def sorted_params(cls, params): + """ Sort parameters. works with RFC 5849 logic. params is a list of key, value pairs """ + + if isinstance(params, dict): + params = params.items() + + # return sorted(params) + ordered = [] + base_keys = sorted(set(k.split('[')[0] for k, v in params)) + keys_seen = [] + for base in base_keys: + for key, value in params: + if key == base or key.startswith(base + '['): + if key not in keys_seen: + ordered.append((key, value)) + keys_seen.append(key) + + return ordered + + @classmethod + def normalize_str(cls, string): + return quote(string, '') + + @classmethod + def flatten_params(cls, params): + if isinstance(params, dict): + params = params.items() + params = cls.normalize_params(params) + params = cls.sorted_params(params) + return "&".join(["%s=%s"%(key, value) for key, value in params]) + +class BasicAuth(Auth): + def __init__(self, requester, consumer_key, consumer_secret, **kwargs): + super(BasicAuth, self).__init__(requester) + self.consumer_key = consumer_key + self.consumer_secret = consumer_secret + + +class OAuth(Auth): oauth_version = '1.0' force_nonce = None force_timestamp = None @@ -40,21 +108,13 @@ class OAuth(object): """ API Class """ def __init__(self, requester, consumer_key, consumer_secret, **kwargs): - self.requester = requester + super(OAuth, self).__init__(requester) self.consumer_key = consumer_key self.consumer_secret = consumer_secret self.signature_method = kwargs.get('signature_method', 'HMAC-SHA1') self.force_timestamp = kwargs.get('force_timestamp') self.force_nonce = kwargs.get('force_nonce') - @property - def api_version(self): - return self.requester.api_version - - @property - def api_namespace(self): - return self.requester.api - def get_sign_key(self, consumer_secret, token_secret=None): "gets consumer_secret and turns it into a string suitable for signing" if not consumer_secret: @@ -137,53 +197,6 @@ def generate_oauth_signature(self, method, params, url, key=None): # print "\nsig_b64: %s" % sig_b64 return sig_b64 - @classmethod - def sorted_params(cls, params): - """ Sort parameters. works with RFC 5849 logic. params is a list of key, value pairs """ - - if isinstance(params, dict): - params = params.items() - - # return sorted(params) - ordered = [] - base_keys = sorted(set(k.split('[')[0] for k, v in params)) - keys_seen = [] - for base in base_keys: - for key, value in params: - if key == base or key.startswith(base + '['): - if key not in keys_seen: - ordered.append((key, value)) - keys_seen.append(key) - - return ordered - - @classmethod - def normalize_str(cls, string): - return quote(string, '') - - @classmethod - def normalize_params(cls, params): - """ Normalize parameters. works with RFC 5849 logic. params is a list of key, value pairs """ - if isinstance(params, dict): - params = params.items() - params = \ - [(cls.normalize_str(key), cls.normalize_str(UrlUtils.get_value_like_as_php(value))) \ - for key, value in params] - - # print "NORMALIZED: %s\n" % str(params.keys()) - # resposne = urlencode(params) - response = params - # print "RESPONSE: %s\n" % str(resposne.split('&')) - return response - - @classmethod - def flatten_params(cls, params): - if isinstance(params, dict): - params = params.items() - params = cls.normalize_params(params) - params = cls.sorted_params(params) - return "&".join(["%s=%s"%(key, value) for key, value in params]) - @classmethod def generate_timestamp(cls): """ Generate timestamp """ diff --git a/wordpress/helpers.py b/wordpress/helpers.py index 7785611..5463475 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -6,6 +6,8 @@ __title__ = "wordpress-requests" +import re + import posixpath try: @@ -165,3 +167,18 @@ def get_value_like_as_php(val): def beautify_response(response): """ Returns a beautified response in the default locale """ return BeautifulSoup(response.text, 'lxml').prettify().encode(errors='backslashreplace') + + @classmethod + def remove_port(cls, url): + """ Remove the port number from a URL """ + + urlparse_result = urlparse(url) + + return urlunparse(URLParseResult( + scheme=urlparse_result.scheme, + netloc=re.sub(r':\d+', r'', urlparse_result.netloc), + path=urlparse_result.path, + params=urlparse_result.params, + query=urlparse_result.query, + fragment=urlparse_result.fragment + )) diff --git a/wordpress/transport.py b/wordpress/transport.py index 0ebea5d..8d83293 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -44,7 +44,21 @@ def api_url(self): self.api ]) + @property + def api_ver_url(self): + return UrlUtils.join_components([ + self.url, + self.api, + self.api_version + ]) + + @property + def api_ver_url_no_port(self): + return UrlUtils.remove_port(self.api_ver_url) + def endpoint_url(self, endpoint): + endpoint = StrUtils.decapitate(endpoint, self.api_ver_url) + endpoint = StrUtils.decapitate(endpoint, self.api_ver_url_no_port) endpoint = StrUtils.decapitate(endpoint, '/') return UrlUtils.join_components([ self.url, From 89f932a7e90f7ac35e913c2c9c847a9bc04a3484 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 16 Jun 2017 16:51:28 +1000 Subject: [PATCH 021/129] version 1.2.2 --- wordpress/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress/__init__.py b/wordpress/__init__.py index 90dcbc3..0469eb7 100644 --- a/wordpress/__init__.py +++ b/wordpress/__init__.py @@ -10,7 +10,7 @@ """ __title__ = "wordpress" -__version__ = "1.2.1" +__version__ = "1.2.2" __author__ = "Claudio Sanches @ WooThemes" __license__ = "MIT" From a82cd424a015d64d263e21cec89d400a1a640c8b Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 20 Jun 2017 14:54:43 +1000 Subject: [PATCH 022/129] updated v1.2.2 readme --- README.rst | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 168aa81..b446026 100644 --- a/README.rst +++ b/README.rst @@ -1,19 +1,21 @@ Wordpress API - Python Client =============================== -A Python wrapper for the Wordpress REST API v1-2 that also works on the WooCommerce REST API v1-3 and WooCommerce WP-API v1. +A Python wrapper for the Wordpress REST API v1-2 that also works on the WooCommerce REST API v1-3 and WooCommerce WP-API v1-2. Forked from the excellent Woocommerce API written by Claudio Sanches and modified to work with Wordpress: https://github.com/woocommerce/wc-api-python I created this fork because I prefer the way that the wc-api-python client interfaces with the Wordpress API compared to the existing python client, https://pypi.python.org/pypi/wordpress_json which does not support OAuth authentication, only Basic Authentication (very unsecure) +Any suggestions about how this repository could be improved are welcome :) + Roadmap ------- - [x] Create initial fork - [x] Implement 3-legged OAuth on Wordpress client -- [ ] Implement iterator for convent access to API items +- [ ] Implement iterator for conveniant access to API items Requirements ------------ @@ -22,9 +24,10 @@ Wordpress version 4.7+ comes pre-installed with REST API v2, so you don't need t You should have the following plugins installed on your wordpress site: -- **WP REST API** (recommended version: 2.0+, only required for WP < v4.7) -- **WP REST API - OAuth 1.0a Server** (https://github.com/WP-API/OAuth1) +- **WP REST API** (only required for WP < v4.7, recommended version: 2.0+) +- **WP REST API - OAuth 1.0a Server** (optional, if you want oauth. https://github.com/WP-API/OAuth1) - **WP REST API - Meta Endpoints** (optional) +- **WooCommerce** (optional, if you want to use the WooCommerce API) The following python packages are also used by the package @@ -55,6 +58,7 @@ If you have installed from source, then you can test with unittest: .. code-block:: bash + pip install -r requirements-test.txt python -m unittest -v tests Getting started @@ -199,6 +203,7 @@ Example of returned data: .. code-block:: bash + >>> from wordpress import api as wpapi >>> r = wpapi.get("posts") >>> r.status_code 200 @@ -215,6 +220,12 @@ Example of returned data: Changelog --------- +1.2.2 - 2017/06/16 +~~~~~~~~~~~~~~~~~~ + - support basic auth without https + - rename oauth module to auth (since auth covers oauth and basic auth) + - tested with latest versions of WP and WC + 1.2.1 - 2016/12/13 ~~~~~~~~~~~~~~~~~~ - tested to handle complex queries like filter[limit] From bd6fd0eba3c9929a4459bab5b1391a578a1e5af0 Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 20 Jun 2017 15:15:03 +1000 Subject: [PATCH 023/129] uploaded v1.2.2 to pypi --- README.rst | 6 +++--- setup.py | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index b446026..7a2632b 100644 --- a/README.rst +++ b/README.rst @@ -222,9 +222,9 @@ Changelog 1.2.2 - 2017/06/16 ~~~~~~~~~~~~~~~~~~ - - support basic auth without https - - rename oauth module to auth (since auth covers oauth and basic auth) - - tested with latest versions of WP and WC +- support basic auth without https +- rename oauth module to auth (since auth covers oauth and basic auth) +- tested with latest versions of WP and WC 1.2.1 - 2016/12/13 ~~~~~~~~~~~~~~~~~~ diff --git a/setup.py b/setup.py index 83f21da..fc4438c 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,6 @@ import os import re - # Get version from __init__.py file VERSION = "" with open("wordpress/__init__.py", "r") as fd: @@ -36,9 +35,10 @@ platforms=['any'], install_requires=[ "requests", - "ordereddict" + "ordereddict", + "beautifulsoup4" ], - classifiers=( + classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Natural Language :: English", @@ -50,5 +50,6 @@ "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Topic :: Software Development :: Libraries :: Python Modules" - ), + ], + keywords='python wordpress woocommerce api development' ) From e0f84c69f71fdbcc8dfa25bf645765a47dbd6c7d Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 20 Jun 2017 15:25:43 +1000 Subject: [PATCH 024/129] update description and roadmap --- README.rst | 5 ++++- setup.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 7a2632b..2fa5dac 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,9 @@ Wordpress API - Python Client =============================== -A Python wrapper for the Wordpress REST API v1-2 that also works on the WooCommerce REST API v1-3 and WooCommerce WP-API v1-2. +A Python wrapper for the Wordpress and WooCommerce REST APIs with oAuth1a 3leg support. + +Supports the Wordpress REST API v1-2, WooCommerce REST API v1-3 and WooCommerce WP-API v1-2 (with automatic OAuth3a handling). Forked from the excellent Woocommerce API written by Claudio Sanches and modified to work with Wordpress: https://github.com/woocommerce/wc-api-python I created this fork because I prefer the way that the wc-api-python client interfaces with @@ -15,6 +17,7 @@ Roadmap - [x] Create initial fork - [x] Implement 3-legged OAuth on Wordpress client +- [ ] Better local storage of OAuth creds to stop unnecessary API keys being generated - [ ] Implement iterator for conveniant access to API items Requirements diff --git a/setup.py b/setup.py index fc4438c..839dbb1 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ setup( name="wordpress-api", version=VERSION, - description="A Python wrapper for the Wordpress REST API", + description="A Python wrapper for the Wordpress and WooCommerce REST APIs with oAuth1a 3leg support", long_description=README, author="Claudio Sanches @ WooThemes", url="https://github.com/woocommerce/wc-api-python", From 88d06788557c359aa4b461cc4c7bc40dcc945845 Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 20 Jun 2017 17:54:28 +1000 Subject: [PATCH 025/129] moved auth stuff from api to auth for clarity, added appropriate test methods, clarify basic_auth option --- README.rst | 2 ++ tests.py | 50 ++++++++++++++++++++++++++++++++++++++++++ wordpress/api.py | 21 +++++------------- wordpress/auth.py | 32 ++++++++++++++++++++++++--- wordpress/helpers.py | 2 ++ wordpress/transport.py | 1 - 6 files changed, 89 insertions(+), 19 deletions(-) diff --git a/README.rst b/README.rst index 2fa5dac..c0961fd 100644 --- a/README.rst +++ b/README.rst @@ -158,6 +158,8 @@ Options +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ | ``verify_ssl`` | ``bool`` | no | Verify SSL when connect, use this option as ``False`` when need to test with self-signed certificates | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ +| ``basic_auth`` | ``bool`` | no | Force Basic Authentication, can be through query string or headers (default) | ++-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ | ``query_string_auth`` | ``bool`` | no | Force Basic Authentication as query string when ``True`` and using under HTTPS, default is ``False`` | +-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ diff --git a/tests.py b/tests.py index 9c49d94..9f8521b 100644 --- a/tests.py +++ b/tests.py @@ -351,8 +351,58 @@ def woo_test_mock(*args, **kwargs): self.assertEqual(response.status_code, 200) self.assertEqual(response.request.url, 'https://woo.test:8888/wp-json/wp/v2/posts') +class BasicAuthTestcases(unittest.TestCase): + def setUp(self): + self.base_url = "http://localhost:8888/wp-api/" + self.api_name = 'wc-api' + self.api_ver = 'v3' + self.endpoint = 'products/26' + self.signature_method = "HMAC-SHA1" + + self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + self.consumer_secret = "cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + self.api_params = dict( + url=self.base_url, + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret, + basic_auth=True, + api=self.api_name, + version=self.api_ver + ) + + def test_endpoint_url(self): + basic_api_params = dict(**self.api_params) + api = API( + **basic_api_params + ) + endpoint_url = api.requester.endpoint_url(self.endpoint) + endpoint_url = api.auth.get_oauth_url(endpoint_url, 'GET') + self.assertEqual( + endpoint_url, + UrlUtils.join_components([self.base_url, self.api_name, self.api_ver, self.endpoint]) + ) + + def test_query_string_endpoint_url(self): + query_string_api_params = dict(**self.api_params) + query_string_api_params.update(dict(query_string_auth=True)) + api = API( + **query_string_api_params + ) + endpoint_url = api.requester.endpoint_url(self.endpoint) + endpoint_url = api.auth.get_oauth_url(endpoint_url, 'GET') + expected_endpoint_url = '%s?consumer_key=%s&consumer_secret=%s' % (self.endpoint, self.consumer_key, self.consumer_secret) + expected_endpoint_url = UrlUtils.join_components([self.base_url, self.api_name, self.api_ver, expected_endpoint_url]) + self.assertEqual( + endpoint_url, + expected_endpoint_url + ) + endpoint_url = api.requester.endpoint_url(self.endpoint) + endpoint_url = api.auth.get_oauth_url(endpoint_url, 'GET') + + class OAuthTestcases(unittest.TestCase): + def setUp(self): self.base_url = "http://localhost:8888/wordpress/" self.api_name = 'wc-api' diff --git a/wordpress/api.py b/wordpress/api.py index dfca368..534590c 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -25,6 +25,10 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): consumer_secret=consumer_secret, ) if kwargs.get('basic_auth'): + if 'query_string_auth' in kwargs: + auth_kwargs.update(dict( + query_string_auth=kwargs.get("query_string_auth") + )) self.auth = BasicAuth(**auth_kwargs) else: auth_kwargs.update(dict( @@ -84,20 +88,8 @@ def __request(self, method, endpoint, data): """ Do requests """ endpoint_url = self.requester.endpoint_url(endpoint) - # endpoint_params = UrlUtils.get_query_dict_singular(endpoint_url) - endpoint_params = {} - auth = None - - if self.requester.is_ssl or isinstance(self.auth, BasicAuth): - if self.requester.query_string_auth: - endpoint_params.update({ - "consumer_key": self.auth.consumer_key, - "consumer_secret": self.auth.consumer_secret - }) - else: - auth = (self.auth.consumer_key, self.auth.consumer_secret) - else: - endpoint_url = self.auth.get_oauth_url(endpoint_url, method) + endpoint_url = self.auth.get_oauth_url(endpoint_url, method) + auth = self.auth.get_auth() if data is not None: data = jsonencode(data, ensure_ascii=False).encode('utf-8') @@ -106,7 +98,6 @@ def __request(self, method, endpoint, data): method=method, url=endpoint_url, auth=auth, - params=endpoint_params, data=data ) diff --git a/wordpress/auth.py b/wordpress/auth.py index a686334..887125d 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -10,9 +10,9 @@ from random import randint from hmac import new as HMAC from hashlib import sha1, sha256 -from base64 import b64encode +# from base64 import b64encode import binascii -import webbrowser +# import webbrowser import requests from bs4 import BeautifulSoup @@ -93,11 +93,37 @@ def flatten_params(cls, params): params = cls.sorted_params(params) return "&".join(["%s=%s"%(key, value) for key, value in params]) + def get_oauth_url(self, endpoint_url, method): + """ Returns the URL with added Auth params """ + return endpoint_url + + def get_auth(self): + """ Returns the auth parameter used in requests """ + pass + class BasicAuth(Auth): def __init__(self, requester, consumer_key, consumer_secret, **kwargs): super(BasicAuth, self).__init__(requester) self.consumer_key = consumer_key self.consumer_secret = consumer_secret + self.query_string_auth = kwargs.get("query_string_auth", False) + + def get_oauth_url(self, endpoint_url, method): + if self.query_string_auth: + endpoint_params = UrlUtils.get_query_dict_singular(endpoint_url) + endpoint_params.update({ + "consumer_key": self.consumer_key, + "consumer_secret": self.consumer_secret + }) + endpoint_url = UrlUtils.substitute_query( + endpoint_url, + self.flatten_params(endpoint_params) + ) + return endpoint_url + + def get_auth(self): + if not self.query_string_auth: + return (self.consumer_key, self.consumer_secret) class OAuth(Auth): @@ -164,7 +190,7 @@ def get_params(self): ] def get_oauth_url(self, endpoint_url, method): - """ Returns the URL with OAuth params """ + """ Returns the URL with added Auth params """ params = self.get_params() return self.add_params_sign(method, endpoint_url, params) diff --git a/wordpress/helpers.py b/wordpress/helpers.py index 5463475..fe33cdb 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -144,6 +144,8 @@ def is_ssl(cls, url): def join_components(cls, components): return reduce(posixpath.join, SeqUtils.filter_true(components)) + # TODO: move flatten_params, sorted_params, normalize_params out of auth into here + @staticmethod def get_value_like_as_php(val): """ Prepare value for quote """ diff --git a/wordpress/transport.py b/wordpress/transport.py index 8d83293..3898a21 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -30,7 +30,6 @@ def __init__(self, url, **kwargs): self.api_version = kwargs.get("version", __default_api_version__) 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.session = Session() @property From 9e10b57458cfbd927d3147f57e0f267e93e0f931 Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 20 Jun 2017 17:55:25 +1000 Subject: [PATCH 026/129] replace get_oauth_url with get_auth_url for clarity --- tests.py | 6 +++--- wordpress/api.py | 2 +- wordpress/auth.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests.py b/tests.py index 9f8521b..d294fa4 100644 --- a/tests.py +++ b/tests.py @@ -376,7 +376,7 @@ def test_endpoint_url(self): **basic_api_params ) endpoint_url = api.requester.endpoint_url(self.endpoint) - endpoint_url = api.auth.get_oauth_url(endpoint_url, 'GET') + endpoint_url = api.auth.get_auth_url(endpoint_url, 'GET') self.assertEqual( endpoint_url, UrlUtils.join_components([self.base_url, self.api_name, self.api_ver, self.endpoint]) @@ -389,7 +389,7 @@ def test_query_string_endpoint_url(self): **query_string_api_params ) endpoint_url = api.requester.endpoint_url(self.endpoint) - endpoint_url = api.auth.get_oauth_url(endpoint_url, 'GET') + endpoint_url = api.auth.get_auth_url(endpoint_url, 'GET') expected_endpoint_url = '%s?consumer_key=%s&consumer_secret=%s' % (self.endpoint, self.consumer_key, self.consumer_secret) expected_endpoint_url = UrlUtils.join_components([self.base_url, self.api_name, self.api_ver, expected_endpoint_url]) self.assertEqual( @@ -397,7 +397,7 @@ def test_query_string_endpoint_url(self): expected_endpoint_url ) endpoint_url = api.requester.endpoint_url(self.endpoint) - endpoint_url = api.auth.get_oauth_url(endpoint_url, 'GET') + endpoint_url = api.auth.get_auth_url(endpoint_url, 'GET') class OAuthTestcases(unittest.TestCase): diff --git a/wordpress/api.py b/wordpress/api.py index 534590c..dfe4759 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -88,7 +88,7 @@ def __request(self, method, endpoint, data): """ Do requests """ endpoint_url = self.requester.endpoint_url(endpoint) - endpoint_url = self.auth.get_oauth_url(endpoint_url, method) + endpoint_url = self.auth.get_auth_url(endpoint_url, method) auth = self.auth.get_auth() if data is not None: diff --git a/wordpress/auth.py b/wordpress/auth.py index 887125d..7ed3623 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -93,7 +93,7 @@ def flatten_params(cls, params): params = cls.sorted_params(params) return "&".join(["%s=%s"%(key, value) for key, value in params]) - def get_oauth_url(self, endpoint_url, method): + def get_auth_url(self, endpoint_url, method): """ Returns the URL with added Auth params """ return endpoint_url @@ -108,7 +108,7 @@ def __init__(self, requester, consumer_key, consumer_secret, **kwargs): self.consumer_secret = consumer_secret self.query_string_auth = kwargs.get("query_string_auth", False) - def get_oauth_url(self, endpoint_url, method): + def get_auth_url(self, endpoint_url, method): if self.query_string_auth: endpoint_params = UrlUtils.get_query_dict_singular(endpoint_url) endpoint_params.update({ @@ -189,7 +189,7 @@ def get_params(self): ("oauth_timestamp", self.generate_timestamp()), ] - def get_oauth_url(self, endpoint_url, method): + def get_auth_url(self, endpoint_url, method): """ Returns the URL with added Auth params """ params = self.get_params() @@ -305,7 +305,7 @@ def access_token(self): # key = "&".join([consumer_secret, oauth_token_secret]) # return key - def get_oauth_url(self, endpoint_url, method): + def get_auth_url(self, endpoint_url, method): """ Returns the URL with OAuth params """ assert self.access_token, "need a valid access token for this step" From 1b7e03d47c4fc9af68f48f6144b5c068e6b5ea68 Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 20 Jun 2017 18:04:13 +1000 Subject: [PATCH 027/129] move flatten_params, sorted_params, normalize_params out of auth into helpers --- tests.py | 4 ++-- wordpress/auth.py | 55 ++++---------------------------------------- wordpress/helpers.py | 49 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 52 insertions(+), 56 deletions(-) diff --git a/tests.py b/tests.py index d294fa4..4a41026 100644 --- a/tests.py +++ b/tests.py @@ -171,7 +171,7 @@ def check_sorted(keys, expected): for key in keys: params[key] = '' - params = auth.OAuth.sorted_params(params) + params = UrlUtils.sorted_params(params) ordered = [key for key, value in params] self.assertEqual(ordered, expected) @@ -584,7 +584,7 @@ def test_get_sign_key(self): ) def test_flatten_params(self): - flattened_params = OAuth.flatten_params(self.twitter_params_raw) + flattened_params = UrlUtils.flatten_params(self.twitter_params_raw) expected_flattened_params = self.twitter_param_string self.assertEqual(flattened_params, expected_flattened_params) diff --git a/wordpress/auth.py b/wordpress/auth.py index 7ed3623..20d187c 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -46,53 +46,6 @@ def api_version(self): def api_namespace(self): return self.requester.api - @classmethod - def normalize_params(cls, params): - """ Normalize parameters. works with RFC 5849 logic. params is a list of key, value pairs """ - if isinstance(params, dict): - params = params.items() - params = \ - [(cls.normalize_str(key), cls.normalize_str(UrlUtils.get_value_like_as_php(value))) \ - for key, value in params] - - # print "NORMALIZED: %s\n" % str(params.keys()) - # resposne = urlencode(params) - response = params - # print "RESPONSE: %s\n" % str(resposne.split('&')) - return response - - @classmethod - def sorted_params(cls, params): - """ Sort parameters. works with RFC 5849 logic. params is a list of key, value pairs """ - - if isinstance(params, dict): - params = params.items() - - # return sorted(params) - ordered = [] - base_keys = sorted(set(k.split('[')[0] for k, v in params)) - keys_seen = [] - for base in base_keys: - for key, value in params: - if key == base or key.startswith(base + '['): - if key not in keys_seen: - ordered.append((key, value)) - keys_seen.append(key) - - return ordered - - @classmethod - def normalize_str(cls, string): - return quote(string, '') - - @classmethod - def flatten_params(cls, params): - if isinstance(params, dict): - params = params.items() - params = cls.normalize_params(params) - params = cls.sorted_params(params) - return "&".join(["%s=%s"%(key, value) for key, value in params]) - def get_auth_url(self, endpoint_url, method): """ Returns the URL with added Auth params """ return endpoint_url @@ -117,7 +70,7 @@ def get_auth_url(self, endpoint_url, method): }) endpoint_url = UrlUtils.substitute_query( endpoint_url, - self.flatten_params(endpoint_params) + UrlUtils.flatten_params(endpoint_params) ) return endpoint_url @@ -167,7 +120,7 @@ def add_params_sign(self, method, url, params, sign_key=None): # for key, value in parse_qsl(urlparse_result.query): # params += [(key, value)] - params = self.sorted_params(params) + params = UrlUtils.sorted_params(params) params_without_signature = [] for key, value in params: @@ -177,7 +130,7 @@ def add_params_sign(self, method, url, params, sign_key=None): signature = self.generate_oauth_signature(method, params_without_signature, url, sign_key) params = params_without_signature + [("oauth_signature", signature)] - query_string = self.flatten_params(params) + query_string = UrlUtils.flatten_params(params) return UrlUtils.substitute_query(url, query_string) @@ -198,7 +151,7 @@ def get_auth_url(self, endpoint_url, method): @classmethod def get_signature_base_string(cls, method, params, url): base_request_uri = quote(UrlUtils.substitute_query(url), "") - query_string = quote( cls.flatten_params(params), '~') + query_string = quote( UrlUtils.flatten_params(params), '~') return "&".join([method, base_request_uri, query_string]) def generate_oauth_signature(self, method, params, url, key=None): diff --git a/wordpress/helpers.py b/wordpress/helpers.py index fe33cdb..f38dae9 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -36,11 +36,11 @@ def remove_head(cls, string, head): string = string[len(head):] return string - @classmethod def decapitate(cls, *args, **kwargs): return cls.remove_head(*args, **kwargs) + class SeqUtils(object): @classmethod def filter_true(cls, seq): @@ -144,8 +144,6 @@ def is_ssl(cls, url): def join_components(cls, components): return reduce(posixpath.join, SeqUtils.filter_true(components)) - # TODO: move flatten_params, sorted_params, normalize_params out of auth into here - @staticmethod def get_value_like_as_php(val): """ Prepare value for quote """ @@ -184,3 +182,48 @@ def remove_port(cls, url): query=urlparse_result.query, fragment=urlparse_result.fragment )) + + @classmethod + def normalize_str(cls, string): + """ Normalize string for the purposes of url query parameters. """ + return quote(string, '') + + @classmethod + def normalize_params(cls, params): + """ Normalize parameters. works with RFC 5849 logic. params is a list of key, value pairs """ + if isinstance(params, dict): + params = params.items() + params = \ + [(cls.normalize_str(key), cls.normalize_str(UrlUtils.get_value_like_as_php(value))) \ + for key, value in params] + + response = params + return response + + @classmethod + def sorted_params(cls, params): + """ Sort parameters. works with RFC 5849 logic. params is a list of key, value pairs """ + + if isinstance(params, dict): + params = params.items() + + # return sorted(params) + ordered = [] + base_keys = sorted(set(k.split('[')[0] for k, v in params)) + keys_seen = [] + for base in base_keys: + for key, value in params: + if key == base or key.startswith(base + '['): + if key not in keys_seen: + ordered.append((key, value)) + keys_seen.append(key) + + return ordered + + @classmethod + def flatten_params(cls, params): + if isinstance(params, dict): + params = params.items() + params = cls.normalize_params(params) + params = cls.sorted_params(params) + return "&".join(["%s=%s"%(key, value) for key, value in params]) From 1e89001fbfc094016201c1c7d33e75a92c7c1c8c Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 20 Jun 2017 20:16:36 +1000 Subject: [PATCH 028/129] nearly implemented creds cache --- README.rst | 2 +- tests.py | 151 ++++++++++++++++++++++++++++++++++++++-------- wordpress/api.py | 20 ++---- wordpress/auth.py | 113 +++++++++++++++++----------------- 4 files changed, 188 insertions(+), 98 deletions(-) diff --git a/README.rst b/README.rst index c0961fd..e1f2c5f 100644 --- a/README.rst +++ b/README.rst @@ -28,7 +28,7 @@ Wordpress version 4.7+ comes pre-installed with REST API v2, so you don't need t You should have the following plugins installed on your wordpress site: - **WP REST API** (only required for WP < v4.7, recommended version: 2.0+) -- **WP REST API - OAuth 1.0a Server** (optional, if you want oauth. https://github.com/WP-API/OAuth1) +- **WP REST API - OAuth 1.0a Server** (optional, if you want oauth within the wordpress API. https://github.com/WP-API/OAuth1) - **WP REST API - Meta Endpoints** (optional) - **WooCommerce** (optional, if you want to use the WooCommerce API) diff --git a/tests.py b/tests.py index 4a41026..93f5800 100644 --- a/tests.py +++ b/tests.py @@ -4,9 +4,10 @@ import pdb import functools import traceback -from httmock import all_requests, HTTMock, urlmatch from collections import OrderedDict +from tempfile import mkstemp +from httmock import all_requests, HTTMock, urlmatch import wordpress from wordpress import auth from wordpress import __default_api_version__, __default_api__ @@ -746,15 +747,66 @@ def test_get_request_token(self): self.assertTrue(authentication) with HTTMock(self.woo_authentication_mock): - access_token, access_token_secret = self.api.auth.get_request_token() - self.assertEquals(access_token, 'XXXXXXXXXXXX') - self.assertEquals(access_token_secret, 'YYYYYYYYYYYY') + request_token, request_token_secret = self.api.auth.get_request_token() + self.assertEquals(request_token, 'XXXXXXXXXXXX') + self.assertEquals(request_token_secret, 'YYYYYYYYYYYY') + + def test_store_access_creds(self): + _, creds_store_path = mkstemp("wp-api-python-test-store-access-creds.json") + api = API( + url="http://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret, + oauth1a_3leg=True, + wp_user='test_user', + wp_pass='test_pass', + callback='http://127.0.0.1/oauth1_callback', + access_token='XXXXXXXXXXXX', + access_token_secret='YYYYYYYYYYYY', + creds_store=creds_store_path + ) + api.auth.store_access_creds() + + with open(creds_store_path) as creds_store_file: + self.assertEqual( + creds_store_file.read(), + '{"access_token": "XXXXXXXXXXXX", "access_token_secret": "YYYYYYYYYYYY"}' + ) + + def test_retrieve_access_creds(self): + _, creds_store_path = mkstemp("wp-api-python-test-store-access-creds.json") + with open(creds_store_path, 'w+') as creds_store_file: + creds_store_file.write('{"access_token": "XXXXXXXXXXXX", "access_token_secret": "YYYYYYYYYYYY"}') + + api = API( + url="http://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret, + oauth1a_3leg=True, + wp_user='test_user', + wp_pass='test_pass', + callback='http://127.0.0.1/oauth1_callback', + creds_store=creds_store_path + ) + + api.auth.retrieve_access_creds() + + self.assertEqual( + api.auth.access_token, + 'XXXXXXXXXXXX' + ) + + self.assertEqual( + api.auth.access_token_secret, + 'YYYYYYYYYYYY' + ) # @unittest.skipIf(platform.uname()[1] != "Ich.lan", "should only work on my machine") @unittest.skip("Should only work on my machine") class WCApiTestCases(unittest.TestCase): + """ Tests for WC API V3 """ def setUp(self): - self.apiParams = { + self.api_params = { 'url':'http://ich.local:8888/woocommerce/', 'api':'wc-api', 'version':'v3', @@ -762,9 +814,8 @@ def setUp(self): 'consumer_secret':'cs_68ef2cf6a708e1c6b30bfb2a38dc948b16bf46c0', } - @debug_on() def test_APIGet(self): - wcapi = API(**self.apiParams) + wcapi = API(**self.api_params) response = wcapi.get('products') # print UrlUtils.beautify_response(response) self.assertIn(response.status_code, [200,201]) @@ -774,9 +825,8 @@ def test_APIGet(self): self.assertEqual(len(response_obj['products']), 10) # print "test_APIGet", response_obj - @debug_on() def test_APIGetWithSimpleQuery(self): - wcapi = API(**self.apiParams) + wcapi = API(**self.api_params) response = wcapi.get('products?page=2') # print UrlUtils.beautify_response(response) self.assertIn(response.status_code, [200,201]) @@ -786,9 +836,8 @@ def test_APIGetWithSimpleQuery(self): self.assertEqual(len(response_obj['products']), 10) # print "test_ApiGenWithSimpleQuery", response_obj - @debug_on() def test_APIGetWithComplexQuery(self): - wcapi = API(**self.apiParams) + wcapi = API(**self.api_params) response = wcapi.get('products?page=2&filter%5Blimit%5D=2') self.assertIn(response.status_code, [200,201]) response_obj = response.json() @@ -802,50 +851,100 @@ def test_APIGetWithComplexQuery(self): self.assertEqual(len(response_obj['products']), 3) def test_APIPutWithSimpleQuery(self): - wcapi = API(**self.apiParams) + wcapi = API(**self.api_params) + response = wcapi.get('products') + first_product = (response.json())['products'][0] + original_title = first_product['title'] + product_id = first_product['id'] + nonce = str(random.random()) - response = wcapi.put('products/633?filter%5Blimit%5D=5', {"product":{"title":str(nonce)}}) + response = wcapi.put('products/%s?filter%%5Blimit%%5D=5' % (product_id), {"product":{"title":str(nonce)}}) request_params = UrlUtils.get_query_dict_singular(response.request.url) - # print "\ntest_APIPutWithSimpleQuery" - # print "request url", response.request.url - # print "response", UrlUtils.beautify_response(response) response_obj = response.json() - # print "response obj", response_obj self.assertEqual(response_obj['product']['title'], str(nonce)) self.assertEqual(request_params['filter[limit]'], str(5)) + wcapi.put('products/%s' % (product_id), {"product":{"title":original_title}}) + +@unittest.skip("Should only work on my machine") +class WCApiTestCasesNew(unittest.TestCase): + """ Tests for New WC API """ + def setUp(self): + self.api_params = { + 'url':'http://ich.local:8888/woocommerce/', + 'api':'wp-json', + 'version':'wc/v2', + 'consumer_key':'ck_0297450a41484f27184d1a8a3275f9bab5b69143', + 'consumer_secret':'cs_68ef2cf6a708e1c6b30bfb2a38dc948b16bf46c0', + 'callback':'http://127.0.0.1/oauth1_callback', + } + + def test_APIGet(self): + wcapi = API(**self.api_params) + per_page = 10 + response = wcapi.get('products?per_page=%d' % per_page) + self.assertIn(response.status_code, [200,201]) + response_obj = response.json() + self.assertEqual(len(response_obj), per_page) + + + def test_APIPutWithSimpleQuery(self): + wcapi = API(**self.api_params) + response = wcapi.get('products') + first_product = (response.json())[0] + # from pprint import pformat + # print "first product %s" % pformat(response.json()) + original_title = first_product['name'] + product_id = first_product['id'] + + nonce = str(random.random()) + response = wcapi.put('products/%s?filter%%5Blimit%%5D=5' % (product_id), {"name":str(nonce)}) + request_params = UrlUtils.get_query_dict_singular(response.request.url) + response_obj = response.json() + self.assertEqual(response_obj['name'], str(nonce)) + self.assertEqual(request_params['filter[limit]'], str(5)) + + wcapi.put('products/%s' % (product_id), {"name":original_title}) + + + # def test_APIPut(self): + + # @unittest.skipIf(platform.uname()[1] != "Ich.lan", "should only work on my machine") @unittest.skip("Should only work on my machine") class WPAPITestCases(unittest.TestCase): def setUp(self): - self.apiParams = { + self.creds_store = '~/wc-api-creds.json' + self.api_params = { 'url':'http://ich.local:8888/woocommerce/', 'api':'wp-json', - 'version':'wp/v2', - 'consumer_key':'kGUDYhYPNTTq', - 'consumer_secret':'44fhpRsd0yo5deHaUSTZUtHgamrKwARzV8JUgTbGu61qrI0i', + 'version':'wp/v1', + 'consumer_key':'ox0p2NZSOja8', + 'consumer_secret':'6Ye77tGlYgxjCexn1m7zGs0GLYmmoGXeHM82jgmw3kqffNLe', 'callback':'http://127.0.0.1/oauth1_callback', 'wp_user':'woocommerce', 'wp_pass':'woocommerce', - 'oauth1a_3leg':True + 'oauth1a_3leg':True, + 'creds_store': self.creds_store } @debug_on() def test_APIGet(self): - wpapi = API(**self.apiParams) + wpapi = API(**self.api_params) + wpapi.auth.clear_stored_creds() response = wpapi.get('users') self.assertIn(response.status_code, [200,201]) response_obj = response.json() self.assertEqual(response_obj[0]['name'], 'woocommerce') def test_APIGetWithSimpleQuery(self): - wpapi = API(**self.apiParams) - response = wpapi.get('media?page=2') + wpapi = API(**self.api_params) + response = wpapi.get('media?page=2&per_page=2') # print UrlUtils.beautify_response(response) self.assertIn(response.status_code, [200,201]) response_obj = response.json() - self.assertEqual(len(response_obj), 10) + self.assertEqual(len(response_obj), 2) # print "test_ApiGenWithSimpleQuery", response_obj diff --git a/wordpress/api.py b/wordpress/api.py index dfe4759..45793a1 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -24,22 +24,14 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): consumer_key=consumer_key, consumer_secret=consumer_secret, ) + auth_kwargs.update(kwargs) + if kwargs.get('basic_auth'): - if 'query_string_auth' in kwargs: - auth_kwargs.update(dict( - query_string_auth=kwargs.get("query_string_auth") - )) self.auth = BasicAuth(**auth_kwargs) else: - auth_kwargs.update(dict( - force_nonce=kwargs.get('force_nonce'), - force_timestamp=kwargs.get('force_timestamp') - )) if kwargs.get('oauth1a_3leg'): - self.oauth1a_3leg = kwargs['oauth1a_3leg'] - auth_kwargs['callback'] = kwargs['callback'] - auth_kwargs['wp_user'] = kwargs['wp_user'] - auth_kwargs['wp_pass'] = kwargs['wp_pass'] + if 'callback' not in auth_kwargs: + raise TypeError("callback url not specified") self.auth = OAuth_3Leg( **auth_kwargs ) else: self.auth = OAuth( **auth_kwargs ) @@ -52,10 +44,6 @@ def url(self): def timeout(self): return self.requester.timeout - @property - def query_string_auth(self): - return self.requester.query_string_auth - @property def namespace(self): return self.requester.api diff --git a/wordpress/auth.py b/wordpress/auth.py index 20d187c..e14e422 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -6,10 +6,12 @@ __title__ = "wordpress-auth" +import os from time import time from random import randint from hmac import new as HMAC from hashlib import sha1, sha256 +import json # from base64 import b64encode import binascii # import webbrowser @@ -205,12 +207,13 @@ def __init__(self, requester, consumer_key, consumer_secret, callback, **kwargs) self.callback = callback self.wp_user = kwargs.get('wp_user') self.wp_pass = kwargs.get('wp_pass') + self._creds_store = kwargs.get('creds_store') self._authentication = None - self._request_token = None + self._request_token = kwargs.get('request_token') self.request_token_secret = None self._oauth_verifier = None - self._access_token = None - self.access_token_secret = None + self._access_token = kwargs.get('access_token') + self.access_token_secret = kwargs.get('access_token_secret') @property def authentication(self): @@ -240,23 +243,16 @@ def request_token(self): def access_token(self): """ This is the oauth_token used to sign requests to protected resources automatically generated if accessed before generated """ + if not self._access_token and self.creds_store: + self.retrieve_access_creds() if not self._access_token: self.get_access_token() return self._access_token - # def get_sign_key(self, consumer_secret, oauth_token_secret=None): - # "gets consumer_secret and oauth_token_secret and turns it into a string suitable for signing" - # if not oauth_token_secret: - # key = super(OAuth_3Leg, self).get_sign_key(consumer_secret) - # else: - # oauth_token_secret = str(oauth_token_secret) if oauth_token_secret else '' - # consumer_secret = str(consumer_secret) if consumer_secret else '' - # # oauth_token_secret has been specified - # if not consumer_secret: - # key = str(oauth_token_secret) - # else: - # key = "&".join([consumer_secret, oauth_token_secret]) - # return key + @property + def creds_store(self): + if self._creds_store: + return os.path.expanduser(self._creds_store) def get_auth_url(self, endpoint_url, method): """ Returns the URL with OAuth params """ @@ -272,25 +268,6 @@ def get_auth_url(self, endpoint_url, method): return self.add_params_sign(method, endpoint_url, params, sign_key) - # params = OrderedDict() - # params["oauth_consumer_key"] = self.consumer_key - # params["oauth_timestamp"] = self.generate_timestamp() - # params["oauth_nonce"] = self.generate_nonce() - # params["oauth_signature_method"] = self.signature_method - # params["oauth_token"] = self.access_token - # - # sign_key = self.get_sign_key(self.consumer_secret, self.access_token_secret) - # - # print "signing with key: %s" % sign_key - # - # return self.add_params_sign(method, endpoint_url, params, sign_key) - - # def get_params(self, get_access_token=False): - # params = super(OAuth_3Leg, self).get_params() - # if get_access_token: - # params.append(('oauth_token', self.access_token)) - # return params - def discover_auth(self): """ Discovers the location of authentication resourcers from the API""" discovery_url = self.requester.api_url @@ -315,13 +292,6 @@ def get_request_token(self): params += [ ('oauth_callback', self.callback) ] - # params = OrderedDict() - # params["oauth_consumer_key"] = self.consumer_key - # params["oauth_timestamp"] = self.generate_timestamp() - # params["oauth_nonce"] = self.generate_nonce() - # params["oauth_signature_method"] = self.signature_method - # params["oauth_callback"] = self.callback - # params["oauth_version"] = self.oauth_version request_token_url = self.authentication['oauth1']['request'] request_token_url = self.add_params_sign("GET", request_token_url, params) @@ -479,6 +449,50 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): self._oauth_verifier = final_location_queries['oauth_verifier'][0] return self._oauth_verifier + def store_access_creds(self): + """ store the access_token and access_token_secret locally. """ + + if not self.creds_store: + return + + creds = OrderedDict() + if self._access_token: + creds['access_token'] = self._access_token + if self.access_token_secret: + creds['access_token_secret'] = self.access_token_secret + if creds: + with open(self.creds_store, 'w+') as creds_store_file: + json.dump(creds, creds_store_file, ensure_ascii=False, encoding='utf-8') + + def retrieve_access_creds(self): + """ retrieve the access_token and access_token_secret stored locally. """ + + if not self.creds_store: + return + + creds = {} + if os.path.isfile(self.creds_store): + with open(self.creds_store, 'r') as creds_store_file: + try: + creds = json.load(creds_store_file, encoding='utf-8') + except ValueError: + pass + + if 'access_token' in creds: + self._access_token = creds['access_token'] + if 'access_token_secret' in creds: + self.access_token_secret = creds['access_token_secret'] + + def clear_stored_creds(self): + """ Clear the file containing stored creds. """ + + if not self.creds_store: + return + + with open(self.creds_store, 'w+') as creds_store_file: + creds_store_file.write('') + + def get_access_token(self, oauth_verifier=None): """ Uses the access authentication link to get an access token """ @@ -493,20 +507,7 @@ def get_access_token(self, oauth_verifier=None): ('oauth_verifier', self.oauth_verifier) ] - # params = OrderedDict() - # params["oauth_consumer_key"] = self.consumer_key - # params['oauth_token'] = self.request_token - # params["oauth_timestamp"] = self.generate_timestamp() - # params["oauth_nonce"] = self.generate_nonce() - # params["oauth_signature_method"] = self.signature_method - # params['oauth_verifier'] = oauth_verifier - # params["oauth_callback"] = self.callback - sign_key = self.get_sign_key(self.consumer_secret, self.request_token_secret) - # sign_key = self.get_sign_key(None, self.request_token_secret) - # print "request_token_secret:", self.request_token_secret - - # print "SIGNING WITH KEY:", repr(sign_key) access_token_url = self.authentication['oauth1']['access'] access_token_url = self.add_params_sign("POST", access_token_url, params, sign_key) @@ -530,4 +531,6 @@ def get_access_token(self, oauth_verifier=None): raise UserWarning("Could not parse access_token or access_token_secret in response from %s : %s" \ % (repr(access_response.request.url), UrlUtils.beautify_response(access_response))) + self.store_access_creds() + return self._access_token, self.access_token_secret From 43182b4bd9b3b17b2b0a5cd0c29289d06302ad9c Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 14 Aug 2017 17:15:52 +1000 Subject: [PATCH 029/129] fixed auth error handling --- wordpress/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress/auth.py b/wordpress/auth.py index e14e422..4418ad7 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -407,7 +407,7 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): confirmation_soup = BeautifulSoup(confirmation_response.text, 'lxml') error = confirmation_soup.select_one('div#login_error') # print "ERROR: %s" % repr(error) - if error and "invalid token" in error.string.lower(): + if error and error.string and "invalid token" in error.string.lower(): raise UserWarning("Invalid token: %s" % repr(request_token)) else: raise UserWarning( From 909e8efba840dfffa929b1dea158f948b8460bec Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 14 Aug 2017 19:24:58 +1000 Subject: [PATCH 030/129] better error handling, post mortem --- setup.py | 3 ++- wordpress/api.py | 55 ++++++++++++++++++++++++++++++------- wordpress/auth.py | 61 +++++++++++++++++++++++------------------- wordpress/helpers.py | 4 +++ wordpress/transport.py | 9 ++++--- 5 files changed, 91 insertions(+), 41 deletions(-) diff --git a/setup.py b/setup.py index 28218bb..236d017 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,8 @@ install_requires=[ "requests", "ordereddict", - "beautifulsoup4" + "beautifulsoup4", + 'lxml' ], classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/wordpress/api.py b/wordpress/api.py index 671512f..49dca84 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -10,7 +10,7 @@ from json import dumps as jsonencode from wordpress.auth import OAuth, OAuth_3Leg, BasicAuth from wordpress.transport import API_Requests_Wrapper -from wordpress.helpers import UrlUtils +from wordpress.helpers import UrlUtils, StrUtils class API(object): """ API Class """ @@ -72,6 +72,49 @@ def consumer_secret(self): def callback(self): return self.auth.callback + def request_post_mortem(self, response=None): + """ + Attempt to diagnose what went wrong in a request + """ + + reason = None + remedy = None + + request_url = "" + if hasattr(response, 'request') and hasattr(response.request, 'url'): + request_url = response.request.url + + headers = {} + if hasattr(response, 'headers'): + headers = response.headers + + requester_api_url = self.requester.api_url + if hasattr(response, 'links'): + links = response.links + if 'https://api.w.org/' in links: + header_api_url = links['https://api.w.org/'].get('url', '') + + if header_api_url and requester_api_url\ + and header_api_url != requester_api_url: + reason = "hostname mismatch. %s != %s" % ( + header_api_url, requester_api_url + ) + header_url = StrUtils.eviscerate(header_api_url, '/') + header_url = StrUtils.eviscerate(header_url, self.requester.api) + remedy = "try changing url to %s" % header_url + + msg = "API call to %s returned \nCODE: %s\n%s \nHEADERS: %s" % ( + request_url, + str(response.status_code), + UrlUtils.beautify_response(response), + str(headers) + ) + if reason: + msg += "\nMost likely because of %s" % reason + if remedy: + msg += "\n%s" % remedy + raise UserWarning(msg) + def __request(self, method, endpoint, data): """ Do requests """ @@ -89,14 +132,8 @@ def __request(self, method, endpoint, data): data=data ) - assert \ - response.status_code in [200, 201], \ - "API call to %s returned \nCODE: %s\n%s \nHEADERS: %s" % ( - response.request.url, - str(response.status_code), - UrlUtils.beautify_response(response), - str(response.headers) - ) + if response.status_code not in [200, 201]: + self.request_post_mortem(response) return response diff --git a/wordpress/auth.py b/wordpress/auth.py index 4418ad7..358cfad 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -308,6 +308,26 @@ def get_request_token(self): return self._request_token, self.request_token_secret + def parse_login_form_error(self, response, exc, **kwargs): + """ + If unable to parse login form, try to determine which error is present + """ + login_form_soup = BeautifulSoup(response.text, 'lxml') + if response.status_code != 200: + raise UserWarning("Response was not a 200, it was a %s. original error: %s" \ + % (str(response.status_code)), str(exc)) + error = login_form_soup.select_one('div#login_error') + if error and error.stripped_strings: + for stripped_string in error.stripped_strings: + if "invalid token" in stripped_string.lower(): + raise UserWarning("Invalid token: %s" % repr(kwargs.get('token'))) + elif "invalid username" in stripped_string.lower(): + raise UserWarning("Invalid username: %s" % repr(kwargs.get('username'))) + elif "the password you entered" in stripped_string.lower(): + raise UserWarning("Invalid password: %s" % repr(kwargs.get('password'))) + raise UserWarning("could not parse login form error. %s " % str(error)) + raise UserWarning("unknown error: %s" % str(exc)) + def get_form_info(self, response, form_id): """ parses a form specified by a given form_id in the response, extracts form data and form action """ @@ -367,19 +387,17 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): authorize_session = requests.Session() login_form_response = authorize_session.get(authorize_url) + login_form_params = { + 'username':wp_user, + 'password':wp_pass, + 'token':request_token + } try: login_form_action, login_form_data = self.get_form_info(login_form_response, 'loginform') - except AssertionError, e: - #try to parse error - login_form_soup = BeautifulSoup(login_form_response.text, 'lxml') - error = login_form_soup.select_one('div#login_error') - if error and "invalid token" in error.string.lower(): - raise UserWarning("Invalid token: %s" % repr(request_token)) - else: - raise UserWarning( - "could not parse login form. Site is misbehaving. Original error: %s " \ - % str(e) - ) + except AssertionError, exc: + self.parse_login_form_error( + login_form_response, exc, **login_form_params + ) for name, values in login_form_data.items(): if name == 'log': @@ -397,23 +415,10 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): confirmation_response = authorize_session.post(login_form_action, data=login_form_data, allow_redirects=True) try: authorize_form_action, authorize_form_data = self.get_form_info(confirmation_response, 'oauth1_authorize_form') - except AssertionError, e: - #try to parse error - # print "STATUS_CODE: %s" % str(confirmation_response.status_code) - if confirmation_response.status_code != 200: - raise UserWarning("Response was not a 200, it was a %s. original error: %s" \ - % (str(confirmation_response.status_code)), str(e)) - # print "HEADERS: %s" % str(confirmation_response.headers) - confirmation_soup = BeautifulSoup(confirmation_response.text, 'lxml') - error = confirmation_soup.select_one('div#login_error') - # print "ERROR: %s" % repr(error) - if error and error.string and "invalid token" in error.string.lower(): - raise UserWarning("Invalid token: %s" % repr(request_token)) - else: - raise UserWarning( - "could not parse login form. Site is misbehaving. Original error: %s " \ - % str(e) - ) + except AssertionError, exc: + self.parse_login_form_error( + confirmation_response, exc, **login_form_params + ) for name, values in authorize_form_data.items(): if name == 'wp-submit': diff --git a/wordpress/helpers.py b/wordpress/helpers.py index f38dae9..a5ff689 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -40,6 +40,10 @@ def remove_head(cls, string, head): def decapitate(cls, *args, **kwargs): return cls.remove_head(*args, **kwargs) + @classmethod + def eviscerate(cls, *args, **kwargs): + return cls.remove_tail(*args, **kwargs) + class SeqUtils(object): @classmethod diff --git a/wordpress/transport.py b/wordpress/transport.py index 3898a21..4a8357c 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -82,9 +82,12 @@ def request(self, method, url, auth=None, params=None, data=None, **kwargs): timeout=self.timeout, ) request_kwargs.update(kwargs) - if auth is not None: request_kwargs['auth'] = auth - if params is not None: request_kwargs['params'] = params - if data is not None: request_kwargs['data'] = data + if auth is not None: + request_kwargs['auth'] = auth + if params is not None: + request_kwargs['params'] = params + if data is not None: + request_kwargs['data'] = data return self.session.request( **request_kwargs ) From b110e3bf59e1d91a68bdd202275c5634a801a86d Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 14 Aug 2017 20:09:37 +1000 Subject: [PATCH 031/129] better postmortem --- wordpress/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wordpress/api.py b/wordpress/api.py index 49dca84..9bfd15c 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -89,10 +89,10 @@ def request_post_mortem(self, response=None): headers = response.headers requester_api_url = self.requester.api_url - if hasattr(response, 'links'): + if hasattr(response, 'links') and response.links: links = response.links - if 'https://api.w.org/' in links: - header_api_url = links['https://api.w.org/'].get('url', '') + first_link_key = list(links)[0] + header_api_url = links[first_link_key].get('url', '') if header_api_url and requester_api_url\ and header_api_url != requester_api_url: From 8e7cbb3054e3feb254cbc8cb975e789cf395b437 Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 15 Aug 2017 12:58:33 +1000 Subject: [PATCH 032/129] better error handling --- setup.py | 9 ++++++--- wordpress/auth.py | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/setup.py b/setup.py index 236d017..b548a79 100644 --- a/setup.py +++ b/setup.py @@ -23,10 +23,13 @@ setup( name="wordpress-api", version=VERSION, - description="A Python wrapper for the Wordpress and WooCommerce REST APIs with oAuth1a 3leg support", + description=( + "A Python wrapper for the Wordpress and WooCommerce REST APIs " + "with oAuth1a 3leg support" + ), long_description=README, - author="Claudio Sanches @ Automattic , forked by Derwent @ Laserphile", - url="https://github.com/woocommerce/wc-api-python", + author="Claudio Sanches @ Automattic, forked by Derwent @ Laserphile", + url="https://github.com/derwentx/wp-api-python", license="MIT License", packages=[ "wordpress" diff --git a/wordpress/auth.py b/wordpress/auth.py index 358cfad..0c0fe1e 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -275,17 +275,25 @@ def discover_auth(self): response = self.requester.request('GET', discovery_url) response_json = response.json() - assert \ - response_json['authentication'], \ - "resopnse should include location of authentication resources, resopnse: %s" \ - % UrlUtils.beautify_response(response) + if not 'authentication' in response_json: + raise UserWarning( + ( + "Resopnse does not include location of authentication resources.\n" + "Resopnse: %s\n" + "Please check you have configured the Wordpress OAuth1 plugin correctly." + ) % (response) + ) self._authentication = response_json['authentication'] return self._authentication def get_request_token(self): - """ Uses the request authentication link to get an oauth_token for requesting an access token """ + """ + Uses the request authentication link to get an oauth_token for + requesting an access token + """ + assert self.consumer_key, "need a valid consumer_key for this step" params = self.get_params() From 34c1d4c0732235099468ad84d0f44e7436dbd551 Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 15 Aug 2017 14:15:05 +1000 Subject: [PATCH 033/129] changed import order --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b548a79..a31ea26 100644 --- a/setup.py +++ b/setup.py @@ -2,9 +2,9 @@ # -*- coding: utf-8 -*- """ Setup module """ -from setuptools import setup import os import re +from setuptools import setup # Get version from __init__.py file VERSION = "" From 6c3d3262dc04b75cdc02186ebeac1f7632ecc0c8 Mon Sep 17 00:00:00 2001 From: derwentx Date: Wed, 16 Aug 2017 19:50:45 +1000 Subject: [PATCH 034/129] look at these beautiful exceptions --- wordpress/api.py | 71 +++++++++++++++++++++++++++++++---------------- wordpress/auth.py | 43 ++++++++++++++++++---------- 2 files changed, 75 insertions(+), 39 deletions(-) diff --git a/wordpress/api.py b/wordpress/api.py index 9bfd15c..42176eb 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -6,7 +6,7 @@ __title__ = "wordpress-api" -from requests import request +# from requests import request from json import dumps as jsonencode from wordpress.auth import OAuth, OAuth_3Leg, BasicAuth from wordpress.transport import API_Requests_Wrapper @@ -80,37 +80,60 @@ def request_post_mortem(self, response=None): reason = None remedy = None + response_json = {} + try: + response_json = response.json() + except ValueError: + pass + + import pudb; pudb.set_trace() + + if 'code' in response_json or 'message' in response_json: + reason = " - ".join([ + response_json.get(key) for key in ['code', 'message'] \ + if key in response_json + ]) + + request_body = {} request_url = "" - if hasattr(response, 'request') and hasattr(response.request, 'url'): - request_url = response.request.url + if hasattr(response, 'request'): + if hasattr(response.request, 'url'): + request_url = response.request.url + if hasattr(response.request, 'body'): + request_body = response.request.body - headers = {} + response_headers = {} if hasattr(response, 'headers'): - headers = response.headers - - requester_api_url = self.requester.api_url - if hasattr(response, 'links') and response.links: - links = response.links - first_link_key = list(links)[0] - header_api_url = links[first_link_key].get('url', '') - - if header_api_url and requester_api_url\ - and header_api_url != requester_api_url: - reason = "hostname mismatch. %s != %s" % ( - header_api_url, requester_api_url - ) - header_url = StrUtils.eviscerate(header_api_url, '/') - header_url = StrUtils.eviscerate(header_url, self.requester.api) - remedy = "try changing url to %s" % header_url - - msg = "API call to %s returned \nCODE: %s\n%s \nHEADERS: %s" % ( + response_headers = response.headers + + if not reason: + requester_api_url = self.requester.api_url + if hasattr(response, 'links') and response.links: + links = response.links + first_link_key = list(links)[0] + header_api_url = links[first_link_key].get('url', '') + if header_api_url: + header_api_url = StrUtils.eviscerate(header_api_url, '/') + + if header_api_url and requester_api_url\ + and header_api_url != requester_api_url: + reason = "hostname mismatch. %s != %s" % ( + header_api_url, requester_api_url + ) + header_url = StrUtils.eviscerate(header_api_url, '/') + header_url = StrUtils.eviscerate(header_url, self.requester.api) + header_url = StrUtils.eviscerate(header_url, '/') + remedy = "try changing url to %s" % header_url + + msg = "API call to %s returned \nCODE: %s\nRESPONSE:%s \nHEADERS: %s\nREQ_BODY:%s" % ( request_url, str(response.status_code), UrlUtils.beautify_response(response), - str(headers) + str(response_headers), + str(request_body) ) if reason: - msg += "\nMost likely because of %s" % reason + msg += "\nBecause of %s" % reason if remedy: msg += "\n%s" % remedy raise UserWarning(msg) diff --git a/wordpress/auth.py b/wordpress/auth.py index 0c0fe1e..bc5a147 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -321,26 +321,39 @@ def parse_login_form_error(self, response, exc, **kwargs): If unable to parse login form, try to determine which error is present """ login_form_soup = BeautifulSoup(response.text, 'lxml') - if response.status_code != 200: - raise UserWarning("Response was not a 200, it was a %s. original error: %s" \ - % (str(response.status_code)), str(exc)) - error = login_form_soup.select_one('div#login_error') - if error and error.stripped_strings: - for stripped_string in error.stripped_strings: - if "invalid token" in stripped_string.lower(): - raise UserWarning("Invalid token: %s" % repr(kwargs.get('token'))) - elif "invalid username" in stripped_string.lower(): - raise UserWarning("Invalid username: %s" % repr(kwargs.get('username'))) - elif "the password you entered" in stripped_string.lower(): - raise UserWarning("Invalid password: %s" % repr(kwargs.get('password'))) - raise UserWarning("could not parse login form error. %s " % str(error)) - raise UserWarning("unknown error: %s" % str(exc)) + if response.status_code == 500: + error = login_form_soup.select_one('body#error-page') + if error and error.stripped_strings: + for stripped_string in error.stripped_strings: + if "plase solve this math problem" in stripped_string.lower(): + raise UserWarning("Can't log in if form has capcha ... yet") + raise UserWarning("could not parse login form error. %s " % str(error)) + if response.status_code == 200: + error = login_form_soup.select_one('div#login_error') + if error and error.stripped_strings: + for stripped_string in error.stripped_strings: + if "invalid token" in stripped_string.lower(): + raise UserWarning("Invalid token: %s" % repr(kwargs.get('token'))) + elif "invalid username" in stripped_string.lower(): + raise UserWarning("Invalid username: %s" % repr(kwargs.get('username'))) + elif "the password you entered" in stripped_string.lower(): + raise UserWarning("Invalid password: %s" % repr(kwargs.get('password'))) + raise UserWarning("could not parse login form error. %s " % str(error)) + raise UserWarning( + "Login form response was code %s. original error: \n%s" % \ + (str(response.status_code), repr(exc)) + ) def get_form_info(self, response, form_id): """ parses a form specified by a given form_id in the response, extracts form data and form action """ - assert response.status_code is 200 + assert \ + response.status_code is 200, \ + "login form response should be 200, not %s\n%s" % ( + response.status_code, + response.text + ) response_soup = BeautifulSoup(response.text, "lxml") form_soup = response_soup.select_one('form#%s' % form_id) assert \ From e2b82ff4d293efbcef79cd79e33b65e9b3b4b043 Mon Sep 17 00:00:00 2001 From: derwentx Date: Wed, 16 Aug 2017 19:51:10 +1000 Subject: [PATCH 035/129] whoopsies --- wordpress/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress/api.py b/wordpress/api.py index 42176eb..3ffafc2 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -86,7 +86,7 @@ def request_post_mortem(self, response=None): except ValueError: pass - import pudb; pudb.set_trace() + # import pudb; pudb.set_trace() if 'code' in response_json or 'message' in response_json: reason = " - ".join([ From d8dc9177de8187c652e588a76fb0e052e8db4f54 Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 17 Aug 2017 12:23:38 +1000 Subject: [PATCH 036/129] fix bug in postmortem --- wordpress/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress/api.py b/wordpress/api.py index 3ffafc2..43eda0d 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -90,7 +90,7 @@ def request_post_mortem(self, response=None): if 'code' in response_json or 'message' in response_json: reason = " - ".join([ - response_json.get(key) for key in ['code', 'message'] \ + str(response_json.get(key)) for key in ['code', 'message', 'data'] \ if key in response_json ]) From 42cd52fd27c0ba68721aedbe823b6fadc615675a Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 7 Sep 2017 08:45:30 +1000 Subject: [PATCH 037/129] clearer error message for duplicate email --- wordpress/api.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/wordpress/api.py b/wordpress/api.py index 43eda0d..7d4c62e 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -88,12 +88,6 @@ def request_post_mortem(self, response=None): # import pudb; pudb.set_trace() - if 'code' in response_json or 'message' in response_json: - reason = " - ".join([ - str(response_json.get(key)) for key in ['code', 'message', 'data'] \ - if key in response_json - ]) - request_body = {} request_url = "" if hasattr(response, 'request'): @@ -102,6 +96,16 @@ def request_post_mortem(self, response=None): if hasattr(response.request, 'body'): request_body = response.request.body + if 'code' in response_json or 'message' in response_json: + reason = " - ".join([ + str(response_json.get(key)) for key in ['code', 'message', 'data'] \ + if key in response_json + ]) + + if 'code' == 'rest_user_invalid_email': + remedy = "Try checking the email %s doesn't already exist" % \ + request_body.get('email') + response_headers = {} if hasattr(response, 'headers'): response_headers = response.headers From 8bd520559fb8ad4a26f04edc657248144e50fd01 Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 7 Sep 2017 08:59:06 +1000 Subject: [PATCH 038/129] version increment --- README.rst | 7 ++++++- wordpress/__init__.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index e1f2c5f..9a9fdf8 100644 --- a/README.rst +++ b/README.rst @@ -17,7 +17,7 @@ Roadmap - [x] Create initial fork - [x] Implement 3-legged OAuth on Wordpress client -- [ ] Better local storage of OAuth creds to stop unnecessary API keys being generated +- [x] Better local storage of OAuth creds to stop unnecessary API keys being generated - [ ] Implement iterator for conveniant access to API items Requirements @@ -225,6 +225,11 @@ Example of returned data: Changelog --------- +1.2.3 - 2017/09/07 +~~~~~~~~~~~~~~~~~~ +- Better local storage of OAuth creds to stop unnecessary API keys being generated +- Improve parsing of API errors to display much more useful error information + 1.2.2 - 2017/06/16 ~~~~~~~~~~~~~~~~~~ - support basic auth without https diff --git a/wordpress/__init__.py b/wordpress/__init__.py index ef70a7c..d56964b 100644 --- a/wordpress/__init__.py +++ b/wordpress/__init__.py @@ -10,7 +10,7 @@ """ __title__ = "wordpress" -__version__ = "1.2.2" +__version__ = "1.2.3" __author__ = "Claudio Sanches @ WooThemes, forked by Derwent" __license__ = "MIT" From 47eb4fce8f4bcbaa7fa7565655878bf4388d3c43 Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 7 Sep 2017 09:26:07 +1000 Subject: [PATCH 039/129] ignore eggs --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 46d9147..47afd2f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ run.py run3.py *.orig +.eggs/* From c2ddb05d426e144311bb78c832e4717a616ede3f Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 7 Sep 2017 09:26:24 +1000 Subject: [PATCH 040/129] setup.py tests config --- setup.cfg | 5 +++++ setup.py | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 setup.cfg diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..9f051a0 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[aliases] +test=pytest +[tool:pytest] +addopts = --verbose +python_files = tests.py diff --git a/setup.py b/setup.py index a31ea26..d9014ab 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,13 @@ "beautifulsoup4", 'lxml' ], + setup_requires=[ + 'pytest-runner', + ], + tests_require=[ + 'httmock', + 'pytest' + ], classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", From e7c7a5144dc1eec39d37f71bc9f6ffa4f08912bf Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 7 Sep 2017 09:41:03 +1000 Subject: [PATCH 041/129] pylama in cfg --- setup.cfg | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/setup.cfg b/setup.cfg index 9f051a0..f35a17e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,3 +3,13 @@ test=pytest [tool:pytest] addopts = --verbose python_files = tests.py +[pylama] +skip=\.*,build/*,dist/*,*.egg-info +[pylama:tests.py] +disable=D +[pylama:radon] +complexity=20 +[pylama:mccabe] +complexity=20 +[pylama:pycodestyle] +max_line_length=100 From 1de6d03a1624f1c4a1a3a572f68b4b6ee680ce07 Mon Sep 17 00:00:00 2001 From: derwentx Date: Wed, 25 Oct 2017 23:34:53 +1100 Subject: [PATCH 042/129] debugging: updated tests, logging and post_mortem --- .gitignore | 2 ++ tests.py | 56 +++++++++++++++++++++++++---------------------- wordpress/api.py | 4 ++++ wordpress/auth.py | 24 ++++++++++++++------ 4 files changed, 53 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 47afd2f..50d2de3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ run.py run3.py *.orig .eggs/* +.cache/v/cache/lastfailed +pylint_report.txt diff --git a/tests.py b/tests.py index 93f5800..7ed416e 100644 --- a/tests.py +++ b/tests.py @@ -1,22 +1,23 @@ """ API Tests """ -import unittest -import sys -import pdb import functools +import logging +import pdb +import random +import sys import traceback +import unittest from collections import OrderedDict +from copy import copy from tempfile import mkstemp -from httmock import all_requests, HTTMock, urlmatch import wordpress -from wordpress import auth -from wordpress import __default_api_version__, __default_api__ -from wordpress.helpers import UrlUtils, SeqUtils, StrUtils -from wordpress.transport import API_Requests_Wrapper +from wordpress import __default_api__, __default_api_version__, auth from wordpress.api import API from wordpress.auth import OAuth -import random -import platform +from wordpress.helpers import SeqUtils, StrUtils, UrlUtils +from wordpress.transport import API_Requests_Wrapper + +from httmock import HTTMock, all_requests, urlmatch try: from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse @@ -32,12 +33,16 @@ def debug_on(*exceptions): def decorator(f): @functools.wraps(f) def wrapper(*args, **kwargs): + prev_root = copy(logging.root) try: + logging.basicConfig(level=logging.DEBUG) return f(*args, **kwargs) except exceptions: info = sys.exc_info() traceback.print_exception(*info) pdb.post_mortem(info[2]) + finally: + logging.root = prev_root return wrapper return decorator @@ -807,7 +812,7 @@ class WCApiTestCases(unittest.TestCase): """ Tests for WC API V3 """ def setUp(self): self.api_params = { - 'url':'http://ich.local:8888/woocommerce/', + 'url':'http://localhost:18080/wptest/', 'api':'wc-api', 'version':'v3', 'consumer_key':'ck_0297450a41484f27184d1a8a3275f9bab5b69143', @@ -871,7 +876,7 @@ class WCApiTestCasesNew(unittest.TestCase): """ Tests for New WC API """ def setUp(self): self.api_params = { - 'url':'http://ich.local:8888/woocommerce/', + 'url':'http://localhost:18080/wptest/', 'api':'wp-json', 'version':'wc/v2', 'consumer_key':'ck_0297450a41484f27184d1a8a3275f9bab5b69143', @@ -914,32 +919,31 @@ def test_APIPutWithSimpleQuery(self): @unittest.skip("Should only work on my machine") class WPAPITestCases(unittest.TestCase): def setUp(self): - self.creds_store = '~/wc-api-creds.json' + self.creds_store = '~/wc-api-creds-test.json' self.api_params = { - 'url':'http://ich.local:8888/woocommerce/', + 'url':'http://localhost:18080/wptest/', 'api':'wp-json', - 'version':'wp/v1', - 'consumer_key':'ox0p2NZSOja8', - 'consumer_secret':'6Ye77tGlYgxjCexn1m7zGs0GLYmmoGXeHM82jgmw3kqffNLe', + 'version':'wp/v2', + 'consumer_key':'tYG1tAoqjBEM', + 'consumer_secret':'s91fvylVrqChwzzDbEJHEWyySYtAmlIsqqYdjka1KyVDdAyB', 'callback':'http://127.0.0.1/oauth1_callback', - 'wp_user':'woocommerce', - 'wp_pass':'woocommerce', + 'wp_user':'wptest', + 'wp_pass':'gZ*gZk#v0t5$j#NQ@9', 'oauth1a_3leg':True, 'creds_store': self.creds_store } + self.wpapi = API(**self.api_params) + self.wpapi.auth.clear_stored_creds() - @debug_on() def test_APIGet(self): - wpapi = API(**self.api_params) - wpapi.auth.clear_stored_creds() - response = wpapi.get('users') + response = self.wpapi.get('users') self.assertIn(response.status_code, [200,201]) response_obj = response.json() - self.assertEqual(response_obj[0]['name'], 'woocommerce') + self.assertEqual(response_obj[0]['name'], self.api_params['wp_user']) + @debug_on() def test_APIGetWithSimpleQuery(self): - wpapi = API(**self.api_params) - response = wpapi.get('media?page=2&per_page=2') + response = self.wpapi.get('media?page=2&per_page=2') # print UrlUtils.beautify_response(response) self.assertIn(response.status_code, [200,201]) diff --git a/wordpress/api.py b/wordpress/api.py index 7d4c62e..117af2b 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -106,6 +106,10 @@ def request_post_mortem(self, response=None): remedy = "Try checking the email %s doesn't already exist" % \ request_body.get('email') + elif 'code' == 'json_oauth1_consumer_mismatch': + remedy = "Try deleting the cached credentials at %s" % \ + self.auth.creds_store + response_headers = {} if hasattr(response, 'headers'): response_headers = response.headers diff --git a/wordpress/auth.py b/wordpress/auth.py index bc5a147..bef6ba3 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -6,17 +6,20 @@ __title__ = "wordpress-auth" -import os -from time import time -from random import randint -from hmac import new as HMAC -from hashlib import sha1, sha256 -import json # from base64 import b64encode import binascii +import json +import logging +import os +from hashlib import sha1, sha256 +from hmac import new as HMAC +from random import randint +from time import time + # import webbrowser import requests from bs4 import BeautifulSoup +from wordpress.helpers import UrlUtils try: from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse @@ -31,7 +34,6 @@ except ImportError: from ordereddict import OrderedDict -from wordpress.helpers import UrlUtils class Auth(object): @@ -39,6 +41,7 @@ class Auth(object): def __init__(self, requester): self.requester = requester + self.logger = logging.getLogger(__name__) @property def api_version(self): @@ -257,6 +260,8 @@ def creds_store(self): def get_auth_url(self, endpoint_url, method): """ Returns the URL with OAuth params """ assert self.access_token, "need a valid access token for this step" + assert self.access_token_secret, \ + "need a valid access token secret for this step" params = self.get_params() params += [ @@ -266,6 +271,8 @@ def get_auth_url(self, endpoint_url, method): sign_key = self.get_sign_key(self.consumer_secret, self.access_token_secret) + self.logger.debug('sign_key: %s' % sign_key ) + return self.add_params_sign(method, endpoint_url, params, sign_key) def discover_auth(self): @@ -305,6 +312,7 @@ def get_request_token(self): request_token_url = self.add_params_sign("GET", request_token_url, params) response = self.requester.get(request_token_url) + self.logger.debug('get_request_token response: %s' % response.text) resp_content = parse_qs(response.text) try: @@ -540,6 +548,8 @@ def get_access_token(self, oauth_verifier=None): access_response = self.requester.post(access_token_url) + self.logger.debug('access_token response: %s' % access_response.text) + assert \ access_response.status_code == 200, \ "Access request did not return 200, returned %s. HTML: %s" % ( From 1dea17e79b4282062babc8b1feef543788b5fb51 Mon Sep 17 00:00:00 2001 From: derwentx Date: Wed, 25 Oct 2017 23:45:06 +1100 Subject: [PATCH 043/129] updated readme thanks Paul --- README.rst | 49 +++++++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/README.rst b/README.rst index 9a9fdf8..35ef346 100644 --- a/README.rst +++ b/README.rst @@ -95,6 +95,7 @@ Setup for the old Wordpress API: ) Setup for the new WP REST API v2: +(Note: the username and password are required so that it can fill out the oauth request token form automatically for you) .. code-block:: python @@ -107,7 +108,9 @@ Setup for the new WP REST API v2: api="wp-json", version="wp/v2", wp_user="XXXX", - wp_pass="XXXX" + wp_pass="XXXX", + oauth1a_3leg=True, + creds_store="~/.wc-api-creds.json" ) Setup for the old WooCommerce API v3: @@ -141,27 +144,29 @@ Setup for the new WP REST API integration (WooCommerce 2.6 or later): Options ~~~~~~~ -+-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| Option | Type | Required | Description | -+=======================+=============+==========+=======================================================================================================+ -| ``url`` | ``string`` | yes | Your Store URL, example: http://wp.dev/ | -+-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``consumerKey`` | ``string`` | yes | Your API consumer key | -+-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``consumerSecret`` | ``string`` | yes | Your API consumer secret | -+-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``api`` | ``string`` | no | Determines which api to use, defaults to ``wp-json``, can be arbitrary: ``wc-api``, ``oembed`` | -+-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``version`` | ``string`` | no | API version, default is ``wp/v2``, can be ``v3`` or ``wc/v1`` if using ``wc-api`` | -+-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``timeout`` | ``integer`` | no | Connection timeout, default is ``5`` | -+-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``verify_ssl`` | ``bool`` | no | Verify SSL when connect, use this option as ``False`` when need to test with self-signed certificates | -+-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``basic_auth`` | ``bool`` | no | Force Basic Authentication, can be through query string or headers (default) | -+-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ -| ``query_string_auth`` | ``bool`` | no | Force Basic Authentication as query string when ``True`` and using under HTTPS, default is ``False`` | -+-----------------------+-------------+----------+-------------------------------------------------------------------------------------------------------+ ++-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ +| Option | Type | Required | Description | ++=======================+=============+==========+==================================================================================================================+ +| ``url`` | ``string`` | yes | Your Store URL, example: http://wp.dev/ | ++-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ +| ``consumerKey`` | ``string`` | yes | Your API consumer key | ++-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ +| ``consumerSecret`` | ``string`` | yes | Your API consumer secret | ++-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ +| ``api`` | ``string`` | no | Determines which api to use, defaults to ``wp-json``, can be arbitrary: ``wc-api``, ``oembed`` | ++-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ +| ``version`` | ``string`` | no | API version, default is ``wp/v2``, can be ``v3`` or ``wc/v1`` if using ``wc-api`` | ++-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ +| ``timeout`` | ``integer`` | no | Connection timeout, default is ``5`` | ++-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ +| ``verify_ssl`` | ``bool`` | no | Verify SSL when connect, use this option as ``False`` when need to test with self-signed certificates | ++-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ +| ``basic_auth`` | ``bool`` | no | Force Basic Authentication, can be through query string or headers (default) | ++-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ +| ``query_string_auth`` | ``bool`` | no | Use query string for Basic Authentication when ``True`` and using HTTPS, default is ``False`` which uses header | ++-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ +| ``creds_store`` | ``string`` | no | JSON file where oauth verifier is stored (only used with OAuth_3Leg) | ++-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ Methods ------- From 9908cb7dab29bc0dbbce94b9529145f058015654 Mon Sep 17 00:00:00 2001 From: derwentx Date: Wed, 1 Nov 2017 20:03:17 +1100 Subject: [PATCH 044/129] added more tests cases for 3leg --- README.rst | 11 ++++-- tests.py | 90 +++++++++++++++++++++++++++++++++--------- wordpress/api.py | 6 ++- wordpress/helpers.py | 57 +++++++++++++++++++++++++- wordpress/transport.py | 36 ++++++++++++++--- 5 files changed, 169 insertions(+), 31 deletions(-) diff --git a/README.rst b/README.rst index 35ef346..d74ceed 100644 --- a/README.rst +++ b/README.rst @@ -17,8 +17,10 @@ Roadmap - [x] Create initial fork - [x] Implement 3-legged OAuth on Wordpress client -- [x] Better local storage of OAuth creds to stop unnecessary API keys being generated -- [ ] Implement iterator for conveniant access to API items +- [x] Better local storage of OAuth credentials to stop unnecessary API keys being generated +- [ ] Support easy image upload to WC Api +- [ ] Better handling of timeouts with a back-off +- [ ] Implement iterator for convenient access to API items Requirements ------------ @@ -138,7 +140,8 @@ Setup for the new WP REST API integration (WooCommerce 2.6 or later): consumer_key="ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", consumer_secret="cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", api="wp-json", - version="wc/v1" + version="wc/v2", + callback='http://127.0.0.1/oauth1_callback' ) Options @@ -165,6 +168,8 @@ Options +-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ | ``query_string_auth`` | ``bool`` | no | Use query string for Basic Authentication when ``True`` and using HTTPS, default is ``False`` which uses header | +-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ +| ``oauth1a_3leg`` | ``string`` | no | use oauth1a 3-legged authentication | ++-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ | ``creds_store`` | ``string`` | no | JSON file where oauth verifier is stored (only used with OAuth_3Leg) | +-----------------------+-------------+----------+------------------------------------------------------------------------------------------------------------------+ diff --git a/tests.py b/tests.py index 7ed416e..db2042a 100644 --- a/tests.py +++ b/tests.py @@ -373,19 +373,21 @@ def setUp(self): consumer_secret=self.consumer_secret, basic_auth=True, api=self.api_name, - version=self.api_ver + version=self.api_ver, + query_string_auth=False, ) def test_endpoint_url(self): - basic_api_params = dict(**self.api_params) api = API( - **basic_api_params + **self.api_params ) endpoint_url = api.requester.endpoint_url(self.endpoint) endpoint_url = api.auth.get_auth_url(endpoint_url, 'GET') self.assertEqual( endpoint_url, - UrlUtils.join_components([self.base_url, self.api_name, self.api_ver, self.endpoint]) + UrlUtils.join_components([ + self.base_url, self.api_name, self.api_ver, self.endpoint + ]) ) def test_query_string_endpoint_url(self): @@ -590,16 +592,48 @@ def test_get_sign_key(self): ) def test_flatten_params(self): - flattened_params = UrlUtils.flatten_params(self.twitter_params_raw) - expected_flattened_params = self.twitter_param_string - self.assertEqual(flattened_params, expected_flattened_params) + self.assertEqual( + UrlUtils.flatten_params(self.twitter_params_raw), + self.twitter_param_string + ) + + def test_sorted_params(self): + # Example given in oauth.net: + oauthnet_example_sorted = [ + ('a', '1'), + ('c', 'hi%%20there'), + ('f', '25'), + ('f', '50'), + ('f', 'a'), + ('z', 'p'), + ('z', 't') + ] + + oauthnet_example = copy(oauthnet_example_sorted) + random.shuffle(oauthnet_example) + + # oauthnet_example_sorted = [ + # ('a', '1'), + # ('c', 'hi%%20there'), + # ('f', '25'), + # ('z', 'p'), + # ] + + self.assertEqual( + UrlUtils.sorted_params(oauthnet_example), + oauthnet_example_sorted + ) - twitter_base_string = OAuth.get_signature_base_string( + def test_get_signature_base_string(self): + twitter_param_string = OAuth.get_signature_base_string( self.twitter_method, self.twitter_params_raw, self.twitter_target_url ) - self.assertEqual(twitter_base_string, self.twitter_signature_base_string) + self.assertEqual( + twitter_param_string, + self.twitter_signature_base_string + ) # @unittest.skip("changed order of parms to fit wordpress api") def test_generate_oauth_signature(self): @@ -815,8 +849,8 @@ def setUp(self): 'url':'http://localhost:18080/wptest/', 'api':'wc-api', 'version':'v3', - 'consumer_key':'ck_0297450a41484f27184d1a8a3275f9bab5b69143', - 'consumer_secret':'cs_68ef2cf6a708e1c6b30bfb2a38dc948b16bf46c0', + 'consumer_key':'ck_e1dd4a9c85f49b9685f7964a154eecb29af39d5a', + 'consumer_secret':'cs_8ef3e5d21f8a0c28cd7bc4643e92111a0326b6b1', } def test_APIGet(self): @@ -873,14 +907,14 @@ def test_APIPutWithSimpleQuery(self): @unittest.skip("Should only work on my machine") class WCApiTestCasesNew(unittest.TestCase): - """ Tests for New WC API """ + """ Tests for New wp-json/wc/v2 API """ def setUp(self): self.api_params = { 'url':'http://localhost:18080/wptest/', 'api':'wp-json', 'version':'wc/v2', - 'consumer_key':'ck_0297450a41484f27184d1a8a3275f9bab5b69143', - 'consumer_secret':'cs_68ef2cf6a708e1c6b30bfb2a38dc948b16bf46c0', + 'consumer_key':'ck_e1dd4a9c85f49b9685f7964a154eecb29af39d5a', + 'consumer_secret':'cs_8ef3e5d21f8a0c28cd7bc4643e92111a0326b6b1', 'callback':'http://127.0.0.1/oauth1_callback', } @@ -903,7 +937,7 @@ def test_APIPutWithSimpleQuery(self): product_id = first_product['id'] nonce = str(random.random()) - response = wcapi.put('products/%s?filter%%5Blimit%%5D=5' % (product_id), {"name":str(nonce)}) + response = wcapi.put('products/%s?page=2&per_page=5' % (product_id), {"name":str(nonce)}) request_params = UrlUtils.get_query_dict_singular(response.request.url) response_obj = response.json() self.assertEqual(response_obj['name'], str(nonce)) @@ -911,9 +945,30 @@ def test_APIPutWithSimpleQuery(self): wcapi.put('products/%s' % (product_id), {"name":original_title}) +@unittest.skip("Should only work on my machine") +class WCApiTestCasesNew3Leg(unittest.TestCase): + """ Tests for New wp-json/wc/v2 API with 3-leg """ + def setUp(self): + self.api_params = { + 'url':'http://localhost:18080/wptest/', + 'api':'wp-json', + 'version':'wc/v2', + 'consumer_key':'ck_e1dd4a9c85f49b9685f7964a154eecb29af39d5a', + 'consumer_secret':'cs_8ef3e5d21f8a0c28cd7bc4643e92111a0326b6b1', + 'callback':'http://127.0.0.1/oauth1_callback', + 'oauth1a_3leg': True, + 'wp_user': 'wptest', + 'wp_pass':'gZ*gZk#v0t5$j#NQ@9' + } - # def test_APIPut(self): - + @debug_on() + def test_api_get_3leg(self): + wcapi = API(**self.api_params) + per_page = 10 + response = wcapi.get('products') + self.assertIn(response.status_code, [200,201]) + response_obj = response.json() + self.assertEqual(len(response_obj), per_page) # @unittest.skipIf(platform.uname()[1] != "Ich.lan", "should only work on my machine") @unittest.skip("Should only work on my machine") @@ -941,7 +996,6 @@ def test_APIGet(self): response_obj = response.json() self.assertEqual(response_obj[0]['name'], self.api_params['wp_user']) - @debug_on() def test_APIGetWithSimpleQuery(self): response = self.wpapi.get('media?page=2&per_page=2') # print UrlUtils.beautify_response(response) diff --git a/wordpress/api.py b/wordpress/api.py index 117af2b..610ad04 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -7,6 +7,7 @@ __title__ = "wordpress-api" # from requests import request +import logging from json import dumps as jsonencode from wordpress.auth import OAuth, OAuth_3Leg, BasicAuth from wordpress.transport import API_Requests_Wrapper @@ -16,7 +17,7 @@ class API(object): """ API Class """ def __init__(self, url, consumer_key, consumer_secret, **kwargs): - + self.logger = logging.getLogger(__name__) self.requester = API_Requests_Wrapper(url=url, **kwargs) auth_kwargs = dict( @@ -163,11 +164,12 @@ def __request(self, method, endpoint, data): data=data ) - if response.status_code not in [200, 201]: + if response.status_code not in [200, 201, 202]: self.request_post_mortem(response) return response + # TODO add kwargs option for headers def get(self, endpoint): """ Get requests """ return self.__request("GET", endpoint, None) diff --git a/wordpress/helpers.py b/wordpress/helpers.py index a5ff689..d58f97f 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -52,6 +52,8 @@ def filter_true(cls, seq): class UrlUtils(object): + reg_netloc = r'(?P[^:]+)(:(?P\d+))?' + @classmethod def get_query_list(cls, url): """ returns the list of queries in the url """ @@ -174,7 +176,7 @@ def beautify_response(response): @classmethod def remove_port(cls, url): - """ Remove the port number from a URL """ + """ Remove the port number from a URL""" urlparse_result = urlparse(url) @@ -187,10 +189,61 @@ def remove_port(cls, url): fragment=urlparse_result.fragment )) + @classmethod + def remove_default_port(cls, url, defaults=None): + """ Remove the port number from a URL if it is a default port. """ + if defaults is None: + defaults = { + 'http':80, + 'https':443 + } + + urlparse_result = urlparse(url) + match = re.match( + cls.reg_netloc, + urlparse_result.netloc + ) + assert match, "netloc %s should match regex %s" + if match.groupdict().get('port'): + hostname = match.groupdict()['hostname'] + port = int(match.groupdict()['port']) + scheme = urlparse_result.scheme.lower() + + if defaults[scheme] == port: + return urlunparse(URLParseResult( + scheme=urlparse_result.scheme, + netloc=hostname, + path=urlparse_result.path, + params=urlparse_result.params, + query=urlparse_result.query, + fragment=urlparse_result.fragment + )) + return urlunparse(URLParseResult( + scheme=urlparse_result.scheme, + netloc=urlparse_result.netloc, + path=urlparse_result.path, + params=urlparse_result.params, + query=urlparse_result.query, + fragment=urlparse_result.fragment + )) + + @classmethod + def lower_scheme(cls, url): + """ ensure the scheme of the url is lowercase. """ + urlparse_result = urlparse(url) + return urlunparse(URLParseResult( + scheme=urlparse_result.scheme.lower(), + netloc=urlparse_result.netloc, + path=urlparse_result.path, + params=urlparse_result.params, + query=urlparse_result.query, + fragment=urlparse_result.fragment + )) + @classmethod def normalize_str(cls, string): """ Normalize string for the purposes of url query parameters. """ - return quote(string, '') + return quote(string, '~') @classmethod def normalize_params(cls, params): diff --git a/wordpress/transport.py b/wordpress/transport.py index 4a8357c..b56a62e 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -6,8 +6,13 @@ __title__ = "wordpress-requests" -from requests import Request, Session +import logging from json import dumps as jsonencode +from pprint import pformat + +from requests import Request, Session +from wordpress import __default_api__, __default_api_version__, __version__ +from wordpress.helpers import SeqUtils, StrUtils, UrlUtils try: from urllib.parse import urlencode, quote, unquote, parse_qsl, urlparse, urlunparse @@ -17,14 +22,11 @@ from urlparse import parse_qsl, urlparse, urlunparse from urlparse import ParseResult as URLParseResult -from wordpress import __version__ -from wordpress import __default_api_version__ -from wordpress import __default_api__ -from wordpress.helpers import SeqUtils, UrlUtils, StrUtils class API_Requests_Wrapper(object): """ provides a wrapper for making requests that handles session info """ def __init__(self, url, **kwargs): + self.logger = logging.getLogger(__name__) self.url = url self.api = kwargs.get("api", __default_api__) self.api_version = kwargs.get("version", __default_api_version__) @@ -88,9 +90,31 @@ def request(self, method, url, auth=None, params=None, data=None, **kwargs): request_kwargs['params'] = params if data is not None: request_kwargs['data'] = data - return self.session.request( + self.logger.debug("request_kwargs:\n%s" % pformat(request_kwargs)) + response = self.session.request( **request_kwargs ) + self.logger.debug("response_code:\n%s" % pformat(response.status_code)) + try: + response_json = response.json() + self.logger.debug("response_json:\n%s" % (pformat(response_json)[:1000])) + except ValueError: + response_text = response.text + self.logger.debug("response_text:\n%s" % (response_text[:1000])) + response_headers = {} + if hasattr(response, 'headers'): + response_headers = response.headers + self.logger.debug("response_headers:\n%s" % pformat(response_headers)) + response_links = {} + if hasattr(response, 'links') and response.links: + response_links = response.links + self.logger.debug("response_links:\n%s" % pformat(response_links)) + + + + + + return response def get(self, *args, **kwargs): return self.request("GET", *args, **kwargs) From 8e7ceb6130c773cd99d543e7a3ca440b422c1d3c Mon Sep 17 00:00:00 2001 From: derwentx Date: Wed, 1 Nov 2017 20:03:58 +1100 Subject: [PATCH 045/129] made signing utilities clearer for debugging --- wordpress/api.py | 21 ++++++++------- wordpress/auth.py | 63 +++++++++++++++++++++++++++++++------------- wordpress/helpers.py | 26 ++++++++++-------- 3 files changed, 70 insertions(+), 40 deletions(-) diff --git a/wordpress/api.py b/wordpress/api.py index 610ad04..3ad2b76 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -9,9 +9,11 @@ # from requests import request import logging from json import dumps as jsonencode -from wordpress.auth import OAuth, OAuth_3Leg, BasicAuth + +from wordpress.auth import BasicAuth, OAuth, OAuth_3Leg +from wordpress.helpers import StrUtils, UrlUtils from wordpress.transport import API_Requests_Wrapper -from wordpress.helpers import UrlUtils, StrUtils + class API(object): """ API Class """ @@ -27,15 +29,13 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): ) auth_kwargs.update(kwargs) + auth_class = OAuth if kwargs.get('basic_auth'): - self.auth = BasicAuth(**auth_kwargs) - else: - if kwargs.get('oauth1a_3leg'): - if 'callback' not in auth_kwargs: - raise TypeError("callback url not specified") - self.auth = OAuth_3Leg( **auth_kwargs ) - else: - self.auth = OAuth( **auth_kwargs ) + auth_class = BasicAuth + elif kwargs.get('oauth1a_3leg'): + auth_class = OAuth_3Leg + + self.auth = auth_class(**auth_kwargs) @property def url(self): @@ -170,6 +170,7 @@ def __request(self, method, endpoint, data): return response # TODO add kwargs option for headers + def get(self, endpoint): """ Get requests """ return self.__request("GET", endpoint, None) diff --git a/wordpress/auth.py b/wordpress/auth.py index bef6ba3..79a1964 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -15,9 +15,13 @@ from hmac import new as HMAC from random import randint from time import time +from pprint import pformat # import webbrowser import requests +from requests.auth import HTTPBasicAuth +from requests_oauthlib import OAuth1 + from bs4 import BeautifulSoup from wordpress.helpers import UrlUtils @@ -39,9 +43,10 @@ class Auth(object): """ Boilerplate for handling authentication stuff. """ - def __init__(self, requester): + def __init__(self, requester, **kwargs): self.requester = requester self.logger = logging.getLogger(__name__) + self.query_string_auth = kwargs.pop('query_string_auth', True) @property def api_version(self): @@ -57,14 +62,13 @@ def get_auth_url(self, endpoint_url, method): def get_auth(self): """ Returns the auth parameter used in requests """ - pass + return HTTPBasicAuth(self.consumer_key, self.consumer_secret) class BasicAuth(Auth): def __init__(self, requester, consumer_key, consumer_secret, **kwargs): - super(BasicAuth, self).__init__(requester) + super(BasicAuth, self).__init__(requester, **kwargs) self.consumer_key = consumer_key self.consumer_secret = consumer_secret - self.query_string_auth = kwargs.get("query_string_auth", False) def get_auth_url(self, endpoint_url, method): if self.query_string_auth: @@ -81,7 +85,7 @@ def get_auth_url(self, endpoint_url, method): def get_auth(self): if not self.query_string_auth: - return (self.consumer_key, self.consumer_secret) + return HTTPBasicAuth(self.consumer_key, self.consumer_secret) class OAuth(Auth): @@ -92,12 +96,14 @@ class OAuth(Auth): """ API Class """ def __init__(self, requester, consumer_key, consumer_secret, **kwargs): - super(OAuth, self).__init__(requester) + super(OAuth, self).__init__(requester, **kwargs) + if not self.query_string_auth: + raise UserWarning("Header Auth not supported for OAuth") self.consumer_key = consumer_key self.consumer_secret = consumer_secret - self.signature_method = kwargs.get('signature_method', 'HMAC-SHA1') - self.force_timestamp = kwargs.get('force_timestamp') - self.force_nonce = kwargs.get('force_nonce') + self.signature_method = kwargs.pop('signature_method', 'HMAC-SHA1') + self.force_timestamp = kwargs.pop('force_timestamp', None) + self.force_nonce = kwargs.pop('force_nonce', None) def get_sign_key(self, consumer_secret, token_secret=None): "gets consumer_secret and turns it into a string suitable for signing" @@ -132,7 +138,13 @@ def add_params_sign(self, method, url, params, sign_key=None): if key != "oauth_signature": params_without_signature.append((key, value)) + self.logger.debug('sorted_params before sign: %s' % pformat(params_without_signature) ) + signature = self.generate_oauth_signature(method, params_without_signature, url, sign_key) + + self.logger.debug('signature: %s' % signature ) + + params = params_without_signature + [("oauth_signature", signature)] query_string = UrlUtils.flatten_params(params) @@ -155,9 +167,18 @@ def get_auth_url(self, endpoint_url, method): @classmethod def get_signature_base_string(cls, method, params, url): - base_request_uri = quote(UrlUtils.substitute_query(url), "") - query_string = quote( UrlUtils.flatten_params(params), '~') - return "&".join([method, base_request_uri, query_string]) + # remove default port + url = UrlUtils.remove_default_port(url) + # ensure scheme is lowercase + url = UrlUtils.lower_scheme(url) + # remove query string parameters + url = UrlUtils.substitute_query(url) + base_request_uri = quote(url, "") + query_string = UrlUtils.flatten_params(params) + query_string = quote( query_string, '~') + return "%s&%s&%s" % ( + method.upper(), base_request_uri, query_string + ) def generate_oauth_signature(self, method, params, url, key=None): """ Generate OAuth Signature """ @@ -208,15 +229,15 @@ class OAuth_3Leg(OAuth): def __init__(self, requester, consumer_key, consumer_secret, callback, **kwargs): super(OAuth_3Leg, self).__init__(requester, consumer_key, consumer_secret, **kwargs) self.callback = callback - self.wp_user = kwargs.get('wp_user') - self.wp_pass = kwargs.get('wp_pass') - self._creds_store = kwargs.get('creds_store') + self.wp_user = kwargs.pop('wp_user', None) + self.wp_pass = kwargs.pop('wp_pass', None) + self._creds_store = kwargs.pop('creds_store', None) self._authentication = None - self._request_token = kwargs.get('request_token') + self._request_token = kwargs.pop('request_token', None) self.request_token_secret = None self._oauth_verifier = None - self._access_token = kwargs.get('access_token') - self.access_token_secret = kwargs.get('access_token_secret') + self._access_token = kwargs.pop('access_token', None) + self.access_token_secret = kwargs.pop('access_token_secret', None) @property def authentication(self): @@ -317,9 +338,13 @@ def get_request_token(self): try: self._request_token = resp_content['oauth_token'][0] + except: + raise UserWarning("Could not parse request_token in response from %s : %s" \ + % (repr(response.request.url), UrlUtils.beautify_response(response))) + try: self.request_token_secret = resp_content['oauth_token_secret'][0] except: - raise UserWarning("Could not parse request_token or request_token_secret in response from %s : %s" \ + raise UserWarning("Could not parse request_token_secret in response from %s : %s" \ % (repr(response.request.url), UrlUtils.beautify_response(response))) return self._request_token, self.request_token_secret diff --git a/wordpress/helpers.py b/wordpress/helpers.py index d58f97f..0ab2d34 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -250,9 +250,12 @@ def normalize_params(cls, params): """ Normalize parameters. works with RFC 5849 logic. params is a list of key, value pairs """ if isinstance(params, dict): params = params.items() - params = \ - [(cls.normalize_str(key), cls.normalize_str(UrlUtils.get_value_like_as_php(value))) \ - for key, value in params] + params = [ + ( + cls.normalize_str(key), + cls.normalize_str(UrlUtils.get_value_like_as_php(value)) + ) for key, value in params + ] response = params return response @@ -264,16 +267,17 @@ def sorted_params(cls, params): if isinstance(params, dict): params = params.items() + if not params: + return params # return sorted(params) ordered = [] - base_keys = sorted(set(k.split('[')[0] for k, v in params)) - keys_seen = [] - for base in base_keys: - for key, value in params: - if key == base or key.startswith(base + '['): - if key not in keys_seen: - ordered.append((key, value)) - keys_seen.append(key) + params_sorting = [] + for i, (key, value) in enumerate(params): + base_key = key.split('[')[0] + params_sorting.append((base_key, value, i, key)) + + for _, value, _, key in sorted(params_sorting): + ordered.append((key, value)) return ordered From d9271908d7c2d9c707ade6412af8922b3c7bc2c1 Mon Sep 17 00:00:00 2001 From: derwentx Date: Wed, 1 Nov 2017 21:09:31 +1100 Subject: [PATCH 046/129] better documentation and tests --- README.rst | 2 ++ tests.py | 51 +++++++++++++++++++++++++++++++------------- wordpress/api.py | 3 +++ wordpress/auth.py | 1 + wordpress/helpers.py | 17 +++++++++++++++ 5 files changed, 59 insertions(+), 15 deletions(-) diff --git a/README.rst b/README.rst index d74ceed..0e01da3 100644 --- a/README.rst +++ b/README.rst @@ -143,6 +143,8 @@ Setup for the new WP REST API integration (WooCommerce 2.6 or later): version="wc/v2", callback='http://127.0.0.1/oauth1_callback' ) + +Note: oauth1a 3legged works with Wordpress but not with WooCommerce. However oauth1a signing still works. Options ~~~~~~~ diff --git a/tests.py b/tests.py index db2042a..812cfa7 100644 --- a/tests.py +++ b/tests.py @@ -6,14 +6,16 @@ import sys import traceback import unittest +import platform from collections import OrderedDict from copy import copy +from time import time from tempfile import mkstemp import wordpress from wordpress import __default_api__, __default_api_version__, auth from wordpress.api import API -from wordpress.auth import OAuth +from wordpress.auth import OAuth, Auth from wordpress.helpers import SeqUtils, StrUtils, UrlUtils from wordpress.transport import API_Requests_Wrapper @@ -46,6 +48,9 @@ def wrapper(*args, **kwargs): return wrapper return decorator +CURRENT_TIMESTAMP = int(time()) +SHITTY_NONCE = "" + class WordpressTestCase(unittest.TestCase): """Test case for the client methods.""" @@ -300,6 +305,16 @@ def test_url_del_query_singular(self): expected = "http://ich.local:8888/woocommerce/wc-api/v3/products?oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1481601370&page=2" self.assertEqual(result, expected) + def test_url_remove_default_port(self): + self.assertEqual( + UrlUtils.remove_default_port('http://www.gooogle.com:80/'), + 'http://www.gooogle.com/' + ) + self.assertEqual( + UrlUtils.remove_default_port('http://www.gooogle.com:18080/'), + 'http://www.gooogle.com:18080/' + ) + def test_seq_filter_true(self): self.assertEquals( ['a', 'b', 'c', 'd'], @@ -840,11 +855,12 @@ def test_retrieve_access_creds(self): 'YYYYYYYYYYYY' ) -# @unittest.skipIf(platform.uname()[1] != "Ich.lan", "should only work on my machine") -@unittest.skip("Should only work on my machine") -class WCApiTestCases(unittest.TestCase): +@unittest.skipIf(platform.uname()[1] != "Derwents-MBP.lan", "should only work on my machine") +class WCApiTestCasesLegacy(unittest.TestCase): """ Tests for WC API V3 """ def setUp(self): + Auth.force_timestamp = CURRENT_TIMESTAMP + Auth.force_nonce = SHITTY_NONCE self.api_params = { 'url':'http://localhost:18080/wptest/', 'api':'wc-api', @@ -858,7 +874,6 @@ def test_APIGet(self): response = wcapi.get('products') # print UrlUtils.beautify_response(response) self.assertIn(response.status_code, [200,201]) - response_obj = response.json() self.assertIn('products', response_obj) self.assertEqual(len(response_obj['products']), 10) @@ -905,10 +920,12 @@ def test_APIPutWithSimpleQuery(self): wcapi.put('products/%s' % (product_id), {"product":{"title":original_title}}) -@unittest.skip("Should only work on my machine") -class WCApiTestCasesNew(unittest.TestCase): +@unittest.skipIf(platform.uname()[1] != "Derwents-MBP.lan", "should only work on my machine") +class WCApiTestCases(unittest.TestCase): """ Tests for New wp-json/wc/v2 API """ def setUp(self): + Auth.force_timestamp = CURRENT_TIMESTAMP + Auth.force_nonce = SHITTY_NONCE self.api_params = { 'url':'http://localhost:18080/wptest/', 'api':'wp-json', @@ -918,6 +935,7 @@ def setUp(self): 'callback':'http://127.0.0.1/oauth1_callback', } + # @debug_on() def test_APIGet(self): wcapi = API(**self.api_params) per_page = 10 @@ -941,14 +959,16 @@ def test_APIPutWithSimpleQuery(self): request_params = UrlUtils.get_query_dict_singular(response.request.url) response_obj = response.json() self.assertEqual(response_obj['name'], str(nonce)) - self.assertEqual(request_params['filter[limit]'], str(5)) + self.assertEqual(request_params['per_page'], '5') wcapi.put('products/%s' % (product_id), {"name":original_title}) -@unittest.skip("Should only work on my machine") -class WCApiTestCasesNew3Leg(unittest.TestCase): +@unittest.skip("these simply don't work for some reason") +class WCApiTestCases3Leg(unittest.TestCase): """ Tests for New wp-json/wc/v2 API with 3-leg """ def setUp(self): + Auth.force_timestamp = CURRENT_TIMESTAMP + Auth.force_nonce = SHITTY_NONCE self.api_params = { 'url':'http://localhost:18080/wptest/', 'api':'wp-json', @@ -961,7 +981,7 @@ def setUp(self): 'wp_pass':'gZ*gZk#v0t5$j#NQ@9' } - @debug_on() + # @debug_on() def test_api_get_3leg(self): wcapi = API(**self.api_params) per_page = 10 @@ -970,10 +990,11 @@ def test_api_get_3leg(self): response_obj = response.json() self.assertEqual(len(response_obj), per_page) -# @unittest.skipIf(platform.uname()[1] != "Ich.lan", "should only work on my machine") -@unittest.skip("Should only work on my machine") +@unittest.skipIf(platform.uname()[1] != "Derwents-MBP.lan", "should only work on my machine") class WPAPITestCases(unittest.TestCase): def setUp(self): + Auth.force_timestamp = CURRENT_TIMESTAMP + Auth.force_nonce = SHITTY_NONCE self.creds_store = '~/wc-api-creds-test.json' self.api_params = { 'url':'http://localhost:18080/wptest/', @@ -991,10 +1012,10 @@ def setUp(self): self.wpapi.auth.clear_stored_creds() def test_APIGet(self): - response = self.wpapi.get('users') + response = self.wpapi.get('users/me') self.assertIn(response.status_code, [200,201]) response_obj = response.json() - self.assertEqual(response_obj[0]['name'], self.api_params['wp_user']) + self.assertEqual(response_obj['name'], self.api_params['wp_user']) def test_APIGetWithSimpleQuery(self): response = self.wpapi.get('media?page=2&per_page=2') diff --git a/wordpress/api.py b/wordpress/api.py index 3ad2b76..7f00e8e 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -35,6 +35,9 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): elif kwargs.get('oauth1a_3leg'): auth_class = OAuth_3Leg + if kwargs.get('version', '').startswith('wc') and kwargs.get('oauth1a_3leg'): + self.logger.warn("WooCommerce JSON Api does not seem to support 3leg") + self.auth = auth_class(**auth_kwargs) @property diff --git a/wordpress/auth.py b/wordpress/auth.py index 79a1964..e0e5358 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -131,6 +131,7 @@ def add_params_sign(self, method, url, params, sign_key=None): # for key, value in parse_qsl(urlparse_result.query): # params += [(key, value)] + params = UrlUtils.unique_params(params) params = UrlUtils.sorted_params(params) params_without_signature = [] diff --git a/wordpress/helpers.py b/wordpress/helpers.py index 0ab2d34..0d4e0c7 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -281,10 +281,27 @@ def sorted_params(cls, params): return ordered + @classmethod + def unique_params(cls, params): + if isinstance(params, dict): + params = params.items() + + if not params: + return params + + unique_params = [] + seen_keys = [] + for key, value in params: + if key not in seen_keys: + unique_params.append((key, value)) + seen_keys.append(key) + return unique_params + @classmethod def flatten_params(cls, params): if isinstance(params, dict): params = params.items() params = cls.normalize_params(params) params = cls.sorted_params(params) + params = cls.unique_params(params) return "&".join(["%s=%s"%(key, value) for key, value in params]) From a35d955b9d3818825d21d000eedcadb9e31fad43 Mon Sep 17 00:00:00 2001 From: derwentx Date: Wed, 1 Nov 2017 22:43:53 +1100 Subject: [PATCH 047/129] V1.2.4: Support image upload to WC Api --- README.rst | 55 +++++++++++++++++++++------- tests.py | 82 +++++++++++++++++++++--------------------- wordpress/api.py | 33 +++++++++-------- wordpress/auth.py | 21 ++++++++--- wordpress/helpers.py | 35 ++++++++++++++++++ wordpress/transport.py | 8 ++++- 6 files changed, 160 insertions(+), 74 deletions(-) diff --git a/README.rst b/README.rst index 0e01da3..77531e3 100644 --- a/README.rst +++ b/README.rst @@ -18,7 +18,7 @@ Roadmap - [x] Create initial fork - [x] Implement 3-legged OAuth on Wordpress client - [x] Better local storage of OAuth credentials to stop unnecessary API keys being generated -- [ ] Support easy image upload to WC Api +- [x] Support image upload to WC Api - [ ] Better handling of timeouts with a back-off - [ ] Implement iterator for convenient access to API items @@ -80,7 +80,9 @@ Check out the Wordpress API endpoints and data that can be manipulated in http:/ Setup ----- -Setup for the old Wordpress API: +Wordpress API with Basic authentication: +---- +(Note: requires Basic Authentication plugin) .. code-block:: python @@ -88,16 +90,18 @@ Setup for the old Wordpress API: wpapi = API( url="http://example.com", - consumer_key="XXXXXXXXXXXX", - consumer_secret="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", api="wp-json", - version=None, + version='wp/v2', wp_user="XXXX", - wp_pass="XXXX" + wp_pass="XXXX", + basic_auth = True, + user_auth = True, ) -Setup for the new WP REST API v2: -(Note: the username and password are required so that it can fill out the oauth request token form automatically for you) +WP REST API v2: +---- +(Note: the username and password are required so that it can fill out the oauth request token form automatically for you. +Requires OAuth 1.0a plugin. ) .. code-block:: python @@ -115,7 +119,8 @@ Setup for the new WP REST API v2: creds_store="~/.wc-api-creds.json" ) -Setup for the old WooCommerce API v3: +Legacy WooCommerce API v3: +---- .. code-block:: python @@ -129,7 +134,10 @@ Setup for the old WooCommerce API v3: version="v3" ) -Setup for the new WP REST API integration (WooCommerce 2.6 or later): +New WC REST API: +---- +Note: oauth1a 3legged works with Wordpress but not with WooCommerce. However oauth1a signing still works. +If you try to do oauth1a_3leg with WooCommerce it just says "consumer_key not valid", even if it is valid. .. code-block:: python @@ -143,8 +151,7 @@ Setup for the new WP REST API integration (WooCommerce 2.6 or later): version="wc/v2", callback='http://127.0.0.1/oauth1_callback' ) - -Note: oauth1a 3legged works with Wordpress but not with WooCommerce. However oauth1a signing still works. + Options ~~~~~~~ @@ -211,6 +218,25 @@ OPTIONS - ``.options(endpoint)`` +Upload an image +----- + +(Note: this only works on WP API with basic auth) + +.. code-block:: python + + assert os.path.exists(img_path), "img should exist" + data = open(img_path, 'rb').read() + filename = os.path.basename(img_path) + _, extension = os.path.splitext(filename) + headers = { + 'cache-control': 'no-cache', + 'content-disposition': 'attachment; filename=%s' % filename, + 'content-type': 'image/%s' % extension + } + return wcapi.post(self.endpoint_singular, data, headers=headers) + + Response -------- @@ -237,6 +263,11 @@ Example of returned data: Changelog --------- +1.2.4 - 2017/10/01 +~~~~~~~~~~~~~~~~~~ +- Support for image upload +- More accurate documentation of WP authentication methods + 1.2.3 - 2017/09/07 ~~~~~~~~~~~~~~~~~~ - Better local storage of OAuth creds to stop unnecessary API keys being generated diff --git a/tests.py b/tests.py index 812cfa7..5d0703b 100644 --- a/tests.py +++ b/tests.py @@ -856,8 +856,8 @@ def test_retrieve_access_creds(self): ) @unittest.skipIf(platform.uname()[1] != "Derwents-MBP.lan", "should only work on my machine") -class WCApiTestCasesLegacy(unittest.TestCase): - """ Tests for WC API V3 """ +class WCApiTestCasesBase(unittest.TestCase): + """ Base class for WC API Test cases """ def setUp(self): Auth.force_timestamp = CURRENT_TIMESTAMP Auth.force_nonce = SHITTY_NONCE @@ -869,6 +869,14 @@ def setUp(self): 'consumer_secret':'cs_8ef3e5d21f8a0c28cd7bc4643e92111a0326b6b1', } +class WCApiTestCasesLegacy(WCApiTestCasesBase): + """ Tests for WC API V3 """ + def setUp(self): + super(WCApiTestCasesLegacy, self).setUp() + self.api_params['version'] = 'v3' + self.api_params['api'] = 'wc-api' + + def test_APIGet(self): wcapi = API(**self.api_params) response = wcapi.get('products') @@ -920,20 +928,16 @@ def test_APIPutWithSimpleQuery(self): wcapi.put('products/%s' % (product_id), {"product":{"title":original_title}}) -@unittest.skipIf(platform.uname()[1] != "Derwents-MBP.lan", "should only work on my machine") -class WCApiTestCases(unittest.TestCase): +class WCApiTestCases(WCApiTestCasesBase): + oauth1a_3leg = False """ Tests for New wp-json/wc/v2 API """ def setUp(self): - Auth.force_timestamp = CURRENT_TIMESTAMP - Auth.force_nonce = SHITTY_NONCE - self.api_params = { - 'url':'http://localhost:18080/wptest/', - 'api':'wp-json', - 'version':'wc/v2', - 'consumer_key':'ck_e1dd4a9c85f49b9685f7964a154eecb29af39d5a', - 'consumer_secret':'cs_8ef3e5d21f8a0c28cd7bc4643e92111a0326b6b1', - 'callback':'http://127.0.0.1/oauth1_callback', - } + super(WCApiTestCases, self).setUp() + self.api_params['version'] = 'wc/v2' + self.api_params['api'] = 'wp-json' + self.api_params['callback'] = 'http://127.0.0.1/oauth1_callback' + if self.oauth1a_3leg: + self.api_params['oauth1a_3leg'] = True # @debug_on() def test_APIGet(self): @@ -964,34 +968,13 @@ def test_APIPutWithSimpleQuery(self): wcapi.put('products/%s' % (product_id), {"name":original_title}) @unittest.skip("these simply don't work for some reason") -class WCApiTestCases3Leg(unittest.TestCase): +class WCApiTestCases3Leg(WCApiTestCases): """ Tests for New wp-json/wc/v2 API with 3-leg """ - def setUp(self): - Auth.force_timestamp = CURRENT_TIMESTAMP - Auth.force_nonce = SHITTY_NONCE - self.api_params = { - 'url':'http://localhost:18080/wptest/', - 'api':'wp-json', - 'version':'wc/v2', - 'consumer_key':'ck_e1dd4a9c85f49b9685f7964a154eecb29af39d5a', - 'consumer_secret':'cs_8ef3e5d21f8a0c28cd7bc4643e92111a0326b6b1', - 'callback':'http://127.0.0.1/oauth1_callback', - 'oauth1a_3leg': True, - 'wp_user': 'wptest', - 'wp_pass':'gZ*gZk#v0t5$j#NQ@9' - } + oauth1a_3leg = True - # @debug_on() - def test_api_get_3leg(self): - wcapi = API(**self.api_params) - per_page = 10 - response = wcapi.get('products') - self.assertIn(response.status_code, [200,201]) - response_obj = response.json() - self.assertEqual(len(response_obj), per_page) @unittest.skipIf(platform.uname()[1] != "Derwents-MBP.lan", "should only work on my machine") -class WPAPITestCases(unittest.TestCase): +class WPAPITestCasesBase(unittest.TestCase): def setUp(self): Auth.force_timestamp = CURRENT_TIMESTAMP Auth.force_nonce = SHITTY_NONCE @@ -1006,17 +989,34 @@ def setUp(self): 'wp_user':'wptest', 'wp_pass':'gZ*gZk#v0t5$j#NQ@9', 'oauth1a_3leg':True, - 'creds_store': self.creds_store } - self.wpapi = API(**self.api_params) - self.wpapi.auth.clear_stored_creds() def test_APIGet(self): + self.wpapi = API(**self.api_params) response = self.wpapi.get('users/me') self.assertIn(response.status_code, [200,201]) response_obj = response.json() self.assertEqual(response_obj['name'], self.api_params['wp_user']) +class WPAPITestCasesBasic(WPAPITestCasesBase): + def setUp(self): + super(WPAPITestCasesBasic, self).setUp() + self.api_params.update({ + 'user_auth': True, + 'basic_auth': True, + 'query_string_auth': False, + }) + self.wpapi = API(**self.api_params) + +class WPAPITestCases3leg(WPAPITestCasesBase): + def setUp(self): + super(WPAPITestCases3leg, self).setUp() + self.api_params.update({ + 'creds_store': self.creds_store, + }) + self.wpapi = API(**self.api_params) + self.wpapi.auth.clear_stored_creds() + def test_APIGetWithSimpleQuery(self): response = self.wpapi.get('media?page=2&per_page=2') # print UrlUtils.beautify_response(response) diff --git a/wordpress/api.py b/wordpress/api.py index 7f00e8e..f2161fe 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -142,7 +142,7 @@ def request_post_mortem(self, response=None): str(response.status_code), UrlUtils.beautify_response(response), str(response_headers), - str(request_body) + str(request_body)[:1000] ) if reason: msg += "\nBecause of %s" % reason @@ -150,21 +150,24 @@ def request_post_mortem(self, response=None): msg += "\n%s" % remedy raise UserWarning(msg) - def __request(self, method, endpoint, data): + def __request(self, method, endpoint, data, **kwargs): """ Do requests """ endpoint_url = self.requester.endpoint_url(endpoint) - endpoint_url = self.auth.get_auth_url(endpoint_url, method) + endpoint_url = self.auth.get_auth_url(endpoint_url, method, **kwargs) auth = self.auth.get_auth() - if data is not None: + content_type = kwargs.get('headers', {}).get('content-type', 'application/json') + + if data is not None and content_type.startswith('application/json'): data = jsonencode(data, ensure_ascii=False).encode('utf-8') response = self.requester.request( method=method, url=endpoint_url, auth=auth, - data=data + data=data, + **kwargs ) if response.status_code not in [200, 201, 202]: @@ -174,22 +177,22 @@ def __request(self, method, endpoint, data): # TODO add kwargs option for headers - def get(self, endpoint): + def get(self, endpoint, **kwargs): """ Get requests """ - return self.__request("GET", endpoint, None) + return self.__request("GET", endpoint, None, **kwargs) - def post(self, endpoint, data): + def post(self, endpoint, data, **kwargs): """ POST requests """ - return self.__request("POST", endpoint, data) + return self.__request("POST", endpoint, data, **kwargs) - def put(self, endpoint, data): + def put(self, endpoint, data, **kwargs): """ PUT requests """ - return self.__request("PUT", endpoint, data) + return self.__request("PUT", endpoint, data, **kwargs) - def delete(self, endpoint): + def delete(self, endpoint, **kwargs): """ DELETE requests """ - return self.__request("DELETE", endpoint, None) + return self.__request("DELETE", endpoint, None, **kwargs) - def options(self, endpoint): + def options(self, endpoint, **kwargs): """ OPTIONS requests """ - return self.__request("OPTIONS", endpoint, None) + return self.__request("OPTIONS", endpoint, None, **kwargs) diff --git a/wordpress/auth.py b/wordpress/auth.py index e0e5358..3c6df1b 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -62,15 +62,19 @@ def get_auth_url(self, endpoint_url, method): def get_auth(self): """ Returns the auth parameter used in requests """ - return HTTPBasicAuth(self.consumer_key, self.consumer_secret) + pass class BasicAuth(Auth): + """ Does not perform any signing, just logs in with oauth creds """ def __init__(self, requester, consumer_key, consumer_secret, **kwargs): super(BasicAuth, self).__init__(requester, **kwargs) self.consumer_key = consumer_key self.consumer_secret = consumer_secret + self.user_auth = kwargs.pop('user_auth', None) + self.wp_user = kwargs.pop('wp_user', None) + self.wp_pass = kwargs.pop('wp_pass', None) - def get_auth_url(self, endpoint_url, method): + def get_auth_url(self, endpoint_url, method, **kwargs): if self.query_string_auth: endpoint_params = UrlUtils.get_query_dict_singular(endpoint_url) endpoint_params.update({ @@ -84,11 +88,14 @@ def get_auth_url(self, endpoint_url, method): return endpoint_url def get_auth(self): + if self.user_auth: + return HTTPBasicAuth(self.wp_user, self.wp_pass) if not self.query_string_auth: return HTTPBasicAuth(self.consumer_key, self.consumer_secret) class OAuth(Auth): + """ Signs string with oauth consumer_key and consumer_secret """ oauth_version = '1.0' force_nonce = None force_timestamp = None @@ -118,7 +125,7 @@ def get_sign_key(self, consumer_secret, token_secret=None): key = "%s&%s" % (consumer_secret, token_secret) return key - def add_params_sign(self, method, url, params, sign_key=None): + def add_params_sign(self, method, url, params, sign_key=None, **kwargs): """ Adds the params to a given url, signs the url with sign_key if provided, otherwise generates sign_key automatically and returns a signed url """ if isinstance(params, dict): @@ -131,6 +138,10 @@ def add_params_sign(self, method, url, params, sign_key=None): # for key, value in parse_qsl(urlparse_result.query): # params += [(key, value)] + # headers = kwargs.get('headers', {}) + # if headers: + # params += headers.items() + params = UrlUtils.unique_params(params) params = UrlUtils.sorted_params(params) @@ -160,11 +171,11 @@ def get_params(self): ("oauth_timestamp", self.generate_timestamp()), ] - def get_auth_url(self, endpoint_url, method): + def get_auth_url(self, endpoint_url, method, **kwargs): """ Returns the URL with added Auth params """ params = self.get_params() - return self.add_params_sign(method, endpoint_url, params) + return self.add_params_sign(method, endpoint_url, params, **kwargs) @classmethod def get_signature_base_string(cls, method, params, url): diff --git a/wordpress/helpers.py b/wordpress/helpers.py index 0d4e0c7..c9edfb8 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -50,6 +50,41 @@ class SeqUtils(object): def filter_true(cls, seq): return [item for item in seq if item] + + @classmethod + def filter_unique_true(cls, list_a): + response = [] + for i in list_a: + if i and i not in response: + response.append(i) + return response + + @classmethod + def combine_two_ordered_dicts(cls, dict_a, dict_b): + """ + Combine OrderedDict a with b by starting with A and overwriting with items from b. + Attempt to preserve order + """ + if not dict_a: + return dict_b if dict_b else OrderedDict() + if not dict_b: + return dict_a + response = OrderedDict(dict_a.items()) + for key, value in dict_b.items(): + response[key] = value + return response + + @classmethod + def combine_ordered_dicts(cls, *args): + """ + Combine all dict arguments overwriting former with items from latter. + Attempt to preserve order + """ + response = OrderedDict() + for arg in args: + response = cls.combine_two_ordered_dicts(response, arg) + return response + class UrlUtils(object): reg_netloc = r'(?P[^:]+)(:(?P\d+))?' diff --git a/wordpress/transport.py b/wordpress/transport.py index b56a62e..025c5b5 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -75,6 +75,10 @@ def request(self, method, url, auth=None, params=None, data=None, **kwargs): } if data is not None: headers["content-type"] = "application/json;charset=utf-8" + headers = SeqUtils.combine_ordered_dicts( + headers, + kwargs.get('headers', {}) + ) request_kwargs = dict( method=method, @@ -90,7 +94,9 @@ def request(self, method, url, auth=None, params=None, data=None, **kwargs): request_kwargs['params'] = params if data is not None: request_kwargs['data'] = data - self.logger.debug("request_kwargs:\n%s" % pformat(request_kwargs)) + self.logger.debug("request_kwargs:\n%s" % pformat([ + (key, repr(value)[:1000]) for key, value in request_kwargs.items() + ])) response = self.session.request( **request_kwargs ) From f82f51495e701758448261eeef18116f2e53145f Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 2 Nov 2017 09:41:50 +1100 Subject: [PATCH 048/129] version number change --- wordpress/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress/__init__.py b/wordpress/__init__.py index d56964b..e6bb1ac 100644 --- a/wordpress/__init__.py +++ b/wordpress/__init__.py @@ -10,7 +10,7 @@ """ __title__ = "wordpress" -__version__ = "1.2.3" +__version__ = "1.2.4" __author__ = "Claudio Sanches @ WooThemes, forked by Derwent" __license__ = "MIT" From 1b20bd1982613b0d4fb1d075e22e7c80bb947454 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 3 Nov 2017 14:47:49 +1100 Subject: [PATCH 049/129] ensure wp-api-v1 compat --- tests.py | 16 ++++++++++++++++ wordpress/transport.py | 29 ++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/tests.py b/tests.py index 5d0703b..3bfe7b4 100644 --- a/tests.py +++ b/tests.py @@ -1008,6 +1008,22 @@ def setUp(self): }) self.wpapi = API(**self.api_params) +class WPAPITestCasesBasicV1(WPAPITestCasesBase): + def setUp(self): + super(WPAPITestCasesBasicV1, self).setUp() + self.api_params.update({ + 'user_auth': True, + 'basic_auth': True, + 'query_string_auth': False, + 'version': 'wp/v1' + }) + self.wpapi = API(**self.api_params) + + def test_get_endpoint_url(self): + endpoint_url = self.wpapi.requester.endpoint_url('') + print endpoint_url + + class WPAPITestCases3leg(WPAPITestCasesBase): def setUp(self): super(WPAPITestCases3leg, self).setUp() diff --git a/wordpress/transport.py b/wordpress/transport.py index 025c5b5..6fb75ea 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -40,18 +40,23 @@ def is_ssl(self): @property def api_url(self): - return UrlUtils.join_components([ + components = [ self.url, self.api - ]) + ] + return UrlUtils.join_components(components) @property def api_ver_url(self): - return UrlUtils.join_components([ + components = [ self.url, self.api, - self.api_version - ]) + ] + if self.api_version != 'wp/v1': + components += [ + self.api_version + ] + return UrlUtils.join_components(components) @property def api_ver_url_no_port(self): @@ -61,12 +66,18 @@ def endpoint_url(self, endpoint): endpoint = StrUtils.decapitate(endpoint, self.api_ver_url) endpoint = StrUtils.decapitate(endpoint, self.api_ver_url_no_port) endpoint = StrUtils.decapitate(endpoint, '/') - return UrlUtils.join_components([ + components = [ self.url, - self.api, - self.api_version, + self.api + ] + if self.api_version != 'wp/v1': + components += [ + self.api_version + ] + components += [ endpoint - ]) + ] + return UrlUtils.join_components(components) def request(self, method, url, auth=None, params=None, data=None, **kwargs): headers = { From d7f26b0c250b1dc2c8808ed20cde0e0cb3b0fc02 Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 7 Dec 2017 16:06:23 +1100 Subject: [PATCH 050/129] fix unicode decode error --- tests.py | 8 ++++++++ wordpress/api.py | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests.py b/tests.py index 3bfe7b4..1dfcb62 100644 --- a/tests.py +++ b/tests.py @@ -1020,9 +1020,17 @@ def setUp(self): self.wpapi = API(**self.api_params) def test_get_endpoint_url(self): + self.api_params.update({ + 'version': '' + }) + self.wpapi = API(**self.api_params) endpoint_url = self.wpapi.requester.endpoint_url('') print endpoint_url + def test_APIGetWithSimpleQuery(self): + response = self.wpapi.get('posts') + self.assertIn(response.status_code, [200,201]) + class WPAPITestCases3leg(WPAPITestCasesBase): def setUp(self): diff --git a/wordpress/api.py b/wordpress/api.py index f2161fe..8b48f09 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -101,8 +101,8 @@ def request_post_mortem(self, response=None): request_body = response.request.body if 'code' in response_json or 'message' in response_json: - reason = " - ".join([ - str(response_json.get(key)) for key in ['code', 'message', 'data'] \ + reason = u" - ".join([ + unicode(response_json.get(key)) for key in ['code', 'message', 'data'] \ if key in response_json ]) From d08419b7d33233c654f39679b13a66ebb06190af Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 7 Dec 2017 16:20:49 +1100 Subject: [PATCH 051/129] deprecate V1 tests fix auth error message when oauth1a plugin not installed --- tests.py | 46 +++++++++++++++++++++++----------------------- wordpress/auth.py | 17 +++++++++++++---- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/tests.py b/tests.py index 1dfcb62..9f018f8 100644 --- a/tests.py +++ b/tests.py @@ -972,7 +972,6 @@ class WCApiTestCases3Leg(WCApiTestCases): """ Tests for New wp-json/wc/v2 API with 3-leg """ oauth1a_3leg = True - @unittest.skipIf(platform.uname()[1] != "Derwents-MBP.lan", "should only work on my machine") class WPAPITestCasesBase(unittest.TestCase): def setUp(self): @@ -991,6 +990,7 @@ def setUp(self): 'oauth1a_3leg':True, } + # @debug_on() def test_APIGet(self): self.wpapi = API(**self.api_params) response = self.wpapi.get('users/me') @@ -1008,28 +1008,28 @@ def setUp(self): }) self.wpapi = API(**self.api_params) -class WPAPITestCasesBasicV1(WPAPITestCasesBase): - def setUp(self): - super(WPAPITestCasesBasicV1, self).setUp() - self.api_params.update({ - 'user_auth': True, - 'basic_auth': True, - 'query_string_auth': False, - 'version': 'wp/v1' - }) - self.wpapi = API(**self.api_params) - - def test_get_endpoint_url(self): - self.api_params.update({ - 'version': '' - }) - self.wpapi = API(**self.api_params) - endpoint_url = self.wpapi.requester.endpoint_url('') - print endpoint_url - - def test_APIGetWithSimpleQuery(self): - response = self.wpapi.get('posts') - self.assertIn(response.status_code, [200,201]) +# class WPAPITestCasesBasicV1(WPAPITestCasesBase): +# def setUp(self): +# super(WPAPITestCasesBasicV1, self).setUp() +# self.api_params.update({ +# 'user_auth': True, +# 'basic_auth': True, +# 'query_string_auth': False, +# 'version': 'wp/v1' +# }) +# self.wpapi = API(**self.api_params) +# +# def test_get_endpoint_url(self): +# self.api_params.update({ +# 'version': '' +# }) +# self.wpapi = API(**self.api_params) +# endpoint_url = self.wpapi.requester.endpoint_url('') +# print endpoint_url +# +# def test_APIGetWithSimpleQuery(self): +# response = self.wpapi.get('posts') +# self.assertIn(response.status_code, [200,201]) class WPAPITestCases3leg(WPAPITestCasesBase): diff --git a/wordpress/auth.py b/wordpress/auth.py index 3c6df1b..d254a07 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -315,16 +315,25 @@ def discover_auth(self): response = self.requester.request('GET', discovery_url) response_json = response.json() - if not 'authentication' in response_json: + has_authentication_resources = True + + if 'authentication' in response_json: + authentication = response_json['authentication'] + if not isinstance(authentication, dict): + has_authentication_resources = False + else: + has_authentication_resources = False + + if not has_authentication_resources: raise UserWarning( ( "Resopnse does not include location of authentication resources.\n" - "Resopnse: %s\n" + "Resopnse: %s\n%s\n" "Please check you have configured the Wordpress OAuth1 plugin correctly." - ) % (response) + ) % (response, response.text[:500]) ) - self._authentication = response_json['authentication'] + self._authentication = authentication return self._authentication From 355b264cc18b312200ffd3987faa22fd373a6600 Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 7 Dec 2017 19:01:16 +1100 Subject: [PATCH 052/129] increment version, update readme --- README.rst | 4 ++++ wordpress/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 77531e3..423c825 100644 --- a/README.rst +++ b/README.rst @@ -263,6 +263,10 @@ Example of returned data: Changelog --------- +1.2.5 - 2017/12/07 +~~~~~~~~~~~~~~~~~~ +- Better UTF-8 support + 1.2.4 - 2017/10/01 ~~~~~~~~~~~~~~~~~~ - Support for image upload diff --git a/wordpress/__init__.py b/wordpress/__init__.py index e6bb1ac..38f1029 100644 --- a/wordpress/__init__.py +++ b/wordpress/__init__.py @@ -10,7 +10,7 @@ """ __title__ = "wordpress" -__version__ = "1.2.4" +__version__ = "1.2.5" __author__ = "Claudio Sanches @ WooThemes, forked by Derwent" __license__ = "MIT" From f98ef85f67ef5b98916ec5fff9b9661ab86d6581 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 8 Dec 2017 11:17:51 +1100 Subject: [PATCH 053/129] update docco add note about deleting --- README.rst | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 423c825..cdf8c5e 100644 --- a/README.rst +++ b/README.rst @@ -4,13 +4,14 @@ Wordpress API - Python Client A Python wrapper for the Wordpress and WooCommerce REST APIs with oAuth1a 3leg support. Supports the Wordpress REST API v1-2, WooCommerce REST API v1-3 and WooCommerce WP-API v1-2 (with automatic OAuth3a handling). -Forked from the excellent Woocommerce API written by Claudio Sanches and modified to work with Wordpress: https://github.com/woocommerce/wc-api-python +Forked from the excellent WooCommerce API written by Claudio Sanches and modified to work with Wordpress: https://github.com/woocommerce/wc-api-python I created this fork because I prefer the way that the wc-api-python client interfaces with the Wordpress API compared to the existing python client, https://pypi.python.org/pypi/wordpress_json which does not support OAuth authentication, only Basic Authentication (very unsecure) -Any suggestions about how this repository could be improved are welcome :) +Any comments about how you're using the API and suggestions about how this repository could be improved are welcome :). +You can find my contact info in my GitHub profile. Roadmap ------- @@ -259,6 +260,18 @@ Example of returned data: >>> r.json() {u'posts': [{u'sold_individually': False,... // Dictionary data +A note on DELETE requests. +===== + +The extra keyword arguments passed to the function of a `__request` call (such as `.delete()`) to a `wordpress.API` object are used to modify a `Requests.request` call, this is to allow you to specify custom parameters to modify how the request is made such as `headers`. At the moment it only passes the `headers` parameter to requests, but if I see a use case for it, I can forward more of the parameters to `Requests`. +The `delete` function doesn’t accept a data object because a HTTP DELETE request does not typically have a payload, and some implementations of a HTTP server would reject a DELETE request that has a payload. +You can still pass api request parameters in the query string of the URL. I would suggest using a library like `urlparse` / `urllib.parse` to modify the query string if you are automatically deleting users. +According the the [documentation](https://developer.wordpress.org/rest-api/reference/users/#delete-a-user) for deleting a user, you need to pass the `force` and `reassign` parameters to the API, which can be done by appending them to the endpoint URL. +.. code-block:: python + >>> response = wpapi.delete(‘/users/?reassign=&force=true’) + >>> response.json() + {“deleted”:true, ... } + Changelog --------- From ac8f6afd6969affc252b3109255d41189521a705 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 18 Dec 2017 16:12:22 +0800 Subject: [PATCH 054/129] extra remedy for woocommerce_rest_cannot_view --- wordpress/api.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/wordpress/api.py b/wordpress/api.py index 8b48f09..bf61ce6 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -114,6 +114,23 @@ def request_post_mortem(self, response=None): remedy = "Try deleting the cached credentials at %s" % \ self.auth.creds_store + elif 'code' == 'woocommerce_rest_cannot_view': + if not self.auth.query_string_auth: + remedy = "Try enabling query_string_auth" + else: + remedy = ( + "This error is super generic and can be caused by just " + "about anything. Here are some things to try: \n" + " - Check that the account which as assigned to your " + "oAuth creds has the correct access level\n" + " - Enable logging and check for error messages in " + "wp-content and wp-content/uploads/wc-logs\n" + " - Check that your query string parameters are valid\n" + " - Make sure your server is not messing with authentication headers\n" + " - Try a different endpoint\n" + " - Try enabling HTTPS and using basic authentication\n" + ) + response_headers = {} if hasattr(response, 'headers'): response_headers = response.headers From 8608b6e308a5ebf06d9a8be5b31fd6b1c2b25f11 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 18 Dec 2017 16:12:30 +0800 Subject: [PATCH 055/129] pass timeout to Requests --- wordpress/transport.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/wordpress/transport.py b/wordpress/transport.py index 6fb75ea..3262070 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -90,13 +90,16 @@ def request(self, method, url, auth=None, params=None, data=None, **kwargs): headers, kwargs.get('headers', {}) ) + timeout = self.timeout + if 'timeout' in kwargs: + timeout = kwargs['timeout'] request_kwargs = dict( method=method, url=url, headers=headers, verify=self.verify_ssl, - timeout=self.timeout, + timeout=timeout, ) request_kwargs.update(kwargs) if auth is not None: From 3bcd09bf6f77dd52fe2d3db5a29751d9a9194fc0 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 29 Jan 2018 18:53:01 +1100 Subject: [PATCH 056/129] Better Python 3 compatibility Credit to @mvartanyan for a lot of these changes. Tested on v3.6.2 and v2.7.13 using pyenv --- .python-version | 1 + requirements-test.txt | 1 + requirements.txt | 1 + setup.py | 3 ++- tests.py | 27 +++++++++++++++------------ wordpress/auth.py | 16 ++++++++++------ wordpress/helpers.py | 3 ++- 7 files changed, 32 insertions(+), 20 deletions(-) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..ecc17b8 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +2.7.13 diff --git a/requirements-test.txt b/requirements-test.txt index 5f4dc7e..34cbeb8 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,3 +1,4 @@ -r requirements.txt httmock==1.2.3 nose==1.3.7 +six diff --git a/requirements.txt b/requirements.txt index 2b3bfb3..95f642f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ requests==2.7.0 ordereddict==1.1 bs4 +six diff --git a/setup.py b/setup.py index d9014ab..9ad7790 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,8 @@ ], tests_require=[ 'httmock', - 'pytest' + 'pytest', + 'six' ], classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests.py b/tests.py index 9f018f8..29afd71 100644 --- a/tests.py +++ b/tests.py @@ -115,7 +115,7 @@ def test_with_timeout(self): def woo_test_mock(*args, **kwargs): """ URL Mock """ return {'status_code': 200, - 'content': 'OK'} + 'content': b'OK'} with HTTMock(woo_test_mock): # call requests @@ -128,7 +128,7 @@ def test_get(self): def woo_test_mock(*args, **kwargs): """ URL Mock """ return {'status_code': 200, - 'content': 'OK'} + 'content': b'OK'} with HTTMock(woo_test_mock): # call requests @@ -141,7 +141,7 @@ def test_post(self): def woo_test_mock(*args, **kwargs): """ URL Mock """ return {'status_code': 201, - 'content': 'OK'} + 'content': b'OK'} with HTTMock(woo_test_mock): # call requests @@ -154,7 +154,7 @@ def test_put(self): def woo_test_mock(*args, **kwargs): """ URL Mock """ return {'status_code': 200, - 'content': 'OK'} + 'content': b'OK'} with HTTMock(woo_test_mock): # call requests @@ -167,7 +167,7 @@ def test_delete(self): def woo_test_mock(*args, **kwargs): """ URL Mock """ return {'status_code': 200, - 'content': 'OK'} + 'content': b'OK'} with HTTMock(woo_test_mock): # call requests @@ -364,7 +364,7 @@ def test_request(self): def woo_test_mock(*args, **kwargs): """ URL Mock """ return {'status_code': 200, - 'content': 'OK'} + 'content': b'OK'} with HTTMock(woo_test_mock): # call requests @@ -474,7 +474,7 @@ def setUp(self): ('oauth_nonce', self.rfc1_request_nonce), ('oauth_callback', self.rfc1_callback), ] - self.rfc1_request_signature = '74KNZJeDHnMBp0EMJ9ZHt/XKycU=' + self.rfc1_request_signature = b'74KNZJeDHnMBp0EMJ9ZHt/XKycU=' # # RFC EXAMPLE 3 DATA: https://tools.ietf.org/html/draft-hammer-oauth-10#section-3.4.1 @@ -553,7 +553,7 @@ def setUp(self): self.twitter_signature_base_string = r"POST&https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521" self.twitter_token_secret = 'LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE' self.twitter_signing_key = 'kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw&LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE' - self.twitter_oauth_signature = 'tnnArxj06cWHq44gCs1OSKk/jLY=' + self.twitter_oauth_signature = b'tnnArxj06cWHq44gCs1OSKk/jLY=' self.lexev_consumer_key='your_app_key' self.lexev_consumer_secret='your_app_secret' @@ -584,7 +584,7 @@ def setUp(self): ('oauth_timestamp',self.lexev_request_timestamp), ('oauth_version',self.lexev_version), ] - self.lexev_request_signature=r"iPdHNIu4NGOjuXZ+YCdPWaRwvJY=" + self.lexev_request_signature=b"iPdHNIu4NGOjuXZ+YCdPWaRwvJY=" self.lexev_resource_url='https://api.bitbucket.org/1.0/repositories/st4lk/django-articles-transmeta/branches' # def test_get_sign(self): @@ -675,7 +675,10 @@ def test_generate_oauth_signature(self): self.rfc1_request_target_url, '%s&' % self.rfc1_consumer_secret ) - self.assertEqual(rfc1_request_signature, self.rfc1_request_signature) + self.assertEqual( + str(rfc1_request_signature), + str(self.rfc1_request_signature) + ) # TEST WITH RFC EXAMPLE 3 DATA @@ -735,7 +738,7 @@ def woo_api_mock(*args, **kwargs): """ URL Mock """ return { 'status_code': 200, - 'content': """ + 'content': b""" { "name": "Wordpress", "description": "Just another WordPress site", @@ -763,7 +766,7 @@ def woo_authentication_mock(*args, **kwargs): """ URL Mock """ return { 'status_code':200, - 'content':"""oauth_token=XXXXXXXXXXXX&oauth_token_secret=YYYYYYYYYYYY""" + 'content': b"""oauth_token=XXXXXXXXXXXX&oauth_token_secret=YYYYYYYYYYYY""" } def test_get_sign_key(self): diff --git a/wordpress/auth.py b/wordpress/auth.py index d254a07..af4b7bd 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -113,7 +113,7 @@ def __init__(self, requester, consumer_key, consumer_secret, **kwargs): self.force_nonce = kwargs.pop('force_nonce', None) def get_sign_key(self, consumer_secret, token_secret=None): - "gets consumer_secret and turns it into a string suitable for signing" + "gets consumer_secret and turns it into a bytestring suitable for signing" if not consumer_secret: raise UserWarning("no consumer_secret provided") token_secret = str(token_secret) if token_secret else '' @@ -129,7 +129,7 @@ def add_params_sign(self, method, url, params, sign_key=None, **kwargs): """ Adds the params to a given url, signs the url with sign_key if provided, otherwise generates sign_key automatically and returns a signed url """ if isinstance(params, dict): - params = params.items() + params = list(params.items()) urlparse_result = urlparse(url) @@ -209,7 +209,11 @@ def generate_oauth_signature(self, method, params, url, key=None): # print "\nstring_to_sign: %s" % repr(string_to_sign) # print "\nkey: %s" % repr(key) - sig = HMAC(key, string_to_sign, hmac_mod) + sig = HMAC( + bytes(key.encode('utf-8')), + bytes(string_to_sign.encode('utf-8')), + hmac_mod + ) sig_b64 = binascii.b2a_base64(sig.digest())[:-1] # print "\nsig_b64: %s" % sig_b64 return sig_b64 @@ -469,7 +473,7 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): } try: login_form_action, login_form_data = self.get_form_info(login_form_response, 'loginform') - except AssertionError, exc: + except AssertionError as exc: self.parse_login_form_error( login_form_response, exc, **login_form_params ) @@ -490,7 +494,7 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): confirmation_response = authorize_session.post(login_form_action, data=login_form_data, allow_redirects=True) try: authorize_form_action, authorize_form_data = self.get_form_info(confirmation_response, 'oauth1_authorize_form') - except AssertionError, exc: + except AssertionError as exc: self.parse_login_form_error( confirmation_response, exc, **login_form_params ) @@ -542,7 +546,7 @@ def store_access_creds(self): creds['access_token_secret'] = self.access_token_secret if creds: with open(self.creds_store, 'w+') as creds_store_file: - json.dump(creds, creds_store_file, ensure_ascii=False, encoding='utf-8') + json.dump(creds, creds_store_file, ensure_ascii=False) def retrieve_access_creds(self): """ retrieve the access_token and access_token_secret stored locally. """ diff --git a/wordpress/helpers.py b/wordpress/helpers.py index c9edfb8..05227d2 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -11,7 +11,7 @@ import posixpath try: - from urllib.parse import urlencode, quote, unquote, parse_qsl, urlparse, urlunparse + from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse from urllib.parse import ParseResult as URLParseResult except ImportError: from urllib import urlencode, quote, unquote @@ -19,6 +19,7 @@ from urlparse import ParseResult as URLParseResult from collections import OrderedDict +from six.moves import reduce from bs4 import BeautifulSoup From 5a88f55f8070b6dcec3a13975ea399942206fe73 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 29 Jan 2018 19:00:01 +1100 Subject: [PATCH 057/129] increment version 1.2.6 --- wordpress/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress/__init__.py b/wordpress/__init__.py index 38f1029..abd7727 100644 --- a/wordpress/__init__.py +++ b/wordpress/__init__.py @@ -10,7 +10,7 @@ """ __title__ = "wordpress" -__version__ = "1.2.5" +__version__ = "1.2.6" __author__ = "Claudio Sanches @ WooThemes, forked by Derwent" __license__ = "MIT" From 7d85e70f2d4897740084fe304d2af1b33a107227 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 29 Jan 2018 19:02:20 +1100 Subject: [PATCH 058/129] update changelog 1.2.6 --- README.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.rst b/README.rst index cdf8c5e..f6a6fe1 100644 --- a/README.rst +++ b/README.rst @@ -276,6 +276,11 @@ According the the [documentation](https://developer.wordpress.org/rest-api/refer Changelog --------- +1.2.6 - 2018/01/29 +~~~~~~~~~~~~~~~~~~ +- Better Python3 support +- Tested on Python v3.6.2 and v2.7.13 + 1.2.5 - 2017/12/07 ~~~~~~~~~~~~~~~~~~ - Better UTF-8 support From 512f88dcf9d7be3c14c61926dba6f94c6c6d7de4 Mon Sep 17 00:00:00 2001 From: derwentx Date: Wed, 9 May 2018 08:04:21 +1000 Subject: [PATCH 059/129] don't crash if response is only -1 --- wordpress/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress/api.py b/wordpress/api.py index bf61ce6..94fcfd6 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -100,7 +100,7 @@ def request_post_mortem(self, response=None): if hasattr(response.request, 'body'): request_body = response.request.body - if 'code' in response_json or 'message' in response_json: + if isinstance(response_json, dict) and ('code' in response_json or 'message' in response_json): reason = u" - ".join([ unicode(response_json.get(key)) for key in ['code', 'message', 'data'] \ if key in response_json From f83ec88f2966d06cee0250480f4962ac4564d84e Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 18 Jun 2018 11:05:47 +1000 Subject: [PATCH 060/129] fixed encoding error on windows python, increment version thanks to Joseph Lawrie --- setup.py | 4 +++- wordpress/__init__.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 9ad7790..9124a68 100644 --- a/setup.py +++ b/setup.py @@ -4,6 +4,8 @@ import os import re +from io import open + from setuptools import setup # Get version from __init__.py file @@ -15,7 +17,7 @@ raise RuntimeError("Cannot find version information") # Get long description -README = open(os.path.join(os.path.dirname(__file__), "README.rst")).read() +README = open(os.path.join(os.path.dirname(__file__), "README.rst"), encoding="utf8").read() # allow setup.py to be run from any path os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) diff --git a/wordpress/__init__.py b/wordpress/__init__.py index abd7727..0bfb350 100644 --- a/wordpress/__init__.py +++ b/wordpress/__init__.py @@ -10,7 +10,7 @@ """ __title__ = "wordpress" -__version__ = "1.2.6" +__version__ = "1.2.7" __author__ = "Claudio Sanches @ WooThemes, forked by Derwent" __license__ = "MIT" From f72fb9ecf35b30a9818e0e5d23eb1ab9c90407b4 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 18 Jun 2018 11:15:54 +1000 Subject: [PATCH 061/129] ignore pypirc --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 50d2de3..b54cd81 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ run3.py .eggs/* .cache/v/cache/lastfailed pylint_report.txt +.pypirc From 6379de83e0ef7e9a483fc8234731eb49f128b7dd Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 18 Jun 2018 11:32:27 +1000 Subject: [PATCH 062/129] update readme --- README.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.rst b/README.rst index f6a6fe1..1a56ab4 100644 --- a/README.rst +++ b/README.rst @@ -67,6 +67,19 @@ If you have installed from source, then you can test with unittest: pip install -r requirements-test.txt python -m unittest -v tests +Publishing +---------- + +Note to self because I keep forgetting how to use Twine >_< + +.. code-block:: bash + + python setup.py sdist bdist_wheel + # Check that you've updated changelog + twine upload dist/wordpress-api-$(python setup.py --version) -r pypitest + twine upload dist/wordpress-api-$(python setup.py --version) -r pypi + + Getting started --------------- @@ -276,6 +289,11 @@ According the the [documentation](https://developer.wordpress.org/rest-api/refer Changelog --------- +1.2.7 - 2018/06/18 +~~~~~~~~~~~~~~~~~~ +- Don't crash on "-1" response from API. +- Fix windows encoding error + 1.2.6 - 2018/01/29 ~~~~~~~~~~~~~~~~~~ - Better Python3 support From e1f31027091e5d00fa4388523eaa0d8f8c42504a Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 10 Aug 2018 13:28:51 +1000 Subject: [PATCH 063/129] better beautification of response takes into account content_type --- wordpress/helpers.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/wordpress/helpers.py b/wordpress/helpers.py index 05227d2..3fef1d0 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -208,7 +208,15 @@ def get_value_like_as_php(val): @staticmethod def beautify_response(response): """ Returns a beautified response in the default locale """ - return BeautifulSoup(response.text, 'lxml').prettify().encode(errors='backslashreplace') + content_type = 'html' + try: + content_type = getattr(response, 'headers', {}).get('Content-Type', content_type) + except: + pass + if 'html' in content_type.lower(): + return BeautifulSoup(response.text, 'lxml').prettify().encode(errors='backslashreplace') + else: + return response.text @classmethod def remove_port(cls, url): From 6ce9fbe5aebbdfbacf01d1d3d62732bf11018e34 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 10 Aug 2018 14:20:19 +1000 Subject: [PATCH 064/129] update tests for travis --- .travis.yml | 3 ++- requirements-test.txt | 1 + tests.py | 12 ++++++------ wordpress/api.py | 18 ++++++++++++++---- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0a4416f..985aec1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,4 +11,5 @@ install: - pip install . - pip install -r requirements-test.txt # command to run tests -script: nosetests +script: + - pytest diff --git a/requirements-test.txt b/requirements-test.txt index 34cbeb8..ced1398 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,3 +2,4 @@ httmock==1.2.3 nose==1.3.7 six +pytest diff --git a/tests.py b/tests.py index 29afd71..59cb13a 100644 --- a/tests.py +++ b/tests.py @@ -858,18 +858,18 @@ def test_retrieve_access_creds(self): 'YYYYYYYYYYYY' ) -@unittest.skipIf(platform.uname()[1] != "Derwents-MBP.lan", "should only work on my machine") +@unittest.skipIf(platform.uname()[1] != "Derwents-MacBook-Pro.local", "should only work on my machine") class WCApiTestCasesBase(unittest.TestCase): """ Base class for WC API Test cases """ def setUp(self): Auth.force_timestamp = CURRENT_TIMESTAMP Auth.force_nonce = SHITTY_NONCE self.api_params = { - 'url':'http://localhost:18080/wptest/', + 'url':'http://derwent-mac.ddns.me:18080/wptest/', 'api':'wc-api', 'version':'v3', - 'consumer_key':'ck_e1dd4a9c85f49b9685f7964a154eecb29af39d5a', - 'consumer_secret':'cs_8ef3e5d21f8a0c28cd7bc4643e92111a0326b6b1', + 'consumer_key':'ck_6f1cf1a528fd94ec3d18a8af91eea94cfc8233bf', + 'consumer_secret':'cs_d9055bdeff59dc992105064f4607de0ffa05ca5e', } class WCApiTestCasesLegacy(WCApiTestCasesBase): @@ -975,14 +975,14 @@ class WCApiTestCases3Leg(WCApiTestCases): """ Tests for New wp-json/wc/v2 API with 3-leg """ oauth1a_3leg = True -@unittest.skipIf(platform.uname()[1] != "Derwents-MBP.lan", "should only work on my machine") +@unittest.skipIf(platform.uname()[1] != "Derwents-MacBook-Pro.local", "should only work on my machine") class WPAPITestCasesBase(unittest.TestCase): def setUp(self): Auth.force_timestamp = CURRENT_TIMESTAMP Auth.force_nonce = SHITTY_NONCE self.creds_store = '~/wc-api-creds-test.json' self.api_params = { - 'url':'http://localhost:18080/wptest/', + 'url':'http://derwent-mac.ddns.me:18080/wptest/', 'api':'wp-json', 'version':'wp/v2', 'consumer_key':'tYG1tAoqjBEM', diff --git a/wordpress/api.py b/wordpress/api.py index 94fcfd6..1ed68ed 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -100,21 +100,24 @@ def request_post_mortem(self, response=None): if hasattr(response.request, 'body'): request_body = response.request.body + try_hostname_mismatch = False + if isinstance(response_json, dict) and ('code' in response_json or 'message' in response_json): reason = u" - ".join([ unicode(response_json.get(key)) for key in ['code', 'message', 'data'] \ if key in response_json ]) + code = response_json.get('code') - if 'code' == 'rest_user_invalid_email': + if code == 'rest_user_invalid_email': remedy = "Try checking the email %s doesn't already exist" % \ request_body.get('email') - elif 'code' == 'json_oauth1_consumer_mismatch': + elif code == 'json_oauth1_consumer_mismatch': remedy = "Try deleting the cached credentials at %s" % \ self.auth.creds_store - elif 'code' == 'woocommerce_rest_cannot_view': + elif code == 'woocommerce_rest_cannot_view': if not self.auth.query_string_auth: remedy = "Try enabling query_string_auth" else: @@ -131,14 +134,21 @@ def request_post_mortem(self, response=None): " - Try enabling HTTPS and using basic authentication\n" ) + elif code == 'woocommerce_rest_authentication_error': + try_hostname_mismatch = True + response_headers = {} if hasattr(response, 'headers'): response_headers = response.headers - if not reason: + if not reason or try_hostname_mismatch: requester_api_url = self.requester.api_url + links = [] if hasattr(response, 'links') and response.links: links = response.links + elif 'Link' in response_headers: + links = [response_headers['Link']] + if links: first_link_key = list(links)[0] header_api_url = links[first_link_key].get('url', '') if header_api_url: From 1a18101be75751b529608ff316e1129aa6825de6 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 10 Aug 2018 14:34:29 +1000 Subject: [PATCH 065/129] update metadata for travis --- .travis.yml | 2 -- requirements.txt | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 985aec1..8f97fd6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,6 @@ language: python python: - - "2.6" - "2.7" - - "3.2" - "3.3" - "3.4" - "nightly" diff --git a/requirements.txt b/requirements.txt index 95f642f..b7f329c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ requests==2.7.0 ordereddict==1.1 bs4 six +requests_oauthlib From 557be23c913dadb9543a7acafa8fb60a14b82478 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 10 Aug 2018 14:39:36 +1000 Subject: [PATCH 066/129] don't test on 3.3, add build status badge --- .travis.yml | 1 - README.rst | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 8f97fd6..a48be57 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: python python: - "2.7" - - "3.3" - "3.4" - "nightly" # command to install dependencies diff --git a/README.rst b/README.rst index 1a56ab4..3029342 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,8 @@ Wordpress API - Python Client =============================== +[![Build Status](https://travis-ci.org/derwentx/wp-api-python.svg?branch=master)](https://travis-ci.org/derwentx/wp-api-python) + A Python wrapper for the Wordpress and WooCommerce REST APIs with oAuth1a 3leg support. Supports the Wordpress REST API v1-2, WooCommerce REST API v1-3 and WooCommerce WP-API v1-2 (with automatic OAuth3a handling). From 45812f4d0300256779b731504770294d1bc3f370 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 10 Aug 2018 16:48:06 +1000 Subject: [PATCH 067/129] added tox config and better error handling --- .gitignore | 6 ++++-- tests.py | 18 +++++++++--------- tox.ini | 14 ++++++++++++++ wordpress/api.py | 9 +++++---- 4 files changed, 32 insertions(+), 15 deletions(-) create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index b54cd81..328ab35 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ run.py run3.py *.orig .eggs/* -.cache/v/cache/lastfailed -pylint_report.txt +.cache/v/cache/lastfailed +pylint_report.txt .pypirc +.tox/* +.pytest_cache/* diff --git a/tests.py b/tests.py index 59cb13a..8aed878 100644 --- a/tests.py +++ b/tests.py @@ -1001,15 +1001,15 @@ def test_APIGet(self): response_obj = response.json() self.assertEqual(response_obj['name'], self.api_params['wp_user']) -class WPAPITestCasesBasic(WPAPITestCasesBase): - def setUp(self): - super(WPAPITestCasesBasic, self).setUp() - self.api_params.update({ - 'user_auth': True, - 'basic_auth': True, - 'query_string_auth': False, - }) - self.wpapi = API(**self.api_params) +# class WPAPITestCasesBasic(WPAPITestCasesBase): +# def setUp(self): +# super(WPAPITestCasesBasic, self).setUp() +# self.api_params.update({ +# 'user_auth': True, +# 'basic_auth': True, +# 'query_string_auth': False, +# }) +# self.wpapi = API(**self.api_params) # class WPAPITestCasesBasicV1(WPAPITestCasesBase): # def setUp(self): diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..555779a --- /dev/null +++ b/tox.ini @@ -0,0 +1,14 @@ +# Tox (https://tox.readthedocs.io/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +envlist = py27, py36 + +[testenv] +deps= + -rrequirements.txt + -rrequirements-test.txt +commands= + pytest diff --git a/wordpress/api.py b/wordpress/api.py index 1ed68ed..94937ca 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -8,6 +8,7 @@ # from requests import request import logging +from six import text_type, u from json import dumps as jsonencode from wordpress.auth import BasicAuth, OAuth, OAuth_3Leg @@ -164,12 +165,12 @@ def request_post_mortem(self, response=None): header_url = StrUtils.eviscerate(header_url, '/') remedy = "try changing url to %s" % header_url - msg = "API call to %s returned \nCODE: %s\nRESPONSE:%s \nHEADERS: %s\nREQ_BODY:%s" % ( + msg = u"API call to %s returned \nCODE: %s\nRESPONSE:%s \nHEADERS: %s\nREQ_BODY:%s" % ( request_url, - str(response.status_code), + text_type(response.status_code), UrlUtils.beautify_response(response), - str(response_headers), - str(request_body)[:1000] + text_type(response_headers), + repr(request_body)[:1000] ) if reason: msg += "\nBecause of %s" % reason From 927124956df4b3502c01016a99b563c5c79a82a0 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 10 Aug 2018 17:00:35 +1000 Subject: [PATCH 068/129] should not have deleted basic test cases --- tests.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests.py b/tests.py index 8aed878..59cb13a 100644 --- a/tests.py +++ b/tests.py @@ -1001,15 +1001,15 @@ def test_APIGet(self): response_obj = response.json() self.assertEqual(response_obj['name'], self.api_params['wp_user']) -# class WPAPITestCasesBasic(WPAPITestCasesBase): -# def setUp(self): -# super(WPAPITestCasesBasic, self).setUp() -# self.api_params.update({ -# 'user_auth': True, -# 'basic_auth': True, -# 'query_string_auth': False, -# }) -# self.wpapi = API(**self.api_params) +class WPAPITestCasesBasic(WPAPITestCasesBase): + def setUp(self): + super(WPAPITestCasesBasic, self).setUp() + self.api_params.update({ + 'user_auth': True, + 'basic_auth': True, + 'query_string_auth': False, + }) + self.wpapi = API(**self.api_params) # class WPAPITestCasesBasicV1(WPAPITestCasesBase): # def setUp(self): From 95905f38c14738cce2100ec4ac3bc7583c22df06 Mon Sep 17 00:00:00 2001 From: RA-Matt Date: Mon, 13 Aug 2018 11:07:40 -0400 Subject: [PATCH 069/129] Add user-agent to login form auth request --- wordpress/auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/wordpress/auth.py b/wordpress/auth.py index af4b7bd..70647ee 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -464,6 +464,7 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): # self.requester.get(authorize_url) authorize_session = requests.Session() + authorize_session.headers.update({'User-Agent': "Wordpress API Client-Python/%s" % __version__}) login_form_response = authorize_session.get(authorize_url) login_form_params = { From b568de0f8aff1c544dfe248101441b890e56b589 Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 14 Aug 2018 20:43:26 +1000 Subject: [PATCH 070/129] added docker compose file and corresponging api keys --- .travis.yml | 4 ++++ docker-compose.yml | 34 ++++++++++++++++++++++++++++++++++ tests.py | 9 ++++----- 3 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 docker-compose.yml diff --git a/.travis.yml b/.travis.yml index a48be57..060b63b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,7 @@ language: python +sudo: required +services: + - docker python: - "2.7" - "3.4" @@ -7,6 +10,7 @@ python: install: - pip install . - pip install -r requirements-test.txt + - docker-compose up # command to run tests script: - pytest diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ec69577 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +version: "2" +services: + db: + image: mariadb + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + MYSQL_DATABASE: "wordpress" + MYSQL_ROOT_PASSWORD: "" + ports: + - "8081:3306" + woocommerce: + image: derwentx/woocommerce-api + environment: + WORDPRESS_DB_HOST: "db" + WORDPRESS_DB_NAME: "wordpress" + WORDPRESS_DB_PASSWORD: "" + WORDPRESS_DB_USER: "root" + WORDPRESS_SITE_URL: "http://localhost:8083/" + WORDPRESS_SITE_TITLE: "API Test" + WORDPRESS_ADMIN_USER: "admin" + WORDPRESS_ADMIN_PASSWORD: "admin" + WORDPRESS_ADMIN_EMAIL: "admin@example.com" + WORDPRESS_DEBUG: 1 + WOOCOMMERCE_TEST_DATA: 1 + WOOCOMMERCE_CONSUMER_KEY: "ck_659f6994ae88fed68897f9977298b0e19947979a" + WOOCOMMERCE_CONSUMER_SECRET: "cs_9421d39290f966172fef64ae18784a2dc7b20976" + links: + - db:mysql + ports: + - "8083:80" + depends_on: + - db + command: apache2-foreground + diff --git a/tests.py b/tests.py index 59cb13a..8c170a6 100644 --- a/tests.py +++ b/tests.py @@ -858,18 +858,17 @@ def test_retrieve_access_creds(self): 'YYYYYYYYYYYY' ) -@unittest.skipIf(platform.uname()[1] != "Derwents-MacBook-Pro.local", "should only work on my machine") class WCApiTestCasesBase(unittest.TestCase): """ Base class for WC API Test cases """ def setUp(self): Auth.force_timestamp = CURRENT_TIMESTAMP Auth.force_nonce = SHITTY_NONCE self.api_params = { - 'url':'http://derwent-mac.ddns.me:18080/wptest/', + 'url':'http://localhost:8083/', 'api':'wc-api', 'version':'v3', - 'consumer_key':'ck_6f1cf1a528fd94ec3d18a8af91eea94cfc8233bf', - 'consumer_secret':'cs_d9055bdeff59dc992105064f4607de0ffa05ca5e', + 'consumer_key':'ck_659f6994ae88fed68897f9977298b0e19947979a', + 'consumer_secret':'cs_9421d39290f966172fef64ae18784a2dc7b20976', } class WCApiTestCasesLegacy(WCApiTestCasesBase): @@ -898,7 +897,7 @@ def test_APIGetWithSimpleQuery(self): response_obj = response.json() self.assertIn('products', response_obj) - self.assertEqual(len(response_obj['products']), 10) + self.assertEqual(len(response_obj['products']), 8) # print "test_ApiGenWithSimpleQuery", response_obj def test_APIGetWithComplexQuery(self): From d9a0891cead295eeecef33a8bc1e229e390ea928 Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 14 Aug 2018 20:48:15 +1000 Subject: [PATCH 071/129] detach docker compose up --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 060b63b..6d7186c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,7 @@ python: install: - pip install . - pip install -r requirements-test.txt - - docker-compose up + - docker-compose up & # command to run tests script: - pytest From 205bf6d8be016c19336057fdc6dbce87d493a4ef Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 14 Aug 2018 21:02:05 +1000 Subject: [PATCH 072/129] i don't get why I have to do this but ok --- .travis.yml | 1 + docker-compose.yml | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/.travis.yml b/.travis.yml index 6d7186c..90e06ff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,7 @@ install: - pip install . - pip install -r requirements-test.txt - docker-compose up & + - sleep 30 # command to run tests script: - pytest diff --git a/docker-compose.yml b/docker-compose.yml index ec69577..3c45209 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,4 +31,11 @@ services: depends_on: - db command: apache2-foreground + # command: sh + # command: ["docker-entrypoint-woocommerce.sh", "bash"] + # command: "apache2" + # command: "sh" + # command: "bash" + # command: ["docker-entrypoint-woocommerce.sh", "apache2-foreground", "&"] + From b17627c094e1c4ffe17ec05b717c23e40111e9fb Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 14 Aug 2018 21:04:32 +1000 Subject: [PATCH 073/129] docker composers up before tests --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 90e06ff..5b1fdbc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,9 +8,9 @@ python: - "nightly" # command to install dependencies install: + - docker-compose up & - pip install . - pip install -r requirements-test.txt - - docker-compose up & - sleep 30 # command to run tests script: From bb6c8ade043ce951794e4ee9b1f9317131e927a6 Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 14 Aug 2018 21:23:09 +1000 Subject: [PATCH 074/129] cool trick to wait until docker is done --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5b1fdbc..90fd0f6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,10 +8,10 @@ python: - "nightly" # command to install dependencies install: - - docker-compose up & + - docker-compose up -d - pip install . - pip install -r requirements-test.txt - - sleep 30 + - docker exec -it wpapipython_woocommerce_1 bash -c 'until [ -f .done ]; do sleep 1; done; echo "complete"' # command to run tests script: - pytest From b1e27544736ecc6070d92f54fc1526fd5a66765b Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 14 Aug 2018 23:26:38 +1000 Subject: [PATCH 075/129] docker is using an older hash --- docker-compose.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3c45209..1687292 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,11 +31,3 @@ services: depends_on: - db command: apache2-foreground - # command: sh - # command: ["docker-entrypoint-woocommerce.sh", "bash"] - # command: "apache2" - # command: "sh" - # command: "bash" - # command: ["docker-entrypoint-woocommerce.sh", "apache2-foreground", "&"] - - From 862372a2ee739905b295821b64d21edda3bbfba1 Mon Sep 17 00:00:00 2001 From: derwentx Date: Wed, 22 Aug 2018 16:02:45 +1000 Subject: [PATCH 076/129] better unicode handling / testing --- tests.py | 90 +++++++++++++++++++++++++++++++++++++++--------- wordpress/api.py | 13 +++++-- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/tests.py b/tests.py index 8c170a6..00f90c4 100644 --- a/tests.py +++ b/tests.py @@ -2,25 +2,26 @@ import functools import logging import pdb +import platform import random import sys import traceback +import six import unittest -import platform from collections import OrderedDict from copy import copy -from time import time from tempfile import mkstemp +from time import time import wordpress +from httmock import HTTMock, all_requests, urlmatch +from six import text_type, u from wordpress import __default_api__, __default_api_version__, auth from wordpress.api import API -from wordpress.auth import OAuth, Auth +from wordpress.auth import Auth, OAuth from wordpress.helpers import SeqUtils, StrUtils, UrlUtils from wordpress.transport import API_Requests_Wrapper -from httmock import HTTMock, all_requests, urlmatch - try: from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse from urllib.parse import ParseResult as URLParseResult @@ -122,6 +123,7 @@ def woo_test_mock(*args, **kwargs): status = api.get("products").status_code self.assertEqual(status, 200) + def test_get(self): """ Test GET requests """ @all_requests @@ -291,8 +293,8 @@ def test_url_get_query_singular(self): ) result = UrlUtils.get_query_singular(self.test_url, 'filter[limit]') self.assertEqual( - str(result), - str(2) + text_type(result), + text_type(2) ) def test_url_set_query_singular(self): @@ -676,8 +678,8 @@ def test_generate_oauth_signature(self): '%s&' % self.rfc1_consumer_secret ) self.assertEqual( - str(rfc1_request_signature), - str(self.rfc1_request_signature) + text_type(rfc1_request_signature), + text_type(self.rfc1_request_signature) ) # TEST WITH RFC EXAMPLE 3 DATA @@ -779,7 +781,6 @@ def test_get_sign_key(self): ) self.assertEqual(type(key), type("")) - def test_auth_discovery(self): with HTTMock(self.woo_api_mock): @@ -921,15 +922,16 @@ def test_APIPutWithSimpleQuery(self): original_title = first_product['title'] product_id = first_product['id'] - nonce = str(random.random()) - response = wcapi.put('products/%s?filter%%5Blimit%%5D=5' % (product_id), {"product":{"title":str(nonce)}}) + nonce = b"%f" % (random.random()) + response = wcapi.put('products/%s?filter%%5Blimit%%5D=5' % (product_id), {"product":{"title":text_type(nonce)}}) request_params = UrlUtils.get_query_dict_singular(response.request.url) response_obj = response.json() - self.assertEqual(response_obj['product']['title'], str(nonce)) - self.assertEqual(request_params['filter[limit]'], str(5)) + self.assertEqual(response_obj['product']['title'], text_type(nonce)) + self.assertEqual(request_params['filter[limit]'], text_type(5)) wcapi.put('products/%s' % (product_id), {"product":{"title":original_title}}) + class WCApiTestCases(WCApiTestCasesBase): oauth1a_3leg = False """ Tests for New wp-json/wc/v2 API """ @@ -960,15 +962,69 @@ def test_APIPutWithSimpleQuery(self): original_title = first_product['name'] product_id = first_product['id'] - nonce = str(random.random()) - response = wcapi.put('products/%s?page=2&per_page=5' % (product_id), {"name":str(nonce)}) + nonce = b"%f" % (random.random()) + response = wcapi.put('products/%s?page=2&per_page=5' % (product_id), {"name":text_type(nonce)}) request_params = UrlUtils.get_query_dict_singular(response.request.url) response_obj = response.json() - self.assertEqual(response_obj['name'], str(nonce)) + self.assertEqual(response_obj['name'], text_type(nonce)) self.assertEqual(request_params['per_page'], '5') wcapi.put('products/%s' % (product_id), {"name":original_title}) + def test_APIPostWithLatin1Query(self): + wcapi = API(**self.api_params) + nonce = u"%f\u00ae" % random.random() + + data = { + "name": nonce.encode('latin-1'), + "type": "simple", + } + + if six.PY2: + response = wcapi.post('products', data) + response_obj = response.json() + product_id = response_obj.get('id') + self.assertEqual(response_obj.get('name'), nonce) + wcapi.delete('products/%s' % product_id) + return + with self.assertRaises(TypeError): + response = wcapi.post('products', data) + + def test_APIPostWithUTF8Query(self): + wcapi = API(**self.api_params) + nonce = u"%f\u00ae" % random.random() + + data = { + "name": nonce.encode('utf8'), + "type": "simple", + } + + if six.PY2: + response = wcapi.post('products', data) + response_obj = response.json() + product_id = response_obj.get('id') + self.assertEqual(response_obj.get('name'), nonce) + wcapi.delete('products/%s' % product_id) + return + with self.assertRaises(TypeError): + response = wcapi.post('products', data) + + + def test_APIPostWithUnicodeQuery(self): + wcapi = API(**self.api_params) + nonce = u"%f\u00ae" % random.random() + + data = { + "name": nonce, + "type": "simple", + } + + response = wcapi.post('products', data) + response_obj = response.json() + product_id = response_obj.get('id') + self.assertEqual(response_obj.get('name'), nonce) + wcapi.delete('products/%s' % product_id) + @unittest.skip("these simply don't work for some reason") class WCApiTestCases3Leg(WCApiTestCases): """ Tests for New wp-json/wc/v2 API with 3-leg """ diff --git a/wordpress/api.py b/wordpress/api.py index 94937ca..37ced7e 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -8,9 +8,9 @@ # from requests import request import logging -from six import text_type, u from json import dumps as jsonencode +from six import text_type, binary_type from wordpress.auth import BasicAuth, OAuth, OAuth_3Leg from wordpress.helpers import StrUtils, UrlUtils from wordpress.transport import API_Requests_Wrapper @@ -188,7 +188,16 @@ def __request(self, method, endpoint, data, **kwargs): content_type = kwargs.get('headers', {}).get('content-type', 'application/json') if data is not None and content_type.startswith('application/json'): - data = jsonencode(data, ensure_ascii=False).encode('utf-8') + data = jsonencode(data, ensure_ascii=False) + # enforce utf-8 encoded binary + if isinstance(data, binary_type): + try: + data = data.decode('utf-8') + except UnicodeDecodeError: + data = data.decode('latin-1') + if isinstance(data, text_type): + data = data.encode('utf-8') + response = self.requester.request( method=method, From 07a118b07bdc57db45f994b25d6d0b8f34258228 Mon Sep 17 00:00:00 2001 From: derwentx Date: Wed, 22 Aug 2018 16:07:22 +1000 Subject: [PATCH 077/129] Drop python 3.4 support in favour of 3.6 --- .gitignore | 1 + .travis.yml | 4 ++-- setup.py | 5 +---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 328ab35..342a348 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ pylint_report.txt .pypirc .tox/* .pytest_cache/* +.python-version diff --git a/.travis.yml b/.travis.yml index 90fd0f6..0e9beaf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,10 @@ language: python sudo: required -services: +services: - docker python: - "2.7" - - "3.4" + - "3.6" - "nightly" # command to install dependencies install: diff --git a/setup.py b/setup.py index 9124a68..002e762 100644 --- a/setup.py +++ b/setup.py @@ -58,11 +58,8 @@ "Natural Language :: English", "License :: OSI Approved :: MIT License", "Programming Language :: Python", - "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.2", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.6", "Topic :: Software Development :: Libraries :: Python Modules" ], keywords='python wordpress woocommerce api development' From a9f242870eb176adc537e8864d34ff11a9aeec21 Mon Sep 17 00:00:00 2001 From: RA-Matt Date: Thu, 23 Aug 2018 22:07:19 -0400 Subject: [PATCH 078/129] added import for version --- wordpress/auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/wordpress/auth.py b/wordpress/auth.py index 70647ee..05f452a 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -16,6 +16,7 @@ from random import randint from time import time from pprint import pformat +from wordpress import __version__ # import webbrowser import requests From c2c935ebd31bcfaaf2242df895b1ae609da51c81 Mon Sep 17 00:00:00 2001 From: Rehmat Alam Date: Mon, 3 Sep 2018 01:09:21 +0500 Subject: [PATCH 079/129] Requires requests_oauthlib --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 002e762..5807c3a 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ platforms=['any'], install_requires=[ "requests", + "requests_oauthlib", "ordereddict", "beautifulsoup4", 'lxml' From c2478856a732cc74176b41e95cad6a63a621261a Mon Sep 17 00:00:00 2001 From: Davide Cazzin Date: Thu, 4 Oct 2018 17:00:59 +0200 Subject: [PATCH 080/129] Fixed Travis CI badge --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 3029342..9388876 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,8 @@ Wordpress API - Python Client =============================== -[![Build Status](https://travis-ci.org/derwentx/wp-api-python.svg?branch=master)](https://travis-ci.org/derwentx/wp-api-python) +.. image:: https://travis-ci.org/derwentx/wp-api-python.svg?branch=master + :target: https://travis-ci.org/derwentx/wp-api-python A Python wrapper for the Wordpress and WooCommerce REST APIs with oAuth1a 3leg support. From 60ae25ceb12f18ba85cb16f884acd3301299b92e Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 13 Oct 2018 10:13:50 +1100 Subject: [PATCH 081/129] clarify media posting in readme --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 3029342..8b4ee38 100644 --- a/README.rst +++ b/README.rst @@ -250,7 +250,8 @@ Upload an image 'content-disposition': 'attachment; filename=%s' % filename, 'content-type': 'image/%s' % extension } - return wcapi.post(self.endpoint_singular, data, headers=headers) + endpoint = "/media" + return wpapi.post(endpoint, data, headers=headers) Response From 3c2aec30e95ba4f508e4a068b6e35c3c8b28932d Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 13 Oct 2018 10:14:12 +1100 Subject: [PATCH 082/129] update local tests for media api --- tests.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tests.py b/tests.py index 00f90c4..e5bcc7c 100644 --- a/tests.py +++ b/tests.py @@ -1037,14 +1037,14 @@ def setUp(self): Auth.force_nonce = SHITTY_NONCE self.creds_store = '~/wc-api-creds-test.json' self.api_params = { - 'url':'http://derwent-mac.ddns.me:18080/wptest/', + 'url':'http://localhost:8083/', 'api':'wp-json', 'version':'wp/v2', 'consumer_key':'tYG1tAoqjBEM', 'consumer_secret':'s91fvylVrqChwzzDbEJHEWyySYtAmlIsqqYdjka1KyVDdAyB', 'callback':'http://127.0.0.1/oauth1_callback', - 'wp_user':'wptest', - 'wp_pass':'gZ*gZk#v0t5$j#NQ@9', + 'wp_user':'admin', + 'wp_pass':'admin', 'oauth1a_3leg':True, } @@ -1056,6 +1056,13 @@ def test_APIGet(self): response_obj = response.json() self.assertEqual(response_obj['name'], self.api_params['wp_user']) + def test_APIGetWithSimpleQuery(self): + response = self.wpapi.get('media?page=2&per_page=2') + self.assertIn(response.status_code, [200,201]) + + response_obj = response.json() + self.assertEqual(len(response_obj), 2) + class WPAPITestCasesBasic(WPAPITestCasesBase): def setUp(self): super(WPAPITestCasesBasic, self).setUp() @@ -1099,15 +1106,6 @@ def setUp(self): self.wpapi = API(**self.api_params) self.wpapi.auth.clear_stored_creds() - def test_APIGetWithSimpleQuery(self): - response = self.wpapi.get('media?page=2&per_page=2') - # print UrlUtils.beautify_response(response) - self.assertIn(response.status_code, [200,201]) - - response_obj = response.json() - self.assertEqual(len(response_obj), 2) - # print "test_ApiGenWithSimpleQuery", response_obj - if __name__ == '__main__': unittest.main() From 0664730c6fee4db5e4e16c92db75c8d7db2c04e4 Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 13 Oct 2018 10:18:42 +1100 Subject: [PATCH 083/129] Fix https://github.com/derwentx/wp-api-python/issues/8 using six.text_type --- wordpress/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wordpress/api.py b/wordpress/api.py index 37ced7e..462838d 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -10,7 +10,7 @@ import logging from json import dumps as jsonencode -from six import text_type, binary_type +from six import binary_type, text_type from wordpress.auth import BasicAuth, OAuth, OAuth_3Leg from wordpress.helpers import StrUtils, UrlUtils from wordpress.transport import API_Requests_Wrapper @@ -105,7 +105,7 @@ def request_post_mortem(self, response=None): if isinstance(response_json, dict) and ('code' in response_json or 'message' in response_json): reason = u" - ".join([ - unicode(response_json.get(key)) for key in ['code', 'message', 'data'] \ + text_type(response_json.get(key)) for key in ['code', 'message', 'data'] \ if key in response_json ]) code = response_json.get('code') From d3a0cf927514cba78432732798de627854c8ec57 Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 13 Oct 2018 10:26:46 +1100 Subject: [PATCH 084/129] delete python-version --- .python-version | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .python-version diff --git a/.python-version b/.python-version deleted file mode 100644 index ecc17b8..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -2.7.13 From 6442c8fd6f3302b860b0e9311dcc503db00aadfb Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 13 Oct 2018 14:22:59 +1100 Subject: [PATCH 085/129] Enable tests for WP API in Travis --- docker-compose.yml | 12 ++++++-- tests.py | 77 +++++++++++++++++----------------------------- 2 files changed, 37 insertions(+), 52 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1687292..5c4624f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ version: "2" -services: +services: db: image: mariadb environment: @@ -7,7 +7,7 @@ services: MYSQL_DATABASE: "wordpress" MYSQL_ROOT_PASSWORD: "" ports: - - "8081:3306" + - "8082:3306" woocommerce: image: derwentx/woocommerce-api environment: @@ -21,10 +21,16 @@ services: WORDPRESS_ADMIN_PASSWORD: "admin" WORDPRESS_ADMIN_EMAIL: "admin@example.com" WORDPRESS_DEBUG: 1 + WORDPRESS_PLUGINS: "https://github.com/WP-API/Basic-Auth/archive/master.zip" + WORDPRESS_API_APPLICATION: "Test" + WORDPRESS_API_DESCRIPTION: "Test Application" + WORDPRESS_API_CALLBACK: "http://127.0.0.1/oauth1_callback" + WORDPRESS_API_KEY: "tYG1tAoqjBEM" + WORDPRESS_API_SECRET: "s91fvylVrqChwzzDbEJHEWyySYtAmlIsqqYdjka1KyVDdAyB" WOOCOMMERCE_TEST_DATA: 1 WOOCOMMERCE_CONSUMER_KEY: "ck_659f6994ae88fed68897f9977298b0e19947979a" WOOCOMMERCE_CONSUMER_SECRET: "cs_9421d39290f966172fef64ae18784a2dc7b20976" - links: + links: - db:mysql ports: - "8083:80" diff --git a/tests.py b/tests.py index e5bcc7c..8a4ef38 100644 --- a/tests.py +++ b/tests.py @@ -1030,23 +1030,23 @@ class WCApiTestCases3Leg(WCApiTestCases): """ Tests for New wp-json/wc/v2 API with 3-leg """ oauth1a_3leg = True -@unittest.skipIf(platform.uname()[1] != "Derwents-MacBook-Pro.local", "should only work on my machine") class WPAPITestCasesBase(unittest.TestCase): + api_params = { + 'url':'http://localhost:8083/', + 'api':'wp-json', + 'version':'wp/v2', + 'consumer_key':'tYG1tAoqjBEM', + 'consumer_secret':'s91fvylVrqChwzzDbEJHEWyySYtAmlIsqqYdjka1KyVDdAyB', + 'callback':'http://127.0.0.1/oauth1_callback', + 'wp_user':'admin', + 'wp_pass':'admin', + 'oauth1a_3leg':True, + } + def setUp(self): Auth.force_timestamp = CURRENT_TIMESTAMP Auth.force_nonce = SHITTY_NONCE - self.creds_store = '~/wc-api-creds-test.json' - self.api_params = { - 'url':'http://localhost:8083/', - 'api':'wp-json', - 'version':'wp/v2', - 'consumer_key':'tYG1tAoqjBEM', - 'consumer_secret':'s91fvylVrqChwzzDbEJHEWyySYtAmlIsqqYdjka1KyVDdAyB', - 'callback':'http://127.0.0.1/oauth1_callback', - 'wp_user':'admin', - 'wp_pass':'admin', - 'oauth1a_3leg':True, - } + self.wpapi = API(**self.api_params) # @debug_on() def test_APIGet(self): @@ -1057,53 +1057,32 @@ def test_APIGet(self): self.assertEqual(response_obj['name'], self.api_params['wp_user']) def test_APIGetWithSimpleQuery(self): - response = self.wpapi.get('media?page=2&per_page=2') + self.wpapi = API(**self.api_params) + response = self.wpapi.get('pages?page=2&per_page=2') self.assertIn(response.status_code, [200,201]) response_obj = response.json() self.assertEqual(len(response_obj), 2) -class WPAPITestCasesBasic(WPAPITestCasesBase): - def setUp(self): - super(WPAPITestCasesBasic, self).setUp() - self.api_params.update({ - 'user_auth': True, - 'basic_auth': True, - 'query_string_auth': False, - }) - self.wpapi = API(**self.api_params) -# class WPAPITestCasesBasicV1(WPAPITestCasesBase): -# def setUp(self): -# super(WPAPITestCasesBasicV1, self).setUp() -# self.api_params.update({ -# 'user_auth': True, -# 'basic_auth': True, -# 'query_string_auth': False, -# 'version': 'wp/v1' -# }) -# self.wpapi = API(**self.api_params) -# -# def test_get_endpoint_url(self): -# self.api_params.update({ -# 'version': '' -# }) -# self.wpapi = API(**self.api_params) -# endpoint_url = self.wpapi.requester.endpoint_url('') -# print endpoint_url -# -# def test_APIGetWithSimpleQuery(self): -# response = self.wpapi.get('posts') -# self.assertIn(response.status_code, [200,201]) +class WPAPITestCasesBasic(WPAPITestCasesBase): + api_params = dict(**WPAPITestCasesBase.api_params) + api_params.update({ + 'user_auth': True, + 'basic_auth': True, + 'query_string_auth': False, + }) class WPAPITestCases3leg(WPAPITestCasesBase): + + api_params = dict(**WPAPITestCasesBase.api_params) + api_params.update({ + 'creds_store': '~/wc-api-creds-test.json', + }) + def setUp(self): super(WPAPITestCases3leg, self).setUp() - self.api_params.update({ - 'creds_store': self.creds_store, - }) - self.wpapi = API(**self.api_params) self.wpapi.auth.clear_stored_creds() From 1f53aca50d2fa3e050f2e16b83fd23d3ff55d3a3 Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 13 Oct 2018 14:35:33 +1100 Subject: [PATCH 086/129] add tests for posting wp data --- tests.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests.py b/tests.py index 8a4ef38..31771e3 100644 --- a/tests.py +++ b/tests.py @@ -1050,20 +1050,35 @@ def setUp(self): # @debug_on() def test_APIGet(self): - self.wpapi = API(**self.api_params) response = self.wpapi.get('users/me') self.assertIn(response.status_code, [200,201]) response_obj = response.json() self.assertEqual(response_obj['name'], self.api_params['wp_user']) def test_APIGetWithSimpleQuery(self): - self.wpapi = API(**self.api_params) response = self.wpapi.get('pages?page=2&per_page=2') self.assertIn(response.status_code, [200,201]) response_obj = response.json() self.assertEqual(len(response_obj), 2) + def test_APIPostData(self): + nonce = u"%f\u00ae" % random.random() + + content = "api test post" + + data = { + "title": nonce, + "content": content, + "excerpt": content + } + + response = self.wpapi.post('posts', data) + response_obj = response.json() + post_id = response_obj.get('id') + self.assertEqual(response_obj.get('title').get('raw'), nonce) + self.wpapi.delete('posts/%s' % post_id) + class WPAPITestCasesBasic(WPAPITestCasesBase): api_params = dict(**WPAPITestCasesBase.api_params) From 0bca9907121bd70418f8dc46f1b2050509bb6089 Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 13 Oct 2018 14:43:47 +1100 Subject: [PATCH 087/129] update requirements, add instructions for testing --- README.rst | 13 +++++++++++-- requirements.txt | 6 +----- setup.py | 3 ++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index c505855..53b3ecf 100644 --- a/README.rst +++ b/README.rst @@ -63,12 +63,21 @@ Download this repo and use setuptools to install the package Testing ------- -If you have installed from source, then you can test with unittest: +Some of the tests make API calls to a dockerized woocommerce container. Don't +worry! It's really simple to set up. You just need to install docker and run + +.. code-block:: bash + + docker-compose up -d + # this just waits until the docker container is set up and exits + docker exec -it wpapipython_woocommerce_1 bash -c 'until [ -f .done ]; do sleep 1; done; echo "complete"' + +Then you can test with: .. code-block:: bash pip install -r requirements-test.txt - python -m unittest -v tests + python setup.py test Publishing ---------- diff --git a/requirements.txt b/requirements.txt index b7f329c..9c558e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1 @@ -requests==2.7.0 -ordereddict==1.1 -bs4 -six -requests_oauthlib +. diff --git a/setup.py b/setup.py index 5807c3a..afb401e 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,8 @@ "requests_oauthlib", "ordereddict", "beautifulsoup4", - 'lxml' + 'lxml', + 'six', ], setup_requires=[ 'pytest-runner', From 9938f27b46ec1acebd9e7b1fa50b99146661ef77 Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 13 Oct 2018 15:14:39 +1100 Subject: [PATCH 088/129] Add all features from https://github.com/derwentx/wp-api-python/pull/2 incl. previously failing test cases --- tests.py | 31 ++++++++++++++++++++++--------- wordpress/api.py | 10 ++-------- wordpress/auth.py | 18 +++++++++--------- wordpress/helpers.py | 16 ++++++++++++++++ wordpress/transport.py | 9 +-------- 5 files changed, 50 insertions(+), 34 deletions(-) diff --git a/tests.py b/tests.py index 31771e3..79d4006 100644 --- a/tests.py +++ b/tests.py @@ -599,19 +599,19 @@ def setUp(self): def test_get_sign_key(self): self.assertEqual( - self.wcapi.auth.get_sign_key(self.consumer_secret), - "%s&" % self.consumer_secret + StrUtils.to_binary(self.wcapi.auth.get_sign_key(self.consumer_secret)), + StrUtils.to_binary("%s&" % self.consumer_secret) ) self.assertEqual( - self.wcapi.auth.get_sign_key(self.twitter_consumer_secret, self.twitter_token_secret), - self.twitter_signing_key + StrUtils.to_binary(self.wcapi.auth.get_sign_key(self.twitter_consumer_secret, self.twitter_token_secret)), + StrUtils.to_binary(self.twitter_signing_key) ) def test_flatten_params(self): self.assertEqual( - UrlUtils.flatten_params(self.twitter_params_raw), - self.twitter_param_string + StrUtils.to_binary(UrlUtils.flatten_params(self.twitter_params_raw)), + StrUtils.to_binary(self.twitter_param_string) ) def test_sorted_params(self): @@ -776,10 +776,9 @@ def test_get_sign_key(self): key = self.api.auth.get_sign_key(self.consumer_secret, oauth_token_secret) self.assertEqual( - key, - "%s&%s" % (self.consumer_secret, oauth_token_secret) + StrUtils.to_binary(key), + StrUtils.to_binary("%s&%s" % (self.consumer_secret, oauth_token_secret)) ) - self.assertEqual(type(key), type("")) def test_auth_discovery(self): @@ -1079,6 +1078,20 @@ def test_APIPostData(self): self.assertEqual(response_obj.get('title').get('raw'), nonce) self.wpapi.delete('posts/%s' % post_id) + def test_APIBadData(self): + """ + No excerpt so should fail to be created. + """ + nonce = u"%f\u00ae" % random.random() + + content = "api test post" + + data = { + } + + with self.assertRaises(UserWarning): + response = self.wpapi.post('posts', data) + class WPAPITestCasesBasic(WPAPITestCasesBase): api_params = dict(**WPAPITestCasesBase.api_params) diff --git a/wordpress/api.py b/wordpress/api.py index 462838d..022b9b7 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -108,7 +108,7 @@ def request_post_mortem(self, response=None): text_type(response_json.get(key)) for key in ['code', 'message', 'data'] \ if key in response_json ]) - code = response_json.get('code') + code = text_type(response_json.get('code')) if code == 'rest_user_invalid_email': remedy = "Try checking the email %s doesn't already exist" % \ @@ -190,13 +190,7 @@ def __request(self, method, endpoint, data, **kwargs): if data is not None and content_type.startswith('application/json'): data = jsonencode(data, ensure_ascii=False) # enforce utf-8 encoded binary - if isinstance(data, binary_type): - try: - data = data.decode('utf-8') - except UnicodeDecodeError: - data = data.decode('latin-1') - if isinstance(data, text_type): - data = data.encode('utf-8') + data = StrUtils.to_binary(data, encoding='utf8') response = self.requester.request( diff --git a/wordpress/auth.py b/wordpress/auth.py index 05f452a..5408094 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -13,18 +13,18 @@ import os from hashlib import sha1, sha256 from hmac import new as HMAC +from pprint import pformat from random import randint from time import time -from pprint import pformat -from wordpress import __version__ # import webbrowser import requests from requests.auth import HTTPBasicAuth -from requests_oauthlib import OAuth1 from bs4 import BeautifulSoup -from wordpress.helpers import UrlUtils +from requests_oauthlib import OAuth1 +from wordpress import __version__ +from .helpers import UrlUtils, StrUtils try: from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse @@ -123,7 +123,7 @@ def get_sign_key(self, consumer_secret, token_secret=None): # special conditions for wc-api v1-2 key = consumer_secret else: - key = "%s&%s" % (consumer_secret, token_secret) + key = StrUtils.to_binary("%s&%s" % (consumer_secret, token_secret)) return key def add_params_sign(self, method, url, params, sign_key=None, **kwargs): @@ -211,8 +211,8 @@ def generate_oauth_signature(self, method, params, url, key=None): # print "\nstring_to_sign: %s" % repr(string_to_sign) # print "\nkey: %s" % repr(key) sig = HMAC( - bytes(key.encode('utf-8')), - bytes(string_to_sign.encode('utf-8')), + StrUtils.to_binary(key), + StrUtils.to_binary(string_to_sign), hmac_mod ) sig_b64 = binascii.b2a_base64(sig.digest())[:-1] @@ -332,7 +332,7 @@ def discover_auth(self): if not has_authentication_resources: raise UserWarning( ( - "Resopnse does not include location of authentication resources.\n" + "Response does not include location of authentication resources.\n" "Resopnse: %s\n%s\n" "Please check you have configured the Wordpress OAuth1 plugin correctly." ) % (response, response.text[:500]) @@ -548,7 +548,7 @@ def store_access_creds(self): creds['access_token_secret'] = self.access_token_secret if creds: with open(self.creds_store, 'w+') as creds_store_file: - json.dump(creds, creds_store_file, ensure_ascii=False) + StrUtils.to_binary(json.dump(creds, creds_store_file, ensure_ascii=False)) def retrieve_access_creds(self): """ retrieve the access_token and access_token_secret stored locally. """ diff --git a/wordpress/helpers.py b/wordpress/helpers.py index 3fef1d0..9803ec9 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -10,6 +10,8 @@ import posixpath +from six import text_type, binary_type + try: from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse from urllib.parse import ParseResult as URLParseResult @@ -45,6 +47,20 @@ def decapitate(cls, *args, **kwargs): def eviscerate(cls, *args, **kwargs): return cls.remove_tail(*args, **kwargs) + @classmethod + def to_binary(cls, string, encoding='utf8', errors='backslashreplace'): + if isinstance(string, binary_type): + try: + string = string.decode('utf8') + except UnicodeDecodeError: + string = string.decode('latin-1') + if not isinstance(string, text_type): + string = text_type(string) + return string.encode(encoding, errors=errors) + + @classmethod + def to_binary_ascii(cls, string): + return cls.to_binary(string, 'ascii') class SeqUtils(object): @classmethod diff --git a/wordpress/transport.py b/wordpress/transport.py index 3262070..6226685 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -90,16 +90,13 @@ def request(self, method, url, auth=None, params=None, data=None, **kwargs): headers, kwargs.get('headers', {}) ) - timeout = self.timeout - if 'timeout' in kwargs: - timeout = kwargs['timeout'] request_kwargs = dict( method=method, url=url, headers=headers, verify=self.verify_ssl, - timeout=timeout, + timeout=kwargs.get('timeout', self.timeout), ) request_kwargs.update(kwargs) if auth is not None: @@ -130,10 +127,6 @@ def request(self, method, url, auth=None, params=None, data=None, **kwargs): response_links = response.links self.logger.debug("response_links:\n%s" % pformat(response_links)) - - - - return response def get(self, *args, **kwargs): From 7aba1420f89520ee1c2b44149881343fd38ec572 Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 13 Oct 2018 15:18:43 +1100 Subject: [PATCH 089/129] gitignore as per https://github.com/derwentx/wp-api-python/pull/3 --- .gitignore | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.gitignore b/.gitignore index 342a348..cf735b3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,21 @@ pylint_report.txt .tox/* .pytest_cache/* .python-version + # Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + # C extensions +*.so + # Distribution / packaging +.Python + # Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ From 0ce4eb62cec9fa13b1780268d2c5b591769244ce Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 13 Oct 2018 15:30:04 +1100 Subject: [PATCH 090/129] merge changes from https://github.com/derwentx/wp-api-python/pull/3 --- .travis.yml | 6 +++++- requirements-test.txt | 4 +++- wordpress/api.py | 8 +++++--- wordpress/auth.py | 7 +++++++ wordpress/transport.py | 5 +++++ 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0e9beaf..0cb8b9b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,7 @@ language: python sudo: required +env: + - CODECOV_TOKEN: "da32b183-0d8b-4dc2-9bf9-e1743a39b2c8" services: - docker python: @@ -14,4 +16,6 @@ install: - docker exec -it wpapipython_woocommerce_1 bash -c 'until [ -f .done ]; do sleep 1; done; echo "complete"' # command to run tests script: - - pytest + - py.test --cov=wordpress tests.py +after_success: + - codecov diff --git a/requirements-test.txt b/requirements-test.txt index ced1398..3fb7e64 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,5 +1,7 @@ -r requirements.txt httmock==1.2.3 -nose==1.3.7 six pytest +pytest-cov==2.5.1 +coverage +codecov diff --git a/wordpress/api.py b/wordpress/api.py index 022b9b7..b7e812a 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -11,7 +11,7 @@ from json import dumps as jsonencode from six import binary_type, text_type -from wordpress.auth import BasicAuth, OAuth, OAuth_3Leg +from wordpress.auth import BasicAuth, OAuth, OAuth_3Leg, NoAuth from wordpress.helpers import StrUtils, UrlUtils from wordpress.transport import API_Requests_Wrapper @@ -35,6 +35,8 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): auth_class = BasicAuth elif kwargs.get('oauth1a_3leg'): auth_class = OAuth_3Leg + elif kwargs.get('no_auth'): + auth_class = NoAuth if kwargs.get('version', '').startswith('wc') and kwargs.get('oauth1a_3leg'): self.logger.warn("WooCommerce JSON Api does not seem to support 3leg") @@ -170,10 +172,10 @@ def request_post_mortem(self, response=None): text_type(response.status_code), UrlUtils.beautify_response(response), text_type(response_headers), - repr(request_body)[:1000] + StrUtils.to_binary(request_body)[:1000] ) if reason: - msg += "\nBecause of %s" % reason + msg += "\nBecause of %s" % StrUtils.to_binary(reason) if remedy: msg += "\n%s" % remedy raise UserWarning(msg) diff --git a/wordpress/auth.py b/wordpress/auth.py index 5408094..d81b7e5 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -94,6 +94,13 @@ def get_auth(self): if not self.query_string_auth: return HTTPBasicAuth(self.consumer_key, self.consumer_secret) +class NoAuth(Auth): + """ + Just a dummy Auth object to allow header based + authorization per request + """ + def get_auth_url(self, endpoint_url, method, **kwargs): + return endpoint_url class OAuth(Auth): """ Signs string with oauth consumer_key and consumer_secret """ diff --git a/wordpress/transport.py b/wordpress/transport.py index 6226685..eee1ce5 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -33,6 +33,7 @@ def __init__(self, url, **kwargs): self.timeout = kwargs.get("timeout", 5) self.verify_ssl = kwargs.get("verify_ssl", True) self.session = Session() + self.headers = kwargs.get("headers", {}) @property def is_ssl(self): @@ -86,6 +87,10 @@ def request(self, method, url, auth=None, params=None, data=None, **kwargs): } if data is not None: headers["content-type"] = "application/json;charset=utf-8" + headers = SeqUtils.combine_ordered_dicts( + headers, + self.headers + ) headers = SeqUtils.combine_ordered_dicts( headers, kwargs.get('headers', {}) From 5136cc9d46b9343c31ff558be210729000bfe995 Mon Sep 17 00:00:00 2001 From: derwentx Date: Sat, 13 Oct 2018 15:38:17 +1100 Subject: [PATCH 091/129] increment version number --- wordpress/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress/__init__.py b/wordpress/__init__.py index 0bfb350..b6a4ff1 100644 --- a/wordpress/__init__.py +++ b/wordpress/__init__.py @@ -10,7 +10,7 @@ """ __title__ = "wordpress" -__version__ = "1.2.7" +__version__ = "1.2.8" __author__ = "Claudio Sanches @ WooThemes, forked by Derwent" __license__ = "MIT" From 19052a6a3cc2590edb3eb831d98c1e63d3a0f883 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 15 Oct 2018 10:33:43 +1100 Subject: [PATCH 092/129] update changelog --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 53b3ecf..af7c883 100644 --- a/README.rst +++ b/README.rst @@ -302,6 +302,12 @@ According the the [documentation](https://developer.wordpress.org/rest-api/refer Changelog --------- +1.2.8 - 2018/10/13 +~~~~~~~~~~~~~~~~~~ +- Much better python3 support +- really good tests +- added NoAuth option for adding custom headers (like JWT) + 1.2.7 - 2018/06/18 ~~~~~~~~~~~~~~~~~~ - Don't crash on "-1" response from API. From b92854d874a6c48b46d69a6d4c8c5cc6d2a26713 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 15 Oct 2018 16:05:27 +1100 Subject: [PATCH 093/129] autopep8 --- setup.py | 6 +- tests.py | 396 ++++++++++++++++++++++------------------- wordpress/api.py | 18 +- wordpress/auth.py | 99 +++++++---- wordpress/helpers.py | 15 +- wordpress/transport.py | 4 +- 6 files changed, 301 insertions(+), 237 deletions(-) diff --git a/setup.py b/setup.py index afb401e..eca3935 100644 --- a/setup.py +++ b/setup.py @@ -11,13 +11,15 @@ # Get version from __init__.py file VERSION = "" with open("wordpress/__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") # Get long description -README = open(os.path.join(os.path.dirname(__file__), "README.rst"), encoding="utf8").read() +README = open(os.path.join(os.path.dirname(__file__), + "README.rst"), encoding="utf8").read() # allow setup.py to be run from any path os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) diff --git a/tests.py b/tests.py index 79d4006..71c7fe9 100644 --- a/tests.py +++ b/tests.py @@ -6,13 +6,13 @@ import random import sys import traceback -import six import unittest from collections import OrderedDict from copy import copy from tempfile import mkstemp from time import time +import six import wordpress from httmock import HTTMock, all_requests, urlmatch from six import text_type, u @@ -23,16 +23,20 @@ from wordpress.transport import API_Requests_Wrapper try: - from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse + from urllib.parse import ( + urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse + ) from urllib.parse import ParseResult as URLParseResult except ImportError: from urllib import urlencode, quote, unquote from urlparse import parse_qs, parse_qsl, urlparse, urlunparse from urlparse import ParseResult as URLParseResult + def debug_on(*exceptions): if not exceptions: exceptions = (AssertionError, ) + def decorator(f): @functools.wraps(f) def wrapper(*args, **kwargs): @@ -49,9 +53,11 @@ def wrapper(*args, **kwargs): return wrapper return decorator + CURRENT_TIMESTAMP = int(time()) SHITTY_NONCE = "" + class WordpressTestCase(unittest.TestCase): """Test case for the client methods.""" @@ -123,7 +129,6 @@ def woo_test_mock(*args, **kwargs): status = api.get("products").status_code self.assertEqual(status, 200) - def test_get(self): """ Test GET requests """ @all_requests @@ -190,15 +195,27 @@ def check_sorted(keys, 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[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]']) + class HelperTestcase(unittest.TestCase): def setUp(self): - self.test_url = "http://ich.local:8888/woocommerce/wc-api/v3/products?filter%5Blimit%5D=2&oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1481601370&page=2" - + self.test_url = ( + "http://ich.local:8888/woocommerce/wc-api/v3/products?" + "filter%5Blimit%5D=2&" + "oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&" + "oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&" + "oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&" + "oauth_signature_method=HMAC-SHA1&" + "oauth_timestamp=1481601370&page=2" + ) def test_url_is_ssl(self): self.assertTrue(UrlUtils.is_ssl("https://woo.test:8888")) @@ -206,7 +223,8 @@ def test_url_is_ssl(self): def test_url_substitute_query(self): self.assertEqual( - UrlUtils.substitute_query("https://woo.test:8888/sdf?param=value", "newparam=newvalue"), + UrlUtils.substitute_query( + "https://woo.test:8888/sdf?param=value", "newparam=newvalue"), "https://woo.test:8888/sdf?newparam=newvalue" ) self.assertEqual( @@ -218,20 +236,28 @@ def test_url_substitute_query(self): "https://woo.test:8888/sdf?param=value", "newparam=newvalue&othernewparam=othernewvalue" ), - "https://woo.test:8888/sdf?newparam=newvalue&othernewparam=othernewvalue" + ( + "https://woo.test:8888/sdf?newparam=newvalue&" + "othernewparam=othernewvalue" + ) ) self.assertEqual( UrlUtils.substitute_query( "https://woo.test:8888/sdf?param=value", "newparam=newvalue&othernewparam=othernewvalue" ), - "https://woo.test:8888/sdf?newparam=newvalue&othernewparam=othernewvalue" + ( + "https://woo.test:8888/sdf?newparam=newvalue&" + "othernewparam=othernewvalue" + ) ) def test_url_add_query(self): self.assertEqual( "https://woo.test:8888/sdf?param=value&newparam=newvalue", - UrlUtils.add_query("https://woo.test:8888/sdf?param=value", 'newparam', 'newvalue') + UrlUtils.add_query( + "https://woo.test:8888/sdf?param=value", 'newparam', 'newvalue' + ) ) def test_url_join_components(self): @@ -241,7 +267,8 @@ def test_url_join_components(self): ) self.assertEqual( 'https://woo.test:8888/wp-json/wp/v2', - UrlUtils.join_components(['https://woo.test:8888/', 'wp-json', 'wp/v2']) + UrlUtils.join_components( + ['https://woo.test:8888/', 'wp-json', 'wp/v2']) ) def test_url_get_php_value(self): @@ -278,7 +305,8 @@ def test_url_get_query_dict_singular(self): 'filter[limit]': '2', 'oauth_nonce': 'c4f2920b0213c43f2e8d3d3333168ec4a22222d1', 'oauth_timestamp': '1481601370', - 'oauth_consumer_key': 'ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + 'oauth_consumer_key': + 'ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'oauth_signature_method': 'HMAC-SHA1', 'oauth_signature': '3ibOjMuhj6JGnI43BQZGniigHh8=', 'page': '2' @@ -286,7 +314,8 @@ def test_url_get_query_dict_singular(self): ) def test_url_get_query_singular(self): - result = UrlUtils.get_query_singular(self.test_url, 'oauth_consumer_key') + result = UrlUtils.get_query_singular( + self.test_url, 'oauth_consumer_key') self.assertEqual( result, 'ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' @@ -299,12 +328,28 @@ def test_url_get_query_singular(self): def test_url_set_query_singular(self): result = UrlUtils.set_query_singular(self.test_url, 'filter[limit]', 3) - expected = "http://ich.local:8888/woocommerce/wc-api/v3/products?filter%5Blimit%5D=3&oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1481601370&page=2" + expected = ( + "http://ich.local:8888/woocommerce/wc-api/v3/products?" + "filter%5Blimit%5D=3&" + "oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&" + "oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&" + "oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&" + "oauth_signature_method=HMAC-SHA1&oauth_timestamp=1481601370&" + "page=2" + ) self.assertEqual(result, expected) def test_url_del_query_singular(self): result = UrlUtils.del_query_singular(self.test_url, 'filter[limit]') - expected = "http://ich.local:8888/woocommerce/wc-api/v3/products?oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1481601370&page=2" + expected = ( + "http://ich.local:8888/woocommerce/wc-api/v3/products?" + "oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&" + "oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&" + "oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&" + "oauth_signature_method=HMAC-SHA1&" + "oauth_timestamp=1481601370&" + "page=2" + ) self.assertEqual(result, expected) def test_url_remove_default_port(self): @@ -320,13 +365,13 @@ def test_url_remove_default_port(self): def test_seq_filter_true(self): self.assertEquals( ['a', 'b', 'c', 'd'], - SeqUtils.filter_true([None, 'a', False, 'b', 'c','d']) + SeqUtils.filter_true([None, 'a', False, 'b', 'c', 'd']) ) def test_str_remove_tail(self): self.assertEqual( 'sdf', - StrUtils.remove_tail('sdf/','/') + StrUtils.remove_tail('sdf/', '/') ) def test_str_remove_head(self): @@ -340,6 +385,7 @@ def test_str_remove_head(self): StrUtils.decapitate('sdf', '/') ) + class TransportTestcases(unittest.TestCase): def setUp(self): self.requester = API_Requests_Wrapper( @@ -370,9 +416,12 @@ def woo_test_mock(*args, **kwargs): with HTTMock(woo_test_mock): # call requests - response = self.requester.request("GET", "https://woo.test:8888/wp-json/wp/v2/posts") + response = self.requester.request( + "GET", "https://woo.test:8888/wp-json/wp/v2/posts") self.assertEqual(response.status_code, 200) - self.assertEqual(response.request.url, 'https://woo.test:8888/wp-json/wp/v2/posts') + self.assertEqual(response.request.url, + 'https://woo.test:8888/wp-json/wp/v2/posts') + class BasicAuthTestcases(unittest.TestCase): def setUp(self): @@ -415,8 +464,11 @@ def test_query_string_endpoint_url(self): ) endpoint_url = api.requester.endpoint_url(self.endpoint) endpoint_url = api.auth.get_auth_url(endpoint_url, 'GET') - expected_endpoint_url = '%s?consumer_key=%s&consumer_secret=%s' % (self.endpoint, self.consumer_key, self.consumer_secret) - expected_endpoint_url = UrlUtils.join_components([self.base_url, self.api_name, self.api_ver, expected_endpoint_url]) + expected_endpoint_url = '%s?consumer_key=%s&consumer_secret=%s' % ( + self.endpoint, self.consumer_key, self.consumer_secret) + expected_endpoint_url = UrlUtils.join_components( + [self.base_url, self.api_name, self.api_ver, expected_endpoint_url] + ) self.assertEqual( endpoint_url, expected_endpoint_url @@ -427,7 +479,6 @@ def test_query_string_endpoint_url(self): class OAuthTestcases(unittest.TestCase): - def setUp(self): self.base_url = "http://localhost:8888/wordpress/" self.api_name = 'wc-api' @@ -446,8 +497,6 @@ def setUp(self): signature_method=self.signature_method ) - # RFC EXAMPLE 1 DATA: https://tools.ietf.org/html/draft-hammer-oauth-10#section-1.2 - self.rfc1_api_url = 'https://photos.example.net/' self.rfc1_consumer_key = 'dpf43f3p2l4k3l03' self.rfc1_consumer_secret = 'kd94hf93k423kf44' @@ -478,56 +527,9 @@ def setUp(self): ] self.rfc1_request_signature = b'74KNZJeDHnMBp0EMJ9ZHt/XKycU=' - - # # RFC EXAMPLE 3 DATA: https://tools.ietf.org/html/draft-hammer-oauth-10#section-3.4.1 - # self.rfc3_method = "GET" - # self.rfc3_target_url = 'http://example.com/request' - # self.rfc3_params_raw = [ - # ('b5', r"=%3D"), - # ('a3', "a"), - # ('c@', ""), - # ('a2', 'r b'), - # ('oauth_consumer_key', '9djdj82h48djs9d2'), - # ('oauth_token', 'kkk9d7dh3k39sjv7'), - # ('oauth_signature_method', 'HMAC-SHA1'), - # ('oauth_timestamp', 137131201), - # ('oauth_nonce', '7d8f3e4a'), - # ('c2', ''), - # ('a3', '2 q') - # ] - # self.rfc3_params_encoded = [ - # ('b5', r"%3D%253D"), - # ('a3', "a"), - # ('c%40', ""), - # ('a2', r"r%20b"), - # ('oauth_consumer_key', '9djdj82h48djs9d2'), - # ('oauth_token', 'kkk9d7dh3k39sjv7'), - # ('oauth_signature_method', 'HMAC-SHA1'), - # ('oauth_timestamp', '137131201'), - # ('oauth_nonce', '7d8f3e4a'), - # ('c2', ''), - # ('a3', r"2%20q") - # ] - # self.rfc3_params_sorted = [ - # ('a2', r"r%20b"), - # # ('a3', r"2%20q"), # disallow multiple - # ('a3', "a"), - # ('b5', r"%3D%253D"), - # ('c%40', ""), - # ('c2', ''), - # ('oauth_consumer_key', '9djdj82h48djs9d2'), - # ('oauth_nonce', '7d8f3e4a'), - # ('oauth_signature_method', 'HMAC-SHA1'), - # ('oauth_timestamp', '137131201'), - # ('oauth_token', 'kkk9d7dh3k39sjv7'), - # ] - # self.rfc3_param_string = r"a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9djdj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1&oauth_timestamp=137131201&oauth_token=kkk9d7dh3k39sjv7" - # self.rfc3_base_string = r"GET&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q%26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk9d7dh3k39sjv7" - - # test data taken from : https://dev.twitter.com/oauth/overview/creating-signatures - self.twitter_api_url = "https://api.twitter.com/" - self.twitter_consumer_secret = "kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw" + self.twitter_consumer_secret = \ + "kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw" self.twitter_consumer_key = "xvz1evFS4wEEPTGEFPHBog" self.twitter_signature_method = "HMAC-SHA1" self.twitter_api = API( @@ -548,20 +550,47 @@ def setUp(self): ("oauth_nonce", "kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg"), ("oauth_signature_method", self.twitter_signature_method), ("oauth_timestamp", "1318622958"), - ("oauth_token", "370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb"), + ("oauth_token", + "370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb"), ("oauth_version", "1.0"), ] - self.twitter_param_string = r"include_entities=true&oauth_consumer_key=xvz1evFS4wEEPTGEFPHBog&oauth_nonce=kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1318622958&oauth_token=370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb&oauth_version=1.0&status=Hello%20Ladies%20%2B%20Gentlemen%2C%20a%20signed%20OAuth%20request%21" - self.twitter_signature_base_string = r"POST&https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521" + self.twitter_param_string = ( + r"include_entities=true&" + r"oauth_consumer_key=xvz1evFS4wEEPTGEFPHBog&" + r"oauth_nonce=kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg&" + r"oauth_signature_method=HMAC-SHA1&" + r"oauth_timestamp=1318622958&" + r"oauth_token=370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb&" + r"oauth_version=1.0&" + r"status=Hello%20Ladies%20%2B%20Gentlemen%2C%20a%20" + r"signed%20OAuth%20request%21" + ) + self.twitter_signature_base_string = ( + r"POST&" + r"https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&" + r"include_entities%3Dtrue%26" + r"oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26" + r"oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26" + r"oauth_signature_method%3DHMAC-SHA1%26" + r"oauth_timestamp%3D1318622958%26" + r"oauth_token%3D370773112-" + r"GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26" + r"oauth_version%3D1.0%26" + r"status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520" + r"a%2520signed%2520OAuth%2520request%2521" + ) self.twitter_token_secret = 'LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE' - self.twitter_signing_key = 'kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw&LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE' + self.twitter_signing_key = ( + 'kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw&' + 'LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE' + ) self.twitter_oauth_signature = b'tnnArxj06cWHq44gCs1OSKk/jLY=' - self.lexev_consumer_key='your_app_key' - self.lexev_consumer_secret='your_app_secret' - self.lexev_callback='http://127.0.0.1/oauth1_callback' - self.lexev_signature_method='HMAC-SHA1' - self.lexev_version='1.0' + self.lexev_consumer_key = 'your_app_key' + self.lexev_consumer_secret = 'your_app_secret' + self.lexev_callback = 'http://127.0.0.1/oauth1_callback' + self.lexev_signature_method = 'HMAC-SHA1' + self.lexev_version = '1.0' self.lexev_api = API( url='https://bitbucket.org/', api='api', @@ -574,43 +603,39 @@ def setUp(self): wp_pass='', oauth1a_3leg=True ) - self.lexev_request_method='POST' - self.lexev_request_url='https://bitbucket.org/api/1.0/oauth/request_token' - self.lexev_request_nonce='27718007815082439851427366369' - self.lexev_request_timestamp='1427366369' - self.lexev_request_params=[ - ('oauth_callback',self.lexev_callback), - ('oauth_consumer_key',self.lexev_consumer_key), - ('oauth_nonce',self.lexev_request_nonce), - ('oauth_signature_method',self.lexev_signature_method), - ('oauth_timestamp',self.lexev_request_timestamp), - ('oauth_version',self.lexev_version), + self.lexev_request_method = 'POST' + self.lexev_request_url = \ + 'https://bitbucket.org/api/1.0/oauth/request_token' + self.lexev_request_nonce = '27718007815082439851427366369' + self.lexev_request_timestamp = '1427366369' + self.lexev_request_params = [ + ('oauth_callback', self.lexev_callback), + ('oauth_consumer_key', self.lexev_consumer_key), + ('oauth_nonce', self.lexev_request_nonce), + ('oauth_signature_method', self.lexev_signature_method), + ('oauth_timestamp', self.lexev_request_timestamp), + ('oauth_version', self.lexev_version), ] - self.lexev_request_signature=b"iPdHNIu4NGOjuXZ+YCdPWaRwvJY=" - self.lexev_resource_url='https://api.bitbucket.org/1.0/repositories/st4lk/django-articles-transmeta/branches' - - # def test_get_sign(self): - # message = "POST&http%3A%2F%2Flocalhost%3A8888%2Fwordpress%2Foauth1%2Frequest&oauth_callback%3Dlocalhost%253A8888%252Fwordpress%26oauth_consumer_key%3DLCLwTOfxoXGh%26oauth_nonce%3D85285179173071287531477036693%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1477036693%26oauth_version%3D1.0" - # signature_method = 'HMAC-SHA1' - # sig_key = 'k7zLzO3mF75Xj65uThpAnNvQHpghp4X1h5N20O8hCbz2kfJq&' - # sig = OAuth.get_sign(message, signature_method, sig_key) - # expected_sig = '8T93S/PDOrEd+N9cm84EDvsPGJ4=' - # self.assertEqual(sig, expected_sig) + self.lexev_request_signature = b"iPdHNIu4NGOjuXZ+YCdPWaRwvJY=" + self.lexev_resource_url = 'https://api.bitbucket.org/1.0/repositories/st4lk/django-articles-transmeta/branches' def test_get_sign_key(self): self.assertEqual( - StrUtils.to_binary(self.wcapi.auth.get_sign_key(self.consumer_secret)), + StrUtils.to_binary( + self.wcapi.auth.get_sign_key(self.consumer_secret)), StrUtils.to_binary("%s&" % self.consumer_secret) ) self.assertEqual( - StrUtils.to_binary(self.wcapi.auth.get_sign_key(self.twitter_consumer_secret, self.twitter_token_secret)), + StrUtils.to_binary(self.wcapi.auth.get_sign_key( + self.twitter_consumer_secret, self.twitter_token_secret)), StrUtils.to_binary(self.twitter_signing_key) ) def test_flatten_params(self): self.assertEqual( - StrUtils.to_binary(UrlUtils.flatten_params(self.twitter_params_raw)), + StrUtils.to_binary(UrlUtils.flatten_params( + self.twitter_params_raw)), StrUtils.to_binary(self.twitter_param_string) ) @@ -629,13 +654,6 @@ def test_sorted_params(self): oauthnet_example = copy(oauthnet_example_sorted) random.shuffle(oauthnet_example) - # oauthnet_example_sorted = [ - # ('a', '1'), - # ('c', 'hi%%20there'), - # ('f', '25'), - # ('z', 'p'), - # ] - self.assertEqual( UrlUtils.sorted_params(oauthnet_example), oauthnet_example_sorted @@ -652,25 +670,8 @@ def test_get_signature_base_string(self): self.twitter_signature_base_string ) - # @unittest.skip("changed order of parms to fit wordpress api") def test_generate_oauth_signature(self): - # endpoint_url = UrlUtils.join_components([self.base_url, self.api_name, self.api_ver, self.endpoint]) - # - # params = OrderedDict() - # params["oauth_consumer_key"] = self.consumer_key - # params["oauth_timestamp"] = "1477041328" - # params["oauth_nonce"] = "166182658461433445531477041328" - # params["oauth_signature_method"] = self.signature_method - # params["oauth_version"] = "1.0" - # params["oauth_callback"] = 'localhost:8888/wordpress' - # - # sig = self.wcapi.auth.generate_oauth_signature("POST", params, endpoint_url) - # expected_sig = "517qNKeq/vrLZGj2UH7+q8ILWAg=" - # self.assertEqual(sig, expected_sig) - - # TEST WITH RFC EXAMPLE 1 DATA - rfc1_request_signature = self.rfc1_api.auth.generate_oauth_signature( self.rfc1_request_method, self.rfc1_request_params, @@ -703,7 +704,6 @@ def test_generate_oauth_signature(self): ) self.assertEqual(lexev_request_signature, self.lexev_request_signature) - def test_add_params_sign(self): endpoint_url = self.wcapi.requester.endpoint_url('products?page=2') @@ -715,12 +715,14 @@ def test_add_params_sign(self): params["oauth_version"] = "1.0" params["oauth_callback"] = 'localhost:8888/wordpress' - signed_url = self.wcapi.auth.add_params_sign("GET", endpoint_url, params) + signed_url = self.wcapi.auth.add_params_sign( + "GET", endpoint_url, params) signed_url_params = parse_qsl(urlparse(signed_url).query) # self.assertEqual('page', signed_url_params[-1][0]) self.assertIn('page', dict(signed_url_params)) + class OAuth3LegTestcases(unittest.TestCase): def setUp(self): self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" @@ -753,9 +755,12 @@ def woo_api_mock(*args, **kwargs): ], "authentication": { "oauth1": { - "request": "http://localhost:8888/wordpress/oauth1/request", - "authorize": "http://localhost:8888/wordpress/oauth1/authorize", - "access": "http://localhost:8888/wordpress/oauth1/access", + "request": + "http://localhost:8888/wordpress/oauth1/request", + "authorize": + "http://localhost:8888/wordpress/oauth1/authorize", + "access": + "http://localhost:8888/wordpress/oauth1/access", "version": "0.1" } } @@ -767,17 +772,20 @@ def woo_api_mock(*args, **kwargs): def woo_authentication_mock(*args, **kwargs): """ URL Mock """ return { - 'status_code':200, - 'content': b"""oauth_token=XXXXXXXXXXXX&oauth_token_secret=YYYYYYYYYYYY""" + 'status_code': 200, + 'content': + b"""oauth_token=XXXXXXXXXXXX&oauth_token_secret=YYYYYYYYYYYY""" } def test_get_sign_key(self): oauth_token_secret = "PNW9j1yBki3e7M7EqB5qZxbe9n5tR6bIIefSMQ9M2pdyRI9g" - key = self.api.auth.get_sign_key(self.consumer_secret, oauth_token_secret) + key = self.api.auth.get_sign_key( + self.consumer_secret, oauth_token_secret) self.assertEqual( StrUtils.to_binary(key), - StrUtils.to_binary("%s&%s" % (self.consumer_secret, oauth_token_secret)) + StrUtils.to_binary("%s&%s" % + (self.consumer_secret, oauth_token_secret)) ) def test_auth_discovery(self): @@ -789,9 +797,12 @@ def test_auth_discovery(self): authentication, { "oauth1": { - "request": "http://localhost:8888/wordpress/oauth1/request", - "authorize": "http://localhost:8888/wordpress/oauth1/authorize", - "access": "http://localhost:8888/wordpress/oauth1/access", + "request": + "http://localhost:8888/wordpress/oauth1/request", + "authorize": + "http://localhost:8888/wordpress/oauth1/authorize", + "access": + "http://localhost:8888/wordpress/oauth1/access", "version": "0.1" } } @@ -804,12 +815,14 @@ def test_get_request_token(self): self.assertTrue(authentication) with HTTMock(self.woo_authentication_mock): - request_token, request_token_secret = self.api.auth.get_request_token() + request_token, request_token_secret = \ + self.api.auth.get_request_token() self.assertEquals(request_token, 'XXXXXXXXXXXX') self.assertEquals(request_token_secret, 'YYYYYYYYYYYY') def test_store_access_creds(self): - _, creds_store_path = mkstemp("wp-api-python-test-store-access-creds.json") + _, creds_store_path = mkstemp( + "wp-api-python-test-store-access-creds.json") api = API( url="http://woo.test", consumer_key=self.consumer_key, @@ -827,13 +840,17 @@ def test_store_access_creds(self): with open(creds_store_path) as creds_store_file: self.assertEqual( creds_store_file.read(), - '{"access_token": "XXXXXXXXXXXX", "access_token_secret": "YYYYYYYYYYYY"}' + ('{"access_token": "XXXXXXXXXXXX", ' + '"access_token_secret": "YYYYYYYYYYYY"}') ) def test_retrieve_access_creds(self): - _, creds_store_path = mkstemp("wp-api-python-test-store-access-creds.json") + _, creds_store_path = mkstemp( + "wp-api-python-test-store-access-creds.json") with open(creds_store_path, 'w+') as creds_store_file: - creds_store_file.write('{"access_token": "XXXXXXXXXXXX", "access_token_secret": "YYYYYYYYYYYY"}') + creds_store_file.write( + ('{"access_token": "XXXXXXXXXXXX", ' + '"access_token_secret": "YYYYYYYYYYYY"}')) api = API( url="http://woo.test", @@ -858,32 +875,35 @@ def test_retrieve_access_creds(self): 'YYYYYYYYYYYY' ) + class WCApiTestCasesBase(unittest.TestCase): """ Base class for WC API Test cases """ + def setUp(self): Auth.force_timestamp = CURRENT_TIMESTAMP Auth.force_nonce = SHITTY_NONCE self.api_params = { - 'url':'http://localhost:8083/', - 'api':'wc-api', - 'version':'v3', - 'consumer_key':'ck_659f6994ae88fed68897f9977298b0e19947979a', - 'consumer_secret':'cs_9421d39290f966172fef64ae18784a2dc7b20976', + 'url': 'http://localhost:8083/', + 'api': 'wc-api', + 'version': 'v3', + 'consumer_key': 'ck_659f6994ae88fed68897f9977298b0e19947979a', + 'consumer_secret': 'cs_9421d39290f966172fef64ae18784a2dc7b20976', } + class WCApiTestCasesLegacy(WCApiTestCasesBase): """ Tests for WC API V3 """ + def setUp(self): super(WCApiTestCasesLegacy, self).setUp() self.api_params['version'] = 'v3' self.api_params['api'] = 'wc-api' - def test_APIGet(self): wcapi = API(**self.api_params) response = wcapi.get('products') # print UrlUtils.beautify_response(response) - self.assertIn(response.status_code, [200,201]) + self.assertIn(response.status_code, [200, 201]) response_obj = response.json() self.assertIn('products', response_obj) self.assertEqual(len(response_obj['products']), 10) @@ -893,7 +913,7 @@ def test_APIGetWithSimpleQuery(self): wcapi = API(**self.api_params) response = wcapi.get('products?page=2') # print UrlUtils.beautify_response(response) - self.assertIn(response.status_code, [200,201]) + self.assertIn(response.status_code, [200, 201]) response_obj = response.json() self.assertIn('products', response_obj) @@ -903,13 +923,21 @@ def test_APIGetWithSimpleQuery(self): def test_APIGetWithComplexQuery(self): wcapi = API(**self.api_params) response = wcapi.get('products?page=2&filter%5Blimit%5D=2') - self.assertIn(response.status_code, [200,201]) + self.assertIn(response.status_code, [200, 201]) response_obj = response.json() self.assertIn('products', response_obj) self.assertEqual(len(response_obj['products']), 2) - response = wcapi.get('products?oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&oauth_nonce=037470f3b08c9232b0888f52cb9d4685b44d8fd1&oauth_signature=wrKfuIjbwi%2BTHynAlTP4AssoPS0%3D&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1481606275&filter%5Blimit%5D=3') - self.assertIn(response.status_code, [200,201]) + response = wcapi.get( + 'products?' + 'oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&' + 'oauth_nonce=037470f3b08c9232b0888f52cb9d4685b44d8fd1&' + 'oauth_signature=wrKfuIjbwi%2BTHynAlTP4AssoPS0%3D&' + 'oauth_signature_method=HMAC-SHA1&' + 'oauth_timestamp=1481606275&' + 'filter%5Blimit%5D=3' + ) + self.assertIn(response.status_code, [200, 201]) response_obj = response.json() self.assertIn('products', response_obj) self.assertEqual(len(response_obj['products']), 3) @@ -922,18 +950,22 @@ def test_APIPutWithSimpleQuery(self): product_id = first_product['id'] nonce = b"%f" % (random.random()) - response = wcapi.put('products/%s?filter%%5Blimit%%5D=5' % (product_id), {"product":{"title":text_type(nonce)}}) + response = wcapi.put('products/%s?filter%%5Blimit%%5D=5' % + (product_id), + {"product": {"title": text_type(nonce)}}) request_params = UrlUtils.get_query_dict_singular(response.request.url) response_obj = response.json() self.assertEqual(response_obj['product']['title'], text_type(nonce)) self.assertEqual(request_params['filter[limit]'], text_type(5)) - wcapi.put('products/%s' % (product_id), {"product":{"title":original_title}}) + wcapi.put('products/%s' % (product_id), + {"product": {"title": original_title}}) class WCApiTestCases(WCApiTestCasesBase): oauth1a_3leg = False """ Tests for New wp-json/wc/v2 API """ + def setUp(self): super(WCApiTestCases, self).setUp() self.api_params['version'] = 'wc/v2' @@ -947,11 +979,10 @@ def test_APIGet(self): wcapi = API(**self.api_params) per_page = 10 response = wcapi.get('products?per_page=%d' % per_page) - self.assertIn(response.status_code, [200,201]) + self.assertIn(response.status_code, [200, 201]) response_obj = response.json() self.assertEqual(len(response_obj), per_page) - def test_APIPutWithSimpleQuery(self): wcapi = API(**self.api_params) response = wcapi.get('products') @@ -962,13 +993,14 @@ def test_APIPutWithSimpleQuery(self): product_id = first_product['id'] nonce = b"%f" % (random.random()) - response = wcapi.put('products/%s?page=2&per_page=5' % (product_id), {"name":text_type(nonce)}) + response = wcapi.put('products/%s?page=2&per_page=5' % + (product_id), {"name": text_type(nonce)}) request_params = UrlUtils.get_query_dict_singular(response.request.url) response_obj = response.json() self.assertEqual(response_obj['name'], text_type(nonce)) self.assertEqual(request_params['per_page'], '5') - wcapi.put('products/%s' % (product_id), {"name":original_title}) + wcapi.put('products/%s' % (product_id), {"name": original_title}) def test_APIPostWithLatin1Query(self): wcapi = API(**self.api_params) @@ -1008,7 +1040,6 @@ def test_APIPostWithUTF8Query(self): with self.assertRaises(TypeError): response = wcapi.post('products', data) - def test_APIPostWithUnicodeQuery(self): wcapi = API(**self.api_params) nonce = u"%f\u00ae" % random.random() @@ -1024,22 +1055,24 @@ def test_APIPostWithUnicodeQuery(self): self.assertEqual(response_obj.get('name'), nonce) wcapi.delete('products/%s' % product_id) + @unittest.skip("these simply don't work for some reason") class WCApiTestCases3Leg(WCApiTestCases): """ Tests for New wp-json/wc/v2 API with 3-leg """ oauth1a_3leg = True + class WPAPITestCasesBase(unittest.TestCase): api_params = { - 'url':'http://localhost:8083/', - 'api':'wp-json', - 'version':'wp/v2', - 'consumer_key':'tYG1tAoqjBEM', - 'consumer_secret':'s91fvylVrqChwzzDbEJHEWyySYtAmlIsqqYdjka1KyVDdAyB', - 'callback':'http://127.0.0.1/oauth1_callback', - 'wp_user':'admin', - 'wp_pass':'admin', - 'oauth1a_3leg':True, + 'url': 'http://localhost:8083/', + 'api': 'wp-json', + 'version': 'wp/v2', + 'consumer_key': 'tYG1tAoqjBEM', + 'consumer_secret': 's91fvylVrqChwzzDbEJHEWyySYtAmlIsqqYdjka1KyVDdAyB', + 'callback': 'http://127.0.0.1/oauth1_callback', + 'wp_user': 'admin', + 'wp_pass': 'admin', + 'oauth1a_3leg': True, } def setUp(self): @@ -1050,13 +1083,13 @@ def setUp(self): # @debug_on() def test_APIGet(self): response = self.wpapi.get('users/me') - self.assertIn(response.status_code, [200,201]) + self.assertIn(response.status_code, [200, 201]) response_obj = response.json() self.assertEqual(response_obj['name'], self.api_params['wp_user']) def test_APIGetWithSimpleQuery(self): response = self.wpapi.get('pages?page=2&per_page=2') - self.assertIn(response.status_code, [200,201]) + self.assertIn(response.status_code, [200, 201]) response_obj = response.json() self.assertEqual(len(response_obj), 2) @@ -1078,15 +1111,14 @@ def test_APIPostData(self): self.assertEqual(response_obj.get('title').get('raw'), nonce) self.wpapi.delete('posts/%s' % post_id) - def test_APIBadData(self): + def test_APIPostBadData(self): """ No excerpt so should fail to be created. """ nonce = u"%f\u00ae" % random.random() - content = "api test post" - data = { + 'a': nonce } with self.assertRaises(UserWarning): diff --git a/wordpress/api.py b/wordpress/api.py index b7e812a..33fcc3e 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -39,7 +39,8 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): auth_class = NoAuth if kwargs.get('version', '').startswith('wc') and kwargs.get('oauth1a_3leg'): - self.logger.warn("WooCommerce JSON Api does not seem to support 3leg") + self.logger.warn( + "WooCommerce JSON Api does not seem to support 3leg") self.auth = auth_class(**auth_kwargs) @@ -107,18 +108,18 @@ def request_post_mortem(self, response=None): if isinstance(response_json, dict) and ('code' in response_json or 'message' in response_json): reason = u" - ".join([ - text_type(response_json.get(key)) for key in ['code', 'message', 'data'] \ + text_type(response_json.get(key)) for key in ['code', 'message', 'data'] if key in response_json ]) code = text_type(response_json.get('code')) if code == 'rest_user_invalid_email': remedy = "Try checking the email %s doesn't already exist" % \ - request_body.get('email') + request_body.get('email') elif code == 'json_oauth1_consumer_mismatch': remedy = "Try deleting the cached credentials at %s" % \ - self.auth.creds_store + self.auth.creds_store elif code == 'woocommerce_rest_cannot_view': if not self.auth.query_string_auth: @@ -158,12 +159,13 @@ def request_post_mortem(self, response=None): header_api_url = StrUtils.eviscerate(header_api_url, '/') if header_api_url and requester_api_url\ - and header_api_url != requester_api_url: + and header_api_url != requester_api_url: reason = "hostname mismatch. %s != %s" % ( header_api_url, requester_api_url ) header_url = StrUtils.eviscerate(header_api_url, '/') - header_url = StrUtils.eviscerate(header_url, self.requester.api) + header_url = StrUtils.eviscerate( + header_url, self.requester.api) header_url = StrUtils.eviscerate(header_url, '/') remedy = "try changing url to %s" % header_url @@ -187,14 +189,14 @@ def __request(self, method, endpoint, data, **kwargs): endpoint_url = self.auth.get_auth_url(endpoint_url, method, **kwargs) auth = self.auth.get_auth() - content_type = kwargs.get('headers', {}).get('content-type', 'application/json') + content_type = kwargs.get('headers', {}).get( + 'content-type', 'application/json') if data is not None and content_type.startswith('application/json'): data = jsonencode(data, ensure_ascii=False) # enforce utf-8 encoded binary data = StrUtils.to_binary(data, encoding='utf8') - response = self.requester.request( method=method, url=endpoint_url, diff --git a/wordpress/auth.py b/wordpress/auth.py index d81b7e5..3c12291 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -40,7 +40,6 @@ from ordereddict import OrderedDict - class Auth(object): """ Boilerplate for handling authentication stuff. """ @@ -65,8 +64,10 @@ def get_auth(self): """ Returns the auth parameter used in requests """ pass + class BasicAuth(Auth): """ Does not perform any signing, just logs in with oauth creds """ + def __init__(self, requester, consumer_key, consumer_secret, **kwargs): super(BasicAuth, self).__init__(requester, **kwargs) self.consumer_key = consumer_key @@ -94,14 +95,17 @@ def get_auth(self): if not self.query_string_auth: return HTTPBasicAuth(self.consumer_key, self.consumer_secret) + class NoAuth(Auth): """ Just a dummy Auth object to allow header based authorization per request """ + def get_auth_url(self, endpoint_url, method, **kwargs): return endpoint_url + class OAuth(Auth): """ Signs string with oauth consumer_key and consumer_secret """ oauth_version = '1.0' @@ -126,7 +130,7 @@ def get_sign_key(self, consumer_secret, token_secret=None): raise UserWarning("no consumer_secret provided") token_secret = str(token_secret) if token_secret else '' if self.api_namespace == 'wc-api' \ - and self.api_version in ["v1", "v2"]: + and self.api_version in ["v1", "v2"]: # special conditions for wc-api v1-2 key = consumer_secret else: @@ -158,12 +162,13 @@ def add_params_sign(self, method, url, params, sign_key=None, **kwargs): if key != "oauth_signature": params_without_signature.append((key, value)) - self.logger.debug('sorted_params before sign: %s' % pformat(params_without_signature) ) - - signature = self.generate_oauth_signature(method, params_without_signature, url, sign_key) + self.logger.debug('sorted_params before sign: %s' % + pformat(params_without_signature)) - self.logger.debug('signature: %s' % signature ) + signature = self.generate_oauth_signature( + method, params_without_signature, url, sign_key) + self.logger.debug('signature: %s' % signature) params = params_without_signature + [("oauth_signature", signature)] @@ -195,7 +200,7 @@ def get_signature_base_string(cls, method, params, url): url = UrlUtils.substitute_query(url) base_request_uri = quote(url, "") query_string = UrlUtils.flatten_params(params) - query_string = quote( query_string, '~') + query_string = quote(query_string, '~') return "%s&%s&%s" % ( method.upper(), base_request_uri, query_string ) @@ -245,13 +250,15 @@ def generate_nonce(cls): sha1 ).hexdigest() + class OAuth_3Leg(OAuth): """ Provides 3 legged OAuth1a, mostly based off this: http://www.lexev.org/en/2015/oauth-step-step/""" # oauth_version = '1.0A' def __init__(self, requester, consumer_key, consumer_secret, callback, **kwargs): - super(OAuth_3Leg, self).__init__(requester, consumer_key, consumer_secret, **kwargs) + super(OAuth_3Leg, self).__init__( + requester, consumer_key, consumer_secret, **kwargs) self.callback = callback self.wp_user = kwargs.pop('wp_user', None) self.wp_pass = kwargs.pop('wp_pass', None) @@ -314,9 +321,10 @@ def get_auth_url(self, endpoint_url, method): ('oauth_token', self.access_token) ] - sign_key = self.get_sign_key(self.consumer_secret, self.access_token_secret) + sign_key = self.get_sign_key( + self.consumer_secret, self.access_token_secret) - self.logger.debug('sign_key: %s' % sign_key ) + self.logger.debug('sign_key: %s' % sign_key) return self.add_params_sign(method, endpoint_url, params, sign_key) @@ -363,7 +371,8 @@ def get_request_token(self): ] request_token_url = self.authentication['oauth1']['request'] - request_token_url = self.add_params_sign("GET", request_token_url, params) + request_token_url = self.add_params_sign( + "GET", request_token_url, params) response = self.requester.get(request_token_url) self.logger.debug('get_request_token response: %s' % response.text) @@ -372,13 +381,13 @@ def get_request_token(self): try: self._request_token = resp_content['oauth_token'][0] except: - raise UserWarning("Could not parse request_token in response from %s : %s" \ - % (repr(response.request.url), UrlUtils.beautify_response(response))) + raise UserWarning("Could not parse request_token in response from %s : %s" + % (repr(response.request.url), UrlUtils.beautify_response(response))) try: self.request_token_secret = resp_content['oauth_token_secret'][0] except: - raise UserWarning("Could not parse request_token_secret in response from %s : %s" \ - % (repr(response.request.url), UrlUtils.beautify_response(response))) + raise UserWarning("Could not parse request_token_secret in response from %s : %s" + % (repr(response.request.url), UrlUtils.beautify_response(response))) return self._request_token, self.request_token_secret @@ -392,21 +401,27 @@ def parse_login_form_error(self, response, exc, **kwargs): if error and error.stripped_strings: for stripped_string in error.stripped_strings: if "plase solve this math problem" in stripped_string.lower(): - raise UserWarning("Can't log in if form has capcha ... yet") - raise UserWarning("could not parse login form error. %s " % str(error)) + raise UserWarning( + "Can't log in if form has capcha ... yet") + raise UserWarning( + "could not parse login form error. %s " % str(error)) if response.status_code == 200: error = login_form_soup.select_one('div#login_error') if error and error.stripped_strings: for stripped_string in error.stripped_strings: if "invalid token" in stripped_string.lower(): - raise UserWarning("Invalid token: %s" % repr(kwargs.get('token'))) + raise UserWarning("Invalid token: %s" % + repr(kwargs.get('token'))) elif "invalid username" in stripped_string.lower(): - raise UserWarning("Invalid username: %s" % repr(kwargs.get('username'))) + raise UserWarning("Invalid username: %s" % + repr(kwargs.get('username'))) elif "the password you entered" in stripped_string.lower(): - raise UserWarning("Invalid password: %s" % repr(kwargs.get('password'))) - raise UserWarning("could not parse login form error. %s " % str(error)) + raise UserWarning("Invalid password: %s" % + repr(kwargs.get('password'))) + raise UserWarning( + "could not parse login form error. %s " % str(error)) raise UserWarning( - "Login form response was code %s. original error: \n%s" % \ + "Login form response was code %s. original error: \n%s" % (str(response.status_code), repr(exc)) ) @@ -465,23 +480,26 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): wp_pass = self.wp_pass authorize_url = self.authentication['oauth1']['authorize'] - authorize_url = UrlUtils.add_query(authorize_url, 'oauth_token', request_token) + authorize_url = UrlUtils.add_query( + authorize_url, 'oauth_token', request_token) # we're using a different session from the usual API calls # (I think the headers are incompatible?) # self.requester.get(authorize_url) authorize_session = requests.Session() - authorize_session.headers.update({'User-Agent': "Wordpress API Client-Python/%s" % __version__}) + authorize_session.headers.update( + {'User-Agent': "Wordpress API Client-Python/%s" % __version__}) login_form_response = authorize_session.get(authorize_url) login_form_params = { - 'username':wp_user, - 'password':wp_pass, - 'token':request_token + 'username': wp_user, + 'password': wp_pass, + 'token': request_token } try: - login_form_action, login_form_data = self.get_form_info(login_form_response, 'loginform') + login_form_action, login_form_data = self.get_form_info( + login_form_response, 'loginform') except AssertionError as exc: self.parse_login_form_error( login_form_response, exc, **login_form_params @@ -500,9 +518,11 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): # print "submitting login form to %s : %s" % (login_form_action, str(login_form_data)) - confirmation_response = authorize_session.post(login_form_action, data=login_form_data, allow_redirects=True) + confirmation_response = authorize_session.post( + login_form_action, data=login_form_data, allow_redirects=True) try: - authorize_form_action, authorize_form_data = self.get_form_info(confirmation_response, 'oauth1_authorize_form') + authorize_form_action, authorize_form_data = self.get_form_info( + confirmation_response, 'oauth1_authorize_form') except AssertionError as exc: self.parse_login_form_error( confirmation_response, exc, **login_form_params @@ -519,12 +539,13 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): assert 'wp-submit' in login_form_data, 'authorize button did not appear on form' - final_response = authorize_session.post(authorize_form_action, data=authorize_form_data, allow_redirects=False) + final_response = authorize_session.post( + authorize_form_action, data=authorize_form_data, allow_redirects=False) assert \ final_response.status_code == 302, \ "was not redirected by authorize screen, was %d instead. something went wrong" \ - % final_response.status_code + % final_response.status_code assert 'location' in final_response.headers, "redirect did not provide redirect location in header" final_location = final_response.headers['location'] @@ -555,7 +576,8 @@ def store_access_creds(self): creds['access_token_secret'] = self.access_token_secret if creds: with open(self.creds_store, 'w+') as creds_store_file: - StrUtils.to_binary(json.dump(creds, creds_store_file, ensure_ascii=False)) + StrUtils.to_binary( + json.dump(creds, creds_store_file, ensure_ascii=False)) def retrieve_access_creds(self): """ retrieve the access_token and access_token_secret stored locally. """ @@ -585,7 +607,6 @@ def clear_stored_creds(self): with open(self.creds_store, 'w+') as creds_store_file: creds_store_file.write('') - def get_access_token(self, oauth_verifier=None): """ Uses the access authentication link to get an access token """ @@ -600,10 +621,12 @@ def get_access_token(self, oauth_verifier=None): ('oauth_verifier', self.oauth_verifier) ] - sign_key = self.get_sign_key(self.consumer_secret, self.request_token_secret) + sign_key = self.get_sign_key( + self.consumer_secret, self.request_token_secret) access_token_url = self.authentication['oauth1']['access'] - access_token_url = self.add_params_sign("POST", access_token_url, params, sign_key) + access_token_url = self.add_params_sign( + "POST", access_token_url, params, sign_key) access_response = self.requester.post(access_token_url) @@ -623,8 +646,8 @@ def get_access_token(self, oauth_verifier=None): self._access_token = access_response_queries['oauth_token'][0] self.access_token_secret = access_response_queries['oauth_token_secret'][0] except: - raise UserWarning("Could not parse access_token or access_token_secret in response from %s : %s" \ - % (repr(access_response.request.url), UrlUtils.beautify_response(access_response))) + raise UserWarning("Could not parse access_token or access_token_secret in response from %s : %s" + % (repr(access_response.request.url), UrlUtils.beautify_response(access_response))) self.store_access_creds() diff --git a/wordpress/helpers.py b/wordpress/helpers.py index 9803ec9..7d1dc50 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -62,12 +62,12 @@ def to_binary(cls, string, encoding='utf8', errors='backslashreplace'): def to_binary_ascii(cls, string): return cls.to_binary(string, 'ascii') + class SeqUtils(object): @classmethod def filter_true(cls, seq): return [item for item in seq if item] - @classmethod def filter_unique_true(cls, list_a): response = [] @@ -102,6 +102,7 @@ def combine_ordered_dicts(cls, *args): response = cls.combine_two_ordered_dicts(response, arg) return response + class UrlUtils(object): reg_netloc = r'(?P[^:]+)(:(?P\d+))?' @@ -138,7 +139,8 @@ def get_query_singular(cls, url, key, default=None): """ Gets the value of a single query in a url """ url_params = parse_qs(urlparse(url).query) values = url_params.get(key, [default]) - assert len(values) == 1, "ambiguous value, could not get singular for key: %s" % key + assert len( + values) == 1, "ambiguous value, could not get singular for key: %s" % key return values[0] @classmethod @@ -226,7 +228,8 @@ def beautify_response(response): """ Returns a beautified response in the default locale """ content_type = 'html' try: - content_type = getattr(response, 'headers', {}).get('Content-Type', content_type) + content_type = getattr(response, 'headers', {}).get( + 'Content-Type', content_type) except: pass if 'html' in content_type.lower(): @@ -254,8 +257,8 @@ def remove_default_port(cls, url, defaults=None): """ Remove the port number from a URL if it is a default port. """ if defaults is None: defaults = { - 'http':80, - 'https':443 + 'http': 80, + 'https': 443 } urlparse_result = urlparse(url) @@ -364,4 +367,4 @@ def flatten_params(cls, params): params = cls.normalize_params(params) params = cls.sorted_params(params) params = cls.unique_params(params) - return "&".join(["%s=%s"%(key, value) for key, value in params]) + return "&".join(["%s=%s" % (key, value) for key, value in params]) diff --git a/wordpress/transport.py b/wordpress/transport.py index eee1ce5..573b054 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -25,6 +25,7 @@ class API_Requests_Wrapper(object): """ provides a wrapper for making requests that handles session info """ + def __init__(self, url, **kwargs): self.logger = logging.getLogger(__name__) self.url = url @@ -119,7 +120,8 @@ def request(self, method, url, auth=None, params=None, data=None, **kwargs): self.logger.debug("response_code:\n%s" % pformat(response.status_code)) try: response_json = response.json() - self.logger.debug("response_json:\n%s" % (pformat(response_json)[:1000])) + self.logger.debug("response_json:\n%s" % + (pformat(response_json)[:1000])) except ValueError: response_text = response.text self.logger.debug("response_text:\n%s" % (response_text[:1000])) From 04a34d201791a234a9a3a739df7e0e0a3f467a7b Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 15 Oct 2018 16:36:29 +1100 Subject: [PATCH 094/129] manual pep8 --- setup.py | 3 +- tests.py | 13 ++-- wordpress/api.py | 35 ++++++--- wordpress/auth.py | 158 ++++++++++++++++++++++++++++------------- wordpress/helpers.py | 54 +++++++------- wordpress/transport.py | 11 +-- 6 files changed, 180 insertions(+), 94 deletions(-) diff --git a/setup.py b/setup.py index eca3935..8c97734 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,8 @@ VERSION = "" with open("wordpress/__init__.py", "r") as fd: VERSION = re.search( - r"^__version__\s*=\s*['\"]([^\"]*)['\"]", fd.read(), re.MULTILINE).group(1) + r"^__version__\s*=\s*['\"]([^\"]*)['\"]", fd.read(), re.MULTILINE + ).group(1) if not VERSION: raise RuntimeError("Cannot find version information") diff --git a/tests.py b/tests.py index 71c7fe9..0f77c02 100644 --- a/tests.py +++ b/tests.py @@ -2,7 +2,6 @@ import functools import logging import pdb -import platform import random import sys import traceback @@ -542,7 +541,10 @@ def setUp(self): ) self.twitter_method = "POST" - self.twitter_target_url = "https://api.twitter.com/1/statuses/update.json?include_entities=true" + self.twitter_target_url = ( + "https://api.twitter.com/1/statuses/update.json?" + "include_entities=true" + ) self.twitter_params_raw = [ ("status", "Hello Ladies + Gentlemen, a signed OAuth request!"), ("include_entities", "true"), @@ -617,7 +619,10 @@ def setUp(self): ('oauth_version', self.lexev_version), ] self.lexev_request_signature = b"iPdHNIu4NGOjuXZ+YCdPWaRwvJY=" - self.lexev_resource_url = 'https://api.bitbucket.org/1.0/repositories/st4lk/django-articles-transmeta/branches' + self.lexev_resource_url = ( + 'https://api.bitbucket.org/1.0/repositories/st4lk/' + 'django-articles-transmeta/branches' + ) def test_get_sign_key(self): self.assertEqual( @@ -1122,7 +1127,7 @@ def test_APIPostBadData(self): } with self.assertRaises(UserWarning): - response = self.wpapi.post('posts', data) + self.wpapi.post('posts', data) class WPAPITestCasesBasic(WPAPITestCasesBase): diff --git a/wordpress/api.py b/wordpress/api.py index 33fcc3e..b3800df 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -10,8 +10,8 @@ import logging from json import dumps as jsonencode -from six import binary_type, text_type -from wordpress.auth import BasicAuth, OAuth, OAuth_3Leg, NoAuth +from six import text_type +from wordpress.auth import BasicAuth, NoAuth, OAuth, OAuth_3Leg from wordpress.helpers import StrUtils, UrlUtils from wordpress.transport import API_Requests_Wrapper @@ -38,7 +38,10 @@ def __init__(self, url, consumer_key, consumer_secret, **kwargs): elif kwargs.get('no_auth'): auth_class = NoAuth - if kwargs.get('version', '').startswith('wc') and kwargs.get('oauth1a_3leg'): + if ( + kwargs.get('version', '').startswith('wc') + and kwargs.get('oauth1a_3leg') + ): self.logger.warn( "WooCommerce JSON Api does not seem to support 3leg") @@ -106,9 +109,13 @@ def request_post_mortem(self, response=None): try_hostname_mismatch = False - if isinstance(response_json, dict) and ('code' in response_json or 'message' in response_json): + if ( + isinstance(response_json, dict) + and ('code' in response_json or 'message' in response_json) + ): reason = u" - ".join([ - text_type(response_json.get(key)) for key in ['code', 'message', 'data'] + text_type(response_json.get(key)) + for key in ['code', 'message', 'data'] if key in response_json ]) code = text_type(response_json.get('code')) @@ -126,16 +133,19 @@ def request_post_mortem(self, response=None): remedy = "Try enabling query_string_auth" else: remedy = ( - "This error is super generic and can be caused by just " - "about anything. Here are some things to try: \n" + "This error is super generic and can be caused by " + "just about anything. Here are some things to try: \n" " - Check that the account which as assigned to your " "oAuth creds has the correct access level\n" " - Enable logging and check for error messages in " "wp-content and wp-content/uploads/wc-logs\n" - " - Check that your query string parameters are valid\n" - " - Make sure your server is not messing with authentication headers\n" + " - Check that your query string parameters are " + "valid\n" + " - Make sure your server is not messing with " + "authentication headers\n" " - Try a different endpoint\n" - " - Try enabling HTTPS and using basic authentication\n" + " - Try enabling HTTPS and using basic " + "authentication\n" ) elif code == 'woocommerce_rest_authentication_error': @@ -169,7 +179,10 @@ def request_post_mortem(self, response=None): header_url = StrUtils.eviscerate(header_url, '/') remedy = "try changing url to %s" % header_url - msg = u"API call to %s returned \nCODE: %s\nRESPONSE:%s \nHEADERS: %s\nREQ_BODY:%s" % ( + msg = ( + u"API call to %s returned \nCODE: " + "%s\nRESPONSE:%s \nHEADERS: %s\nREQ_BODY:%s" + ) % ( request_url, text_type(response.status_code), UrlUtils.beautify_response(response), diff --git a/wordpress/auth.py b/wordpress/auth.py index 3c12291..4e2620e 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -22,12 +22,13 @@ from requests.auth import HTTPBasicAuth from bs4 import BeautifulSoup -from requests_oauthlib import OAuth1 from wordpress import __version__ -from .helpers import UrlUtils, StrUtils + +from .helpers import StrUtils, UrlUtils try: - from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse + from urllib.parse import (urlencode, quote, unquote, parse_qs, parse_qsl, + urlparse, urlunparse) from urllib.parse import ParseResult as URLParseResult except ImportError: from urllib import urlencode, quote, unquote @@ -125,7 +126,7 @@ def __init__(self, requester, consumer_key, consumer_secret, **kwargs): self.force_nonce = kwargs.pop('force_nonce', None) def get_sign_key(self, consumer_secret, token_secret=None): - "gets consumer_secret and turns it into a bytestring suitable for signing" + """Get consumer_secret, convert to bytestring suitable for signing.""" if not consumer_secret: raise UserWarning("no consumer_secret provided") token_secret = str(token_secret) if token_secret else '' @@ -138,8 +139,12 @@ def get_sign_key(self, consumer_secret, token_secret=None): return key def add_params_sign(self, method, url, params, sign_key=None, **kwargs): - """ Adds the params to a given url, signs the url with sign_key if provided, - otherwise generates sign_key automatically and returns a signed url """ + """ + Add the params to a given url. + + Sign the url with sign_key if provided, otherwise generate + sign_key automatically and return a signed url. + """ if isinstance(params, dict): params = list(params.items()) @@ -252,11 +257,17 @@ def generate_nonce(cls): class OAuth_3Leg(OAuth): - """ Provides 3 legged OAuth1a, mostly based off this: http://www.lexev.org/en/2015/oauth-step-step/""" + """ + Provide 3 legged OAuth1a. + + Mostly based off this: http://www.lexev.org/en/2015/oauth-step-step/ + """ # oauth_version = '1.0A' - def __init__(self, requester, consumer_key, consumer_secret, callback, **kwargs): + def __init__( + self, requester, consumer_key, consumer_secret, callback, **kwargs + ): super(OAuth_3Leg, self).__init__( requester, consumer_key, consumer_secret, **kwargs) self.callback = callback @@ -272,32 +283,44 @@ def __init__(self, requester, consumer_key, consumer_secret, callback, **kwargs) @property def authentication(self): - """ This is an object holding the authentication links discovered from the API - automatically generated if accessed before generated """ + """ + Provide authentication links discovered from the API. + + Automatically generated if accessed before generated. + """ if not self._authentication: self._authentication = self.discover_auth() return self._authentication @property def oauth_verifier(self): - """ This is the verifier string used in authentication - automatically generated if accessed before generated """ + """ + Verifier string used in authentication. + + Automatically generated if accessed before generated. + """ if not self._oauth_verifier: self._oauth_verifier = self.get_verifier() return self._oauth_verifier @property def request_token(self): - """ This is the oauth_token used in requesting an access_token - automatically generated if accessed before generated """ + """ + OAuth token used in requesting an access_token. + + Automatically generated if accessed before generated. + """ if not self._request_token: self.get_request_token() return self._request_token @property def access_token(self): - """ This is the oauth_token used to sign requests to protected resources - automatically generated if accessed before generated """ + """ + OAuth token used to sign requests to protected resources. + + Automatically generated if accessed before generated. + """ if not self._access_token and self.creds_store: self.retrieve_access_creds() if not self._access_token: @@ -310,7 +333,9 @@ def creds_store(self): return os.path.expanduser(self._creds_store) def get_auth_url(self, endpoint_url, method): - """ Returns the URL with OAuth params """ + """ + Return the URL with OAuth params. + """ assert self.access_token, "need a valid access token for this step" assert self.access_token_secret, \ "need a valid access token secret for this step" @@ -329,7 +354,9 @@ def get_auth_url(self, endpoint_url, method): return self.add_params_sign(method, endpoint_url, params, sign_key) def discover_auth(self): - """ Discovers the location of authentication resourcers from the API""" + """ + Discover the location of authentication resourcers from the API. + """ discovery_url = self.requester.api_url response = self.requester.request('GET', discovery_url) @@ -347,9 +374,11 @@ def discover_auth(self): if not has_authentication_resources: raise UserWarning( ( - "Response does not include location of authentication resources.\n" + "Response does not include location of authentication " + "resources.\n" "Resopnse: %s\n%s\n" - "Please check you have configured the Wordpress OAuth1 plugin correctly." + "Please check you have configured the Wordpress OAuth1 " + "plugin correctly." ) % (response, response.text[:500]) ) @@ -381,13 +410,21 @@ def get_request_token(self): try: self._request_token = resp_content['oauth_token'][0] except: - raise UserWarning("Could not parse request_token in response from %s : %s" - % (repr(response.request.url), UrlUtils.beautify_response(response))) + raise UserWarning( + "Could not parse request_token in response from %s : %s" + % ( + repr(response.request.url), + UrlUtils.beautify_response(response)) + ) try: self.request_token_secret = resp_content['oauth_token_secret'][0] except: - raise UserWarning("Could not parse request_token_secret in response from %s : %s" - % (repr(response.request.url), UrlUtils.beautify_response(response))) + raise UserWarning( + "Could not parse request_token_secret in response from %s : %s" + % ( + repr(response.request.url), + UrlUtils.beautify_response(response)) + ) return self._request_token, self.request_token_secret @@ -400,7 +437,10 @@ def parse_login_form_error(self, response, exc, **kwargs): error = login_form_soup.select_one('body#error-page') if error and error.stripped_strings: for stripped_string in error.stripped_strings: - if "plase solve this math problem" in stripped_string.lower(): + if ( + "plase solve this math problem" + in stripped_string.lower() + ): raise UserWarning( "Can't log in if form has capcha ... yet") raise UserWarning( @@ -439,7 +479,11 @@ def get_form_info(self, response, form_id): form_soup = response_soup.select_one('form#%s' % form_id) assert \ form_soup, "unable to find form with id=%s in %s " \ - % (form_id, (response_soup.prettify()).encode('ascii', errors='backslashreplace')) + % ( + form_id, + (response_soup.prettify()).encode('ascii', + errors='backslashreplace') + ) # print "login form: \n", form_soup.prettify() action = form_soup.get('action') @@ -467,8 +511,12 @@ def get_form_info(self, response, form_id): return action, form_data def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): - """ pretends to be a browser, uses the authorize auth link, submits user creds to WP login form to get - verifier string from access token """ + """ + Get verifier string from access token. + + Pretends to be a browser, uses the authorize auth link, + submits user creds to WP login form. + """ if request_token is None: request_token = self.request_token @@ -513,10 +561,10 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): else: login_form_data[name] = values[0] - assert 'log' in login_form_data, 'input for user login did not appear on form' - assert 'pwd' in login_form_data, 'input for user password did not appear on form' - - # print "submitting login form to %s : %s" % (login_form_action, str(login_form_data)) + assert 'log' in login_form_data, \ + 'input for user login did not appear on form' + assert 'pwd' in login_form_data, \ + 'input for user password did not appear on form' confirmation_response = authorize_session.post( login_form_action, data=login_form_data, allow_redirects=True) @@ -537,16 +585,21 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): else: authorize_form_data[name] = values[0] - assert 'wp-submit' in login_form_data, 'authorize button did not appear on form' + assert 'wp-submit' in login_form_data, \ + 'authorize button did not appear on form' final_response = authorize_session.post( - authorize_form_action, data=authorize_form_data, allow_redirects=False) - - assert \ - final_response.status_code == 302, \ - "was not redirected by authorize screen, was %d instead. something went wrong" \ - % final_response.status_code - assert 'location' in final_response.headers, "redirect did not provide redirect location in header" + authorize_form_action, data=authorize_form_data, + allow_redirects=False) + + assert final_response.status_code == 302, \ + ( + "was not redirected by authorize screen, " + "was %d instead. something went wrong" + % final_response.status_code + ) + assert 'location' in final_response.headers, \ + "redirect did not provide redirect location in header" final_location = final_response.headers['location'] @@ -556,9 +609,11 @@ def get_verifier(self, request_token=None, wp_user=None, wp_pass=None): final_location_queries = parse_qs(urlparse(final_location).query) - assert \ - 'oauth_verifier' in final_location_queries, \ - "oauth verifier not provided in final redirect: %s" % final_location + assert 'oauth_verifier' in final_location_queries, \ + ( + "oauth verifier not provided in final redirect: %s" + % final_location + ) self._oauth_verifier = final_location_queries['oauth_verifier'][0] return self._oauth_verifier @@ -580,7 +635,7 @@ def store_access_creds(self): json.dump(creds, creds_store_file, ensure_ascii=False)) def retrieve_access_creds(self): - """ retrieve the access_token and access_token_secret stored locally. """ + """Retrieve access_token / access_token_secret stored locally.""" if not self.creds_store: return @@ -613,7 +668,8 @@ def get_access_token(self, oauth_verifier=None): if oauth_verifier is None: oauth_verifier = self.oauth_verifier assert oauth_verifier, "Need an oauth verifier to perform this step" - assert self.request_token, "Need a valid request_token to perform this step" + assert self.request_token, \ + "Need a valid request_token to perform this step" params = self.get_params() params += [ @@ -644,10 +700,16 @@ def get_access_token(self, oauth_verifier=None): try: self._access_token = access_response_queries['oauth_token'][0] - self.access_token_secret = access_response_queries['oauth_token_secret'][0] + self.access_token_secret = \ + access_response_queries['oauth_token_secret'][0] except: - raise UserWarning("Could not parse access_token or access_token_secret in response from %s : %s" - % (repr(access_response.request.url), UrlUtils.beautify_response(access_response))) + raise UserWarning( + "Could not parse access_token or access_token_secret in " + "response from %s : %s" + % ( + repr(access_response.request.url), + UrlUtils.beautify_response(access_response)) + ) self.store_access_creds() diff --git a/wordpress/helpers.py b/wordpress/helpers.py index 7d1dc50..6c529ae 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -6,25 +6,23 @@ __title__ = "wordpress-requests" -import re - import posixpath +import re +from collections import OrderedDict -from six import text_type, binary_type +from bs4 import BeautifulSoup +from six import binary_type, text_type +from six.moves import reduce try: - from urllib.parse import urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse + from urllib.parse import (urlencode, quote, unquote, parse_qs, parse_qsl, + urlparse, urlunparse) from urllib.parse import ParseResult as URLParseResult except ImportError: from urllib import urlencode, quote, unquote from urlparse import parse_qs, parse_qsl, urlparse, urlunparse from urlparse import ParseResult as URLParseResult -from collections import OrderedDict -from six.moves import reduce - -from bs4 import BeautifulSoup - class StrUtils(object): @classmethod @@ -79,8 +77,8 @@ def filter_unique_true(cls, list_a): @classmethod def combine_two_ordered_dicts(cls, dict_a, dict_b): """ - Combine OrderedDict a with b by starting with A and overwriting with items from b. - Attempt to preserve order + Combine OrderedDict a with b by starting with A and overwriting with + items from b. Attempt to preserve order """ if not dict_a: return dict_b if dict_b else OrderedDict() @@ -109,12 +107,15 @@ class UrlUtils(object): @classmethod def get_query_list(cls, url): - """ returns the list of queries in the url """ + """Return the list of queries in the url.""" return parse_qsl(urlparse(url).query) @classmethod def get_query_dict_singular(cls, url): - """ return an ordered mapping from each key in the query string to a singular value """ + """ + Return an ordered mapping from each key in the query string to a + singular value. + """ query_list = cls.get_query_list(url) return OrderedDict(query_list) # query_dict = parse_qs(urlparse(url).query) @@ -139,8 +140,8 @@ def get_query_singular(cls, url, key, default=None): """ Gets the value of a single query in a url """ url_params = parse_qs(urlparse(url).query) values = url_params.get(key, [default]) - assert len( - values) == 1, "ambiguous value, could not get singular for key: %s" % key + assert len(values) == 1, \ + "ambiguous value, could not get singular for key: %s" % key return values[0] @classmethod @@ -153,14 +154,6 @@ def del_query_singular(cls, url, key): url = cls.substitute_query(url, query_string) return url - # @classmethod - # def split_url_query(cls, url): - # """ Splits a url, returning the url without query and the query as a dict """ - # parsed_result = urlparse(url) - # parsed_query_dict = parse_qs(parsed_result.query) - # split_url = cls.substitute_query(url) - # return split_url, parsed_query_dict - @classmethod def split_url_query_singular(cls, url): query_dict_singular = cls.get_query_dict_singular(url) @@ -233,7 +226,8 @@ def beautify_response(response): except: pass if 'html' in content_type.lower(): - return BeautifulSoup(response.text, 'lxml').prettify().encode(errors='backslashreplace') + return BeautifulSoup(response.text, 'lxml').prettify().encode( + errors='backslashreplace') else: return response.text @@ -310,7 +304,11 @@ def normalize_str(cls, string): @classmethod def normalize_params(cls, params): - """ Normalize parameters. works with RFC 5849 logic. params is a list of key, value pairs """ + """ + Normalize parameters. + + Works with RFC 5849 logic. params is a list of key, value pairs. + """ if isinstance(params, dict): params = params.items() params = [ @@ -325,7 +323,11 @@ def normalize_params(cls, params): @classmethod def sorted_params(cls, params): - """ Sort parameters. works with RFC 5849 logic. params is a list of key, value pairs """ + """ + Sort parameters. + + works with RFC 5849 logic. params is a list of key, value pairs + """ if isinstance(params, dict): params = params.items() diff --git a/wordpress/transport.py b/wordpress/transport.py index 573b054..9fff2f2 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -7,15 +7,16 @@ __title__ = "wordpress-requests" import logging -from json import dumps as jsonencode from pprint import pformat -from requests import Request, Session +from requests import Session + from wordpress import __default_api__, __default_api_version__, __version__ from wordpress.helpers import SeqUtils, StrUtils, UrlUtils try: - from urllib.parse import urlencode, quote, unquote, parse_qsl, urlparse, urlunparse + from urllib.parse import (urlencode, quote, unquote, parse_qsl, urlparse, + urlunparse) from urllib.parse import ParseResult as URLParseResult except ImportError: from urllib import urlencode, quote, unquote @@ -81,7 +82,9 @@ def endpoint_url(self, endpoint): ] return UrlUtils.join_components(components) - def request(self, method, url, auth=None, params=None, data=None, **kwargs): + def request( + self, method, url, auth=None, params=None, data=None, **kwargs + ): headers = { "user-agent": "Wordpress API Client-Python/%s" % __version__, "accept": "application/json" From da3dbd90d00d7ba5985ff10c11e392957dc805a9 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 15 Oct 2018 16:41:03 +1100 Subject: [PATCH 095/129] add codeclimate coverage --- .travis.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.travis.yml b/.travis.yml index 0cb8b9b..0ce97e2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ language: python sudo: required env: - CODECOV_TOKEN: "da32b183-0d8b-4dc2-9bf9-e1743a39b2c8" + - CC_TEST_REPORTER_ID: "f65f25793658d7b33a3729b7b0303fef71fca3210105bb5b83605afb2fee687e" services: - docker python: @@ -14,8 +15,13 @@ install: - pip install . - pip install -r requirements-test.txt - docker exec -it wpapipython_woocommerce_1 bash -c 'until [ -f .done ]; do sleep 1; done; echo "complete"' +before_script: + - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter + - chmod +x ./cc-test-reporter + - ./cc-test-reporter before-build # command to run tests script: - py.test --cov=wordpress tests.py after_success: - codecov + - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT --debug From 97ed070fde3a6ff25d77125461e550d087673167 Mon Sep 17 00:00:00 2001 From: derwentx Date: Mon, 15 Oct 2018 20:47:54 +1100 Subject: [PATCH 096/129] more sane encoding --- tests.py | 86 +++++++++++++++++++++++++----------------- wordpress/api.py | 24 ++++++------ wordpress/auth.py | 28 +++----------- wordpress/helpers.py | 68 ++++++++++++++++++++++++--------- wordpress/transport.py | 9 ----- 5 files changed, 120 insertions(+), 95 deletions(-) diff --git a/tests.py b/tests.py index 0f77c02..e7bfc0e 100644 --- a/tests.py +++ b/tests.py @@ -1,4 +1,6 @@ """ API Tests """ +from __future__ import unicode_literals + import functools import logging import pdb @@ -14,23 +16,14 @@ import six import wordpress from httmock import HTTMock, all_requests, urlmatch -from six import text_type, u +from six import text_type +from six.moves.urllib.parse import parse_qsl, urlparse from wordpress import __default_api__, __default_api_version__, auth from wordpress.api import API from wordpress.auth import Auth, OAuth from wordpress.helpers import SeqUtils, StrUtils, UrlUtils from wordpress.transport import API_Requests_Wrapper -try: - from urllib.parse import ( - urlencode, quote, unquote, parse_qs, parse_qsl, urlparse, urlunparse - ) - from urllib.parse import ParseResult as URLParseResult -except ImportError: - from urllib import urlencode, quote, unquote - from urlparse import parse_qs, parse_qsl, urlparse, urlunparse - from urlparse import ParseResult as URLParseResult - def debug_on(*exceptions): if not exceptions: @@ -55,6 +48,7 @@ def wrapper(*args, **kwargs): CURRENT_TIMESTAMP = int(time()) SHITTY_NONCE = "" +DEFAULT_ENCODING = sys.getdefaultencoding() class WordpressTestCase(unittest.TestCase): @@ -1007,47 +1001,71 @@ def test_APIPutWithSimpleQuery(self): wcapi.put('products/%s' % (product_id), {"name": original_title}) + @unittest.skipIf(six.PY2, "non-utf8 bytes not supported in python2") + def test_APIPostWithBytesQuery(self): + wcapi = API(**self.api_params) + nonce = b"%f\xff" % random.random() + + data = { + "name": nonce, + "type": "simple", + } + + response = wcapi.post('products', data) + response_obj = response.json() + product_id = response_obj.get('id') + + expected = StrUtils.to_text(nonce, encoding='ascii', errors='replace') + + self.assertEqual( + response_obj.get('name'), + expected, + ) + wcapi.delete('products/%s' % product_id) + + @unittest.skipIf(six.PY2, "non-utf8 bytes not supported in python2") def test_APIPostWithLatin1Query(self): wcapi = API(**self.api_params) - nonce = u"%f\u00ae" % random.random() + nonce = "%f\u00ae" % random.random() data = { "name": nonce.encode('latin-1'), "type": "simple", } - if six.PY2: - response = wcapi.post('products', data) - response_obj = response.json() - product_id = response_obj.get('id') - self.assertEqual(response_obj.get('name'), nonce) - wcapi.delete('products/%s' % product_id) - return - with self.assertRaises(TypeError): - response = wcapi.post('products', data) + response = wcapi.post('products', data) + response_obj = response.json() + product_id = response_obj.get('id') + + expected = StrUtils.to_text( + StrUtils.to_binary(nonce, encoding='latin-1'), + encoding='ascii', errors='replace' + ) + + self.assertEqual( + response_obj.get('name'), + expected + ) + wcapi.delete('products/%s' % product_id) def test_APIPostWithUTF8Query(self): wcapi = API(**self.api_params) - nonce = u"%f\u00ae" % random.random() + nonce = "%f\u00ae" % random.random() data = { "name": nonce.encode('utf8'), "type": "simple", } - if six.PY2: - response = wcapi.post('products', data) - response_obj = response.json() - product_id = response_obj.get('id') - self.assertEqual(response_obj.get('name'), nonce) - wcapi.delete('products/%s' % product_id) - return - with self.assertRaises(TypeError): - response = wcapi.post('products', data) + response = wcapi.post('products', data) + response_obj = response.json() + product_id = response_obj.get('id') + self.assertEqual(response_obj.get('name'), nonce) + wcapi.delete('products/%s' % product_id) def test_APIPostWithUnicodeQuery(self): wcapi = API(**self.api_params) - nonce = u"%f\u00ae" % random.random() + nonce = "%f\u00ae" % random.random() data = { "name": nonce, @@ -1100,7 +1118,7 @@ def test_APIGetWithSimpleQuery(self): self.assertEqual(len(response_obj), 2) def test_APIPostData(self): - nonce = u"%f\u00ae" % random.random() + nonce = "%f\u00ae" % random.random() content = "api test post" @@ -1120,7 +1138,7 @@ def test_APIPostBadData(self): """ No excerpt so should fail to be created. """ - nonce = u"%f\u00ae" % random.random() + nonce = "%f\u00ae" % random.random() data = { 'a': nonce diff --git a/wordpress/api.py b/wordpress/api.py index b3800df..6df1355 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -4,17 +4,18 @@ Wordpress API Class """ -__title__ = "wordpress-api" +from __future__ import unicode_literals # from requests import request import logging -from json import dumps as jsonencode from six import text_type from wordpress.auth import BasicAuth, NoAuth, OAuth, OAuth_3Leg from wordpress.helpers import StrUtils, UrlUtils from wordpress.transport import API_Requests_Wrapper +__title__ = "wordpress-api" + class API(object): """ API Class """ @@ -113,7 +114,7 @@ def request_post_mortem(self, response=None): isinstance(response_json, dict) and ('code' in response_json or 'message' in response_json) ): - reason = u" - ".join([ + reason = " - ".join([ text_type(response_json.get(key)) for key in ['code', 'message', 'data'] if key in response_json @@ -180,15 +181,15 @@ def request_post_mortem(self, response=None): remedy = "try changing url to %s" % header_url msg = ( - u"API call to %s returned \nCODE: " + "API call to %s returned \nCODE: " "%s\nRESPONSE:%s \nHEADERS: %s\nREQ_BODY:%s" - ) % ( + ) % tuple(map(StrUtils.to_text, [ request_url, - text_type(response.status_code), + response.status_code, UrlUtils.beautify_response(response), - text_type(response_headers), - StrUtils.to_binary(request_body)[:1000] - ) + response_headers, + request_body[:1000] + ])) if reason: msg += "\nBecause of %s" % StrUtils.to_binary(reason) if remedy: @@ -206,9 +207,10 @@ def __request(self, method, endpoint, data, **kwargs): 'content-type', 'application/json') if data is not None and content_type.startswith('application/json'): - data = jsonencode(data, ensure_ascii=False) + data = StrUtils.jsonencode(data, ensure_ascii=False) + # enforce utf-8 encoded binary - data = StrUtils.to_binary(data, encoding='utf8') + data = StrUtils.to_binary(data) response = self.requester.request( method=method, diff --git a/wordpress/auth.py b/wordpress/auth.py index 4e2620e..81e2bcb 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -6,40 +6,26 @@ __title__ = "wordpress-auth" -# from base64 import b64encode import binascii import json import logging import os +from collections import OrderedDict from hashlib import sha1, sha256 from hmac import new as HMAC from pprint import pformat from random import randint from time import time -# import webbrowser import requests from requests.auth import HTTPBasicAuth from bs4 import BeautifulSoup +from six.moves.urllib.parse import parse_qs, parse_qsl, quote, urlparse from wordpress import __version__ from .helpers import StrUtils, UrlUtils -try: - from urllib.parse import (urlencode, quote, unquote, parse_qs, parse_qsl, - urlparse, urlunparse) - from urllib.parse import ParseResult as URLParseResult -except ImportError: - from urllib import urlencode, quote, unquote - from urlparse import parse_qs, parse_qsl, urlparse, urlunparse - from urlparse import ParseResult as URLParseResult - -try: - from collections import OrderedDict -except ImportError: - from ordereddict import OrderedDict - class Auth(object): """ Boilerplate for handling authentication stuff. """ @@ -492,13 +478,9 @@ def get_form_info(self, response, form_id): % (form_soup.prettify()).encode('ascii', errors='backslashreplace') form_data = OrderedDict() - for input_soup in form_soup.select('input') + form_soup.select('button'): - # print "input, class:%5s, id=%5s, name=%5s, value=%s" % ( - # input_soup.get('class'), - # input_soup.get('id'), - # input_soup.get('name'), - # input_soup.get('value') - # ) + for input_soup in ( + form_soup.select('input') + form_soup.select('button') + ): name = input_soup.get('name') if not name: continue diff --git a/wordpress/helpers.py b/wordpress/helpers.py index 6c529ae..fda3896 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -1,27 +1,24 @@ # -*- coding: utf-8 -*- """ -Wordpress Hellpers Class +Wordpress Hellper Class """ __title__ = "wordpress-requests" +import json import posixpath import re +import sys from collections import OrderedDict from bs4 import BeautifulSoup -from six import binary_type, text_type +from six import (PY2, PY3, binary_type, iterbytes, string_types, text_type, + unichr) from six.moves import reduce - -try: - from urllib.parse import (urlencode, quote, unquote, parse_qs, parse_qsl, - urlparse, urlunparse) - from urllib.parse import ParseResult as URLParseResult -except ImportError: - from urllib import urlencode, quote, unquote - from urlparse import parse_qs, parse_qsl, urlparse, urlunparse - from urlparse import ParseResult as URLParseResult +from six.moves.urllib.parse import ParseResult as URLParseResult +from six.moves.urllib.parse import (parse_qs, parse_qsl, quote, urlencode, + urlparse, urlunparse) class StrUtils(object): @@ -46,19 +43,54 @@ def eviscerate(cls, *args, **kwargs): return cls.remove_tail(*args, **kwargs) @classmethod - def to_binary(cls, string, encoding='utf8', errors='backslashreplace'): + def to_text(cls, string, encoding='utf-8', errors='replace'): + if isinstance(string, text_type): + return string if isinstance(string, binary_type): try: - string = string.decode('utf8') - except UnicodeDecodeError: - string = string.decode('latin-1') + return string.decode(encoding, errors=errors) + except TypeError: + return ''.join([ + unichr(c) for c in iterbytes(string) + ]) + return text_type(string) + + @classmethod + def to_binary(cls, string, encoding='utf8', errors='backslashreplace'): + if isinstance(string, binary_type): + return string if not isinstance(string, text_type): string = text_type(string) - return string.encode(encoding, errors=errors) + return string.encode(encoding, errors) @classmethod - def to_binary_ascii(cls, string): - return cls.to_binary(string, 'ascii') + def jsonencode(cls, data, **kwargs): + # kwargs['cls'] = BytesJsonEncoder + # if PY2: + # kwargs['encoding'] = 'utf8' + if PY2: + for encoding in [ + kwargs.get('encoding', 'utf8'), + sys.getdefaultencoding(), + 'utf8', + ]: + try: + kwargs['encoding'] = encoding + return json.dumps(data, **kwargs) + except UnicodeDecodeError: + pass + kwargs.pop('encoding', None) + kwargs['cls'] = BytesJsonEncoder + return json.dumps(data, **kwargs) + + +class BytesJsonEncoder(json.JSONEncoder): + def default(self, obj): + + if isinstance(obj, binary_type): + return StrUtils.to_text(obj, errors='replace') + # Let the base class default method raise the TypeError + return json.JSONEncoder.default(self, obj) class SeqUtils(object): diff --git a/wordpress/transport.py b/wordpress/transport.py index 9fff2f2..8872a3b 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -14,15 +14,6 @@ from wordpress import __default_api__, __default_api_version__, __version__ from wordpress.helpers import SeqUtils, StrUtils, UrlUtils -try: - from urllib.parse import (urlencode, quote, unquote, parse_qsl, urlparse, - urlunparse) - from urllib.parse import ParseResult as URLParseResult -except ImportError: - from urllib import urlencode, quote, unquote - from urlparse import parse_qsl, urlparse, urlunparse - from urlparse import ParseResult as URLParseResult - class API_Requests_Wrapper(object): """ provides a wrapper for making requests that handles session info """ From 8cc919c57261385473c8aa4da605a46dfc97db0c Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 16 Oct 2018 09:00:37 +1100 Subject: [PATCH 097/129] Hardening encoding in post-mortem --- wordpress/api.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/wordpress/api.py b/wordpress/api.py index 6df1355..eecc8b2 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -169,11 +169,16 @@ def request_post_mortem(self, response=None): if header_api_url: header_api_url = StrUtils.eviscerate(header_api_url, '/') - if header_api_url and requester_api_url\ - and header_api_url != requester_api_url: - reason = "hostname mismatch. %s != %s" % ( - header_api_url, requester_api_url - ) + if ( + header_api_url and requester_api_url + and StrUtils.to_text(header_api_url) + != StrUtils.to_text(requester_api_url) + ): + reason = "hostname mismatch. %s != %s" % tuple(map( + StrUtils.to_text, [ + header_api_url, requester_api_url + ] + )) header_url = StrUtils.eviscerate(header_api_url, '/') header_url = StrUtils.eviscerate( header_url, self.requester.api) @@ -188,7 +193,7 @@ def request_post_mortem(self, response=None): response.status_code, UrlUtils.beautify_response(response), response_headers, - request_body[:1000] + StrUtils.to_binary(request_body)[:1000] ])) if reason: msg += "\nBecause of %s" % StrUtils.to_binary(reason) From 80a993eca6078cdc36897e2551f0c545e16f7793 Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 16 Oct 2018 09:01:03 +1100 Subject: [PATCH 098/129] Harden content_type detection in __request was always detecting content-type as json --- wordpress/api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/wordpress/api.py b/wordpress/api.py index eecc8b2..491dce5 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -208,8 +208,10 @@ def __request(self, method, endpoint, data, **kwargs): endpoint_url = self.auth.get_auth_url(endpoint_url, method, **kwargs) auth = self.auth.get_auth() - content_type = kwargs.get('headers', {}).get( - 'content-type', 'application/json') + content_type = 'application/json' + for key, value in kwargs.get('headers', {}).items(): + if key.lower() == 'content-type': + content_type = value.lower() if data is not None and content_type.startswith('application/json'): data = StrUtils.jsonencode(data, ensure_ascii=False) From 51fcb5f3231c02e1c4fb66cfbd4971b7ed421800 Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 16 Oct 2018 09:01:51 +1100 Subject: [PATCH 099/129] allow ignorable kwargs in get_auth_url --- wordpress/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress/auth.py b/wordpress/auth.py index 81e2bcb..fc76a54 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -318,7 +318,7 @@ def creds_store(self): if self._creds_store: return os.path.expanduser(self._creds_store) - def get_auth_url(self, endpoint_url, method): + def get_auth_url(self, endpoint_url, method, **kwargs): """ Return the URL with OAuth params. """ From 50669dc73196b2417e8b21992c8cf77f646ffd1e Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 16 Oct 2018 09:15:19 +1100 Subject: [PATCH 100/129] Split tests into multiple files additional tests for images --- README.rst | 10 +- setup.cfg | 2 +- tests.py | 1173 --------------------------------------- tests/__init__.py | 36 ++ tests/data/test.jpg | Bin 0 -> 114586 bytes tests/test_api.py | 503 +++++++++++++++++ tests/test_auth.py | 478 ++++++++++++++++ tests/test_helpers.py | 188 +++++++ tests/test_transport.py | 43 ++ 9 files changed, 1258 insertions(+), 1175 deletions(-) delete mode 100644 tests.py create mode 100644 tests/__init__.py create mode 100644 tests/data/test.jpg create mode 100644 tests/test_api.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_helpers.py create mode 100644 tests/test_transport.py diff --git a/README.rst b/README.rst index af7c883..af5f82a 100644 --- a/README.rst +++ b/README.rst @@ -263,7 +263,6 @@ Upload an image endpoint = "/media" return wpapi.post(endpoint, data, headers=headers) - Response -------- @@ -298,6 +297,15 @@ According the the [documentation](https://developer.wordpress.org/rest-api/refer >>> response.json() {“deleted”:true, ... } +A Note on Encoding +==== + +In Python2, make sure to only `POST` unicode string objects or strings that +have been correctly encoded as utf-8. Serializing objects containing non-utf8 +byte strings in Python2 is broken by importing `unicode_literals` from +`__future__` because of a bug in `json.dumps`. You may be able to get around +this problem by serializing the data yourself. + Changelog --------- diff --git a/setup.cfg b/setup.cfg index f35a17e..224224d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,7 @@ test=pytest [tool:pytest] addopts = --verbose -python_files = tests.py +python_files = tests/test_*.py [pylama] skip=\.*,build/*,dist/*,*.egg-info [pylama:tests.py] diff --git a/tests.py b/tests.py deleted file mode 100644 index e7bfc0e..0000000 --- a/tests.py +++ /dev/null @@ -1,1173 +0,0 @@ -""" API Tests """ -from __future__ import unicode_literals - -import functools -import logging -import pdb -import random -import sys -import traceback -import unittest -from collections import OrderedDict -from copy import copy -from tempfile import mkstemp -from time import time - -import six -import wordpress -from httmock import HTTMock, all_requests, urlmatch -from six import text_type -from six.moves.urllib.parse import parse_qsl, urlparse -from wordpress import __default_api__, __default_api_version__, auth -from wordpress.api import API -from wordpress.auth import Auth, OAuth -from wordpress.helpers import SeqUtils, StrUtils, UrlUtils -from wordpress.transport import API_Requests_Wrapper - - -def debug_on(*exceptions): - if not exceptions: - exceptions = (AssertionError, ) - - def decorator(f): - @functools.wraps(f) - def wrapper(*args, **kwargs): - prev_root = copy(logging.root) - try: - logging.basicConfig(level=logging.DEBUG) - return f(*args, **kwargs) - except exceptions: - info = sys.exc_info() - traceback.print_exception(*info) - pdb.post_mortem(info[2]) - finally: - logging.root = prev_root - return wrapper - return decorator - - -CURRENT_TIMESTAMP = int(time()) -SHITTY_NONCE = "" -DEFAULT_ENCODING = sys.getdefaultencoding() - - -class WordpressTestCase(unittest.TestCase): - """Test case for the client methods.""" - - def setUp(self): - self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" - self.consumer_secret = "cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" - self.api = wordpress.API( - url="http://woo.test", - consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret - ) - - def test_api(self): - """ Test default API """ - api = wordpress.API( - url="https://woo.test", - consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret - ) - - self.assertEqual(api.namespace, __default_api__) - - def test_version(self): - """ Test default version """ - api = wordpress.API( - url="https://woo.test", - consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret - ) - - self.assertEqual(api.version, __default_api_version__) - - def test_non_ssl(self): - """ Test non-ssl """ - api = wordpress.API( - url="http://woo.test", - consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret - ) - self.assertFalse(api.is_ssl) - - def test_with_ssl(self): - """ Test non-ssl """ - api = wordpress.API( - url="https://woo.test", - consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret - ) - self.assertTrue(api.is_ssl, True) - - def test_with_timeout(self): - """ Test non-ssl """ - api = wordpress.API( - url="https://woo.test", - consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret, - timeout=10, - ) - self.assertEqual(api.timeout, 10) - - @all_requests - def woo_test_mock(*args, **kwargs): - """ URL Mock """ - return {'status_code': 200, - 'content': b'OK'} - - with HTTMock(woo_test_mock): - # call requests - status = api.get("products").status_code - self.assertEqual(status, 200) - - def test_get(self): - """ Test GET requests """ - @all_requests - def woo_test_mock(*args, **kwargs): - """ URL Mock """ - return {'status_code': 200, - 'content': b'OK'} - - with HTTMock(woo_test_mock): - # call requests - status = self.api.get("products").status_code - self.assertEqual(status, 200) - - def test_post(self): - """ Test POST requests """ - @all_requests - def woo_test_mock(*args, **kwargs): - """ URL Mock """ - return {'status_code': 201, - 'content': b'OK'} - - with HTTMock(woo_test_mock): - # call requests - status = self.api.post("products", {}).status_code - self.assertEqual(status, 201) - - def test_put(self): - """ Test PUT requests """ - @all_requests - def woo_test_mock(*args, **kwargs): - """ URL Mock """ - return {'status_code': 200, - 'content': b'OK'} - - with HTTMock(woo_test_mock): - # call requests - status = self.api.put("products", {}).status_code - self.assertEqual(status, 200) - - def test_delete(self): - """ Test DELETE requests """ - @all_requests - def woo_test_mock(*args, **kwargs): - """ URL Mock """ - return {'status_code': 200, - 'content': b'OK'} - - with HTTMock(woo_test_mock): - # call requests - status = self.api.delete("products").status_code - self.assertEqual(status, 200) - - # @unittest.skip("going by RRC 5849 sorting instead") - def test_oauth_sorted_params(self): - """ Test order of parameters for OAuth signature """ - def check_sorted(keys, expected): - params = auth.OrderedDict() - for key in keys: - params[key] = '' - - params = UrlUtils.sorted_params(params) - ordered = [key for key, value in params] - 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]']) - - -class HelperTestcase(unittest.TestCase): - def setUp(self): - self.test_url = ( - "http://ich.local:8888/woocommerce/wc-api/v3/products?" - "filter%5Blimit%5D=2&" - "oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&" - "oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&" - "oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&" - "oauth_signature_method=HMAC-SHA1&" - "oauth_timestamp=1481601370&page=2" - ) - - def test_url_is_ssl(self): - self.assertTrue(UrlUtils.is_ssl("https://woo.test:8888")) - self.assertFalse(UrlUtils.is_ssl("http://woo.test:8888")) - - def test_url_substitute_query(self): - self.assertEqual( - UrlUtils.substitute_query( - "https://woo.test:8888/sdf?param=value", "newparam=newvalue"), - "https://woo.test:8888/sdf?newparam=newvalue" - ) - self.assertEqual( - UrlUtils.substitute_query("https://woo.test:8888/sdf?param=value"), - "https://woo.test:8888/sdf" - ) - self.assertEqual( - UrlUtils.substitute_query( - "https://woo.test:8888/sdf?param=value", - "newparam=newvalue&othernewparam=othernewvalue" - ), - ( - "https://woo.test:8888/sdf?newparam=newvalue&" - "othernewparam=othernewvalue" - ) - ) - self.assertEqual( - UrlUtils.substitute_query( - "https://woo.test:8888/sdf?param=value", - "newparam=newvalue&othernewparam=othernewvalue" - ), - ( - "https://woo.test:8888/sdf?newparam=newvalue&" - "othernewparam=othernewvalue" - ) - ) - - def test_url_add_query(self): - self.assertEqual( - "https://woo.test:8888/sdf?param=value&newparam=newvalue", - UrlUtils.add_query( - "https://woo.test:8888/sdf?param=value", 'newparam', 'newvalue' - ) - ) - - def test_url_join_components(self): - self.assertEqual( - 'https://woo.test:8888/wp-json', - UrlUtils.join_components(['https://woo.test:8888/', '', 'wp-json']) - ) - self.assertEqual( - 'https://woo.test:8888/wp-json/wp/v2', - UrlUtils.join_components( - ['https://woo.test:8888/', 'wp-json', 'wp/v2']) - ) - - def test_url_get_php_value(self): - self.assertEqual( - '1', - UrlUtils.get_value_like_as_php(True) - ) - self.assertEqual( - '', - UrlUtils.get_value_like_as_php(False) - ) - self.assertEqual( - 'asd', - UrlUtils.get_value_like_as_php('asd') - ) - self.assertEqual( - '1', - UrlUtils.get_value_like_as_php(1) - ) - self.assertEqual( - '1', - UrlUtils.get_value_like_as_php(1.0) - ) - self.assertEqual( - '1.1', - UrlUtils.get_value_like_as_php(1.1) - ) - - def test_url_get_query_dict_singular(self): - result = UrlUtils.get_query_dict_singular(self.test_url) - self.assertEquals( - result, - { - 'filter[limit]': '2', - 'oauth_nonce': 'c4f2920b0213c43f2e8d3d3333168ec4a22222d1', - 'oauth_timestamp': '1481601370', - 'oauth_consumer_key': - 'ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', - 'oauth_signature_method': 'HMAC-SHA1', - 'oauth_signature': '3ibOjMuhj6JGnI43BQZGniigHh8=', - 'page': '2' - } - ) - - def test_url_get_query_singular(self): - result = UrlUtils.get_query_singular( - self.test_url, 'oauth_consumer_key') - self.assertEqual( - result, - 'ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' - ) - result = UrlUtils.get_query_singular(self.test_url, 'filter[limit]') - self.assertEqual( - text_type(result), - text_type(2) - ) - - def test_url_set_query_singular(self): - result = UrlUtils.set_query_singular(self.test_url, 'filter[limit]', 3) - expected = ( - "http://ich.local:8888/woocommerce/wc-api/v3/products?" - "filter%5Blimit%5D=3&" - "oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&" - "oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&" - "oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&" - "oauth_signature_method=HMAC-SHA1&oauth_timestamp=1481601370&" - "page=2" - ) - self.assertEqual(result, expected) - - def test_url_del_query_singular(self): - result = UrlUtils.del_query_singular(self.test_url, 'filter[limit]') - expected = ( - "http://ich.local:8888/woocommerce/wc-api/v3/products?" - "oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&" - "oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&" - "oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&" - "oauth_signature_method=HMAC-SHA1&" - "oauth_timestamp=1481601370&" - "page=2" - ) - self.assertEqual(result, expected) - - def test_url_remove_default_port(self): - self.assertEqual( - UrlUtils.remove_default_port('http://www.gooogle.com:80/'), - 'http://www.gooogle.com/' - ) - self.assertEqual( - UrlUtils.remove_default_port('http://www.gooogle.com:18080/'), - 'http://www.gooogle.com:18080/' - ) - - def test_seq_filter_true(self): - self.assertEquals( - ['a', 'b', 'c', 'd'], - SeqUtils.filter_true([None, 'a', False, 'b', 'c', 'd']) - ) - - def test_str_remove_tail(self): - self.assertEqual( - 'sdf', - StrUtils.remove_tail('sdf/', '/') - ) - - def test_str_remove_head(self): - self.assertEqual( - 'sdf', - StrUtils.remove_head('/sdf', '/') - ) - - self.assertEqual( - 'sdf', - StrUtils.decapitate('sdf', '/') - ) - - -class TransportTestcases(unittest.TestCase): - def setUp(self): - self.requester = API_Requests_Wrapper( - url='https://woo.test:8888/', - api='wp-json', - api_version='wp/v2' - ) - - def test_api_url(self): - self.assertEqual( - 'https://woo.test:8888/wp-json', - self.requester.api_url - ) - - def test_endpoint_url(self): - self.assertEqual( - 'https://woo.test:8888/wp-json/wp/v2/posts', - self.requester.endpoint_url('posts') - ) - - def test_request(self): - - @all_requests - def woo_test_mock(*args, **kwargs): - """ URL Mock """ - return {'status_code': 200, - 'content': b'OK'} - - with HTTMock(woo_test_mock): - # call requests - response = self.requester.request( - "GET", "https://woo.test:8888/wp-json/wp/v2/posts") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.request.url, - 'https://woo.test:8888/wp-json/wp/v2/posts') - - -class BasicAuthTestcases(unittest.TestCase): - def setUp(self): - self.base_url = "http://localhost:8888/wp-api/" - self.api_name = 'wc-api' - self.api_ver = 'v3' - self.endpoint = 'products/26' - self.signature_method = "HMAC-SHA1" - - self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" - self.consumer_secret = "cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" - self.api_params = dict( - url=self.base_url, - consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret, - basic_auth=True, - api=self.api_name, - version=self.api_ver, - query_string_auth=False, - ) - - def test_endpoint_url(self): - api = API( - **self.api_params - ) - endpoint_url = api.requester.endpoint_url(self.endpoint) - endpoint_url = api.auth.get_auth_url(endpoint_url, 'GET') - self.assertEqual( - endpoint_url, - UrlUtils.join_components([ - self.base_url, self.api_name, self.api_ver, self.endpoint - ]) - ) - - def test_query_string_endpoint_url(self): - query_string_api_params = dict(**self.api_params) - query_string_api_params.update(dict(query_string_auth=True)) - api = API( - **query_string_api_params - ) - endpoint_url = api.requester.endpoint_url(self.endpoint) - endpoint_url = api.auth.get_auth_url(endpoint_url, 'GET') - expected_endpoint_url = '%s?consumer_key=%s&consumer_secret=%s' % ( - self.endpoint, self.consumer_key, self.consumer_secret) - expected_endpoint_url = UrlUtils.join_components( - [self.base_url, self.api_name, self.api_ver, expected_endpoint_url] - ) - self.assertEqual( - endpoint_url, - expected_endpoint_url - ) - endpoint_url = api.requester.endpoint_url(self.endpoint) - endpoint_url = api.auth.get_auth_url(endpoint_url, 'GET') - - -class OAuthTestcases(unittest.TestCase): - - def setUp(self): - self.base_url = "http://localhost:8888/wordpress/" - self.api_name = 'wc-api' - self.api_ver = 'v3' - self.endpoint = 'products/99' - self.signature_method = "HMAC-SHA1" - self.consumer_key = "ck_681c2be361e415519dce4b65ee981682cda78bc6" - self.consumer_secret = "cs_b11f652c39a0afd3752fc7bb0c56d60d58da5877" - - self.wcapi = API( - url=self.base_url, - consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret, - api=self.api_name, - version=self.api_ver, - signature_method=self.signature_method - ) - - self.rfc1_api_url = 'https://photos.example.net/' - self.rfc1_consumer_key = 'dpf43f3p2l4k3l03' - self.rfc1_consumer_secret = 'kd94hf93k423kf44' - self.rfc1_oauth_token = 'hh5s93j4hdidpola' - self.rfc1_signature_method = 'HMAC-SHA1' - self.rfc1_callback = 'http://printer.example.com/ready' - self.rfc1_api = API( - url=self.rfc1_api_url, - consumer_key=self.rfc1_consumer_key, - consumer_secret=self.rfc1_consumer_secret, - api='', - version='', - callback=self.rfc1_callback, - wp_user='', - wp_pass='', - oauth1a_3leg=True - ) - self.rfc1_request_method = 'POST' - self.rfc1_request_target_url = 'https://photos.example.net/initiate' - self.rfc1_request_timestamp = '137131200' - self.rfc1_request_nonce = 'wIjqoS' - self.rfc1_request_params = [ - ('oauth_consumer_key', self.rfc1_consumer_key), - ('oauth_signature_method', self.rfc1_signature_method), - ('oauth_timestamp', self.rfc1_request_timestamp), - ('oauth_nonce', self.rfc1_request_nonce), - ('oauth_callback', self.rfc1_callback), - ] - self.rfc1_request_signature = b'74KNZJeDHnMBp0EMJ9ZHt/XKycU=' - - self.twitter_api_url = "https://api.twitter.com/" - self.twitter_consumer_secret = \ - "kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw" - self.twitter_consumer_key = "xvz1evFS4wEEPTGEFPHBog" - self.twitter_signature_method = "HMAC-SHA1" - self.twitter_api = API( - url=self.twitter_api_url, - consumer_key=self.twitter_consumer_key, - consumer_secret=self.twitter_consumer_secret, - api='', - version='1', - signature_method=self.twitter_signature_method, - ) - - self.twitter_method = "POST" - self.twitter_target_url = ( - "https://api.twitter.com/1/statuses/update.json?" - "include_entities=true" - ) - self.twitter_params_raw = [ - ("status", "Hello Ladies + Gentlemen, a signed OAuth request!"), - ("include_entities", "true"), - ("oauth_consumer_key", self.twitter_consumer_key), - ("oauth_nonce", "kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg"), - ("oauth_signature_method", self.twitter_signature_method), - ("oauth_timestamp", "1318622958"), - ("oauth_token", - "370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb"), - ("oauth_version", "1.0"), - ] - self.twitter_param_string = ( - r"include_entities=true&" - r"oauth_consumer_key=xvz1evFS4wEEPTGEFPHBog&" - r"oauth_nonce=kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg&" - r"oauth_signature_method=HMAC-SHA1&" - r"oauth_timestamp=1318622958&" - r"oauth_token=370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb&" - r"oauth_version=1.0&" - r"status=Hello%20Ladies%20%2B%20Gentlemen%2C%20a%20" - r"signed%20OAuth%20request%21" - ) - self.twitter_signature_base_string = ( - r"POST&" - r"https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&" - r"include_entities%3Dtrue%26" - r"oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26" - r"oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26" - r"oauth_signature_method%3DHMAC-SHA1%26" - r"oauth_timestamp%3D1318622958%26" - r"oauth_token%3D370773112-" - r"GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26" - r"oauth_version%3D1.0%26" - r"status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520" - r"a%2520signed%2520OAuth%2520request%2521" - ) - self.twitter_token_secret = 'LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE' - self.twitter_signing_key = ( - 'kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw&' - 'LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE' - ) - self.twitter_oauth_signature = b'tnnArxj06cWHq44gCs1OSKk/jLY=' - - self.lexev_consumer_key = 'your_app_key' - self.lexev_consumer_secret = 'your_app_secret' - self.lexev_callback = 'http://127.0.0.1/oauth1_callback' - self.lexev_signature_method = 'HMAC-SHA1' - self.lexev_version = '1.0' - self.lexev_api = API( - url='https://bitbucket.org/', - api='api', - version='1.0', - consumer_key=self.lexev_consumer_key, - consumer_secret=self.lexev_consumer_secret, - signature_method=self.lexev_signature_method, - callback=self.lexev_callback, - wp_user='', - wp_pass='', - oauth1a_3leg=True - ) - self.lexev_request_method = 'POST' - self.lexev_request_url = \ - 'https://bitbucket.org/api/1.0/oauth/request_token' - self.lexev_request_nonce = '27718007815082439851427366369' - self.lexev_request_timestamp = '1427366369' - self.lexev_request_params = [ - ('oauth_callback', self.lexev_callback), - ('oauth_consumer_key', self.lexev_consumer_key), - ('oauth_nonce', self.lexev_request_nonce), - ('oauth_signature_method', self.lexev_signature_method), - ('oauth_timestamp', self.lexev_request_timestamp), - ('oauth_version', self.lexev_version), - ] - self.lexev_request_signature = b"iPdHNIu4NGOjuXZ+YCdPWaRwvJY=" - self.lexev_resource_url = ( - 'https://api.bitbucket.org/1.0/repositories/st4lk/' - 'django-articles-transmeta/branches' - ) - - def test_get_sign_key(self): - self.assertEqual( - StrUtils.to_binary( - self.wcapi.auth.get_sign_key(self.consumer_secret)), - StrUtils.to_binary("%s&" % self.consumer_secret) - ) - - self.assertEqual( - StrUtils.to_binary(self.wcapi.auth.get_sign_key( - self.twitter_consumer_secret, self.twitter_token_secret)), - StrUtils.to_binary(self.twitter_signing_key) - ) - - def test_flatten_params(self): - self.assertEqual( - StrUtils.to_binary(UrlUtils.flatten_params( - self.twitter_params_raw)), - StrUtils.to_binary(self.twitter_param_string) - ) - - def test_sorted_params(self): - # Example given in oauth.net: - oauthnet_example_sorted = [ - ('a', '1'), - ('c', 'hi%%20there'), - ('f', '25'), - ('f', '50'), - ('f', 'a'), - ('z', 'p'), - ('z', 't') - ] - - oauthnet_example = copy(oauthnet_example_sorted) - random.shuffle(oauthnet_example) - - self.assertEqual( - UrlUtils.sorted_params(oauthnet_example), - oauthnet_example_sorted - ) - - def test_get_signature_base_string(self): - twitter_param_string = OAuth.get_signature_base_string( - self.twitter_method, - self.twitter_params_raw, - self.twitter_target_url - ) - self.assertEqual( - twitter_param_string, - self.twitter_signature_base_string - ) - - def test_generate_oauth_signature(self): - - rfc1_request_signature = self.rfc1_api.auth.generate_oauth_signature( - self.rfc1_request_method, - self.rfc1_request_params, - self.rfc1_request_target_url, - '%s&' % self.rfc1_consumer_secret - ) - self.assertEqual( - text_type(rfc1_request_signature), - text_type(self.rfc1_request_signature) - ) - - # TEST WITH RFC EXAMPLE 3 DATA - - # TEST WITH TWITTER DATA - - twitter_signature = self.twitter_api.auth.generate_oauth_signature( - self.twitter_method, - self.twitter_params_raw, - self.twitter_target_url, - self.twitter_signing_key - ) - self.assertEqual(twitter_signature, self.twitter_oauth_signature) - - # TEST WITH LEXEV DATA - - lexev_request_signature = self.lexev_api.auth.generate_oauth_signature( - method=self.lexev_request_method, - params=self.lexev_request_params, - url=self.lexev_request_url - ) - self.assertEqual(lexev_request_signature, self.lexev_request_signature) - - def test_add_params_sign(self): - endpoint_url = self.wcapi.requester.endpoint_url('products?page=2') - - params = OrderedDict() - params["oauth_consumer_key"] = self.consumer_key - params["oauth_timestamp"] = "1477041328" - params["oauth_nonce"] = "166182658461433445531477041328" - params["oauth_signature_method"] = self.signature_method - params["oauth_version"] = "1.0" - params["oauth_callback"] = 'localhost:8888/wordpress' - - signed_url = self.wcapi.auth.add_params_sign( - "GET", endpoint_url, params) - - signed_url_params = parse_qsl(urlparse(signed_url).query) - # self.assertEqual('page', signed_url_params[-1][0]) - self.assertIn('page', dict(signed_url_params)) - - -class OAuth3LegTestcases(unittest.TestCase): - def setUp(self): - self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" - self.consumer_secret = "cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" - self.api = API( - url="http://woo.test", - consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret, - oauth1a_3leg=True, - wp_user='test_user', - wp_pass='test_pass', - callback='http://127.0.0.1/oauth1_callback' - ) - - @urlmatch(path=r'.*wp-json.*') - def woo_api_mock(*args, **kwargs): - """ URL Mock """ - return { - 'status_code': 200, - 'content': b""" - { - "name": "Wordpress", - "description": "Just another WordPress site", - "url": "http://localhost:8888/wordpress", - "home": "http://localhost:8888/wordpress", - "namespaces": [ - "wp/v2", - "oembed/1.0", - "wc/v1" - ], - "authentication": { - "oauth1": { - "request": - "http://localhost:8888/wordpress/oauth1/request", - "authorize": - "http://localhost:8888/wordpress/oauth1/authorize", - "access": - "http://localhost:8888/wordpress/oauth1/access", - "version": "0.1" - } - } - } - """ - } - - @urlmatch(path=r'.*oauth.*') - def woo_authentication_mock(*args, **kwargs): - """ URL Mock """ - return { - 'status_code': 200, - 'content': - b"""oauth_token=XXXXXXXXXXXX&oauth_token_secret=YYYYYYYYYYYY""" - } - - def test_get_sign_key(self): - oauth_token_secret = "PNW9j1yBki3e7M7EqB5qZxbe9n5tR6bIIefSMQ9M2pdyRI9g" - - key = self.api.auth.get_sign_key( - self.consumer_secret, oauth_token_secret) - self.assertEqual( - StrUtils.to_binary(key), - StrUtils.to_binary("%s&%s" % - (self.consumer_secret, oauth_token_secret)) - ) - - def test_auth_discovery(self): - - with HTTMock(self.woo_api_mock): - # call requests - authentication = self.api.auth.authentication - self.assertEquals( - authentication, - { - "oauth1": { - "request": - "http://localhost:8888/wordpress/oauth1/request", - "authorize": - "http://localhost:8888/wordpress/oauth1/authorize", - "access": - "http://localhost:8888/wordpress/oauth1/access", - "version": "0.1" - } - } - ) - - def test_get_request_token(self): - - with HTTMock(self.woo_api_mock): - authentication = self.api.auth.authentication - self.assertTrue(authentication) - - with HTTMock(self.woo_authentication_mock): - request_token, request_token_secret = \ - self.api.auth.get_request_token() - self.assertEquals(request_token, 'XXXXXXXXXXXX') - self.assertEquals(request_token_secret, 'YYYYYYYYYYYY') - - def test_store_access_creds(self): - _, creds_store_path = mkstemp( - "wp-api-python-test-store-access-creds.json") - api = API( - url="http://woo.test", - consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret, - oauth1a_3leg=True, - wp_user='test_user', - wp_pass='test_pass', - callback='http://127.0.0.1/oauth1_callback', - access_token='XXXXXXXXXXXX', - access_token_secret='YYYYYYYYYYYY', - creds_store=creds_store_path - ) - api.auth.store_access_creds() - - with open(creds_store_path) as creds_store_file: - self.assertEqual( - creds_store_file.read(), - ('{"access_token": "XXXXXXXXXXXX", ' - '"access_token_secret": "YYYYYYYYYYYY"}') - ) - - def test_retrieve_access_creds(self): - _, creds_store_path = mkstemp( - "wp-api-python-test-store-access-creds.json") - with open(creds_store_path, 'w+') as creds_store_file: - creds_store_file.write( - ('{"access_token": "XXXXXXXXXXXX", ' - '"access_token_secret": "YYYYYYYYYYYY"}')) - - api = API( - url="http://woo.test", - consumer_key=self.consumer_key, - consumer_secret=self.consumer_secret, - oauth1a_3leg=True, - wp_user='test_user', - wp_pass='test_pass', - callback='http://127.0.0.1/oauth1_callback', - creds_store=creds_store_path - ) - - api.auth.retrieve_access_creds() - - self.assertEqual( - api.auth.access_token, - 'XXXXXXXXXXXX' - ) - - self.assertEqual( - api.auth.access_token_secret, - 'YYYYYYYYYYYY' - ) - - -class WCApiTestCasesBase(unittest.TestCase): - """ Base class for WC API Test cases """ - - def setUp(self): - Auth.force_timestamp = CURRENT_TIMESTAMP - Auth.force_nonce = SHITTY_NONCE - self.api_params = { - 'url': 'http://localhost:8083/', - 'api': 'wc-api', - 'version': 'v3', - 'consumer_key': 'ck_659f6994ae88fed68897f9977298b0e19947979a', - 'consumer_secret': 'cs_9421d39290f966172fef64ae18784a2dc7b20976', - } - - -class WCApiTestCasesLegacy(WCApiTestCasesBase): - """ Tests for WC API V3 """ - - def setUp(self): - super(WCApiTestCasesLegacy, self).setUp() - self.api_params['version'] = 'v3' - self.api_params['api'] = 'wc-api' - - def test_APIGet(self): - wcapi = API(**self.api_params) - response = wcapi.get('products') - # print UrlUtils.beautify_response(response) - self.assertIn(response.status_code, [200, 201]) - response_obj = response.json() - self.assertIn('products', response_obj) - self.assertEqual(len(response_obj['products']), 10) - # print "test_APIGet", response_obj - - def test_APIGetWithSimpleQuery(self): - wcapi = API(**self.api_params) - response = wcapi.get('products?page=2') - # print UrlUtils.beautify_response(response) - self.assertIn(response.status_code, [200, 201]) - - response_obj = response.json() - self.assertIn('products', response_obj) - self.assertEqual(len(response_obj['products']), 8) - # print "test_ApiGenWithSimpleQuery", response_obj - - def test_APIGetWithComplexQuery(self): - wcapi = API(**self.api_params) - response = wcapi.get('products?page=2&filter%5Blimit%5D=2') - self.assertIn(response.status_code, [200, 201]) - response_obj = response.json() - self.assertIn('products', response_obj) - self.assertEqual(len(response_obj['products']), 2) - - response = wcapi.get( - 'products?' - 'oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&' - 'oauth_nonce=037470f3b08c9232b0888f52cb9d4685b44d8fd1&' - 'oauth_signature=wrKfuIjbwi%2BTHynAlTP4AssoPS0%3D&' - 'oauth_signature_method=HMAC-SHA1&' - 'oauth_timestamp=1481606275&' - 'filter%5Blimit%5D=3' - ) - self.assertIn(response.status_code, [200, 201]) - response_obj = response.json() - self.assertIn('products', response_obj) - self.assertEqual(len(response_obj['products']), 3) - - def test_APIPutWithSimpleQuery(self): - wcapi = API(**self.api_params) - response = wcapi.get('products') - first_product = (response.json())['products'][0] - original_title = first_product['title'] - product_id = first_product['id'] - - nonce = b"%f" % (random.random()) - response = wcapi.put('products/%s?filter%%5Blimit%%5D=5' % - (product_id), - {"product": {"title": text_type(nonce)}}) - request_params = UrlUtils.get_query_dict_singular(response.request.url) - response_obj = response.json() - self.assertEqual(response_obj['product']['title'], text_type(nonce)) - self.assertEqual(request_params['filter[limit]'], text_type(5)) - - wcapi.put('products/%s' % (product_id), - {"product": {"title": original_title}}) - - -class WCApiTestCases(WCApiTestCasesBase): - oauth1a_3leg = False - """ Tests for New wp-json/wc/v2 API """ - - def setUp(self): - super(WCApiTestCases, self).setUp() - self.api_params['version'] = 'wc/v2' - self.api_params['api'] = 'wp-json' - self.api_params['callback'] = 'http://127.0.0.1/oauth1_callback' - if self.oauth1a_3leg: - self.api_params['oauth1a_3leg'] = True - - # @debug_on() - def test_APIGet(self): - wcapi = API(**self.api_params) - per_page = 10 - response = wcapi.get('products?per_page=%d' % per_page) - self.assertIn(response.status_code, [200, 201]) - response_obj = response.json() - self.assertEqual(len(response_obj), per_page) - - def test_APIPutWithSimpleQuery(self): - wcapi = API(**self.api_params) - response = wcapi.get('products') - first_product = (response.json())[0] - # from pprint import pformat - # print "first product %s" % pformat(response.json()) - original_title = first_product['name'] - product_id = first_product['id'] - - nonce = b"%f" % (random.random()) - response = wcapi.put('products/%s?page=2&per_page=5' % - (product_id), {"name": text_type(nonce)}) - request_params = UrlUtils.get_query_dict_singular(response.request.url) - response_obj = response.json() - self.assertEqual(response_obj['name'], text_type(nonce)) - self.assertEqual(request_params['per_page'], '5') - - wcapi.put('products/%s' % (product_id), {"name": original_title}) - - @unittest.skipIf(six.PY2, "non-utf8 bytes not supported in python2") - def test_APIPostWithBytesQuery(self): - wcapi = API(**self.api_params) - nonce = b"%f\xff" % random.random() - - data = { - "name": nonce, - "type": "simple", - } - - response = wcapi.post('products', data) - response_obj = response.json() - product_id = response_obj.get('id') - - expected = StrUtils.to_text(nonce, encoding='ascii', errors='replace') - - self.assertEqual( - response_obj.get('name'), - expected, - ) - wcapi.delete('products/%s' % product_id) - - @unittest.skipIf(six.PY2, "non-utf8 bytes not supported in python2") - def test_APIPostWithLatin1Query(self): - wcapi = API(**self.api_params) - nonce = "%f\u00ae" % random.random() - - data = { - "name": nonce.encode('latin-1'), - "type": "simple", - } - - response = wcapi.post('products', data) - response_obj = response.json() - product_id = response_obj.get('id') - - expected = StrUtils.to_text( - StrUtils.to_binary(nonce, encoding='latin-1'), - encoding='ascii', errors='replace' - ) - - self.assertEqual( - response_obj.get('name'), - expected - ) - wcapi.delete('products/%s' % product_id) - - def test_APIPostWithUTF8Query(self): - wcapi = API(**self.api_params) - nonce = "%f\u00ae" % random.random() - - data = { - "name": nonce.encode('utf8'), - "type": "simple", - } - - response = wcapi.post('products', data) - response_obj = response.json() - product_id = response_obj.get('id') - self.assertEqual(response_obj.get('name'), nonce) - wcapi.delete('products/%s' % product_id) - - def test_APIPostWithUnicodeQuery(self): - wcapi = API(**self.api_params) - nonce = "%f\u00ae" % random.random() - - data = { - "name": nonce, - "type": "simple", - } - - response = wcapi.post('products', data) - response_obj = response.json() - product_id = response_obj.get('id') - self.assertEqual(response_obj.get('name'), nonce) - wcapi.delete('products/%s' % product_id) - - -@unittest.skip("these simply don't work for some reason") -class WCApiTestCases3Leg(WCApiTestCases): - """ Tests for New wp-json/wc/v2 API with 3-leg """ - oauth1a_3leg = True - - -class WPAPITestCasesBase(unittest.TestCase): - api_params = { - 'url': 'http://localhost:8083/', - 'api': 'wp-json', - 'version': 'wp/v2', - 'consumer_key': 'tYG1tAoqjBEM', - 'consumer_secret': 's91fvylVrqChwzzDbEJHEWyySYtAmlIsqqYdjka1KyVDdAyB', - 'callback': 'http://127.0.0.1/oauth1_callback', - 'wp_user': 'admin', - 'wp_pass': 'admin', - 'oauth1a_3leg': True, - } - - def setUp(self): - Auth.force_timestamp = CURRENT_TIMESTAMP - Auth.force_nonce = SHITTY_NONCE - self.wpapi = API(**self.api_params) - - # @debug_on() - def test_APIGet(self): - response = self.wpapi.get('users/me') - self.assertIn(response.status_code, [200, 201]) - response_obj = response.json() - self.assertEqual(response_obj['name'], self.api_params['wp_user']) - - def test_APIGetWithSimpleQuery(self): - response = self.wpapi.get('pages?page=2&per_page=2') - self.assertIn(response.status_code, [200, 201]) - - response_obj = response.json() - self.assertEqual(len(response_obj), 2) - - def test_APIPostData(self): - nonce = "%f\u00ae" % random.random() - - content = "api test post" - - data = { - "title": nonce, - "content": content, - "excerpt": content - } - - response = self.wpapi.post('posts', data) - response_obj = response.json() - post_id = response_obj.get('id') - self.assertEqual(response_obj.get('title').get('raw'), nonce) - self.wpapi.delete('posts/%s' % post_id) - - def test_APIPostBadData(self): - """ - No excerpt so should fail to be created. - """ - nonce = "%f\u00ae" % random.random() - - data = { - 'a': nonce - } - - with self.assertRaises(UserWarning): - self.wpapi.post('posts', data) - - -class WPAPITestCasesBasic(WPAPITestCasesBase): - api_params = dict(**WPAPITestCasesBase.api_params) - api_params.update({ - 'user_auth': True, - 'basic_auth': True, - 'query_string_auth': False, - }) - - -class WPAPITestCases3leg(WPAPITestCasesBase): - - api_params = dict(**WPAPITestCasesBase.api_params) - api_params.update({ - 'creds_store': '~/wc-api-creds-test.json', - }) - - def setUp(self): - super(WPAPITestCases3leg, self).setUp() - self.wpapi.auth.clear_stored_creds() - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3fe6c03 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,36 @@ +""" +Test case module. +""" + +from time import time +import sys +import logging +import pdb +import functools +import traceback +import copy + +CURRENT_TIMESTAMP = int(time()) +SHITTY_NONCE = "" +DEFAULT_ENCODING = sys.getdefaultencoding() + + +def debug_on(*exceptions): + if not exceptions: + exceptions = (AssertionError, ) + + def decorator(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + prev_root = copy(logging.root) + try: + logging.basicConfig(level=logging.DEBUG) + return f(*args, **kwargs) + except exceptions: + info = sys.exc_info() + traceback.print_exception(*info) + pdb.post_mortem(info[2]) + finally: + logging.root = prev_root + return wrapper + return decorator diff --git a/tests/data/test.jpg b/tests/data/test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ea01a220baf283ce46cad2e2f611bd245a30eda4 GIT binary patch literal 114586 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 =jKQC8 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%0DZ#{(Vb@Pjb&bcmj~#q^6jZyDe?5(L=@GY2^& zFe&rQ?~o9U*dSh9h%|=y1hdxV$hTQ#xFXuyzGDiM2@(2$tU;a2cE+UO=IjEgf#)8y zd}D@F18c@aGcmp~BzN#SoE=F}-NzxG6-LiJ(Xc<4a!kibtmf|ZP>1kmRE8;uWZBbG z2f=-YW)8&jhRhsDa0|Zw;LpD{b7bIVj{Z4z+|1#NBK1a#jZ^)9t$hbPmGAriX-LT^ zBN53AEwhZ0LPk~&85u=ngd}@c$SlbwlD)Ues&FV&c1FsG!Z9Nx{qJWuj_ULKetzHo z&+Ap^bf4%E}s$5l3?x$?gUC^ zAktuT#~bqOV^Hl+B(fcjWcceRt<9>K?das7vWox1o7+Ssc%8Ri?KD2|UKh%ekOUV2 zf);0nra?Kw;5vU93*v9mzvoFnCxDH2R;dFTUJ_9U9KmEk%^1wd>n0$>gG=7;V({LX zM+>B(iD^^qd6wTSI zfh7;+m`LlXFNu>gDX$&Rn9fUeZ4)hbP8yiHuV8sYL;6dG(AmdUbqQ17I*^|NSnG8~j}tm5dL*YEI&N89CqhHN`xRc4f$zXLSCi z#z_q>9rKZt4$djMH~52fN0Q~ug14O&VDatOD=2`fOWq`Y2zNUvm^i%u%8cloZKuPc z!ggQmL#6T7L9GG(lN;`A98uaS?PwOXlkyq{p1B3t8qhl@-r^9dMcdx<9A1Tlx(8Mg zAfD(DBp%+`1((%ufQ<*;oZ4MHQ&y*p%faqnlVURa>K z2Vo0t4O{Zd@BSmU{EI9FLdM3T>3VFbvSgYkqg9*6zwu-J9<$+5PiaNSQ24c+wxo8M zud2eT;E5TKHF5%wP~JhPL*GoHbsJj>Fc<=y2tQt}N9Ly2LKvk_CG1&YMD|656lgW= z1mOypJXecO!%G!+D<*<1t6+yp)$5nZcH?Ot{@_lwM;(!(UV}W!th#0bwAhe6wLa+_ zmMPd_*{S55402cf9Y;T5c4Y1WgSFM{EZj`hpV?V(p|jm;c2+o8NQa0%4(Aau9>DJx zLs*Z(ey$!pzGn(fd+{pt#4Oh_Dn!uQVOi28~;!eOcOegns5__ojt-`D)#g&2^%)QL;CZk4h9fo zgaAQj-8b>DtCGD-PE7p&|TwF(X`{b0vz=S2m>+#O%hl=JA_wa_x` z_ou~eVq_~$Pi3{w&*H;>EOU60Jp+pQ$Dboi|W_kNv*y945`v$Hzr{N&C9~(Hzi?F-KYD{27VT~Wy$VMJG5o`(x@MQ(QDWG z71}EAEfPnz?zsj$eB2Ph!(VY?`P0M0BadewG|>QTKCIB#fB_yG$o(-kNPba=jt!dU z0G$CkHju=)bDg=L;16n&Pe-;p1}mvM=4ROT<~oa%RBZWv)jTfwto9BW|I1(+>WfsY z7bFVuMtB`Ob`o9O^OZ(;L`EEmZc3mBif%6WGwUsIieH%3*a%#Z z>l~74<78rkW(yW}(P0{xvnjJH;wCLu}hSlC~Z^dM%H?OAB<8I+{A4$6p=^gu2tnxqFO zyC6w=k;}ET0-v-7b>+#7Gz>N@35#FR9jBOkTJF)UsDh_EzO80UzNvYDMuuVx*)Pl$ zsux}oiHM2k9v3NQq@&-{K zjke76;l7XkBFt0d$0WfbpWhI5_y%LTO*P|wZ+eEtSL7d`U80^ZT1dc^nSD~rzq{nz zULQRLR#P7u;JU5e&NaWt#o{mfecUj9!soc-BQ5*pv)}v~?yG% zd9@=c#IjxO+ZFSH}GTU9*i2_k45(eqmBgAImt#_s*<{W|Rr$M>V8RUa^;@ z*(FlGdwI)_?Mgze(lSCTiHwpP?!9hsI0n=OaJhiO_QdTlRnlJwcc8KcIHRKUHWwIU zuficQWfok7j$$5B?hhFLu@x#apHLHZpmyecPU0kn2-=Z~%9Yf+=PUD%50$D>C9hpP zH!j~qnn1?hWso*f2xUh?8GCDA)@ST-MK6h2EGmEc>D{9&G!^2>?3L+f)pOQ{$L0m2 zkre;q_pe6`{mkANF7V@DYfK&4S-ydc5TQv_WLdK>BPF)0Z$65Ec<5{hk7>_*wP-}k z>9MWLaPEPp#Xj_4QbKxxgpR~29H#BzS@h?==w%dS!@(1z;Y#|Y4IgJ)JY-?(SXC4? z;~&tcyiYhlMnflA?ecqfWtLkT-DEDp7X=FGFV*5 zDzetHJuG%WyIE@Q;a4o~hZ2NO=t>AlwFs|seFB0J~xtpzuRGrZcEq}nJARl5fC zi77=FQD-z!>vr9nZ>vZPWx*{qNf~rGl{dcbZj&<;<*I*F9LDI7=&fl=F`8eVi$vTK z`IpqJO}`3qRmE>o*|AlF$pm@pl1ZHUW@^y#U~_XGt1;`G*i-hWlLe) zdzR}mf8xO>_S1(2j6F7~chA{<(6Q5QOZ%WW9&(C&N5*N=x1JMv&(oPoW@BzUY)rb| z^Tuk2h^ctQHD1|6<-`5VXcHwP<>y|qkOphfC$ebMCZAhR(KB|UO_KeoZIQ>2Oc$ZP zPeM7m;AyP`hXnIWj0?A>X1%iH9Nni?DdgchusQmJxeUVtESC(Gv0mCfjmh+eSTRj2 z_V}abGEC+f85vh8@mEV*)s6|-w}#W!lHoG@_$+>~9ErgXsp6k? zil5$iSCi^4RhD`GbpKpL^m1L(ut$x>EPB>DO;~qU@&ncR<@nq22dI3<_;sH2`?OZc z_|g2(KbcP9Rcse)_4)LJUH3*GOSkX8b+zF_dVZ*m-C^TxNelgVsT=QB(I<&tbBwI+ z6jqsM+?zwie8NbRH27hmWv8=sOrTF$t84FN>gbM-T*;ZY>QuDaUks*Z_1N{r3=gvA zeEWEefi$`!Sm`WuN2DUJ=Woebn{;*K9w z2UZ{jS%ImWNp)_PE3l5ayD^RRcuj+&6-Fvt6|g-CUt4&$)Z}ZRwOHP0F$Sa=q-h|i z)%q$I$oUbdW(0;;c&W2P!dj4V35eWCqqDI?XvU%&x~!2VRS;Fydpqc|xXOHBXYF>g z!a>I$WEuIE_AGNOYPp`&zG7wwGO2oP_5lMt;3b>sTXzclJ3Wj10O7@tlLI7siLV5HfUY$D3v-9= zV4^SBi-2QM4)j>8rIt)*f7Lb#)#&bLZ;oAmq_9cO?p&gI=!+7C?2hPTS)vilbXi{> z0R;)D+QFET!@|g-1q=I#@FKt;T$KP21+DX%h{`*M0sb&9H#7}jgu09hwqVvE3c!uU zEI>4<%K+Zs*U`}A4xW=gw-F#3$yx4x{4ki0l~7}$>QFAqKOOEjA*}LXkgFs|FsL_D z&GQYDO(I+Bt*c=dvV}s{A?Fdf^_}|(y{9VZJwcswgx()8sEV#l5Tf3LJFSEKP72=Z z@4uV6U7n|lP-OGwRFMfI8`L&0Q&5I+m2m|;mq}(vcHiR{*M+LzM+F5axMiDvcG(l@ zLR+6GeG6OSZ!r3({ow9a4a$}jiRrYXIfY2q>9m@6VqcVKX&e%3&3Mz(kSec>m7sM_ zuA?59OxLM%6Sb-k?jMn!SviKaH?p{NTo`9xqHc4%w8t)zO@~*An!-|5SkIE2O*Ytu4li>_uASaowIvoN=B^1 z0@yz07Vu#TvSBZY+3+E~ryERVk!}+&7I%r!lS;@PiFsfbW-ZZWUG-89lbN{>IS~2( zIB5d8?fzGxcMBSClFQ%iKgula8QXl>7OMZ+@lthY^X|F=4;_FBZnsp_T51K;Yo z(CmY%$%!t(9ALMLwCE1Va4rGEp)-P!6;e)Ow@&YVD0eHQoqJ)^sUABb3~47Y#m;$v zH7oldGwFx$=ePtgllP0|Zx@0*j3DcWjQGDe>n!&pKqgFVMw6i9TqU`Je!XeHikQ_+ zqmw36x_BXR=L{3XeRx^YJo<_2WvDL#&J7>dLC_sqU@-ruO?2dseM@5ic0x$R)>22N zbL{;9F!!Z^qs1LIFm|2_7Lrz9$@M##sfun^nJnVS%d=3i(@OD5^ z0RH13{9jnbe{~rD(Z?pfG~_$9v9Rw5?Qnwit57~OFG@nU;-ZNklWD-1V7oc(Fr6#~ zYl2WP!#P8_h?gouRVxx1!&M&xWPga*ln#QD%Vl%tHF$b2Oz2vaVJEHgxjx1Gw90|_VU%cK)6e;9c3{nB)bG-z>Hi!FL`OIg9 zHzF=}s6vN_hjXhnpIDHexs59>*(vtGPtuGm3nq}iEY5Pbs*SXVo!KL!P?zc4$Y0QJhU@)q+$|JW#*NEEqFK#^onuHDq#E zILJbz1Zh=sgmm2ole#ib@n$u1Dn^lsMHEvTEqv5YLvKXGZlS`NG!(<;)1hwG=TE1X za4chOU8>ewrs8o@Q+UX{$Z&(U?Iq=9k!GyvI~UIuQ7ArT;$wdMgpM5wTR{*S3`ex& zq_z_za%e;gH+fZW=>6fP2A_d8?Ys8eK>|GkM6}4o42kNKm@P#0w%Tz$*_JG9$Jr>H zYI88^9_~&-e^1SmS9Z%3*aPjIcG~S%)E?UjYSQj_L6)@7b%AoEcS|S|molnAA?qfe zO;B7q10q=vm%>ew{RtCp&&`g*LGA{)X@|%~D9DA+06{L?M9B!sY>}cTtkT3Lh0k@a zy<7{gu?HAT_VD{VH-@+9C+gHrKVHU!>SoJ5F3=8T^DW4&xy9n9tl!^K7RR($Xm<|e zNN1$GPjoc6iL%?tT8d+f(byGo;4-j20tY^|grEdJJR~96MFdxN(D(!g4@n^8Ah_T) zKYCGJAb4+|^;GC*)~TOYPOMInb?S67;3gD#PMK{H0WNcIN0)!l<+Ss`9oubC}W*<^olo?R{2v{a~)neV&CA z1>Z_%3inp`DyIsi)dx%0dYu>YwsAbC{;lEwh@vhy>T{H^}RBY3K#{T;l30X+*~(}Vd1vq9ZkIV-aKh%7}B8~joWg)*%U7rKcL}kTok=0^F`O#xmxR8MJ>O9!Lng0SyE#IIg zUya22_uHphLx~G?%d^*m3$jOg4t-yc$I#g!)frP%k{Cy-M(n;@dvioC)qcb!yZFzE zmSpT6G1Ahc9<^`!_P$IXFL)wy;n8LH^W4VE-0iH&!dMLlixe5Mm)#0nLJJKadEzwo zmZ^`wVfSZpoVKA^@JQv?XmqMdvxv#xvnQr|c8gXE&$A~X;_vVeDDg&7Eprr~IbuCZ z?_PaG5vf1;YuLlZZNk6<|y_1 zh{?SjorzTwT8RX&A0$7JAZs#!C7PIq9-V@P6z%V8$wVygC=ESw+dpXNdr@+8u;&?p zRhCel2?-x2zeZ9qAVZl%>51uy3?)jzPBczI`a0J^vV2ft^3p$5$r5YcKQw=@u~tmc zG11#O_&l;!Xsj16nw{<-CS7||#AHxr9TYT8a_EyBb!7Juu~V%9voz|P;Fk~e0r?|! zx!Rd|zBpTqF8qU(J`6_^H4!6#_A$^ccETF{VHU~ z71&FJ0#|2fYO{T<_zB7pTAjZn$`NvLB++(|E1KldiKKH8dhqj3M7f$L*xj%NJ9=&4 zruo^s)#NTRrEdJGj)QwZ(WVWrL)iB4T^T9WpR(HHH@Y`oyz$9=JiCGCNl5Qbg@92- zQ~jfvc{k#_L?aez?`-p2PP$e%t-M03GUtdKMkXG6xt+hs_Rz_p z$k&?o-Er(phgx{@-OT(jU1X!)*W;DK^HpN~D-RwBQeya-msVLvUW_-@e6c~6>0G0q zQLwN-=VV6O9t!>U$323v#^cmw?^`R=EGpv)-+?k}BE^Cg8X}^H%c$4zuMxpNONd;W zm9P~57xGsE{-Fl&8~#Cz3%6ki);xvmfeAq`K+I>WITRRBn+I)^K$uB}is|8OEtL5K z2nHtXL<-ncAg#9m3u`c8XT{npTzxFaOc8$l->IHAgr7~vPallRKb5X)=BFq*lVO-P z)#&a$wq5QglYAJznz;=1>1Vl=!jlnJ`0ZyjM4R?kQ%mvKn1}EW3L%?xtH#3wpEM3! zFLZ-;p79%g+nBM(*14Kq>|vb*tyU<@OI_(Su7;qU7o*7ay>YgJLJ`3i673n9;akeN z#4DalH1Yq1vCMb&$hl8Jt(260wvHU@!X-Re`)U8{5^R2Tm(I(33mWuS{WHJz-ne?_ z;dpuXg^S$DIvoe7=hcHLva{p^!`P~nuKDM^sSUs>N*ni9(U^lgkrKzRVUc{2Ka=Wh zswMP~{0zL;6v0v(wORj8e(bE_xMhk*^#{J63zT%K$f!C+NPlKf*_Z>L= z{GO(Wx@I|1f`k#T3C`UQ^0OH^Kb@#~Ozm4$>iL~IP{G-~Z&R-VKhIzj_0&Wg9uLyc zZrzUSc$l7@T>|obeNSzQt7)Q@wp|^0pX67%xz(nYi7&MyMdYEd50g0{P5yrNOTIdaKT*zD9G9W|{Y8%&Q;$})TAZ&Y^Gs0%^htAv(Nr-x%pVRVEzV+LLm`BKp&T{vK@^jUP=nVZ>fj4<_ zCx0Ju`#NC)p`+)ia4TpDsg{n`XwGx=dpafFX-(d||WdTtM zywd_4eXMCX5lHNu%?G zt^)qx<-<$p_6{Hsp&ns^-cFQ=0K5zu@_6G%i1xPyd%sI+M5qU-&PSwsX%@axdGF^^ z%eN^_4bLf&O8O%5{@_&g-e$`nI{uVyrc3;ZgEF-NLD&Q~UWXA;T17Mdgmfh1 zFF*_hq68oYYhdvE+|*R=_@iNt=gUUjjEXbLZK<}b%kuZLH4HV~de1E>cy4*!^1jW; zmRDCNOm0xxK%1-7e3X1Nhw)WglE7e%PZm;+4@bYx1r>|K9DlqyPj!mY*d$&2^WH{- zn%fxOtonQ$Aim0gJHF`vx)=!37_7lAI4~`8D3Xf1CWtjfeb~3=O~E!bYmb$+s^MVI z4T7Emmg{e8YKoM{pN4nasE|f>`B%2ib|9wRu9%Ke@hcvQ&d0vSK@t`6X3*Y z=(*6{8elm=VYX3 z{M;G;+w&Uq6&0M~U{j|vcgoyJuq5vHaWZy8JC1s>$_Lz8M$8l*UVKk$9nxtXn8JmR zX-;(aw?E2NOgb8TX25QLL^s2PJBl6>QjittqIP7~<{Cy4$+>>%L70~c%XxOnUXz%6x;*|WL zwn%nMvTEA;b8g0Xr`GVf2#Tb){@c_vv75K`d5iet2zd3d&u*{NuA;Be_M#JpNA!N` zW-5?(Vn?L>uYMU&=EPDSQOWwYXMioTnF3Z8D3ODRrF~6#*LI8nBnVc7JsBKN8LvUg zC&DWhC_rHhTKT<6v^2dgDJlsp4*zY>3#2FD6|bK78OlanUDJlu7*2?Vj)PbN;B+e3 z2Ei+aVjAe8_)#c10saHox*yrV?m|6abPWN4YXIO5w}fcu*YqDHs(}JmI20i~p%aoK zw_(82GNt9Gf-@%SaQ3;-VxIAO4{F^^4Xv<`4zwOW=r9yv$@yuxVf82X>44ely5Cna zX>Vg_XA%xeZ)>GkhdN()|Z$KyY)^wN+Mp3&&+BF>8 zCE;%W54(aO&qXi-0=1Ly7T7fq@XT`^v(sU~2}77xGxvLhku^?wXmo$nx_CNw=j(*wrrtD-_VEMV1uM_8-j zEAdo>eJPSdLDK`KB3Ab-Qn6l>%~~c5@R`zUsW3ok*5U#|ct!@=c>!oYk`kyu1N5ze zNZ(8dJ{**X0KkRB17M1fk_mO2JZmj#E(eGU1?GtUtRZd`TuT7~oD&Lgh)J!pMf612 z7FQpUI4c*xEjkqV$FcB#%&Y#FV_`I>4l+e+bs!rkX$1!VbtAh<;_8n@`aDQ9-~ow! z5jviL_HtVj&FT5sv^Civ-UuGMI1ti5U!t%so4XLAJsYy2cRKJ2`NYrjL+4$Gq*i}?$M+aD zL;WKE7&e!Zc;1vK)4VFxq8p1>Q(JoEZ<11vm?s2j*u2OY)FB_wlW?&{ zj5ZN&H=7rOTjQCLkBy;>Hi2e1z4`63!{W-W_Q(fWip#Sz9~Yq^nT%4%2}O@#>WdMk zpXzSq52l9AgSqi;#0t zq{cJttY+H9nju3rC^J5s@PiDW=q6q=UgP~RBukLK;0R7Bd1J$F9M<7{s#7j|?70s* zal_@oHj+J1 zO6Ub-(e{l$qf@QrdVXO9pkAdH(3g1(Gsf=Lx^!5?$fr?G?vbu(MTk-h&%A%qoq1G$ zP|=|J^ZpQhBwlnH`IuT$AALl9pbsMT0TP7N2l`l3ADLlsl}5@Jrsa?V@<#F6&$?C3 zLZFoaRJ(M={63;U*R*{6h!kjfqNk90m-Cr)eiydo%adNlfkuB(hTX6KPy`X2z@J*i zANYrcxi@)K0iXdO%$8FB5EQP*w3+8!G7fywAH1EPmCf5GabD(_NUDxl`2J?ClKjlu z^3lohA8|2~=LVJegGByR5~0D}t*D;FF2L#%yFeS+8fc^}Fqx-wlE2$_5c&9|1~eNe z@%G>K40~L!g57!_@;>A%^G3&WyQ9!T+%C7Nj*@;&#KE|9t!JF z!omDiwa`J;!VFfMHMKYo>y@k>(!Ozo(Ix??7}h}p)jOn}NbisfpzW$hx-tlFE%O&9 zzYsPwN6Thkq-q5!BD<)fUZ4mZC5W^Txs1ey68bE>?Y?)|N0R*iDnUm2@m z-RV|6Sn>qd$)NP%cPUr|TG~AL(7ygz58MAQ4u%BtEeKNQ+hcMB`_jC$`C1(XK{82L zJe~Z`Z|4tzJSu9F^ef+|K^?l|s$v{L6a)Hz6a!T)U@UKgVVimDB@big;U6T4v&^Xy z;>ZUH*Q&k+zyJ?h-7H2!Kpj8)!($kl#HKqc{oQ)G1%e?+sdu!)N^Ps^=5616GOowo z@bp``cCI6r4Fcn)4(10MAG6c3Mo`795<;Eru||;4VjxHYssp!e9D(3UBV`eWE2Ism z4L>AC_$0KANM)2zAI1%t*KG(84Nt>>=x=2q*4&ma;a^)Eq{}9oq5X5&tcYaq>T~^u zx2uu?8UslN`dE|9V?;9W2P7HDry!6`Fhx4Wc5&x5*L57AB46m{GTF`7YDw;x!%8fg zCh@Y&F>Xy7;aC)U9I{{wD-Ljw-*4+t|T1n=uRG;yl|+$q$j zcGi~-3^v44%2zJ3JE)SFZ)tMyo7~*llT|@SJs;utQGY$V2}Tw7i)4yK-i!ni#V{u6 z-yN25IlTv&VuG!^oa4p6GOK(znE1mP#pUo9|HY?u5AM+gI`*6A`uoA}8CLBIXpf)i zLrVaK=JzgLx(yV04-q7dniU7jF2C(3 z*wurG99Je5iDI(hCew1I`<}QX$)|qQIz-XlGVFtK2*($1^qQN#KQ426dG!D{rHkzL_!J2 zd<1L_Tni1$ab}2CG@ax_L&JbZ6A)tq2>z$Tu$;<$)}$ISjxR5nDh;V%ps zeqdd2=yvqM_JeQ~zvj6RH)8Qu$yne@~WinMAs6KvCaQ;uelSRKzQaCt&QIq=5vJ-{Z6!;%CLsNTR<{-Lnv%hbdrHF+Qv3LImn(AOdyFYUv?N(T*@wVpu)V z<5&#NA;hnq8~9dFT_h_+xMh!Hq*i}}Xq?$08fVN#Q}nkDGZa_cKaK#hEzFMR=x61rl+`=@T_{DCLl75H0!C@|I3N`3 wp7DPnluU7i7Z>}S=)T=YXZWD)Sk}J`Jj8On?q&S-zH2XIt;hm%@>kdY1G9bN!2kdN literal 0 HcmV?d00001 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..2e7dded --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,503 @@ +""" API Tests """ +from __future__ import unicode_literals + +import os +import random +import unittest + +import requests + +import six +import wordpress +from httmock import HTTMock, all_requests +from six import text_type +from wordpress import __default_api__, __default_api_version__, auth +from wordpress.api import API +from wordpress.auth import Auth +from wordpress.helpers import StrUtils, UrlUtils + +from . import CURRENT_TIMESTAMP, SHITTY_NONCE + + +class WordpressTestCase(unittest.TestCase): + """Test case for the mocked client methods.""" + + def setUp(self): + self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + self.consumer_secret = "cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + self.api = wordpress.API( + url="http://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret + ) + + def test_api(self): + """ Test default API """ + api = wordpress.API( + url="https://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret + ) + + self.assertEqual(api.namespace, __default_api__) + + def test_version(self): + """ Test default version """ + api = wordpress.API( + url="https://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret + ) + + self.assertEqual(api.version, __default_api_version__) + + def test_non_ssl(self): + """ Test non-ssl """ + api = wordpress.API( + url="http://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret + ) + self.assertFalse(api.is_ssl) + + def test_with_ssl(self): + """ Test non-ssl """ + api = wordpress.API( + url="https://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret + ) + self.assertTrue(api.is_ssl, True) + + def test_with_timeout(self): + """ Test non-ssl """ + api = wordpress.API( + url="https://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret, + timeout=10, + ) + self.assertEqual(api.timeout, 10) + + @all_requests + def woo_test_mock(*args, **kwargs): + """ URL Mock """ + return {'status_code': 200, + 'content': b'OK'} + + with HTTMock(woo_test_mock): + # call requests + status = api.get("products").status_code + self.assertEqual(status, 200) + + def test_get(self): + """ Test GET requests """ + @all_requests + def woo_test_mock(*args, **kwargs): + """ URL Mock """ + return {'status_code': 200, + 'content': b'OK'} + + with HTTMock(woo_test_mock): + # call requests + status = self.api.get("products").status_code + self.assertEqual(status, 200) + + def test_post(self): + """ Test POST requests """ + @all_requests + def woo_test_mock(*args, **kwargs): + """ URL Mock """ + return {'status_code': 201, + 'content': b'OK'} + + with HTTMock(woo_test_mock): + # call requests + status = self.api.post("products", {}).status_code + self.assertEqual(status, 201) + + def test_put(self): + """ Test PUT requests """ + @all_requests + def woo_test_mock(*args, **kwargs): + """ URL Mock """ + return {'status_code': 200, + 'content': b'OK'} + + with HTTMock(woo_test_mock): + # call requests + status = self.api.put("products", {}).status_code + self.assertEqual(status, 200) + + def test_delete(self): + """ Test DELETE requests """ + @all_requests + def woo_test_mock(*args, **kwargs): + """ URL Mock """ + return {'status_code': 200, + 'content': b'OK'} + + with HTTMock(woo_test_mock): + # call requests + status = self.api.delete("products").status_code + self.assertEqual(status, 200) + + # @unittest.skip("going by RRC 5849 sorting instead") + def test_oauth_sorted_params(self): + """ Test order of parameters for OAuth signature """ + def check_sorted(keys, expected): + params = auth.OrderedDict() + for key in keys: + params[key] = '' + + params = UrlUtils.sorted_params(params) + ordered = [key for key, value in params] + 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]']) + + +class WCApiTestCasesBase(unittest.TestCase): + """ Base class for WC API Test cases """ + + def setUp(self): + Auth.force_timestamp = CURRENT_TIMESTAMP + Auth.force_nonce = SHITTY_NONCE + self.api_params = { + 'url': 'http://localhost:8083/', + 'api': 'wc-api', + 'version': 'v3', + 'consumer_key': 'ck_659f6994ae88fed68897f9977298b0e19947979a', + 'consumer_secret': 'cs_9421d39290f966172fef64ae18784a2dc7b20976', + } + + +class WCApiTestCasesLegacy(WCApiTestCasesBase): + """ Tests for WC API V3 """ + + def setUp(self): + super(WCApiTestCasesLegacy, self).setUp() + self.api_params['version'] = 'v3' + self.api_params['api'] = 'wc-api' + + def test_APIGet(self): + wcapi = API(**self.api_params) + response = wcapi.get('products') + # print UrlUtils.beautify_response(response) + self.assertIn(response.status_code, [200, 201]) + response_obj = response.json() + self.assertIn('products', response_obj) + self.assertEqual(len(response_obj['products']), 10) + # print "test_APIGet", response_obj + + def test_APIGetWithSimpleQuery(self): + wcapi = API(**self.api_params) + response = wcapi.get('products?page=2') + # print UrlUtils.beautify_response(response) + self.assertIn(response.status_code, [200, 201]) + + response_obj = response.json() + self.assertIn('products', response_obj) + self.assertEqual(len(response_obj['products']), 8) + # print "test_ApiGenWithSimpleQuery", response_obj + + def test_APIGetWithComplexQuery(self): + wcapi = API(**self.api_params) + response = wcapi.get('products?page=2&filter%5Blimit%5D=2') + self.assertIn(response.status_code, [200, 201]) + response_obj = response.json() + self.assertIn('products', response_obj) + self.assertEqual(len(response_obj['products']), 2) + + response = wcapi.get( + 'products?' + 'oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&' + 'oauth_nonce=037470f3b08c9232b0888f52cb9d4685b44d8fd1&' + 'oauth_signature=wrKfuIjbwi%2BTHynAlTP4AssoPS0%3D&' + 'oauth_signature_method=HMAC-SHA1&' + 'oauth_timestamp=1481606275&' + 'filter%5Blimit%5D=3' + ) + self.assertIn(response.status_code, [200, 201]) + response_obj = response.json() + self.assertIn('products', response_obj) + self.assertEqual(len(response_obj['products']), 3) + + def test_APIPutWithSimpleQuery(self): + wcapi = API(**self.api_params) + response = wcapi.get('products') + first_product = (response.json())['products'][0] + original_title = first_product['title'] + product_id = first_product['id'] + + nonce = b"%f" % (random.random()) + response = wcapi.put('products/%s?filter%%5Blimit%%5D=5' % + (product_id), + {"product": {"title": text_type(nonce)}}) + request_params = UrlUtils.get_query_dict_singular(response.request.url) + response_obj = response.json() + self.assertEqual(response_obj['product']['title'], text_type(nonce)) + self.assertEqual(request_params['filter[limit]'], text_type(5)) + + wcapi.put('products/%s' % (product_id), + {"product": {"title": original_title}}) + + +class WCApiTestCases(WCApiTestCasesBase): + oauth1a_3leg = False + """ Tests for New wp-json/wc/v2 API """ + + def setUp(self): + super(WCApiTestCases, self).setUp() + self.api_params['version'] = 'wc/v2' + self.api_params['api'] = 'wp-json' + self.api_params['callback'] = 'http://127.0.0.1/oauth1_callback' + if self.oauth1a_3leg: + self.api_params['oauth1a_3leg'] = True + + # @debug_on() + def test_APIGet(self): + wcapi = API(**self.api_params) + per_page = 10 + response = wcapi.get('products?per_page=%d' % per_page) + self.assertIn(response.status_code, [200, 201]) + response_obj = response.json() + self.assertEqual(len(response_obj), per_page) + + def test_APIPutWithSimpleQuery(self): + wcapi = API(**self.api_params) + response = wcapi.get('products') + first_product = (response.json())[0] + # from pprint import pformat + # print "first product %s" % pformat(response.json()) + original_title = first_product['name'] + product_id = first_product['id'] + + nonce = b"%f" % (random.random()) + response = wcapi.put('products/%s?page=2&per_page=5' % + (product_id), {"name": text_type(nonce)}) + request_params = UrlUtils.get_query_dict_singular(response.request.url) + response_obj = response.json() + self.assertEqual(response_obj['name'], text_type(nonce)) + self.assertEqual(request_params['per_page'], '5') + + wcapi.put('products/%s' % (product_id), {"name": original_title}) + + @unittest.skipIf(six.PY2, "non-utf8 bytes not supported in python2") + def test_APIPostWithBytesQuery(self): + wcapi = API(**self.api_params) + nonce = b"%f\xff" % random.random() + + data = { + "name": nonce, + "type": "simple", + } + + response = wcapi.post('products', data) + response_obj = response.json() + product_id = response_obj.get('id') + + expected = StrUtils.to_text(nonce, encoding='ascii', errors='replace') + + self.assertEqual( + response_obj.get('name'), + expected, + ) + wcapi.delete('products/%s' % product_id) + + @unittest.skipIf(six.PY2, "non-utf8 bytes not supported in python2") + def test_APIPostWithLatin1Query(self): + wcapi = API(**self.api_params) + nonce = "%f\u00ae" % random.random() + + data = { + "name": nonce.encode('latin-1'), + "type": "simple", + } + + response = wcapi.post('products', data) + response_obj = response.json() + product_id = response_obj.get('id') + + expected = StrUtils.to_text( + StrUtils.to_binary(nonce, encoding='latin-1'), + encoding='ascii', errors='replace' + ) + + self.assertEqual( + response_obj.get('name'), + expected + ) + wcapi.delete('products/%s' % product_id) + + def test_APIPostWithUTF8Query(self): + wcapi = API(**self.api_params) + nonce = "%f\u00ae" % random.random() + + data = { + "name": nonce.encode('utf8'), + "type": "simple", + } + + response = wcapi.post('products', data) + response_obj = response.json() + product_id = response_obj.get('id') + self.assertEqual(response_obj.get('name'), nonce) + wcapi.delete('products/%s' % product_id) + + def test_APIPostWithUnicodeQuery(self): + wcapi = API(**self.api_params) + nonce = "%f\u00ae" % random.random() + + data = { + "name": nonce, + "type": "simple", + } + + response = wcapi.post('products', data) + response_obj = response.json() + product_id = response_obj.get('id') + self.assertEqual(response_obj.get('name'), nonce) + wcapi.delete('products/%s' % product_id) + + +@unittest.skip("these simply don't work for some reason") +class WCApiTestCases3Leg(WCApiTestCases): + """ Tests for New wp-json/wc/v2 API with 3-leg """ + oauth1a_3leg = True + + +class WPAPITestCasesBase(unittest.TestCase): + api_params = { + 'url': 'http://localhost:8083/', + 'api': 'wp-json', + 'version': 'wp/v2', + 'consumer_key': 'tYG1tAoqjBEM', + 'consumer_secret': 's91fvylVrqChwzzDbEJHEWyySYtAmlIsqqYdjka1KyVDdAyB', + 'callback': 'http://127.0.0.1/oauth1_callback', + 'wp_user': 'admin', + 'wp_pass': 'admin', + 'oauth1a_3leg': True, + } + + def setUp(self): + Auth.force_timestamp = CURRENT_TIMESTAMP + Auth.force_nonce = SHITTY_NONCE + self.wpapi = API(**self.api_params) + + # @debug_on() + def test_APIGet(self): + response = self.wpapi.get('users/me') + self.assertIn(response.status_code, [200, 201]) + response_obj = response.json() + self.assertEqual(response_obj['name'], self.api_params['wp_user']) + + def test_APIGetWithSimpleQuery(self): + response = self.wpapi.get('pages?page=2&per_page=2') + self.assertIn(response.status_code, [200, 201]) + + response_obj = response.json() + self.assertEqual(len(response_obj), 2) + + def test_APIPostData(self): + nonce = "%f\u00ae" % random.random() + + content = "api test post" + + data = { + "title": nonce, + "content": content, + "excerpt": content + } + + response = self.wpapi.post('posts', data) + response_obj = response.json() + post_id = response_obj.get('id') + self.assertEqual(response_obj.get('title').get('raw'), nonce) + self.wpapi.delete('posts/%s' % post_id) + + def test_APIPostBadData(self): + """ + No excerpt so should fail to be created. + """ + nonce = "%f\u00ae" % random.random() + + data = { + 'a': nonce + } + + with self.assertRaises(UserWarning): + self.wpapi.post('posts', data) + + def test_APIPostMedia(self): + img_path = 'tests/data/test.jpg' + with open(img_path, 'rb') as test_file: + img_data = test_file.read() + img_name = os.path.basename(img_path) + + res = self.wpapi.post( + 'media', + data=img_data, + headers={ + 'Content-Type': 'image/jpg', + 'Content-Disposition' : 'attachment; filename=%s'% img_name + } + ) + + self.assertEqual(res.status_code, 201) + res_obj = res.json() + created_id = res_obj.get('id') + self.assertTrue(created_id) + uploaded_res = requests.get(res_obj.get('source_url')) + + # check for bug where image bytestream was quoted + self.assertNotEqual(StrUtils.to_binary(uploaded_res.text[0]), b'"') + + self.wpapi.delete('media/%s?force=True' % created_id) + + # def test_APIPostMediaBadCreds(self): + # """ + # TODO: make sure the warning is "ensure login and basic auth is installed" + # """ + # img_path = 'tests/data/test.jpg' + # with open(img_path, 'rb') as test_file: + # img_data = test_file.read() + # img_name = os.path.basename(img_path) + # res = self.wpapi.post( + # 'media', + # data=img_data, + # headers={ + # 'Content-Type': 'image/jpg', + # 'Content-Disposition' : 'attachment; filename=%s'% img_name + # } + # ) + + +class WPAPITestCasesBasic(WPAPITestCasesBase): + api_params = dict(**WPAPITestCasesBase.api_params) + api_params.update({ + 'user_auth': True, + 'basic_auth': True, + 'query_string_auth': False, + }) + + +class WPAPITestCases3leg(WPAPITestCasesBase): + + api_params = dict(**WPAPITestCasesBase.api_params) + api_params.update({ + 'creds_store': '~/wc-api-creds-test.json', + }) + + def setUp(self): + super(WPAPITestCases3leg, self).setUp() + self.wpapi.auth.clear_stored_creds() diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..42893ce --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,478 @@ +""" API Tests """ +from __future__ import unicode_literals + +import random +import unittest +from collections import OrderedDict +from copy import copy +from tempfile import mkstemp + +from httmock import HTTMock, urlmatch +from six import text_type +from six.moves.urllib.parse import parse_qsl, urlparse +from wordpress.api import API +from wordpress.auth import OAuth +from wordpress.helpers import StrUtils, UrlUtils + + +class BasicAuthTestcases(unittest.TestCase): + def setUp(self): + self.base_url = "http://localhost:8888/wp-api/" + self.api_name = 'wc-api' + self.api_ver = 'v3' + self.endpoint = 'products/26' + self.signature_method = "HMAC-SHA1" + + self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + self.consumer_secret = "cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + self.api_params = dict( + url=self.base_url, + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret, + basic_auth=True, + api=self.api_name, + version=self.api_ver, + query_string_auth=False, + ) + + def test_endpoint_url(self): + api = API( + **self.api_params + ) + endpoint_url = api.requester.endpoint_url(self.endpoint) + endpoint_url = api.auth.get_auth_url(endpoint_url, 'GET') + self.assertEqual( + endpoint_url, + UrlUtils.join_components([ + self.base_url, self.api_name, self.api_ver, self.endpoint + ]) + ) + + def test_query_string_endpoint_url(self): + query_string_api_params = dict(**self.api_params) + query_string_api_params.update(dict(query_string_auth=True)) + api = API( + **query_string_api_params + ) + endpoint_url = api.requester.endpoint_url(self.endpoint) + endpoint_url = api.auth.get_auth_url(endpoint_url, 'GET') + expected_endpoint_url = '%s?consumer_key=%s&consumer_secret=%s' % ( + self.endpoint, self.consumer_key, self.consumer_secret) + expected_endpoint_url = UrlUtils.join_components( + [self.base_url, self.api_name, self.api_ver, expected_endpoint_url] + ) + self.assertEqual( + endpoint_url, + expected_endpoint_url + ) + endpoint_url = api.requester.endpoint_url(self.endpoint) + endpoint_url = api.auth.get_auth_url(endpoint_url, 'GET') + + +class OAuthTestcases(unittest.TestCase): + + def setUp(self): + self.base_url = "http://localhost:8888/wordpress/" + self.api_name = 'wc-api' + self.api_ver = 'v3' + self.endpoint = 'products/99' + self.signature_method = "HMAC-SHA1" + self.consumer_key = "ck_681c2be361e415519dce4b65ee981682cda78bc6" + self.consumer_secret = "cs_b11f652c39a0afd3752fc7bb0c56d60d58da5877" + + self.wcapi = API( + url=self.base_url, + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret, + api=self.api_name, + version=self.api_ver, + signature_method=self.signature_method + ) + + self.rfc1_api_url = 'https://photos.example.net/' + self.rfc1_consumer_key = 'dpf43f3p2l4k3l03' + self.rfc1_consumer_secret = 'kd94hf93k423kf44' + self.rfc1_oauth_token = 'hh5s93j4hdidpola' + self.rfc1_signature_method = 'HMAC-SHA1' + self.rfc1_callback = 'http://printer.example.com/ready' + self.rfc1_api = API( + url=self.rfc1_api_url, + consumer_key=self.rfc1_consumer_key, + consumer_secret=self.rfc1_consumer_secret, + api='', + version='', + callback=self.rfc1_callback, + wp_user='', + wp_pass='', + oauth1a_3leg=True + ) + self.rfc1_request_method = 'POST' + self.rfc1_request_target_url = 'https://photos.example.net/initiate' + self.rfc1_request_timestamp = '137131200' + self.rfc1_request_nonce = 'wIjqoS' + self.rfc1_request_params = [ + ('oauth_consumer_key', self.rfc1_consumer_key), + ('oauth_signature_method', self.rfc1_signature_method), + ('oauth_timestamp', self.rfc1_request_timestamp), + ('oauth_nonce', self.rfc1_request_nonce), + ('oauth_callback', self.rfc1_callback), + ] + self.rfc1_request_signature = b'74KNZJeDHnMBp0EMJ9ZHt/XKycU=' + + self.twitter_api_url = "https://api.twitter.com/" + self.twitter_consumer_secret = \ + "kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw" + self.twitter_consumer_key = "xvz1evFS4wEEPTGEFPHBog" + self.twitter_signature_method = "HMAC-SHA1" + self.twitter_api = API( + url=self.twitter_api_url, + consumer_key=self.twitter_consumer_key, + consumer_secret=self.twitter_consumer_secret, + api='', + version='1', + signature_method=self.twitter_signature_method, + ) + + self.twitter_method = "POST" + self.twitter_target_url = ( + "https://api.twitter.com/1/statuses/update.json?" + "include_entities=true" + ) + self.twitter_params_raw = [ + ("status", "Hello Ladies + Gentlemen, a signed OAuth request!"), + ("include_entities", "true"), + ("oauth_consumer_key", self.twitter_consumer_key), + ("oauth_nonce", "kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg"), + ("oauth_signature_method", self.twitter_signature_method), + ("oauth_timestamp", "1318622958"), + ("oauth_token", + "370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb"), + ("oauth_version", "1.0"), + ] + self.twitter_param_string = ( + r"include_entities=true&" + r"oauth_consumer_key=xvz1evFS4wEEPTGEFPHBog&" + r"oauth_nonce=kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg&" + r"oauth_signature_method=HMAC-SHA1&" + r"oauth_timestamp=1318622958&" + r"oauth_token=370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb&" + r"oauth_version=1.0&" + r"status=Hello%20Ladies%20%2B%20Gentlemen%2C%20a%20" + r"signed%20OAuth%20request%21" + ) + self.twitter_signature_base_string = ( + r"POST&" + r"https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&" + r"include_entities%3Dtrue%26" + r"oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26" + r"oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26" + r"oauth_signature_method%3DHMAC-SHA1%26" + r"oauth_timestamp%3D1318622958%26" + r"oauth_token%3D370773112-" + r"GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26" + r"oauth_version%3D1.0%26" + r"status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520" + r"a%2520signed%2520OAuth%2520request%2521" + ) + self.twitter_token_secret = 'LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE' + self.twitter_signing_key = ( + 'kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw&' + 'LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE' + ) + self.twitter_oauth_signature = b'tnnArxj06cWHq44gCs1OSKk/jLY=' + + self.lexev_consumer_key = 'your_app_key' + self.lexev_consumer_secret = 'your_app_secret' + self.lexev_callback = 'http://127.0.0.1/oauth1_callback' + self.lexev_signature_method = 'HMAC-SHA1' + self.lexev_version = '1.0' + self.lexev_api = API( + url='https://bitbucket.org/', + api='api', + version='1.0', + consumer_key=self.lexev_consumer_key, + consumer_secret=self.lexev_consumer_secret, + signature_method=self.lexev_signature_method, + callback=self.lexev_callback, + wp_user='', + wp_pass='', + oauth1a_3leg=True + ) + self.lexev_request_method = 'POST' + self.lexev_request_url = \ + 'https://bitbucket.org/api/1.0/oauth/request_token' + self.lexev_request_nonce = '27718007815082439851427366369' + self.lexev_request_timestamp = '1427366369' + self.lexev_request_params = [ + ('oauth_callback', self.lexev_callback), + ('oauth_consumer_key', self.lexev_consumer_key), + ('oauth_nonce', self.lexev_request_nonce), + ('oauth_signature_method', self.lexev_signature_method), + ('oauth_timestamp', self.lexev_request_timestamp), + ('oauth_version', self.lexev_version), + ] + self.lexev_request_signature = b"iPdHNIu4NGOjuXZ+YCdPWaRwvJY=" + self.lexev_resource_url = ( + 'https://api.bitbucket.org/1.0/repositories/st4lk/' + 'django-articles-transmeta/branches' + ) + + def test_get_sign_key(self): + self.assertEqual( + StrUtils.to_binary( + self.wcapi.auth.get_sign_key(self.consumer_secret)), + StrUtils.to_binary("%s&" % self.consumer_secret) + ) + + self.assertEqual( + StrUtils.to_binary(self.wcapi.auth.get_sign_key( + self.twitter_consumer_secret, self.twitter_token_secret)), + StrUtils.to_binary(self.twitter_signing_key) + ) + + def test_flatten_params(self): + self.assertEqual( + StrUtils.to_binary(UrlUtils.flatten_params( + self.twitter_params_raw)), + StrUtils.to_binary(self.twitter_param_string) + ) + + def test_sorted_params(self): + # Example given in oauth.net: + oauthnet_example_sorted = [ + ('a', '1'), + ('c', 'hi%%20there'), + ('f', '25'), + ('f', '50'), + ('f', 'a'), + ('z', 'p'), + ('z', 't') + ] + + oauthnet_example = copy(oauthnet_example_sorted) + random.shuffle(oauthnet_example) + + self.assertEqual( + UrlUtils.sorted_params(oauthnet_example), + oauthnet_example_sorted + ) + + def test_get_signature_base_string(self): + twitter_param_string = OAuth.get_signature_base_string( + self.twitter_method, + self.twitter_params_raw, + self.twitter_target_url + ) + self.assertEqual( + twitter_param_string, + self.twitter_signature_base_string + ) + + def test_generate_oauth_signature(self): + + rfc1_request_signature = self.rfc1_api.auth.generate_oauth_signature( + self.rfc1_request_method, + self.rfc1_request_params, + self.rfc1_request_target_url, + '%s&' % self.rfc1_consumer_secret + ) + self.assertEqual( + text_type(rfc1_request_signature), + text_type(self.rfc1_request_signature) + ) + + # TEST WITH RFC EXAMPLE 3 DATA + + # TEST WITH TWITTER DATA + + twitter_signature = self.twitter_api.auth.generate_oauth_signature( + self.twitter_method, + self.twitter_params_raw, + self.twitter_target_url, + self.twitter_signing_key + ) + self.assertEqual(twitter_signature, self.twitter_oauth_signature) + + # TEST WITH LEXEV DATA + + lexev_request_signature = self.lexev_api.auth.generate_oauth_signature( + method=self.lexev_request_method, + params=self.lexev_request_params, + url=self.lexev_request_url + ) + self.assertEqual(lexev_request_signature, self.lexev_request_signature) + + def test_add_params_sign(self): + endpoint_url = self.wcapi.requester.endpoint_url('products?page=2') + + params = OrderedDict() + params["oauth_consumer_key"] = self.consumer_key + params["oauth_timestamp"] = "1477041328" + params["oauth_nonce"] = "166182658461433445531477041328" + params["oauth_signature_method"] = self.signature_method + params["oauth_version"] = "1.0" + params["oauth_callback"] = 'localhost:8888/wordpress' + + signed_url = self.wcapi.auth.add_params_sign( + "GET", endpoint_url, params) + + signed_url_params = parse_qsl(urlparse(signed_url).query) + # self.assertEqual('page', signed_url_params[-1][0]) + self.assertIn('page', dict(signed_url_params)) + + +class OAuth3LegTestcases(unittest.TestCase): + def setUp(self): + self.consumer_key = "ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + self.consumer_secret = "cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + self.api = API( + url="http://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret, + oauth1a_3leg=True, + wp_user='test_user', + wp_pass='test_pass', + callback='http://127.0.0.1/oauth1_callback' + ) + + @urlmatch(path=r'.*wp-json.*') + def woo_api_mock(*args, **kwargs): + """ URL Mock """ + return { + 'status_code': 200, + 'content': b""" + { + "name": "Wordpress", + "description": "Just another WordPress site", + "url": "http://localhost:8888/wordpress", + "home": "http://localhost:8888/wordpress", + "namespaces": [ + "wp/v2", + "oembed/1.0", + "wc/v1" + ], + "authentication": { + "oauth1": { + "request": + "http://localhost:8888/wordpress/oauth1/request", + "authorize": + "http://localhost:8888/wordpress/oauth1/authorize", + "access": + "http://localhost:8888/wordpress/oauth1/access", + "version": "0.1" + } + } + } + """ + } + + @urlmatch(path=r'.*oauth.*') + def woo_authentication_mock(*args, **kwargs): + """ URL Mock """ + return { + 'status_code': 200, + 'content': + b"""oauth_token=XXXXXXXXXXXX&oauth_token_secret=YYYYYYYYYYYY""" + } + + def test_get_sign_key(self): + oauth_token_secret = "PNW9j1yBki3e7M7EqB5qZxbe9n5tR6bIIefSMQ9M2pdyRI9g" + + key = self.api.auth.get_sign_key( + self.consumer_secret, oauth_token_secret) + self.assertEqual( + StrUtils.to_binary(key), + StrUtils.to_binary("%s&%s" % + (self.consumer_secret, oauth_token_secret)) + ) + + def test_auth_discovery(self): + + with HTTMock(self.woo_api_mock): + # call requests + authentication = self.api.auth.authentication + self.assertEquals( + authentication, + { + "oauth1": { + "request": + "http://localhost:8888/wordpress/oauth1/request", + "authorize": + "http://localhost:8888/wordpress/oauth1/authorize", + "access": + "http://localhost:8888/wordpress/oauth1/access", + "version": "0.1" + } + } + ) + + def test_get_request_token(self): + + with HTTMock(self.woo_api_mock): + authentication = self.api.auth.authentication + self.assertTrue(authentication) + + with HTTMock(self.woo_authentication_mock): + request_token, request_token_secret = \ + self.api.auth.get_request_token() + self.assertEquals(request_token, 'XXXXXXXXXXXX') + self.assertEquals(request_token_secret, 'YYYYYYYYYYYY') + + def test_store_access_creds(self): + _, creds_store_path = mkstemp( + "wp-api-python-test-store-access-creds.json") + api = API( + url="http://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret, + oauth1a_3leg=True, + wp_user='test_user', + wp_pass='test_pass', + callback='http://127.0.0.1/oauth1_callback', + access_token='XXXXXXXXXXXX', + access_token_secret='YYYYYYYYYYYY', + creds_store=creds_store_path + ) + api.auth.store_access_creds() + + with open(creds_store_path) as creds_store_file: + self.assertEqual( + creds_store_file.read(), + ('{"access_token": "XXXXXXXXXXXX", ' + '"access_token_secret": "YYYYYYYYYYYY"}') + ) + + def test_retrieve_access_creds(self): + _, creds_store_path = mkstemp( + "wp-api-python-test-store-access-creds.json") + with open(creds_store_path, 'w+') as creds_store_file: + creds_store_file.write( + ('{"access_token": "XXXXXXXXXXXX", ' + '"access_token_secret": "YYYYYYYYYYYY"}')) + + api = API( + url="http://woo.test", + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret, + oauth1a_3leg=True, + wp_user='test_user', + wp_pass='test_pass', + callback='http://127.0.0.1/oauth1_callback', + creds_store=creds_store_path + ) + + api.auth.retrieve_access_creds() + + self.assertEqual( + api.auth.access_token, + 'XXXXXXXXXXXX' + ) + + self.assertEqual( + api.auth.access_token_secret, + 'YYYYYYYYYYYY' + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..da07de8 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,188 @@ +""" API Tests """ +from __future__ import unicode_literals + +import unittest + +from six import text_type +from wordpress.helpers import SeqUtils, StrUtils, UrlUtils + + +class HelperTestcase(unittest.TestCase): + def setUp(self): + self.test_url = ( + "http://ich.local:8888/woocommerce/wc-api/v3/products?" + "filter%5Blimit%5D=2&" + "oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&" + "oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&" + "oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&" + "oauth_signature_method=HMAC-SHA1&" + "oauth_timestamp=1481601370&page=2" + ) + + def test_url_is_ssl(self): + self.assertTrue(UrlUtils.is_ssl("https://woo.test:8888")) + self.assertFalse(UrlUtils.is_ssl("http://woo.test:8888")) + + def test_url_substitute_query(self): + self.assertEqual( + UrlUtils.substitute_query( + "https://woo.test:8888/sdf?param=value", "newparam=newvalue"), + "https://woo.test:8888/sdf?newparam=newvalue" + ) + self.assertEqual( + UrlUtils.substitute_query("https://woo.test:8888/sdf?param=value"), + "https://woo.test:8888/sdf" + ) + self.assertEqual( + UrlUtils.substitute_query( + "https://woo.test:8888/sdf?param=value", + "newparam=newvalue&othernewparam=othernewvalue" + ), + ( + "https://woo.test:8888/sdf?newparam=newvalue&" + "othernewparam=othernewvalue" + ) + ) + self.assertEqual( + UrlUtils.substitute_query( + "https://woo.test:8888/sdf?param=value", + "newparam=newvalue&othernewparam=othernewvalue" + ), + ( + "https://woo.test:8888/sdf?newparam=newvalue&" + "othernewparam=othernewvalue" + ) + ) + + def test_url_add_query(self): + self.assertEqual( + "https://woo.test:8888/sdf?param=value&newparam=newvalue", + UrlUtils.add_query( + "https://woo.test:8888/sdf?param=value", 'newparam', 'newvalue' + ) + ) + + def test_url_join_components(self): + self.assertEqual( + 'https://woo.test:8888/wp-json', + UrlUtils.join_components(['https://woo.test:8888/', '', 'wp-json']) + ) + self.assertEqual( + 'https://woo.test:8888/wp-json/wp/v2', + UrlUtils.join_components( + ['https://woo.test:8888/', 'wp-json', 'wp/v2']) + ) + + def test_url_get_php_value(self): + self.assertEqual( + '1', + UrlUtils.get_value_like_as_php(True) + ) + self.assertEqual( + '', + UrlUtils.get_value_like_as_php(False) + ) + self.assertEqual( + 'asd', + UrlUtils.get_value_like_as_php('asd') + ) + self.assertEqual( + '1', + UrlUtils.get_value_like_as_php(1) + ) + self.assertEqual( + '1', + UrlUtils.get_value_like_as_php(1.0) + ) + self.assertEqual( + '1.1', + UrlUtils.get_value_like_as_php(1.1) + ) + + def test_url_get_query_dict_singular(self): + result = UrlUtils.get_query_dict_singular(self.test_url) + self.assertEquals( + result, + { + 'filter[limit]': '2', + 'oauth_nonce': 'c4f2920b0213c43f2e8d3d3333168ec4a22222d1', + 'oauth_timestamp': '1481601370', + 'oauth_consumer_key': + 'ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + 'oauth_signature_method': 'HMAC-SHA1', + 'oauth_signature': '3ibOjMuhj6JGnI43BQZGniigHh8=', + 'page': '2' + } + ) + + def test_url_get_query_singular(self): + result = UrlUtils.get_query_singular( + self.test_url, 'oauth_consumer_key') + self.assertEqual( + result, + 'ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + ) + result = UrlUtils.get_query_singular(self.test_url, 'filter[limit]') + self.assertEqual( + text_type(result), + text_type(2) + ) + + def test_url_set_query_singular(self): + result = UrlUtils.set_query_singular(self.test_url, 'filter[limit]', 3) + expected = ( + "http://ich.local:8888/woocommerce/wc-api/v3/products?" + "filter%5Blimit%5D=3&" + "oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&" + "oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&" + "oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&" + "oauth_signature_method=HMAC-SHA1&oauth_timestamp=1481601370&" + "page=2" + ) + self.assertEqual(result, expected) + + def test_url_del_query_singular(self): + result = UrlUtils.del_query_singular(self.test_url, 'filter[limit]') + expected = ( + "http://ich.local:8888/woocommerce/wc-api/v3/products?" + "oauth_consumer_key=ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&" + "oauth_nonce=c4f2920b0213c43f2e8d3d3333168ec4a22222d1&" + "oauth_signature=3ibOjMuhj6JGnI43BQZGniigHh8%3D&" + "oauth_signature_method=HMAC-SHA1&" + "oauth_timestamp=1481601370&" + "page=2" + ) + self.assertEqual(result, expected) + + def test_url_remove_default_port(self): + self.assertEqual( + UrlUtils.remove_default_port('http://www.gooogle.com:80/'), + 'http://www.gooogle.com/' + ) + self.assertEqual( + UrlUtils.remove_default_port('http://www.gooogle.com:18080/'), + 'http://www.gooogle.com:18080/' + ) + + def test_seq_filter_true(self): + self.assertEquals( + ['a', 'b', 'c', 'd'], + SeqUtils.filter_true([None, 'a', False, 'b', 'c', 'd']) + ) + + def test_str_remove_tail(self): + self.assertEqual( + 'sdf', + StrUtils.remove_tail('sdf/', '/') + ) + + def test_str_remove_head(self): + self.assertEqual( + 'sdf', + StrUtils.remove_head('/sdf', '/') + ) + + self.assertEqual( + 'sdf', + StrUtils.decapitate('sdf', '/') + ) diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..a221d3c --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,43 @@ +""" API Tests """ +from __future__ import unicode_literals + +import unittest + +from httmock import HTTMock, all_requests +from wordpress.transport import API_Requests_Wrapper + + +class TransportTestcases(unittest.TestCase): + def setUp(self): + self.requester = API_Requests_Wrapper( + url='https://woo.test:8888/', + api='wp-json', + api_version='wp/v2' + ) + + def test_api_url(self): + self.assertEqual( + 'https://woo.test:8888/wp-json', + self.requester.api_url + ) + + def test_endpoint_url(self): + self.assertEqual( + 'https://woo.test:8888/wp-json/wp/v2/posts', + self.requester.endpoint_url('posts') + ) + + def test_request(self): + @all_requests + def woo_test_mock(*args, **kwargs): + """ URL Mock """ + return {'status_code': 200, + 'content': b'OK'} + + with HTTMock(woo_test_mock): + # call requests + response = self.requester.request( + "GET", "https://woo.test:8888/wp-json/wp/v2/posts") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.request.url, + 'https://woo.test:8888/wp-json/wp/v2/posts') From 9fcdbc978c8ec4ce6eb6a5d5d7d65e6f496d200f Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 16 Oct 2018 09:16:52 +1100 Subject: [PATCH 101/129] fix travis run in single env --- .travis.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0ce97e2..d136b69 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,9 @@ language: python sudo: required env: - - CODECOV_TOKEN: "da32b183-0d8b-4dc2-9bf9-e1743a39b2c8" - - CC_TEST_REPORTER_ID: "f65f25793658d7b33a3729b7b0303fef71fca3210105bb5b83605afb2fee687e" + global: + - CODECOV_TOKEN: "da32b183-0d8b-4dc2-9bf9-e1743a39b2c8" + - CC_TEST_REPORTER_ID: "f65f25793658d7b33a3729b7b0303fef71fca3210105bb5b83605afb2fee687e" services: - docker python: From 29faf975841a3ee4ea5169f3fd6005fb4a54f713 Mon Sep 17 00:00:00 2001 From: derwentx Date: Tue, 16 Oct 2018 09:21:01 +1100 Subject: [PATCH 102/129] Update .travis.yml for new tests module --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d136b69..4e44514 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,7 +22,7 @@ before_script: - ./cc-test-reporter before-build # command to run tests script: - - py.test --cov=wordpress tests.py + - py.test --cov=wordpress tests after_success: - codecov - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT --debug From 04c403ffe3ec63651202a4b80be0135974757ea9 Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 18 Oct 2018 07:34:03 +1100 Subject: [PATCH 103/129] support more encoding types --- wordpress/helpers.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/wordpress/helpers.py b/wordpress/helpers.py index fda3896..081af1b 100644 --- a/wordpress/helpers.py +++ b/wordpress/helpers.py @@ -7,6 +7,8 @@ __title__ = "wordpress-requests" import json +import locale +import os import posixpath import re import sys @@ -65,15 +67,14 @@ def to_binary(cls, string, encoding='utf8', errors='backslashreplace'): @classmethod def jsonencode(cls, data, **kwargs): - # kwargs['cls'] = BytesJsonEncoder - # if PY2: - # kwargs['encoding'] = 'utf8' if PY2: - for encoding in [ + for encoding in filter(None, { kwargs.get('encoding', 'utf8'), sys.getdefaultencoding(), + sys.getfilesystemencoding(), + locale.getpreferredencoding(), 'utf8', - ]: + }): try: kwargs['encoding'] = encoding return json.dumps(data, **kwargs) From cf21cad60e1283e0c6c609ddacf8a45aa7e3eadc Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 19 Oct 2018 14:04:19 +1100 Subject: [PATCH 104/129] manually set requirements using pipreqs --- reuirements.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 reuirements.txt diff --git a/reuirements.txt b/reuirements.txt new file mode 100644 index 0000000..e2f69a8 --- /dev/null +++ b/reuirements.txt @@ -0,0 +1,17 @@ +six==1.11.0 +Twisted==18.7.0 +ordereddict==1.1 +httmock==1.2.3 +requests_oauthlib==1.0.0 +pathlib2==2.3.2 +setuptools==40.0.0 +funcsigs==1.0.2 +requests==2.19.1 +zope.interface==4.5.0 +more_itertools==4.2.0 +colorama==0.3.9 +atomicwrites==1.1.5 +numpy==1.13.3 +argcomplete==1.9.4 +beautifulsoup4==4.6.3 +zope==4.0b6 From dd3e9cb5a0295ac89348b6f7dd39ce79f7d9730a Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 19 Oct 2018 14:11:52 +1100 Subject: [PATCH 105/129] fix typo requirements.txt --- requirements.txt | 18 +++++++++++++++++- reuirements.txt | 17 ----------------- 2 files changed, 17 insertions(+), 18 deletions(-) delete mode 100644 reuirements.txt diff --git a/requirements.txt b/requirements.txt index 9c558e3..e2f69a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,17 @@ -. +six==1.11.0 +Twisted==18.7.0 +ordereddict==1.1 +httmock==1.2.3 +requests_oauthlib==1.0.0 +pathlib2==2.3.2 +setuptools==40.0.0 +funcsigs==1.0.2 +requests==2.19.1 +zope.interface==4.5.0 +more_itertools==4.2.0 +colorama==0.3.9 +atomicwrites==1.1.5 +numpy==1.13.3 +argcomplete==1.9.4 +beautifulsoup4==4.6.3 +zope==4.0b6 diff --git a/reuirements.txt b/reuirements.txt deleted file mode 100644 index e2f69a8..0000000 --- a/reuirements.txt +++ /dev/null @@ -1,17 +0,0 @@ -six==1.11.0 -Twisted==18.7.0 -ordereddict==1.1 -httmock==1.2.3 -requests_oauthlib==1.0.0 -pathlib2==2.3.2 -setuptools==40.0.0 -funcsigs==1.0.2 -requests==2.19.1 -zope.interface==4.5.0 -more_itertools==4.2.0 -colorama==0.3.9 -atomicwrites==1.1.5 -numpy==1.13.3 -argcomplete==1.9.4 -beautifulsoup4==4.6.3 -zope==4.0b6 From 23a574e6ffbb3408581b6f6d3c65fb6630bb4206 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 19 Oct 2018 14:17:45 +1100 Subject: [PATCH 106/129] ensure requirements are mutually exclusive Double requirement given: six (from -r requirements-test.txt (line 3)) (already in six==1.11.0 (from -r requirements.txt (line 1)), name='six') --- requirements-test.txt | 1 - requirements.txt | 1 - 2 files changed, 2 deletions(-) diff --git a/requirements-test.txt b/requirements-test.txt index 3fb7e64..f0d28af 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,5 @@ -r requirements.txt httmock==1.2.3 -six pytest pytest-cov==2.5.1 coverage diff --git a/requirements.txt b/requirements.txt index e2f69a8..30a613b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ six==1.11.0 Twisted==18.7.0 ordereddict==1.1 -httmock==1.2.3 requests_oauthlib==1.0.0 pathlib2==2.3.2 setuptools==40.0.0 From 76eccf859a8be70eb833c0af22fc3f501d9884fc Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 19 Oct 2018 14:36:06 +1100 Subject: [PATCH 107/129] fix requirements for travis --- requirements.txt | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/requirements.txt b/requirements.txt index 30a613b..703eb9c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,16 @@ -six==1.11.0 -Twisted==18.7.0 -ordereddict==1.1 -requests_oauthlib==1.0.0 -pathlib2==2.3.2 -setuptools==40.0.0 -funcsigs==1.0.2 -requests==2.19.1 -zope.interface==4.5.0 -more_itertools==4.2.0 -colorama==0.3.9 -atomicwrites==1.1.5 -numpy==1.13.3 -argcomplete==1.9.4 -beautifulsoup4==4.6.3 -zope==4.0b6 +six +Twisted +ordereddict +requests_oauthlib +pathlib2 +setuptools +funcsigs +requests +zope.interface +more_itertools +colorama +atomicwrites +numpy +argcomplete +beautifulsoup4 +zope From 2688c3d20fea33b9c102ee87fd59f12b525bb465 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 19 Oct 2018 14:40:09 +1100 Subject: [PATCH 108/129] remove versions from requirements-test to fix travis build for python3 --- requirements-test.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-test.txt b/requirements-test.txt index f0d28af..ca5291a 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,6 @@ -r requirements.txt -httmock==1.2.3 +httmock pytest -pytest-cov==2.5.1 +pytest-cov coverage codecov From 43608ba1d5d4a4f1ec97559f1b0faf5c634c558b Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 19 Oct 2018 14:45:57 +1100 Subject: [PATCH 109/129] remove un-necessary dependencies for travis --- requirements.txt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index 703eb9c..3821318 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,9 @@ six -Twisted ordereddict requests_oauthlib pathlib2 -setuptools funcsigs requests -zope.interface more_itertools colorama -atomicwrites -numpy -argcomplete beautifulsoup4 -zope From 9793c4aaf679c362391dde53a5ed204193dce113 Mon Sep 17 00:00:00 2001 From: derwentx Date: Fri, 19 Oct 2018 14:52:12 +1100 Subject: [PATCH 110/129] add Snyk badge --- README.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.rst b/README.rst index af5f82a..c04824e 100644 --- a/README.rst +++ b/README.rst @@ -4,6 +4,9 @@ Wordpress API - Python Client .. image:: https://travis-ci.org/derwentx/wp-api-python.svg?branch=master :target: https://travis-ci.org/derwentx/wp-api-python +.. image:: https://snyk.io/test/github/derwentx/wp-api-python/badge.svg?targetFile=requirements.txt + :target: https://snyk.io/test/github/derwentx/wp-api-python?targetFile=requirements.txt + A Python wrapper for the Wordpress and WooCommerce REST APIs with oAuth1a 3leg support. Supports the Wordpress REST API v1-2, WooCommerce REST API v1-3 and WooCommerce WP-API v1-2 (with automatic OAuth3a handling). From 30ca16a1e8c429eb64bca70c9c34c32c23e615b8 Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 25 Oct 2018 15:28:20 +1100 Subject: [PATCH 111/129] use old WC XML sample data see https://github.com/woocommerce/woocommerce/issues/21663 --- docker-compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5c4624f..4dc59e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,8 @@ services: WORDPRESS_API_CALLBACK: "http://127.0.0.1/oauth1_callback" WORDPRESS_API_KEY: "tYG1tAoqjBEM" WORDPRESS_API_SECRET: "s91fvylVrqChwzzDbEJHEWyySYtAmlIsqqYdjka1KyVDdAyB" - WOOCOMMERCE_TEST_DATA: 1 + WOOCOMMERCE_TEST_DATA: "1" + WOOCOMMERCE_TEST_DATA_URL: "https://raw.githubusercontent.com/woocommerce/woocommerce/c81b3cf1655f9983db37bff750cb5baae3c3236e/dummy-data/dummy-products.xml" WOOCOMMERCE_CONSUMER_KEY: "ck_659f6994ae88fed68897f9977298b0e19947979a" WOOCOMMERCE_CONSUMER_SECRET: "cs_9421d39290f966172fef64ae18784a2dc7b20976" links: From cf29c4c7ca1768d5fe122f311c8cc4aa0b537e02 Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 25 Oct 2018 15:48:15 +1100 Subject: [PATCH 112/129] update README with CC badges --- README.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.rst b/README.rst index c04824e..c4b14ae 100644 --- a/README.rst +++ b/README.rst @@ -3,6 +3,14 @@ Wordpress API - Python Client .. image:: https://travis-ci.org/derwentx/wp-api-python.svg?branch=master :target: https://travis-ci.org/derwentx/wp-api-python + +.. image:: https://api.codeclimate.com/v1/badges/4df627621037b2df7e5d/maintainability + :target: https://codeclimate.com/github/derwentx/wp-api-python/maintainability + :alt: Maintainability + +.. image:: https://api.codeclimate.com/v1/badges/4df627621037b2df7e5d/test_coverage + :target: https://codeclimate.com/github/derwentx/wp-api-python/test_coverage + :alt: Test Coverage .. image:: https://snyk.io/test/github/derwentx/wp-api-python/badge.svg?targetFile=requirements.txt :target: https://snyk.io/test/github/derwentx/wp-api-python?targetFile=requirements.txt From c3137dd916233bd2ebef0da8e9463b691c1ca77f Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 25 Oct 2018 18:10:06 +1100 Subject: [PATCH 113/129] create creds_store if not exist --- wordpress/auth.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/wordpress/auth.py b/wordpress/auth.py index fc76a54..5c31ae5 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -612,6 +612,9 @@ def store_access_creds(self): if self.access_token_secret: creds['access_token_secret'] = self.access_token_secret if creds: + dirname = os.path.dirname(self.creds_store) + if not os.path.exists(dirname): + os.mkdir(dirname) with open(self.creds_store, 'w+') as creds_store_file: StrUtils.to_binary( json.dump(creds, creds_store_file, ensure_ascii=False)) From b3940af31a12306b81283f551b0e659468659774 Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 25 Oct 2018 18:10:35 +1100 Subject: [PATCH 114/129] clearer check of wp_json_v1 --- wordpress/transport.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/wordpress/transport.py b/wordpress/transport.py index 8872a3b..3f05a97 100644 --- a/wordpress/transport.py +++ b/wordpress/transport.py @@ -40,13 +40,17 @@ def api_url(self): ] return UrlUtils.join_components(components) + @property + def is_wp_json_v1(self): + return self.api == 'wp-json' and self.api_version == 'wp/v1' + @property def api_ver_url(self): components = [ self.url, self.api, ] - if self.api_version != 'wp/v1': + if not self.is_wp_json_v1: components += [ self.api_version ] @@ -64,7 +68,7 @@ def endpoint_url(self, endpoint): self.url, self.api ] - if self.api_version != 'wp/v1': + if not self.is_wp_json_v1: components += [ self.api_version ] From 60fec3fa0a3c664f78cb283636e4ee5a7779b72b Mon Sep 17 00:00:00 2001 From: derwentx Date: Thu, 25 Oct 2018 18:16:41 +1100 Subject: [PATCH 115/129] account for scenario where creds store has tilde --- wordpress/auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wordpress/auth.py b/wordpress/auth.py index 5c31ae5..819c957 100644 --- a/wordpress/auth.py +++ b/wordpress/auth.py @@ -613,6 +613,8 @@ def store_access_creds(self): creds['access_token_secret'] = self.access_token_secret if creds: dirname = os.path.dirname(self.creds_store) + dirname = os.path.expanduser(dirname) + dirname = os.path.expandvars(dirname) if not os.path.exists(dirname): os.mkdir(dirname) with open(self.creds_store, 'w+') as creds_store_file: From fcd4d66b7e8c2f80e96e8f05476ae04229fe711e Mon Sep 17 00:00:00 2001 From: Stephen Brown Date: Sat, 16 Mar 2019 10:44:09 +0000 Subject: [PATCH 116/129] Allow a wordpress api request to specify certain status codes it wants to allow/handle in response. --- tests/test_api.py | 18 ++++++++++++++++++ wordpress/api.py | 4 +++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index 2e7dded..da8aff9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -438,6 +438,24 @@ def test_APIPostBadData(self): with self.assertRaises(UserWarning): self.wpapi.post('posts', data) + def test_APIPostBadDataHandleBadStatus(self): + """ + Test handling explicitly a bad status code for a request. + """ + nonce = "%f\u00ae" % random.random() + + data = { + 'a': nonce + } + + response = self.wpapi.post('posts', data, handle_status_codes=[400]) + self.assertEqual(response.status_code, 400) + + # If we don't specify a correct status code to handle we should + # still expect an exception + with self.assertRaises(UserWarning): + self.wpapi.post('posts', data, handle_status_codes=[404]) + def test_APIPostMedia(self): img_path = 'tests/data/test.jpg' with open(img_path, 'rb') as test_file: diff --git a/wordpress/api.py b/wordpress/api.py index 491dce5..79a1114 100644 --- a/wordpress/api.py +++ b/wordpress/api.py @@ -219,6 +219,8 @@ def __request(self, method, endpoint, data, **kwargs): # enforce utf-8 encoded binary data = StrUtils.to_binary(data) + handle_status_codes = kwargs.pop('handle_status_codes', []) + response = self.requester.request( method=method, url=endpoint_url, @@ -227,7 +229,7 @@ def __request(self, method, endpoint, data, **kwargs): **kwargs ) - if response.status_code not in [200, 201, 202]: + if response.status_code not in [200, 201, 202] + handle_status_codes: self.request_post_mortem(response) return response From dd4374ca8005a455af5cc9de73b3d97ab4fdc7ea Mon Sep 17 00:00:00 2001 From: Derwent Date: Sun, 5 May 2019 12:05:20 +1000 Subject: [PATCH 117/129] pin pytest-cov to fix failing build see: https://github.com/pywbem/pywbem/issues/1371 --- requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-test.txt b/requirements-test.txt index ca5291a..a6d0ee5 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,6 @@ -r requirements.txt httmock pytest -pytest-cov +pytest-cov<2.6.0 coverage codecov From 1326d89d965eb2a63eac1ea1937aa2942980ce83 Mon Sep 17 00:00:00 2001 From: Derwent Date: Sun, 5 May 2019 12:14:48 +1000 Subject: [PATCH 118/129] update pytest-cov requirements to fix CI build --- requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-test.txt b/requirements-test.txt index ca5291a..a6d0ee5 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,6 @@ -r requirements.txt httmock pytest -pytest-cov +pytest-cov<2.6.0 coverage codecov From 300f2396f29f8db001847938b1c171bd2a1943e9 Mon Sep 17 00:00:00 2001 From: Derwent Date: Sun, 5 May 2019 12:07:46 +1000 Subject: [PATCH 119/129] =?UTF-8?q?=F0=9F=90=8D=20update=20python=20versio?= =?UTF-8?q?n=20in=20travis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 4e44514..3111aaa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,7 @@ services: - docker python: - "2.7" - - "3.6" + - "3.7" - "nightly" # command to install dependencies install: From ec87d18126144f95e93269165b5ee492021de28f Mon Sep 17 00:00:00 2001 From: Derwent Date: Sun, 5 May 2019 12:39:29 +1000 Subject: [PATCH 120/129] =?UTF-8?q?=E2=9C=85=20add=20test=20for=20post=20w?= =?UTF-8?q?ith=20complex=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_api.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index da8aff9..e9c39f8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -482,6 +482,20 @@ def test_APIPostMedia(self): self.wpapi.delete('media/%s?force=True' % created_id) + def test_APIPostComplexContent(self): + data = { + 'content': "this content has links" + } + res = self.wpapi.post('posts', data) + + self.assertEqual(res.status_code, 201) + res_obj = res.json() + res_id= res_obj.get('id') + self.assertTrue(res_id) + print(res_obj) + res_content = res_obj.get('content').get('raw') + self.assertEqual(data.get('content'), res_content) + # def test_APIPostMediaBadCreds(self): # """ # TODO: make sure the warning is "ensure login and basic auth is installed" From 838eaea9452d6fd6b9de9208177b79afdb33dae1 Mon Sep 17 00:00:00 2001 From: Derwent Date: Sun, 5 May 2019 12:46:20 +1000 Subject: [PATCH 121/129] =?UTF-8?q?=F0=9F=90=8D=20revert=20back=20to=20pyt?= =?UTF-8?q?hon=203.6=20because=20of=20travis=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/travis-ci/travis-ci/issues/9815 --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 3111aaa..4e44514 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,7 @@ services: - docker python: - "2.7" - - "3.7" + - "3.6" - "nightly" # command to install dependencies install: From b983ecbb3cbab8065520cb6b5938c07a730c758a Mon Sep 17 00:00:00 2001 From: Derwent Date: Sun, 5 May 2019 13:06:24 +1000 Subject: [PATCH 122/129] =?UTF-8?q?=F0=9F=94=92=20fix=20urllib3=20requirem?= =?UTF-8?q?ent=20to=20remove=20vuln?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 3821318..da0aed0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ requests more_itertools colorama beautifulsoup4 +urllib3>=1.24.3 From e3b257b5ad2ef0bf0e8e325bba710aa12f5a4ebb Mon Sep 17 00:00:00 2001 From: Derwent Date: Sun, 5 May 2019 14:44:57 +1000 Subject: [PATCH 123/129] =?UTF-8?q?=F0=9F=91=B7=20add=20pypi=20deploy=20an?= =?UTF-8?q?d=20secrets=20to=20travis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .talismanrc | 4 ++++ .travis.yml | 44 +++++++++++++++++++++++++------------------- 2 files changed, 29 insertions(+), 19 deletions(-) create mode 100644 .talismanrc diff --git a/.talismanrc b/.talismanrc new file mode 100644 index 0000000..5182285 --- /dev/null +++ b/.talismanrc @@ -0,0 +1,4 @@ +fileignoreconfig: +- filename: .travis.yml + checksum: 1f8ad739036010977c7663abd2a9348f00e7a25424f550f7d0cfc26423e667e5 + ignore_detectors: [] diff --git a/.travis.yml b/.travis.yml index 4e44514..11a4a25 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,28 +1,34 @@ language: python sudo: required env: - global: - - CODECOV_TOKEN: "da32b183-0d8b-4dc2-9bf9-e1743a39b2c8" - - CC_TEST_REPORTER_ID: "f65f25793658d7b33a3729b7b0303fef71fca3210105bb5b83605afb2fee687e" + global: + CODECOV_TOKEN: + secure: ZXkVg6JLVPav6OJPzfUgIGD64e85N92tgpXA2nymIHfucCTGC5B4yniCTD49jz6xET1m1qRb04lvomCiQ7PeDoBW/LgJLIjXyJOW+2iA9VA3MdxwQF8Teu5W1J34wH4dm6nfn0KCNxhYRDTBkDgvXeUcXP5nNlo9w3sL+FKbAdXucwlL8ytcJXf641YhuGQpIrh1XGioBSNJ54BTRDXQXcRW76XaltxHCsbEv+fLH4yuahJdCTGjsr9cGdygAlo3FcLsqgkcjFCoNjg5UgBcPN8QPfWAppeIrmLRCq/q+p5KH2awPYqH0BL8jdTTmFElyGLmQBNnB6R5tI5HIx7+OCsw79mhXPVRgn3xkRj0OWRjzYlA+vW8JM2rEepixs9CRWtZJdC72oe1aytFb2cyVDfmotLwyuUqFI2ieQkyHgj0OLl1n1tcicRo8eS5RIB8mYicCm29lDrs/J6TFWSl/VqNUtZU+y54I/lv/fiFbRVjtMZ+PdwGHoigaVaIKeWe1TmlGWun7bh4Ov3jz62WtBlvuhz3LHMYD8OIuijot0HHqWsC1mlmZUvKoeSDYFXNLBBSAwMkkAfxxQM0PhMG3qUUirkd5xPJyBh1n8d4/KQzrkTblI7QzZgCwJE1r1L99XMs2/Ugf65gfTxRYFOMZGZUi0rzvXlu0Z7P5VrQr6c= + CC_TEST_REPORTER_ID: + secure: IceCOfujcdUwsTsg1328sbrvO/33N39/9pHxG/1VkMpqt47WDlG1lxbQGV78WuK7exip/JaDcB+iWPNJbyxGirOK0Co64O61iZcKUEbH6wjWxjeZ2yuhpyxyrnUF9OWmk8op4ewkU2ww6tzXQT2Lo+b/g8ryTFag0o8roA9unCj5p42aywZ927UIagaVqQh0sJ/qUUCmwAvGIB8bqKL8nxg97PwgBy38mH5PWE3Bqkm0FBpreKb1x4m4n9wZE29noiImT0xEIZMCwZ4zUPzbpKQmdUe1tHWf0hoQuVPWHLCMwqU2AW/PiY3CqlaAiUX71450WaKDrjbBtUvDl73YaUdiroWoL4rrm2UjlNGFbpEoqEbdBn2HLEefCw+zoo8YEPxieXVUQgmRygGpgoHTrRFqkReLA2BxV6F7IeMZ2AtOW0OXejzjcOEBWnRFs2sF6EqQZL8decye3P5CPcKVzNg28QEBBtdYgYT02qlY8JFv8N6KU9qNMjMvT9yQU8lfbV0iteMtdZl4coinNR34hNf9jMY+uj3/44kHgooygur/A9tHgQt/9/VTpS//y79gG6+ozllwzFQjzWE1AqLUPnPtSJZpvF8F5mmnww/sf/pjsV7jvA9VwyF/paO2JicGIN+bw86FNydXRHP3mmEAfJXOBiJVr5xPD2Wi1Q8Dw3k= services: - - docker +- docker python: - - "2.7" - - "3.6" - - "nightly" -# command to install dependencies +- '2.7' +- '3.6' +- nightly install: - - docker-compose up -d - - pip install . - - pip install -r requirements-test.txt - - docker exec -it wpapipython_woocommerce_1 bash -c 'until [ -f .done ]; do sleep 1; done; echo "complete"' +- docker-compose up -d +- pip install . +- pip install -r requirements-test.txt +- docker exec -it wpapipython_woocommerce_1 bash -c 'until [ -f .done ]; do sleep + 1; done; echo "complete"' before_script: - - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter - - chmod +x ./cc-test-reporter - - ./cc-test-reporter before-build -# command to run tests +- curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter +- chmod +x ./cc-test-reporter +- "./cc-test-reporter before-build" script: - - py.test --cov=wordpress tests +- py.test --cov=wordpress tests after_success: - - codecov - - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT --debug +- codecov +- "./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT --debug" +deploy: + provider: pypi + user: derwents + password: + secure: Hj0CU4CzrA1WhZ/lHW7sDYXjmYSBnEfooRPwgoIMbjk+1G1THNk5A0efR6Vmjh5Ugm8vejHRRCUuYY6UwFE86KbGRUCModx/9rSeoLSKJ1DPIryHNRXgDDJGx2zt8fbvOwnzY7vFcvGDnN65EkGcZxekdwi4kNGQgp54vCQzjvf+dxo4A9+YuHPl8JzQekHQP3uU4VIMGgKSNpMfCdTraBzO8rAX0EuBTEEjczDJn3X77D/vSgqgA6G8oU0tcgnpd9ryydSHoc1icU70D0NUvHTLRr/RNVfIk+QVEcvcFedg4Wo81rcXxta1UQTmyIIZGBGNfNo/68o9GqsD0Edc4oHfZkMqainknaKfke8LtqdEEkOSwuhHX4NdzwBnMCV5rMud2W42hikiJKEPy3cGZJjiabUmEG8IpI1EsiMz5zCod/OVPGq87n4towvnsIpIzyGC7JkbSxHn+NYASkIkX38lhiPzYpgW7VZXRAUPebkxW8P2Bmyx0A8Sli2ijAkx04ul6jk1/HGCB8Y4EtQ9GuefH5pwfV1fhb2lEf56Dyd+REdZia/jiU+dKoPXYv+ZmM1ynrQVwn1/ZCHZejLOzhCGR5Dxk2yjT51hKPHcNzKboR++XiiKML1/cPSTDcGSamADazKuLMqJkP+CWRPkwts+tKBOka0YLCVJwD4ZFgU= From f658875d47b05aa0a73c3919273c57fa2d6da4cd Mon Sep 17 00:00:00 2001 From: Derwent Date: Sun, 5 May 2019 14:54:29 +1000 Subject: [PATCH 124/129] =?UTF-8?q?=F0=9F=94=96=20update=20version=201.2.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .travis.yml | 1 + wordpress/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 11a4a25..8ba408d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -30,5 +30,6 @@ after_success: deploy: provider: pypi user: derwents + skip_existing: true password: secure: Hj0CU4CzrA1WhZ/lHW7sDYXjmYSBnEfooRPwgoIMbjk+1G1THNk5A0efR6Vmjh5Ugm8vejHRRCUuYY6UwFE86KbGRUCModx/9rSeoLSKJ1DPIryHNRXgDDJGx2zt8fbvOwnzY7vFcvGDnN65EkGcZxekdwi4kNGQgp54vCQzjvf+dxo4A9+YuHPl8JzQekHQP3uU4VIMGgKSNpMfCdTraBzO8rAX0EuBTEEjczDJn3X77D/vSgqgA6G8oU0tcgnpd9ryydSHoc1icU70D0NUvHTLRr/RNVfIk+QVEcvcFedg4Wo81rcXxta1UQTmyIIZGBGNfNo/68o9GqsD0Edc4oHfZkMqainknaKfke8LtqdEEkOSwuhHX4NdzwBnMCV5rMud2W42hikiJKEPy3cGZJjiabUmEG8IpI1EsiMz5zCod/OVPGq87n4towvnsIpIzyGC7JkbSxHn+NYASkIkX38lhiPzYpgW7VZXRAUPebkxW8P2Bmyx0A8Sli2ijAkx04ul6jk1/HGCB8Y4EtQ9GuefH5pwfV1fhb2lEf56Dyd+REdZia/jiU+dKoPXYv+ZmM1ynrQVwn1/ZCHZejLOzhCGR5Dxk2yjT51hKPHcNzKboR++XiiKML1/cPSTDcGSamADazKuLMqJkP+CWRPkwts+tKBOka0YLCVJwD4ZFgU= diff --git a/wordpress/__init__.py b/wordpress/__init__.py index b6a4ff1..ab933ec 100644 --- a/wordpress/__init__.py +++ b/wordpress/__init__.py @@ -10,7 +10,7 @@ """ __title__ = "wordpress" -__version__ = "1.2.8" +__version__ = "1.2.9" __author__ = "Claudio Sanches @ WooThemes, forked by Derwent" __license__ = "MIT" From 3bd9162dd3520686091726f6e8b4a562263cc9a2 Mon Sep 17 00:00:00 2001 From: Derwent Date: Sun, 5 May 2019 15:06:48 +1000 Subject: [PATCH 125/129] =?UTF-8?q?=F0=9F=93=9D=20add=20pypi=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++++ README.rst | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index cf735b3..e6b16b7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ nosetests.xml coverage.xml *.cover .hypothesis/ + +\.vscode/settings\.json + +\.vscode/ diff --git a/README.rst b/README.rst index c4b14ae..0c8bd62 100644 --- a/README.rst +++ b/README.rst @@ -15,6 +15,9 @@ Wordpress API - Python Client .. image:: https://snyk.io/test/github/derwentx/wp-api-python/badge.svg?targetFile=requirements.txt :target: https://snyk.io/test/github/derwentx/wp-api-python?targetFile=requirements.txt +.. image:: https://badge.fury.io/py/wordpress-api.svg + :target: https://badge.fury.io/py/wordpress-api + A Python wrapper for the Wordpress and WooCommerce REST APIs with oAuth1a 3leg support. Supports the Wordpress REST API v1-2, WooCommerce REST API v1-3 and WooCommerce WP-API v1-2 (with automatic OAuth3a handling). From 27e0385a9ceb3715c98768f3adf565142ba93387 Mon Sep 17 00:00:00 2001 From: derwentx Date: Sun, 8 Sep 2019 09:30:02 +1000 Subject: [PATCH 126/129] Update README.rst --- README.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.rst b/README.rst index 0c8bd62..886768d 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,6 @@ +**A note from the author:** I no longer do Wordpress work, so I won't have the time to adequately maintain this repo. If you would like to maintain a fork of this repo, and want me to link to your fork here, please `let me know `_. +thanks! + Wordpress API - Python Client =============================== From b75420c85a6411235dc49bf7bb7cbf82700b2180 Mon Sep 17 00:00:00 2001 From: derwentx Date: Sun, 26 Jul 2020 11:25:10 +1000 Subject: [PATCH 127/129] Update README.rst --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 886768d..730b576 100644 --- a/README.rst +++ b/README.rst @@ -53,6 +53,7 @@ You should have the following plugins installed on your wordpress site: - **WP REST API** (only required for WP < v4.7, recommended version: 2.0+) - **WP REST API - OAuth 1.0a Server** (optional, if you want oauth within the wordpress API. https://github.com/WP-API/OAuth1) - **WP REST API - Meta Endpoints** (optional) +- **WP API Basic Auth** https://github.com/WP-API/Basic-Auth (for image uploading) - **WooCommerce** (optional, if you want to use the WooCommerce API) The following python packages are also used by the package @@ -264,7 +265,7 @@ OPTIONS Upload an image ----- -(Note: this only works on WP API with basic auth) +(Note: this only works on WP API with the Basic Auth plugin enabled: https://github.com/WP-API/Basic-Auth ) .. code-block:: python From 4210346798900e99c808ec23e5d2383f04c20565 Mon Sep 17 00:00:00 2001 From: Dev Null Date: Fri, 24 Mar 2023 21:01:40 +0800 Subject: [PATCH 128/129] Update README.rst --- README.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.rst b/README.rst index 730b576..f97ea05 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,7 @@ **A note from the author:** I no longer do Wordpress work, so I won't have the time to adequately maintain this repo. If you would like to maintain a fork of this repo, and want me to link to your fork here, please `let me know `_. + +One such fork is [this one](https://github.com/Synoptik-Labs/wp-api-python) + thanks! Wordpress API - Python Client From 1ae45d3b95f4da968337d66e4bd932ce6cd4058f Mon Sep 17 00:00:00 2001 From: Dev Null Date: Fri, 24 Mar 2023 21:02:34 +0800 Subject: [PATCH 129/129] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index f97ea05..179d2c7 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,6 @@ **A note from the author:** I no longer do Wordpress work, so I won't have the time to adequately maintain this repo. If you would like to maintain a fork of this repo, and want me to link to your fork here, please `let me know `_. -One such fork is [this one](https://github.com/Synoptik-Labs/wp-api-python) +One such fork is https://github.com/Synoptik-Labs/wp-api-python thanks!