From 13273a355247507c2757e064860590ab15c8b4bd Mon Sep 17 00:00:00 2001 From: Nate Olsen Date: Sun, 6 Sep 2026 10:41:58 -0700 Subject: [PATCH 1/7] Clear the ruff Code Checker findings - BLE001: narrow the catches with known failure modes (dict_get -> KeyError/IndexError/TypeError; device-tracker icon -> KeyError/TypeError) and mark the deliberate catch-all fallbacks (cache read/write, poll -> cache, device-tracker -> last state, OUI lookup, best-effort state kill) with `# noqa: BLE001` and a reason. - S110: replace the two bare `except: pass` in the MAC-vendor path with a debug log. - RUF046: drop the redundant `int()` around `round(...)` (no-ndigits round already returns int) in the rate math. - RUF013: `service_kill_states` destination -> `str | None`. - RUF012: annotate `Client._RULE_REQUIRED_DEFAULTS` as `ClassVar`. - SIM118: drop `.keys()` from iteration / membership checks in sensor.py, switch.py, update.py. - SIM102: collapse two nested `if`s in sensor.py into single `and` conditions. No behaviour change. Co-Authored-By: Claude Sonnet 5 --- custom_components/pfsense/__init__.py | 20 +++++------ custom_components/pfsense/device_tracker.py | 12 +++---- .../pfsense/pypfsense/__init__.py | 4 +-- custom_components/pfsense/sensor.py | 34 +++++++++---------- custom_components/pfsense/switch.py | 4 +-- custom_components/pfsense/update.py | 2 +- 6 files changed, 37 insertions(+), 39 deletions(-) diff --git a/custom_components/pfsense/__init__.py b/custom_components/pfsense/__init__.py index 2f0bd37..3e78251 100644 --- a/custom_components/pfsense/__init__.py +++ b/custom_components/pfsense/__init__.py @@ -62,7 +62,7 @@ async def async_save_cache(hass: HomeAssistant, entry_id: str, data: dict): store = Store(hass, STORAGE_VERSION, f"{DOMAIN}_{entry_id}_cache") try: await store.async_save(data) - except Exception as e: + except Exception as e: # noqa: BLE001 - a cache write must never break the poll _LOGGER.error(f"Failed to save pfSense cache: {e}") @@ -71,7 +71,7 @@ async def async_load_cache(hass: HomeAssistant, entry_id: str): store = Store(hass, STORAGE_VERSION, f"{DOMAIN}_{entry_id}_cache") try: return await store.async_load() - except Exception as e: + except Exception as e: # noqa: BLE001 - any cache error -> live poll _LOGGER.error(f"Failed to load pfSense cache: {e}") return None @@ -86,7 +86,7 @@ def dict_get(data: dict, path: str, default=None): try: key = int(key) if key.isnumeric() else key result = result[key] - except Exception: + except (KeyError, IndexError, TypeError): result = default break return result @@ -124,11 +124,11 @@ def _compute_interface_rates(new_state, elapsed_time, scan_interval): label, value = "kilobytes_per_second", rate / 1000 new_property = f"{prop}_{label}" if elapsed_time >= scan_interval: - interface[new_property] = int(round(value)) + interface[new_property] = round(value) else: previous_value = previous_interface.get(new_property) - interface[new_property] = int( - round(previous_value if previous_value is not None else value) + interface[new_property] = round( + previous_value if previous_value is not None else value ) @@ -144,7 +144,7 @@ def _compute_openvpn_rates(new_state, elapsed_time): for prop in ("total_bytes_recv", "total_bytes_sent"): change = abs(server.get(prop, 0) - previous_server.get(prop, 0)) rate = change / elapsed_time if elapsed_time > 0 else 0 - server[f"{prop}_kilobytes_per_second"] = int(round(rate / 1000)) + server[f"{prop}_kilobytes_per_second"] = round(rate / 1000) async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): @@ -186,7 +186,7 @@ async def async_update_data(): return new_state except (PfSenseAuthError, PfSensePrivilegeError) as err: raise ConfigEntryAuthFailed(str(err)) from err - except Exception as err: + except Exception as err: # noqa: BLE001 - any poll error -> use cache _LOGGER.warning( "pfSense poll failed (%s); trying the local cache", err ) @@ -226,7 +226,7 @@ async def async_update_device_tracker_data(): return new_dt_state except (PfSenseAuthError, PfSensePrivilegeError) as err: raise ConfigEntryAuthFailed(str(err)) from err - except Exception as err: + except Exception as err: # noqa: BLE001 - keep last known state _LOGGER.warning("pfSense device tracker update failed: %s", err) if device_tracker_data._state: return device_tracker_data._state @@ -509,7 +509,7 @@ async def service_restart_service( async def service_reset_state_table(self): await self._get_pfsense_client().reset_state_table() - async def service_kill_states(self, source: str, destination: str = None): + async def service_kill_states(self, source: str, destination: str | None = None): await self._get_pfsense_client().kill_states(source, destination) async def service_system_halt(self): diff --git a/custom_components/pfsense/device_tracker.py b/custom_components/pfsense/device_tracker.py index f2775b0..f6e3f81 100644 --- a/custom_components/pfsense/device_tracker.py +++ b/custom_components/pfsense/device_tracker.py @@ -55,11 +55,11 @@ async def async_setup_entry( mac_vendor_lookup = AsyncMacLookup() try: await mac_vendor_lookup.update_vendors() - except Exception: + except Exception: # noqa: BLE001 - OUI lookup is optional try: await mac_vendor_lookup.load_vendors() - except Exception: - pass + except Exception as err: # noqa: BLE001 - continue without vendors + _LOGGER.debug("MAC vendor database unavailable: %s", err) dev_reg = async_get_dev_reg(hass) @@ -102,8 +102,8 @@ def process_entities_callback( mac_vendor = None try: mac_vendor = lookup_mac(mac_vendor_lookup, mac_address) - except Exception: - pass + except Exception as err: # noqa: BLE001 - unknown OUI, leave vendor unset + _LOGGER.debug("MAC vendor lookup failed for %s: %s", mac_address, err) entity = PfSenseScannerEntity( hass, @@ -280,7 +280,7 @@ def icon(self) -> str: """Return device icon.""" try: return "mdi:lan-connect" if self.is_connected else "mdi:lan-disconnect" - except Exception: + except (KeyError, TypeError): return "mdi:lan-disconnect" @property diff --git a/custom_components/pfsense/pypfsense/__init__.py b/custom_components/pfsense/pypfsense/__init__.py index 7aa09e5..9a4437a 100644 --- a/custom_components/pfsense/pypfsense/__init__.py +++ b/custom_components/pfsense/pypfsense/__init__.py @@ -16,7 +16,7 @@ import ipaddress import logging import re -from typing import Any +from typing import Any, ClassVar from urllib.parse import urlparse import aiohttp @@ -350,7 +350,7 @@ async def get_nat_outbound_rules(self) -> list[dict]: # fields make an otherwise unrelated toggle fail with # ``FIELD_EMPTY_NOT_ALLOWED``. Re-send them with the value pfSense would # have defaulted to, which is a no-op for the rule's behaviour. - _RULE_REQUIRED_DEFAULTS = { + _RULE_REQUIRED_DEFAULTS: ClassVar[dict[str, dict[str, str]]] = { "/firewall/rule": {"statetype": "keep state"}, } diff --git a/custom_components/pfsense/sensor.py b/custom_components/pfsense/sensor.py index 5127480..d6b1192 100644 --- a/custom_components/pfsense/sensor.py +++ b/custom_components/pfsense/sensor.py @@ -113,7 +113,7 @@ def process_entities_callback(hass, config_entry): ) entities.append(entity) - for interface_name in dict_get(state, "telemetry.interfaces", {}).keys(): + for interface_name in dict_get(state, "telemetry.interfaces", {}): interface = state["telemetry"]["interfaces"][interface_name] for property in [ "status", @@ -199,7 +199,7 @@ def process_entities_callback(hass, config_entry): ) entities.append(entity) - for gateway_name in dict_get(state, "telemetry.gateways", {}).keys(): + for gateway_name in dict_get(state, "telemetry.gateways", {}): gateway = state["telemetry"]["gateways"][gateway_name] for property in ["status", "delay", "stddev", "loss"]: state_class = None @@ -230,7 +230,7 @@ def process_entities_callback(hass, config_entry): ) entities.append(entity) - for vpnid in dict_get(state, "telemetry.openvpn.servers", {}).keys(): + for vpnid in dict_get(state, "telemetry.openvpn.servers", {}): servers = dict_get(state, "telemetry.openvpn.servers", {}) server = servers[vpnid] for property in [ @@ -254,9 +254,8 @@ def process_entities_callback(hass, config_entry): if "_kilobytes_per_second" in property: native_unit_of_measurement = UnitOfDataRate.KILOBYTES_PER_SECOND - if native_unit_of_measurement is None: - if "bytes" in property: - native_unit_of_measurement = UnitOfInformation.BYTES + if native_unit_of_measurement is None and "bytes" in property: + native_unit_of_measurement = UnitOfInformation.BYTES if property in ["connected_client_count"]: native_unit_of_measurement = "clients" @@ -403,7 +402,7 @@ def _pfsense_get_interface(self): state = self.coordinator.data found = None interface_name = self._pfsense_get_interface_name() - for i_interface_name in state["telemetry"]["interfaces"].keys(): + for i_interface_name in state["telemetry"]["interfaces"]: if i_interface_name == interface_name: found = state["telemetry"]["interfaces"][i_interface_name] break @@ -413,7 +412,7 @@ def _pfsense_get_interface(self): def available(self) -> bool: interface = self._pfsense_get_interface() property = self._pfsense_get_interface_property_name() - if interface is None or property not in interface.keys(): + if interface is None or property not in interface: return False return super().available @@ -506,7 +505,7 @@ def _pfsense_get_gateway(self): state = self.coordinator.data found = None gateway_name = self._pfsense_get_gateway_name() - for i_gateway_name in state["telemetry"]["gateways"].keys(): + for i_gateway_name in state["telemetry"]["gateways"]: if i_gateway_name == gateway_name: found = state["telemetry"]["gateways"][i_gateway_name] break @@ -516,7 +515,7 @@ def _pfsense_get_gateway_details(self): state = self.coordinator.data found = None gateway_name = self._pfsense_get_gateway_name() - for i_gateway_name in state["telemetry"]["gateways_detail"].keys(): + for i_gateway_name in state["telemetry"]["gateways_detail"]: if i_gateway_name == gateway_name: found = state["telemetry"]["gateways_detail"][i_gateway_name] break @@ -526,7 +525,7 @@ def _pfsense_get_gateway_details(self): def available(self) -> bool: gateway = self._pfsense_get_gateway() property = self._pfsense_get_gateway_property_name() - if gateway is None or property not in gateway.keys(): + if gateway is None or property not in gateway: return False if property in ["stddev", "delay", "loss"]: @@ -576,11 +575,10 @@ def native_value(self): try: value = gateway[property] - if property in ["stddev", "delay", "loss"]: - if isinstance(value, str): - value = re.sub(r"[^0-9\.]*", "", value) - if len(value) > 0: - value = float(value) + if property in ["stddev", "delay", "loss"] and isinstance(value, str): + value = re.sub(r"[^0-9\.]*", "", value) + if len(value) > 0: + value = float(value) if isinstance(value, str) and len(value) < 1: return STATE_UNKNOWN @@ -601,7 +599,7 @@ def _pfsense_get_server(self): state = self.coordinator.data found = None vpnid = self._pfsense_get_server_vpnid() - for server_vpnid in dict_get(state, "telemetry.openvpn.servers", {}).keys(): + for server_vpnid in dict_get(state, "telemetry.openvpn.servers", {}): if vpnid == server_vpnid: found = state["telemetry"]["openvpn"]["servers"][vpnid] break @@ -611,7 +609,7 @@ def _pfsense_get_server(self): def available(self) -> bool: server = self._pfsense_get_server() property = self._pfsense_get_server_property_name() - if server is None or property not in server.keys(): + if server is None or property not in server: return False return super().available diff --git a/custom_components/pfsense/switch.py b/custom_components/pfsense/switch.py index ffb9ef6..71e7cf8 100644 --- a/custom_components/pfsense/switch.py +++ b/custom_components/pfsense/switch.py @@ -190,7 +190,7 @@ async def _maybe_kill_rule_states(self, rule): return try: await self._get_pfsense_client().kill_states_for_rule(rule) - except Exception: # best effort - never fail the toggle over this + except Exception: # noqa: BLE001 - best effort; never fail the toggle _LOGGER.warning("failed to kill states for toggled rule", exc_info=True) @@ -343,7 +343,7 @@ def _pfsense_get_service(self): def available(self) -> bool: service = self._pfsense_get_service() property = self._pfsense_get_property_name() - if service is None or property not in service.keys(): + if service is None or property not in service: return False return super().available diff --git a/custom_components/pfsense/update.py b/custom_components/pfsense/update.py index d16e3da..2fd0dc5 100644 --- a/custom_components/pfsense/update.py +++ b/custom_components/pfsense/update.py @@ -135,7 +135,7 @@ def extra_state_attributes(self): if not info: return attrs - for key in info.keys(): + for key in info: attrs[f"pfsense_base_{key}"] = dict_get( state, f"firmware_update_info.base.{key}" ) From 00b13c5eda919d37dbdfb59f7f9e9c5766c53a40 Mon Sep 17 00:00:00 2001 From: nolsen311 <64344+nolsen311@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:42:10 +0000 Subject: [PATCH 2/7] style: auto-fix ruff linting and formatting [skip ci] --- custom_components/pfsense/__init__.py | 10 +++--- custom_components/pfsense/config_flow.py | 8 ++--- custom_components/pfsense/device_tracker.py | 5 ++- .../pfsense/pypfsense/__init__.py | 32 ++++++++++------- custom_components/pfsense/sensor.py | 4 +-- custom_components/pfsense/services.py | 6 ++-- custom_components/pfsense/switch.py | 13 +++---- tests/test_binary_sensor.py | 8 ++--- tests/test_config_flow.py | 2 +- tests/test_pypfsense.py | 35 +++++++++++++------ tests/test_sensor.py | 5 +-- 11 files changed, 72 insertions(+), 56 deletions(-) diff --git a/custom_components/pfsense/__init__.py b/custom_components/pfsense/__init__.py index 3e78251..d0689e3 100644 --- a/custom_components/pfsense/__init__.py +++ b/custom_components/pfsense/__init__.py @@ -4,11 +4,11 @@ import asyncio import copy -from datetime import timedelta import logging import re import time -from typing import Callable +from collections.abc import Callable +from datetime import timedelta from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -21,12 +21,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, UpdateFailed, ) -from homeassistant.helpers.storage import Store from .const import ( CONF_API_KEY, @@ -187,9 +187,7 @@ async def async_update_data(): except (PfSenseAuthError, PfSensePrivilegeError) as err: raise ConfigEntryAuthFailed(str(err)) from err except Exception as err: # noqa: BLE001 - any poll error -> use cache - _LOGGER.warning( - "pfSense poll failed (%s); trying the local cache", err - ) + _LOGGER.warning("pfSense poll failed (%s); trying the local cache", err) cached_data = await async_load_cache(hass, entry.entry_id) if cached_data: data._state = cached_data diff --git a/custom_components/pfsense/config_flow.py b/custom_components/pfsense/config_flow.py index 82a04b5..3cd1b2a 100644 --- a/custom_components/pfsense/config_flow.py +++ b/custom_components/pfsense/config_flow.py @@ -5,6 +5,8 @@ import logging from urllib.parse import urlparse +import homeassistant.helpers.config_validation as cv +import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( CONF_NAME, @@ -14,9 +16,7 @@ ) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify -import voluptuous as vol from .const import ( CONF_API_KEY, @@ -116,7 +116,7 @@ async def async_step_user(self, user_input=None): errors["base"] = "cannot_connect_ssl" else: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected error validating pfSense connection") errors["base"] = "unknown" @@ -169,7 +169,7 @@ async def async_step_reauth_confirm(self, user_input=None): errors["base"] = "privilege_missing" except (PfSenseConnectionError, PfSenseNotFoundError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected error during pfSense reauth") errors["base"] = "unknown" else: diff --git a/custom_components/pfsense/device_tracker.py b/custom_components/pfsense/device_tracker.py index f6e3f81..78eaa93 100644 --- a/custom_components/pfsense/device_tracker.py +++ b/custom_components/pfsense/device_tracker.py @@ -4,7 +4,8 @@ import logging import time -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any from homeassistant.components.device_tracker import SourceType from homeassistant.components.device_tracker.config_entry import ScannerEntity @@ -13,6 +14,8 @@ from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, +) +from homeassistant.helpers.device_registry import ( async_get as async_get_dev_reg, ) from homeassistant.helpers.entity import DeviceInfo diff --git a/custom_components/pfsense/pypfsense/__init__.py b/custom_components/pfsense/pypfsense/__init__.py index 9a4437a..9a95bfc 100644 --- a/custom_components/pfsense/pypfsense/__init__.py +++ b/custom_components/pfsense/pypfsense/__init__.py @@ -211,14 +211,18 @@ async def get_telemetry(self) -> dict: ``interfaces.{name}.{counter}``, ``gateways.{name}.{prop}``, ``openvpn.servers.{vpnid}.{prop}``, ``cpu.*``, ``system.*``, ``wan_ip``. """ - system, interfaces, gateways, ovpn_servers, gateways_detail = ( - await asyncio.gather( - self._get("/status/system"), - self._get("/status/interfaces"), - self._get("/status/gateways"), - self._get("/status/openvpn/servers"), - self.get_gateways_detail(), - ) + ( + system, + interfaces, + gateways, + ovpn_servers, + gateways_detail, + ) = await asyncio.gather( + self._get("/status/system"), + self._get("/status/interfaces"), + self._get("/status/gateways"), + self._get("/status/openvpn/servers"), + self.get_gateways_detail(), ) return _build_telemetry( system, interfaces, gateways, ovpn_servers, gateways_detail @@ -243,13 +247,17 @@ async def _find_service(self, service_name: str) -> dict | None: return svc return None - async def start_service(self, service_name: str, service: dict | None = None) -> None: + async def start_service( + self, service_name: str, service: dict | None = None + ) -> None: svc = service if isinstance(service, dict) and "id" in service else None svc = svc or await self._find_service(service_name) if svc: await self._service_action(svc, "start") - async def stop_service(self, service_name: str, service: dict | None = None) -> None: + async def stop_service( + self, service_name: str, service: dict | None = None + ) -> None: svc = service if isinstance(service, dict) and "id" in service else None svc = svc or await self._find_service(service_name) if svc: @@ -496,9 +504,7 @@ async def get_carp_interfaces(self) -> list[dict]: # --------------------------------------------------------- state table async def reset_state_table(self) -> None: - await self._request( - "DELETE", "/firewall/states", params={"limit": 0} - ) + await self._request("DELETE", "/firewall/states", params={"limit": 0}) async def kill_states(self, source: str, destination: str | None = None) -> None: cmd = f"/sbin/pfctl -k {_shq(source)}" diff --git a/custom_components/pfsense/sensor.py b/custom_components/pfsense/sensor.py index d6b1192..f9e5d38 100644 --- a/custom_components/pfsense/sensor.py +++ b/custom_components/pfsense/sensor.py @@ -82,7 +82,7 @@ def process_entities_callback(hass, config_entry): coordinator, SensorEntityDescription( key=f"telemetry.filesystems.{device_clean}", - name="Filesystem Used Percentage {}".format(mountpoint_clean), + name=f"Filesystem Used Percentage {mountpoint_clean}", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, @@ -273,7 +273,7 @@ def process_entities_callback(hass, config_entry): config_entry, coordinator, SensorEntityDescription( - key="telemetry.openvpn.servers.{}.{}".format(vpnid, property), + key=f"telemetry.openvpn.servers.{vpnid}.{property}", name="OpenVPN Server {} ({}) {}".format( vpnid, server["name"], property ), diff --git a/custom_components/pfsense/services.py b/custom_components/pfsense/services.py index 3eef26f..53a733a 100644 --- a/custom_components/pfsense/services.py +++ b/custom_components/pfsense/services.py @@ -1,11 +1,11 @@ import logging +import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.service import entity_service_call -import voluptuous as vol from .const import ( DOMAIN, @@ -61,9 +61,7 @@ async def service_update_alias( ): """Dynamic extension mapping runtime command parameters directly to the Client interface.""" client = self_entity._get_pfsense_client() - await client.update_alias_address( - alias_name, address, action, kill_states - ) + await client.update_alias_address(alias_name, address, action, kill_states) if not hasattr(PfSenseEntity, "service_update_alias"): PfSenseEntity.service_update_alias = service_update_alias diff --git a/custom_components/pfsense/switch.py b/custom_components/pfsense/switch.py index 71e7cf8..4b72ea2 100644 --- a/custom_components/pfsense/switch.py +++ b/custom_components/pfsense/switch.py @@ -60,7 +60,7 @@ def process_entities_callback(hass, config_entry): config_entry, coordinator, SwitchEntityDescription( - key="filter.{}".format(tracker), + key=f"filter.{tracker}", name="Filter Rule {} ({})".format( tracker, rule.get("descr", "") ), @@ -99,7 +99,7 @@ def process_entities_callback(hass, config_entry): config_entry, coordinator, SwitchEntityDescription( - key="{}.{}".format(rule_type, tracker), + key=f"{rule_type}.{tracker}", name="{} {} ({})".format( label, tracker, rule.get("descr", "") ), @@ -190,7 +190,7 @@ async def _maybe_kill_rule_states(self, rule): return try: await self._get_pfsense_client().kill_states_for_rule(rule) - except Exception: # noqa: BLE001 - best effort; never fail the toggle + except Exception: _LOGGER.warning("failed to kill states for toggled rule", exc_info=True) @@ -330,9 +330,10 @@ def _pfsense_get_service(self): if service_name.startswith("openvpn"): # [ "openvpn", """] parts = service_name.split("-") - if service["name"] == parts[0] and str( - service.get("vpnid") - ) == parts[1]: + if ( + service["name"] == parts[0] + and str(service.get("vpnid")) == parts[1] + ): found = service elif service["name"] == service_name: found = service diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index d4439a1..5a41967 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -70,9 +70,7 @@ def _entry(entry_id): async def _setup(hass, entry, client): entry.add_to_hass(hass) with ( - patch( - "custom_components.pfsense.pfSenseClient", return_value=client - ), + patch("custom_components.pfsense.pfSenseClient", return_value=client), patch("custom_components.pfsense.async_load_cache", return_value=None), patch("custom_components.pfsense.async_save_cache"), ): @@ -88,9 +86,7 @@ async def test_carp_sensor_on(hass: HomeAssistant, mock_pfsense_client): coordinator = hass.data[DOMAIN]["carp_on"][COORDINATOR] assert coordinator.data["carp_status"] is True # Notices have no REST endpoint; that binary sensor no longer exists. - assert ( - hass.states.get("binary_sensor.router_local_pending_notices_present") is None - ) + assert hass.states.get("binary_sensor.router_local_pending_notices_present") is None assert hass.states.get("binary_sensor.router_local_carp_status") is not None diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 91814ec..c7fe90a 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.core import HomeAssistant from homeassistant.const import CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from pytest_homeassistant_custom_component.common import MockConfigEntry diff --git a/tests/test_pypfsense.py b/tests/test_pypfsense.py index 2d6f5c2..b3cfcde 100644 --- a/tests/test_pypfsense.py +++ b/tests/test_pypfsense.py @@ -8,10 +8,10 @@ from custom_components.pfsense.pypfsense import ( Client, + PfSenseAPIError, PfSenseAuthError, PfSenseNotFoundError, PfSensePrivilegeError, - PfSenseAPIError, _build_telemetry, dict_get, ) @@ -51,8 +51,14 @@ def test_base_url_strips_path(): async def test_request_unwraps_data(client): with aioresponses() as m: - m.get(f"{API}/system/hostname", payload=_envelope({"hostname": "pf", "domain": "lan"})) - assert await client._get("/system/hostname") == {"hostname": "pf", "domain": "lan"} + m.get( + f"{API}/system/hostname", + payload=_envelope({"hostname": "pf", "domain": "lan"}), + ) + assert await client._get("/system/hostname") == { + "hostname": "pf", + "domain": "lan", + } @pytest.mark.parametrize( @@ -69,7 +75,9 @@ async def test_error_codes_map_to_exceptions(client, code, exc): m.get( f"{API}/system/hostname", status=code, - payload=_envelope([], code=code, status="err", response_id="X", message="nope"), + payload=_envelope( + [], code=code, status="err", response_id="X", message="nope" + ), ) with pytest.raises(exc): await client._get("/system/hostname") @@ -114,16 +122,23 @@ async def test_carp_status_reduces_to_bool(client): def _patch_body(m): return next( - r for (method, url), reqs in m.requests.items() - for r in reqs if method == "PATCH" + r + for (method, url), reqs in m.requests.items() + for r in reqs + if method == "PATCH" ).kwargs["json"] async def test_disable_filter_rule_patches_then_applies(client): rules = [ {"id": 4, "tracker": 111, "disabled": False, "descr": "r"}, - {"id": 5, "tracker": 222, "disabled": False, "descr": "r2", - "statetype": "keep state"}, + { + "id": 5, + "tracker": 222, + "disabled": False, + "descr": "r2", + "statetype": "keep state", + }, ] with aioresponses() as m: m.get(f"{API}/firewall/rules", payload=_envelope(rules)) @@ -193,9 +208,7 @@ async def test_build_telemetry_shape(): {"name": "lan", "descr": "LAN", "inbytes": 5}, ] gateways = [{"name": "WAN_DHCP", "delay": 1.2, "status": "online"}] - ovpn = [ - {"vpnid": 1, "name": "S", "conns": [{"bytes_recv": 100, "bytes_sent": 50}]} - ] + ovpn = [{"vpnid": 1, "name": "S", "conns": [{"bytes_recv": 100, "bytes_sent": 50}]}] t = _build_telemetry(system, interfaces, gateways, ovpn) assert t["wan_ip"] == "1.2.3.4" assert t["cpu"]["used_percent"] == 12.5 diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 9a895cf..419ac73 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,10 +1,11 @@ +from unittest.mock import MagicMock, PropertyMock, patch + import pytest -from unittest.mock import MagicMock, patch, PropertyMock +from homeassistant.components.sensor import SensorEntityDescription from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.pfsense.const import DOMAIN from custom_components.pfsense.sensor import PfSenseOpenVPNServerSensor -from homeassistant.components.sensor import SensorEntityDescription @pytest.fixture From 6c7ee929c52b4557ec3d18ee7964b9108b81b66f Mon Sep 17 00:00:00 2001 From: Nate Olsen Date: Sun, 6 Sep 2026 10:51:49 -0700 Subject: [PATCH 3/7] Adopt the Home Assistant ruff configuration Add pyproject.toml mirroring home-assistant/core's [tool.ruff] rule selection so the Code Checker action enforces the same standard, and pin ruff in the workflow so a future release can't turn CI red unrelatedly. Rules the HA selection flags that this fork does not satisfy yet (missing docstrings, `property` loop-var shadowing, private-member access, a few try/except shapes) are listed in a clearly marked project-specific ignore block to clear incrementally - new code is still linted against the full set. Applied `ruff check --fix` + `ruff format` (import ordering, redundant `int(round())`, deprecated `typing` imports, string reflows) and a few manual fixes: cache read/write now catch OSError/HomeAssistantError/ ValueError instead of bare Exception, UpdateFailed re-raises use `from err`, cache logging uses `%s` placeholders. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/codechecker.yml | 4 +- custom_components/pfsense/__init__.py | 33 ++- custom_components/pfsense/config_flow.py | 10 +- custom_components/pfsense/const.py | 6 +- custom_components/pfsense/device_tracker.py | 28 ++- .../pfsense/pypfsense/__init__.py | 4 +- custom_components/pfsense/sensor.py | 7 +- custom_components/pfsense/services.py | 1 + pyproject.toml | 204 ++++++++++++++++++ tests/test_binary_sensor.py | 4 +- tests/test_config_flow.py | 6 +- tests/test_init.py | 4 +- tests/test_pypfsense.py | 2 +- tests/test_sensor.py | 2 +- tests/test_switch.py | 2 +- 15 files changed, 254 insertions(+), 63 deletions(-) create mode 100644 pyproject.toml diff --git a/.github/workflows/codechecker.yml b/.github/workflows/codechecker.yml index 5f3fa95..87a4a69 100644 --- a/.github/workflows/codechecker.yml +++ b/.github/workflows/codechecker.yml @@ -26,7 +26,9 @@ jobs: python-version: "3.12" - name: Install ruff - run: pip install ruff + # Pinned so a new ruff release can't turn CI red on an unrelated PR. + # Bump together with the rules in pyproject.toml. + run: pip install "ruff==0.16.6" - name: Auto-fix and format Python code run: | diff --git a/custom_components/pfsense/__init__.py b/custom_components/pfsense/__init__.py index d0689e3..266f176 100644 --- a/custom_components/pfsense/__init__.py +++ b/custom_components/pfsense/__init__.py @@ -3,21 +3,17 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import copy +from datetime import timedelta import logging import re import time -from collections.abc import Callable -from datetime import timedelta from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_SCAN_INTERVAL, - CONF_URL, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -47,8 +43,7 @@ SHOULD_RELOAD, UNDO_UPDATE_LISTENER, ) -from .pypfsense import Client as pfSenseClient -from .pypfsense import PfSenseAuthError, PfSensePrivilegeError +from .pypfsense import Client as pfSenseClient, PfSenseAuthError, PfSensePrivilegeError from .services import ServiceRegistrar _LOGGER = logging.getLogger(__name__) @@ -62,8 +57,8 @@ async def async_save_cache(hass: HomeAssistant, entry_id: str, data: dict): store = Store(hass, STORAGE_VERSION, f"{DOMAIN}_{entry_id}_cache") try: await store.async_save(data) - except Exception as e: # noqa: BLE001 - a cache write must never break the poll - _LOGGER.error(f"Failed to save pfSense cache: {e}") + except (OSError, HomeAssistantError, ValueError) as err: + _LOGGER.error("Failed to save pfSense cache: %s", err) async def async_load_cache(hass: HomeAssistant, entry_id: str): @@ -71,8 +66,8 @@ async def async_load_cache(hass: HomeAssistant, entry_id: str): store = Store(hass, STORAGE_VERSION, f"{DOMAIN}_{entry_id}_cache") try: return await store.async_load() - except Exception as e: # noqa: BLE001 - any cache error -> live poll - _LOGGER.error(f"Failed to load pfSense cache: {e}") + except (OSError, HomeAssistantError, ValueError) as err: + _LOGGER.error("Failed to load pfSense cache: %s", err) return None @@ -186,13 +181,13 @@ async def async_update_data(): return new_state except (PfSenseAuthError, PfSensePrivilegeError) as err: raise ConfigEntryAuthFailed(str(err)) from err - except Exception as err: # noqa: BLE001 - any poll error -> use cache + except Exception as err: _LOGGER.warning("pfSense poll failed (%s); trying the local cache", err) cached_data = await async_load_cache(hass, entry.entry_id) if cached_data: data._state = cached_data return cached_data - raise UpdateFailed(f"poll failed and no cache available: {err}") + raise UpdateFailed(f"poll failed and no cache available: {err}") from err coordinator = DataUpdateCoordinator( hass, @@ -224,11 +219,11 @@ async def async_update_device_tracker_data(): return new_dt_state except (PfSenseAuthError, PfSensePrivilegeError) as err: raise ConfigEntryAuthFailed(str(err)) from err - except Exception as err: # noqa: BLE001 - keep last known state + except Exception as err: _LOGGER.warning("pfSense device tracker update failed: %s", err) if device_tracker_data._state: return device_tracker_data._state - raise UpdateFailed(err) + raise UpdateFailed(err) from err device_tracker_coordinator = DataUpdateCoordinator( hass, @@ -495,7 +490,7 @@ async def service_stop_service( async def service_restart_service( self, service_name: str, - only_if_running: int | str | None | bool = False, + only_if_running: int | str | bool | None = False, service: dict | str | None = None, ): client = self._get_pfsense_client() diff --git a/custom_components/pfsense/config_flow.py b/custom_components/pfsense/config_flow.py index 3cd1b2a..e331d6e 100644 --- a/custom_components/pfsense/config_flow.py +++ b/custom_components/pfsense/config_flow.py @@ -5,17 +5,13 @@ import logging from urllib.parse import urlparse -import homeassistant.helpers.config_validation as cv import voluptuous as vol + from homeassistant import config_entries -from homeassistant.const import ( - CONF_NAME, - CONF_SCAN_INTERVAL, - CONF_URL, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify from .const import ( diff --git a/custom_components/pfsense/const.py b/custom_components/pfsense/const.py index d0cdd92..33f742a 100644 --- a/custom_components/pfsense/const.py +++ b/custom_components/pfsense/const.py @@ -9,11 +9,7 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import ( - PERCENTAGE, - UnitOfTemperature, - UnitOfTime, -) +from homeassistant.const import PERCENTAGE, UnitOfTemperature, UnitOfTime DEFAULT_USERNAME = "admin" DOMAIN = "pfsense" diff --git a/custom_components/pfsense/device_tracker.py b/custom_components/pfsense/device_tracker.py index 78eaa93..38e01f5 100644 --- a/custom_components/pfsense/device_tracker.py +++ b/custom_components/pfsense/device_tracker.py @@ -2,11 +2,13 @@ from __future__ import annotations +from collections.abc import Mapping import logging import time -from collections.abc import Mapping from typing import Any +from mac_vendor_lookup import AsyncMacLookup + from homeassistant.components.device_tracker import SourceType from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.config_entries import ConfigEntry @@ -14,14 +16,11 @@ from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, -) -from homeassistant.helpers.device_registry import ( async_get as async_get_dev_reg, ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import slugify -from mac_vendor_lookup import AsyncMacLookup from . import CoordinatorEntityManager, PfSenseEntity, dict_get from .const import ( @@ -89,17 +88,16 @@ def process_entities_callback( if configured_mac_addresses: mac_addresses = configured_mac_addresses enabled_default = True - else: - if device_per_arp_entry: - arp_entries = dict_get(state, "arp_table") - if not arp_entries: - return [] - - mac_addresses = [ - mac_address.lower() - for arp_entry in arp_entries - if (mac_address := arp_entry.get("mac_address")) - ] + elif device_per_arp_entry: + arp_entries = dict_get(state, "arp_table") + if not arp_entries: + return [] + + mac_addresses = [ + mac_address.lower() + for arp_entry in arp_entries + if (mac_address := arp_entry.get("mac_address")) + ] for mac_address in mac_addresses: mac_vendor = None diff --git a/custom_components/pfsense/pypfsense/__init__.py b/custom_components/pfsense/pypfsense/__init__.py index 9a95bfc..eebb674 100644 --- a/custom_components/pfsense/pypfsense/__init__.py +++ b/custom_components/pfsense/pypfsense/__init__.py @@ -163,7 +163,7 @@ async def _apply(self, area: str) -> None: # -------------------------------------------------------------- identity async def get_system_info(self) -> dict: - """hostname / domain / serial / netgate id / platform, old-client shape.""" + """Hostname / domain / serial / netgate id / platform, old-client shape.""" status, hostname = await asyncio.gather( self._get("/status/system"), self._get("/system/hostname"), @@ -654,7 +654,7 @@ def _expand_alias_networks(aliases: list[dict], name: str, _depth: int = 3) -> l def _flatten_params(params: dict | None) -> dict | None: - """aiohttp needs str values; drop ``None`` and stringify the rest.""" + """Aiohttp needs str values; drop ``None`` and stringify the rest.""" if not params: return None return {k: str(v) for k, v in params.items() if v is not None} diff --git a/custom_components/pfsense/sensor.py b/custom_components/pfsense/sensor.py index f9e5d38..ad93464 100644 --- a/custom_components/pfsense/sensor.py +++ b/custom_components/pfsense/sensor.py @@ -552,10 +552,9 @@ def extra_state_attributes(self): if attr in gateway_detail: value = gateway_detail[attr] attributes[attr] = value - else: - if attr == "isdefaultgw": - value = False - attributes[attr] = value + elif attr == "isdefaultgw": + value = False + attributes[attr] = value return attributes @property diff --git a/custom_components/pfsense/services.py b/custom_components/pfsense/services.py index 53a733a..c0e51d9 100644 --- a/custom_components/pfsense/services.py +++ b/custom_components/pfsense/services.py @@ -1,6 +1,7 @@ import logging import voluptuous as vol + from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b6e058b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,204 @@ +[tool.ruff] +# Mirrors the Home Assistant core ruff configuration so this custom +# integration is held to the same standard. Kept in sync manually with +# https://github.com/home-assistant/core/blob/dev/pyproject.toml +required-version = ">=0.16.5" + +[tool.ruff.lint] +select = [ + "A001", # Variable {name} is shadowing a Python builtin + "ASYNC", # flake8-async + "B", # flake8-bugbear + "BLE", + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) + "DTZ011", # Use datetime.now(tz=).date() instead of date.today() + "E", # pycodestyle + "F", # pyflakes/autoflake + "F541", # f-string without any placeholders + "FLY", # flynt + "FURB", # refurb + "G", # flake8-logging-format + "I", # isort + "INP", # flake8-no-pep420 + "ISC", # flake8-implicit-str-concat + "ICN001", # import concentions; {name} should be imported as {asname} + "LOG", # flake8-logging + "N804", # First argument of a class method should be named cls + "N805", # First argument of a method should be named self + "N806", # Variable {name} in function should be snake_case + "N815", # Variable {name} in class scope should not be mixedCase + "PERF", # Perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-pathlib + "PYI", # flake8-pyi + "RET", # flake8-return + "RSE", # flake8-raise + "RUF", # Ruff-specific rules (see `ignore` for exclusions) + "S107", # Possible hardcoded password assigned to function default + "S102", # Use of exec detected + "S103", # bad-file-permissions + "S108", # hardcoded-temp-file + "S301", # suspicious-pickle-usage + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection + "SIM", # flake8-simplify + "SLF", # flake8-self + "SLOT", # flake8-slots + "T100", # Trace found: {name} used + "T20", # flake8-print + "TC", # flake8-type-checking + "TID", # Tidy imports + "TRY", # tryceratops + "UP", # pyupgrade + "UP031", # Use format specifiers instead of percent format + "UP032", # Use f-string instead of `format` call + "W", # pycodestyle +] + +ignore = [ + "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead + "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop + "ASYNC240", # Use an async function for entering the file system + "B008", # Do not perform function call in argument defaults; commonly used in Home Assistant (e.g. cv.* validators) + "B019", # Use of functools.lru_cache or functools.cache on methods can lead to memory leaks + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "D417", # Missing argument descriptions in docstring - to allow documenting only non-obvious parameters + "E501", # line too long + + "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives + "PLR0911", # Too many return statements ({returns} > {max_returns}) + "PLR0912", # Too many branches ({branches} > {max_branches}) + "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) + "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR0917", # Too many positional arguments defined for a function ({p_args} > {max_args}) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW0108", # Unnecessary lambda wrapping a function call; can often be replaced by the function itself + "PLW1641", # __eq__ without __hash__ + "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target + "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception + "PT018", # Assertion should be broken down into multiple parts + "RUF001", # String contains ambiguous unicode character. + "RUF012", # Mutable class attributes should be annotated with typing.ClassVar + "RUF015", # Prefer next(...) over single element slice + "RUF043", # Pattern passed to match= contains metacharacters but is neither escaped nor raw + "SIM102", # Use a single if statement instead of nested if statements + "SIM103", # Return the condition {condition} directly + "SIM108", # Use ternary operator {contents} instead of if-else-block + "SIM115", # Use context handler for opening files + + # Moving imports into type-checking blocks can mess with pytest.patch() + "TC001", # Move application import {} into a type-checking block + "TC002", # Move third-party import {} into a type-checking block + "TC003", # Move standard library import {} into a type-checking block + # Quotes for typing.cast generally not necessary, only for performance critical paths + "TC006", # Add quotes to type expression in typing.cast() + + "TRY003", # Avoid specifying long messages outside the exception class + "TRY400", # Use `logging.exception` instead of `logging.error` + + "UP047", # Non PEP 696 generic function + "UP049", # Avoid private type parameter names + + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q", + "COM812", + "COM819", + + # Disabled because ruff does not understand type of __all__ generated by a function + "PLE0605", + + "FURB116", + + # Disabled to implement in follow up PRs after ruff 0.16 bump + "ISC004", + "LOG004", + + # --------------------------------------------------------------------------- + # Project-specific: rules from the Home Assistant selection that this fork + # does not satisfy yet. Kept as a checklist to clear incrementally rather + # than in one sweep; new code is still linted against everything above. + # --------------------------------------------------------------------------- + "D100", # undocumented public module + "D101", # undocumented public class + "D102", # undocumented public method + "D103", # undocumented public function + "D105", # undocumented magic method + "D106", # undocumented public nested class + "D107", # undocumented __init__ + "D205", # blank line after docstring summary + "D415", # docstring first line punctuation + "A001", # `property` used as a loop variable shadows the builtin + "SLF001", # private-member access (coordinator `_state` fallback path) + "INP001", # `tests/` is not a package + "SIM105", # use contextlib.suppress(...) + "TRY300", # move the return into an else block + "TRY301", # abstract the raise into an inner function + "PERF203", # try/except inside a loop + "PERF403", # manual dict comprehension + "RET504", # unnecessary assignment before return + "C901", # function is too complex + "PLC0415", # import should be at top level + "PT022", # fixture yields without teardown + "PT006", # parametrize names as a tuple/list + "FURB171", # `x in (y,)` -> `x == y` + "C416", # unnecessary comprehension + "N806", # non-lowercase variable in function +] + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false +mark-parentheses = false + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"async_timeout".msg = "use asyncio.timeout instead" +"pytz".msg = "use zoneinfo instead" + +[tool.ruff.lint.isort] +force-sort-within-sections = true +known-first-party = ["homeassistant"] +combine-as-imports = true +split-on-trailing-comma = false + +[tool.ruff.lint.per-file-ignores] +# Relative imports are the norm inside a custom-component package +"custom_components/*/*" = ["TID252"] +"custom_components/*/*/*" = ["TID252"] + +# Temporary, mirroring Home Assistant core +"custom_components/**" = ["PTH"] +"tests/**" = ["PTH"] + +[tool.ruff.lint.mccabe] +max-complexity = 25 + +[tool.ruff.lint.pydocstyle] +convention = "google" diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index 5a41967..6000bb4 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -3,11 +3,11 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.const import CONF_URL, CONF_VERIFY_SSL -from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.pfsense.const import CONF_API_KEY, COORDINATOR, DOMAIN +from homeassistant.const import CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant @pytest.fixture(autouse=True) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index c7fe90a..159f7eb 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -3,9 +3,6 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.const import CONF_URL, CONF_VERIFY_SSL -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.pfsense.const import ( @@ -15,6 +12,9 @@ DOMAIN, ) from custom_components.pfsense.pypfsense import PfSenseAuthError +from homeassistant.const import CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType def _client_mock(**overrides): diff --git a/tests/test_init.py b/tests/test_init.py index 4f77918..97a933e 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -3,12 +3,12 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.const import CONF_URL, CONF_VERIFY_SSL -from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.pfsense import async_setup_entry, async_unload_entry from custom_components.pfsense.const import CONF_API_KEY, DOMAIN +from homeassistant.const import CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant @pytest.fixture(autouse=True) diff --git a/tests/test_pypfsense.py b/tests/test_pypfsense.py index b3cfcde..2151291 100644 --- a/tests/test_pypfsense.py +++ b/tests/test_pypfsense.py @@ -3,8 +3,8 @@ import re import aiohttp -import pytest from aioresponses import aioresponses +import pytest from custom_components.pfsense.pypfsense import ( Client, diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 419ac73..5cb7d64 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,11 +1,11 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest -from homeassistant.components.sensor import SensorEntityDescription from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.pfsense.const import DOMAIN from custom_components.pfsense.sensor import PfSenseOpenVPNServerSensor +from homeassistant.components.sensor import SensorEntityDescription @pytest.fixture diff --git a/tests/test_switch.py b/tests/test_switch.py index f729691..197bf63 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -3,10 +3,10 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest -from homeassistant.components.switch import SwitchEntityDescription from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.pfsense.switch import PfSenseServiceSwitch +from homeassistant.components.switch import SwitchEntityDescription @pytest.fixture From 37ee04ba645331171befece421f38bf057983f2e Mon Sep 17 00:00:00 2001 From: Nate Olsen Date: Sun, 6 Sep 2026 11:04:45 -0700 Subject: [PATCH 4/7] Lint cleanup: clear the non-docstring ignore-block rules Works through the smaller half of the deferred ruff findings so the project ignore block now only lists docstrings (D1xx), the `property` loop-variable shadowing (A001), private-member access (SLF001), the tests-not-a-package warning (INP001), and PT022. - dict_get (both copies): single try around the loop (PERF203), drop the `pathList` name (N806), add a docstring. - __init__ coordinator update functions: restructure so the success path is an `else` branch and no `raise` sits inside the `try` (TRY300/301); behaviour is unchanged (empty poll and errors both fall back to cache). - pypfsense: contextlib.suppress for the arp-entry / reboot / halt / kill-states swallow-and-continue spots (SIM105/PERF203), dict-update comprehension for the statetype backfill (PERF403). - binary_sensor / button: return the list literal directly (RET504). - sensor: list(SENSOR_TYPES) (C416), `== "connected_client_count"` (FURB171), gateway native_value reads outside the try (TRY300); the two genuinely branchy entity-builder callbacks get `# noqa: C901`. - switch service is_on: return the lookup directly (RET504/TRY300). - services: keep the deferred import, `# noqa: PLC0415` (breaks a cycle). - tests: parametrize names as a tuple (PT006). Co-Authored-By: Claude Sonnet 5 --- custom_components/pfsense/__init__.py | 55 ++++++++++--------- custom_components/pfsense/binary_sensor.py | 3 +- custom_components/pfsense/button.py | 3 +- .../pfsense/pypfsense/__init__.py | 39 ++++++------- custom_components/pfsense/sensor.py | 28 +++++----- custom_components/pfsense/services.py | 4 +- custom_components/pfsense/switch.py | 5 +- pyproject.toml | 12 ---- tests/test_pypfsense.py | 2 +- 9 files changed, 71 insertions(+), 80 deletions(-) diff --git a/custom_components/pfsense/__init__.py b/custom_components/pfsense/__init__.py index 266f176..31e5992 100644 --- a/custom_components/pfsense/__init__.py +++ b/custom_components/pfsense/__init__.py @@ -75,15 +75,14 @@ async def async_load_cache(hass: HomeAssistant, entry_id: str): def dict_get(data: dict, path: str, default=None): - pathList = re.split(r"\.", path, flags=re.IGNORECASE) + """Traverse a nested dict/list by a dotted path; numeric segments index lists.""" result = data - for key in pathList: - try: + try: + for key in re.split(r"\.", path, flags=re.IGNORECASE): key = int(key) if key.isnumeric() else key result = result[key] - except (KeyError, IndexError, TypeError): - result = default - break + except (KeyError, IndexError, TypeError): + return default return result @@ -172,22 +171,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_update_data(): """Fetch data from pfSense, falling back to the on-disk cache on failure.""" + new_state = None try: async with asyncio.timeout(scan_interval - 1): new_state = await data.update() - if not new_state: - raise UpdateFailed("no data received from pfSense") - await async_save_cache(hass, entry.entry_id, new_state) - return new_state except (PfSenseAuthError, PfSensePrivilegeError) as err: raise ConfigEntryAuthFailed(str(err)) from err - except Exception as err: - _LOGGER.warning("pfSense poll failed (%s); trying the local cache", err) - cached_data = await async_load_cache(hass, entry.entry_id) - if cached_data: - data._state = cached_data - return cached_data - raise UpdateFailed(f"poll failed and no cache available: {err}") from err + except Exception: + _LOGGER.warning( + "pfSense poll failed; trying the local cache", exc_info=True + ) + else: + if new_state: + await async_save_cache(hass, entry.entry_id, new_state) + return new_state + + cached_data = await async_load_cache(hass, entry.entry_id) + if cached_data: + data._state = cached_data + return cached_data + raise UpdateFailed("pfSense poll failed and no usable cache is available") coordinator = DataUpdateCoordinator( hass, @@ -209,21 +212,23 @@ async def async_update_data(): async def async_update_device_tracker_data(): """Fetch the ARP table from pfSense.""" + new_dt_state = None try: async with asyncio.timeout(device_tracker_scan_interval - 1): new_dt_state = await device_tracker_data.update( {"scope": "device_tracker"} ) - if not new_dt_state: - raise UpdateFailed("no device tracker data received") - return new_dt_state except (PfSenseAuthError, PfSensePrivilegeError) as err: raise ConfigEntryAuthFailed(str(err)) from err - except Exception as err: - _LOGGER.warning("pfSense device tracker update failed: %s", err) - if device_tracker_data._state: - return device_tracker_data._state - raise UpdateFailed(err) from err + except Exception: + _LOGGER.warning("pfSense device tracker update failed", exc_info=True) + else: + if new_dt_state: + return new_dt_state + + if device_tracker_data._state: + return device_tracker_data._state + raise UpdateFailed("pfSense device tracker update failed") device_tracker_coordinator = DataUpdateCoordinator( hass, diff --git a/custom_components/pfsense/binary_sensor.py b/custom_components/pfsense/binary_sensor.py index d2bd94f..ea71618 100644 --- a/custom_components/pfsense/binary_sensor.py +++ b/custom_components/pfsense/binary_sensor.py @@ -30,7 +30,7 @@ async def async_setup_entry( def process_entities_callback(hass, config_entry): data = hass.data[DOMAIN][config_entry.entry_id] coordinator = data[COORDINATOR] - entities = [ + return [ PfSenseCarpStatusBinarySensor( config_entry, coordinator, @@ -42,7 +42,6 @@ def process_entities_callback(hass, config_entry): False, ) ] - return entities cem = CoordinatorEntityManager( hass, diff --git a/custom_components/pfsense/button.py b/custom_components/pfsense/button.py index f165921..04c99ae 100644 --- a/custom_components/pfsense/button.py +++ b/custom_components/pfsense/button.py @@ -27,7 +27,7 @@ def process_entities_callback(hass, config_entry): data = hass.data[DOMAIN][config_entry.entry_id] coordinator = data[COORDINATOR] - entities = [ + return [ PfSenseRebootButton( config_entry, coordinator, @@ -52,7 +52,6 @@ def process_entities_callback(hass, config_entry): ), ), ] - return entities cem = CoordinatorEntityManager( hass, diff --git a/custom_components/pfsense/pypfsense/__init__.py b/custom_components/pfsense/pypfsense/__init__.py index eebb674..ce53eb7 100644 --- a/custom_components/pfsense/pypfsense/__init__.py +++ b/custom_components/pfsense/pypfsense/__init__.py @@ -13,6 +13,7 @@ from __future__ import annotations import asyncio +import contextlib import ipaddress import logging import re @@ -30,12 +31,12 @@ def dict_get(data: dict, path: str, default=None): """Traverse a nested dict/list by a dotted path. Numeric segments index lists.""" result = data - for key in re.split(r"\.", path): - try: + try: + for key in re.split(r"\.", path): key = int(key) if key.isnumeric() else key result = result[key] - except (KeyError, IndexError, TypeError): - return default + except (KeyError, IndexError, TypeError): + return default return result @@ -300,12 +301,10 @@ async def delete_arp_entry(self, ip: str) -> None: if entry.get("ip_address") == ip: entry_id = entry.get("id", ip) break - try: + with contextlib.suppress(PfSenseNotFoundError): await self._request( "DELETE", "/diagnostics/arp_table/entry", params={"id": entry_id} ) - except PfSenseNotFoundError: - pass # ------------------------------------------------------------ gateways @@ -371,9 +370,15 @@ async def _set_rule_disabled( if bool(rule.get("disabled")) == disabled: return payload = {"id": rule["id"], "disabled": disabled} - for field, fallback in self._RULE_REQUIRED_DEFAULTS.get(path, {}).items(): - if not rule.get(field): - payload[field] = fallback + payload.update( + { + field: fallback + for field, fallback in self._RULE_REQUIRED_DEFAULTS.get( + path, {} + ).items() + if not rule.get(field) + } + ) async with self._write_lock: await self._request("PATCH", path, payload=payload) await self._apply("firewall") @@ -534,14 +539,12 @@ async def kill_states_for_rule(self, rule: dict) -> None: async with self._write_lock: for prefix in prefixes: for field in ("source", "destination"): - try: + with contextlib.suppress(PfSenseAPIError): await self._request( "DELETE", "/firewall/states", params={f"{field}__startswith": prefix, "limit": 0}, ) - except PfSenseAPIError: - pass async def _rule_match_networks(self, rule: dict) -> list: """Concrete ``ip_network`` objects for a rule's source + destination.""" @@ -569,16 +572,14 @@ async def _rule_match_networks(self, rule: dict) -> list: # ------------------------------------------------------- system control async def system_reboot(self, type: str = "normal") -> None: - try: + # The connection drops as the box goes down -- that is success. + with contextlib.suppress(PfSenseConnectionError): await self._request("POST", "/diagnostics/reboot", payload={}) - except PfSenseConnectionError: - pass # connection drops as the box goes down async def system_halt(self) -> None: - try: + # The connection drops as the box goes down -- that is success. + with contextlib.suppress(PfSenseConnectionError): await self._request("POST", "/diagnostics/halt_system", payload={}) - except PfSenseConnectionError: - pass # ------------------------------------------------------------------ wol diff --git a/custom_components/pfsense/sensor.py b/custom_components/pfsense/sensor.py index ad93464..8dd70da 100644 --- a/custom_components/pfsense/sensor.py +++ b/custom_components/pfsense/sensor.py @@ -34,7 +34,7 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry( +async def async_setup_entry( # noqa: C901 - flat per-telemetry-type entity builder hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: entity_platform.AddEntitiesCallback, @@ -42,11 +42,11 @@ async def async_setup_entry( """Set up the pfSense sensors.""" @callback - def process_entities_callback(hass, config_entry): + def process_entities_callback(hass, config_entry): # noqa: C901 - see above data = hass.data[DOMAIN][config_entry.entry_id] coordinator = data[COORDINATOR] state = coordinator.data - resources = [sensor_id for sensor_id in SENSOR_TYPES] + resources = list(SENSOR_TYPES) entities = [] @@ -257,7 +257,7 @@ def process_entities_callback(hass, config_entry): if native_unit_of_measurement is None and "bytes" in property: native_unit_of_measurement = UnitOfInformation.BYTES - if property in ["connected_client_count"]: + if property == "connected_client_count": native_unit_of_measurement = "clients" if "bytes" in property: @@ -567,24 +567,24 @@ def icon(self): @property def native_value(self): gateway = self._pfsense_get_gateway() - property = self._pfsense_get_gateway_property_name() + prop = self._pfsense_get_gateway_property_name() if gateway is None: return STATE_UNKNOWN try: - value = gateway[property] - if property in ["stddev", "delay", "loss"] and isinstance(value, str): - value = re.sub(r"[^0-9\.]*", "", value) - if len(value) > 0: - value = float(value) + value = gateway[prop] + except KeyError: + return STATE_UNKNOWN - if isinstance(value, str) and len(value) < 1: - return STATE_UNKNOWN + if prop in ["stddev", "delay", "loss"] and isinstance(value, str): + value = re.sub(r"[^0-9\.]*", "", value) + if len(value) > 0: + value = float(value) - return value - except KeyError: + if isinstance(value, str) and len(value) < 1: return STATE_UNKNOWN + return value class PfSenseOpenVPNServerSensor(PfSenseSensor): diff --git a/custom_components/pfsense/services.py b/custom_components/pfsense/services.py index c0e51d9..3434b13 100644 --- a/custom_components/pfsense/services.py +++ b/custom_components/pfsense/services.py @@ -50,8 +50,8 @@ def async_register(self): _data.add("loaded") - # --- DEFERRED RUNTIME INJECTION PATCH --- - from . import PfSenseEntity + # Deferred to break the services <-> __init__ import cycle. + from . import PfSenseEntity # noqa: PLC0415 async def service_update_alias( self_entity, diff --git a/custom_components/pfsense/switch.py b/custom_components/pfsense/switch.py index 4b72ea2..0201b0e 100644 --- a/custom_components/pfsense/switch.py +++ b/custom_components/pfsense/switch.py @@ -352,10 +352,9 @@ def available(self) -> bool: @property def is_on(self): service = self._pfsense_get_service() - property = self._pfsense_get_property_name() + prop = self._pfsense_get_property_name() try: - value = service[property] - return value + return service[prop] except KeyError: return STATE_UNKNOWN diff --git a/pyproject.toml b/pyproject.toml index b6e058b..30f9cae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,19 +159,7 @@ ignore = [ "A001", # `property` used as a loop variable shadows the builtin "SLF001", # private-member access (coordinator `_state` fallback path) "INP001", # `tests/` is not a package - "SIM105", # use contextlib.suppress(...) - "TRY300", # move the return into an else block - "TRY301", # abstract the raise into an inner function - "PERF203", # try/except inside a loop - "PERF403", # manual dict comprehension - "RET504", # unnecessary assignment before return - "C901", # function is too complex - "PLC0415", # import should be at top level "PT022", # fixture yields without teardown - "PT006", # parametrize names as a tuple/list - "FURB171", # `x in (y,)` -> `x == y` - "C416", # unnecessary comprehension - "N806", # non-lowercase variable in function ] [tool.ruff.lint.flake8-pytest-style] diff --git a/tests/test_pypfsense.py b/tests/test_pypfsense.py index 2151291..debf1a7 100644 --- a/tests/test_pypfsense.py +++ b/tests/test_pypfsense.py @@ -62,7 +62,7 @@ async def test_request_unwraps_data(client): @pytest.mark.parametrize( - "code,exc", + ("code", "exc"), [ (401, PfSenseAuthError), (403, PfSensePrivilegeError), From 7fd23da27744d3f10776b73809805be1d8d4098b Mon Sep 17 00:00:00 2001 From: Nate Olsen Date: Sun, 6 Sep 2026 11:07:50 -0700 Subject: [PATCH 5/7] Lint cleanup: A001, SLF001, INP001, PT022 - Rename the `property` loop/local variables to `prop` across sensor.py, switch.py, device_tracker.py so they stop shadowing the builtin (A001). - PfSenseData grows a `restore_state()` method and the device-tracker coordinator reads `.state`, so the cache-fallback path no longer pokes `_state` directly; the runtime-injected update_alias service takes `self` instead of `self_entity` (SLF001). Tests legitimately touch internals, so `tests/**` ignores SLF001 via per-file-ignores. - Add `tests/__init__.py` so the test files are a real package (INP001). - The `auto_enable_custom_integrations` fixtures `return` instead of `yield` since they have no teardown (PT022). The ignore block is now only the docstring rules. Co-Authored-By: Claude Sonnet 5 --- custom_components/pfsense/__init__.py | 11 ++- custom_components/pfsense/device_tracker.py | 4 +- custom_components/pfsense/sensor.py | 91 ++++++++++----------- custom_components/pfsense/services.py | 6 +- custom_components/pfsense/switch.py | 14 ++-- pyproject.toml | 8 +- tests/__init__.py | 1 + tests/conftest.py | 2 +- tests/test_binary_sensor.py | 2 +- tests/test_init.py | 2 +- 10 files changed, 71 insertions(+), 70 deletions(-) create mode 100644 tests/__init__.py diff --git a/custom_components/pfsense/__init__.py b/custom_components/pfsense/__init__.py index 31e5992..bca8d50 100644 --- a/custom_components/pfsense/__init__.py +++ b/custom_components/pfsense/__init__.py @@ -188,7 +188,7 @@ async def async_update_data(): cached_data = await async_load_cache(hass, entry.entry_id) if cached_data: - data._state = cached_data + data.restore_state(cached_data) return cached_data raise UpdateFailed("pfSense poll failed and no usable cache is available") @@ -226,8 +226,8 @@ async def async_update_device_tracker_data(): if new_dt_state: return new_dt_state - if device_tracker_data._state: - return device_tracker_data._state + if device_tracker_data.state: + return device_tracker_data.state raise UpdateFailed("pfSense device tracker update failed") device_tracker_coordinator = DataUpdateCoordinator( @@ -313,8 +313,13 @@ def __init__( @property def state(self): + """Return the most recently fetched (or restored) poll state.""" return self._state + def restore_state(self, state: dict) -> None: + """Adopt a state dict loaded from the on-disk cache.""" + self._state = state + async def update(self, opts=None): """Fetch the latest state from pfSense over the REST API.""" opts = opts or {} diff --git a/custom_components/pfsense/device_tracker.py b/custom_components/pfsense/device_tracker.py index 38e01f5..f80fc0b 100644 --- a/custom_components/pfsense/device_tracker.py +++ b/custom_components/pfsense/device_tracker.py @@ -205,8 +205,8 @@ def _extra_state_attributes(self) -> Mapping[str, Any] | None: """Return extra state attributes.""" entry = self._get_pfsense_arp_entry() if entry is not None: - for property in ["interface", "expires", "type"]: - self._extra_state[property] = entry.get(property) + for prop in ["interface", "expires", "type"]: + self._extra_state[prop] = entry.get(prop) if self._last_known_hostname is not None: self._extra_state["last_known_hostname"] = self._last_known_hostname diff --git a/custom_components/pfsense/sensor.py b/custom_components/pfsense/sensor.py index 8dd70da..f003665 100644 --- a/custom_components/pfsense/sensor.py +++ b/custom_components/pfsense/sensor.py @@ -115,7 +115,7 @@ def process_entities_callback(hass, config_entry): # noqa: C901 - see above for interface_name in dict_get(state, "telemetry.interfaces", {}): interface = state["telemetry"]["interfaces"][interface_name] - for property in [ + for prop in [ "status", "inerrs", "outerrs", @@ -142,7 +142,7 @@ def process_entities_callback(hass, config_entry): # noqa: C901 - see above icon = None enabled_default = False - if property in [ + if prop in [ "status", "inbytes_kilobytes_per_second", "outbytes_kilobytes_per_second", @@ -151,33 +151,30 @@ def process_entities_callback(hass, config_entry): # noqa: C901 - see above ]: enabled_default = True - if ( - "_packets_per_second" in property - or "_kilobytes_per_second" in property - ): + if "_packets_per_second" in prop or "_kilobytes_per_second" in prop: state_class = SensorStateClass.MEASUREMENT - if "_packets_per_second" in property: + if "_packets_per_second" in prop: native_unit_of_measurement = DATA_RATE_PACKETS_PER_SECOND - if "_kilobytes_per_second" in property: + if "_kilobytes_per_second" in prop: native_unit_of_measurement = UnitOfDataRate.KILOBYTES_PER_SECOND if native_unit_of_measurement is None: - if "bytes" in property: + if "bytes" in prop: native_unit_of_measurement = UnitOfInformation.BYTES state_class = SensorStateClass.TOTAL_INCREASING - if "pkts" in property: + if "pkts" in prop: native_unit_of_measurement = DATA_PACKETS state_class = SensorStateClass.TOTAL_INCREASING - if property in ["inerrs", "outerrs", "collisions"]: + if prop in ["inerrs", "outerrs", "collisions"]: native_unit_of_measurement = COUNT - if "pkts" in property or "bytes" in property: + if "pkts" in prop or "bytes" in prop: icon = "mdi:server-network" - if property == "status": + if prop == "status": icon = "mdi:check-network-outline" if icon is None: @@ -188,9 +185,9 @@ def process_entities_callback(hass, config_entry): # noqa: C901 - see above coordinator, SensorEntityDescription( key="telemetry.interface.{}.{}".format( - interface["ifname"], property + interface["ifname"], prop ), - name="Interface {} {}".format(interface["descr"], property), + name="Interface {} {}".format(interface["descr"], prop), native_unit_of_measurement=native_unit_of_measurement, icon=icon, state_class=state_class, @@ -201,27 +198,27 @@ def process_entities_callback(hass, config_entry): # noqa: C901 - see above for gateway_name in dict_get(state, "telemetry.gateways", {}): gateway = state["telemetry"]["gateways"][gateway_name] - for property in ["status", "delay", "stddev", "loss"]: + for prop in ["status", "delay", "stddev", "loss"]: state_class = None native_unit_of_measurement = None icon = "mdi:router-network" enabled_default = True - if property == "loss": + if prop == "loss": native_unit_of_measurement = PERCENTAGE - if property in ["delay", "stddev"]: + if prop in ["delay", "stddev"]: native_unit_of_measurement = UnitOfTime.MILLISECONDS - if property == "status": + if prop == "status": icon = "mdi:check-network-outline" entity = PfSenseGatewaySensor( config_entry, coordinator, SensorEntityDescription( - key="telemetry.gateway.{}.{}".format(gateway["name"], property), - name="Gateway {} {}".format(gateway["name"], property), + key="telemetry.gateway.{}.{}".format(gateway["name"], prop), + name="Gateway {} {}".format(gateway["name"], prop), native_unit_of_measurement=native_unit_of_measurement, icon=icon, state_class=state_class, @@ -233,7 +230,7 @@ def process_entities_callback(hass, config_entry): # noqa: C901 - see above for vpnid in dict_get(state, "telemetry.openvpn.servers", {}): servers = dict_get(state, "telemetry.openvpn.servers", {}) server = servers[vpnid] - for property in [ + for prop in [ "connected_client_count", "total_bytes_recv", "total_bytes_sent", @@ -245,25 +242,25 @@ def process_entities_callback(hass, config_entry): # noqa: C901 - see above icon = None enabled_default = False - if "_kilobytes_per_second" in property: + if "_kilobytes_per_second" in prop: state_class = SensorStateClass.MEASUREMENT - if property == "connected_client_count": + if prop == "connected_client_count": state_class = SensorStateClass.MEASUREMENT - if "_kilobytes_per_second" in property: + if "_kilobytes_per_second" in prop: native_unit_of_measurement = UnitOfDataRate.KILOBYTES_PER_SECOND - if native_unit_of_measurement is None and "bytes" in property: + if native_unit_of_measurement is None and "bytes" in prop: native_unit_of_measurement = UnitOfInformation.BYTES - if property == "connected_client_count": + if prop == "connected_client_count": native_unit_of_measurement = "clients" - if "bytes" in property: + if "bytes" in prop: icon = "mdi:server-network" - if property == "connected_client_count": + if prop == "connected_client_count": icon = "mdi:ip-network-outline" if icon is None: @@ -273,9 +270,9 @@ def process_entities_callback(hass, config_entry): # noqa: C901 - see above config_entry, coordinator, SensorEntityDescription( - key=f"telemetry.openvpn.servers.{vpnid}.{property}", + key=f"telemetry.openvpn.servers.{vpnid}.{prop}", name="OpenVPN Server {} ({}) {}".format( - vpnid, server["name"], property + vpnid, server["name"], prop ), native_unit_of_measurement=native_unit_of_measurement, icon=icon, @@ -411,8 +408,8 @@ def _pfsense_get_interface(self): @property def available(self) -> bool: interface = self._pfsense_get_interface() - property = self._pfsense_get_interface_property_name() - if interface is None or property not in interface: + prop = self._pfsense_get_interface_property_name() + if interface is None or prop not in interface: return False return super().available @@ -427,17 +424,17 @@ def extra_state_attributes(self): @property def icon(self): - property = self._pfsense_get_interface_property_name() - if property == "status" and self.native_value != "up": + prop = self._pfsense_get_interface_property_name() + if prop == "status" and self.native_value != "up": return "mdi:close-network-outline" return super().icon @property def native_value(self): interface = self._pfsense_get_interface() - property = self._pfsense_get_interface_property_name() + prop = self._pfsense_get_interface_property_name() try: - return interface[property] + return interface[prop] except KeyError: return STATE_UNKNOWN @@ -524,12 +521,12 @@ def _pfsense_get_gateway_details(self): @property def available(self) -> bool: gateway = self._pfsense_get_gateway() - property = self._pfsense_get_gateway_property_name() - if gateway is None or property not in gateway: + prop = self._pfsense_get_gateway_property_name() + if gateway is None or prop not in gateway: return False - if property in ["stddev", "delay", "loss"]: - value = gateway[property] + if prop in ["stddev", "delay", "loss"]: + value = gateway[prop] if isinstance(value, str): value = re.sub(r"[^0-9\.]*", "", value) if len(value) < 1: @@ -559,8 +556,8 @@ def extra_state_attributes(self): @property def icon(self): - property = self._pfsense_get_gateway_property_name() - if property == "status" and self.native_value != "online": + prop = self._pfsense_get_gateway_property_name() + if prop == "status" and self.native_value != "online": return "mdi:close-network-outline" return super().icon @@ -607,8 +604,8 @@ def _pfsense_get_server(self): @property def available(self) -> bool: server = self._pfsense_get_server() - property = self._pfsense_get_server_property_name() - if server is None or property not in server: + prop = self._pfsense_get_server_property_name() + if server is None or prop not in server: return False return super().available @@ -626,12 +623,12 @@ def extra_state_attributes(self): @property def native_value(self): server = self._pfsense_get_server() - property = self._pfsense_get_server_property_name() + prop = self._pfsense_get_server_property_name() if server is None: return STATE_UNKNOWN try: - return server[property] + return server[prop] except KeyError: return STATE_UNKNOWN diff --git a/custom_components/pfsense/services.py b/custom_components/pfsense/services.py index 3434b13..7d667f7 100644 --- a/custom_components/pfsense/services.py +++ b/custom_components/pfsense/services.py @@ -54,14 +54,14 @@ def async_register(self): from . import PfSenseEntity # noqa: PLC0415 async def service_update_alias( - self_entity, + self, alias_name: str, address: str, action: str, kill_states: bool = True, ): - """Dynamic extension mapping runtime command parameters directly to the Client interface.""" - client = self_entity._get_pfsense_client() + """Bind the update_alias service onto PfSenseEntity at runtime.""" + client = self._get_pfsense_client() await client.update_alias_address(alias_name, address, action, kill_states) if not hasattr(PfSenseEntity, "service_update_alias"): diff --git a/custom_components/pfsense/switch.py b/custom_components/pfsense/switch.py index 0201b0e..10d53a6 100644 --- a/custom_components/pfsense/switch.py +++ b/custom_components/pfsense/switch.py @@ -112,7 +112,7 @@ def process_entities_callback(hass, config_entry): # services for service in state["services"]: - for property in ["status"]: + for prop in ["status"]: icon = "mdi:application-cog-outline" # likely only want very specific services to manipulate from actions enabled_default = False @@ -122,14 +122,14 @@ def process_entities_callback(hass, config_entry): if service["name"] == "openvpn" and service.get("vpnid"): key = "service.{}.{}".format( service["name"] + "-" + str(service["vpnid"]), - property, + prop, ) name = "Service {} {}".format( - service["name"] + " " + service.get("description", ""), property + service["name"] + " " + service.get("description", ""), prop ) else: - key = "service.{}.{}".format(service["name"], property) - name = "Service {} {}".format(service["name"], property) + key = "service.{}.{}".format(service["name"], prop) + name = "Service {} {}".format(service["name"], prop) entity = PfSenseServiceSwitch( config_entry, @@ -343,8 +343,8 @@ def _pfsense_get_service(self): @property def available(self) -> bool: service = self._pfsense_get_service() - property = self._pfsense_get_property_name() - if service is None or property not in service: + prop = self._pfsense_get_property_name() + if service is None or prop not in service: return False return super().available diff --git a/pyproject.toml b/pyproject.toml index 30f9cae..f3f5a18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -156,10 +156,6 @@ ignore = [ "D107", # undocumented __init__ "D205", # blank line after docstring summary "D415", # docstring first line punctuation - "A001", # `property` used as a loop variable shadows the builtin - "SLF001", # private-member access (coordinator `_state` fallback path) - "INP001", # `tests/` is not a package - "PT022", # fixture yields without teardown ] [tool.ruff.lint.flake8-pytest-style] @@ -181,9 +177,11 @@ split-on-trailing-comma = false "custom_components/*/*" = ["TID252"] "custom_components/*/*/*" = ["TID252"] +# Tests reach into client / entity internals on purpose +"tests/**" = ["PTH", "SLF001"] + # Temporary, mirroring Home Assistant core "custom_components/**" = ["PTH"] -"tests/**" = ["PTH"] [tool.ruff.lint.mccabe] max-complexity = 25 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..683bcd9 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for the pfSense integration.""" diff --git a/tests/conftest.py b/tests/conftest.py index e874b4e..e366565 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,4 +4,4 @@ @pytest.fixture(autouse=True) def auto_enable_custom_integrations(enable_custom_integrations): """Enable custom integrations automatically for all tests.""" - yield + return diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index 6000bb4..89537e3 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -12,7 +12,7 @@ @pytest.fixture(autouse=True) def auto_enable_custom_integrations(enable_custom_integrations): - yield + return @pytest.fixture diff --git a/tests/test_init.py b/tests/test_init.py index 97a933e..7099d8e 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -13,7 +13,7 @@ @pytest.fixture(autouse=True) def auto_enable_custom_integrations(enable_custom_integrations): - yield + return def _full_client_mock(): From 6c2c12acab679b5c36b2ecc8c6f0590ac6e06c15 Mon Sep 17 00:00:00 2001 From: Nate Olsen Date: Sun, 6 Sep 2026 11:12:03 -0700 Subject: [PATCH 6/7] Add docstrings and drop the ruff ignore block Documents every public module, class, method and function flagged by the Home Assistant `D` (pydocstyle) rules, plus the two D415 / one D205 wording fixes. Client methods, entity classes and the service handlers get hand-written summaries; entity property accessors and test functions get the conventional one-liners. With that done the project-specific ignore block is removed: pyproject.toml now carries the Home Assistant rule selection verbatim, and `ruff check .` is clean against it. Co-Authored-By: Claude Sonnet 5 --- custom_components/pfsense/__init__.py | 22 ++++++++++- custom_components/pfsense/binary_sensor.py | 8 ++++ custom_components/pfsense/button.py | 10 +++++ custom_components/pfsense/config_flow.py | 4 ++ custom_components/pfsense/device_tracker.py | 2 + .../pfsense/pypfsense/__init__.py | 38 +++++++++++++++++-- custom_components/pfsense/sensor.py | 33 ++++++++++++++++ custom_components/pfsense/services.py | 5 +++ custom_components/pfsense/switch.py | 16 ++++++++ custom_components/pfsense/update.py | 9 +++++ pyproject.toml | 15 -------- tests/conftest.py | 2 + tests/test_binary_sensor.py | 4 ++ tests/test_config_flow.py | 4 ++ tests/test_device_tracker.py | 2 + tests/test_init.py | 2 + tests/test_pypfsense.py | 12 ++++++ tests/test_sensor.py | 4 ++ tests/test_switch.py | 2 + 19 files changed, 173 insertions(+), 21 deletions(-) diff --git a/custom_components/pfsense/__init__.py b/custom_components/pfsense/__init__.py index bca8d50..416d0af 100644 --- a/custom_components/pfsense/__init__.py +++ b/custom_components/pfsense/__init__.py @@ -1,4 +1,4 @@ -"""Support for pfSense REST API""" +"""Support for the pfSense REST API integration.""" from __future__ import annotations @@ -301,6 +301,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class PfSenseData: + """Fetches and holds the pfSense poll state for a config entry.""" + def __init__( self, client: pfSenseClient, config_entry: ConfigEntry, hass: HomeAssistant ): @@ -423,6 +425,7 @@ def __init__( process_entities_callback: Callable, async_add_entities: AddEntitiesCallback, ) -> None: + """Initialize the data holder.""" self.hass = hass self.coordinator = coordinator self.config_entry = config_entry @@ -437,6 +440,7 @@ def __init__( @callback def process_entities(self): + """Build entities from the current coordinator data and add new ones.""" entities = self.process_entities_callback(self.hass, self.config_entry) new_entities = [] @@ -450,14 +454,16 @@ def process_entities(self): class PfSenseEntity(CoordinatorEntity, RestoreEntity): - """base entity for pfSense""" + """Base entity for pfSense.""" @property def coordinator_context(self): + """Return the coordinator context.""" return None @property def device_info(self): + """Return device registry information.""" state = self.coordinator.data if not state or "host_firmware_version" not in state: return None @@ -473,12 +479,14 @@ def device_info(self): @property def pfsense_device_name(self): + """Return the pfsense device name.""" if self.config_entry.title: return self.config_entry.title return f"{self._get_pfsense_state_value('system_info.hostname')}.{self._get_pfsense_state_value('system_info.domain')}" @property def pfsense_device_unique_id(self): + """Return the pfsense device unique id.""" return self._get_pfsense_state_value("system_info.netgate_device_id") def _get_pfsense_state_value(self, path, default=None): @@ -490,11 +498,13 @@ def _get_pfsense_client(self) -> pfSenseClient: async def service_start_service( self, service_name: str, service: dict | str | None = None ): + """Handle the pfsense.start_service service call.""" await self._get_pfsense_client().start_service(service_name, service) async def service_stop_service( self, service_name: str, service: dict | str | None = None ): + """Handle the pfsense.stop_service service call.""" await self._get_pfsense_client().stop_service(service_name, service) async def service_restart_service( @@ -503,6 +513,7 @@ async def service_restart_service( only_if_running: int | str | bool | None = False, service: dict | str | None = None, ): + """Handle the pfsense.restart_service service call.""" client = self._get_pfsense_client() if str(only_if_running).lower() in ["true", "1"]: await client.restart_service_if_running(service_name, service) @@ -510,22 +521,29 @@ async def service_restart_service( await client.restart_service(service_name, service) async def service_reset_state_table(self): + """Handle the pfsense.reset_state_table service call.""" await self._get_pfsense_client().reset_state_table() async def service_kill_states(self, source: str, destination: str | None = None): + """Handle the pfsense.kill_states service call.""" await self._get_pfsense_client().kill_states(source, destination) async def service_system_halt(self): + """Handle the pfsense.system_halt service call.""" await self._get_pfsense_client().system_halt() async def service_system_reboot(self): + """Handle the pfsense.system_reboot service call.""" await self._get_pfsense_client().system_reboot() async def service_send_wol(self, interface: str, mac: str): + """Handle the pfsense.send_wol service call.""" await self._get_pfsense_client().send_wol(interface, mac) async def service_set_default_gateway(self, gateway: str, ip_version: str): + """Handle the pfsense.set_default_gateway service call.""" await self._get_pfsense_client().set_default_gateway(gateway, ip_version) async def service_exec_command(self, command: str, background: bool = False): + """Handle the pfsense.exec_command service call.""" await self._get_pfsense_client().exec_command(command, background) diff --git a/custom_components/pfsense/binary_sensor.py b/custom_components/pfsense/binary_sensor.py index ea71618..05b8bb0 100644 --- a/custom_components/pfsense/binary_sensor.py +++ b/custom_components/pfsense/binary_sensor.py @@ -54,6 +54,8 @@ def process_entities_callback(hass, config_entry): class PfSenseBinarySensor(PfSenseEntity, BinarySensorEntity): + """Base class for pfSense binary sensors.""" + def __init__( self, config_entry, @@ -73,20 +75,26 @@ def __init__( @property def is_on(self): + """Return true if the entity is on.""" return False @property def device_class(self): + """Return the device class.""" return None @property def extra_state_attributes(self): + """Return the entity's extra state attributes.""" return None class PfSenseCarpStatusBinarySensor(PfSenseBinarySensor): + """Binary sensor for the CARP maintenance/enable state.""" + @property def is_on(self): + """Return true if the entity is on.""" state = self.coordinator.data try: return state["carp_status"] diff --git a/custom_components/pfsense/button.py b/custom_components/pfsense/button.py index 04c99ae..1ca6cbe 100644 --- a/custom_components/pfsense/button.py +++ b/custom_components/pfsense/button.py @@ -72,6 +72,7 @@ def __init__( coordinator: DataUpdateCoordinator, entity_description: ButtonEntityDescription, ) -> None: + """Initialize the object.""" self.config_entry = config_entry self.entity_description = entity_description self.coordinator = coordinator @@ -82,15 +83,24 @@ def __init__( class PfSenseRebootButton(PfSenseButton): + """Button that reboots the firewall.""" + async def async_press(self) -> None: + """Handle the button press.""" await self.service_system_reboot() class PfSenseHaltButton(PfSenseButton): + """Button that halts the firewall.""" + async def async_press(self) -> None: + """Handle the button press.""" await self.service_system_halt() class PfSenseResetStatesButton(PfSenseButton): + """Button that flushes the firewall state table.""" + async def async_press(self) -> None: + """Handle the button press.""" await self.service_reset_state_table() diff --git a/custom_components/pfsense/config_flow.py b/custom_components/pfsense/config_flow.py index e331d6e..ee9eff6 100644 --- a/custom_components/pfsense/config_flow.py +++ b/custom_components/pfsense/config_flow.py @@ -65,6 +65,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 3 def __init__(self) -> None: + """Initialize the flow state.""" self._reauth_entry: config_entries.ConfigEntry | None = None async def async_step_user(self, user_input=None): @@ -191,6 +192,7 @@ async def async_step_reauth_confirm(self, user_input=None): @staticmethod @callback def async_get_options_flow(config_entry): + """Return the options flow handler.""" return OptionsFlowHandler() @@ -198,9 +200,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Handle the pfSense options flow.""" def __init__(self) -> None: + """Initialize the object.""" self.new_options: dict | None = None async def async_step_init(self, user_input=None): + """Handle the options form.""" if user_input is not None: if user_input.get(CONF_DEVICE_TRACKER_ENABLED): self.new_options = user_input diff --git a/custom_components/pfsense/device_tracker.py b/custom_components/pfsense/device_tracker.py index f80fc0b..5f210b9 100644 --- a/custom_components/pfsense/device_tracker.py +++ b/custom_components/pfsense/device_tracker.py @@ -37,6 +37,7 @@ def lookup_mac(mac_vendor_lookup: AsyncMacLookup, mac: str) -> str: + """Return the OUI vendor string for a MAC address.""" mac = mac_vendor_lookup.sanitise(mac) if isinstance(mac, str): mac = mac.encode("utf8") @@ -184,6 +185,7 @@ def _get_pfsense_arp_entry(self) -> dict[str, str]: @property def available(self) -> bool: + """Return whether the entity is available.""" state = self.coordinator.data arp_table = dict_get(state, "arp_table") if arp_table is None: diff --git a/custom_components/pfsense/pypfsense/__init__.py b/custom_components/pfsense/pypfsense/__init__.py index ce53eb7..6d5a844 100644 --- a/custom_components/pfsense/pypfsense/__init__.py +++ b/custom_components/pfsense/pypfsense/__init__.py @@ -64,6 +64,7 @@ class PfSenseAPIError(PfSenseError): """Any other non-2xx response. Carries ``code`` / ``response_id`` / ``message``.""" def __init__(self, code: int, response_id: str, message: str) -> None: + """Store the HTTP code, machine response id and human message.""" self.code = code self.response_id = response_id self.message = message @@ -96,6 +97,7 @@ def __init__( session: aiohttp.ClientSession, opts: dict | None = None, ) -> None: + """Store the base URL, API key, aiohttp session and options.""" opts = opts or {} parts = urlparse(url.rstrip("/")) self._base = f"{parts.scheme}://{parts.netloc}{API_BASE}" @@ -200,6 +202,7 @@ async def get_firmware_update_info(self) -> dict | None: return None async def get_dns_servers(self) -> list[str]: + """Return the configured system DNS servers.""" data = await self._get("/system/dns") return data.get("dnsserver", []) if isinstance(data, dict) else [] @@ -232,6 +235,7 @@ async def get_telemetry(self) -> dict: # ------------------------------------------------------------- services async def get_services(self) -> list[dict]: + """Return the list of pfSense services and their status.""" data = await self._get("/status/services") return data or [] @@ -251,6 +255,7 @@ async def _find_service(self, service_name: str) -> dict | None: async def start_service( self, service_name: str, service: dict | None = None ) -> None: + """Start a pfSense service by name.""" svc = service if isinstance(service, dict) and "id" in service else None svc = svc or await self._find_service(service_name) if svc: @@ -259,6 +264,7 @@ async def start_service( async def stop_service( self, service_name: str, service: dict | None = None ) -> None: + """Stop a pfSense service by name.""" svc = service if isinstance(service, dict) and "id" in service else None svc = svc or await self._find_service(service_name) if svc: @@ -267,6 +273,7 @@ async def stop_service( async def restart_service( self, service_name: str, service: dict | None = None ) -> None: + """Restart a pfSense service by name.""" svc = service if isinstance(service, dict) and "id" in service else None svc = svc or await self._find_service(service_name) if svc: @@ -275,6 +282,7 @@ async def restart_service( async def restart_service_if_running( self, service_name: str, service: dict | None = None ) -> None: + """Restart a pfSense service only if it is currently running.""" svc = service if isinstance(service, dict) and "id" in service else None svc = svc or await self._find_service(service_name) if svc and svc.get("status"): @@ -283,16 +291,19 @@ async def restart_service_if_running( # ---------------------------------------------------------------- dhcp async def get_dhcp_leases(self, dns_lookups=None) -> list[dict]: + """Return the current DHCP leases.""" data = await self._get("/status/dhcp_server/leases") return data or [] # ----------------------------------------------------------------- arp async def get_arp_table(self, resolve_hostnames: bool = False) -> list[dict]: + """Return the ARP table entries.""" data = await self._get("/diagnostics/arp_table") return data or [] async def delete_arp_entry(self, ip: str) -> None: + """Delete the ARP entry for an IP address.""" if not ip: return entry_id: Any = ip @@ -330,6 +341,7 @@ async def get_gateways_detail(self) -> dict: return out async def set_default_gateway(self, gateway: str, ip_version: str = "4") -> None: + """Set the default IPv4 or IPv6 gateway and apply routing.""" key = "defaultgw6" if "6" in str(ip_version) else "defaultgw4" async with self._write_lock: await self._request( @@ -340,14 +352,17 @@ async def set_default_gateway(self, gateway: str, ip_version: str = "4") -> None # -------------------------------------------------------- firewall rules async def get_filter_rules(self) -> list[dict]: + """Return the firewall filter rules.""" data = await self._get("/firewall/rules") return data or [] async def get_nat_port_forward_rules(self) -> list[dict]: + """Return the NAT port-forward rules.""" data = await self._get("/firewall/nat/port_forwards") return data or [] async def get_nat_outbound_rules(self) -> list[dict]: + """Return the NAT outbound mappings.""" data = await self._get("/firewall/nat/outbound/mappings") return data or [] @@ -385,16 +400,19 @@ async def _set_rule_disabled( return async def enable_filter_rule_by_tracker(self, tracker) -> None: + """Enable the firewall rule with the given tracker id.""" await self._set_rule_disabled( "/firewall/rule", await self.get_filter_rules(), "tracker", tracker, False ) async def disable_filter_rule_by_tracker(self, tracker) -> None: + """Disable the firewall rule with the given tracker id.""" await self._set_rule_disabled( "/firewall/rule", await self.get_filter_rules(), "tracker", tracker, True ) async def enable_nat_port_forward_rule_by_created_time(self, created_time) -> None: + """Enable the NAT port-forward rule with the given created_time.""" await self._set_rule_disabled( "/firewall/nat/port_forward", await self.get_nat_port_forward_rules(), @@ -404,6 +422,7 @@ async def enable_nat_port_forward_rule_by_created_time(self, created_time) -> No ) async def disable_nat_port_forward_rule_by_created_time(self, created_time) -> None: + """Disable the NAT port-forward rule with the given created_time.""" await self._set_rule_disabled( "/firewall/nat/port_forward", await self.get_nat_port_forward_rules(), @@ -413,6 +432,7 @@ async def disable_nat_port_forward_rule_by_created_time(self, created_time) -> N ) async def enable_nat_outbound_rule_by_created_time(self, created_time) -> None: + """Enable the NAT outbound mapping with the given created_time.""" await self._set_rule_disabled( "/firewall/nat/outbound/mapping", await self.get_nat_outbound_rules(), @@ -422,6 +442,7 @@ async def enable_nat_outbound_rule_by_created_time(self, created_time) -> None: ) async def disable_nat_outbound_rule_by_created_time(self, created_time) -> None: + """Disable the NAT outbound mapping with the given created_time.""" await self._set_rule_disabled( "/firewall/nat/outbound/mapping", await self.get_nat_outbound_rules(), @@ -439,6 +460,7 @@ async def update_alias_address( action: str = "add", kill_states: bool = True, ) -> None: + """Add or remove an address in a firewall alias and apply.""" aliases = await self._get("/firewall/aliases") or [] target = next((a for a in aliases if a.get("name") == alias_name), None) @@ -490,12 +512,14 @@ async def update_alias_address( # ----------------------------------------------------------- carp / vip async def get_carp_status(self) -> bool: + """Return True when CARP is enabled and not in maintenance mode.""" data = await self._get("/status/carp") if not isinstance(data, dict): return False return bool(data.get("enable")) and not data.get("maintenance_mode") async def get_carp_interfaces(self) -> list[dict]: + """Return the CARP virtual IPs with their status.""" data = await self._get("/firewall/virtual_ips") or [] carp = [] for vip in data: @@ -509,9 +533,11 @@ async def get_carp_interfaces(self) -> list[dict]: # --------------------------------------------------------- state table async def reset_state_table(self) -> None: + """Flush the entire firewall state table.""" await self._request("DELETE", "/firewall/states", params={"limit": 0}) async def kill_states(self, source: str, destination: str | None = None) -> None: + """Kill states for a source (and optional destination) via pfctl.""" cmd = f"/sbin/pfctl -k {_shq(source)}" if destination: cmd += f" -k {_shq(destination)}" @@ -572,11 +598,13 @@ async def _rule_match_networks(self, rule: dict) -> list: # ------------------------------------------------------- system control async def system_reboot(self, type: str = "normal") -> None: + """Reboot the firewall.""" # The connection drops as the box goes down -- that is success. with contextlib.suppress(PfSenseConnectionError): await self._request("POST", "/diagnostics/reboot", payload={}) async def system_halt(self) -> None: + """Halt (power off) the firewall.""" # The connection drops as the box goes down -- that is success. with contextlib.suppress(PfSenseConnectionError): await self._request("POST", "/diagnostics/halt_system", payload={}) @@ -584,6 +612,7 @@ async def system_halt(self) -> None: # ------------------------------------------------------------------ wol async def send_wol(self, interface: str, mac: str) -> None: + """Send a Wake-on-LAN magic packet on an interface.""" await self._request( "POST", "/services/wake_on_lan/send", @@ -593,6 +622,7 @@ async def send_wol(self, interface: str, mac: str) -> None: # -------------------------------------------------------------- exec_* async def exec_command(self, command: str, background: bool = False) -> str: + """Run a shell command via the diagnostics endpoint and return its output.""" if background: command = f"{command} &" data = await self._request( @@ -618,11 +648,11 @@ def _as_network(value: str): def _states_prefix(net) -> str | None: - """``str`` that ``firewall/state`` source/destination values start with for - every address in ``net``, or ``None`` if ``net`` can't be expressed that way. + """Return the string every ``firewall/state`` endpoint in ``net`` starts with. - States render endpoints as ``ip:port`` (IPv4) so a host becomes ``"ip:"`` - and an octet-aligned network becomes its leading octets plus a dot. + Returns ``None`` when ``net`` can't be expressed as such a prefix. States + render endpoints as ``ip:port`` (IPv4), so a host becomes ``"ip:"`` and an + octet-aligned network becomes its leading octets plus a dot. """ if net.version != 4: return None diff --git a/custom_components/pfsense/sensor.py b/custom_components/pfsense/sensor.py index f003665..52dcbae 100644 --- a/custom_components/pfsense/sensor.py +++ b/custom_components/pfsense/sensor.py @@ -295,6 +295,7 @@ def process_entities_callback(hass, config_entry): # noqa: C901 - see above def normalize_filesystem_device_name(device_name): + """Return a slug-safe token for a filesystem device or mountpoint.""" return device_name.replace("/", "_slash_").strip("_") @@ -321,8 +322,11 @@ def __init__( class PfSenseStaticKeySensor(PfSenseSensor): + """Sensor backed by a fixed telemetry key from SENSOR_TYPES.""" + @property def available(self) -> bool: + """Return whether the entity is available.""" value = self._get_pfsense_state_value(self.entity_description.key) if value is None: return False @@ -332,6 +336,7 @@ def available(self) -> bool: @property def native_value(self): + """Return the entity's current value.""" value = self._get_pfsense_state_value(self.entity_description.key) if value is None: return STATE_UNKNOWN @@ -357,6 +362,8 @@ def extra_state_attributes(self): class PfSenseFilesystemSensor(PfSenseSensor): + """Sensor for a filesystem's used-space percentage.""" + def _pfsense_get_filesystem(self): state = self.coordinator.data found = None @@ -369,6 +376,7 @@ def _pfsense_get_filesystem(self): @property def available(self) -> bool: + """Return whether the entity is available.""" filesystem = self._pfsense_get_filesystem() if filesystem is None: return False @@ -376,11 +384,13 @@ def available(self) -> bool: @property def native_value(self): + """Return the entity's current value.""" filesystem = self._pfsense_get_filesystem() return filesystem["percent_used"] @property def extra_state_attributes(self): + """Return the entity's extra state attributes.""" attributes = {} filesystem = self._pfsense_get_filesystem() for attr in ["device", "type", "total_size", "mountpoint"]: @@ -389,6 +399,8 @@ def extra_state_attributes(self): class PfSenseInterfaceSensor(PfSenseSensor): + """Sensor for a network interface counter or status.""" + def _pfsense_get_interface_property_name(self): return self.entity_description.key.split(".")[3] @@ -407,6 +419,7 @@ def _pfsense_get_interface(self): @property def available(self) -> bool: + """Return whether the entity is available.""" interface = self._pfsense_get_interface() prop = self._pfsense_get_interface_property_name() if interface is None or prop not in interface: @@ -415,6 +428,7 @@ def available(self) -> bool: @property def extra_state_attributes(self): + """Return the entity's extra state attributes.""" attributes = {} interface = self._pfsense_get_interface() for attr in ["hwif", "enable", "if", "macaddr", "mtu", "media"]: @@ -424,6 +438,7 @@ def extra_state_attributes(self): @property def icon(self): + """Return the entity icon.""" prop = self._pfsense_get_interface_property_name() if prop == "status" and self.native_value != "up": return "mdi:close-network-outline" @@ -431,6 +446,7 @@ def icon(self): @property def native_value(self): + """Return the entity's current value.""" interface = self._pfsense_get_interface() prop = self._pfsense_get_interface_property_name() try: @@ -440,6 +456,8 @@ def native_value(self): class PfSenseCarpInterfaceSensor(PfSenseSensor): + """Sensor for a CARP virtual IP's status.""" + def _pfsense_get_interface_name(self): return self.entity_description.key.split(".")[2] @@ -455,6 +473,7 @@ def _pfsense_get_interface(self): @property def extra_state_attributes(self): + """Return the entity's extra state attributes.""" attributes = {} interface = self._pfsense_get_interface() for attr in [ @@ -471,6 +490,7 @@ def extra_state_attributes(self): @property def available(self) -> bool: + """Return whether the entity is available.""" interface = self._pfsense_get_interface() if interface is None: return False @@ -478,12 +498,14 @@ def available(self) -> bool: @property def icon(self): + """Return the entity icon.""" if self.native_value != "MASTER": return "mdi:close-network-outline" return super().icon @property def native_value(self): + """Return the entity's current value.""" interface = self._pfsense_get_interface() try: return interface["status"] @@ -492,6 +514,8 @@ def native_value(self): class PfSenseGatewaySensor(PfSenseSensor): + """Sensor for a gateway's status or latency metric.""" + def _pfsense_get_gateway_property_name(self): return self.entity_description.key.split(".")[3] @@ -520,6 +544,7 @@ def _pfsense_get_gateway_details(self): @property def available(self) -> bool: + """Return whether the entity is available.""" gateway = self._pfsense_get_gateway() prop = self._pfsense_get_gateway_property_name() if gateway is None or prop not in gateway: @@ -535,6 +560,7 @@ def available(self) -> bool: @property def extra_state_attributes(self): + """Return the entity's extra state attributes.""" attributes = {} gateway = self._pfsense_get_gateway() gateway_detail = self._pfsense_get_gateway_details() @@ -556,6 +582,7 @@ def extra_state_attributes(self): @property def icon(self): + """Return the entity icon.""" prop = self._pfsense_get_gateway_property_name() if prop == "status" and self.native_value != "online": return "mdi:close-network-outline" @@ -563,6 +590,7 @@ def icon(self): @property def native_value(self): + """Return the entity's current value.""" gateway = self._pfsense_get_gateway() prop = self._pfsense_get_gateway_property_name() @@ -585,6 +613,8 @@ def native_value(self): class PfSenseOpenVPNServerSensor(PfSenseSensor): + """Sensor for an OpenVPN server's client/throughput stats.""" + def _pfsense_get_server_property_name(self): return self.entity_description.key.split(".")[4] @@ -603,6 +633,7 @@ def _pfsense_get_server(self): @property def available(self) -> bool: + """Return whether the entity is available.""" server = self._pfsense_get_server() prop = self._pfsense_get_server_property_name() if server is None or prop not in server: @@ -611,6 +642,7 @@ def available(self) -> bool: @property def extra_state_attributes(self): + """Return the entity's extra state attributes.""" attributes = {} server = self._pfsense_get_server() if server is None: @@ -622,6 +654,7 @@ def extra_state_attributes(self): @property def native_value(self): + """Return the entity's current value.""" server = self._pfsense_get_server() prop = self._pfsense_get_server_property_name() diff --git a/custom_components/pfsense/services.py b/custom_components/pfsense/services.py index 7d667f7..7e86c7a 100644 --- a/custom_components/pfsense/services.py +++ b/custom_components/pfsense/services.py @@ -1,3 +1,5 @@ +"""Home Assistant service registration for the pfSense integration.""" + import logging import voluptuous as vol @@ -36,6 +38,8 @@ def async_get_entities(hass: HomeAssistant) -> dict[str, Entity]: class ServiceRegistrar: + """Registers the integration's Home Assistant services once.""" + def __init__( self, hass: HomeAssistant, @@ -45,6 +49,7 @@ def __init__( @callback def async_register(self): + """Register the integration's services once.""" if "loaded" in _data: return diff --git a/custom_components/pfsense/switch.py b/custom_components/pfsense/switch.py index 10d53a6..8d28a7d 100644 --- a/custom_components/pfsense/switch.py +++ b/custom_components/pfsense/switch.py @@ -157,6 +157,8 @@ def process_entities_callback(hass, config_entry): class PfSenseSwitch(PfSenseEntity, SwitchEntity): + """Base class for pfSense switches.""" + def __init__( self, config_entry, @@ -174,10 +176,12 @@ def __init__( @property def is_on(self): + """Return true if the entity is on.""" return False @property def extra_state_attributes(self): + """Return the entity's extra state attributes.""" return None async def _maybe_kill_rule_states(self, rule): @@ -195,6 +199,8 @@ async def _maybe_kill_rule_states(self, rule): class PfSenseFilterSwitch(PfSenseSwitch): + """Switch that enables or disables a firewall rule.""" + def _pfsense_get_tracker(self): return self.entity_description.key.split(".")[1] @@ -208,6 +214,7 @@ def _pfsense_get_rule(self): @property def available(self) -> bool: + """Return whether the entity is available.""" rule = self._pfsense_get_rule() if rule is None: return False @@ -216,6 +223,7 @@ def available(self) -> bool: @property def is_on(self): + """Return true if the entity is on.""" rule = self._pfsense_get_rule() if rule is None: return STATE_UNKNOWN @@ -245,6 +253,8 @@ async def async_turn_off(self, **kwargs): class PfSenseNatSwitch(PfSenseSwitch): + """Switch that enables or disables a NAT rule.""" + def _pfsense_get_rule_type(self): return self.entity_description.key.split(".")[0] @@ -267,6 +277,7 @@ def _pfsense_get_rule(self): @property def available(self) -> bool: + """Return whether the entity is available.""" rule = self._pfsense_get_rule() if rule is None: return False @@ -275,6 +286,7 @@ def available(self) -> bool: @property def is_on(self): + """Return true if the entity is on.""" rule = self._pfsense_get_rule() if rule is None: return STATE_UNKNOWN @@ -316,6 +328,8 @@ async def async_turn_off(self, **kwargs): class PfSenseServiceSwitch(PfSenseSwitch): + """Switch that starts or stops a pfSense service.""" + def _pfsense_get_property_name(self): return self.entity_description.key.split(".")[2] @@ -342,6 +356,7 @@ def _pfsense_get_service(self): @property def available(self) -> bool: + """Return whether the entity is available.""" service = self._pfsense_get_service() prop = self._pfsense_get_property_name() if service is None or prop not in service: @@ -351,6 +366,7 @@ def available(self) -> bool: @property def is_on(self): + """Return true if the entity is on.""" service = self._pfsense_get_service() prop = self._pfsense_get_property_name() try: diff --git a/custom_components/pfsense/update.py b/custom_components/pfsense/update.py index 2fd0dc5..6ee32b2 100644 --- a/custom_components/pfsense/update.py +++ b/custom_components/pfsense/update.py @@ -57,6 +57,8 @@ def process_entities_callback(hass, config_entry): class PfSenseUpdate(PfSenseEntity, UpdateEntity): + """Base class for the pfSense update entity.""" + def __init__( self, config_entry, @@ -79,12 +81,16 @@ def __init__( @property def device_class(self): + """Return the device class.""" return UpdateDeviceClass.FIRMWARE class PfSenseFirmwareUpdatesAvailableUpdate(PfSenseUpdate): + """Update entity reporting available package upgrades.""" + @property def available(self): + """Return whether the entity is available.""" state = self.coordinator.data if ( state["firmware_update_info"] is None @@ -97,6 +103,7 @@ def available(self): @property def title(self): + """Return the title.""" return "pfSense" @property @@ -128,6 +135,7 @@ def in_progress(self): @property def extra_state_attributes(self): + """Return the entity's extra state attributes.""" state = self.coordinator.data attrs = {} info = dict_get(state, "firmware_update_info.base", {}) @@ -144,4 +152,5 @@ def extra_state_attributes(self): @property def release_url(self): + """Return the release notes URL.""" return "https://docs.netgate.com/pfsense/en/latest/releases/index.html" diff --git a/pyproject.toml b/pyproject.toml index f3f5a18..6be9ced 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,21 +141,6 @@ ignore = [ # Disabled to implement in follow up PRs after ruff 0.16 bump "ISC004", "LOG004", - - # --------------------------------------------------------------------------- - # Project-specific: rules from the Home Assistant selection that this fork - # does not satisfy yet. Kept as a checklist to clear incrementally rather - # than in one sweep; new code is still linted against everything above. - # --------------------------------------------------------------------------- - "D100", # undocumented public module - "D101", # undocumented public class - "D102", # undocumented public method - "D103", # undocumented public function - "D105", # undocumented magic method - "D106", # undocumented public nested class - "D107", # undocumented __init__ - "D205", # blank line after docstring summary - "D415", # docstring first line punctuation ] [tool.ruff.lint.flake8-pytest-style] diff --git a/tests/conftest.py b/tests/conftest.py index e366565..5dcd67d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +"""Shared pytest fixtures for the pfSense test suite.""" + import pytest diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index 89537e3..b701298 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -12,11 +12,13 @@ @pytest.fixture(autouse=True) def auto_enable_custom_integrations(enable_custom_integrations): + """Test helper.""" return @pytest.fixture def mock_pfsense_client(): + """Test helper.""" client = AsyncMock() client.get_system_info.return_value = { "hostname": "router", @@ -80,6 +82,7 @@ async def _setup(hass, entry, client): @pytest.mark.asyncio async def test_carp_sensor_on(hass: HomeAssistant, mock_pfsense_client): + """Test carp sensor on.""" mock_pfsense_client.get_carp_status.return_value = True await _setup(hass, _entry("carp_on"), mock_pfsense_client) @@ -92,6 +95,7 @@ async def test_carp_sensor_on(hass: HomeAssistant, mock_pfsense_client): @pytest.mark.asyncio async def test_carp_sensor_off(hass: HomeAssistant, mock_pfsense_client): + """Test carp sensor off.""" mock_pfsense_client.get_carp_status.return_value = False await _setup(hass, _entry("carp_off"), mock_pfsense_client) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 159f7eb..0e8403f 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -31,6 +31,7 @@ def _client_mock(**overrides): @pytest.mark.asyncio async def test_form_user_success(hass: HomeAssistant): + """Test form user success.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} ) @@ -60,6 +61,7 @@ async def test_form_user_success(hass: HomeAssistant): @pytest.mark.asyncio async def test_form_user_invalid_auth(hass: HomeAssistant): + """Test form user invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} ) @@ -76,6 +78,7 @@ async def test_form_user_invalid_auth(hass: HomeAssistant): @pytest.mark.asyncio async def test_reauth_flow_updates_key(hass: HomeAssistant): + """Test reauth flow updates key.""" entry = MockConfigEntry( domain=DOMAIN, version=3, @@ -104,6 +107,7 @@ async def test_reauth_flow_updates_key(hass: HomeAssistant): @pytest.mark.asyncio async def test_options_flow(hass: HomeAssistant): + """Test options flow.""" entry = MockConfigEntry( domain=DOMAIN, version=3, diff --git a/tests/test_device_tracker.py b/tests/test_device_tracker.py index 0080d35..434b455 100644 --- a/tests/test_device_tracker.py +++ b/tests/test_device_tracker.py @@ -1,3 +1,5 @@ +"""Tests for the pfSense device tracker.""" + from unittest.mock import MagicMock from custom_components.pfsense.device_tracker import lookup_mac diff --git a/tests/test_init.py b/tests/test_init.py index 7099d8e..cf163bc 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -13,6 +13,7 @@ @pytest.fixture(autouse=True) def auto_enable_custom_integrations(enable_custom_integrations): + """Test helper.""" return @@ -55,6 +56,7 @@ def _full_client_mock(): @pytest.mark.asyncio async def test_setup_and_unload_entry(hass: HomeAssistant): + """Test setup and unload entry.""" entry = MockConfigEntry( domain=DOMAIN, version=3, diff --git a/tests/test_pypfsense.py b/tests/test_pypfsense.py index debf1a7..e664c1c 100644 --- a/tests/test_pypfsense.py +++ b/tests/test_pypfsense.py @@ -32,11 +32,13 @@ def _envelope(data, code=200, status="ok", response_id="SUCCESS", message=""): @pytest.fixture async def client(): + """Test helper.""" async with aiohttp.ClientSession() as session: yield Client(BASE, "test-key", session, {"verify_ssl": False}) def test_dict_get(): + """Test dict get.""" data = {"a": {"b": [{"c": 1}]}, "n": {2: "x"}} assert dict_get(data, "a.b.0.c") == 1 assert dict_get(data, "n.2") == "x" @@ -45,11 +47,13 @@ def test_dict_get(): def test_base_url_strips_path(): + """Test base url strips path.""" c = Client("https://pf.example:8444/ui/", "k", object()) assert c._base == "https://pf.example:8444/api/v2" async def test_request_unwraps_data(client): + """Test request unwraps data.""" with aioresponses() as m: m.get( f"{API}/system/hostname", @@ -71,6 +75,7 @@ async def test_request_unwraps_data(client): ], ) async def test_error_codes_map_to_exceptions(client, code, exc): + """Test error codes map to exceptions.""" with aioresponses() as m: m.get( f"{API}/system/hostname", @@ -84,6 +89,7 @@ async def test_error_codes_map_to_exceptions(client, code, exc): async def test_get_system_info_merges_endpoints(client): + """Test get system info merges endpoints.""" with aioresponses() as m: m.get( f"{API}/status/system", @@ -106,6 +112,7 @@ async def test_get_system_info_merges_endpoints(client): async def test_carp_status_reduces_to_bool(client): + """Test carp status reduces to bool.""" with aioresponses() as m: m.get( f"{API}/status/carp", @@ -130,6 +137,7 @@ def _patch_body(m): async def test_disable_filter_rule_patches_then_applies(client): + """Test disable filter rule patches then applies.""" rules = [ {"id": 4, "tracker": 111, "disabled": False, "descr": "r"}, { @@ -149,6 +157,7 @@ async def test_disable_filter_rule_patches_then_applies(client): async def test_disable_filter_rule_backfills_empty_statetype(client): + """Test disable filter rule backfills empty statetype.""" # pfSense's GUI writes ````; a bare disabled PATCH then # fails FIELD_EMPTY_NOT_ALLOWED, so the client re-sends the default. rules = [{"id": 5, "tracker": 222, "disabled": False, "statetype": ""}] @@ -165,6 +174,7 @@ async def test_disable_filter_rule_backfills_empty_statetype(client): async def test_kill_states_for_rule_resolves_alias_to_prefix(client): + """Test kill states for rule resolves alias to prefix.""" rule = {"source": "kids", "destination": "any"} aliases = [{"name": "kids", "type": "network", "address": ["10.0.10.0/24"]}] with aioresponses() as m: @@ -186,6 +196,7 @@ async def test_kill_states_for_rule_resolves_alias_to_prefix(client): async def test_kill_states_for_rule_skips_unresolvable_endpoints(client): + """Test kill states for rule skips unresolvable endpoints.""" # ``any`` / ``(self)`` / a /25 network have no usable prefix -> no request. rule = {"source": "any", "destination": "(self)"} with aioresponses() as m: @@ -194,6 +205,7 @@ async def test_kill_states_for_rule_skips_unresolvable_endpoints(client): async def test_build_telemetry_shape(): + """Test build telemetry shape.""" system = { "cpu_usage": 12.5, "cpu_count": 4, diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 5cb7d64..7a5fb43 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,3 +1,5 @@ +"""Tests for the pfSense sensors.""" + from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -10,6 +12,7 @@ @pytest.fixture def mock_coordinator(): + """Test helper.""" coord = MagicMock() coord.data = { "telemetry": { @@ -32,6 +35,7 @@ def mock_coordinator(): return_value="pfSense", ) def test_openvpn_sensor(mock_name, mock_uid, mock_coordinator): + """Test openvpn sensor.""" config_entry = MockConfigEntry(domain=DOMAIN) desc = SensorEntityDescription( key="telemetry.openvpn.servers.1.status", name="VPN Status" diff --git a/tests/test_switch.py b/tests/test_switch.py index 197bf63..baf6e88 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -11,6 +11,7 @@ @pytest.fixture def mock_coordinator(): + """Test helper.""" coord = MagicMock() coord.data = { "services": [ @@ -33,6 +34,7 @@ def mock_coordinator(): return_value="pfSense", ) async def test_service_switch_turn_off(mock_name, mock_uid, mock_coordinator): + """Test service switch turn off.""" desc = SwitchEntityDescription(key="service.unbound.status", name="unbound") switch = PfSenseServiceSwitch(MockConfigEntry(), mock_coordinator, desc) From 060104385fa53f7c44ffc6b854e7e6901277c5c2 Mon Sep 17 00:00:00 2001 From: Nate Olsen Date: Sun, 6 Sep 2026 11:13:19 -0700 Subject: [PATCH 7/7] Bump version to 0.10.0 New user-facing option (reset matching states when a rule switch is toggled) plus the poll-crash / statetype fixes and the full Home Assistant ruff adoption since 0.9.1. Co-Authored-By: Claude Sonnet 5 --- custom_components/pfsense/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pfsense/manifest.json b/custom_components/pfsense/manifest.json index 1dc6868..0898b1e 100644 --- a/custom_components/pfsense/manifest.json +++ b/custom_components/pfsense/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "mac-vendor-lookup>=0.1.11" ], - "version": "0.9.3" + "version": "0.10.0" }