pax_global_header00006660000000000000000000000064144542636510014524gustar00rootroot0000000000000052 comment=4aa1ff96bd1eb289b1024b550a058633498166f8 andrewsayre-pysmartapp-4aa1ff9/000077500000000000000000000000001445426365100167435ustar00rootroot00000000000000andrewsayre-pysmartapp-4aa1ff9/.coveragerc000066400000000000000000000000311445426365100210560ustar00rootroot00000000000000[run] source = pysmartappandrewsayre-pysmartapp-4aa1ff9/.github/000077500000000000000000000000001445426365100203035ustar00rootroot00000000000000andrewsayre-pysmartapp-4aa1ff9/.github/workflows/000077500000000000000000000000001445426365100223405ustar00rootroot00000000000000andrewsayre-pysmartapp-4aa1ff9/.github/workflows/ci.yaml000066400000000000000000000051201445426365100236150ustar00rootroot00000000000000name: CI on: push: branches: - master pull_request: ~ jobs: lint: name: "Check style and lint" runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.11" - uses: actions/cache@v3 with: path: ${{ env.pythonLocation }} key: ${{ env.pythonLocation }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('test-requirements.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt -r test-requirements.txt --upgrade --upgrade-strategy eager - name: Check isort run: isort tests pysmartapp --check-only - name: Check pylint run: pylint tests pysmartapp - name: Check flake8 run: flake8 tests pysmartapp --doctests tests: name: "Run tests on ${{ matrix.python-version }}" runs-on: ubuntu-latest needs: lint strategy: matrix: python-version: ["3.10", "3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - uses: actions/cache@v3 with: path: ${{ env.pythonLocation }} key: ${{ env.pythonLocation }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('test-requirements.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt -r test-requirements.txt --upgrade --upgrade-strategy eager - name: Run pytest on ${{ matrix.python-version }} run: pytest coverage: name: "Check code coverage" runs-on: ubuntu-latest needs: lint steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: 3.9 - uses: actions/cache@v3 with: path: ${{ env.pythonLocation }} key: ${{ env.pythonLocation }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('test-requirements.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt -r test-requirements.txt --upgrade --upgrade-strategy eager - name: Run pytest on ${{ matrix.python-version }} run: pytest --cov=./ --cov-report=xml - name: "Upload coverage to Codecov" uses: codecov/codecov-action@v3 with: fail_ci_if_error: true andrewsayre-pysmartapp-4aa1ff9/.github/workflows/publish.yaml000066400000000000000000000020461445426365100246740ustar00rootroot00000000000000name: Publish to Pypi on: workflow_dispatch: release: types: ["published"] jobs: lint: name: "Package and Publish" runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.11" - uses: actions/cache@v3 with: path: ${{ env.pythonLocation }} key: ${{ env.pythonLocation }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('test-requirements.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt -r test-requirements.txt --upgrade --upgrade-strategy eager pip install setuptools wheel twine --upgrade --upgrade-strategy eager - name: Build run: | python setup.py sdist bdist_wheel - name: Deploy run: | python3 -m twine upload dist/* env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} andrewsayre-pysmartapp-4aa1ff9/.gitignore000066400000000000000000000022711445426365100207350ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ demo/andrewsayre-pysmartapp-4aa1ff9/.isort.cfg000066400000000000000000000011111445426365100206340ustar00rootroot00000000000000[isort] # https://github.com/timothycrosley/isort # https://github.com/timothycrosley/isort/wiki/isort-Settings # splits long import on multiple lines indented by 4 spaces multi_line_output = 4 indent = " " # by default isort don't check module indexes not_skip = __init__.py # will group `import x` and `from x import` of the same module. force_sort_within_sections = true sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER default_section = THIRDPARTY known_first_party = pysmartapp,tests forced_separate = tests combine_as_imports = true use_parentheses = trueandrewsayre-pysmartapp-4aa1ff9/.vscode/000077500000000000000000000000001445426365100203045ustar00rootroot00000000000000andrewsayre-pysmartapp-4aa1ff9/.vscode/settings.json000066400000000000000000000000541445426365100230360ustar00rootroot00000000000000{ "python.linting.pylintEnabled": true }andrewsayre-pysmartapp-4aa1ff9/LICENSE000066400000000000000000000020551445426365100177520ustar00rootroot00000000000000MIT License Copyright (c) 2018 Andrew Sayre Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. andrewsayre-pysmartapp-4aa1ff9/README.md000066400000000000000000000015341445426365100202250ustar00rootroot00000000000000# pysmartapp [![CI Status](https://github.com/andrewsayre/pysmartapp/workflows/CI/badge.svg)](https://github.com/andrewsayre/pysmartapp/actions) [![codecov](https://codecov.io/gh/andrewsayre/pysmartapp/branch/master/graph/badge.svg?token=VKPQ25JRAY)](https://codecov.io/gh/andrewsayre/pysmartapp) [![image](https://img.shields.io/pypi/v/pysmartapp.svg)](https://pypi.org/project/pysmartapp/) [![image](https://img.shields.io/pypi/pyversions/pysmartapp.svg)](https://pypi.org/project/pysmartapp/) [![image](https://img.shields.io/pypi/l/pysmartapp.svg)](https://pypi.org/project/pysmartapp/) A python implementation of the WebHook-based [SmartThings SmartApp](https://smartthings.developer.samsung.com/develop/guides/smartapps/basics.html) that uses asyncio and the dispatcher pattern to notify callbacks (coroutines or functions) of SmartApp lifecycle events.andrewsayre-pysmartapp-4aa1ff9/codecov.yml000066400000000000000000000002011445426365100211010ustar00rootroot00000000000000codecov: branch: dev coverage: status: project: default: target: 90 threshold: 0.09 comment: false andrewsayre-pysmartapp-4aa1ff9/lint.cmd000066400000000000000000000003041445426365100203730ustar00rootroot00000000000000@echo off pip install isort --quiet isort tests demo pysmartapp --recursive pip install -r test-requirements.txt --quiet pylint tests pysmartapp flake8 tests pysmartapp pydocstyle tests pysmartappandrewsayre-pysmartapp-4aa1ff9/pylintrc000066400000000000000000000023531445426365100205350ustar00rootroot00000000000000[MESSAGES CONTROL] # Reasons disabled: # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # abstract-class-little-used - prevents from setting right foundation # unused-argument - generic callbacks and setup methods create a lot of warnings # global-statement - used for the on-demand requirement installation # redefined-variable-type - this is Python, we're duck typing! # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # not-an-iterable - https://github.com/PyCQA/pylint/issues/2311 # unnecessary-pass - readability for functions which only contain pass disable= abstract-method, cyclic-import, duplicate-code, global-statement, inconsistent-return-statements, locally-disabled, not-an-iterable, not-context-manager, too-few-public-methods, too-many-arguments, too-many-branches, too-many-instance-attributes, too-many-lines, too-many-locals, too-many-public-methods, too-many-return-statements, too-many-statements, unnecessary-pass, unused-argument [REPORTS] reports=noandrewsayre-pysmartapp-4aa1ff9/pysmartapp/000077500000000000000000000000001445426365100211435ustar00rootroot00000000000000andrewsayre-pysmartapp-4aa1ff9/pysmartapp/__init__.py000066400000000000000000000024611445426365100232570ustar00rootroot00000000000000"""Define the pysmartapp package.""" from pysmartapp.config import ( ConfigInitResponse, ConfigPageResponse, ConfigRequest) from pysmartapp.const import __title__, __version__ # noqa from pysmartapp.dispatch import Dispatcher from pysmartapp.errors import ( SignatureVerificationError, SmartAppNotRegisteredError) from pysmartapp.event import Event, EventRequest from pysmartapp.install import InstallRequest from pysmartapp.oauthcallback import OAuthCallbackRequest from pysmartapp.ping import PingRequest, PingResponse from pysmartapp.request import EmptyDataResponse, Request, Response from pysmartapp.smartapp import SmartApp, SmartAppManager from pysmartapp.uninstall import UninstallRequest from pysmartapp.update import UpdateRequest __all__ = [ # config 'ConfigInitResponse', 'ConfigPageResponse', 'ConfigRequest', # dispatch 'Dispatcher', # errors 'SignatureVerificationError', 'SmartAppNotRegisteredError', # event 'Event', 'EventRequest', # install 'InstallRequest', # oauthcallback 'OAuthCallbackRequest', # ping 'PingRequest', 'PingResponse', # request 'EmptyDataResponse', 'Request', 'Response', # smartapp 'SmartApp', 'SmartAppManager', # unisntall 'UninstallRequest', 'UpdateRequest' ] andrewsayre-pysmartapp-4aa1ff9/pysmartapp/config.py000066400000000000000000000143041445426365100227640ustar00rootroot00000000000000"""Define the configuration module.""" from typing import List from .const import LIFECYCLE_CONFIG_INIT, LIFECYCLE_CONFIG_PAGE from .request import Request, Response class ConfigRequest(Request): """Defines a ConfigRequest.""" def __init__(self, data: dict): """Create a new instance of the ConfigRequest.""" super().__init__(data) config = self._config_data_raw = data['configurationData'] self._installed_app_id = config['installedAppId'] self._phase = config['phase'] self._page_id = config['pageId'] self._previous_page_id = config['previousPageId'] async def _process(self, app) -> Response: if self._phase == LIFECYCLE_CONFIG_INIT: resp = self._process_init(app) elif self._phase == LIFECYCLE_CONFIG_PAGE: resp = self._process_page() else: raise ValueError("Invalid request configuration phase.") return resp @staticmethod def _process_init(app) -> Response: # This is a hard-coded primitive response. resp = ConfigInitResponse() resp.name = app.name resp.config_app_id = app.config_app_id resp.description = app.description resp.permissions.extend(app.permissions) resp.first_page_id = '1' return resp @staticmethod def _process_page() -> Response: # This is a hard-coded primitive response. resp = ConfigPageResponse() resp.page_id = '1' resp.name = 'Configuration' resp.complete = True resp.next_page_id = None resp.previous_page_id = None return resp @property def config_data_raw(self) -> dict: """Get the raw configuration data.""" return self._config_data_raw @property def phase(self) -> str: """Get the current phase.""" return self._phase @property def page_id(self) -> str: """Set the current page id.""" return self._page_id @property def previous_page_id(self) -> str: """Get the previous page id.""" return self._previous_page_id class ConfigInitResponse(Response): """Define a configuration init response.""" def __init__(self): """Create a new instance of the ConfigInitResponse.""" self._name = None self._description = None self._config_app_id = None self._permissions = [] self._first_page_id = "1" def to_data(self) -> dict: """Return a data structure representing the response.""" return { "configurationData": { "initialize": { "name": self.name, "description": self.description, "id": self.config_app_id, "permissions": self.permissions, "firstPageId": self.first_page_id } } } @property def name(self) -> str: """Get the name of the smartapp.""" return self._name @name.setter def name(self, value: str): """Set the name of the smartapp.""" self._name = value @property def description(self) -> str: """Get the description of the smartapp.""" return self._description @description.setter def description(self, value: str): """Set the value of the smartapp.""" self._description = value @property def config_app_id(self) -> str: """Get the id of the smartapp to use in config.""" return self._config_app_id @config_app_id.setter def config_app_id(self, value: str): """Set the id of the smartapp to use in config.""" self._config_app_id = value @property def permissions(self) -> List[str]: """Get the permissions the app requires.""" return self._permissions @property def first_page_id(self) -> str: """Get the id of the first page.""" return self._first_page_id @first_page_id.setter def first_page_id(self, value: str): """Set the id of the first page.""" self._first_page_id = value class ConfigPageResponse(Response): """Define a configuration page response.""" def __init__(self): """Create a new instance of the ConfigPageResponse.""" self._page_id = None self._name = None self._next_page_id = None self._previous_page_id = None self._complete = False def to_data(self) -> dict: """Return a data structure representing the response.""" return { "configurationData": { "page": { "pageId": self.page_id, "name": self.name, "nextPageId": self.next_page_id, "previousPageId": self.previous_page_id, "complete": self.complete, "sections": [] } } } @property def page_id(self) -> str: """Get the id of the page.""" return self._page_id @page_id.setter def page_id(self, value: str): """Set the id of the page.""" self._page_id = value @property def name(self) -> str: """Get the name of the configuration page.""" return self._name @name.setter def name(self, value: str): """Set the name of the configuration page.""" self._name = value @property def next_page_id(self) -> str: """Get the id of the next page.""" return self._next_page_id @next_page_id.setter def next_page_id(self, value: str): """Set the id of the next page.""" self._next_page_id = value @property def previous_page_id(self) -> str: """Get the id of the previous page.""" return self._previous_page_id @previous_page_id.setter def previous_page_id(self, value: str): """Set the id of the previous page.""" self._previous_page_id = value @property def complete(self) -> bool: """Get whether this is the last config page.""" return self._complete @complete.setter def complete(self, value: bool): """Set whether this is the last config page.""" self._complete = value andrewsayre-pysmartapp-4aa1ff9/pysmartapp/const.py000066400000000000000000000007351445426365100226500ustar00rootroot00000000000000"""Define constants used in the SmartApp.""" __title__ = "pysmartapp" __version__ = "0.3.5" LIFECYCLE_PING = 'PING' LIFECYCLE_CONFIG = 'CONFIGURATION' LIFECYCLE_CONFIG_INIT = 'INITIALIZE' LIFECYCLE_CONFIG_PAGE = 'PAGE' LIFECYCLE_INSTALL = 'INSTALL' LIFECYCLE_UPDATE = 'UPDATE' LIFECYCLE_EVENT = 'EVENT' LIFECYCLE_OAUTH_CALLBACK = 'OAUTH_CALLBACK' LIFECYCLE_UNINSTALL = 'UNINSTALL' EVENT_TYPE_DEVICE = 'DEVICE_EVENT' EVENT_TYPE_TIMER = 'TIMER_EVENT' SETTINGS_APP_ID = 'appId' andrewsayre-pysmartapp-4aa1ff9/pysmartapp/dispatch.py000066400000000000000000000062541445426365100233230ustar00rootroot00000000000000"""Defines the dispatch component for notifying others of signals.""" import asyncio from collections import defaultdict import functools from typing import Any, Callable, Dict, List, Sequence TargetType = Callable[..., Any] DisconnectType = Callable[[], None] ConnectType = Callable[[str, TargetType], DisconnectType] SendType = Callable[..., Sequence[asyncio.Future]] class Dispatcher: """Define the dispatch class.""" def __init__(self, *, connect: ConnectType = None, send: SendType = None, signal_prefix: str = '', loop=None): """Create a new instance of the dispatch component.""" self._signal_prefix = signal_prefix self._signals = defaultdict(list) self._loop = loop or asyncio.get_event_loop() self._connect = connect or self._default_connect self._send = send or self._default_send self._last_sent = [] self._disconnects = [] def connect(self, signal: str, target: TargetType) \ -> DisconnectType: """Connect function to signal. Must be ran in the event loop.""" disconnect = self._connect(self._signal_prefix + signal, target) self._disconnects.append(disconnect) return disconnect def send(self, signal: str, *args: Any) -> Sequence[asyncio.Future]: """Fire a signal. Must be ran in the event loop.""" sent = self._last_sent = self._send( self._signal_prefix + signal, *args) return sent def disconnect_all(self): """Disconnect all connected.""" disconnects = self._disconnects.copy() self._disconnects.clear() for disconnect in disconnects: disconnect() def _default_connect(self, signal: str, target: TargetType) \ -> DisconnectType: """Connect function to signal. Must be ran in the event loop.""" self._signals[signal].append(target) def remove_dispatcher() -> None: """Remove signal listener.""" try: self._signals[signal].remove(target) except ValueError: # signal was already removed pass return remove_dispatcher def _default_send(self, signal: str, *args: Any) -> \ Sequence[asyncio.Future]: """Fire a signal. Must be ran in the event loop.""" targets = self._signals[signal] futures = [] for target in targets: task = self._call_target(target, *args) futures.append(task) return futures def _call_target(self, target, *args) -> asyncio.Future: check_target = target while isinstance(check_target, functools.partial): check_target = check_target.func if asyncio.iscoroutinefunction(check_target): return self._loop.create_task(target(*args)) return self._loop.run_in_executor(None, target, *args) @property def signals(self) -> Dict[str, List[TargetType]]: """Get the dictionary of registered signals and callbaks.""" return self._signals @property def last_sent(self) -> Sequence[asyncio.Future]: """Get the last sent asyncio tasks.""" return self._last_sent andrewsayre-pysmartapp-4aa1ff9/pysmartapp/errors.py000066400000000000000000000012711445426365100230320ustar00rootroot00000000000000"""Define the errors module.""" class SignatureVerificationError(Exception): """Defines an error for signature verification failures.""" pass class SmartAppNotRegisteredError(Exception): """Defines an error when a SmartApp isn't registered.""" _message = "SmartApp handler for installed app '{}' was not found." def __init__(self, installed_app_id: str): """Create a new instance of the error.""" Exception.__init__(self, self._message.format(installed_app_id)) self._installed_app_id = installed_app_id @property def installed_app_id(self) -> str: """Get the installed app id not found.""" return self._installed_app_id andrewsayre-pysmartapp-4aa1ff9/pysmartapp/event.py000066400000000000000000000113161445426365100226400ustar00rootroot00000000000000"""Define the event module.""" from typing import Any, Dict, Optional, Sequence from .const import EVENT_TYPE_DEVICE, EVENT_TYPE_TIMER from .request import EmptyDataResponse, Request, Response class Event: """Define an event.""" def __init__(self, data: dict): """Create a new instance of the event class.""" self._event_type = data['eventType'] self._subscription_name = None self._event_id = None self._location_id = None self._device_id = None self._component_id = None self._capability = None self._attribute = None self._value = None self._value_type = None self._data = None self._state_change = None if self._event_type == EVENT_TYPE_DEVICE: device_event = data['deviceEvent'] self._subscription_name = device_event['subscriptionName'] self._event_id = device_event['eventId'] self._location_id = device_event['locationId'] self._device_id = device_event['deviceId'] self._component_id = device_event['componentId'] self._capability = device_event['capability'] self._attribute = device_event['attribute'] self._value = device_event['value'] self._value_type = device_event['valueType'] self._data = device_event.get('data') self._state_change = device_event['stateChange'] self._timer_name = None self._timer_type = None self._timer_time = None self._timer_expression = None if self._event_type == EVENT_TYPE_TIMER: timer_event = data['timerEvent'] self._event_id = timer_event['eventId'] self._timer_name = timer_event['name'] self._timer_type = timer_event['type'] self._timer_time = timer_event['time'] self._timer_expression = timer_event['expression'] @property def event_type(self) -> str: """Get the type of the event.""" return self._event_type @property def subscription_name(self) -> str: """Get the subscription name.""" return self._subscription_name @property def event_id(self) -> str: """Get the event id.""" return self._event_id @property def location_id(self) -> str: """Get the location id.""" return self._location_id @property def device_id(self) -> str: """Get the device id.""" return self._device_id @property def component_id(self) -> str: """Get the component id.""" return self._component_id @property def capability(self) -> str: """Get the capability.""" return self._capability @property def attribute(self) -> str: """Get the attribute.""" return self._attribute @property def value(self) -> Optional[Any]: """Get the value.""" return self._value @property def value_type(self) -> str: """Get the type of the value.""" return self._value_type @property def data(self) -> Optional[Dict[str, Any]]: """Get the data associated with the event.""" return self._data @property def state_change(self) -> bool: """Get whether this is a new state change.""" return self._state_change @property def timer_name(self) -> str: """Get the name of the timer schedule.""" return self._timer_name @property def timer_type(self) -> str: """Get the type of time.""" return self._timer_type @property def timer_time(self) -> str: """Get the time the timer fired.""" return self._timer_time @property def timer_expression(self) -> str: """Get the timer firing expression.""" return self._timer_expression class EventRequest(Request): """Define the EventRequest class.""" def __init__(self, data: dict): """Create a new instance of the EventRequest.""" super().__init__(data) event_data = self._event_data_raw = data['eventData'] self._auth_token = event_data['authToken'] self._init_installed_app(event_data['installedApp']) self._events = [Event(item) for item in event_data['events']] async def _process(self, app) -> Response: resp = EmptyDataResponse('eventData') return resp @property def event_data_raw(self) -> dict: """Get the raw event data.""" return self._event_data_raw @property def auth_token(self) -> str: """Get the auth token.""" return self._auth_token @property def events(self) -> Sequence[Event]: """Get the events.""" return self._events andrewsayre-pysmartapp-4aa1ff9/pysmartapp/install.py000066400000000000000000000017671445426365100231760ustar00rootroot00000000000000"""Define the install module.""" from .request import EmptyDataResponse, Request, Response class InstallRequest(Request): """Define the InstallRequest class.""" def __init__(self, data: dict): """Create a new instance of the InstallRequest.""" super().__init__(data) install_data = self._install_data_raw = data['installData'] self._init_installed_app(install_data['installedApp']) self._auth_token = install_data['authToken'] self._refresh_token = install_data['refreshToken'] async def _process(self, app) -> Response: resp = EmptyDataResponse('installData') return resp @property def install_data_raw(self) -> dict: """Get the raw installation data.""" return self._install_data_raw @property def auth_token(self): """Get the auth token.""" return self._auth_token @property def refresh_token(self): """Get the refresh token.""" return self._refresh_token andrewsayre-pysmartapp-4aa1ff9/pysmartapp/oauthcallback.py000066400000000000000000000016501445426365100243140ustar00rootroot00000000000000"""Define the oauthcallback module.""" from .request import EmptyDataResponse, Request, Response class OAuthCallbackRequest(Request): """Define the OAuthCallbackRequest class.""" def __init__(self, data: dict): """Create a new instance of the OAuthCallbackRequest.""" super().__init__(data) callback_data = self._oauth_callback_data_raw = \ data['oAuthCallbackData'] self._installed_app_id = callback_data['installedAppId'] self._url_path = callback_data['urlPath'] async def _process(self, app) -> Response: resp = EmptyDataResponse('oAuthCallbackData') return resp @property def oauth_callback_data_raw(self) -> dict: """Get the raw OAuth Callback data.""" return self._oauth_callback_data_raw @property def url_path(self) -> str: """Get the url path of the OAuth callback.""" return self._url_path andrewsayre-pysmartapp-4aa1ff9/pysmartapp/ping.py000066400000000000000000000026261445426365100224600ustar00rootroot00000000000000"""Define the ping module.""" from .request import Request, Response class PingRequest(Request): """Defines a ping request.""" def __init__(self, data: dict): """Create a new instance of the PingRequest class.""" super().__init__(data) self._supports_validation = False self._ping_data_raw = data['pingData'] async def _process(self, app): response = PingResponse() response.ping_challenge = self.ping_challenge return response @property def ping_data_raw(self) -> dict: """Get the raw data structure of the ping request.""" return self._ping_data_raw @property def ping_challenge(self): """Get the challenge code as part of the request.""" return self._ping_data_raw['challenge'] class PingResponse(Response): """Defines a ping response.""" def __init__(self): """Create a new instance of the PingResponse.""" self._ping_challenge = None def to_data(self) -> dict: """Create a data structure for the response.""" return {'pingData': {'challenge': self.ping_challenge}} @property def ping_challenge(self): """Get the ping challenge in the response.""" return self._ping_challenge @ping_challenge.setter def ping_challenge(self, value: str): """Set the ping challenge response.""" self._ping_challenge = value andrewsayre-pysmartapp-4aa1ff9/pysmartapp/request.py000066400000000000000000000067331445426365100232160ustar00rootroot00000000000000"""Define the request module.""" from httpsig.verify import HeaderVerifier from .errors import SignatureVerificationError class Response: """Defines a response.""" def to_data(self) -> dict: """Create a data structure for the response.""" raise NotImplementedError class EmptyDataResponse(Response): """Defines a response with an empty data structure.""" def __init__(self, name: str): """Create a new instance of the EmptyDataResponse class.""" self._name = name def to_data(self) -> dict: """Return a data structure representing this request.""" return {self.name: {}} @property def name(self) -> str: """Get the name of the empty data tag.""" return self._name @name.setter def name(self, value: str): """Set the name of the empty data tag.""" self._name = value class Request: """Defines a request to process.""" def __init__(self, data: dict): """Create a new instance of the Request class.""" self._lifecycle = data['lifecycle'] self._execution_id = data['executionId'] self._locale = data['locale'] self._version = data['version'] self._installed_app_id = None self._location_id = None self._installed_app_config = {} self._settings = data.get('settings', {}) self._supports_validation = True def _init_installed_app(self, installed_app): self._installed_app_id = installed_app['installedAppId'] self._location_id = installed_app['locationId'] self._installed_app_config = installed_app['config'] async def process(self, app, headers: list = None, validate_signature: bool = True) -> Response: """Process the request with the SmartApp.""" if validate_signature and self._supports_validation: try: verifier = HeaderVerifier( headers=headers, secret=app.public_key, method='POST', path=app.path) result = verifier.verify() except Exception as ex: raise SignatureVerificationError from ex if not result: raise SignatureVerificationError response = await self._process(app) app.dispatcher.send(self.lifecycle, self, response, app) return response async def _process(self, app) -> Response: raise NotImplementedError @property def lifecycle(self) -> str: """Get the lifecycle of the request.""" return self._lifecycle @property def execution_id(self) -> str: """Get the execution id of the request.""" return self._execution_id @property def locale(self) -> str: """Get the locale of the request.""" return self._locale @property def version(self) -> str: """Get the version of the request.""" return self._version @property def installed_app_id(self) -> str: """Get the installed app id the request is for.""" return self._installed_app_id @property def location_id(self) -> str: """Get the installed app location id.""" return self._location_id @property def installed_app_config(self) -> dict: """Get the installed app configuration.""" return self._installed_app_config @property def settings(self): """Get the settings associated with the request.""" return self._settings andrewsayre-pysmartapp-4aa1ff9/pysmartapp/smartapp.py000066400000000000000000000166511445426365100233550ustar00rootroot00000000000000"""Define a SmartApp.""" import logging from typing import Any, Callable, Dict, List from .const import ( LIFECYCLE_CONFIG, LIFECYCLE_EVENT, LIFECYCLE_INSTALL, LIFECYCLE_OAUTH_CALLBACK, LIFECYCLE_PING, LIFECYCLE_UNINSTALL, LIFECYCLE_UPDATE, SETTINGS_APP_ID) from .dispatch import Dispatcher from .errors import SmartAppNotRegisteredError from .utilities import create_request _LOGGER = logging.getLogger(__name__) class SmartAppBase: """Define common functionality for the SmartApp and SmartAppManager.""" def __init__(self, *, path: str = '/', dispatcher: Dispatcher = None): """Initialize a new instance of the smartapp.""" self._dispatcher = dispatcher or Dispatcher() self._path = path def connect_ping(self, target: Callable[..., Any]) \ -> Callable[[], None]: """Connect a target to the ping signal.""" return self._dispatcher.connect(LIFECYCLE_PING, target) def connect_config(self, target: Callable[..., Any]) \ -> Callable[[], None]: """Connect a target to the config signal.""" return self._dispatcher.connect(LIFECYCLE_CONFIG, target) def connect_install(self, target: Callable[..., Any]) \ -> Callable[[], None]: """Connect a target to the install signal.""" return self._dispatcher.connect(LIFECYCLE_INSTALL, target) def connect_update(self, target: Callable[..., Any]) \ -> Callable[[], None]: """Connect a target to the update signal.""" return self._dispatcher.connect(LIFECYCLE_UPDATE, target) def connect_event(self, target: Callable[..., Any]) \ -> Callable[[], None]: """Connect a target to the event signal.""" return self._dispatcher.connect(LIFECYCLE_EVENT, target) def connect_oauth_callback(self, target: Callable[..., Any]) \ -> Callable[[], None]: """Connect a target to the oauth callback signal.""" return self._dispatcher.connect(LIFECYCLE_OAUTH_CALLBACK, target) def connect_uninstall(self, target: Callable[..., Any]) \ -> Callable[[], None]: """Connect a target to the oauth callback signal.""" return self._dispatcher.connect(LIFECYCLE_UNINSTALL, target) @property def path(self) -> str: """Get the path the SmartApp is installed at.""" return self._path @property def dispatcher(self): """Get the dispatcher used to connect and send notifications.""" return self._dispatcher class SmartApp(SmartAppBase): """Define the SmartApp class.""" def __init__(self, *, path: str = '/', public_key=None, dispatcher: Dispatcher = None): """Initialize the SmartApp class.""" super().__init__(path=path, dispatcher=dispatcher) self._app_id = None self._config_app_id = 'app' self._description = None self._name = None self._permissions = [] self._public_key = public_key async def handle_request(self, data: dict, headers: dict = None, validate_signature: bool = True) -> dict: """Process a lifecycle event.""" req = create_request(data) resp = await req.process(self, headers, validate_signature) if req.installed_app_id: _LOGGER.debug("%s: %s received for installed app %s.", req.execution_id, req.lifecycle, req.installed_app_id) else: _LOGGER.debug("%s: %s received.", req.execution_id, req.lifecycle) return resp.to_data() @property def app_id(self): """Get the unique id of the SmartApp.""" return self._app_id @app_id.setter def app_id(self, value: str): """Set the app id of the SmartApp.""" self._app_id = value @property def name(self) -> str: """Get the name of the SmartApp using during config.""" return self._name @name.setter def name(self, value: str): """Set the name of the SmartApp used during config.""" self._name = value @property def description(self) -> str: """Get the description of the SmartApp used during config.""" return self._description @description.setter def description(self, value: str): """Set the description of the SmartApp used during config.""" self._description = value @property def config_app_id(self): """Get the id for the SmartApp used during config.""" return self._config_app_id @config_app_id.setter def config_app_id(self, value: str): """Set the id of the SmartApp used during config.""" self._config_app_id = value @property def permissions(self) -> List[str]: """Get the permissions of the SmartApp used during config.""" return self._permissions @property def public_key(self): """Get the public key of the SmartApp used to verify events.""" return self._public_key class SmartAppManager(SmartAppBase): """Service to support multiple SmartApps at the same end-point.""" def __init__(self, path: str, *, dispatcher: Dispatcher = None): """Create a new instance of the manager.""" super().__init__(path=path, dispatcher=dispatcher) self._smartapps = {} async def handle_request(self, data: dict, headers: dict = None, validate_signature: bool = True) -> dict: """Process a lifecycle event.""" req = create_request(data) # Always process ping lifecycle events. if req.lifecycle == LIFECYCLE_PING: resp = await req.process(self, headers, validate_signature) else: app_id = req.settings.get(SETTINGS_APP_ID) if not app_id: raise SmartAppNotRegisteredError(req.installed_app_id) smartapp = self._smartapps.get(app_id) if not smartapp: raise SmartAppNotRegisteredError(req.installed_app_id) resp = await req.process(smartapp, headers, validate_signature) if req.installed_app_id: _LOGGER.debug("%s: %s received for installed app %s.", req.execution_id, req.lifecycle, req.installed_app_id) else: _LOGGER.debug("%s: %s received.", req.execution_id, req.lifecycle) return resp.to_data() def register(self, app_id: str, public_key: str) -> SmartApp: """Create a new SmartApp for the end-point.""" if app_id is None: raise ValueError('smartapp must have an app_id.') if app_id in self._smartapps: raise ValueError('smartapp already registered.') smartapp = SmartApp( path=self._path, public_key=public_key, dispatcher=self._dispatcher ) smartapp.app_id = app_id self._smartapps[smartapp.app_id] = smartapp return smartapp def unregister(self, app_id: str): """Unregister the specified installed app id.""" if app_id is None: raise ValueError('smartapp must have an app_id.') if app_id not in self._smartapps: raise ValueError('smartapp was not previously registered.') self._smartapps.pop(app_id, None) @property def smartapps(self) -> Dict[str, SmartApp]: """Get registered SmartApps.""" return self._smartapps andrewsayre-pysmartapp-4aa1ff9/pysmartapp/uninstall.py000066400000000000000000000012701445426365100235260ustar00rootroot00000000000000"""Define the uninstall module.""" from .request import EmptyDataResponse, Request, Response class UninstallRequest(Request): """Define the UninstallRequest class.""" def __init__(self, data: dict): """Create a new instance of the UninstallRequest.""" super().__init__(data) uninstall_data = self._uninstall_data_raw = data['uninstallData'] self._init_installed_app(uninstall_data['installedApp']) async def _process(self, app) -> Response: resp = EmptyDataResponse('uninstallData') return resp @property def uninstall_data_raw(self) -> dict: """Get the raw update data.""" return self._uninstall_data_raw andrewsayre-pysmartapp-4aa1ff9/pysmartapp/update.py000066400000000000000000000017441445426365100230050ustar00rootroot00000000000000"""Define the update module.""" from .request import EmptyDataResponse, Request, Response class UpdateRequest(Request): """Define the UpdateRequest class.""" def __init__(self, data: dict): """Create a new instance of the UpdateRequest.""" super().__init__(data) update_data = self._update_data_raw = data['updateData'] self._init_installed_app(update_data['installedApp']) self._auth_token = update_data['authToken'] self._refresh_token = update_data['refreshToken'] async def _process(self, app) -> Response: resp = EmptyDataResponse('updateData') return resp @property def update_data_raw(self) -> dict: """Get the raw update data.""" return self._update_data_raw @property def auth_token(self): """Get the auth token.""" return self._auth_token @property def refresh_token(self): """Get the refresh token.""" return self._refresh_token andrewsayre-pysmartapp-4aa1ff9/pysmartapp/utilities.py000066400000000000000000000022511445426365100235300ustar00rootroot00000000000000"""Define the utilities class.""" from .config import ConfigRequest from .const import ( LIFECYCLE_CONFIG, LIFECYCLE_EVENT, LIFECYCLE_INSTALL, LIFECYCLE_OAUTH_CALLBACK, LIFECYCLE_PING, LIFECYCLE_UNINSTALL, LIFECYCLE_UPDATE) from .event import EventRequest from .install import InstallRequest from .oauthcallback import OAuthCallbackRequest from .ping import PingRequest from .uninstall import UninstallRequest from .update import UpdateRequest def create_request(data: dict): """Create a request from the given dictionary and headers.""" lifecycle = data['lifecycle'] if lifecycle == LIFECYCLE_PING: return PingRequest(data) if lifecycle == LIFECYCLE_CONFIG: return ConfigRequest(data) if lifecycle == LIFECYCLE_INSTALL: return InstallRequest(data) if lifecycle == LIFECYCLE_UPDATE: return UpdateRequest(data) if lifecycle == LIFECYCLE_EVENT: return EventRequest(data) if lifecycle == LIFECYCLE_OAUTH_CALLBACK: return OAuthCallbackRequest(data) if lifecycle == LIFECYCLE_UNINSTALL: return UninstallRequest(data) raise ValueError('The specified lifecycle event was not recognized.') andrewsayre-pysmartapp-4aa1ff9/requirements.txt000066400000000000000000000000161445426365100222240ustar00rootroot00000000000000httpsig==1.3.0andrewsayre-pysmartapp-4aa1ff9/setup.py000066400000000000000000000024721445426365100204620ustar00rootroot00000000000000"""SmartThings Cloud API""" import os from setuptools import find_packages, setup here = os.path.abspath(os.path.dirname(__file__)) with open(os.path.join("README.md"), 'r') as fh: long_description = fh.read() consts = {} with open(os.path.join('pysmartapp', 'const.py'), 'r') as fp: exec(fp.read(), consts) setup(name=consts['__title__'], version=consts['__version__'], description='A python library for building a SmartThings SmartApp', long_description=long_description, long_description_content_type='text/markdown', url='https://github.com/andrewsayre/pysmartapp', author='Andrew Sayre', author_email='andrew@sayre.net', license='MIT', packages=find_packages(exclude=('tests*',)), install_requires=['httpsig>=1.3.0,<2.0.0'], tests_require=[], platforms=['any'], keywords=["smartthings", "smartapp"], zip_safe=False, classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries", "Topic :: Home Automation", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", ]) andrewsayre-pysmartapp-4aa1ff9/test-requirements.txt000066400000000000000000000002631445426365100232050ustar00rootroot00000000000000coveralls==3.3.1 flake8==6.0.0 flake8-docstrings==1.7.0 pydocstyle==6.3.0 isort==5.12.0 pylint==2.17.4 pytest==7.4.0 pytest-asyncio==0.21.0 pytest-cov==4.1.0 pytest-timeout==2.1.0andrewsayre-pysmartapp-4aa1ff9/tests/000077500000000000000000000000001445426365100201055ustar00rootroot00000000000000andrewsayre-pysmartapp-4aa1ff9/tests/__init__.py000066400000000000000000000000501445426365100222110ustar00rootroot00000000000000"""Tests for the pysmartapp package.""" andrewsayre-pysmartapp-4aa1ff9/tests/conftest.py000066400000000000000000000023271445426365100223100ustar00rootroot00000000000000"""Define common test configuraiton.""" import pytest from pysmartapp.dispatch import Dispatcher from pysmartapp.smartapp import SmartApp, SmartAppManager @pytest.fixture def smartapp(event_loop) -> SmartApp: """Fixture for testing against the SmartApp class.""" app = SmartApp(dispatcher=Dispatcher(loop=event_loop)) app.name = 'SmartApp' app.description = 'SmartApp Description' app.permissions.append('l:devices') app.config_app_id = 'myapp' return app @pytest.fixture def manager(event_loop) -> SmartAppManager: """Fixture for testing against the SmartAppManager class.""" return SmartAppManager('/path/to/app', dispatcher=Dispatcher(loop=event_loop)) @pytest.fixture def handler(): """Fixture handler to mock in the dispatcher.""" def target(*args, **kwargs): target.fired = True target.args = args target.kwargs = kwargs target.fired = False return target @pytest.fixture def async_handler(): """Fixture async handler to mock in the dispatcher.""" async def target(*args, **kwargs): target.fired = True target.args = args target.kwargs = kwargs target.fired = False return target andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/000077500000000000000000000000001445426365100217565ustar00rootroot00000000000000andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/config_init_request.json000066400000000000000000000006061445426365100267130ustar00rootroot00000000000000{ "lifecycle": "CONFIGURATION", "executionId": "85f0047b-bb24-8eeb-da11-cb6e2f767322", "locale": "en", "version": "0.1.0", "configurationData": { "installedAppId": "8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121", "phase": "INITIALIZE", "pageId": "", "previousPageId": "", "config": { } }, "settings": { "appId": "f6c071aa-6ae7-463f-b0ad-8620ac23140f" } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/config_init_response.json000066400000000000000000000003431445426365100270570ustar00rootroot00000000000000{ "configurationData": { "initialize": { "name": "SmartApp", "description": "SmartApp Description", "id": "myapp", "permissions": [ "l:devices" ], "firstPageId": "1" } } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/config_init_sig_fail_request.json000066400000000000000000000021141445426365100305440ustar00rootroot00000000000000{ "headers": { "Authorization": "Signature keyId=\"/SmartThings/dd:41:39:60:b2:a3:32:0e:d4:79:39:a3:f1:ee:bd:ac\",signature=\"eOYM/ahp8CI8A8ia92ZA8DD/P6eE0E50QQjNxIQuvXMAaYvVd6TDrbP/CK5Ip5aZBkRruMrkzQPO9BJYybBLzzR+NmwyqfBGGOyLrNBDymACDj4z5qGKwkxsM5BdEaBZp5y6Rx8Mp8qj5mCVQuLcpjAjQu7lWatKpjspN8AbQctzkTRC95JqcJMYYbqSB8WT63aJ1Q+hnQxNl6J3R910ngFgzV85WWVL6rqEBWqc9vOIY0AyVz5mFQEla9SiYkhcziAQtdWb7zwfdDtRwEyuLd4DJak+rGWroR/HPq35cV7DIdydutvxbQMwVjCb0s/y1j8j4jf8+FdanK2YMGXfyg==\",headers=\"(request-target) digest date\",algorithm=\"rsa-sha256\"", "Date": "Wed, 20 Dec 2018 16:06:40 UTC", "Digest": "SHA-256=LGClKnf5iim5yeki/P/Z/vJvUCS21mr+ut59h8wW538=" }, "body": { "lifecycle": "CONFIGURATION", "executionId": "eb2465ce-36f9-9f54-3e58-42ba035d84f6", "locale": "en", "version": "0.1.0", "client": { "os": "ios", "version": "0.0.0", "language": "en-US" }, "configurationData": { "installedAppId": "8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121", "phase": "INITIALIZE", "pageId": "", "previousPageId": "", "config": { "app": [{} ] } }, "settings": {} } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/config_init_sig_pass_request.json000066400000000000000000000021141445426365100305770ustar00rootroot00000000000000{ "headers": { "Authorization": "Signature keyId=\"/SmartThings/dd:41:39:60:b2:a3:32:0e:d4:79:39:a3:f1:ee:bd:ac\",signature=\"eOYM/ahp8CI8A8ia92ZA8DD/P6eE0E50QQjNxIQuvXMAaYvVd6TDrbP/CK5Ip5aZBkRruMrkzQPO9BJYybBLzzR+NmwyqfBGGOyLrNBDymACDj4z5qGKwkxsM5BdEaBZp5y6Rx8Mp8qj5mCVQuLcpjAjQu7lWatKpjspN8AbQctzkTRC95JqcJMYYbqSB8WT63aJ1Q+hnQxNl6J3R910ngFgzV85WWVL6rqEBWqc9vOIY0AyVz5mFQEla9SiYkhcziAQtdWb7zwfdDtRwEyuLd4DJak+rGWroR/HPq35cV7DIdydutvxbQMwVjCb0s/y1j8j4jf8+FdanK2YMGXfyg==\",headers=\"(request-target) digest date\",algorithm=\"rsa-sha256\"", "Date": "Wed, 26 Dec 2018 16:06:40 UTC", "Digest": "SHA-256=LGClKnf5iim5yeki/P/Z/vJvUCS21mr+ut59h8wW538=" }, "body": { "lifecycle": "CONFIGURATION", "executionId": "eb2465ce-36f9-9f54-3e58-42ba035d84f6", "locale": "en", "version": "0.1.0", "client": { "os": "ios", "version": "0.0.0", "language": "en-US" }, "configurationData": { "installedAppId": "8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121", "phase": "INITIALIZE", "pageId": "", "previousPageId": "", "config": { "app": [{} ] } }, "settings": {} } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/config_page_request.json000066400000000000000000000006541445426365100266670ustar00rootroot00000000000000{ "lifecycle": "CONFIGURATION", "executionId": "85f0047b-bb24-8eeb-da11-cb6e2f767322", "locale": "en", "version": "0.1.0", "configurationData": { "installedAppId": "8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121", "phase": "PAGE", "pageId": "1", "previousPageId": "", "config": { "app": [ { } ] } }, "settings": { "appId": "f6c071aa-6ae7-463f-b0ad-8620ac23140f" } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/config_page_response.json000066400000000000000000000003151445426365100270270ustar00rootroot00000000000000{ "configurationData": { "page": { "pageId": "1", "name": "Configuration", "nextPageId": null, "previousPageId": null, "complete": true, "sections": [] } } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/event_request.json000066400000000000000000000035321445426365100255450ustar00rootroot00000000000000{ "lifecycle": "EVENT", "executionId": "b328f242-c602-4204-8d73-33c48ae180af", "locale": "en", "version": "1.0.0", "eventData": { "authToken": "f01894ce-013a-434a-b51e-f82126fd72e4", "installedApp": { "installedAppId": "8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121", "locationId": "e675a3d9-2499-406c-86dc-8a492a886494", "config": {} }, "events": [ { "eventType": "DEVICE_EVENT", "deviceEvent": { "subscriptionName": "motion_sensors", "eventId": "736e3903-001c-4d40-b408-ff40d162a06b", "locationId": "499e28ba-b33b-49c9-a5a1-cce40e41f8a6", "deviceId": "6f5ea629-4c05-4a90-a244-cc129b0a80c3", "componentId": "main", "capability": "motionSensor", "attribute": "motion", "value" :"active", "valueType": "string", "stateChange": true } }, { "eventType": "DEVICE_EVENT", "deviceEvent": { "eventId": "0dd0b7f2-407e-11e9-82d8-df54c125e40f", "locationId": "5c03e518-118a-44cb-85ad-7877d0b302e4", "deviceId": "45cae340-bc11-41f9-b46c-abfaf82189fe", "componentId": "main", "capability": "lock", "attribute": "lock", "value": "locked", "valueType": "string", "stateChange": true, "data": { "codeId": "1", "method": "manual" }, "subscriptionName": "013f8f57-b02f-495e-899e-ad7e9a689cc1" } }, { "eventType": "TIMER_EVENT", "timerEvent": { "eventId": "string", "name": "lights_off_timeout", "type": "CRON", "time": "2017-09-13T04:18:12.469Z", "expression": "string" } } ] }, "settings": { "appId": "f6c071aa-6ae7-463f-b0ad-8620ac23140f" } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/event_response.json000066400000000000000000000000251445426365100257050ustar00rootroot00000000000000{ "eventData": {} }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/install_request.json000066400000000000000000000025411445426365100260710ustar00rootroot00000000000000{ "lifecycle": "INSTALL", "executionId": "b328f242-c602-4204-8d73-33c48ae180af", "locale": "en", "version": "1.0.0", "installData": { "authToken": "580aff1f-f0f1-44e0-94d4-e68bf9c2e768", "refreshToken": "ad58374e-9d6a-4457-8488-a05aa8337ab3", "installedApp": { "installedAppId": "8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121", "locationId": "e675a3d9-2499-406c-86dc-8a492a886494", "config": { "contactSensor": [ { "valueType": "DEVICE", "deviceConfig": { "deviceId": "e457978e-5e37-43e6-979d-18112e12c961", "componentId": "main" } } ], "lightSwitch": [ { "valueType": "DEVICE", "deviceConfig": { "deviceId": "74aac3bb-91f2-4a88-8c49-ae5e0a234d76", "componentId": "main" } } ], "minutes": [ { "valueType": "STRING", "stringConfig": { "value": "5" } } ], "permissions": [ "r:devices:e457978e-5e37-43e6-979d-18112e12c961", "r:devices:74aac3bb-91f2-4a88-8c49-ae5e0a234d76", "x:devices:74aac3bb-91f2-4a88-8c49-ae5e0a234d76" ] } } }, "settings": { "appId": "f6c071aa-6ae7-463f-b0ad-8620ac23140f" } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/install_response.json000066400000000000000000000000271445426365100262340ustar00rootroot00000000000000{ "installData": {} }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/oauth_callback_request.json000066400000000000000000000005011445426365100273510ustar00rootroot00000000000000{ "lifecycle": "OAUTH_CALLBACK", "executionId": "b328f242-c602-4204-8d73-33c48ae180af", "locale": "en", "version": "1.0.0", "oAuthCallbackData": { "installedAppId": "8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121", "urlPath": "string" }, "settings": { "appId": "f6c071aa-6ae7-463f-b0ad-8620ac23140f" } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/oauth_callback_response.json000066400000000000000000000000341445426365100275200ustar00rootroot00000000000000{ "oAuthCallbackData": { } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/ping_request.json000066400000000000000000000003071445426365100253560ustar00rootroot00000000000000{ "lifecycle": "PING", "executionId": "b328f242-c602-4204-8d73-33c48ae180af", "locale": "en", "version": "1.0.0", "pingData": { "challenge": "1a904d57-4fab-4b15-a11e-1c4bfe7cb502" } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/ping_response.json000066400000000000000000000001171445426365100255230ustar00rootroot00000000000000{ "pingData": { "challenge": "1a904d57-4fab-4b15-a11e-1c4bfe7cb502" } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/public_key.pem000066400000000000000000000007151445426365100246120ustar00rootroot00000000000000-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6S3/8HyHyI5seDWh++eHRo05o3aWTuiq x2AKW5hiP6nvyCx6uK7IRPbTiJV6e8M1Rh+HTR5oJjnYLMG1JjrdXXIWCzA/ERwPxjyK4l0HUEZ9 I0W8+rkuxb8QZ+JYhfJhibZnmJ2lfINVdbaKaUn06a237Gnj1b9SMpvG7kETCBdrxMiE9557CGBf NR9lA38fNoxciOgGHdMNteubnELt2bfP48ZchQcyPeJIfO9cQ5Bj4wZnz7F+d6zmuhImUXmZssM/ 1SaNAYDat8SWGAGUijUCTsfto6EsQnQ05Kbxl7Jp/j93dSKuD5ML0GH9ryQC2yaRnGKV8SEyJ6UK kgZ0XQIDAQAB -----END PUBLIC KEY-----andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/uninstall_request.json000066400000000000000000000023341445426365100264340ustar00rootroot00000000000000{ "lifecycle": "UNINSTALL", "executionId": "b328f242-c602-4204-8d73-33c48ae180af", "locale": "en", "version": "1.0.0", "uninstallData":{ "installedApp":{ "installedAppId":"8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121", "locationId":"e675a3d9-2499-406c-86dc-8a492a886494", "config":{ "contactSensor":[ { "valueType":"DEVICE", "deviceConfig":{ "deviceId":"e457978e-5e37-43e6-979d-18112e12c961", "componentId":"main" } } ], "lightSwitch":[ { "valueType":"DEVICE", "deviceConfig":{ "deviceId":"74aac3bb-91f2-4a88-8c49-ae5e0a234d76", "componentId":"main" } } ], "minutes":[ { "valueType":"STRING", "stringConfig":{ "value":"5" } } ], "permissions":[ "r:devices:e457978e-5e37-43e6-979d-18112e12c961", "r:devices:74aac3bb-91f2-4a88-8c49-ae5e0a234d76", "x:devices:74aac3bb-91f2-4a88-8c49-ae5e0a234d76" ] } } }, "settings": { "appId": "f6c071aa-6ae7-463f-b0ad-8620ac23140f" } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/uninstall_response.json000066400000000000000000000000311445426365100265720ustar00rootroot00000000000000{ "uninstallData": {} }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/update_request.json000066400000000000000000000042321445426365100257040ustar00rootroot00000000000000{ "lifecycle": "UPDATE", "executionId": "b328f242-c602-4204-8d73-33c48ae180af", "locale": "en", "version": "1.0.0", "updateData": { "authToken": "4ebd8d9f-53b0-483f-a989-4bde30ca83c0", "refreshToken": "6e3bbf5f-b68d-4250-bbc9-f7151016a77f", "installedApp": { "installedAppId": "8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121", "locationId": "e675a3d9-2499-406c-86dc-8a492a886494", "config": { "contactSensor": [ { "valueType": "DEVICE", "deviceConfig": { "deviceId": "e457978e-5e37-43e6-979d-18112e12c961", "componentId": "main" } } ], "lightSwitch": [ { "valueType": "DEVICE", "deviceConfig": { "deviceId": "74aac3bb-91f2-4a88-8c49-ae5e0a234d76", "componentId": "main" } } ], "minutes": [ { "valueType": "STRING", "stringConfig": { "value": "5" } } ], "permissions": [ "r:devices:e457978e-5e37-43e6-979d-18112e12c961", "r:devices:74aac3bb-91f2-4a88-8c49-ae5e0a234d76", "x:devices:74aac3bb-91f2-4a88-8c49-ae5e0a234d76" ] } }, "previousConfig": { "contactSensor": [ { "valueType": "DEVICE", "deviceConfig": { "deviceId": "e457978e-5e37-43e6-979d-18112e12c961", "componentId": "main" } } ], "lightSwitch": [ { "valueType": "DEVICE", "deviceConfig": { "deviceId": "74aac3bb-91f2-4a88-8c49-ae5e0a234d76", "componentId": "main" } } ], "minutes": [ { "valueType": "STRING", "stringConfig": { "value": "5" } } ] }, "previousPermissions": [ "r:devices:e457978e-5e37-43e6-979d-18112e12c961", "r:devices:74aac3bb-91f2-4a88-8c49-ae5e0a234d76", "x:devices:74aac3bb-91f2-4a88-8c49-ae5e0a234d76" ] }, "settings": { "appId": "f6c071aa-6ae7-463f-b0ad-8620ac23140f" } }andrewsayre-pysmartapp-4aa1ff9/tests/fixtures/update_response.json000066400000000000000000000000261445426365100260470ustar00rootroot00000000000000{ "updateData": {} }andrewsayre-pysmartapp-4aa1ff9/tests/test_config.py000066400000000000000000000025631445426365100227710ustar00rootroot00000000000000"""Tests for the config module.""" import pytest from pysmartapp.config import ConfigRequest from pysmartapp.const import LIFECYCLE_CONFIG, LIFECYCLE_CONFIG_INIT from .utilities import get_fixture class TestConfigRequest: """Tests for the ConfigRequest class.""" @staticmethod def test_init(): """Tests the init method.""" # Arrange data = get_fixture('config_init_request') # Act req = ConfigRequest(data) # Assert assert req.config_data_raw == data['configurationData'] assert req.lifecycle == LIFECYCLE_CONFIG assert req.execution_id == data['executionId'] assert req.locale == data['locale'] assert req.version == data['version'] assert req.installed_app_id == \ data['configurationData']['installedAppId'] assert req.phase == LIFECYCLE_CONFIG_INIT assert req.page_id == '' assert req.previous_page_id == '' @staticmethod @pytest.mark.asyncio async def test_process_invalid(): """Tests the process method for an invalid phase.""" # Arrange data = get_fixture('config_init_request') data['configurationData']['phase'] = "UNKNOWN" # Act req = ConfigRequest(data) # Assert with pytest.raises(ValueError): await req.process(None, validate_signature=False) andrewsayre-pysmartapp-4aa1ff9/tests/test_dispatch.py000066400000000000000000000076261445426365100233300ustar00rootroot00000000000000"""Define tests for the Dispatch module.""" import asyncio import functools import pytest from pysmartapp.dispatch import Dispatcher class TestDispatcher: """Define tests for the dispatcher class.""" @staticmethod @pytest.mark.asyncio async def test_connect(handler): """Tests the connect function.""" # Arrange dispatcher = Dispatcher() # Act dispatcher.connect('TEST', handler) # Assert assert handler in dispatcher.signals['TEST'] @staticmethod @pytest.mark.asyncio async def test_disconnect(handler): """Tests the disconnect function.""" # Arrange dispatcher = Dispatcher() disconnect = dispatcher.connect('TEST', handler) # Act disconnect() # Assert assert handler not in dispatcher.signals['TEST'] @staticmethod @pytest.mark.asyncio async def test_disconnect_all(handler): """Tests the disconnect all function.""" # Arrange dispatcher = Dispatcher() dispatcher.connect('TEST', handler) dispatcher.connect('TEST', handler) dispatcher.connect('TEST2', handler) dispatcher.connect('TEST3', handler) # Act dispatcher.disconnect_all() # Assert assert handler not in dispatcher.signals['TEST'] assert handler not in dispatcher.signals['TEST2'] assert handler not in dispatcher.signals['TEST3'] @staticmethod @pytest.mark.asyncio async def test_already_disconnected(handler): """Tests that disconnect can be called more than once.""" # Arrange dispatcher = Dispatcher() disconnect = dispatcher.connect('TEST', handler) disconnect() # Act disconnect() # Assert assert handler not in dispatcher.signals['TEST'] @staticmethod @pytest.mark.asyncio async def test_send_async_handler(async_handler): """Tests sending to async handlers.""" # Arrange dispatcher = Dispatcher() dispatcher.connect('TEST', async_handler) # Act await asyncio.gather(*dispatcher.send('TEST')) # Assert assert async_handler.fired @staticmethod @pytest.mark.asyncio async def test_send_async_partial_handler(async_handler): """Tests sending to async handlers.""" # Arrange partial = functools.partial(async_handler) dispatcher = Dispatcher() dispatcher.connect('TEST', partial) # Act await asyncio.gather(*dispatcher.send('TEST')) # Assert assert async_handler.fired @staticmethod @pytest.mark.asyncio async def test_send(handler): """Tests sending to async handlers.""" # Arrange dispatcher = Dispatcher() dispatcher.connect('TEST', handler) args = object() # Act await asyncio.gather(*dispatcher.send('TEST', args)) # Assert assert handler.fired assert handler.args[0] == args @staticmethod @pytest.mark.asyncio async def test_custom_connect_and_send(handler): """Tests using the custom connect and send implementations.""" # Arrange test_signal = 'PREFIX_TEST' stored_target = None def connect(signal, target): assert signal == test_signal nonlocal stored_target stored_target = target def disconnect(): nonlocal stored_target stored_target = None return disconnect def send(signal, *args): assert signal == test_signal stored_target(*args) # pylint:disable=not-callable dispatcher = Dispatcher(connect=connect, send=send, signal_prefix='PREFIX_') # Act dispatcher.connect('TEST', handler) dispatcher.send('TEST') # Assert assert handler.fired andrewsayre-pysmartapp-4aa1ff9/tests/test_event.py000066400000000000000000000054471445426365100226510ustar00rootroot00000000000000"""Tests for the event module.""" from pysmartapp.const import ( EVENT_TYPE_DEVICE, EVENT_TYPE_TIMER, LIFECYCLE_EVENT) from pysmartapp.event import Event, EventRequest from .utilities import get_fixture class TestEventRequest: """Tests for the EventRequest class.""" @staticmethod def test_init(): """Tests the init method.""" # Arrange data = get_fixture('event_request') # Act req = EventRequest(data) # Assert assert req.event_data_raw == data['eventData'] assert req.lifecycle == LIFECYCLE_EVENT assert req.execution_id == data['executionId'] assert req.locale == data['locale'] assert req.version == data['version'] assert req.installed_app_id == '8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121' assert req.location_id == 'e675a3d9-2499-406c-86dc-8a492a886494' assert req.installed_app_config == {} assert req.settings == data['settings'] assert req.auth_token == 'f01894ce-013a-434a-b51e-f82126fd72e4' assert len(req.events) == 3 class TestEvent: """Tests for the Event class.""" @staticmethod def test_init_device_event(): """Tests the init method.""" # Arrange data = get_fixture('event_request')['eventData']['events'][0] # Act evt = Event(data) # Assert assert evt.event_type == EVENT_TYPE_DEVICE assert evt.subscription_name == 'motion_sensors' assert evt.event_id == '736e3903-001c-4d40-b408-ff40d162a06b' assert evt.location_id == '499e28ba-b33b-49c9-a5a1-cce40e41f8a6' assert evt.device_id == '6f5ea629-4c05-4a90-a244-cc129b0a80c3' assert evt.component_id == 'main' assert evt.capability == 'motionSensor' assert evt.attribute == 'motion' assert evt.value == 'active' assert evt.value_type == 'string' assert evt.data is None assert evt.state_change @staticmethod def test_init_device_event_with_data(): """Tests the init method.""" # Arrange data = get_fixture('event_request')['eventData']['events'][1] # Act evt = Event(data) # Assert assert evt.event_type == EVENT_TYPE_DEVICE assert evt.data == {"codeId": "1", "method": "manual"} @staticmethod def test_init_timer_event(): """Tests the init method.""" # Arrange data = get_fixture('event_request')['eventData']['events'][2] # Act evt = Event(data) # Assert assert evt.event_type == EVENT_TYPE_TIMER assert evt.event_id == 'string' assert evt.timer_name == 'lights_off_timeout' assert evt.timer_type == 'CRON' assert evt.timer_time == '2017-09-13T04:18:12.469Z' assert evt.timer_expression == 'string' andrewsayre-pysmartapp-4aa1ff9/tests/test_install.py000066400000000000000000000021751445426365100231710ustar00rootroot00000000000000"""Tests for the install module.""" from pysmartapp.const import LIFECYCLE_INSTALL from pysmartapp.install import InstallRequest from .utilities import get_fixture class TestInstallRequest: """Tests for the InstallRequest class.""" @staticmethod def test_init(): """Tests the init method.""" # Arrange data = get_fixture('install_request') # Act req = InstallRequest(data) # Assert assert req.install_data_raw == data['installData'] assert req.lifecycle == LIFECYCLE_INSTALL assert req.execution_id == data['executionId'] assert req.locale == data['locale'] assert req.version == data['version'] assert req.installed_app_id == '8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121' assert req.location_id == 'e675a3d9-2499-406c-86dc-8a492a886494' assert req.installed_app_config ==\ data['installData']['installedApp']['config'] assert req.settings == data['settings'] assert req.auth_token == '580aff1f-f0f1-44e0-94d4-e68bf9c2e768' assert req.refresh_token == 'ad58374e-9d6a-4457-8488-a05aa8337ab3' andrewsayre-pysmartapp-4aa1ff9/tests/test_oauthcallback.py000066400000000000000000000015711445426365100243170ustar00rootroot00000000000000"""Tests for the oauthcallback module.""" from pysmartapp.const import LIFECYCLE_OAUTH_CALLBACK from pysmartapp.oauthcallback import OAuthCallbackRequest from .utilities import get_fixture class TestOAuthCallbackRequest: """Tests for the OAuthCallbackRequest class.""" @staticmethod def test_init(): """Tests the init method.""" # Arrange data = get_fixture('oauth_callback_request') # Act req = OAuthCallbackRequest(data) # Assert assert req.oauth_callback_data_raw == data['oAuthCallbackData'] assert req.lifecycle == LIFECYCLE_OAUTH_CALLBACK assert req.execution_id == data['executionId'] assert req.locale == data['locale'] assert req.version == data['version'] assert req.installed_app_id == '8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121' assert req.url_path == 'string' andrewsayre-pysmartapp-4aa1ff9/tests/test_ping.py000066400000000000000000000013501445426365100224520ustar00rootroot00000000000000"""Tests for the ping module.""" from pysmartapp.const import LIFECYCLE_PING from pysmartapp.ping import PingRequest from .utilities import get_fixture class TestPingRequest: """Tests for the PingRequest class.""" @staticmethod def test_init(): """Tests the init method.""" # Arrange data = get_fixture('ping_request') # Act req = PingRequest(data) # Assert assert req.ping_data_raw == data['pingData'] assert req.lifecycle == LIFECYCLE_PING assert req.execution_id == data['executionId'] assert req.locale == data['locale'] assert req.version == data['version'] assert req.ping_challenge == '1a904d57-4fab-4b15-a11e-1c4bfe7cb502' andrewsayre-pysmartapp-4aa1ff9/tests/test_request.py000066400000000000000000000026021445426365100232060ustar00rootroot00000000000000"""Tests for the requests module.""" import pytest from pysmartapp.request import EmptyDataResponse, Request, Response from .utilities import get_fixture class TestResponse: """Tests for the Response class.""" @staticmethod def test_to_data(): """Tests the to_data method.""" resp = Response() with pytest.raises(NotImplementedError): resp.to_data() class TestEmptyDataResponse: """Tests for the EmptyDataResponse class.""" @staticmethod def test_init(): """Tests the init method.""" resp = EmptyDataResponse('tag') assert resp.name == 'tag' @staticmethod def test_to_data(): """Tests the to_data method.""" resp = EmptyDataResponse('tag') result = resp.to_data() assert result == {'tag': {}} @staticmethod def test_name(): """Tests the name setter.""" resp = EmptyDataResponse('tag') resp.name = 'tag2' assert resp.name == 'tag2' class TestRequest: """Tests for the Request class.""" @staticmethod @pytest.mark.asyncio async def test_process_raises(): """Tests the to_data method.""" # Arrange data = get_fixture('ping_request') resp = Request(data) # Act/Assert with pytest.raises(NotImplementedError): await resp.process(None, validate_signature=False) andrewsayre-pysmartapp-4aa1ff9/tests/test_smartapp.py000066400000000000000000000360261445426365100233540ustar00rootroot00000000000000"""Tests for the SmartApp file.""" import asyncio import pytest from pysmartapp.dispatch import Dispatcher from pysmartapp.errors import ( SignatureVerificationError, SmartAppNotRegisteredError) from pysmartapp.smartapp import SmartApp, SmartAppManager from .utilities import get_dispatch_handler, get_fixture INSTALLED_APP_ID = '8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121' APP_ID = 'f6c071aa-6ae7-463f-b0ad-8620ac23140f' class TestSmartApp: """Tests for the SmartApp class.""" @staticmethod def test_initialize(): """Tests the property initialization.""" # Arrange path = '/my/test/path' public_key = 'test' dispatcher = Dispatcher() # Act app = SmartApp(path=path, public_key=public_key, dispatcher=dispatcher) # Assert assert app.path == path assert app.public_key == public_key assert app.dispatcher == dispatcher assert not app.permissions assert app.config_app_id == 'app' @staticmethod def test_setters(): """Tests the property setters.""" # Arrange app = SmartApp() # Act app.app_id = "Test" app.config_app_id = "Test Config Id" app.description = "Description" app.name = "Name" # Assert assert app.app_id == "Test" assert app.config_app_id == "Test Config Id" assert app.description == "Description" assert app.name == "Name" @staticmethod @pytest.mark.asyncio async def test_ping(smartapp): """Tests the ping lifecycle event.""" # Arrange request = get_fixture("ping_request") expected_response = get_fixture("ping_response") handler = get_dispatch_handler(smartapp) smartapp.connect_ping(handler) # Act response = await smartapp.handle_request(request) # ensure dispatched tasks complete await asyncio.gather(*smartapp.dispatcher.last_sent) # Assert assert handler.fired assert response == expected_response @staticmethod @pytest.mark.asyncio async def test_config_init(smartapp): """Tests the configuration initialization lifecycle event.""" # Arrange request = get_fixture("config_init_request") expected_response = get_fixture("config_init_response") handler = get_dispatch_handler(smartapp) smartapp.connect_config(handler) # Act response = await smartapp.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*smartapp.dispatcher.last_sent) # Assert assert handler.fired assert response == expected_response @staticmethod @pytest.mark.asyncio async def test_config_page(smartapp): """Tests the configuration initialization page event.""" # Arrange request = get_fixture("config_page_request") expected_response = get_fixture("config_page_response") handler = get_dispatch_handler(smartapp) smartapp.connect_config(handler) # Act response = await smartapp.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*smartapp.dispatcher.last_sent) # Assert assert handler.fired assert response == expected_response @staticmethod @pytest.mark.asyncio async def test_install(smartapp): """Tests the install lifecycle event.""" # Arrange request = get_fixture("install_request") expected_response = get_fixture("install_response") handler = get_dispatch_handler(smartapp) smartapp.connect_install(handler) # Act response = await smartapp.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*smartapp.dispatcher.last_sent) # Assert assert handler.fired assert response == expected_response @staticmethod @pytest.mark.asyncio async def test_update(smartapp): """Tests the update lifecycle event.""" # Arrange request = get_fixture("update_request") expected_response = get_fixture("update_response") handler = get_dispatch_handler(smartapp) smartapp.connect_update(handler) # Act response = await smartapp.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*smartapp.dispatcher.last_sent) # Assert assert handler.fired assert response == expected_response @staticmethod @pytest.mark.asyncio async def test_event(smartapp): """Tests the event lifecycle event.""" # Arrange request = get_fixture("event_request") expected_response = get_fixture("event_response") handler = get_dispatch_handler(smartapp) smartapp.connect_event(handler) # Act response = await smartapp.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*smartapp.dispatcher.last_sent) # Assert assert handler.fired assert response == expected_response @staticmethod @pytest.mark.asyncio async def test_oauth_callback(smartapp): """Tests the oauth_callback lifecycle event.""" # Arrange request = get_fixture("oauth_callback_request") expected_response = get_fixture("oauth_callback_response") handler = get_dispatch_handler(smartapp) smartapp.connect_oauth_callback(handler) # Act response = await smartapp.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*smartapp.dispatcher.last_sent) # Assert assert handler.fired assert response == expected_response @staticmethod @pytest.mark.asyncio async def test_uninstall(smartapp): """Tests the uninstall lifecycle event.""" # Arrange request = get_fixture("uninstall_request") expected_response = get_fixture("uninstall_response") handler = get_dispatch_handler(smartapp) smartapp.connect_uninstall(handler) # Act response = await smartapp.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*smartapp.dispatcher.last_sent) # Assert assert handler.fired assert response == expected_response @staticmethod @pytest.mark.asyncio async def test_handle_request_sig_verification(): """Tests handle_request with sig verification.""" # Arrange public_key = get_fixture('public_key', 'pem') data = get_fixture('config_init_sig_pass_request') smartapp = SmartApp(public_key=public_key) # Act resp = await smartapp.handle_request( data['body'], data['headers'], True) # Assert assert resp @staticmethod @pytest.mark.asyncio async def test_handle_request_sig_verification_missing_headers(): """Tests handle_request with sig verification.""" # Arrange public_key = get_fixture('public_key', 'pem') data = get_fixture('config_init_sig_pass_request') smartapp = SmartApp(public_key=public_key) # Act/Assert with pytest.raises(SignatureVerificationError): await smartapp.handle_request(data['body'], [], True) @staticmethod @pytest.mark.asyncio async def test_handle_request_sig_verification_fails(): """Tests handle_request with sig verification.""" # Arrange public_key = get_fixture('public_key', 'pem') data = get_fixture('config_init_sig_fail_request') smartapp = SmartApp(public_key=public_key) # Act/Assert with pytest.raises(SignatureVerificationError): await smartapp.handle_request(data['body'], data['headers'], True) class TestSmartAppManager: """Tests for the SmartAppManager class.""" @staticmethod @pytest.mark.asyncio async def test_handle_request_ping_not_registered(manager): """Tests the ping lifecycle event with no registered apps.""" # Arrange request = get_fixture("ping_request") expected_response = get_fixture("ping_response") handler = get_dispatch_handler(manager) manager.connect_ping(handler) # Act response = await manager.handle_request(request) # ensure dispatched tasks complete await asyncio.gather(*manager.dispatcher.last_sent) # Assert assert handler.fired assert response == expected_response @staticmethod @pytest.mark.asyncio async def test_handle_request_not_registered(manager: SmartAppManager): """Tests processing a request when no SmartApp has been registered.""" # Arrange request = get_fixture("config_init_request") # Act with pytest.raises(SmartAppNotRegisteredError) as e_info: await manager.handle_request(request, None, False) # Assert assert e_info.value.installed_app_id == INSTALLED_APP_ID @staticmethod @pytest.mark.asyncio async def test_handle_request_not_app_id(manager: SmartAppManager): """Tests processing a request when no SmartApp has been registered.""" # Arrange request = get_fixture("config_init_sig_fail_request")['body'] # Act with pytest.raises(SmartAppNotRegisteredError) as e_info: await manager.handle_request(request, None, False) # Assert assert e_info.value.installed_app_id == INSTALLED_APP_ID @staticmethod def test_register(manager: SmartAppManager): """Test register.""" public_key = '123' # Act app = manager.register(APP_ID, public_key) # Assert assert app.app_id == APP_ID assert app.public_key == public_key assert app.path == manager.path assert APP_ID in manager.smartapps @staticmethod def test_register_no_app_id(manager: SmartAppManager): """Test register with no SmartApp app id.""" # Act with pytest.raises(ValueError) as e_info: manager.register(None, '') # Assert assert str(e_info.value) == 'smartapp must have an app_id.' @staticmethod def test_register_twice(manager: SmartAppManager): """Test register with the same app twice.""" # Arrange public_key = '123' manager.register(APP_ID, public_key) # Act with pytest.raises(ValueError) as e_info: manager.register(APP_ID, public_key) # Assert assert str(e_info.value) == 'smartapp already registered.' @staticmethod def test_unregister(manager: SmartAppManager): """Test unregister.""" # Arrange' manager.register(APP_ID, '123') # Act manager.unregister(APP_ID) # Assert assert APP_ID not in manager.smartapps @staticmethod def test_unregister_no_app_id(manager: SmartAppManager): """Test unregister with no SmartApp app id.""" # Act with pytest.raises(ValueError) as e_info: manager.unregister(None) # Assert assert str(e_info.value) == 'smartapp must have an app_id.' @staticmethod def test_unregister_not_registered(manager: SmartAppManager): """Test register with the same app twice.""" # Act with pytest.raises(ValueError) as e_info: manager.unregister(APP_ID) # Assert assert str(e_info.value) == 'smartapp was not previously registered.' @staticmethod @pytest.mark.asyncio async def test_on_config(manager: SmartAppManager): """Tests the config event handler at the manager level.""" # Arrange request = get_fixture("config_init_request") app = manager.register(APP_ID, 'none') handler = get_dispatch_handler(app) manager.connect_config(handler) # Act await manager.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*manager.dispatcher.last_sent) # Assert assert handler.fired @staticmethod @pytest.mark.asyncio async def test_on_install(manager: SmartAppManager): """Tests the config event handler at the manager level.""" # Arrange request = get_fixture("install_request") app = manager.register(APP_ID, 'none') handler = get_dispatch_handler(app) manager.connect_install(handler) # Act await manager.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*manager.dispatcher.last_sent) # Assert assert handler.fired @staticmethod @pytest.mark.asyncio async def test_on_update(manager: SmartAppManager): """Tests the config event handler at the manager level.""" # Arrange request = get_fixture("update_request") app = manager.register(APP_ID, 'none') handler = get_dispatch_handler(app) manager.connect_update(handler) # Act await manager.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*manager.dispatcher.last_sent) # Assert assert handler.fired @staticmethod @pytest.mark.asyncio async def test_on_event(manager: SmartAppManager): """Tests the config event handler at the manager level.""" # Arrange request = get_fixture("event_request") app = manager.register(APP_ID, 'none') handler = get_dispatch_handler(app) manager.connect_event(handler) # Act await manager.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*manager.dispatcher.last_sent) # Assert assert handler.fired @staticmethod @pytest.mark.asyncio async def test_on_oauth_callback(manager: SmartAppManager): """Tests the config event handler at the manager level.""" # Arrange request = get_fixture("oauth_callback_request") app = manager.register(APP_ID, 'none') handler = get_dispatch_handler(app) manager.connect_oauth_callback(handler) # Act await manager.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*manager.dispatcher.last_sent) # Assert assert handler.fired @staticmethod @pytest.mark.asyncio async def test_on_uninstall(manager: SmartAppManager): """Tests the config event handler at the manager level.""" # Arrange request = get_fixture("uninstall_request") app = manager.register(APP_ID, 'none') handler = get_dispatch_handler(app) manager.connect_uninstall(handler) # Act await manager.handle_request(request, None, False) # ensure dispatched tasks complete await asyncio.gather(*manager.dispatcher.last_sent) # Assert assert handler.fired andrewsayre-pysmartapp-4aa1ff9/tests/test_uninstall.py000066400000000000000000000020031445426365100235220ustar00rootroot00000000000000"""Tests for the uninstall module.""" from pysmartapp.const import LIFECYCLE_UNINSTALL from pysmartapp.uninstall import UninstallRequest from .utilities import get_fixture class TestUninstallRequest: """Tests for the UninstallRequest class.""" @staticmethod def test_init(): """Tests the init method.""" # Arrange data = get_fixture('uninstall_request') # Act req = UninstallRequest(data) # Assert assert req.uninstall_data_raw == data['uninstallData'] assert req.lifecycle == LIFECYCLE_UNINSTALL assert req.execution_id == data['executionId'] assert req.locale == data['locale'] assert req.version == data['version'] assert req.installed_app_id == '8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121' assert req.location_id == 'e675a3d9-2499-406c-86dc-8a492a886494' assert req.installed_app_config == \ data['uninstallData']['installedApp']['config'] assert req.settings == data['settings'] andrewsayre-pysmartapp-4aa1ff9/tests/test_update.py000066400000000000000000000021621445426365100230010ustar00rootroot00000000000000"""Tests for the update module.""" from pysmartapp.const import LIFECYCLE_UPDATE from pysmartapp.update import UpdateRequest from .utilities import get_fixture class TestUpdateRequest: """Tests for the UpdateRequest class.""" @staticmethod def test_init(): """Tests the init method.""" # Arrange data = get_fixture('update_request') # Act req = UpdateRequest(data) # Assert assert req.update_data_raw == data['updateData'] assert req.lifecycle == LIFECYCLE_UPDATE assert req.execution_id == data['executionId'] assert req.locale == data['locale'] assert req.version == data['version'] assert req.installed_app_id == '8a0dcdc9-1ab4-4c60-9de7-cb78f59a1121' assert req.location_id == 'e675a3d9-2499-406c-86dc-8a492a886494' assert req.installed_app_config == \ data['updateData']['installedApp']['config'] assert req.settings == data['settings'] assert req.auth_token == '4ebd8d9f-53b0-483f-a989-4bde30ca83c0' assert req.refresh_token == '6e3bbf5f-b68d-4250-bbc9-f7151016a77f' andrewsayre-pysmartapp-4aa1ff9/tests/test_utilities.py000066400000000000000000000004651445426365100235360ustar00rootroot00000000000000"""Tests for the utilities module.""" import pytest from pysmartapp import utilities def test_create_request_invalid(): """Tests the create_request method.""" # Arrange data = {'lifecycle': 'UNKNOWN'} # Act/Assert with pytest.raises(ValueError): utilities.create_request(data) andrewsayre-pysmartapp-4aa1ff9/tests/utilities.py000066400000000000000000000010511445426365100224670ustar00rootroot00000000000000"""Testing utilities.""" import json def get_fixture(file: str, ext: str = 'json'): """Load a fixtures file.""" file_name = F"tests/fixtures/{file}.{ext}" with open(file_name, encoding="utf-8") as open_file: if ext == 'json': return json.load(open_file) return open_file.read() def get_dispatch_handler(smartapp): """Get a handler to mock in the dispatcher.""" async def handler(req, resp, app): handler.fired = True assert app == smartapp handler.fired = False return handler