Skip to content

Introducing AsyncIO Support & Request Timeouts #18

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-18.04

steps:
- uses: actions/checkout@master
- name: Set up Python 3.9
uses: actions/setup-python@v1
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v3
with:
python-version: 3.9
python-version: "3.11"
- name: Install pypa/build
run: >-
python -m
Expand All @@ -31,4 +31,4 @@ jobs:
- name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.PYPI_API_TOKEN }}
password: ${{ secrets.PYPI_API_TOKEN }}
6 changes: 3 additions & 3 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ jobs:

steps:
- uses: actions/checkout@v3
- name: Set up Python 3.10
- name: Set up Python 3.11
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install requests flake8 pytest
pip install requests flake8 pytest pytest-asyncio
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,12 @@ venv.bak/
.mypy_cache/

.DS_Store
.idea

# Pipenv
Pipfile
Pipfile.lock
.vscode/launch.json

# Unasync
knockapi/sync_client
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
recursive-include knockapi/sync_client *
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,22 @@ client.users.set_preferences(
client.users.get_preferences(user_id="jhammond")
```

### Signing JWTs
## AsyncIO Usage
The library utilises the [unasync](https://github.com/python-trio/unasync) library to provide full AsyncIO support, you just need to import the AsyncKnock client.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The library utilises the [unasync](https://github.com/python-trio/unasync) library to provide full AsyncIO support, you just need to import the AsyncKnock client.
The [unasync](https://github.com/python-trio/unasync) library is used to generate synchronous code from asynchronous one.
As a consequence, this SDK provides full asynchronous (resp. synchronous) support: to work asynchronously (resp. synchronously), you just need to import the AsyncKnock (resp. Knock) client.

```python
import asyncio
from knockapi import AsyncKnock

client = AsyncKnock(api_key="sk_12345")

async def example_coroutine():
messages = await client.messages.list()
print(messages)

asyncio.run(example_coroutine())
```

## Signing JWTs

You can use the `pyjwt` package to [sign JWTs easily](https://pyjwt.readthedocs.io/en/stable/usage.html#encoding-decoding-tokens-with-rs256-rsa).
You will need to generate an environment specific signing key, which you can find in the Knock dashboard.
Expand Down
16 changes: 14 additions & 2 deletions knockapi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,14 @@
from knockapi.client import Knock
from knockapi.resources import *
__version__ = '0.6.0'

from knockapi.async_client import Knock as AsyncKnock

try:
from knockapi.sync_client import Knock
except ImportError:
Knock = None

__all__ = [
"AsyncKnock",
"Knock",
"__version__"
]
87 changes: 28 additions & 59 deletions knockapi/client.py → knockapi/async_client/Knock.py
Original file line number Diff line number Diff line change
@@ -1,94 +1,62 @@
import requests
from json.decoder import JSONDecodeError

__version__ = '0.5.2'


class Connection(object):
def __init__(self, api_key, host='https://api.knock.app'):
self.api_key = api_key
self.host = host
self.client_version = __version__
self.headers = {
'Authorization': 'Bearer {}'.format(self.api_key),
'User-Agent': 'Knock Python - {}'.format(self.client_version)
}

def request(self, method, endpoint, payload=None):
url = '{}/v1{}'.format(self.host, endpoint)

r = requests.request(
method,
url,
params=payload if method == 'get' else None,
json=payload if method != 'get' else None,
headers=self.headers,
)

# If we got a successful response, then attempt to deserialize as JSON
if r.ok:
try:
return r.json()
except JSONDecodeError:
return None
from knockapi import __version__
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PEP8 convention about modules and packages naming states:

Modules should have short, all-lowercase names. Underscores can be used in the module name if it improves readability. Python packages should also have short, all-lowercase names, although the use of underscores is discouraged.

So files such as Knock.py should be renamed as knock.py (lower case). Same for async_client package which should be renamed as asyncclient (no underscore).

from knockapi.core import AsyncConnection
from knockapi.async_client.services import User, Workflows, Preferences, Objects, Tenants, BulkOperations, Messages

return r.raise_for_status()


class Knock(Connection):
class Knock(object):
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this PR requires "to drop Python 2 support and require >3.6.", there is no reason to make classes inheriting from object

Suggested change
class Knock(object):
class Knock:

"""Client to access the Knock features."""
@property
def _auth(self):
return self.api_key

@property
def _version(self):
return __version__
def __init__(
self,
api_key: str,
api_host: str = 'https://api.knock.app',
read_timeout: int = 300
):
self.connection = AsyncConnection(
api_key,
api_host,
__version__,
read_timeout
)

@property
def users(self):
from .resources import User
return User(self)

@property
def workflows(self):
from .resources import Workflows
return Workflows(self)

@property
def preferences(self):
from .resources import Preferences
return Preferences(self)

@property
def objects(self):
from .resources import Objects
return Objects(self)

@property
def tenants(self):
from .resources import Tenants
return Tenants(self)

@property
def bulk_operations(self):
from .resources import BulkOperations
return BulkOperations(self)

@property
def messages(self):
from .resources import Messages
return Messages(self)

# Defined at the top level here for convenience
def notify(
self,
key,
recipients,
data={},
actor=None,
cancellation_key=None,
tenant=None):
async def notify(
self,
key,
recipients,
data={},
actor=None,
cancellation_key=None,
tenant=None
):
"""
Triggers a workflow.

Expand All @@ -112,10 +80,11 @@ def notify(
dict: Response from Knock.
"""
# Note: this is essentially a delegated method
return self.workflows.trigger(
return await self.workflows.trigger(
key=key,
recipients=recipients,
data=data,
actor=actor,
cancellation_key=cancellation_key,
tenant=tenant)
tenant=tenant
)
1 change: 1 addition & 0 deletions knockapi/async_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .Knock import Knock
19 changes: 19 additions & 0 deletions knockapi/async_client/services/bulk_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from knockapi.core.Service import Service

default_set_id = "default"


class BulkOperations(Service):

async def get(self, bulk_operation_id):
"""
Returns a bulk operation.

Args:
bulk_operation_id (str): The id of the bulk operation

Returns:
dict: A Knock BulkOperation
"""
endpoint = f'/bulk_operations/{bulk_operation_id}'
return await self.client.connection.request('get', endpoint)
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import json
from .service import Service

from knockapi.core.Service import Service


class Messages(Service):
def list(self, options=None):

async def list(self, options=None):
"""
Gets a paginated list of Message records

Expand All @@ -17,60 +20,60 @@ def list(self, options=None):
if options and options['trigger_data']:
options['trigger_data'] = json.dumps(options['trigger_data'])

return self.client.request('get', endpoint, payload=options)
return await self.client.connection.request('get', endpoint, payload=options)

def get(self, id):
async def get(self, message_id):
"""
Get a message by its id

Args:
id: The message ID
message_id: The message ID

Returns:
dict: Message response from Knock.
"""
endpoint = '/messages/{}'.format(id)
return self.client.request('get', endpoint)
endpoint = f'/messages/{message_id}'
return await self.client.connection.request('get', endpoint)

def get_content(self, id):
async def get_content(self, message_id):
"""
Get a message's content by its id

Args:
id: The message ID
message_id: The message ID

Returns:
dict: MessageContent response from Knock.
"""
endpoint = '/messages/{}/content'.format(id)
return self.client.request('get', endpoint)
endpoint = f'/messages/{message_id}/content'
return await self.client.connection.request('get', endpoint)

def get_activities(self, id, options=None):
async def get_activities(self, message_id, options=None):
"""
Get a message's activities by its id

Args:
id: The message ID
message_id: The message ID

Returns:
dict: paginated Activity response from Knock.
"""
endpoint = '/messages/{}/activities'.format(id)
endpoint = f'/messages/{message_id}/activities'

if options and options['trigger_data']:
options['trigger_data'] = json.dumps(options['trigger_data'])

return self.client.request('get', endpoint, options)
return await self.client.connection.request('get', endpoint, options)

def get_events(self, id, options=None):
async def get_events(self, message_id, options=None):
"""
Get a message's events by its id

Args:
id: The message ID
message_id: The message ID

Returns:
dict: paginated Event response from Knock.
"""
endpoint = '/messages/{}/events'.format(id)
return self.client.request('get', endpoint, options)
endpoint = f'/messages/{message_id}/events'
return await self.client.connection.request('get', endpoint, options)
Loading