From 25f1d66a24d3627dbca138398f4a71369d723296 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Sun, 25 Jan 2026 15:39:23 +0100 Subject: [PATCH 1/7] refactor(tests): use explicit assertions following Python idioms - Use `is None` / `is not None` for explicit None checks (PEP 8) - Use `pytest.raises(TypeError, lambda: expr)` for exception tests - Keep `assert collection` for empty/non-empty checks (Pythonic) --- tests/test_client.py | 14 +++++++------- tests/test_models.py | 28 +++++++++++----------------- tests/test_utils.py | 4 ++-- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 7b844ff0..08aa2d47 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -194,9 +194,9 @@ async def test_get_setup( assert len(setup.gateways) == gateway_count for device in setup.devices: - assert device.gateway_id - assert device.device_address - assert device.protocol + assert device.gateway_id is not None + assert device.device_address is not None + assert device.protocol is not None @pytest.mark.parametrize( "fixture_name", @@ -238,7 +238,7 @@ async def test_get_diagnostic_data(self, client: OverkizClient, fixture_name: st with patch.object(aiohttp.ClientSession, "get", return_value=resp): diagnostics = await client.get_diagnostic_data() - assert diagnostics + assert diagnostics is not None @pytest.mark.parametrize( "fixture_name, exception, status_code", @@ -446,16 +446,16 @@ async def test_get_scenarios( assert len(scenarios) == scenario_count for scenario in scenarios: - assert scenario.oid + assert scenario.oid is not None assert scenario.label is not None assert scenario.actions for action in scenario.actions: - assert action.device_url + assert action.device_url is not None assert action.commands for command in action.commands: - assert command.name + assert command.name is not None class MockResponse: diff --git a/tests/test_models.py b/tests/test_models.py index 19ef17a4..03d343fc 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -201,7 +201,7 @@ def test_none_states(self): hump_device = humps.decamelize(RAW_DEVICES) del hump_device["states"] device = Device(**hump_device) - assert not device.states.get(STATE) + assert device.states.get(STATE) is None class TestStates: @@ -211,19 +211,19 @@ def test_empty_states(self): """An empty list yields an empty States object with no state found.""" states = States([]) assert not states - assert not states.get(STATE) + assert states.get(STATE) is None def test_none_states(self): """A None value for states should behave as empty.""" states = States(None) assert not states - assert not states.get(STATE) + assert states.get(STATE) is None def test_getter(self): """Retrieve a known state and validate its properties.""" states = States(RAW_STATES) state = states.get(STATE) - assert state + assert state is not None assert state.name == STATE assert state.type == DataType.STRING assert state.value == "alarm name" @@ -232,7 +232,7 @@ def test_getter_missing(self): """Requesting a missing state returns falsy (None).""" states = States(RAW_STATES) state = states.get("FooState") - assert not state + assert state is None class TestState: @@ -246,8 +246,7 @@ def test_int_value(self): def test_bad_int_value(self): """Accessor raises TypeError if the state type mismatches expected int.""" state = State(name="state", type=DataType.BOOLEAN, value=False) - with pytest.raises(TypeError): - assert state.value_as_int + pytest.raises(TypeError, lambda: state.value_as_int) def test_float_value(self): """Float typed state returns proper float accessor.""" @@ -257,8 +256,7 @@ def test_float_value(self): def test_bad_float_value(self): """Accessor raises TypeError if the state type mismatches expected float.""" state = State(name="state", type=DataType.BOOLEAN, value=False) - with pytest.raises(TypeError): - assert state.value_as_float + pytest.raises(TypeError, lambda: state.value_as_float) def test_bool_value(self): """Boolean typed state returns proper boolean accessor.""" @@ -268,8 +266,7 @@ def test_bool_value(self): def test_bad_bool_value(self): """Accessor raises TypeError if the state type mismatches expected bool.""" state = State(name="state", type=DataType.INTEGER, value=1) - with pytest.raises(TypeError): - assert state.value_as_bool + pytest.raises(TypeError, lambda: state.value_as_bool) def test_str_value(self): """String typed state returns proper string accessor.""" @@ -279,8 +276,7 @@ def test_str_value(self): def test_bad_str_value(self): """Accessor raises TypeError if the state type mismatches expected string.""" state = State(name="state", type=DataType.BOOLEAN, value=False) - with pytest.raises(TypeError): - assert state.value_as_str + pytest.raises(TypeError, lambda: state.value_as_str) def test_dict_value(self): """JSON object typed state returns proper dict accessor.""" @@ -290,8 +286,7 @@ def test_dict_value(self): def test_bad_dict_value(self): """Accessor raises TypeError if the state type mismatches expected dict.""" state = State(name="state", type=DataType.BOOLEAN, value=False) - with pytest.raises(TypeError): - assert state.value_as_dict + pytest.raises(TypeError, lambda: state.value_as_dict) def test_list_value(self): """JSON array typed state returns proper list accessor.""" @@ -301,5 +296,4 @@ def test_list_value(self): def test_bad_list_value(self): """Accessor raises TypeError if the state type mismatches expected list.""" state = State(name="state", type=DataType.BOOLEAN, value=False) - with pytest.raises(TypeError): - assert state.value_as_list + pytest.raises(TypeError, lambda: state.value_as_list) diff --git a/tests/test_utils.py b/tests/test_utils.py index 22738e16..42be3ea2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -18,7 +18,7 @@ def test_generate_local_server(self): """Create a local server descriptor using the host and default values.""" local_server = generate_local_server(host=LOCAL_HOST) - assert local_server + assert local_server is not None assert ( local_server.endpoint == "https://gateway-1234-5678-1243.local:8443/enduser-mobile-web/1/enduserAPI/" @@ -36,7 +36,7 @@ def test_generate_local_server_by_ip(self): configuration_url="https://somfy.com", ) - assert local_server + assert local_server is not None assert ( local_server.endpoint == "https://192.168.1.105:8443/enduser-mobile-web/1/enduserAPI/" From 69871f67b7366c878fdffe38b7c48ad9d92a5256 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Sun, 25 Jan 2026 15:51:21 +0100 Subject: [PATCH 2/7] chore: add ruff per-file-ignores for test assertions --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 06d75443..1bfe80eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,9 @@ select = [ ] ignore = ["E501"] # Line too long +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] # Allow assert in tests + [tool.ruff.lint.pydocstyle] convention = "google" # Accepts: "google", "numpy", or "pep257". From 827e72d60209b0f304cd268c1d7d061dbe3e663e Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Sun, 25 Jan 2026 15:52:02 +0100 Subject: [PATCH 3/7] chore: remove redundant S101 noqa comments from tests --- tests/test_client.py | 3 +-- tests/test_enums.py | 3 --- tests/test_models.py | 3 --- tests/test_obfuscate.py | 3 --- tests/test_utils.py | 3 --- 5 files changed, 1 insertion(+), 14 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 08aa2d47..f65ae5d0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,6 @@ """Unit tests for the high-level OverkizClient behaviour and responses.""" -# ruff: noqa: S101, ASYNC230 -# S101: Tests use assert statements +# ruff: noqa: ASYNC230 # ASYNC230: Blocking open() is acceptable for reading test fixtures from __future__ import annotations diff --git a/tests/test_enums.py b/tests/test_enums.py index b693231a..79abfd8b 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -1,8 +1,5 @@ """Tests for enum helper behaviour and expected values.""" -# ruff: noqa: S101 -# Tests use assert statements - from pyoverkiz.enums import ( EventName, ExecutionSubType, diff --git a/tests/test_models.py b/tests/test_models.py index 03d343fc..5848cd1d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,8 +1,5 @@ """Unit tests for models (Device, State and States helpers).""" -# ruff: noqa: S101 -# Tests use assert statements - from __future__ import annotations import humps diff --git a/tests/test_obfuscate.py b/tests/test_obfuscate.py index 41063946..ec129460 100644 --- a/tests/test_obfuscate.py +++ b/tests/test_obfuscate.py @@ -1,8 +1,5 @@ """Tests for the obfuscation utilities used in fixtures and logging.""" -# ruff: noqa: S101 -# Tests use assert statements - import pytest from pyoverkiz.obfuscate import obfuscate_email, obfuscate_sensitive_data diff --git a/tests/test_utils.py b/tests/test_utils.py index 42be3ea2..114d8454 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,8 +1,5 @@ """Tests for utility helper functions like server generation and gateway checks.""" -# ruff: noqa: S101 -# Tests use assert statements - import pytest from pyoverkiz.utils import generate_local_server, is_overkiz_gateway From 09422512447d6adaf11fc76899c00d7ef816b023 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Sun, 25 Jan 2026 15:53:28 +0100 Subject: [PATCH 4/7] refactor(tests): replace os.path with pathlib.Path --- tests/test_client.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index f65ae5d0..9ebdc7d0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,7 +6,7 @@ from __future__ import annotations import json -import os +from pathlib import Path from unittest.mock import patch import aiohttp @@ -20,7 +20,7 @@ from pyoverkiz.models import Option from pyoverkiz.utils import generate_local_server -CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +CURRENT_DIR = Path(__file__).parent class TestOverkizClient: @@ -53,9 +53,7 @@ async def test_get_api_type_local(self, local_client: OverkizClient): @pytest.mark.asyncio async def test_get_devices_basic(self, client: OverkizClient): """Ensure the client can fetch and parse the basic devices fixture.""" - with open( - os.path.join(CURRENT_DIR, "devices.json"), encoding="utf-8" - ) as raw_devices: + with open(CURRENT_DIR / "devices.json", encoding="utf-8") as raw_devices: resp = MockResponse(raw_devices.read()) with patch.object(aiohttp.ClientSession, "get", return_value=resp): @@ -75,7 +73,7 @@ async def test_fetch_events_basic( ): """Parameterised test that fetches events fixture and checks the expected count.""" with open( - os.path.join(CURRENT_DIR, "fixtures/event/" + fixture_name), + CURRENT_DIR / "fixtures" / "event" / fixture_name, encoding="utf-8", ) as raw_events: resp = MockResponse(raw_events.read()) @@ -88,7 +86,7 @@ async def test_fetch_events_basic( async def test_fetch_events_simple_cast(self, client: OverkizClient): """Check that event state values from the cloud (strings) are cast to appropriate types.""" with open( - os.path.join(CURRENT_DIR, "fixtures/event/events.json"), encoding="utf-8" + CURRENT_DIR / "fixtures" / "event" / "events.json", encoding="utf-8" ) as raw_events: resp = MockResponse(raw_events.read()) @@ -112,7 +110,7 @@ async def test_fetch_events_simple_cast(self, client: OverkizClient): async def test_fetch_events_casting(self, client: OverkizClient, fixture_name: str): """Validate that fetched event states are cast to the expected Python types for each data type.""" with open( - os.path.join(CURRENT_DIR, "fixtures/event/" + fixture_name), + CURRENT_DIR / "fixtures" / "event" / fixture_name, encoding="utf-8", ) as raw_events: resp = MockResponse(raw_events.read()) @@ -181,7 +179,7 @@ async def test_get_setup( ): """Ensure setup parsing yields expected device and gateway counts and device metadata.""" with open( - os.path.join(CURRENT_DIR, "fixtures/setup/" + fixture_name), + CURRENT_DIR / "fixtures" / "setup" / fixture_name, encoding="utf-8", ) as setup_mock: resp = MockResponse(setup_mock.read()) @@ -230,7 +228,7 @@ async def test_get_setup( async def test_get_diagnostic_data(self, client: OverkizClient, fixture_name: str): """Verify that diagnostic data can be fetched and is not empty.""" with open( - os.path.join(CURRENT_DIR, "fixtures/setup/" + fixture_name), + CURRENT_DIR / "fixtures" / "setup" / fixture_name, encoding="utf-8", ) as setup_mock: resp = MockResponse(setup_mock.read()) @@ -354,7 +352,7 @@ async def test_check_response_exception_handling( with pytest.raises(exception): if fixture_name: with open( - os.path.join(CURRENT_DIR, "fixtures/exceptions/" + fixture_name), + CURRENT_DIR / "fixtures" / "exceptions" / fixture_name, encoding="utf-8", ) as raw_events: resp = MockResponse(raw_events.read(), status_code) @@ -370,7 +368,7 @@ async def test_get_setup_options( ): """Check that setup options are parsed and return the expected number of Option instances.""" with open( - os.path.join(CURRENT_DIR, "fixtures/endpoints/setup-options.json"), + CURRENT_DIR / "fixtures" / "endpoints" / "setup-options.json", encoding="utf-8", ) as raw_events: resp = MockResponse(raw_events.read()) @@ -403,7 +401,7 @@ async def test_get_setup_option( ): """Verify retrieval of a single setup option by name, including non-existent options.""" with open( - os.path.join(CURRENT_DIR, "fixtures/endpoints/" + fixture_name), + CURRENT_DIR / "fixtures" / "endpoints" / fixture_name, encoding="utf-8", ) as raw_events: resp = MockResponse(raw_events.read()) @@ -434,7 +432,7 @@ async def test_get_scenarios( ): """Ensure action groups (scenarios) are parsed correctly and contain actions and commands.""" with open( - os.path.join(CURRENT_DIR, "fixtures/action_groups/" + fixture_name), + CURRENT_DIR / "fixtures" / "action_groups" / fixture_name, encoding="utf-8", ) as action_group_mock: resp = MockResponse(action_group_mock.read()) From 19d8a5745a450697e842b8d278d17e8f3a11ebf9 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Sun, 25 Jan 2026 15:56:07 +0100 Subject: [PATCH 5/7] refactor(tests): extract shared fixtures to conftest.py --- tests/conftest.py | 64 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_client.py | 46 +------------------------------ 2 files changed, 65 insertions(+), 45 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..3a7c70fb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,64 @@ +"""Shared pytest fixtures for the test suite.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest +import pytest_asyncio + +from pyoverkiz.client import OverkizClient +from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.utils import generate_local_server + +if TYPE_CHECKING: + from collections.abc import Generator + + +class MockResponse: + """Simple stand-in for aiohttp responses used in tests.""" + + def __init__(self, text: str | None, status: int = 200, url: str = "") -> None: + """Create a mock response with text payload and optional status/url.""" + self._text = text + self.status = status + self.url = url + + async def text(self) -> str | None: + """Return text payload asynchronously.""" + return self._text + + async def json(self, content_type: str | None = None) -> dict: + """Return parsed JSON payload asynchronously.""" + return json.loads(self._text) + + async def __aexit__(self, exc_type, exc, tb) -> None: + """Context manager exit (noop).""" + pass + + async def __aenter__(self) -> "MockResponse": + """Context manager enter returning self.""" + return self + + +@pytest.fixture +def mock_response() -> type[MockResponse]: + """Provide the MockResponse class for creating mock responses.""" + return MockResponse + + +@pytest_asyncio.fixture +async def client() -> OverkizClient: + """Fixture providing an OverkizClient configured for the cloud server.""" + return OverkizClient("username", "password", SUPPORTED_SERVERS["somfy_europe"]) + + +@pytest_asyncio.fixture +async def local_client() -> OverkizClient: + """Fixture providing an OverkizClient configured for a local (developer) server.""" + return OverkizClient( + "username", + "password", + generate_local_server("gateway-1234-5678-1243.local:8443"), + ) diff --git a/tests/test_client.py b/tests/test_client.py index 9ebdc7d0..ad0924a1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,20 +5,17 @@ from __future__ import annotations -import json from pathlib import Path from unittest.mock import patch import aiohttp import pytest -from pytest_asyncio import fixture from pyoverkiz import exceptions from pyoverkiz.client import OverkizClient -from pyoverkiz.const import SUPPORTED_SERVERS from pyoverkiz.enums import APIType, DataType from pyoverkiz.models import Option -from pyoverkiz.utils import generate_local_server +from tests.conftest import MockResponse CURRENT_DIR = Path(__file__).parent @@ -26,20 +23,6 @@ class TestOverkizClient: """Tests for the public OverkizClient behaviour (API type, devices, events, setup and diagnostics).""" - @fixture - async def client(self): - """Fixture providing an OverkizClient configured for the cloud server.""" - return OverkizClient("username", "password", SUPPORTED_SERVERS["somfy_europe"]) - - @fixture - async def local_client(self): - """Fixture providing an OverkizClient configured for a local (developer) server.""" - return OverkizClient( - "username", - "password", - generate_local_server("gateway-1234-5678-1243.local:8443"), - ) - @pytest.mark.asyncio async def test_get_api_type_cloud(self, client: OverkizClient): """Verify that a cloud-configured client reports APIType.CLOUD.""" @@ -453,30 +436,3 @@ async def test_get_scenarios( for command in action.commands: assert command.name is not None - - -class MockResponse: - """Simple stand-in for aiohttp responses used in tests.""" - - def __init__(self, text, status=200, url=""): - """Create a mock response with text payload and optional status/url.""" - self._text = text - self.status = status - self.url = url - - async def text(self): - """Return text payload asynchronously.""" - return self._text - - # pylint: disable=unused-argument - async def json(self, content_type=None): - """Return parsed JSON payload asynchronously.""" - return json.loads(self._text) - - async def __aexit__(self, exc_type, exc, tb): - """Context manager exit (noop).""" - pass - - async def __aenter__(self): - """Context manager enter returning self.""" - return self From f3ed765216ba9bbd80067c8f0f2eb0e1da838a31 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Sun, 25 Jan 2026 15:56:33 +0100 Subject: [PATCH 6/7] style(tests): apply ruff auto-fixes to conftest.py --- tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3a7c70fb..73364acd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ from pyoverkiz.utils import generate_local_server if TYPE_CHECKING: - from collections.abc import Generator + pass class MockResponse: @@ -37,7 +37,7 @@ async def __aexit__(self, exc_type, exc, tb) -> None: """Context manager exit (noop).""" pass - async def __aenter__(self) -> "MockResponse": + async def __aenter__(self) -> MockResponse: """Context manager enter returning self.""" return self From d0fc65b0d2fb945904ec777bf427be992825d62d Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Sun, 25 Jan 2026 19:26:59 +0100 Subject: [PATCH 7/7] refactor(tests): remove dead code from conftest.py --- tests/conftest.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 73364acd..be1ef79d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,29 +3,24 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING -import pytest import pytest_asyncio from pyoverkiz.client import OverkizClient from pyoverkiz.const import SUPPORTED_SERVERS from pyoverkiz.utils import generate_local_server -if TYPE_CHECKING: - pass - class MockResponse: """Simple stand-in for aiohttp responses used in tests.""" - def __init__(self, text: str | None, status: int = 200, url: str = "") -> None: + def __init__(self, text: str, status: int = 200, url: str = "") -> None: """Create a mock response with text payload and optional status/url.""" self._text = text self.status = status self.url = url - async def text(self) -> str | None: + async def text(self) -> str: """Return text payload asynchronously.""" return self._text @@ -42,12 +37,6 @@ async def __aenter__(self) -> MockResponse: return self -@pytest.fixture -def mock_response() -> type[MockResponse]: - """Provide the MockResponse class for creating mock responses.""" - return MockResponse - - @pytest_asyncio.fixture async def client() -> OverkizClient: """Fixture providing an OverkizClient configured for the cloud server."""