From 6d28c7b3e8d86ee6364ba5102f6597635b6159e4 Mon Sep 17 00:00:00 2001 From: Jevgeni Kiski Date: Sun, 28 Jul 2024 22:07:40 +0300 Subject: [PATCH 1/2] Get rid of async loops, rewrite tests to pytest, get rid of setup.py, precommit, tox, test coverage, workflows --- .github/workflows/codeql-analysis.yml | 71 +++++++++ .github/workflows/coverage.yml | 35 +++++ .github/workflows/python-package.yml | 62 -------- .github/workflows/python-publish.yml | 43 ++++++ .github/workflows/test.yml | 40 +++++ .gitignore | 2 + .pre-commit-config.yaml | 59 ++++++++ nextion/__init__.py | 10 +- nextion/client.py | 74 +++++---- nextion/protocol/base.py | 1 - nextion/py.typed | 0 pyproject.toml | 24 +++ setup.cfg | 65 +++++++- setup.py | 45 ------ tests/test_client.py | 207 ++++++++++++++------------ tests/test_connection.py | 127 ++++++++++------ tests/test_protocol.py | 68 ++++----- tox.ini | 17 +++ 18 files changed, 635 insertions(+), 315 deletions(-) create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/coverage.yml delete mode 100644 .github/workflows/python-package.yml create mode 100644 .github/workflows/python-publish.yml create mode 100644 .github/workflows/test.yml create mode 100644 .pre-commit-config.yaml create mode 100644 nextion/py.typed create mode 100644 pyproject.toml delete mode 100644 setup.py create mode 100644 tox.ini diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..742daee --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,71 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '35 10 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..6293172 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,35 @@ +name: Coverage + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pytest pytest-cov pytest-asyncio + - name: Coverage + run: | + python -m pip install . + pytest tests/ --cov=nextion --cov-report=xml + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml deleted file mode 100644 index c6b5ceb..0000000 --- a/.github/workflows/python-package.yml +++ /dev/null @@ -1,62 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: Python package - -on: - push: - branches: [ master ] - tags: [ '*' ] - pull_request: - branches: [ master ] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.6", "3.7.7", "3.8", "3.9", "3.10"] - - steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pytest - python -m pip install . - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Python Unit Test - run: | - python3 setup.py test - deploy: - needs: build - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..a85eb49 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,43 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Publish release to PyPi + +on: + release: + types: [published] + +jobs: + test: + name: Test + uses: ./.github/workflows/test.yml + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - name: Check versions match + run: | + PUBLISHING_VERSION=${{ github.event.release.tag_name }} + grep -qE '__version__ = "'$PUBLISHING_VERSION'"' nextion/__init__.py + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1.5 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..69e8315 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Test and Lint + +on: + workflow_call: + workflow_dispatch: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade flake8 tox + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test + run: tox diff --git a/.gitignore b/.gitignore index 388f0d1..2e20d98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ __pycache__ /venv /.idea +.tox +*.egg-info diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..644125d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,59 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + args: ["--fix=lf"] + - id: requirements-txt-fixer + - id: trailing-whitespace + +- repo: https://github.com/asottile/pyupgrade + rev: v3.17.0 + hooks: + - id: pyupgrade + args: ["--py36-plus"] + +- repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + args: + - --safe + - --quiet + +- repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + +- repo: https://github.com/PyCQA/flake8 + rev: 7.1.0 + hooks: + - id: flake8 + additional_dependencies: [flake8-bugbear] + +- repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + +- repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: python-check-blanket-noqa + - id: python-check-blanket-type-ignore + - id: python-no-eval + - id: python-no-log-warn + - id: python-use-type-annotations + - id: python-check-mock-methods + - id: rst-backticks + - id: rst-directive-colons + - id: rst-inline-touching-normal diff --git a/nextion/__init__.py b/nextion/__init__.py index a99f010..4aaf68e 100644 --- a/nextion/__init__.py +++ b/nextion/__init__.py @@ -1,4 +1,12 @@ from .client import EventType, Nextion from .exceptions import CommandFailed, CommandTimeout, ConnectionFailed -__all__ = ["Nextion", "CommandFailed", "CommandTimeout", "ConnectionFailed", "EventType"] +__version__ = "2.0.0" + +__all__ = [ + "Nextion", + "CommandFailed", + "CommandTimeout", + "ConnectionFailed", + "EventType", +] diff --git a/nextion/client.py b/nextion/client.py index 0d3d4a5..d2726f0 100644 --- a/nextion/client.py +++ b/nextion/client.py @@ -1,13 +1,13 @@ import asyncio import binascii +from collections import namedtuple +from io import BufferedReader import logging import os import struct import typing -from collections import namedtuple -from io import BufferedReader -import serial_asyncio +import serial_asyncio_fast as serial_asyncio from .constants import BAUDRATES, IO_TIMEOUT from .exceptions import CommandFailed, CommandTimeout, ConnectionFailed @@ -15,29 +15,30 @@ logger = logging.getLogger("nextion").getChild(__name__) -TouchDataPayload = namedtuple("Touch", "page_id component_id touch_event") -TouchCoordinateDataPayload = namedtuple("TouchCoordinate", "x y touch_event") +TouchDataPayload = namedtuple("TouchDataPayload", "page_id component_id touch_event") +TouchCoordinateDataPayload = namedtuple("TouchCoordinateDataPayload", "x y touch_event") + + +async def default_event_handler(type_, data): + logger.info(f"Event {type_} data: {str(data)}") class Nextion: def __init__( self, url: str, - baudrate: int = None, - event_handler: typing.Callable[[EventType, any], typing.Union[typing.Awaitable[None], None]] = None, - loop=asyncio.get_event_loop(), + baudrate: typing.Optional[int] = None, + event_handler: typing.Callable[ + [EventType, typing.Any], typing.Union[typing.Awaitable[None], None] + ] = default_event_handler, reconnect_attempts: int = 3, encoding: str = "ascii", ): - self._loop = loop - self._url = url self._baudrate = baudrate - self._connection = None + self._connection: typing.Optional[NextionProtocol] = None self._command_lock = asyncio.Lock() - self.event_handler = event_handler or ( - lambda t, d: logger.info("Event %s data: %s" % (t, str(d))) - ) + self.event_handler = event_handler self.reconnect_attempts = reconnect_attempts self.encoding = encoding @@ -50,7 +51,7 @@ async def on_startup(self): async def on_wakeup(self): logger.debug('Updating variables after wakeup: "%s"', str(self.sets_todo)) for k, v in self.sets_todo.items(): - self._loop.create_task(self.set(k, v)) + asyncio.create_task(self.set(k, v)) self.sets_todo = {} self._sleeping = False @@ -77,10 +78,10 @@ def event_message_handler(self, message): self._sleeping = True self._schedule_event_message_handler(EventType(typ), None) elif typ == EventType.AUTO_WAKE: # Device automatically wake up - self._loop.create_task(self.on_wakeup()) + asyncio.create_task(self.on_wakeup()) self._schedule_event_message_handler(EventType(typ), None) elif typ == EventType.STARTUP: # System successful start up - self._loop.create_task(self.on_startup()) + asyncio.create_task(self.on_startup()) self._schedule_event_message_handler(EventType(typ), None) elif typ == EventType.SD_CARD_UPGRADE: # Start SD card upgrade self._schedule_event_message_handler(EventType(typ), None) @@ -88,10 +89,14 @@ def event_message_handler(self, message): logger.warning("Other event: 0x%02x", typ) def _schedule_event_message_handler(self, type_, data): - if asyncio.iscoroutinefunction(self.event_handler): - self._loop.create_task(self.event_handler(type_, data)) - else: - self._loop.call_soon(self.event_handler, type_, data) + asyncio.create_task(self._call_event_handler(type_, data)) + + async def _call_event_handler(self, type_, data): + result = self.event_handler(type_, data) + if not asyncio.iscoroutine(result): + return result + + return await result def _make_protocol(self) -> NextionProtocol: return NextionProtocol(event_message_handler=self.event_message_handler) @@ -151,7 +156,7 @@ async def _attempt_connect_messages(self, delay_between_connect_attempts): async def _create_serial_connection(self, baud): _, self._connection = await serial_asyncio.create_serial_connection( - self._loop, self._make_protocol, url=self._url, baudrate=baud + asyncio.get_event_loop(), self._make_protocol, url=self._url, baudrate=baud ) async def connect(self) -> None: @@ -170,7 +175,7 @@ async def connect(self) -> None: try: await self._command("bkcmd=3", attempts=1) - except CommandTimeout as e: + except CommandTimeout: pass # it is fine await self._update_sleep_status() @@ -179,7 +184,7 @@ async def connect(self) -> None: except ConnectionFailed: logger.exception("Connection failed") raise - except: + except Exception: logger.exception("Unexpected exception during connect") raise @@ -215,9 +220,13 @@ async def reconnect(self): await self.connect() async def disconnect(self) -> None: - await self._connection.close() + if self._connection: + await self._connection.close() async def read_packet(self, timeout=IO_TIMEOUT) -> bytes: + if not self._connection: + raise ConnectionFailed("Connection is not established") + return await asyncio.wait_for(self._connection.read(), timeout=timeout) async def get(self, key, timeout=IO_TIMEOUT): @@ -242,7 +251,7 @@ async def set(self, key, value, timeout=IO_TIMEOUT): ) self.sets_todo[key] = value else: - return await self.command("%s=%s" % (key, out_value), timeout=timeout) + return await self.command(f"{key}={out_value}", timeout=timeout) async def _command(self, command: str, timeout=IO_TIMEOUT, attempts=None): assert attempts is None or attempts > 0 @@ -272,7 +281,7 @@ async def _command(self, command: str, timeout=IO_TIMEOUT, attempts=None): while not finished: try: response = await self.read_packet(timeout=timeout) - except asyncio.TimeoutError as e: + except asyncio.TimeoutError: logger.error('Command "%s" timeout.', command) last_exception = CommandTimeout( 'Command "%s" response was not received' % command @@ -307,7 +316,7 @@ async def _command(self, command: str, timeout=IO_TIMEOUT, attempts=None): ) if command.partition(" ")[0] in ["get", "sendme"]: finished = True - else: # this will run if loop ended successfully + else: # this will run if while loop ended successfully return data if data is not None else result if last_exception is not None: @@ -371,12 +380,15 @@ async def upload_firmware(self, file: BufferedReader, upload_baud=None): logger.info("Reconnecting at new baud rate: %d" % (upload_baud)) await self._connection.close() _, self._connection = await serial_asyncio.create_serial_connection( - self._loop, self._make_upload_protocol, url=self._url, baudrate=upload_baud + asyncio.get_event_loop(), + self._make_upload_protocol, + url=self._url, + baudrate=upload_baud, ) res = await self.read_packet(timeout=1) if res != b"\x05": - raise IOError( + raise OSError( "Wrong response to upload command: %s" % binascii.hexlify(res) ) @@ -393,7 +405,7 @@ async def upload_firmware(self, file: BufferedReader, upload_baud=None): timeout = len(buf) * 12 / self._baudrate + 1 res = await self.read_packet(timeout=timeout) if res != b"\x05": - raise IOError( + raise OSError( "Wrong response while uploading chunk: %s" % binascii.hexlify(res) ) diff --git a/nextion/protocol/base.py b/nextion/protocol/base.py index c18d511..9b5446f 100644 --- a/nextion/protocol/base.py +++ b/nextion/protocol/base.py @@ -45,6 +45,5 @@ def connection_lost(self, exc): logger.error("Connection lost") if not self.connect_future.done(): self.connect_future.set_result(False) - # self.connect_future = asyncio.get_event_loop().create_future() if not self.disconnect_future.done(): self.disconnect_future.set_result(True) diff --git a/nextion/py.typed b/nextion/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..86e1e82 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=46.4.0"] +build-backend = "setuptools.build_meta" + +[tool.black] +target-version = ["py36", "py37", "py38", "py39", "py310"] +exclude = 'generated' + +[tool.isort] +# https://github.com/PyCQA/isort/wiki/isort-Settings +profile = "black" +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +known_first_party = [ + "nextion", + "tests", +] +forced_separate = [ + "tests", +] +combine_as_imports = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/setup.cfg b/setup.cfg index 4489b56..f3084c2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,65 @@ [metadata] -description-file=README.md \ No newline at end of file +name = nextion +version = attr: nextion.__version__ +author = Jevgeni Kiski +author_email = yozik04@gmail.com +description = Nextion display serial client +description-file = README.md +long_description = file: README.md, LICENSE +long_description_content_type = text/markdown +keywords = nextion serial async asyncio +url = https://github.com/yozik04/nextion +project_urls = + Bug Tracker = https://github.com/yozik04/nextion/issues +classifiers = + Development Status :: 5 - Production/Stable + Intended Audience :: Developers + License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3) + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 +license = LGPL 3 + +[options] +packages = find: +python_requires = >=3.6.0, <4 +install_requires = + pyserial-asyncio-fast +zip_safe = True +include_package_data = True +exclude = tests, tests.* + +[options.packages.find] +exclude = + tests* + +[options.package_data] +* = *.md +nextion = py.typed + +[options.entry_points] +console_scripts = + nextion-fw-upload = nextion.console_scripts.upload_firmware:main + +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build +max-complexity = 25 +doctests = True +# To work with Black +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +# E128 continuation line under-indented for visual indent +ignore = + E501, + W503, + E203, + D202, + W504, + E128 +noqa-require-code = True + +[coverage:report] +show_missing = true diff --git a/setup.py b/setup.py deleted file mode 100644 index fcb1874..0000000 --- a/setup.py +++ /dev/null @@ -1,45 +0,0 @@ -from os import path as p - -from setuptools import find_packages, setup - - -def read(filename, parent=None): - parent = parent or __file__ - - try: - with open(p.join(p.dirname(parent), filename)) as f: - return f.read() - except IOError: - return "" - - -setup( - name="nextion", - version="1.8.1", - packages=find_packages(exclude=["tests", "tests.*"]), - python_requires=">=3.6.0, <4", - license="LGPL 3", - author="Jevgeni Kiski", - author_email="yozik04@gmail.com", - description="Nextion display serial client", - long_description=read("README.md"), - long_description_content_type="text/markdown", - keywords="nextion serial async asyncio", - url="https://github.com/yozik04/nextion", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - ], - install_requires=["pyserial-asyncio"], - setup_requires=["wheel"], - tests_require=["asynctest"], - entry_points={ - "console_scripts": [ - "nextion-fw-upload = nextion.console_scripts.upload_firmware:main" - ] - }, -) diff --git a/tests/test_client.py b/tests/test_client.py index e55618a..066fe82 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,98 +1,115 @@ -import binascii -import typing -from abc import ABC, abstractmethod +import asyncio +from unittest.mock import AsyncMock, Mock -import asynctest +import pytest from nextion import Nextion -from tests.decorators import with_client - - -class AbstractTestClient(ABC): - @with_client - async def setUp(self, client: Nextion, protocol): - self.client = client - self.protocol = protocol - - @abstractmethod - async def _get_mocked_result( - self, - response_data, - action_fn: typing.Callable[[Nextion], typing.Any], - asset_command_called, - ): - pass - - async def test_get_numeric(self): - result = await self._get_mocked_result( - "7101000000", lambda client: client.get("sleep"), "get sleep" - ) - - assert result == 1 - - async def test_get_negative_numeric(self): - result = await self._get_mocked_result( - "71a5ffffff", lambda client: client.get("var1"), "get var1" - ) - - assert result == -91 - - async def test_get_string(self): - result = await self._get_mocked_result( - "703430", lambda client: client.get("t16.txt"), "get t16.txt" - ) - - assert result == "40" - - async def test_sendme_pageid(self): - result = await self._get_mocked_result( - "6605", lambda client: client.command("sendme"), "sendme" - ) - - print(result) - assert result == 5 - - async def test_set(self): - result = await self._get_mocked_result( - b"\x01", lambda client: client.set("sleep", 1), "sleep=1" - ) - - assert result is True - - -class TestClientAfter1_61_1(AbstractTestClient, asynctest.TestCase): - async def _get_mocked_result( - self, - response_data, - action_fn: typing.Callable[[Nextion], typing.Any], - asset_command_called, - ): - if isinstance(response_data, str): - response_data = binascii.unhexlify(response_data) - if isinstance(asset_command_called, str): - asset_command_called = asset_command_called.encode() - - self.protocol.read = asynctest.CoroutineMock(side_effect=[response_data]) - result = await action_fn(self.client) # self.client.get(variable) - self.protocol.write.assert_called_once_with(asset_command_called) - return result - - -class TestClientPrior1_61_1(AbstractTestClient, asynctest.TestCase): - async def _get_mocked_result( - self, - response_data, - action_fn: typing.Callable[[Nextion], typing.Any], - asset_command_called, - ): - if isinstance(response_data, str): - response_data = binascii.unhexlify(response_data) - if isinstance(asset_command_called, str): - asset_command_called = asset_command_called.encode() - - self.protocol.read = asynctest.CoroutineMock( - side_effect=[response_data, b"\01", b""] - ) - result = await action_fn(self.client) # self.client.get(variable) - self.protocol.write.assert_called_once_with(asset_command_called) - return result +from nextion.client import TouchDataPayload +from nextion.protocol.nextion import EventType, NextionProtocol + + +@pytest.fixture +async def transport() -> Mock: + return Mock() + + +@pytest.fixture +def event_handler(): + return Mock() + + +@pytest.fixture +async def protocol(transport) -> NextionProtocol: + protocol = NextionProtocol( + lambda x: None + ) # Assuming a lambda for the event_message_handler + protocol.connection_made(transport) + protocol.read = AsyncMock() + protocol.read_no_wait = Mock(side_effect=asyncio.QueueEmpty) + protocol.write = Mock() + + return protocol + + +@pytest.fixture +async def client(protocol: NextionProtocol, event_handler) -> Nextion: + client = Nextion("/dev/ttyS1", 9600, event_handler) + client._connection = protocol + protocol.event_message_handler = client.event_message_handler + return client + + +@pytest.mark.parametrize( + "response_data, expected_result, variable", + [ + ([b"\x71\x01\x00\x00\x00"], 1, "sleep"), + ([b"\x71\x01\x00\x00\x00", b"\01", b""], 1, "sleep"), + ([b"\x71\xa5\xff\xff\xff"], -91, "var1"), + ([b"\x71\xa5\xff\xff\xff", b"\01", b""], -91, "var1"), + ([b"\x70\x34\x30"], "40", "t16.txt"), + ([b"\x70\x34\x30", b"\01", b""], "40", "t16.txt"), + ], +) +async def test_get(client, protocol, response_data, expected_result, variable): + protocol.read.side_effect = response_data + result = await client.get(variable) + protocol.write.assert_called_once_with(f"get {variable}".encode()) + assert result == expected_result + + +@pytest.mark.parametrize( + "response_data, expected_result, command", + [ + (b"\x66\x05\xff\xff\xff", 5, "sendme"), + (b"\x66\x05\xff\xff\xff\01\xff\xff\xff\xff\xff\xff", 5, "sendme"), + ], +) +async def test_command(client, protocol, response_data, expected_result, command): + protocol.read.side_effect = [response_data] + assert await client.command(command) == expected_result + protocol.write.assert_called_once_with(command.encode()) + + +@pytest.mark.parametrize( + "response_data, variable, value", + [ + (b"\x01\xff\xff\xff", "sleep", 1), + (b"\x01\xff\xff\xff\01\xff\xff\xff\xff\xff\xff", "sleep", 1), + ], +) +async def test_set(client, protocol, response_data, variable, value): + protocol.data_received(response_data) + assert await client.set(variable, value) is True + protocol.write.assert_called_once_with(f"{variable}={value}".encode()) + + +async def test_event_handler(client, protocol, event_handler): + event_handler_called = asyncio.Future() + + def event_handler_called_set_result(*args): + event_handler_called.set_result(args) + + event_handler.side_effect = event_handler_called_set_result + + protocol.data_received(b"\x65\x01\x03\x01\xff\xff\xff") + await asyncio.wait_for(event_handler_called, timeout=0.1) + assert event_handler_called.result() == ( + EventType.TOUCH, + TouchDataPayload(page_id=1, component_id=3, touch_event=1), + ) + + +async def test_async_event_handler(client, protocol, event_handler): + event_handler_called = asyncio.Future() + + async def event_handler_called_set_result(*args): + event_handler_called.set_result(args) + + event_handler.side_effect = event_handler_called_set_result + + protocol.data_received(b"\x65\x01\x03\x01\xff\xff\xff") + await asyncio.wait_for(event_handler_called, timeout=0.1) + assert event_handler_called.result() == ( + EventType.TOUCH, + TouchDataPayload(page_id=1, component_id=3, touch_event=1), + ) diff --git a/tests/test_connection.py b/tests/test_connection.py index a3dd274..af5693e 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -1,63 +1,100 @@ import binascii import logging +from unittest.mock import Mock, patch -import asynctest +import pytest from nextion import Nextion from nextion.protocol import BasicProtocol -from tests.decorators import with_protocol logger = logging.getLogger("nextion").getChild(__name__) -class DummyNextionProtocol_1_61_1(BasicProtocol): +class BaseDummyNextionProtocol(BasicProtocol): + def __init__(self, responses, *args, **kwargs): + super().__init__(*args, **kwargs) + self.responses = responses + def write(self, data: bytes, eol=True): logger.debug("sent: %s" % (data)) - if data == b"DRAKJHSUYDGBNCJHGJKSHBDN": - self.data_received(b"\x1a") - elif data == b"connect": - self.data_received( - binascii.unhexlify( - "636f6d6f6b20312c36372d302c4e5834383237543034335f303131522c3133302c36313438382c453436383543423335423631333633362c3136373737323136" - ) - ) - elif data == b"bkcmd=3": - self.data_received(b"\x01") - elif data == b"get sleep": - self.data_received(b"\x71\x00\x00\x00\x00") + response = self.responses.get(data) + if response: + if isinstance(response, list): + for r in response: + self.data_received(r) + else: + self.data_received(response) else: - logger.error("write with no response(eol=%s): %s" % (eol, data)) + logger.error(f"write with no response(eol={eol}): {data}") -class DummyOldNextionProtocol(BasicProtocol): - def write(self, data: bytes, eol=True): - logger.debug("sent: %s" % (data)) - if data == b"DRAKJHSUYDGBNCJHGJKSHBDN": - self.data_received(b"\x1a") - elif data == b"connect": - self.data_received( - binascii.unhexlify( - "636f6d6f6b20312c36372d302c4e5834383237543034335f303131522c3133302c36313438382c453436383543423335423631333633362c3136373737323136" - ) - ) - elif data == b"bkcmd=3": - self.data_received(b"\x01") - elif data == b"thup=1": - self.data_received(b"\x01") - elif data == b"get sleep": - self.data_received(b"\x71\x00\x00\x00\x00") - self.data_received(b"\x01") - else: - logger.error("write with no response(eol=%s): %s" % (eol, data)) +class DummyNextionProtocol_1_61_1(BaseDummyNextionProtocol): + def __init__(self, *args, **kwargs): + responses = { + b"DRAKJHSUYDGBNCJHGJKSHBDN": b"\x1a", + b"connect": binascii.unhexlify( + "636f6d6f6b20312c343131332d302c4e5831303630503130315f303131522c3133322c31303530312c353531363334303142333939453535432c3133313037323030302d30" + ), + b"bkcmd=3": b"\x01", + b"get sleep": b"\x71\x00\x00\x00\x00", + } + super().__init__(responses, *args, **kwargs) + + +class DummyOldNextionProtocol(BaseDummyNextionProtocol): + def __init__(self, *args, **kwargs): + responses = { + b"DRAKJHSUYDGBNCJHGJKSHBDN": b"\x1a", + b"connect": binascii.unhexlify( + "636f6d6f6b20312c36372d302c4e5834383237543034335f303131522c3133302c36313438382c453436383543423335423631333633362c3136373737323136" + ), + b"bkcmd=3": b"\x01", + b"thup=1": b"\x01", + b"get sleep": [b"\x71\x00\x00\x00\x00", b"\x01"], + } + super().__init__(responses, *args, **kwargs) + + +@pytest.fixture +def transport(): + return Mock() + + +@pytest.fixture +def create_serial_connection(): + with patch( + "serial_asyncio_fast.create_serial_connection" + ) as create_serial_connection: + yield create_serial_connection + + +@pytest.fixture +def protocol_1_61_1(): + with patch( + "nextion.client.NextionProtocol", DummyNextionProtocol_1_61_1() + ) as protocol: + yield protocol + + +@pytest.fixture +def protocol_older(): + with patch("nextion.client.NextionProtocol", DummyOldNextionProtocol()) as protocol: + yield protocol + + +async def connect_and_test(client, protocol, transport, create_serial_connection): + async def on_connection_made(*args, **kwargs): + protocol.connection_made(transport) + return None, protocol + + create_serial_connection.side_effect = on_connection_made + await client.connect() + assert client.is_sleeping() is False -class TestClientConnection(asynctest.TestCase): - @with_protocol(protocol_class=DummyNextionProtocol_1_61_1) - async def test_connect_1_61_1_plus(self, client: Nextion, protocol): - await client.connect() - assert client.is_sleeping() is False - @with_protocol(protocol_class=DummyOldNextionProtocol) - async def test_connect_older_than_1_61_1(self, client: Nextion, protocol): - await client.connect() - assert client.is_sleeping() is False +@pytest.mark.parametrize("protocol_fixture", ["protocol_1_61_1", "protocol_older"]) +async def test_connect(protocol_fixture, request, transport, create_serial_connection): + protocol = request.getfixturevalue(protocol_fixture) + client = Nextion("/dev/ttyS1", 9600, None) + await connect_and_test(client, protocol, transport, create_serial_connection) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 86abfcd..1df0fd3 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1,44 +1,36 @@ -import typing -from unittest import TestCase from unittest.mock import MagicMock -from nextion.protocol import NextionProtocol - +import pytest -class TestClient(TestCase): - def assertPacketsParsed( - self, input_chunks: typing.List[bytes], expected_packets: typing.List[bytes] - ): - obj = NextionProtocol(MagicMock()) +from nextion.protocol import NextionProtocol - for chunk in input_chunks: - obj.data_received(chunk) - self.assertEqual(len(expected_packets), obj.queue.qsize()) +@pytest.fixture +def protocol(): + return NextionProtocol(MagicMock()) - for expected_packet in expected_packets: - self.assertEqual(expected_packet, obj.read_no_wait()) - def test_one_chunk_data_received(self): - self.assertPacketsParsed( +@pytest.mark.parametrize( + "input_chunks, expected_packets", + [ + ( [b"\x70\x31\x32\xff\xff\xff\x01\xff\xff\xff\xff\xff\xff"], [b"\x70\x31\x32", b"\x01", b""], - ) - - def test_chunked_data_received(self): - self.assertPacketsParsed( - [b"\x70\x31\x32\xff\xff", b"\xff\x01\xff", b"\xff\xff", b"\xff\xff\xff"], + ), + ( + [ + b"\x70\x31\x32\xff\xff", + b"\xff\x01\xff", + b"\xff\xff", + b"\xff\xff\xff", + ], [b"\x70\x31\x32", b"\x01", b""], - ) - - def test_negative_integer_data_received(self): - self.assertPacketsParsed( + ), + ( [b"\x71\xa5\xff\xff\xff\xff\xff\xff\x01\xff\xff\xff\xff\xff\xff"], [b"\x71\xa5\xff\xff\xff", b"\x01", b""], - ) - - def test_negative_integer_chunked_data_received(self): - self.assertPacketsParsed( + ), + ( [ b"\x71\xa5\xff", b"\xff\xff\xff", @@ -47,10 +39,18 @@ def test_negative_integer_chunked_data_received(self): b"\xff\xff\xff\xff", ], [b"\x71\xa5\xff\xff\xff", b"\x01", b""], - ) - - def test_junk_data_received(self): - self.assertPacketsParsed( + ), + ( [b"\x71\xff\xff\xff\x71\xa5\xff\xff\xff\xff\xff\xff"], [b"\x71\xa5\xff\xff\xff"], - ) + ), + ], +) +def test_data_received(protocol, input_chunks, expected_packets): + for chunk in input_chunks: + protocol.data_received(chunk) + + assert len(expected_packets) == protocol.queue.qsize() + + for expected_packet in expected_packets: + assert expected_packet == protocol.read_no_wait() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..1fc8978 --- /dev/null +++ b/tox.ini @@ -0,0 +1,17 @@ +[tox] +envlist = + py36 + py37 + py38 + py39 + py310 + py311 +isolated_build = True +skip_missing_interpreters = True + +[testenv] +changedir = tests +deps = + pytest + pytest-asyncio +commands = pytest --basetemp="{envtmpdir}" {posargs} From 0e83d231488bc5f899c1c1dc95be64ac89911c24 Mon Sep 17 00:00:00 2001 From: Jevgeni Kiski Date: Sun, 28 Jul 2024 22:13:12 +0300 Subject: [PATCH 2/2] Python 3.8 is minimal --- .github/workflows/coverage.yml | 2 +- .github/workflows/test.yml | 2 +- README.md | 26 +++++++++++++------------- setup.cfg | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6293172..e4992a0 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11"] + python-version: ["3.12"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69e8315..140ab37 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 3a00300..1d90e8c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Nextion serial client [![Build](https://github.com/yozik04/nextion/actions/workflows/python-package.yml/badge.svg)](https://github.com/yozik04/nextion/actions/workflows/python-package.yml) -Lightweight Python 3.6+ async library to control Nextion displays. +Lightweight Python 3.8+ async library to control Nextion displays. ## Installation ### Pypi @@ -16,34 +16,34 @@ from nextion import Nextion, EventType class App: def __init__(self): self.client = Nextion('/dev/ttyS1', 9600, self.event_handler) - + # Note: async event_handler can be used only in versions 1.8.0+ (versions 1.8.0+ supports both sync and async versions) async def event_handler(self, type_, data): if type_ == EventType.STARTUP: print('We have booted up!') elif type_ == EventType.TOUCH: print('A button (id: %d) was touched on page %d' % (data.component_id, data.page_id)) - + logging.info('Event %s data: %s', type, str(data)) - + print(await self.client.get('field1.txt')) - + async def run(self): await self.client.connect() - + # await client.sleep() # await client.wakeup() - + # await client.command('sendxy=0') - + print(await self.client.get('sleep')) print(await self.client.get('field1.txt')) - + await self.client.set('field1.txt', "%.1f" % (random.randint(0, 1000) / 10)) await self.client.set('field2.txt', "%.1f" % (random.randint(0, 1000) / 10)) - + await self.client.set('field3.txt', random.randint(0, 100)) - + print('finished') if __name__ == '__main__': @@ -81,7 +81,7 @@ Get current set encoding (Not fetched from the device) ## Event handling -```event_handler``` method in the example above will be called on every event comming from the display. +```event_handler``` method in the example above will be called on every event coming from the display. | EventType | Data | Data attributes | |------------------|----------------------------|------------------------------------| @@ -101,7 +101,7 @@ If you installed the library you should have `nextion-fw-upload` command in your nextion-fw-upload -h ``` -Otherwise use next command in the root of the project: +Otherwise use next command in the root of the project: ```bash python -m nextion.console_scripts.upload_firmware -h ``` diff --git a/setup.cfg b/setup.cfg index f3084c2..63c4437 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,7 +22,7 @@ license = LGPL 3 [options] packages = find: -python_requires = >=3.6.0, <4 +python_requires = >=3.8.0, <4 install_requires = pyserial-asyncio-fast zip_safe = True