From 6a96a419759689852e59a38f8a0bad71a00311ee Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 3 Nov 2023 22:23:17 +0100 Subject: [PATCH 1/2] cleanup, use pyproject.toml --- .github/workflows/python-code-format.yml | 3 +- .gitignore | 1 + .pylintrc | 29 ----------- README.md | 19 +++++-- docs/code_guidelines.md | 5 +- docs/setup.md | 11 ----- examples/hello_integration.py | 14 ++++-- pyproject.toml | 63 ++++++++++++++++++------ requirements.txt | 12 +++-- setup.cfg | 2 + setup.py | 26 ---------- test-requirements.txt | 4 ++ tox.ini | 36 -------------- ucapi/__init__.py | 7 +++ 14 files changed, 101 insertions(+), 131 deletions(-) delete mode 100644 .pylintrc delete mode 100644 docs/setup.md create mode 100644 setup.cfg delete mode 100644 setup.py delete mode 100644 tox.ini diff --git a/.github/workflows/python-code-format.yml b/.github/workflows/python-code-format.yml index fcd7179..c71e7ad 100644 --- a/.github/workflows/python-code-format.yml +++ b/.github/workflows/python-code-format.yml @@ -8,9 +8,8 @@ on: - 'test-requirements.txt' - 'tests/**' - '.github/**/*.yml' - - '.pylintrc' - 'pyproject.toml' - - 'tox.ini' + - '*.cfg' pull_request: branches: [main] types: [opened, synchronize, reopened] diff --git a/.gitignore b/.gitignore index 592ae9d..133eaa3 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,4 @@ ENV/ .vscode/ .DS_Store +/ucapi/_version.py diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 8ae4749..0000000 --- a/.pylintrc +++ /dev/null @@ -1,29 +0,0 @@ -[FORMAT] - -# Maximum number of characters on a single line. -max-line-length=120 - -[MESSAGES CONTROL] - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then re-enable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" - -disable= - too-many-instance-attributes, - global-statement, - too-many-arguments, - too-few-public-methods, - fixme - -[STRING] - -# This flag controls whether inconsistent-quotes generates a warning when the -# character used as a quote delimiter is used inconsistently within a module. -check-quote-consistency=yes diff --git a/README.md b/README.md index 402b6f0..8a04d39 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ This library simplifies writing Python based integrations for the [Unfolded Circle Remote Two](https://www.unfoldedcircle.com/) by wrapping the [WebSocket Integration API](https://github.com/unfoldedcircle/core-api/tree/main/integration-api). -It's a pre-alpha release (in our eyes). Missing features will be added continuously. +It's an alpha release (in our eyes). Breaking changes are to be expected and missing features will be continuously added. Based on our [Node.js integration library](https://github.com/unfoldedcircle/integration-node-library). ❗️**Attention:** @@ -21,9 +21,22 @@ Requirements: ## Usage -See [examples directory](examples) for a minimal integration driver example. +Install build tools: +```shell +pip3 install build setuptools setuptools_scm +``` -More examples will be published. +Build: +```shell +python -m build +``` + +Local installation: +```shell +pip3 install --force-reinstall dist/ucapi-$VERSION-py3-none-any.whl +``` + +See [examples directory](examples) for a minimal integration driver example. More examples will be published. ### Environment Variables diff --git a/docs/code_guidelines.md b/docs/code_guidelines.md index bfe22b0..abbea37 100644 --- a/docs/code_guidelines.md +++ b/docs/code_guidelines.md @@ -11,9 +11,12 @@ following customization: Install all code linting tools: ```shell -pip3 install -r test-requirements.txt +pip3 install -r requirements.txt -r test-requirements.txt ``` +Note: once is implemented, the requirements files can be removed and +`pyproject.toml` is sufficient. + ### Linting ```shell diff --git a/docs/setup.md b/docs/setup.md deleted file mode 100644 index 4185fd4..0000000 --- a/docs/setup.md +++ /dev/null @@ -1,11 +0,0 @@ -# Development Setup - -This library requires Python 3.10 or newer. - -## Local installation - -```shell -python3 setup.py bdist_wheel -pip3 install --force-reinstall dist/ucapi-$VERSION-py3-none-any.whl -``` - diff --git a/examples/hello_integration.py b/examples/hello_integration.py index c51a8d9..8c514ee 100644 --- a/examples/hello_integration.py +++ b/examples/hello_integration.py @@ -26,9 +26,7 @@ async def cmd_handler(entity: ucapi.Button, cmd_id: str, _params: dict[str, Any] return ucapi.StatusCodes.OK -if __name__ == "__main__": - logging.basicConfig() - +async def main() -> None: button = ucapi.Button( "button1", "Push the button", @@ -36,8 +34,14 @@ async def cmd_handler(entity: ucapi.Button, cmd_id: str, _params: dict[str, Any] ) api.available_entities.add(button) + await api.init("hello_integration.json") + # We are ready all the time! Otherwise, use @api.listens_to(ucapi.Events.CONNECT) & DISCONNECT - api.set_device_state(ucapi.DeviceStates.CONNECTED) + await api.set_device_state(ucapi.DeviceStates.CONNECTED) + + +if __name__ == "__main__": + logging.basicConfig() - loop.run_until_complete(api.init("hello_integration.json")) + loop.run_until_complete(main()) loop.run_forever() diff --git a/pyproject.toml b/pyproject.toml index 032f95c..328d464 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,16 @@ -# TODO migrate from setup.py: this is work in progress [build-system] -requires = ["setuptools>=61.2"] +requires = ["setuptools", "setuptools-scm"] build-backend = "setuptools.build_meta" [project] name = "ucapi" -version = "0.1.0" authors = [ {name = "Unfolded Circle ApS", email = "hello@unfoldedcircle.com"} ] license = {text = "MPL-2.0"} description = "Python wrapper for the Unfolded Circle Integration API" classifiers = [ - "Development Status :: 2 - Pre-Alpha", + "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: MPL-2.0 License", "Operating System :: OS Independent", @@ -23,16 +21,24 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "asyncio==3.4.3", - "pyee==9.0.4", - "websockets==11.0.3", - "zeroconf==0.119.0", + "asyncio>=3.4", + "pyee>=9.0", + "websockets>=11.0", + "zeroconf~=0.119.0", ] +dynamic = ["version"] [project.readme] file = "README.md" content-type = "text/markdown; charset=UTF-8" +[project.urls] +"Homepage" = "https://www.unfoldedcircle.com/" +"Source Code" = "https://github.com/unfoldedcircle/integration-python-library" +"Bug Reports" = "https://github.com/unfoldedcircle/integration-python-library/issues" +"Discord" = "http://unfolded.chat/" +"Forum" = "http://unfolded.community/" + [project.optional-dependencies] testing = [ "pylint", @@ -43,15 +49,44 @@ testing = [ ] [tool.setuptools] -zip-safe = false +packages = ["ucapi"] platforms = ["any"] license-files = ["LICENSE"] -# TODO is this correct? Set to True in old setup.py -include-package-data = true -[tool.setuptools.packages.find] -exclude = ["tests"] -namespaces = false +[tool.setuptools_scm] +write_to = "ucapi/_version.py" [tool.isort] profile = "black" + +[tool.pylint.exceptions] +overgeneral-exceptions = ["builtins.BaseException", "builtins.Exception"] + +[tool.pylint.format] +max-line-length = "120" + +[tool.pylint.MASTER] +ignore-paths = [ + # ignore generated file + "ucapi/_version.py" +] + +[tool.pylint."messages control"] +# Reasons disabled: +# global-statement - not yet considered +# too-many-* - are not enforced for the sake of readability +# too-few-* - same as too-many-* +# fixme - refactoring in progress + +disable = [ + "global-statement", + "too-many-arguments", + "too-many-instance-attributes", + "too-few-public-methods", + "fixme" +] + +[tool.pylint.STRING] +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency = true diff --git a/requirements.txt b/requirements.txt index 3812b95..cbc4cd5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ -asyncio==3.4.3 -pyee==9.0.4 -websockets==11.0.3 -zeroconf==0.119.0 +# Keep in sync with pyproject.toml +# Waiting for: https://github.com/pypa/pip/issues/11440 +# Workaround: use a pre-commit hook with https://github.com/scikit-image/scikit-image/blob/main/tools/generate_requirements.py + +asyncio>=3.4 +pyee>=9.0 +websockets>=11.0 +zeroconf~=0.119.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6deafc2 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 120 diff --git a/setup.py b/setup.py deleted file mode 100644 index c60f49f..0000000 --- a/setup.py +++ /dev/null @@ -1,26 +0,0 @@ -# TODO remove and use pyproject.toml: https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html - -from setuptools import setup, find_packages - -from codecs import open -from os import path - -PACKAGE_NAME = "ucapi" -HERE = path.abspath(path.dirname(__file__)) -VERSION = "0.1.0" - -with open(path.join(HERE, "README.md"), encoding="utf-8") as f: - long_description = f.read() - -setup( - name=PACKAGE_NAME, - version=VERSION, - description="Python wrapper for the Unfolded Circle Integration API", - url="https://unfoldedcircle.com", - author="Unfolded Circle ApS", - author_email="hello@unfoldedcircle.com", - license="MPL-2.0", - packages=["ucapi"], - include_package_data=True, - install_requires=find_packages(), -) diff --git a/test-requirements.txt b/test-requirements.txt index 251bc19..b6a2cb1 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,3 +1,7 @@ +# Keep in sync with pyproject.toml +# Waiting for: https://github.com/pypa/pip/issues/11440 +# Workaround: use a pre-commit hook with https://github.com/scikit-image/scikit-image/blob/main/tools/generate_requirements.py + pylint flake8-docstrings flake8 diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 8692bcc..0000000 --- a/tox.ini +++ /dev/null @@ -1,36 +0,0 @@ -[flake8] -max_line_length = 120 - -[tox] -envlist = py310,py311,pylint,lint,format -skip_missing_interpreters = True - -[testenv:format] -basepython = python3.11 -deps = - -r{toxinidir}/test-requirements.txt -commands = - python -m isort ucapi/. --check --verbose - python -m black ucapi --check --verbose - -[testenv:pylint] -basepython = python3.11 -deps = - -r{toxinidir}/test-requirements.txt -commands=python -m pylint ucapi - -[testenv:lint] -basepython = python3.11 -deps = - -r{toxinidir}/test-requirements.txt -commands = - python -m flake8 ucapi -; python -m pydocstyle ucapi - -;[testenv] -;setenv = -; LANG=en_US.UTF-8 -; PYTHONPATH = {toxinidir} -;deps = -; -r{toxinidir}/test-requirements.txt -;commands=python -m pytest tests --timeout=30 --durations=10 --cov=denonavr --cov-report html {posargs} diff --git a/ucapi/__init__.py b/ucapi/__init__.py index 1742853..85e6158 100644 --- a/ucapi/__init__.py +++ b/ucapi/__init__.py @@ -38,4 +38,11 @@ from .sensor import Sensor # noqa: F401 from .switch import Switch # noqa: F401 +try: + from ._version import version as __version__ + from ._version import version_tuple +except ImportError: + __version__ = "unknown version" + version_tuple = (0, 0, "unknown version") + logging.getLogger(__name__).addHandler(logging.NullHandler()) From a9e8a277008d2729cf8414e4da131be2094bdc11 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Sat, 4 Nov 2023 01:36:52 +0100 Subject: [PATCH 2/2] docs: add setup-flow example --- examples/README.md | 35 +++++++ examples/hello_integration.py | 21 ++--- examples/setup_flow.json | 40 ++++++++ examples/setup_flow.py | 170 ++++++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 11 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/setup_flow.json create mode 100644 examples/setup_flow.py diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..c4b7ae8 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,35 @@ +# API wrapper examples + +This directory contains a few examples on how to use the Remote Two Integration-API wrapper. + +Each example uses a driver metadata definition file. It's a json file named after the example. +The most important fields are: + +- `driver_id`: unique identifier of the driver. Make sure you create a new ID for every driver. +- `port` defines the listening port of the WebSocket server for the Remote Two to connect to. + - This port is published in the mDNS service information. +- `name`: Friendly name of the driver to show. + +See the [WebSocket Integration API documentation](https://github.com/unfoldedcircle/core-api/tree/main/doc/integration-driver) + +## hello_integration + +The [hello_integration.py](hello_integration.py) example is a "hello world" example showing the bare minimum required +to start with an integration driver for the Remote Two. + +It defines a single push button with a callback handler. When pushed, it just prints a message in the console. + +## setup_flow + +The [setup_flow](setup_flow.py) example shows how to define a dynamic setup flow for the driver setup. + +If the user selects the _expert_ option in the main setup screen: +1. An input screen is shown asking to select an item from a dropdown list. +2. The chosen option will be shown in the next input screen with another setting, on how many button entities to create. +3. The number of push buttons are created. + +The available input settings are defined in the [Integration-API asyncapi.yaml definition](https://github.com/unfoldedcircle/core-api/tree/main/integration-api) +and are not yet available as typed Python objects. + +See `Setting` object definition and the referenced SettingTypeNumber, SettingTypeText, SettingTypeTextArea, +SettingTypePassword, SettingTypeCheckbox, SettingTypeDropdown, SettingTypeLabel. diff --git a/examples/hello_integration.py b/examples/hello_integration.py index 8c514ee..6c0ffa6 100644 --- a/examples/hello_integration.py +++ b/examples/hello_integration.py @@ -26,7 +26,15 @@ async def cmd_handler(entity: ucapi.Button, cmd_id: str, _params: dict[str, Any] return ucapi.StatusCodes.OK -async def main() -> None: +@api.listens_to(ucapi.Events.CONNECT) +async def on_connect() -> None: + """When the remote connects, we just set the device state. We are ready all the time!""" + await api.set_device_state(ucapi.DeviceStates.CONNECTED) + + +if __name__ == "__main__": + logging.basicConfig() + button = ucapi.Button( "button1", "Push the button", @@ -34,14 +42,5 @@ async def main() -> None: ) api.available_entities.add(button) - await api.init("hello_integration.json") - - # We are ready all the time! Otherwise, use @api.listens_to(ucapi.Events.CONNECT) & DISCONNECT - await api.set_device_state(ucapi.DeviceStates.CONNECTED) - - -if __name__ == "__main__": - logging.basicConfig() - - loop.run_until_complete(main()) + loop.run_until_complete(api.init("hello_integration.json")) loop.run_forever() diff --git a/examples/setup_flow.json b/examples/setup_flow.json new file mode 100644 index 0000000..a53da2f --- /dev/null +++ b/examples/setup_flow.json @@ -0,0 +1,40 @@ +{ + "driver_id": "setupflow_example", + "version": "0.0.1", + "min_core_api": "0.20.0", + "name": { "en": "Setup Flow Demo" }, + "icon": "uc:integration", + "description": { + "en": "Setup Flow Python integration driver example." + }, + "port": 9081, + "developer": { + "name": "Unfolded Circle ApS", + "email": "hello@unfoldedcircle.com", + "url": "https://www.unfoldedcircle.com" + }, + "home_page": "https://www.unfoldedcircle.com", + "setup_data_schema": { + "title": { + "en": "Example settings", + "de": "Beispiel Konfiguration", + "fr": "Exemple de configuration" + }, + "settings": [ + { + "id": "expert", + "label": { + "en": "Configure enhanced options", + "de": "Erweiterte Optionen konfigurieren", + "fr": "Configurer les options avancées" + }, + "field": { + "checkbox": { + "value": false + } + } + } + ] + }, + "release_date": "2023-11-03" +} diff --git a/examples/setup_flow.py b/examples/setup_flow.py new file mode 100644 index 0000000..d9fde2b --- /dev/null +++ b/examples/setup_flow.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Integration setup flow example.""" +import asyncio +import logging +from typing import Any + +import ucapi + +loop = asyncio.get_event_loop() +api = ucapi.IntegrationAPI(loop) + + +async def driver_setup_handler(msg: ucapi.SetupDriver) -> ucapi.SetupAction: + """ + Dispatch driver setup requests to corresponding handlers. + + Either start the setup process or handle the provided user input data. + + :param msg: the setup driver request object, either DriverSetupRequest or UserDataResponse + :return: the setup action on how to continue + """ + if isinstance(msg, ucapi.DriverSetupRequest): + return await handle_driver_setup(msg) + if isinstance(msg, ucapi.UserDataResponse): + return await handle_user_data_response(msg) + + # user confirmation not used in our demo setup process + # if isinstance(msg, UserConfirmationResponse): + # return handle_user_confirmation(msg) + + return ucapi.SetupError() + + +async def handle_driver_setup(msg: ucapi.DriverSetupRequest) -> ucapi.RequestUserInput | ucapi.SetupError: + """ + Start driver setup. + + Initiated by Remote Two to set up the driver. + + :param msg: not used, value(s) of input fields in the first setup screen. See setup_data_schema in driver metadata. + :return: the setup action on how to continue + """ + # for our demo we clear everything, a real driver might have to handle this differently + api.available_entities.clear() + api.configured_entities.clear() + + # check if user selected the expert option in the initial setup screen + # please note that all values are returned as strings! + if "expert" not in msg.setup_data or msg.setup_data["expert"] != "true": + return ucapi.SetupComplete() + + # Dropdown selections are usually set dynamically, e.g. with found devices etc. + dropdown_items = [ + {"id": "red", "label": {"en": "Red", "de": "Rot"}}, + {"id": "green", "label": {"en": "Green", "de": "Grün"}}, + {"id": "blue", "label": {"en": "Blue", "de": "Blau"}}, + ] + + return ucapi.RequestUserInput( + {"en": "Please choose", "de": "Bitte auswählen"}, + [ + { + "id": "info", + "label": {"en": "Setup flow example", "de": "Setup Flow Beispiel"}, + "field": { + "label": { + "value": { + "en": "This is just some informational text.\n" + + "Simple **Markdown** is supported!\n" + + "For example _some italic text_.\n" + + "## Or a header text\n~~strikethrough txt~~", + } + } + }, + }, + { + "field": {"dropdown": {"value": "", "items": dropdown_items}}, + "id": "step1.choice", + "label": { + "en": "Choose color", + "de": "Wähle Farbe", + }, + }, + ], + ) + + +async def handle_user_data_response(msg: ucapi.UserDataResponse) -> ucapi.SetupAction: + """ + Process user data response in a setup process. + + Driver setup callback to provide requested user data during the setup process. + + :param msg: response data from the requested user data + :return: the setup action on how to continue: SetupComplete if finished. + """ + # values from all screens are returned: check in reverse order + if "step2.count" in msg.input_values: + for x in range(int(msg.input_values["step2.count"])): + button = ucapi.Button( + f"button{x}", + f"Button {x + 1}", + cmd_handler=cmd_handler, + ) + api.available_entities.add(button) + + return ucapi.SetupComplete() + + if "step1.choice" in msg.input_values: + choice = msg.input_values["step1.choice"] + print(f"Chosen color: {choice}") + return ucapi.RequestUserInput( + {"en": "Step 2"}, + [ + { + "id": "info", + "label": { + "en": "Selected value from previous step:", + "de": "Selektierter Wert vom vorherigen Schritt:", + }, + "field": { + "label": { + "value": { + "en": choice, + } + } + }, + }, + { + "field": {"number": {"value": 1, "min": 1, "max": 100, "steps": 2}}, + "id": "step2.count", + "label": { + "en": "Button instance count", + "de": "Anzahl Button Instanzen", + }, + }, + ], + ) + + print("No choice was received") + return ucapi.SetupError() + + +async def cmd_handler(entity: ucapi.Button, cmd_id: str, _params: dict[str, Any] | None) -> ucapi.StatusCodes: + """ + Push button command handler. + + Called by the integration-API if a command is sent to a configured button-entity. + + :param entity: button entity + :param cmd_id: command + :param _params: optional command parameters + :return: status of the command + """ + print(f"Got {entity.id} command request: {cmd_id}") + + return ucapi.StatusCodes.OK + + +@api.listens_to(ucapi.Events.CONNECT) +async def on_connect() -> None: + """When the remote connects, we just set the device state. We are ready all the time!""" + await api.set_device_state(ucapi.DeviceStates.CONNECTED) + + +if __name__ == "__main__": + logging.basicConfig() + + loop.run_until_complete(api.init("setup_flow.json", driver_setup_handler)) + loop.run_forever()