diff --git a/.github/workflows/package-upload.yml b/.github/workflows/package-upload.yml new file mode 100644 index 0000000..c012281 --- /dev/null +++ b/.github/workflows/package-upload.yml @@ -0,0 +1,26 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..352fc8e --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,39 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python package + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.7, 3.8, 3.9] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest + python -m pip install . + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/.gitignore b/.gitignore index d1d3644..64e437d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,12 @@ + +# Run artifacts *.pyc +.coverage + +# Local secrets *.env + +# Python package artifacts. +*.egg-info +build/ +dist/ diff --git a/FlowrouteMessagingLib/APIException.py b/FlowrouteMessagingLib/APIException.py deleted file mode 100644 index a83990c..0000000 --- a/FlowrouteMessagingLib/APIException.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- - -""" - FlowrouteMessagingLib.APIException - - Copyright Flowroute, Inc. 2016 -""" - - -class APIException(Exception): - """ - Class that handles HTTP Exceptions when fetching API Endpoints. - - Attributes: - reason (string): The reason (or error message) for the Exception to be - raised. - response_code (int): The HTTP Response Code from the API Request that - caused this exception to be raised. - response_body (string): The body that was retrieved during the API - request. - """ - - def __init__(self, reason, response_code, response_body): - Exception.__init__(self, reason) - self.response_code = response_code - self.response_body = response_body diff --git a/FlowrouteMessagingLib/Configuration.py b/FlowrouteMessagingLib/Configuration.py deleted file mode 100644 index e188840..0000000 --- a/FlowrouteMessagingLib/Configuration.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- - -""" - FlowrouteMessagingLib.Configuration - - Copyright Flowroute, Inc. 2016 -""" - - -class Configuration(object): - # The base Uri for API calls - BASE_URI = "https://api.flowroute.com/v2" diff --git a/FlowrouteMessagingLib/Controllers/__init__.py b/FlowrouteMessagingLib/Controllers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/FlowrouteMessagingLib/Models/__init__.py b/FlowrouteMessagingLib/Models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/FlowrouteMessagingLib/__init__.py b/FlowrouteMessagingLib/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/demo_send.py b/demo_send.py index 5f0c056..12f5307 100644 --- a/demo_send.py +++ b/demo_send.py @@ -16,8 +16,7 @@ import pprint from time import sleep -from FlowrouteMessagingLib.Controllers.APIController import * -from FlowrouteMessagingLib.Models.Message import * +from flowroute import Controller, Message, FlowrouteException # Set up your API credentials # Please replace the variables in Configuration.php with your information. @@ -30,7 +29,7 @@ print("Flowroute, Inc - Demo SMS Python script.\n") # Create the Controller. -controller = APIController(username=username, password=password) +controller = Controller(username=username, password=password) pprint.pprint(controller) # Build your message. @@ -40,7 +39,7 @@ try: response = controller.create_message(message) pprint.pprint(response) -except APIException as e: +except FlowrouteException as e: print("Send Error - " + str(e.response_code) + '\n') pprint.pprint(e.response_body['errors']) exit(1) # can't continue from here @@ -48,12 +47,16 @@ # Get the MDR id from the response. mdr_id = response['data']['id'] +# Wait for message to register. +# Five seconds should be enough. +sleep(5) + # Retrieve the MDR record. try: # Example MDR: 'mdr1-b334f89df8de4f8fa7ce377e06090a2e' mdr_record = controller.get_message_lookup(mdr_id) pprint.pprint(mdr_record) -except APIException as e: +except FlowrouteException as e: print("Get Error - " + str(e.response_code) + '\n') pprint.pprint(e.response_body['errors']) exit(2) diff --git a/flowroute/__init__.py b/flowroute/__init__.py new file mode 100644 index 0000000..0af9cea --- /dev/null +++ b/flowroute/__init__.py @@ -0,0 +1,4 @@ + +from .controller import Controller +from .exception import FlowrouteException +from .message import Message diff --git a/flowroute/configuration.py b/flowroute/configuration.py new file mode 100644 index 0000000..0990324 --- /dev/null +++ b/flowroute/configuration.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- + +"""FlowrouteMessagingLib.Configuration + +Copyright Flowroute, Inc. 2016 +""" + + +class Configuration(): + # The base Uri for API calls + BASE_URI = "https://api.flowroute.com/v2" diff --git a/FlowrouteMessagingLib/Controllers/APIController.py b/flowroute/controller.py similarity index 70% rename from FlowrouteMessagingLib/Controllers/APIController.py rename to flowroute/controller.py index 643bbb1..2626d39 100644 --- a/FlowrouteMessagingLib/Controllers/APIController.py +++ b/flowroute/controller.py @@ -1,30 +1,29 @@ # -*- coding: utf-8 -*- -""" - FlowrouteMessagingLib.Controllers.APIController +"""flowroute.controller - Copyright Flowroute, Inc. 2016 +Copyright Flowroute, Inc. 2016 """ import requests -from FlowrouteMessagingLib.APIHelper import APIHelper -from FlowrouteMessagingLib.Configuration import Configuration -from FlowrouteMessagingLib.APIException import APIException +from flowroute.helper import Helper +from flowroute.configuration import Configuration +from flowroute.exception import FlowrouteException +from flowroute.message import Message -class APIController(object): - """ - A Controller to access Endpoints in the FlowrouteMessagingLib API. +class Controller(): + """Controller to access Endpoints in the Flowroute API. Args: - username (str): Username for authentication - password (str): password for authentication + username (str): Username for authentication. + password (str): Password for authentication. """ def __init__(self, username, password): self.__username = username self.__password = password - def create_message(self, message) -> dict: + def send_message(self, message: Message) -> dict: """Does a POST request to /messages. Send a message. @@ -49,7 +48,7 @@ def create_message(self, message) -> dict: query_builder += "/messages" # Validate and preprocess url - query_url = APIHelper.clean_url(query_builder) + query_url = Helper.clean_url(query_builder) # Prepare headers headers = { @@ -61,19 +60,19 @@ def create_message(self, message) -> dict: response = requests.post( url=query_url, headers=headers, - data=APIHelper.json_serialize(message), + data=Helper.json_serialize(message), auth=(self.__username, self.__password)) - json_content = APIHelper.json_deserialize(response.content) + json_content = Helper.json_deserialize(response.content) # Error handling using HTTP status codes if response.status_code == 401: - raise APIException("UNAUTHORIZED", 401, json_content) + raise FlowrouteException("UNAUTHORIZED", 401, json_content) elif response.status_code == 403: - raise APIException("FORBIDDEN", 403, json_content) + raise FlowrouteException("FORBIDDEN", 403, json_content) elif response.status_code < 200 or response.status_code > 206: # 200 = HTTP OK - raise APIException("HTTP Response Not OK", response.status_code, + raise FlowrouteException("HTTP Response Not OK", response.status_code, json_content) return json_content @@ -104,26 +103,28 @@ def get_message_lookup(self, record_id: str) -> dict: query_builder += "/messages/{record_id}" # Process optional template parameters - query_builder = APIHelper.append_url_with_template_parameters( + query_builder = Helper.append_url_with_template_parameters( query_builder, { "record_id": record_id, }) # Validate and preprocess url - query_url = APIHelper.clean_url(query_builder) + query_url = Helper.clean_url(query_builder) # Prepare headers - headers = {"user-agent": "Flowroute Messaging SDK 1.0", } + headers = { + "user-agent": "Flowroute Messaging SDK 1.0", + } # Prepare and invoke the API call request to fetch the response response = requests.get( url=query_url, auth=(self.__username, self.__password)) - json_content = APIHelper.json_deserialize(response.content) + json_content = Helper.json_deserialize(response.content) # Error handling using HTTP status codes if response.status_code < 200 or response.status_code > 206: # 200 = HTTP OK - raise APIException("HTTP Response Not OK", response.status_code, + raise FlowrouteException("HTTP Response Not OK", response.status_code, json_content) return json_content diff --git a/flowroute/exception.py b/flowroute/exception.py new file mode 100644 index 0000000..eb5b6e8 --- /dev/null +++ b/flowroute/exception.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +"""FlowrouteMessagingLib.APIException + +Copyright Flowroute, Inc. 2016 +""" + + +class FlowrouteException(Exception): + """Class that handles HTTP Exceptions when fetching API Endpoints. + + Attributes: + reason (str): + The reason (or error message) for the Exception + to be raised. + response_code (int): + The HTTP Response Code from the API Request that + caused this exception to be raised. + response_body (str): + The body that was retrieved during the API request. + """ + + def __init__(self, reason: str, response_code: int, response_body: str): + Exception.__init__(self, reason) + self.response_code = response_code + self.response_body = response_body diff --git a/FlowrouteMessagingLib/APIHelper.py b/flowroute/helper.py similarity index 84% rename from FlowrouteMessagingLib/APIHelper.py rename to flowroute/helper.py index dd49686..6b8270f 100644 --- a/FlowrouteMessagingLib/APIHelper.py +++ b/flowroute/helper.py @@ -1,16 +1,15 @@ # -*- coding: utf-8 -*- -""" - FlowrouteMessagingLib.APIHelper +"""Flowroute.Helper - Copyright Flowroute, Inc. 2016 +Copyright Flowroute, Inc. 2016 """ + import jsonpickle import re -class APIHelper: - """ - A Helper Class for various functions associated with API Calls. +class Helper: + """A Helper Class for various functions associated with API Calls. This class contains static methods for operations that need to be performed during API requests. All of the methods inside this class are @@ -19,12 +18,11 @@ class APIHelper: """ @classmethod - def json_serialize(cls, obj): - """ - JSON Serialization of a given object. + def json_serialize(cls, obj) -> str: + """JSON Serialization of a given object. Args: - obj (object): The object to serialise. + obj (object): The object to serialize. Returns: str: The JSON serialized string of the object. @@ -54,9 +52,8 @@ def json_serialize(cls, obj): return jsonpickle.encode(obj, False) @classmethod - def json_deserialize(cls, json): - """ - JSON Deerialization of a given string. + def json_deserialize(cls, json: str) -> dict: + """JSON deserialization of a given string. Args: json (str): The JSON serialized string to deserialize. @@ -72,7 +69,7 @@ def json_deserialize(cls, json): return jsonpickle.decode(json) @classmethod - def append_url_with_template_parameters(cls, url, parameters): + def append_url_with_template_parameters(cls, url: str, parameters: dict) -> str: """ Replaces template parameters in the given url. @@ -108,7 +105,7 @@ def append_url_with_template_parameters(cls, url, parameters): return url @classmethod - def append_url_with_query_parameters(cls, url, parameters): + def append_url_with_query_parameters(cls, url: str, parameters: dict) -> str: """ Appends the given set of parameters to the given query string. @@ -153,7 +150,7 @@ def append_url_with_query_parameters(cls, url, parameters): return url @classmethod - def clean_url(cls, url): + def clean_url(cls, url: str) -> str: """ Validates and processes the given query Url to clean empty slashes. @@ -178,9 +175,8 @@ def clean_url(cls, url): return protocol + query_url @classmethod - def form_encode(cls, obj, instanceName): - """ - Encodes a model in a form-encoded manner such as person[Name] + def form_encode(cls, obj, instanceName: str) -> dict: + """Encodes a model in a form-encoded manner such as person[Name]. Args: obj (object): The given Object to form encode. @@ -192,7 +188,7 @@ def form_encode(cls, obj, instanceName): """ # Resolve the names first - value = APIHelper.resolve_name(obj) + value = Helper.resolve_name(obj) retval = dict() if value is None: @@ -204,13 +200,13 @@ def form_encode(cls, obj, instanceName): # Loop through each item in the list and add it by number i = 0 for entry in value[item]: - retval.update(APIHelper.form_encode( + retval.update(Helper.form_encode( entry, instanceName + "[" + item + "][" + str( i) + "]")) i += 1 elif isinstance(value[item], dict): # Loop through each item in the dictionary and add it - retval.update(APIHelper.form_encode(value[item], instanceName + + retval.update(Helper.form_encode(value[item], instanceName + "[" + item + "]")) else: # Add the current item @@ -219,9 +215,8 @@ def form_encode(cls, obj, instanceName): return retval @classmethod - def resolve_names(cls, obj, names, retval): - """ - Resolves parameters from their Model names to their API names. + def resolve_names(cls, obj, names: dict, retval: dict) -> dict: + """Resolve parameters from their Model names to their API names. Args: obj (object): The given Object to resolve names for. @@ -243,23 +238,22 @@ def resolve_names(cls, obj, names, retval): # Loop through each item retval[names[name]] = list() for item in value: - retval[names[name]].append(APIHelper.resolve_name(item)) + retval[names[name]].append(Helper.resolve_name(item)) elif isinstance(value, dict): # Loop through each item retval[names[name]] = dict() for key in value: - retval[names[name]][key] = APIHelper.resolve_name(value[ + retval[names[name]][key] = Helper.resolve_name(value[ key]) else: - retval[names[name]] = APIHelper.resolve_name(value) + retval[names[name]] = Helper.resolve_name(value) # Return the result return retval @classmethod def resolve_name(cls, value): - """ - Resolves name for a given object + """Resolves name for a given object. If the object needs to be recursively resolved, this method will perform that recursive call. diff --git a/FlowrouteMessagingLib/Models/Message.py b/flowroute/message.py similarity index 65% rename from FlowrouteMessagingLib/Models/Message.py rename to flowroute/message.py index 94a677f..69b9cba 100644 --- a/FlowrouteMessagingLib/Models/Message.py +++ b/flowroute/message.py @@ -1,25 +1,24 @@ # -*- coding: utf-8 -*- -""" - FlowrouteMessagingLib.Models.Message +"""flowroute.Message - This file was automatically generated for flowroute - by APIMATIC BETA v2.0 on 02/08/2016 +This file was automatically generated for flowroute +by APIMATIC BETA v2.0 on 02/08/2016 """ -from FlowrouteMessagingLib.APIHelper import APIHelper +from flowroute.helper import Helper -class Message(object): - """ - Implementation of the 'Message' model. + +class Message(): + """Implementation of the 'Message' model. A simple message. Attributes: - to (string): Phone number in E.164 format to send a message to. - mfrom (string): Phone number in E.164 format where the message is sent + to (str): Phone number in E.164 format to send a message to. + mfrom (str): Phone number in E.164 format where the message is sent from. - content (string): The content of the message. + content (str): The content of the message. """ @@ -43,9 +42,8 @@ def __init__(self, **kwargs): if key in replace_names: setattr(self, replace_names[key], kwargs[key]) - def resolve_names(self): - """ - Creates a dictionary representation of this object. + def resolve_names(self) -> dict: + """Create a dictionary representation of this object. This method converts an object to a dictionary that represents the format that the model should be in when passed into an API Request. @@ -65,4 +63,4 @@ def resolve_names(self): retval = dict() - return APIHelper.resolve_names(self, replace_names, retval) + return Helper.resolve_names(self, replace_names, retval) diff --git a/flowroute/test/test_apicontroller.py b/flowroute/test/test_apicontroller.py new file mode 100644 index 0000000..87e7881 --- /dev/null +++ b/flowroute/test/test_apicontroller.py @@ -0,0 +1,51 @@ + +import os +import unittest +from unittest import mock + +from flowroute import Controller +from flowroute import Message +from flowroute import FlowrouteException + + +class TestController(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.access = os.environ['ACCESS_KEY'] + cls.secret = os.environ['SECRET_KEY'] + cls.to_number = os.environ['TO_E164'] + cls.from_number = os.environ['FROM_E164'] + + cls.controller = Controller(cls.access, cls.secret) + + cls.message_content = 'Flowroute test message...' + cls.message = Message( + to=cls.to_number, from_=cls.from_number, + content=cls.message_content) + + return super().setUpClass() + + def test_send_message_bad_to_number(self): + bad_number_msg = Message( + to='+5555555555', from_=self.from_number, + content=self.message_content) + with self.assertRaises(FlowrouteException) as flow_exception: + self.controller.send_message(bad_number_msg) + self.assertEqual(flow_exception.exception.response_code, 422) + + def test_send_message_bad_from_number(self): + bad_number_msg = Message( + to=self.to_number, from_="+5555555555", + content=self.message_content) + with self.assertRaises(FlowrouteException) as flow_exception: + self.controller.send_message(bad_number_msg) + self.assertEqual(flow_exception.exception.response_code, 403) + + def test_send_message_bad_auth(self): + self.controller = Controller(self.access, "bad_pass") + with self.assertRaises(FlowrouteException) as flow_exception: + self.controller.send_message(self.message) + self.assertEqual(flow_exception.exception.response_code, 401) + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/flowroute/test/test_helper.py b/flowroute/test/test_helper.py new file mode 100644 index 0000000..191dde4 --- /dev/null +++ b/flowroute/test/test_helper.py @@ -0,0 +1,30 @@ + +import os +import unittest +from unittest import mock + +from flowroute.helper import Helper +from flowroute import FlowrouteException + + +class TestJSONSerialize(unittest.TestCase): + def test_no_message(self): + self.assertIsNone(Helper.json_serialize(None)) + + def test_list_message(self): + actual_str = Helper.json_serialize([None]) + expected_str = '[null]' + self.assertEqual(expected_str, actual_str) + + def test_str_message(self): + actual_str = Helper.json_serialize({"key": "value"}) + expected_str = '{"key": "value"}' + self.assertEqual(expected_str, actual_str) + + +class TestJSONDeserialize(unittest.TestCase): + def test_no_message(self): + self.assertIsNone(Helper.json_deserialize(None)) + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..270127c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta:__legacy__" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b0cf2b9..0292e04 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -requests==1.1.6 +requests==2.25.1 jsonpickle==0.7.1 diff --git a/setup.py b/setup.py index 00d5ec0..a4af05c 100644 --- a/setup.py +++ b/setup.py @@ -2,13 +2,13 @@ setup( - name='flowroute-messaging-python', - version='0.0.0', + name='flowroute-messaging-fossum', + version='0.2.0', license='MIT', - description='Flowroute\'s Messaging API', - author='Flowroute Developers', - author_email='developer@flowroute.com', - url='https://github.com/flowroute/flowroute-messaging-python', + description='Flowroute\'s Messaging API (Fossum edition).', + author='Eric Fossum', + author_email='fossum.eric@gmail.com', + url='https://github.com/fossum/flowroute-messaging-python', packages=find_packages('.'), zip_safe=False, classifiers=[ @@ -19,6 +19,8 @@ 'Operating System :: POSIX', 'Operating System :: Microsoft :: Windows', 'Programming Language :: Python', + 'Programming Language :: Python :: 3 :: Only', + 'Topic :: Communications :: Telephony' ], keywords=[ 'messaging', 'sms', 'telephony', 'sip', 'api'