diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5ad9857..8e18dc7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,9 +17,6 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest - python: 3.7 - toxenv: py - os: ubuntu-latest python: 3.8 toxenv: py @@ -32,6 +29,9 @@ jobs: - os: ubuntu-latest python: '3.11' toxenv: py + - os: ubuntu-latest + python: '3.12' + toxenv: py runs-on: ${{ matrix.os }} outputs: version: ${{ steps.package-version.outputs.VALUE }} @@ -118,13 +118,6 @@ jobs: permissions: contents: read packages: write - # Publishing Docker images only happens when a release published out of the - # main branch - if: >- - github.event_name == 'release' - && github.event.action == 'published' - && (github.event.release.target_commitish == 'main' - || github.event.release.target_commitish == 'master') steps: - name: Checkout the code uses: actions/checkout@v3 @@ -150,8 +143,5 @@ jobs: push: true tags: "ghcr.io/${{ github.repository_owner }}\ - /${{ github.event.repository.name }}:latest - - ghcr.io/${{ github.repository_owner }}\ /${{ github.event.repository.name }}\ :${{ needs.tests.outputs.version }}" diff --git a/pyproject.toml b/pyproject.toml index 3a9c617..2060d90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ markers = [ [tool.pylint.main] load-plugins = "pylint.extensions.no_self_use" +# Newer `pylint` raises errors on using `dict()`, ignore those for now +disable = "use-dict-literal" [tool.pylint.typecheck] signature-mutators = [ diff --git a/setup.cfg b/setup.cfg index 0e53d7f..88e486e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,27 +17,25 @@ classifiers = Topic :: System :: Hardware License :: OSI Approved :: MIT License Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 Programming Language :: Python :: 3 :: Only [options] package_dir = = src packages = find: -python_requires = >=3.7 +python_requires = >=3.8 install_requires = iec62056-21 @ git+https://github.com/hostcc/iec62056-21.git@feature/transport-improvements addict==2.4.0 - asyncio-mqtt==0.16.2 - # Pin `paho-mqtt` to 1.x until migrating to `aiomqtt`, to prevent - # https://github.com/sbtinstruments/aiomqtt/issues/289 from occuring - paho-mqtt==1.6.1 + aiomqtt==2.1.0 pyyaml==6.0.1 - schema==0.7.5 - python-dateutil==2.8.2 + schema==0.7.7 + python-dateutil==2.9.0 [options.packages.find] where = src diff --git a/src/energomera_hass_mqtt/hass_mqtt.py b/src/energomera_hass_mqtt/hass_mqtt.py index 33f89e7..137306c 100644 --- a/src/energomera_hass_mqtt/hass_mqtt.py +++ b/src/energomera_hass_mqtt/hass_mqtt.py @@ -166,8 +166,7 @@ async def iec_read_admin(self): # connecting to the broker await self.set_online_sensor(False, setup_only=True) # The connection to MQTT broker is instantiated only once, if not - # connected previously. See `MqttClient.connect()` for more - # details + # connected previously. await self._mqtt_client.connect() # Process parameters requested for param in self._config.of.parameters: diff --git a/src/energomera_hass_mqtt/mqtt_client.py b/src/energomera_hass_mqtt/mqtt_client.py index f794b77..81f53b8 100644 --- a/src/energomera_hass_mqtt/mqtt_client.py +++ b/src/energomera_hass_mqtt/mqtt_client.py @@ -19,23 +19,17 @@ # SOFTWARE. """ -The package provides additional functionality over `asyncio_mqtt`. +The package provides additional functionality over `aiomqtt`. """ import logging -import asyncio_mqtt +import aiomqtt _LOGGER = logging.getLogger(__name__) -class MqttClient(asyncio_mqtt.Client): +class MqttClient(aiomqtt.Client): """ - Class attribute allowing to provide MQTT keepalive down to MQTT client. - Used only by tests at the moment, thus no interface controlling the - attribute is provided. - """ - _keepalive = None - """ - The class extends the `asyncio_mqtt.Client` to provide better convenience + The class extends the `aiomqtt.Client` to provide better convenience working with last wills. Namely, the original class only allows setting the last will thru its constructor, while the `EnergomeraHassMqtt` consuming it only has required property for that around actual publish calls. @@ -45,39 +39,35 @@ class MqttClient(asyncio_mqtt.Client): """ def __init__(self, *args, **kwargs): self._will_set = 'will' in kwargs - # Pass keepalive option down to parent class if set - if self._keepalive: - kwargs['keepalive'] = self._keepalive super().__init__(logger=_LOGGER, *args, **kwargs) - async def connect(self, *args, **kwargs): + async def connect(self): """ - Connects to MQTT broker. - Multiple calls will result only in single call to `connect()` method of - parent class if the MQTT client needs a connection (not being connected - or got disconnected), to allow the method to be called within a process - loop with no risk of constantly reinitializing MQTT broker connection. - - :param args: Pass-through positional arguments for parent class - :param kwargs: Pass-through keyword arguments for parent class - + Connects to MQTT broker using `__aenter__` method of the base class as + recommended in + https://github.com/sbtinstruments/aiomqtt/blob/main/docs/migration-guide-v2.md. + Multiple calls will result only in single call to `__aenter__()` method + of parent class if the MQTT client needs a connection (not being + connected or got disconnected), to allow the method to be called within + a process loop with no risk of hitting non-reentrant error from base + class. """ - # Using combination of `self._connected` and `self._disconnected` (both - # inherited from `asyncio_mqtt.client`) to detect of MQTT client needs - # a reconnection upon a network error isn't reliable - the former isn't - # finished even after a disconnect, while the latter stays with - # exception after a successfull reconnect. Neither - # `self._client.is_connected` (from Paho client) is - is returns True - # if socket is disconnected due to network error. Only testing for - # `self._client.socket()` (from Paho client as well) fits the purpose - - # None indicates the client needs `connect` - if self._client.socket(): + if self._lock.locked(): _LOGGER.debug( 'MQTT client is already connected, skipping subsequent attempt' ) return - await super().connect(*args, *kwargs) + # pylint:disable=unnecessary-dunder-call + await self.__aenter__() + + async def disconnect(self): + """ + Disconnects from MQTT broker using `__aexit__` method of the base class + as per migration guide above. + """ + # pylint:disable=unnecessary-dunder-call + await self.__aexit__(None, None, None) def will_set(self, *args, **kwargs): """ diff --git a/tests/conftest.py b/tests/conftest.py index 36a3cc1..d00fa22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,12 +10,7 @@ import pytest import iec62056_21.transports from energomera_hass_mqtt.mqtt_client import MqttClient -try: - from unittest.mock import AsyncMock -except ImportError: - # AsyncMock introduced in Python 3.8, import from alternative package if - # older - from mock import AsyncMock +from unittest.mock import AsyncMock SERIAL_EXCHANGE_COMPLETE = [ @@ -1046,11 +1041,6 @@ def mock_mqtt(): ''' # Mock the calls we interested in with patch.multiple(MqttClient, - publish=DEFAULT, connect=DEFAULT, + publish=DEFAULT, connect=DEFAULT, will_set=DEFAULT, new_callable=AsyncMock) as mocks: - # Python 3.7 can't properly distinguish between regular and async calls - # using `MagicMock` or `AsyncMock` respectively, so patch the regular - # method separately - with patch.object(MqttClient, 'will_set') as will_mock: - mocks.update({'will_set': will_mock}) - yield mocks + yield mocks diff --git a/tox.ini b/tox.ini index f3b7ab1..f329f14 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{37,38,39,310,311} +envlist = py{38,39,310,311,312} # Define the minimal tox version required to run; # if the host tox is less than this the tool with create an environment and @@ -15,15 +15,12 @@ isolated_build = true [testenv] deps = check-manifest==0.49 - flake8==5.0.4;python_version=="3.7" - flake8==6.0.0;python_version>"3.7" - pylint==2.15.9 - pytest==7.2.0 - pytest-asyncio==0.20.3 - pytest-cov==4.0.0 - mock==4.0.3;python_version<"3.8" - freezegun==1.2.2 - docker==6.1.3 + flake8==7.0.0 + pylint==3.2.0 + pytest==8.2.0 + pytest-asyncio==0.23.6 + pytest-cov==5.0.0 + freezegun==1.5.1 setenv = # Ensure the module under test will be found under `src/` directory, in # case of any test command below will attempt importing it. In particular,