From d79c98c884f97d3d5e032e7e3cc843b4109e928b Mon Sep 17 00:00:00 2001 From: Philippe Labat Date: Wed, 1 Mar 2023 09:28:01 -0500 Subject: [PATCH 1/6] Introducing AsyncIO Support & Request Timeouts --- .github/workflows/publish.yml | 10 +- .github/workflows/python-app.yml | 4 +- .gitignore | 3 + .idea/.gitignore | 8 + .idea/knock-python.iml | 9 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + MANIFEST.in | 1 + README.md | 17 +- knockapi/__init__.py | 16 +- knockapi/{client.py => async_client/Knock.py} | 87 +++------ knockapi/async_client/__init__.py | 1 + .../services}/__init__.py | 0 .../async_client/services/bulk_operations.py | 19 ++ .../services}/messages.py | 41 +++-- .../services}/objects.py | 169 ++++++++--------- knockapi/async_client/services/preferences.py | 71 +++++++ knockapi/async_client/services/tenants.py | 58 ++++++ .../services}/users.py | 173 ++++++++---------- .../services}/workflows.py | 22 +-- knockapi/core/AsyncConnection.py | 46 +++++ knockapi/core/Connection.py | 43 +++++ .../{resources/service.py => core/Service.py} | 0 knockapi/core/__init__.py | 3 + knockapi/resources/bulk_operations.py | 18 -- knockapi/resources/preferences.py | 84 --------- knockapi/resources/tenants.py | 57 ------ pyproject.toml | 3 + pytest.ini | 2 + setup.py | 42 ----- test.py | 24 +++ tests/test_tenants_class.py | 43 +++-- 33 files changed, 583 insertions(+), 511 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/knock-python.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 MANIFEST.in rename knockapi/{client.py => async_client/Knock.py} (50%) create mode 100644 knockapi/async_client/__init__.py rename knockapi/{resources => async_client/services}/__init__.py (100%) create mode 100644 knockapi/async_client/services/bulk_operations.py rename knockapi/{resources => async_client/services}/messages.py (53%) rename knockapi/{resources => async_client/services}/objects.py (60%) create mode 100644 knockapi/async_client/services/preferences.py create mode 100644 knockapi/async_client/services/tenants.py rename knockapi/{resources => async_client/services}/users.py (60%) rename knockapi/{resources => async_client/services}/workflows.py (75%) create mode 100644 knockapi/core/AsyncConnection.py create mode 100644 knockapi/core/Connection.py rename knockapi/{resources/service.py => core/Service.py} (100%) create mode 100644 knockapi/core/__init__.py delete mode 100644 knockapi/resources/bulk_operations.py delete mode 100644 knockapi/resources/preferences.py delete mode 100644 knockapi/resources/tenants.py create mode 100644 pyproject.toml create mode 100644 pytest.ini delete mode 100644 setup.py create mode 100644 test.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9f93cb3..fa62cd8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 @@ -31,4 +31,4 @@ jobs: - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@master with: - password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 37956f9..b713c11 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -19,10 +19,10 @@ 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 diff --git a/.gitignore b/.gitignore index 6b70983..bb55181 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,6 @@ venv.bak/ Pipfile Pipfile.lock .vscode/launch.json + +# Unasync +knockapi/sync_client diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/knock-python.iml b/.idea/knock-python.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/knock-python.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e0877f8 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e315c4f --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..2d970d2 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include knockapi/sync_client * diff --git a/README.md b/README.md index 45e5955..7391c02 100644 --- a/README.md +++ b/README.md @@ -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. +```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. diff --git a/knockapi/__init__.py b/knockapi/__init__.py index 220cf65..5793456 100644 --- a/knockapi/__init__.py +++ b/knockapi/__init__.py @@ -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__" +] diff --git a/knockapi/client.py b/knockapi/async_client/Knock.py similarity index 50% rename from knockapi/client.py rename to knockapi/async_client/Knock.py index 0f432cd..5318fbb 100644 --- a/knockapi/client.py +++ b/knockapi/async_client/Knock.py @@ -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__ +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): """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. @@ -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 + ) diff --git a/knockapi/async_client/__init__.py b/knockapi/async_client/__init__.py new file mode 100644 index 0000000..ca6d96e --- /dev/null +++ b/knockapi/async_client/__init__.py @@ -0,0 +1 @@ +from .Knock import Knock diff --git a/knockapi/resources/__init__.py b/knockapi/async_client/services/__init__.py similarity index 100% rename from knockapi/resources/__init__.py rename to knockapi/async_client/services/__init__.py diff --git a/knockapi/async_client/services/bulk_operations.py b/knockapi/async_client/services/bulk_operations.py new file mode 100644 index 0000000..81a6604 --- /dev/null +++ b/knockapi/async_client/services/bulk_operations.py @@ -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) diff --git a/knockapi/resources/messages.py b/knockapi/async_client/services/messages.py similarity index 53% rename from knockapi/resources/messages.py rename to knockapi/async_client/services/messages.py index 01fd294..c287d16 100644 --- a/knockapi/resources/messages.py +++ b/knockapi/async_client/services/messages.py @@ -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 @@ -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) diff --git a/knockapi/resources/objects.py b/knockapi/async_client/services/objects.py similarity index 60% rename from knockapi/resources/objects.py rename to knockapi/async_client/services/objects.py index c9bf486..4072d52 100644 --- a/knockapi/resources/objects.py +++ b/knockapi/async_client/services/objects.py @@ -1,41 +1,43 @@ import json -from .service import Service + +from knockapi.core.Service import Service default_set_id = "default" class Objects(Service): - def get(self, collection, id): + + async def get(self, collection, object_id): """ Returns an object in a collection with the id given. Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection Returns: dict: A Knock Object """ - endpoint = '/objects/{}/{}'.format(collection, id) - return self.client.request('get', endpoint) + endpoint = f'/objects/{collection}/{object_id}' + return await self.client.connection.request('get', endpoint) # NOTE: This is `set_object` as `set` is a reserved keyword - def set_object(self, collection, id, data={}): + async def set_object(self, collection, object_id, data={}): """ Returns an object in a collection with the id given. Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection data (dict): The data to set on the object Returns: dict: A Knock Object """ - endpoint = '/objects/{}/{}'.format(collection, id) - return self.client.request('put', endpoint, payload=data) + endpoint = f'/objects/{collection}/{object_id}' + return await self.client.connection.request('put', endpoint, payload=data) - def bulk_set(self, collection, objects): + async def bulk_set(self, collection, objects): """ Bulk sets up to 100 objects in a collection. @@ -47,24 +49,24 @@ def bulk_set(self, collection, objects): dict: BulkOperation from Knock """ data = {'objects': objects} - endpoint = '/objects/{}/bulk/set'.format(collection) - return self.client.request('post', endpoint, payload=data) + endpoint = f'/objects/{collection}/bulk/set' + return await self.client.connection.request('post', endpoint, payload=data) - def delete(self, collection, id): + async def delete(self, collection, object_id): """ Deletes the given object. Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection Returns: None: No response """ - endpoint = '/objects/{}/{}'.format(collection, id) - return self.client.request('delete', endpoint) + endpoint = f'/objects/{collection}/{object_id}' + return await self.client.connection.request('delete', endpoint) - def bulk_delete(self, collection, object_ids): + async def bulk_delete(self, collection, object_ids): """ Bulk deletes up to 100 objects in a collection. @@ -76,112 +78,109 @@ def bulk_delete(self, collection, object_ids): dict: BulkOperation from Knock """ data = {'object_ids': object_ids} - endpoint = '/objects/{}/bulk/delete'.format(collection) - return self.client.request('post', endpoint, payload=data) + endpoint = f'/objects/{collection}/bulk/delete' + return await self.client.connection.request('post', endpoint, payload=data) ## # Channel data ## - def get_channel_data(self, collection, id, channel_id): + async def get_channel_data(self, collection, object_id, channel_id): """ Get object's channel data for the given channel id. Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection channel_id (str): Target channel ID Returns: dict: Channel data from Knock. """ - endpoint = '/objects/{}/{}/channel_data/{}'.format( - collection, id, channel_id) - return self.client.request('get', endpoint) + endpoint = f'/objects/{collection}/{object_id}/channel_data/{channel_id}' + return await self.client.connection.request('get', endpoint) - def set_channel_data(self, collection, id, channel_id, channel_data): + async def set_channel_data(self, collection, object_id, channel_id, channel_data): """ Upserts object's channel data for the given channel id. Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection channel_id (str): Target channel ID channel_data (dict): Channel data Returns: dict: Channel data from Knock. """ - endpoint = '/objects/{}/{}/channel_data/{}'.format( - collection, id, channel_id) - return self.client.request( + endpoint = f'/objects/{collection}/{object_id}/channel_data/{channel_id}' + return await self.client.connection.request( 'put', endpoint, payload={ 'data': channel_data}) - def unset_channel_data(self, collection, id, channel_id): + async def unset_channel_data(self, collection, object_id, channel_id): """ Unsets the object's channel data for the given channel id. Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection channel_id (str): Target channel ID Returns: None: No response """ - endpoint = '/objects/{}/{}/channel_data/{}'.format( - collection, id, channel_id) - return self.client.request('delete', endpoint) + endpoint = f'/objects/{collection}/{object_id}/channel_data/{channel_id}' + return await self.client.connection.request('delete', endpoint) ## # Messages ## - def get_messages(self, collection, id, options=None): + async def get_messages(self, collection, object_id, options=None): """ Get object's messages Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection options (dict): An optional set of filtering options to pass to the query Returns: dict: Paginated Message response. """ - endpoint = '/objects/{}/{}/messages'.format(collection, id) + endpoint = f'/objects/{collection}/{object_id}/messages' 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) ## # Preferences ## - def get_all_preferences(self, collection, id): + async def get_all_preferences(self, collection, object_id): """ Get an objects full set of preferences Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection Returns: dict: PreferenceSet response from Knock. """ - endpoint = '/objects/{}/{}/preferences'.format(collection, id) - return self.client.request('get', endpoint) + endpoint = f'/objects/{collection}/{object_id}/preferences' + return await self.client.connection.request('get', endpoint) - def get_preferences(self, collection, id, options={}): + async def get_preferences(self, collection, object_id, options={}): """ Get a preference set Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection options (dict): preference_set (str): The preference set to retrieve (defaults to "default") @@ -189,15 +188,14 @@ def get_preferences(self, collection, id, options={}): dict: PreferenceSet response from Knock. """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/objects/{}/{}/preferences/{}'.format( - collection, id, preference_set_id) + endpoint = f'/objects/{collection}/{object_id}/preferences/{preference_set_id}' - return self.client.request('get', endpoint) + return await self.client.connection.request('get', endpoint) - def set_preferences( + async def set_preferences( self, collection, - id, + object_id, channel_types=None, categories=None, workflows=None, @@ -207,7 +205,7 @@ def set_preferences( Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection channel_types (dict): A dictionary of channel type preferences categories (dict): A dictionary of category preferences workflows (dict): A dictionary of workflow preferences @@ -218,8 +216,7 @@ def set_preferences( """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/objects/{}/{}/preferences/{}'.format( - collection, id, preference_set_id) + endpoint = f'/objects/{collection}/{object_id}/preferences/{preference_set_id}' params = { 'channel_types': channel_types, @@ -227,16 +224,16 @@ def set_preferences( 'workflows': workflows } - return self.client.request('put', endpoint, payload=params) + return await self.client.connection.request('put', endpoint, payload=params) - def set_channel_types_preferences( - self, collection, id, preferences, options={}): + async def set_channel_types_preferences( + self, collection, object_id, preferences, options={}): """ Sets the channel type preferences Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection preferences (dict): A dictionary of channel type preferences options (dict): A dictionary of options @@ -245,15 +242,14 @@ def set_channel_types_preferences( """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/objects/{}/{}/preferences/{}/channel_types'.format( - collection, id, preference_set_id) + endpoint = f'/objects/{collection}/{object_id}/preferences/{preference_set_id}/channel_types' - return self.client.request('put', endpoint, payload=preferences) + return await self.client.connection.request('put', endpoint, payload=preferences) - def set_channel_type_preferences( + async def set_channel_type_preferences( self, collection, - id, + object_id, channel_type, setting, options={}): @@ -262,7 +258,7 @@ def set_channel_type_preferences( Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection channel_type (str): The channel_type to set setting (boolean): The preference setting options (dict): A dictionary of options @@ -272,17 +268,16 @@ def set_channel_type_preferences( """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/objects/{}/{}/preferences/{}/channel_types/{}'.format( - collection, id, preference_set_id, channel_type) + endpoint = f'/objects/{collection}/{object_id}/preferences/{preference_set_id}/channel_types/{channel_type}' - return self.client.request( + return await self.client.connection.request( 'put', endpoint, payload={ 'subscribed': setting}) - def set_workflows_preferences( + async def set_workflows_preferences( self, collection, - id, + object_id, preferences, options={}): """ @@ -290,7 +285,7 @@ def set_workflows_preferences( Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection preferences (dict): A dictionary of workflow preferences options (dict): A dictionary of options @@ -299,15 +294,14 @@ def set_workflows_preferences( """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/objects/{}/{}/preferences/{}/workflows'.format( - collection, id, preference_set_id) + endpoint = f'/objects/{collection}/{object_id}/preferences/{preference_set_id}/workflows' - return self.client.request('put', endpoint, payload=preferences) + return await self.client.connection.request('put', endpoint, payload=preferences) - def set_workflow_preferences( + async def set_workflow_preferences( self, collection, - id, + object_id, key, setting, options={}): @@ -316,7 +310,7 @@ def set_workflow_preferences( Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection key (str): The workflow key setting (boolean or dict): The preference setting options (dict): A dictionary of options @@ -326,18 +320,17 @@ def set_workflow_preferences( """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/objects/{}/{}/preferences/{}/workflows/{}'.format( - collection, id, preference_set_id, key) + endpoint = f'/objects/{collection}/{object_id}/preferences/{preference_set_id}/workflows/{key}' params = setting if isinstance(setting, dict) else { 'subscribed': setting} - return self.client.request('put', endpoint, payload=params) + return await self.client.connection.request('put', endpoint, payload=params) - def set_categories_preferences( + async def set_categories_preferences( self, collection, - id, + object_id, preferences, options={}): """ @@ -345,7 +338,7 @@ def set_categories_preferences( Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection preferences (dict): A dictionary of category preferences options (dict): A dictionary of options @@ -354,15 +347,14 @@ def set_categories_preferences( """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/objects/{}/{}/preferences/{}/categories'.format( - collection, id, preference_set_id) + endpoint = f'/objects/{collection}/{object_id}/preferences/{preference_set_id}/categories' - return self.client.request('put', endpoint, payload=preferences) + return await self.client.connection.request('put', endpoint, payload=preferences) - def set_category_preferences( + async def set_category_preferences( self, collection, - id, + object_id, key, setting, options={}): @@ -371,7 +363,7 @@ def set_category_preferences( Args: collection (str): The collection the object belongs to - id (str): The id of the object in the collection + object_id (str): The id of the object in the collection key (str): The category key setting (boolean or dict): The preference setting options (dict): A dictionary of options @@ -381,10 +373,9 @@ def set_category_preferences( """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/objects/{}/{}/preferences/{}/categories/{}'.format( - collection, id, preference_set_id, key) + endpoint = f'/objects/{collection}/{object_id}/preferences/{preference_set_id}/categories/{key}' params = setting if isinstance(setting, dict) else { 'subscribed': setting} - return self.client.request('put', endpoint, payload=params) + return await self.client.connection.request('put', endpoint, payload=params) diff --git a/knockapi/async_client/services/preferences.py b/knockapi/async_client/services/preferences.py new file mode 100644 index 0000000..b987749 --- /dev/null +++ b/knockapi/async_client/services/preferences.py @@ -0,0 +1,71 @@ +from knockapi.core.Service import Service +from warnings import warn + + +class Preferences(Service): + + async def get_all(self, user_id): + warn("This method is deprecated. Use users.get_all_preferences instead.", DeprecationWarning, stacklevel=2) + return await self.client.users.get_all_preferences(user_id) + + async def get(self, user_id, options={}): + warn("This method is deprecated. Use users.get_preferences instead.", DeprecationWarning, stacklevel=2) + return await self.client.users.get_preferences(user_id, options) + + async def update(self, user_id, channel_types=None, categories=None, workflows=None, options={}): + warn("This method is deprecated. Use users.set_preferences instead.", DeprecationWarning, stacklevel=2) + return await self.client.users.set_preferences( + user_id, + channel_types=channel_types, + categories=categories, + workflows=workflows, + options=options + ) + + async def set_channel_types(self, user_id, preferences, options={}): + warn( + "This method is deprecated. Use users.set_channel_types_preferences instead.", + DeprecationWarning, + stacklevel=2 + ) + return await self.client.users.set_channel_types_preferences(user_id, preferences, options=options) + + async def set_channel_type(self, user_id, channel_type, setting, options={}): + warn( + "This method is deprecated. Use users.set_channel_type_preferences instead.", + DeprecationWarning, + stacklevel=2 + ) + return await self.client.users.set_channel_types_preferences(user_id, channel_type, setting, options=options) + + async def set_workflows(self, user_id, preferences, options={}): + warn( + "This method is deprecated. Use users.set_workflows_preferences instead.", + DeprecationWarning, + stacklevel=2 + ) + return await self.client.users.set_workflows_preferences(user_id, preferences, options=options) + + async def set_workflow(self, user_id, key, setting, options={}): + warn( + "This method is deprecated. Use users.set_workflow_preferences instead.", + DeprecationWarning, + stacklevel=2 + ) + return await self.client.users.set_workflow_preferences(user_id, key, setting, setting, options=options) + + async def set_categories(self, user_id, preferences, options={}): + warn( + "This method is deprecated. Use users.set_categories_preferences instead.", + DeprecationWarning, + stacklevel=2 + ) + return await self.client.users.set_categories_preferences(user_id, preferences, options=options) + + async def set_category(self, user_id, key, setting, options={}): + warn( + "This method is deprecated. Use users.set_category_preferences instead.", + DeprecationWarning, + stacklevel=2 + ) + return await self.client.users.set_category_preferences(user_id, key, setting, setting, options=options) diff --git a/knockapi/async_client/services/tenants.py b/knockapi/async_client/services/tenants.py new file mode 100644 index 0000000..10789e6 --- /dev/null +++ b/knockapi/async_client/services/tenants.py @@ -0,0 +1,58 @@ +from knockapi.core.Service import Service + + +class Tenants(Service): + + async def list(self): + """ + Returns all tenants in the environment + + Args: + None + + Returns: + dict: A Knock Tenant + """ + endpoint = '/tenants' + return await self.client.connection.request('get', endpoint) + + async def get(self, tenant_id): + """ + Returns a tenant with the id given. + + Args: + tenant_id (str): The id of the tenant + + Returns: + dict: A Knock Tenant + """ + endpoint = f'/tenants/{tenant_id}' + return await self.client.connection.request('get', endpoint) + + # NOTE: This is `set_tenant` as `set` is a reserved keyword + async def set_tenant(self, tenant_id, tenant_data={}): + """ + Returns a tenant with the id given and updated settings. + + Args: + tenant_id (str): The id of the tenant + tenant_data (dict): The data to set on the tenant + + Returns: + dict: A Knock Tenant + """ + endpoint = f'/tenants/{tenant_id}' + return await self.client.connection.request('put', endpoint, payload=tenant_data) + + async def delete(self, tenant_id): + """ + Deletes the given tenant. + + Args: + tenant_id (str): The id of the tenant + + Returns: + None: No response + """ + endpoint = f'/tenants/{tenant_id}' + return await self.client.connection.request('delete', endpoint) diff --git a/knockapi/resources/users.py b/knockapi/async_client/services/users.py similarity index 60% rename from knockapi/resources/users.py rename to knockapi/async_client/services/users.py index 7d381c3..0c70ab3 100644 --- a/knockapi/resources/users.py +++ b/knockapi/async_client/services/users.py @@ -1,44 +1,44 @@ import json -from .service import Service +from knockapi.core.Service import Service from warnings import warn default_set_id = "default" class User(Service): - def get_user(self, id): - warn("This method is deprecated. Use User.get instead.", - DeprecationWarning, stacklevel=2) - return self.get(id) - def get(self, id): + async def get_user(self, user_id): + warn("This method is deprecated. Use User.get instead.", DeprecationWarning, stacklevel=2) + return await self.get(user_id) + + async def get(self, user_id): """ Get a user by their id Args: - id: The user ID + user_id: The user ID Returns: dict: User response from Knock. """ - endpoint = '/users/{}'.format(id) - return self.client.request('get', endpoint) + endpoint = f'/users/{user_id}' + return await self.client.connection.request('get', endpoint) - def identify(self, id, data={}): + async def identify(self, user_id, data={}): """ Identify a user, upserting them Args: - id (str): The user ID + user_id (str): The user ID data (dict): Other properties to put on the user Returns: dict: User response from Knock. """ - endpoint = '/users/{}'.format(id) - return self.client.request('put', endpoint, payload=data) + endpoint = f'/users/{user_id}' + return await self.client.connection.request('put', endpoint, payload=data) - def bulk_identify(self, users): + async def bulk_identify(self, users): """ Bulk identifies up to 100 users @@ -50,22 +50,22 @@ def bulk_identify(self, users): """ endpoint = '/users/bulk/identify' data = {'users': users} - return self.client.request('post', endpoint, payload=data) + return await self.client.connection.request('post', endpoint, payload=data) - def delete(self, id): + async def delete(self, user_id): """ Deletes the given user. Args: - id (str): The user ID + user_id (str): The user ID Returns: None: No response """ - endpoint = '/users/{}'.format(id) - return self.client.request('delete', endpoint) + endpoint = f'/users/{user_id}' + return await self.client.connection.request('delete', endpoint) - def bulk_delete(self, user_ids): + async def bulk_delete(self, user_ids): """ Bulk deletes up to 100 users. @@ -77,9 +77,9 @@ def bulk_delete(self, user_ids): """ endpoint = '/users/bulk/delete' data = {'user_ids': user_ids} - return self.client.request('post', endpoint, payload=data) + return await self.client.connection.request('post', endpoint, payload=data) - def get_feed(self, user_id, channel_id, options=None): + async def get_feed(self, user_id, channel_id, options=None): """ Gets a feed for the given user @@ -91,14 +91,14 @@ def get_feed(self, user_id, channel_id, options=None): Returns dict: A Knock feed response """ - endpoint = '/users/{}/feeds/{}'.format(user_id, channel_id) + endpoint = f'/users/{user_id}/feeds/{channel_id}' 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 merge(self, user_id, from_user_id): + async def merge(self, user_id, from_user_id): """ Merges the user specified with `from_user_id` into the user specified with `user_id`. @@ -109,64 +109,62 @@ def merge(self, user_id, from_user_id): Returns dict: A Knock User response """ - endpoint = '/users/{}/merge'.format(user_id) + endpoint = f'/users/{user_id}/merge' data = {'from_user_id': from_user_id} - return self.client.request('post', endpoint, payload=data) + return await self.client.connection.request('post', endpoint, payload=data) ## # Channel data ## - def get_channel_data(self, id, channel_id): + async def get_channel_data(self, user_id, channel_id): """ Get user's channel data for the given channel id. Args: - id (str): The user ID + user_id (str): The user ID channel_id (str): Target channel ID Returns: dict: Channel data from Knock. """ - endpoint = '/users/{}/channel_data/{}'.format(id, channel_id) - return self.client.request('get', endpoint) + endpoint = f'/users/{user_id}/channel_data/{channel_id}' + return await self.client.connection.request('get', endpoint) - def set_channel_data(self, id, channel_id, channel_data): + async def set_channel_data(self, user_id, channel_id, channel_data): """ Upserts user's channel data for the given channel id. Args: - id (str): The user ID + user_id (str): The user ID channel_id (str): Target channel ID channel_data (dict): Channel data Returns: dict: Channel data from Knock. """ - endpoint = '/users/{}/channel_data/{}'.format(id, channel_id) - return self.client.request( - 'put', endpoint, payload={ - 'data': channel_data}) + endpoint = f'/users/{user_id}/channel_data/{channel_id}' + return await self.client.connection.request('put', endpoint, payload={'data': channel_data}) - def unset_channel_data(self, id, channel_id): + async def unset_channel_data(self, user_id, channel_id): """ Unsets the user's channel data for the given channel id. Args: - id (str): The user ID + user_id (str): The user ID channel_id (str): Target channel ID Returns: None: no response """ - endpoint = '/users/{}/channel_data/{}'.format(id, channel_id) - return self.client.request('delete', endpoint) + endpoint = f'/users/{user_id}/channel_data/{channel_id}' + return await self.client.connection.request('delete', endpoint) ## # Preferences ## - def get_all_preferences(self, user_id): + async def get_all_preferences(self, user_id): """ Get a users full set of preferences @@ -176,10 +174,10 @@ def get_all_preferences(self, user_id): Returns: dict: PreferenceSet response from Knock. """ - endpoint = '/users/{}/preferences'.format(user_id) - return self.client.request('get', endpoint) + endpoint = f'/users/{user_id}/preferences' + return await self.client.connection.request('get', endpoint) - def get_preferences(self, user_id, options={}): + async def get_preferences(self, user_id, options={}): """ Get a users preference set @@ -192,18 +190,11 @@ def get_preferences(self, user_id, options={}): dict: PreferenceSet response from Knock. """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/users/{}/preferences/{}'.format( - user_id, preference_set_id) + endpoint = f'/users/{user_id}/preferences/{preference_set_id}' - return self.client.request('get', endpoint) + return await self.client.connection.request('get', endpoint) - def set_preferences( - self, - user_id, - channel_types=None, - categories=None, - workflows=None, - options={}): + async def set_preferences(self, user_id, channel_types=None, categories=None, workflows=None, options={}): """ Sets the preference set for the user @@ -219,8 +210,7 @@ def set_preferences( """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/users/{}/preferences/{}'.format( - user_id, preference_set_id) + endpoint = f'/users/{user_id}/preferences/{preference_set_id}' params = { 'channel_types': channel_types, @@ -228,9 +218,9 @@ def set_preferences( 'workflows': workflows } - return self.client.request('put', endpoint, payload=params) + return await self.client.connection.request('put', endpoint, payload=params) - def bulk_set_preferences(self, user_ids=[], preferences={}, options={}): + async def bulk_set_preferences(self, user_ids=[], preferences={}, options={}): """ Bulk sets the preference set for the users given @@ -254,9 +244,9 @@ def bulk_set_preferences(self, user_ids=[], preferences={}, options={}): 'user_ids': user_ids, } - return self.client.request('put', endpoint, payload=params) + return await self.client.connection.request('put', endpoint, payload=params) - def set_channel_types_preferences(self, user_id, preferences, options={}): + async def set_channel_types_preferences(self, user_id, preferences, options={}): """ Sets the channel type preferences for the user @@ -270,17 +260,11 @@ def set_channel_types_preferences(self, user_id, preferences, options={}): """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/users/{}/preferences/{}/channel_types'.format( - user_id, preference_set_id) + endpoint = f'/users/{user_id}/preferences/{preference_set_id}/channel_types' - return self.client.request('put', endpoint, payload=preferences) + return await self.client.connection.request('put', endpoint, payload=preferences) - def set_channel_type_preferences( - self, - user_id, - channel_type, - setting, - options={}): + async def set_channel_type_preferences(self, user_id, channel_type, setting, options={}): """ Sets the channel type preference for the user @@ -295,14 +279,11 @@ def set_channel_type_preferences( """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/users/{}/preferences/{}/channel_types/{}'.format( - user_id, preference_set_id, channel_type) + endpoint = f'/users/{user_id}/preferences/{preference_set_id}/channel_types/{channel_type}' - return self.client.request( - 'put', endpoint, payload={ - 'subscribed': setting}) + return await self.client.connection.request('put', endpoint, payload={'subscribed': setting}) - def set_workflows_preferences(self, user_id, preferences, options={}): + async def set_workflows_preferences(self, user_id, preferences, options={}): """ Sets the workflow preferences for the user @@ -316,12 +297,11 @@ def set_workflows_preferences(self, user_id, preferences, options={}): """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/users/{}/preferences/{}/workflows'.format( - user_id, preference_set_id) + endpoint = f'/users/{user_id}/preferences/{preference_set_id}/workflows' - return self.client.request('put', endpoint, payload=preferences) + return await self.client.connection.request('put', endpoint, payload=preferences) - def set_workflow_preferences(self, user_id, key, setting, options={}): + async def set_workflow_preferences(self, user_id, key, setting, options={}): """ Sets the workflow preferences for the user @@ -336,15 +316,13 @@ def set_workflow_preferences(self, user_id, key, setting, options={}): """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/users/{}/preferences/{}/workflows/{}'.format( - user_id, preference_set_id, key) + endpoint = f'/users/{user_id}/preferences/{preference_set_id}/workflows/{key}' - params = setting if isinstance(setting, dict) else { - 'subscribed': setting} + params = setting if isinstance(setting, dict) else {'subscribed': setting} - return self.client.request('put', endpoint, payload=params) + return await self.client.connection.request('put', endpoint, payload=params) - def set_categories_preferences(self, user_id, preferences, options={}): + async def set_categories_preferences(self, user_id, preferences, options={}): """ Sets the categories preferences for the user @@ -358,12 +336,11 @@ def set_categories_preferences(self, user_id, preferences, options={}): """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/users/{}/preferences/{}/categories'.format( - user_id, preference_set_id) + endpoint = f'/users/{user_id}/preferences/{preference_set_id}/categories' - return self.client.request('put', endpoint, payload=preferences) + return await self.client.connection.request('put', endpoint, payload=preferences) - def set_category_preferences(self, user_id, key, setting, options={}): + async def set_category_preferences(self, user_id, key, setting, options={}): """ Sets the category preferences for the user @@ -378,28 +355,26 @@ def set_category_preferences(self, user_id, key, setting, options={}): """ preference_set_id = options.get('preference_set', default_set_id) - endpoint = '/users/{}/preferences/{}/categories/{}'.format( - user_id, preference_set_id, key) + endpoint = f'/users/{user_id}/preferences/{preference_set_id}/categories/{key}' - params = setting if isinstance(setting, dict) else { - 'subscribed': setting} + params = setting if isinstance(setting, dict) else {'subscribed': setting} - return self.client.request('put', endpoint, payload=params) + return await self.client.connection.request('put', endpoint, payload=params) ## # Messages ## - def get_messages(self, id, options=None): + async def get_messages(self, user_id, options=None): """ Get user's messages Args: - id (str): The user ID + user_id (str): The user ID options (dict): An optional set of filtering options to pass to the query Returns: dict: Paginated Message response. """ - endpoint = '/users/{}/messages'.format(id) - return self.client.request('get', endpoint, payload=options) + endpoint = f'/users/{user_id}/messages' + return await self.client.connection.request('get', endpoint, payload=options) diff --git a/knockapi/resources/workflows.py b/knockapi/async_client/services/workflows.py similarity index 75% rename from knockapi/resources/workflows.py rename to knockapi/async_client/services/workflows.py index c352d56..0a64b28 100644 --- a/knockapi/resources/workflows.py +++ b/knockapi/async_client/services/workflows.py @@ -1,15 +1,9 @@ -from .service import Service +from knockapi.core.Service import Service class Workflows(Service): - def trigger( - self, - key, - recipients, - data={}, - actor=None, - cancellation_key=None, - tenant=None): + + async def trigger(self, key, recipients, data={}, actor=None, cancellation_key=None, tenant=None): """ Triggers a workflow. @@ -32,7 +26,7 @@ def trigger( Returns: dict: Response from Knock. """ - endpoint = '/workflows/{}/trigger'.format(key) + endpoint = f'/workflows/{key}/trigger' params = { 'actor': actor, @@ -42,9 +36,9 @@ def trigger( 'tenant': tenant } - return self.client.request("post", endpoint, payload=params) + return await self.client.connection.request("post", endpoint, payload=params) - def cancel(self, key, cancellation_key, recipients=None): + async def cancel(self, key, cancellation_key, recipients=None): """ Cancels an in-flight workflow. @@ -56,11 +50,11 @@ def cancel(self, key, cancellation_key, recipients=None): Returns: dict: Response from Knock. """ - endpoint = '/workflows/{}/cancel'.format(key) + endpoint = f'/workflows/{key}/cancel' params = { 'recipients': recipients, 'cancellation_key': cancellation_key } - return self.client.request("post", endpoint, payload=params) + return await self.client.connection.request("post", endpoint, payload=params) diff --git a/knockapi/core/AsyncConnection.py b/knockapi/core/AsyncConnection.py new file mode 100644 index 0000000..7b54248 --- /dev/null +++ b/knockapi/core/AsyncConnection.py @@ -0,0 +1,46 @@ +import aiohttp + +from json.decoder import JSONDecodeError + + +class AsyncConnection(object): + + def __init__( + self, + api_key: str, + api_host: str, + api_version: str, + read_timeout: int + ): + self.api_key = api_key + self.host = api_host + self.client_version = api_version + self.headers = { + 'Authorization': 'Bearer {}'.format(self.api_key), + 'User-Agent': 'Knock Python - {}'.format(self.client_version) + } + self.session = aiohttp.ClientSession( + raise_for_status=True, + headers=self.headers, + read_timeout=read_timeout + ) + + async def request(self, method, endpoint, payload=None): + async with self.session as client: + url = '{}/v1{}'.format(self.host, endpoint) + + r = await client.request( + method, + url, + params=payload if method == 'get' else None, + json=payload if method != 'get' else None + ) + + async with r: + + # If we got a successful response, then attempt to deserialize as JSON + if r.ok: + try: + return await r.json() + except JSONDecodeError: + return None diff --git a/knockapi/core/Connection.py b/knockapi/core/Connection.py new file mode 100644 index 0000000..c5b5bc6 --- /dev/null +++ b/knockapi/core/Connection.py @@ -0,0 +1,43 @@ +import requests + +from json.decoder import JSONDecodeError + + +class Connection(object): + def __init__( + self, + api_key: str, + api_host: str, + api_version: str, + read_timeout: int + ): + self.api_key = api_key + self.host = api_host + self.client_version = api_version + self.timeout = read_timeout + self.headers = { + 'Authorization': 'Bearer {}'.format(self.api_key), + 'User-Agent': 'Knock Python - {}'.format(self.client_version) + } + self.session = requests.Session() + + def request(self, method, endpoint, payload=None): + url = '{}/v1{}'.format(self.host, endpoint) + + r = self.session.request( + method, + url, + params=payload if method == 'get' else None, + json=payload if method != 'get' else None, + headers=self.headers, + timeout=self.timeout + ) + + # If we got a successful response, then attempt to deserialize as JSON + if r.ok: + try: + return r.json() + except JSONDecodeError: + return None + + return r.raise_for_status() diff --git a/knockapi/resources/service.py b/knockapi/core/Service.py similarity index 100% rename from knockapi/resources/service.py rename to knockapi/core/Service.py diff --git a/knockapi/core/__init__.py b/knockapi/core/__init__.py new file mode 100644 index 0000000..e1aafc1 --- /dev/null +++ b/knockapi/core/__init__.py @@ -0,0 +1,3 @@ +from .Service import Service +from .AsyncConnection import AsyncConnection +from .Connection import Connection diff --git a/knockapi/resources/bulk_operations.py b/knockapi/resources/bulk_operations.py deleted file mode 100644 index a04927f..0000000 --- a/knockapi/resources/bulk_operations.py +++ /dev/null @@ -1,18 +0,0 @@ -from .service import Service - -default_set_id = "default" - - -class BulkOperations(Service): - def get(self, id): - """ - Returns an bulk operation. - - Args: - id (str): The id of the bulk operation - - Returns: - dict: A Knock BulkOperation - """ - endpoint = '/bulk_operations/{}'.format(id) - return self.client.request('get', endpoint) diff --git a/knockapi/resources/preferences.py b/knockapi/resources/preferences.py deleted file mode 100644 index ed91203..0000000 --- a/knockapi/resources/preferences.py +++ /dev/null @@ -1,84 +0,0 @@ -from .service import Service -from warnings import warn - - -class Preferences(Service): - def get_all(self, user_id): - warn( - "This method is deprecated. Use users.get_all_preferences instead.", - DeprecationWarning, - stacklevel=2) - return self.client.users.get_all_preferences(user_id) - - def get(self, user_id, options={}): - warn( - "This method is deprecated. Use users.get_preferences instead.", - DeprecationWarning, - stacklevel=2) - return self.client.users.get_preferences(user_id, options) - - def update( - self, - user_id, - channel_types=None, - categories=None, - workflows=None, - options={}): - warn( - "This method is deprecated. Use users.set_preferences instead.", - DeprecationWarning, - stacklevel=2) - return self.client.users.set_preferences( - user_id, - channel_types=channel_types, - categories=categories, - workflows=workflows, - options=options) - - def set_channel_types(self, user_id, preferences, options={}): - warn( - "This method is deprecated. Use users.set_channel_types_preferences instead.", - DeprecationWarning, - stacklevel=2) - return self.client.users.set_channel_types_preferences( - user_id, preferences, options=options) - - def set_channel_type(self, user_id, channel_type, setting, options={}): - warn( - "This method is deprecated. Use users.set_channel_type_preferences instead.", - DeprecationWarning, - stacklevel=2) - return self.client.users.set_channel_types_preferences( - user_id, channel_type, setting, options=options) - - def set_workflows(self, user_id, preferences, options={}): - warn( - "This method is deprecated. Use users.set_workflows_preferences instead.", - DeprecationWarning, - stacklevel=2) - return self.client.users.set_workflows_preferences( - user_id, preferences, options=options) - - def set_workflow(self, user_id, key, setting, options={}): - warn( - "This method is deprecated. Use users.set_workflow_preferences instead.", - DeprecationWarning, - stacklevel=2) - return self.client.users.set_workflow_preferences( - user_id, key, setting, setting, options=options) - - def set_categories(self, user_id, preferences, options={}): - warn( - "This method is deprecated. Use users.set_categories_preferences instead.", - DeprecationWarning, - stacklevel=2) - return self.client.users.set_categories_preferences( - user_id, preferences, options=options) - - def set_category(self, user_id, key, setting, options={}): - warn( - "This method is deprecated. Use users.set_category_preferences instead.", - DeprecationWarning, - stacklevel=2) - return self.client.users.set_category_preferences( - user_id, key, setting, setting, options=options) diff --git a/knockapi/resources/tenants.py b/knockapi/resources/tenants.py deleted file mode 100644 index 24790a3..0000000 --- a/knockapi/resources/tenants.py +++ /dev/null @@ -1,57 +0,0 @@ -from .service import Service - - -class Tenants(Service): - def list(self): - """ - Returns all tenants in the environment - - Args: - None - - Returns: - dict: A Knock Tenant - """ - endpoint = '/tenants' - return self.client.request('get', endpoint) - - def get(self, id): - """ - Returns a tenant with the id given. - - Args: - id (str): The id of the tenant - - Returns: - dict: A Knock Tenant - """ - endpoint = '/tenants/{}'.format(id) - return self.client.request('get', endpoint) - - # NOTE: This is `set_tenant` as `set` is a reserved keyword - def set_tenant(self, id, tenant_data={}): - """ - Returns a tenant with the id given and updated settings. - - Args: - id (str): The id of the tenant - tenant_data (dict): The data to set on the tenant - - Returns: - dict: A Knock Tenant - """ - endpoint = '/tenants/{}'.format(id) - return self.client.request('put', endpoint, payload=tenant_data) - - def delete(self, id): - """ - Deletes the given tenant. - - Args: - id (str): The id of the tenant - - Returns: - None: No response - """ - endpoint = '/tenants/{}'.format(id) - return self.client.request('delete', endpoint) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..50df01b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=62.3.0", "wheel", "unasync"] +build-backend = "setuptools.build_meta" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/setup.py b/setup.py deleted file mode 100644 index 8b28bfc..0000000 --- a/setup.py +++ /dev/null @@ -1,42 +0,0 @@ -import setuptools - -version = '0.5.2' - -with open("README.md", "r") as f: - long_description = f.read() - -setuptools.setup( - name='knockapi', - version=version, - python_requires='>=2.7.16, <4', - install_requires=[ - 'requests' - ], - extras_require={ - 'dev': [ - 'bump2version', - ] - }, - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Libraries', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - ], - description='Client library for the Knock API', - long_description=long_description, - long_description_content_type='text/markdown', - url='https://github.com/knocklabs/knock-python', - packages=setuptools.find_packages(), - author='Knock Labs, Inc.', - author_email='support@knock.app', - license='MIT' -) diff --git a/test.py b/test.py new file mode 100644 index 0000000..2bac559 --- /dev/null +++ b/test.py @@ -0,0 +1,24 @@ +import asyncio + +from knockapi import AsyncKnock, Knock + + +async def co_test_fn(): + client = AsyncKnock( + api_key="sk_z0fChGyRob55qhWxQkfbJqgdNRz9Bo0ZVLzdkeCz1Os" + ) + + print(await client.messages.list()) + + +def test_fn(): + client = Knock( + api_key="sk_z0fChGyRob55qhWxQkfbJqgdNRz9Bo0ZVLzdkeCz1Os" + ) + + print(client.messages.list()) + + +asyncio.run(co_test_fn()) +test_fn() + diff --git a/tests/test_tenants_class.py b/tests/test_tenants_class.py index 0c06f63..8a00d6b 100644 --- a/tests/test_tenants_class.py +++ b/tests/test_tenants_class.py @@ -1,34 +1,37 @@ -from unittest.mock import Mock +import pytest -from knockapi.resources.tenants import Tenants +from unittest.mock import AsyncMock +from knockapi.async_client.services.tenants import Tenants -# Tests - -def test_list(): - mocked_client = Mock() +@pytest.mark.asyncio +async def test_list(): + mocked_client = AsyncMock() tenants = Tenants(mocked_client) - tenants.list() - mocked_client.request.assert_called_with("get", "/tenants") + await tenants.list() + mocked_client.connection.request.assert_awaited_with("get", "/tenants") -def test_get(): - mocked_client = Mock() +@pytest.mark.asyncio +async def test_get(): + mocked_client = AsyncMock() tenants = Tenants(mocked_client) - tenants.get("tenant-123") - mocked_client.request.assert_called_with("get", "/tenants/tenant-123") + await tenants.get("tenant-123") + mocked_client.connection.request.assert_awaited_with("get", "/tenants/tenant-123") -def test_delete(): - mocked_client = Mock() +@pytest.mark.asyncio +async def test_delete(): + mocked_client = AsyncMock() tenants = Tenants(mocked_client) - tenants.delete("tenant-123") - mocked_client.request.assert_called_with("delete", "/tenants/tenant-123") + await tenants.delete("tenant-123") + mocked_client.connection.request.assert_awaited_with("delete", "/tenants/tenant-123") -def test_set(): - mocked_client = Mock() +@pytest.mark.asyncio +async def test_set(): + mocked_client = AsyncMock() tenants = Tenants(mocked_client) - tenants.set_tenant("tenant-123", {"name": "Tenant 1"}) - mocked_client.request.assert_called_with( + await tenants.set_tenant("tenant-123", {"name": "Tenant 1"}) + mocked_client.connection.request.assert_awaited_with( "put", "/tenants/tenant-123", payload={'name': 'Tenant 1'}) From a3ce309b0396c3311aac371ce356556d20b88113 Mon Sep 17 00:00:00 2001 From: Philippe Labat Date: Wed, 1 Mar 2023 09:33:59 -0500 Subject: [PATCH 2/6] Added pytest-asyncio to the build plan --- .github/workflows/python-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index b713c11..5a097aa 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -26,7 +26,7 @@ jobs: - 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: | From d67f77fe7b125fe038530ba0f5c5919862c6c3ee Mon Sep 17 00:00:00 2001 From: Philippe Labat Date: Wed, 1 Mar 2023 09:39:06 -0500 Subject: [PATCH 3/6] Deleted test file --- test.py | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 test.py diff --git a/test.py b/test.py deleted file mode 100644 index 2bac559..0000000 --- a/test.py +++ /dev/null @@ -1,24 +0,0 @@ -import asyncio - -from knockapi import AsyncKnock, Knock - - -async def co_test_fn(): - client = AsyncKnock( - api_key="sk_z0fChGyRob55qhWxQkfbJqgdNRz9Bo0ZVLzdkeCz1Os" - ) - - print(await client.messages.list()) - - -def test_fn(): - client = Knock( - api_key="sk_z0fChGyRob55qhWxQkfbJqgdNRz9Bo0ZVLzdkeCz1Os" - ) - - print(client.messages.list()) - - -asyncio.run(co_test_fn()) -test_fn() - From a70ba4aaca847804944174ad219273a3c1367d22 Mon Sep 17 00:00:00 2001 From: Philippe Labat Date: Wed, 1 Mar 2023 09:40:18 -0500 Subject: [PATCH 4/6] Oops, included my IDE specs --- .gitignore | 1 + .idea/.gitignore | 8 -------- .idea/knock-python.iml | 9 --------- .idea/misc.xml | 6 ------ .idea/modules.xml | 8 -------- .idea/vcs.xml | 6 ------ 6 files changed, 1 insertion(+), 37 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/knock-python.iml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index bb55181..f004a8b 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ venv.bak/ .mypy_cache/ .DS_Store +.idea # Pipenv Pipfile diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/knock-python.iml b/.idea/knock-python.iml deleted file mode 100644 index d6ebd48..0000000 --- a/.idea/knock-python.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index e0877f8..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index e315c4f..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From 614362556e150f3f613d25092e0ee9df0ee97f69 Mon Sep 17 00:00:00 2001 From: Philippe Labat Date: Wed, 1 Mar 2023 16:59:35 -0500 Subject: [PATCH 5/6] Re-commiting removed setup.py --- setup.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 setup.py diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b324293 --- /dev/null +++ b/setup.py @@ -0,0 +1,53 @@ +import setuptools +import unasync + +version = '0.6.0' + +with open("README.md", "r") as f: + long_description = f.read() + +setuptools.setup( + name='knockapi', + version=version, + python_requires='>=3.6, <4', + install_requires=[ + 'requests', + 'aiohttp', + 'unasync' + ], + extras_require={ + 'dev': [ + 'bump2version', + ] + }, + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Libraries', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + ], + description='Client library for the Knock API', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/knocklabs/knock-python', + packages=setuptools.find_packages(), + author='Knock Labs, Inc.', + author_email='support@knock.app', + license='MIT', + cmdclass={"build_py": unasync.cmdclass_build_py( + rules=[ + unasync.Rule("knockapi/async_client", "knockapi/sync_client", additional_replacements={ + "AsyncConnection": "Connection", + "async_client": "sync_client", + }) + ] + )}, +) From 931874c84c00257f23a6db136f57c15174da4f7a Mon Sep 17 00:00:00 2001 From: Philippe Labat Date: Thu, 2 Mar 2023 06:41:17 -0500 Subject: [PATCH 6/6] Fixed issues with ClientSession not persisting --- knockapi/core/AsyncConnection.py | 35 ++++++++++++++++---------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/knockapi/core/AsyncConnection.py b/knockapi/core/AsyncConnection.py index 7b54248..668ce2d 100644 --- a/knockapi/core/AsyncConnection.py +++ b/knockapi/core/AsyncConnection.py @@ -26,21 +26,20 @@ def __init__( ) async def request(self, method, endpoint, payload=None): - async with self.session as client: - url = '{}/v1{}'.format(self.host, endpoint) - - r = await client.request( - method, - url, - params=payload if method == 'get' else None, - json=payload if method != 'get' else None - ) - - async with r: - - # If we got a successful response, then attempt to deserialize as JSON - if r.ok: - try: - return await r.json() - except JSONDecodeError: - return None + url = '{}/v1{}'.format(self.host, endpoint) + + r = await self.session.request( + method, + url, + params=payload if method == 'get' else None, + json=payload if method != 'get' else None + ) + + async with r: + + # If we got a successful response, then attempt to deserialize as JSON + if r.ok: + try: + return await r.json() + except JSONDecodeError: + return None