Skip to content

Commit 05070f9

Browse files
karthanistyrfguillot
authored andcommitted
Adds support for async/await API calls (no deps)
**BREAKING**: Drops support for python 2.7 and < 3.5 (it relies on asyncio (so 2.7 is out), and python versions below 3.5 have different behaviours regarding the thread pool, see: https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor). I am suggesting this change for your consideration. Backwards compatibility has been preserved by running the synchronous `kanboard.client.Kanboard.execute` method into an asyncio executor (for now, only the default executor can be used, but the event loop can be customised via Kanboard() ctor) Basic usage is to use the coroutines mapped by method calls with the _async suffix. Example: for a given kb object (of type Kanboard) Synchronous: kb.create_project(name="My project") Asynchronous: kb.create_project_async(name="My project") Fixes #14 Signed-off-by: Antoine Mazeas <antoine@karthanis.net>
1 parent a1e8109 commit 05070f9

7 files changed

Lines changed: 107 additions & 30 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
*.eggs
55
dist
66
build
7-
MANIFEST
7+
MANIFEST
8+
__pycache__

.travis.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ notifications:
44
language: python
55

66
python:
7-
- '2.7'
8-
- '3.4'
97
- '3.5'
108
- '3.6'
119

README.rst

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Installation
1818
pip install kanboard
1919
2020
21-
This library is compatible with Python 2.7, 3.4, 3.5, 3.6, and 3.7.
21+
This library is compatible with Python 3.5, 3.6, and 3.7.
2222

2323
Examples
2424
========
@@ -27,6 +27,8 @@ Methods and arguments are the same as the JSON-RPC procedures described in the `
2727

2828
Python methods are dynamically mapped to the API procedures. You must use named arguments.
2929

30+
By default, the calls are made synchronously, meaning that they will block the program until completed.
31+
3032
Create a new team project
3133
-------------------------
3234

@@ -59,4 +61,33 @@ Create a new task
5961
project_id = kb.create_project(name='My project')
6062
task_id = kb.create_task(project_id=project_id, title='My task title')
6163
64+
Asynchronous I/O
65+
================
66+
67+
The client also exposes async/await style method calls. Similarly to the synchronous calls (see above), the method names are mapped to the API methods.
68+
To invoke an asynchronous call, the method name must be appended with `_async`: for example, a synchronous call to `create_project` can be made asynchronous by calling `create_project_async` instead.
69+
70+
.. code-block:: python
71+
72+
import asyncio
73+
from kanboard import Kanboard
74+
75+
kb = Kanboard('http://localhost/jsonrpc.php', 'jsonrpc', 'your_api_token')
76+
77+
loop = asyncio.get_event_loop()
78+
project_id = loop.run_until_complete(kb.create_project_async(name='My project'))
79+
80+
81+
.. code-block:: python
82+
83+
import asyncio
84+
from kanboard import Kanboard
85+
86+
async def call_within_function()
87+
kb = Kanboard('http://localhost/jsonrpc.php', 'jsonrpc', 'your_api_token')
88+
return await kb.create_project_async(name='My project')
89+
90+
loop = asyncio.get_event_loop()
91+
project_id = loop.run_until_complete(call_within_function())
92+
6293
See the `official API documentation <https://docs.kanboard.org/en/latest/api/index.html>`_ for the complete list of methods and arguments.

kanboard/client.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,15 @@
2222

2323
import json
2424
import base64
25+
import functools
26+
import asyncio
2527

2628
from kanboard import exceptions
27-
28-
try:
29-
from urllib import request as http
30-
except ImportError:
31-
import urllib2 as http
29+
from urllib import request as http
3230

3331

3432
DEFAULT_AUTH_HEADER = 'Authorization'
33+
ASYNC_FUNCNAME_MARKER = "_async"
3534

3635

