diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b854707 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,377 @@ +name: CI + +# yamllint disable-line rule:truthy +on: + push: + pull_request: ~ + +env: + CACHE_VERSION: 1 + DEFAULT_PYTHON: 3.7 + PRE_COMMIT_HOME: ~/.cache/pre-commit + +jobs: + # Separate job to pre-populate the base dependency cache + # This prevent upcoming jobs to do the same individually + prepare-base: + name: Prepare base dependencies + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.7, 3.8, 3.9] + steps: + - name: Check out code from GitHub + uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + id: python + uses: actions/setup-python@v2.1.4 + with: + python-version: ${{ matrix.python-version }} + - name: Restore base Python virtual environment + id: cache-venv + uses: actions/cache@v2 + with: + path: venv + key: >- + ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ + steps.python.outputs.python-version }}-${{ + hashFiles('requirements_test.txt') }} + restore-keys: | + ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}- + - name: Create Python virtual environment + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + python -m venv venv + . venv/bin/activate + pip install -U pip setuptools pre-commit + pip install -r requirements_test.txt + pip install -e . + + pre-commit: + name: Prepare pre-commit environment + runs-on: ubuntu-latest + needs: prepare-base + steps: + - name: Check out code from GitHub + uses: actions/checkout@v2 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v2.1.4 + id: python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Restore base Python virtual environment + id: cache-venv + uses: actions/cache@v2 + with: + path: venv + key: >- + ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ + steps.python.outputs.python-version }}-${{ + hashFiles('requirements_test.txt') }} + - name: Fail job if Python cache restore failed + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + echo "Failed to restore Python virtual environment from cache" + exit 1 + - name: Restore pre-commit environment from cache + id: cache-precommit + uses: actions/cache@v2 + with: + path: ${{ env.PRE_COMMIT_HOME }} + key: | + ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: | + ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit- + - name: Install pre-commit dependencies + if: steps.cache-precommit.outputs.cache-hit != 'true' + run: | + . venv/bin/activate + pre-commit install-hooks + + lint-black: + name: Check black + runs-on: ubuntu-latest + needs: pre-commit + steps: + - name: Check out code from GitHub + uses: actions/checkout@v2 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v2.1.4 + id: python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Restore base Python virtual environment + id: cache-venv + uses: actions/cache@v2 + with: + path: venv + key: >- + ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ + steps.python.outputs.python-version }}-${{ + hashFiles('requirements_test.txt') }} + - name: Fail job if Python cache restore failed + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + echo "Failed to restore Python virtual environment from cache" + exit 1 + - name: Restore pre-commit environment from cache + id: cache-precommit + uses: actions/cache@v2 + with: + path: ${{ env.PRE_COMMIT_HOME }} + key: | + ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + - name: Fail job if cache restore failed + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + echo "Failed to restore Python virtual environment from cache" + exit 1 + - name: Run black + run: | + . venv/bin/activate + pre-commit run --hook-stage manual black --all-files --show-diff-on-failure + + lint-flake8: + name: Check flake8 + runs-on: ubuntu-latest + needs: pre-commit + steps: + - name: Check out code from GitHub + uses: actions/checkout@v2 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v2.1.4 + id: python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Restore base Python virtual environment + id: cache-venv + uses: actions/cache@v2 + with: + path: venv + key: >- + ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ + steps.python.outputs.python-version }}-${{ + hashFiles('requirements_test.txt') }} + - name: Fail job if Python cache restore failed + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + echo "Failed to restore Python virtual environment from cache" + exit 1 + - name: Restore pre-commit environment from cache + id: cache-precommit + uses: actions/cache@v2 + with: + path: ${{ env.PRE_COMMIT_HOME }} + key: | + ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + - name: Fail job if cache restore failed + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + echo "Failed to restore Python virtual environment from cache" + exit 1 + - name: Register flake8 problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/flake8.json" + - name: Run flake8 + run: | + . venv/bin/activate + pre-commit run --hook-stage manual flake8 --all-files + + lint-isort: + name: Check isort + runs-on: ubuntu-latest + needs: pre-commit + steps: + - name: Check out code from GitHub + uses: actions/checkout@v2 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v2.1.4 + id: python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Restore base Python virtual environment + id: cache-venv + uses: actions/cache@v2 + with: + path: venv + key: >- + ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ + steps.python.outputs.python-version }}-${{ + hashFiles('requirements_test.txt') }} + - name: Fail job if Python cache restore failed + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + echo "Failed to restore Python virtual environment from cache" + exit 1 + - name: Restore pre-commit environment from cache + id: cache-precommit + uses: actions/cache@v2 + with: + path: ${{ env.PRE_COMMIT_HOME }} + key: | + ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + - name: Fail job if cache restore failed + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + echo "Failed to restore Python virtual environment from cache" + exit 1 + - name: Run isort + run: | + . venv/bin/activate + pre-commit run --hook-stage manual isort --all-files --show-diff-on-failure + + lint-codespell: + name: Check codespell + runs-on: ubuntu-latest + needs: pre-commit + steps: + - name: Check out code from GitHub + uses: actions/checkout@v2 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v2.1.4 + id: python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Restore base Python virtual environment + id: cache-venv + uses: actions/cache@v2 + with: + path: venv + key: >- + ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ + steps.python.outputs.python-version }}-${{ + hashFiles('requirements_test.txt') }} + - name: Fail job if Python cache restore failed + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + echo "Failed to restore Python virtual environment from cache" + exit 1 + - name: Restore pre-commit environment from cache + id: cache-precommit + uses: actions/cache@v2 + with: + path: ${{ env.PRE_COMMIT_HOME }} + key: | + ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + - name: Fail job if cache restore failed + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + echo "Failed to restore Python virtual environment from cache" + exit 1 + - name: Register codespell problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/codespell.json" + - name: Run codespell + run: | + . venv/bin/activate + pre-commit run --hook-stage manual codespell --all-files --show-diff-on-failure + + pytest: + runs-on: ubuntu-latest + needs: prepare-base + strategy: + matrix: + python-version: [3.7, 3.8, 3.9] + name: >- + Run tests Python ${{ matrix.python-version }} + steps: + - name: Check out code from GitHub + uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2.1.4 + id: python + with: + python-version: ${{ matrix.python-version }} + - name: Restore base Python virtual environment + id: cache-venv + uses: actions/cache@v2 + with: + path: venv + key: >- + ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ + steps.python.outputs.python-version }}-${{ + hashFiles('requirements_test.txt') }} + - name: Fail job if Python cache restore failed + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + echo "Failed to restore Python virtual environment from cache" + exit 1 + - name: Register Python problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/python.json" + - name: Install Pytest Annotation plugin + run: | + . venv/bin/activate + # Ideally this should be part of our dependencies + # However this plugin is fairly new and doesn't run correctly + # on a non-GitHub environment. + pip install pytest-github-actions-annotate-failures + - name: Run pytest + run: | + . venv/bin/activate + pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + --cov zigpy_deconz \ + --cov-report=term-missing \ + -o console_output_style=count \ + -p no:sugar \ + tests + - name: Upload coverage artifact + uses: actions/upload-artifact@v2.2.0 + with: + name: coverage-${{ matrix.python-version }} + path: .coverage + - name: Coveralls + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_FLAG_NAME: ${{ matrix.python-version }} + COVERALLS_PARALLEL: true + run: | + . venv/bin/activate + coveralls + + + coverage: + name: Process test coverage + runs-on: ubuntu-latest + needs: pytest + steps: + - name: Check out code from GitHub + uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2.1.4 + id: python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Restore base Python virtual environment + id: cache-venv + uses: actions/cache@v2 + with: + path: venv + key: >- + ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ + steps.python.outputs.python-version }}-${{ + hashFiles('requirements_test.txt') }} + - name: Fail job if Python cache restore failed + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + echo "Failed to restore Python virtual environment from cache" + exit 1 + - name: Download all coverage artifacts + uses: actions/download-artifact@v2 + - name: Combine coverage results + run: | + . venv/bin/activate + coverage combine coverage*/.coverage* + coverage report --fail-under=97 + coverage xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1.0.14 + - name: Upload coverage to Coveralls + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + . venv/bin/activate + coveralls --finish diff --git a/.github/workflows/matchers/codespell.json b/.github/workflows/matchers/codespell.json new file mode 100644 index 0000000..cfa66d3 --- /dev/null +++ b/.github/workflows/matchers/codespell.json @@ -0,0 +1,16 @@ +{ + "problemMatcher": [ + { + "owner": "codespell", + "severity": "warning", + "pattern": [ + { + "regexp": "^(.+):(\\d+):\\s(.+)$", + "file": 1, + "line": 2, + "message": 3 + } + ] + } + ] +} diff --git a/.github/workflows/matchers/flake8.json b/.github/workflows/matchers/flake8.json new file mode 100644 index 0000000..e059a1c --- /dev/null +++ b/.github/workflows/matchers/flake8.json @@ -0,0 +1,30 @@ +{ + "problemMatcher": [ + { + "owner": "flake8-error", + "severity": "error", + "pattern": [ + { + "regexp": "^(.*):(\\d+):(\\d+):\\s([EF]\\d{3}\\s.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + ] + }, + { + "owner": "flake8-warning", + "severity": "warning", + "pattern": [ + { + "regexp": "^(.*):(\\d+):(\\d+):\\s([CDNW]\\d{3}\\s.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + ] + } + ] +} diff --git a/.github/workflows/matchers/python.json b/.github/workflows/matchers/python.json new file mode 100644 index 0000000..3e5d8d5 --- /dev/null +++ b/.github/workflows/matchers/python.json @@ -0,0 +1,18 @@ +{ + "problemMatcher": [ + { + "owner": "python", + "pattern": [ + { + "regexp": "^\\s*File\\s\\\"(.*)\\\",\\sline\\s(\\d+),\\sin\\s(.*)$", + "file": 1, + "line": 2 + }, + { + "regexp": "^\\s*raise\\s(.*)\\(\\'(.*)\\'\\)$", + "message": 2 + } + ] + } + ] +} diff --git a/.github/workflows/release-management.yml b/.github/workflows/release-management.yml index 83d8d98..fb48dba 100644 --- a/.github/workflows/release-management.yml +++ b/.github/workflows/release-management.yml @@ -4,6 +4,7 @@ on: push: # branches to consider in the event; optional, defaults to all branches: + - dev - master jobs: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c7c4399..6d34ff6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,14 +6,25 @@ repos: args: - --safe - --quiet + - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.3 + rev: 3.8.4 hooks: - id: flake8 additional_dependencies: - flake8-docstrings==1.5.0 - pydocstyle==5.1.1 + - repo: https://github.com/PyCQA/isort rev: 5.5.2 hooks: - id: isort + + - repo: https://github.com/codespell-project/codespell + rev: v1.17.1 + hooks: + - id: codespell + args: + - --ignore-words-list=ser,nd,hass + - --skip="./.*" + - --quiet-level=2 diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..3e7f591 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,8 @@ +# Test dependencies + +asynctest +coveralls +pytest +pytest-cov +pytest-asyncio +pytest-timeout diff --git a/setup.py b/setup.py index 8ea0514..23cfe50 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ author="Daniel Schmidt", author_email="schmidt.d@aon.at", license="GPL-3.0", - packages=find_packages(exclude=["*.tests"]), + packages=find_packages(exclude=["tests"]), install_requires=["pyserial-asyncio", "zigpy>=0.24.0"], tests_require=["pytest", "pytest-asyncio", "asynctest"], ) diff --git a/tests/test_api.py b/tests/test_api.py index b5dfe86..13ceab8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,6 +3,7 @@ import asyncio import binascii import logging +import sys import pytest import serial @@ -474,7 +475,6 @@ async def test_reconnect_multiple_disconnects(connect_mock, caplog): assert api._uart is sentinel.uart_reconnect assert connect_mock.call_count == 1 - assert "Cancelling reconnection attempt" in caplog.messages @patch("zigpy_deconz.uart.connect") @@ -526,7 +526,13 @@ async def test_probe_success(mock_connect, mock_device_state): @patch("zigpy_deconz.uart.connect", return_value=MagicMock(spec_set=uart.Gateway)) @pytest.mark.parametrize( "exception", - (asyncio.TimeoutError, serial.SerialException, zigpy_deconz.exception.CommandError), + ( + asyncio.TimeoutError, + serial.SerialException, + zigpy_deconz.exception.CommandError, + ) + if sys.version_info[:3] != (3, 7, 9) + else (asyncio.TimeoutError,), ) async def test_probe_fail(mock_connect, mock_device_state, exception): """Test device probing fails.""" diff --git a/tests/test_exception.py b/tests/test_exception.py index 559eab8..3181847 100644 --- a/tests/test_exception.py +++ b/tests/test_exception.py @@ -1,3 +1,5 @@ +"""Test exceptions.""" + from unittest import mock import zigpy_deconz.exception diff --git a/tests/test_types.py b/tests/test_types.py index 03482ed..f02d052 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,3 +1,5 @@ +"""Tests for zigpy_deconz.types module.""" + from unittest import mock import pytest diff --git a/tests/test_uart.py b/tests/test_uart.py index b509a1f..8167838 100644 --- a/tests/test_uart.py +++ b/tests/test_uart.py @@ -1,3 +1,5 @@ +"""Tests for the uart module.""" + from unittest import mock import pytest diff --git a/tox.ini b/tox.ini index 1a6af8d..36ae502 100644 --- a/tox.ini +++ b/tox.ini @@ -11,27 +11,20 @@ skip_missing_interpreters = True setenv = PYTHONPATH = {toxinidir} install_command = pip install {opts} {packages} commands = py.test --cov --cov-report= -deps = - asynctest - coveralls - pytest - pytest-cov - pytest-asyncio +deps = -rrequirements_test.txt [testenv:lint] basepython = python3 -deps = - flake8==3.8.3 - isort==5.5.2 +deps = pre-commit commands = - flake8 - isort --check {toxinidir}/zigpy_deconz {toxinidir}/tests {toxinidir}/setup.py + pre-commit run --hook-stage manual flake8 --all-files + pre-commit run --hook-stage manual isort --all-files --show-diff-on-failure + pre-commit run --hook-stage manual codespell --all-files [testenv:black] -deps = - black==20.8b1 +deps = pre-commit setenv = LC_ALL=C.UTF-8 LANG=C.UTF-8 commands= - black --check --fast --diff {toxinidir}/zigpy_deconz {toxinidir}/tests {toxinidir}/setup.py + pre-commit run --hook-stage manual black --all-files --show-diff-on-failure diff --git a/zigpy_deconz/__init__.py b/zigpy_deconz/__init__.py index 1f1b828..34cbef0 100644 --- a/zigpy_deconz/__init__.py +++ b/zigpy_deconz/__init__.py @@ -1,6 +1,8 @@ +"""Init file for zigpy_deconz.""" + # coding: utf-8 MAJOR_VERSION = 0 MINOR_VERSION = 11 -PATCH_VERSION = "0" +PATCH_VERSION = "1" __short_version__ = "{}.{}".format(MAJOR_VERSION, MINOR_VERSION) __version__ = "{}.{}".format(__short_version__, PATCH_VERSION) diff --git a/zigpy_deconz/config.py b/zigpy_deconz/config.py index f1727f6..b010785 100644 --- a/zigpy_deconz/config.py +++ b/zigpy_deconz/config.py @@ -1,3 +1,5 @@ +"""Default configuration values.""" + import voluptuous as vol from zigpy.config import ( # noqa: F401 pylint: disable=unused-import CONF_DEVICE, diff --git a/zigpy_deconz/exception.py b/zigpy_deconz/exception.py index c2b0cf5..db2d18d 100644 --- a/zigpy_deconz/exception.py +++ b/zigpy_deconz/exception.py @@ -1,8 +1,11 @@ +"""Zigpy-deconz exceptions.""" + from zigpy.exceptions import APIException class CommandError(APIException): def __init__(self, status=1, *args, **kwargs): + """Initialize instance.""" self._status = status super().__init__(*args, **kwargs) diff --git a/zigpy_deconz/types.py b/zigpy_deconz/types.py index 46351f1..bb8931a 100644 --- a/zigpy_deconz/types.py +++ b/zigpy_deconz/types.py @@ -1,3 +1,5 @@ +"""Data types module.""" + import enum @@ -128,6 +130,8 @@ class Struct: _fields = [] def __init__(self, *args, **kwargs): + """Initialize instance.""" + if len(args) == 1 and isinstance(args[0], self.__class__): # copy constructor for field in self._fields: @@ -143,6 +147,7 @@ def serialize(self): @classmethod def deserialize(cls, data): + """Deserialize data.""" r = cls() for field_name, field_type in cls._fields: v, data = field_type.deserialize(data) @@ -150,6 +155,7 @@ def deserialize(cls, data): return r, data def __repr__(self): + """Instance representation.""" r = "<%s " % (self.__class__.__name__,) r += " ".join( ["%s=%s" % (f[0], getattr(self, f[0], None)) for f in self._fields] @@ -195,17 +201,21 @@ class EUI64(FixedList): _itemtype = uint8_t def __repr__(self): + """Instance representation.""" return ":".join("%02x" % i for i in self[::-1]) def __hash__(self): + """Hash magic method.""" return hash(repr(self)) class HexRepr: def __repr__(self): + """Instance representation.""" return ("0x{:0" + str(self._size * 2) + "x}").format(self) def __str__(self): + """Instance str method.""" return ("0x{:0" + str(self._size * 2) + "x}").format(self) diff --git a/zigpy_deconz/uart.py b/zigpy_deconz/uart.py index 09d5646..e421b1f 100644 --- a/zigpy_deconz/uart.py +++ b/zigpy_deconz/uart.py @@ -1,3 +1,5 @@ +"""Uart module.""" + import asyncio import binascii import logging @@ -20,13 +22,16 @@ class Gateway(asyncio.Protocol): ESC_ESC = b"\xDD" def __init__(self, api, connected_future=None): + """Initialize instance of the UART gateway.""" + self._api = api self._buffer = b"" self._connected_future = connected_future self._transport = None def connection_lost(self, exc) -> None: - """Port was closed expecteddly or unexpectedly.""" + """Port was closed expectedly or unexpectedly.""" + if self._connected_future and not self._connected_future.done(): if exc is None: self._connected_future.set_result(True) @@ -40,7 +45,8 @@ def connection_lost(self, exc) -> None: self._api.connection_lost(exc) def connection_made(self, transport): - """Callback when the uart is connected""" + """Call this when the uart connection is established.""" + LOGGER.debug("Connection made") self._transport = transport if self._connected_future: @@ -50,14 +56,14 @@ def close(self): self._transport.close() def send(self, data): - """Send data, taking care of escaping and framing""" + """Send data, taking care of escaping and framing.""" LOGGER.debug("Send: 0x%s", binascii.hexlify(data).decode()) checksum = bytes(self._checksum(data)) frame = self._escape(data + checksum) self._transport.write(self.END + frame + self.END) def data_received(self, data): - """Callback when there is data received from the uart""" + """Handle data received from the uart.""" self._buffer += data while self._buffer: end = self._buffer.find(self.END) diff --git a/zigpy_deconz/zigbee/__init__.py b/zigpy_deconz/zigbee/__init__.py index e69de29..ba91e22 100644 --- a/zigpy_deconz/zigbee/__init__.py +++ b/zigpy_deconz/zigbee/__init__.py @@ -0,0 +1 @@ +"""ApplicationController implementation."""