3736
class Kanboard(object):
@@ -50,7 +49,13 @@ class Kanboard(object):
5049
5150
"""
5251

53-
def __init__(self, url, username, password, auth_header=DEFAULT_AUTH_HEADER, cafile=None):
52+
def __init__(self,
53+
url,
54+
username,
55+
password,
56+
auth_header=DEFAULT_AUTH_HEADER,
57+
cafile=None,
58+
loop=asyncio.get_event_loop()):
5459
"""
5560
Constructor
5661
@@ -59,18 +64,35 @@ def __init__(self, url, username, password, auth_header=DEFAULT_AUTH_HEADER, caf
5964
username: API username or real username
6065
password: API token or user password
6166
auth_header: API HTTP header
62-
67+
cafile: path to a custom CA certificate
68+
loop: an asyncio event loop. Default: asyncio.get_event_loop()
6369
"""
6470
self._url = url
6571
self._username = username
6672
self._password = password
6773
self._auth_header = auth_header
6874
self._cafile = cafile
75+
self._event_loop = loop
6976

7077
def __getattr__(self, name):
71-
def function(*args, **kwargs):
72-
return self.execute(method=self._to_camel_case(name), **kwargs)
73-
return function
78+
if(self.is_async_method_name(name)):
79+
async def function(*args, **kwargs):
80+
return await self._event_loop.run_in_executor(
81+
None,
82+
functools.partial(self.execute, method=self._to_camel_case(self.get_funcname_from_async_name(name)), **kwargs))
83+
return function
84+
else:
85+
def function(*args, **kwargs):
86+
return self.execute(method=self._to_camel_case(name), **kwargs)
87+
return function
88+
89+
@staticmethod
90+
def is_async_method_name(funcname):
91+
return funcname.endswith(ASYNC_FUNCNAME_MARKER)
92+
93+
@staticmethod
94+
def get_funcname_from_async_name(funcname):
95+
return funcname[:len(funcname) - len(ASYNC_FUNCNAME_MARKER)]
7496

7597
@staticmethod
7698
def _to_camel_case(snake_str):

setup.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def readme():
3232

3333
setup(
3434
name='kanboard',
35-
version='1.0.7',
35+
version='1.1.0',
3636
description='Client library for Kanboard API',
3737
long_description=readme(),
3838
keywords='kanboard api client',
@@ -47,8 +47,6 @@ def readme():
4747
'Natural Language :: English',
4848
'License :: OSI Approved :: MIT License',
4949
'Programming Language :: Python',
50-
'Programming Language :: Python :: 2.7',
51-
'Programming Language :: Python :: 3.4',
5250
'Programming Language :: Python :: 3.5',
5351
'Programming Language :: Python :: 3.6',
5452
'Programming Language :: Python :: 3.7',

test-requirements.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
11
flake8
2-
mock

tests/test_kanboard.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@
2020
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2121
# THE SOFTWARE.
2222

23-
import mock
24-
import sys
2523
import unittest
24+
from unittest import mock
25+
import types
26+
import warnings
2627

2728
from kanboard import client
2829
from kanboard import exceptions
@@ -35,10 +36,17 @@ def setUp(self):
3536
self.client = client.Kanboard(self.url, 'username', 'password')
3637
self.request, self.urlopen = self._create_mocks()
3738

39+
def ignore_warnings(test_func):
40+
def do_test(self, *args, **kwargs):
41+
with warnings.catch_warnings():
42+
warnings.simplefilter("ignore")
43+
test_func(self, *args, **kwargs)
44+
return do_test
45+
3846
def test_api_call(self):
3947
body = b'{"jsonrpc": "2.0", "result": true, "id": 123}'
4048
self.urlopen.return_value.read.return_value = body
41-
self.assertEquals(True, self.client.remote_procedure())
49+
self.assertEqual(True, self.client.remote_procedure())
4250
self.request.assert_called_once_with(self.url,
4351
data=mock.ANY,
4452
headers=mock.ANY)
@@ -47,8 +55,10 @@ def test_custom_auth_header(self):
4755
self.client._auth_header = 'X-Auth-Header'
4856
body = b'{"jsonrpc": "2.0", "result": true, "id": 123}'
4957
self.urlopen.return_value.read.return_value = body
50-
self.assertEquals(True, self.client.remote_procedure())
51-
self.request.assert_called_once()
58+
self.assertEqual(True, self.client.remote_procedure())
59+
self.request.assert_called_once_with(self.url,
60+
data=mock.ANY,
61+
headers=mock.ANY)
5262
_, kwargs = self.request.call_args
5363
assert kwargs['headers']['X-Auth-Header'] == 'dXNlcm5hbWU6cGFzc3dvcmQ='
5464

@@ -64,13 +74,31 @@ def test_application_error(self):
6474
with self.assertRaises(exceptions.KanboardClientException, msg='Internal error'):
6575
self.client.remote_procedure()
6676

77+
def test_async_method_call_recognised(self):
78+
method_name = "some_method_async"
79+
result = self.client.is_async_method_name(method_name)
80+
self.assertTrue(result)
81+
82+
def test_standard_method_call_recognised(self):
83+
method_name = "some_method"
84+
result = self.client.is_async_method_name(method_name)
85+
self.assertFalse(result)
86+
87+
def test_method_name_extracted_from_async_name(self):
88+
expected_method_name = "some_method"
89+
async_method_name = expected_method_name + "_async"
90+
result = self.client.get_funcname_from_async_name(async_method_name)
91+
self.assertEqual(expected_method_name, result)
92+
93+
# suppress a RuntimeWarning because coro is not awaited
94+
# this is done on purpose
95+
@ignore_warnings
96+
def test_async_call_generates_coro(self):
97+
method = self.client.my_method_async()
98+
self.assertIsInstance(method, types.CoroutineType)
99+
67100
@staticmethod
68101
def _create_mocks():
69-
if sys.version_info[0] < 3:
70-
urlopen_patcher = mock.patch('urllib2.urlopen')
71-
request_patcher = mock.patch('urllib2.Request')
72-
else:
73-
request_patcher = mock.patch('urllib.request.Request')
74-
urlopen_patcher = mock.patch('urllib.request.urlopen')
75-
102+
request_patcher = mock.patch('urllib.request.Request')
103+
urlopen_patcher = mock.patch('urllib.request.urlopen')
76104
return request_patcher.start(), urlopen_patcher.start()

0 commit comments

Comments
 (0)