From 18fcab4e7789d631daa091afd2bf30e5e3351263 Mon Sep 17 00:00:00 2001 From: Guy Khmelnitsky Date: Tue, 10 Feb 2026 09:53:55 +0200 Subject: [PATCH] feat: type-checking --- .github/workflows/lint.yml | 63 ++++++++- CONTRIBUTING.md | 64 ++++++--- custom_components/iec/__init__.py | 6 +- custom_components/iec/binary_sensor.py | 16 ++- custom_components/iec/commons.py | 9 +- custom_components/iec/config_flow.py | 36 ++--- custom_components/iec/coordinator.py | 186 +++++++++++++------------ custom_components/iec/iec_entity.py | 6 +- custom_components/iec/sensor.py | 27 ++-- mypy.ini | 66 +++++++++ requirements.txt | 2 + scripts/lint | 5 + scripts/setup | 87 ++++++++++++ scripts/typecheck | 8 ++ 14 files changed, 417 insertions(+), 164 deletions(-) create mode 100644 mypy.ini create mode 100755 scripts/typecheck diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6139af5..5e96fe5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,5 +25,66 @@ jobs: - name: "Install requirements" run: python3 -m pip install -r requirements.txt - - name: "Run" + - name: "Run Ruff" run: python3 -m ruff check . + + mypy: + name: "Type Check" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v6.0.2" + + - name: "Set up Python" + uses: actions/setup-python@v6.2.0 + with: + python-version: "3.11" + cache: "pip" + + - name: "Install requirements" + run: python3 -m pip install -r requirements.txt + + - name: "Run MyPy" + run: python3 -m mypy custom_components/iec + + autofix: + name: "Auto-fix" + runs-on: "ubuntu-latest" + if: github.event_name == 'pull_request' + permissions: + contents: write + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v6.0.2" + with: + ref: ${{ github.head_ref }} + + - name: "Set up Python" + uses: actions/setup-python@v6.2.0 + with: + python-version: "3.11" + cache: "pip" + + - name: "Install requirements" + run: python3 -m pip install -r requirements.txt + + - name: "Auto-fix with Ruff" + run: python3 -m ruff check . --fix + + - name: "Check for changes" + id: verify-changed-files + run: | + if git diff --quiet; then + echo "changed=false" >> $GITHUB_OUTPUT + else + echo "changed=true" >> $GITHUB_OUTPUT + fi + + - name: "Commit changes" + if: steps.verify-changed-files.outputs.changed == 'true' + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add -A + git commit -m "style: auto-fix ruff linting issues" + git push diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 977122a..fb66724 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,23 +1,31 @@ -# Contribution guidelines - -Contributing to this project should be as easy and transparent as possible, whether it's: - -- Reporting a bug -- Discussing the current state of the code -- Submitting a fix -- Proposing new features - -## Github is used for everything - -Github is used to host code, to track issues and feature requests, as well as accept pull requests. - -Pull requests are the best way to propose changes to the codebase. - -1. Fork the repo and create your branch from `main`. -2. If you've changed something, update the documentation. -3. Make sure your code lints (using `scripts/lint`). -4. Test you contribution. -5. Issue that pull request! +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Development Setup + +To set up your development environment: + +1. Fork the repo and clone it locally +2. Run `./scripts/setup` to install dependencies and set up git hooks +3. The pre-commit hook will automatically run linting and type checking on every commit + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `main`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using `scripts/lint`). +4. Test you contribution. +5. Issue that pull request! ## Any contributions you make will be under the MIT Software License @@ -42,9 +50,19 @@ Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! People *love* thorough bug reports. I'm not even kidding. -## Use a Consistent Coding Style - -Use [black](https://github.com/ambv/black) to make sure the code follows the style. +## Use a Consistent Coding Style + +This project uses: +- **[Ruff](https://github.com/astral-sh/ruff)** for linting and code formatting +- **[MyPy](https://github.com/python/mypy)** for static type checking + +The pre-commit hook will automatically run these checks before each commit. You can also run them manually: + +```bash +./scripts/lint # Run both ruff and mypy +./scripts/typecheck # Run mypy only +ruff check . --fix # Auto-fix linting issues +``` ## Test your code modification diff --git a/custom_components/iec/__init__.py b/custom_components/iec/__init__.py index 1011e84..aebe5b9 100644 --- a/custom_components/iec/__init__.py +++ b/custom_components/iec/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -30,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Register the debug service - async def handle_debug_get_coordinator_data(call) -> None: # noqa: ANN001 ARG001 + async def handle_debug_get_coordinator_data(call: Any) -> None: # Log or return coordinator data data = iec_coordinator.data _LOGGER.info("Coordinator data: %s", data) @@ -45,7 +46,8 @@ async def handle_debug_get_coordinator_data(call) -> None: # noqa: ANN001 ARG00 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + unload_ok: bool = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: coordinator = hass.data[DOMAIN].pop(entry.entry_id, None) if coordinator: await coordinator.async_unload() diff --git a/custom_components/iec/binary_sensor.py b/custom_components/iec/binary_sensor.py index bdfd882..4a8a54f 100644 --- a/custom_components/iec/binary_sensor.py +++ b/custom_components/iec/binary_sensor.py @@ -5,6 +5,7 @@ import logging from collections.abc import Callable from dataclasses import dataclass +from typing import Any from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, @@ -35,7 +36,7 @@ class IecBinaryEntityDescriptionMixin: """Mixin values for required keys.""" - value_fn: Callable[dict, bool | None] + value_fn: Callable[[Any], bool | None] @dataclass(frozen=True, kw_only=True) @@ -110,8 +111,8 @@ def __init__( entity_description: IecBinarySensorEntityDescription, contract_id: str, is_multi_contract: bool, - attributes_to_add: dict | None = None, - ): + attributes_to_add: dict[str, Any] | None = None, + ) -> None: """Initialize the sensor.""" super().__init__( coordinator, @@ -122,7 +123,7 @@ def __init__( self.entity_description = entity_description self._attr_unique_id = f"{str(contract_id)}_{entity_description.key}" - attributes = {"contract_id": contract_id} + attributes: dict[str, Any] = {"contract_id": contract_id} if attributes_to_add: attributes.update(attributes_to_add) @@ -140,9 +141,10 @@ def __init__( @property def is_on(self) -> bool | None: """Return the state of the sensor.""" - return self.entity_description.value_fn( - self.coordinator.data.get(self.contract_id) - ) + contract_data = self.coordinator.data.get(self.contract_id) + if contract_data is None: + return None + return self.entity_description.value_fn(contract_data) @property def device_info(self) -> DeviceInfo: diff --git a/custom_components/iec/commons.py b/custom_components/iec/commons.py index 73d9323..d73d98d 100644 --- a/custom_components/iec/commons.py +++ b/custom_components/iec/commons.py @@ -1,14 +1,13 @@ """IEC common functions.""" -import pytz - from datetime import date from enum import Enum +import pytz from homeassistant.helpers.device_registry import DeviceInfo from iec_api.models.remote_reading import PeriodConsumption -from custom_components.iec import DOMAIN +from .const import DOMAIN TIMEZONE = pytz.timezone("Asia/Jerusalem") @@ -29,7 +28,9 @@ def find_reading_by_date(daily_reading: PeriodConsumption, desired_date: date) - TypeError: If the `daily_reading.date` attribute is not of type `datetime`. """ - return daily_reading.interval.date() == desired_date # Checks if the dates match + return bool( + daily_reading.interval.date() == desired_date + ) # Checks if the dates match class IecEntityType(Enum): diff --git a/custom_components/iec/config_flow.py b/custom_components/iec/config_flow.py index 22df709..ec33339 100644 --- a/custom_components/iec/config_flow.py +++ b/custom_components/iec/config_flow.py @@ -74,10 +74,11 @@ async def _validate_login( return errors -class IecConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class IecConfigFlow(config_entries.ConfigFlow): """Handle a config flow for IEC.""" VERSION = 1 + DOMAIN = DOMAIN def __init__(self) -> None: """Initialize a new IECConfigFlow.""" @@ -139,6 +140,7 @@ async def async_step_mfa( if data.get(CONF_TOTP_SECRET): data.pop(CONF_TOTP_SECRET) + contract_ids: list[int] = [] try: customer = await client.get_customer() data[CONF_BP_NUMBER] = customer.bp_number @@ -221,19 +223,18 @@ async def async_step_select_contracts( assert self.data.get(CONF_BP_NUMBER) is not None errors: dict[str, str] = {} - if ( - user_input is not None - and user_input.get(CONF_SELECTED_CONTRACTS) is not None - ): - if len(user_input.get(CONF_SELECTED_CONTRACTS)) == 0: - errors["base"] = "no_contracts" - else: - data = {**self.data, **user_input} - if data.get(CONF_AVAILABLE_CONTRACTS): - data.pop(CONF_AVAILABLE_CONTRACTS) - - self.data = data - return self._async_create_iec_entry(data) + if user_input is not None: + selected_contracts = user_input.get(CONF_SELECTED_CONTRACTS) + if selected_contracts is not None: + if len(selected_contracts) == 0: + errors["base"] = "no_contracts" + else: + data = {**self.data, **user_input} + if data.get(CONF_AVAILABLE_CONTRACTS): + data.pop(CONF_AVAILABLE_CONTRACTS) + + self.data = data + return self._async_create_iec_entry(data) schema = { vol.Required( @@ -280,9 +281,10 @@ async def async_step_reauth_confirm( return self.async_abort(reason="reauth_successful") if not client: - self.client = IecClient( - self.data[CONF_USER_ID], async_create_clientsession(self.hass) - ) + user_id = self.data.get(CONF_USER_ID) if self.data else None + if not user_id: + user_id = self.reauth_entry.data.get(CONF_USER_ID, "") + self.client = IecClient(user_id, async_create_clientsession(self.hass)) client = self.client try: diff --git a/custom_components/iec/coordinator.py b/custom_components/iec/coordinator.py index 82c03eb..f816264 100644 --- a/custom_components/iec/coordinator.py +++ b/custom_components/iec/coordinator.py @@ -9,7 +9,6 @@ from collections import Counter from datetime import date, datetime, timedelta from typing import Any, cast # noqa: UP035 -from uuid import UUID import jwt from homeassistant.components.recorder import get_instance @@ -34,6 +33,7 @@ from iec_api.models.device import Device, Devices from iec_api.models.exceptions import IECError from iec_api.models.jwt import JWT +from iec_api.models.invoice import Invoice from iec_api.models.meter_reading import MeterReading from iec_api.models.remote_reading import ( FutureConsumptionInfo, @@ -101,16 +101,16 @@ def __init__( self._bp_number = config_entry.data.get(CONF_BP_NUMBER) self._contract_ids = config_entry.data.get(CONF_SELECTED_CONTRACTS) self._entry_data = config_entry.data - self._today_readings = {} - self._devices_by_contract_id = {} - self._last_meter_reading = {} - self._devices_by_meter_id = {} - self._delivery_tariff_by_phase = {} - self._distribution_tariff_by_phase = {} - self._power_size_by_connection_size = {} + self._today_readings: dict[str, RemoteReadingResponse] = {} + self._devices_by_contract_id: dict[int, list[Device]] = {} + self._last_meter_reading: dict[tuple[int, int], MeterReading] = {} + self._devices_by_meter_id: dict[str, Devices] = {} + self._delivery_tariff_by_phase: dict[int, float] = {} + self._distribution_tariff_by_phase: dict[int, float] = {} + self._power_size_by_connection_size: dict[str, float] = {} self._kwh_tariff: float | None = None self._kva_tariff: float | None = None - self._readings = {} + self._readings: dict[tuple[int, int, str], RemoteReadingResponse] = {} self._account_id: str | None = None self._connection_size: str | None = None self.api = IecClient( @@ -129,11 +129,13 @@ def _dummy_listener() -> None: # _async_update_data not periodically getting called which is needed for _insert_statistics. self.async_add_listener(_dummy_listener) - async def async_unload(self): + async def async_unload(self) -> None: """Unload the coordinator, cancel any pending tasks.""" _LOGGER.info("Coordinator unloaded successfully.") - async def _get_devices_by_contract_id(self, contract_id) -> list[Device]: + async def _get_devices_by_contract_id( + self, contract_id: int + ) -> list[Device] | None: devices = self._devices_by_contract_id.get(contract_id) if not devices: try: @@ -145,7 +147,7 @@ async def _get_devices_by_contract_id(self, contract_id) -> list[Device]: ) return devices - async def _get_devices_by_device_id(self, meter_id) -> Devices: + async def _get_devices_by_device_id(self, meter_id: str) -> Devices | None: devices = self._devices_by_meter_id.get(meter_id) if not devices: try: @@ -158,8 +160,8 @@ async def _get_devices_by_device_id(self, meter_id) -> Devices: return devices async def _get_last_meter_reading( - self, bp_number, contract_id, meter_id - ) -> MeterReading: + self, bp_number: str | None, contract_id: int, meter_id: str + ) -> MeterReading | None: key = (contract_id, int(meter_id)) last_meter_reading = self._last_meter_reading.get(key) if not last_meter_reading: @@ -307,7 +309,7 @@ async def _fetch_tariffs_from_calculators( return kwh_tariff, kva_tariff - async def _get_delivery_tariff(self, phase) -> float: + async def _get_delivery_tariff(self, phase: int) -> float: delivery_tariff = self._delivery_tariff_by_phase.get(phase) if not delivery_tariff: try: @@ -319,7 +321,7 @@ async def _get_delivery_tariff(self, phase) -> float: ) return delivery_tariff or 0.0 - async def _get_distribution_tariff(self, phase) -> float: + async def _get_distribution_tariff(self, phase: int) -> float: distribution_tariff = self._distribution_tariff_by_phase.get(phase) if not distribution_tariff: try: @@ -331,16 +333,16 @@ async def _get_distribution_tariff(self, phase) -> float: ) return distribution_tariff or 0.0 - async def _get_account_id(self) -> UUID | None: + async def _get_account_id(self) -> str | None: if not self._account_id: try: account = await self.api.get_default_account() - self._account_id = account.id + self._account_id = str(account.id) if account.id else None except IECError as e: _LOGGER.exception("Failed fetching Account", e) return self._account_id - async def _get_connection_size(self, account_id) -> str | None: + async def _get_connection_size(self, account_id: str | None) -> str | None: if not self._connection_size: try: self._connection_size = ( @@ -350,7 +352,7 @@ async def _get_connection_size(self, account_id) -> str | None: _LOGGER.exception("Failed fetching Masa Connection Size", e) return self._connection_size - async def _get_power_size(self, connection_size) -> float: + async def _get_power_size(self, connection_size: str) -> float: power_size = self._power_size_by_connection_size.get(connection_size) if not power_size: try: @@ -370,7 +372,7 @@ async def _get_readings( device_code: str | int, reading_date: datetime, resolution: ReadingResolution, - ): + ) -> RemoteReadingResponse | None: date_key = reading_date.strftime("%Y") match resolution: case ReadingResolution.DAILY: @@ -412,7 +414,7 @@ async def _verify_daily_readings_exist( device: Device, contract_id: int, prefetched_reading: RemoteReadingResponse | None = None, - ): + ) -> None: if not daily_readings.get(device.device_number): daily_readings[device.device_number] = [] @@ -552,11 +554,11 @@ async def _update_data( for contract_id in self._contract_ids: # Because IEC API provides historical usage/cost with a delay of a couple of days # we need to insert data into statistics. - self.hass.async_create_task( - self._insert_statistics( - contract_id, contracts.get(contract_id).smart_meter + contract = contracts.get(contract_id) + if contract: + self.hass.async_create_task( + self._insert_statistics(contract_id, contract.smart_meter) ) - ) try: billing_invoices = await self.api.get_billing_invoices( @@ -589,11 +591,12 @@ async def _update_data( else: last_invoice = EMPTY_INVOICE - future_consumption: dict[str, FutureConsumptionInfo | None] | None = {} - daily_readings: dict[str, list[PeriodConsumption] | None] | None = {} + future_consumption: dict[str, FutureConsumptionInfo | None] = {} + daily_readings: dict[str, list[PeriodConsumption]] = {} - is_smart_meter = contracts.get(contract_id).smart_meter - is_private_producer = contracts.get(contract_id).from_private_producer + contract = contracts.get(contract_id) + is_smart_meter = contract.smart_meter if contract else False + is_private_producer = contract.from_private_producer if contract else False attributes_to_add = { CONTRACT_ID_ATTR_NAME: str(contract_id), IS_SMART_METER_ATTR_NAME: is_smart_meter, @@ -618,27 +621,21 @@ async def _update_data( reading_date: date | None = None if localized_today.date() != localized_first_of_month.date(): - reading_type: ReadingResolution | None = ( - ReadingResolution.MONTHLY - ) - reading_date: date | None = localized_first_of_month + reading_type = ReadingResolution.MONTHLY + reading_date = localized_first_of_month.date() elif localized_today.date().isoweekday() != 7: # If today's the 1st of the month, but not sunday, get weekly from yesterday yesterday = localized_today - timedelta(days=1) - reading_type: ReadingResolution | None = ( - ReadingResolution.WEEKLY - ) - reading_date: date | None = yesterday + reading_type = ReadingResolution.WEEKLY + reading_date = yesterday.date() else: # Today is the 1st and is Monday (since monday.isoweekday==1) last_month_first_of_the_month = ( localized_first_of_month - timedelta(days=1) ).replace(day=1) - reading_type: ReadingResolution | None = ( - ReadingResolution.MONTHLY - ) - reading_date: date | None = last_month_first_of_the_month + reading_type = ReadingResolution.MONTHLY + reading_date = last_month_first_of_the_month.date() _LOGGER.debug( f"Fetching {reading_type.name} readings from {reading_date}" @@ -647,7 +644,9 @@ async def _update_data( contract_id, device.device_number, device.device_code, - reading_date, + datetime.combine(reading_date, datetime.min.time()) + if reading_date + else datetime.now(), reading_type, ) if ( @@ -690,25 +689,25 @@ async def _update_data( self._today_readings[today_reading_key] = today_reading # fallbacks for future consumption since IEC api is broken :/ + future_consumption_info = future_consumption.get( + device.device_number + ) if ( - not future_consumption.get(device.device_number) - or not future_consumption[ - device.device_number - ].future_consumption + not future_consumption_info + or not future_consumption_info.future_consumption ): + today_reading_resp = self._today_readings.get(today_reading_key) if ( - self._today_readings.get(today_reading_key) - and self._today_readings.get(today_reading_key).meter_list[ + today_reading_resp + and today_reading_resp.meter_list + and today_reading_resp.meter_list[0] + and today_reading_resp.meter_list[0].future_consumption_info + and today_reading_resp.meter_list[ 0 - ] - and self._today_readings.get(today_reading_key) - .meter_list[0] - .future_consumption_info.future_consumption + ].future_consumption_info.future_consumption ): future_consumption[device.device_number] = ( - self._today_readings.get(today_reading_key) - .meter_list[0] - .future_consumption_info + today_reading_resp.meter_list[0].future_consumption_info ) else: req_date = localized_today - timedelta(days=2) @@ -722,6 +721,8 @@ async def _update_data( if ( two_days_ago_reading + and two_days_ago_reading.meter_list + and two_days_ago_reading.meter_list[0] and two_days_ago_reading.meter_list[0].total_import ): # use total_import as validation that reading OK: future_consumption[device.device_number] = ( @@ -974,7 +975,7 @@ async def _insert_statistics(self, contract_id: int, is_smart_meter: bool) -> No if not stats.get(consumption_statistic_id): _LOGGER.debug("[IEC Statistics] No recent usage data") - consumption_sum = 0 + consumption_sum = 0.0 else: consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"]) @@ -997,12 +998,14 @@ async def _insert_statistics(self, contract_id: int, is_smart_meter: bool) -> No f"[IEC Statistics] Last Estimated Cost Sum for C[{contract_id}] D[{device.device_number}]: {cost_sum}" ) - new_readings: list[PeriodConsumption] = filter( - lambda reading: ( - reading.interval - >= TIMEZONE.localize(datetime.fromtimestamp(last_stat_time)) - ), - readings.meter_list[0].period_consumptions, + new_readings: list[PeriodConsumption] = list( + filter( + lambda reading: ( + reading.interval + >= TIMEZONE.localize(datetime.fromtimestamp(last_stat_time)) + ), + readings.meter_list[0].period_consumptions, + ) ) grouped_new_readings_by_hour = itertools.groupby( @@ -1088,14 +1091,14 @@ async def _insert_statistics(self, contract_id: int, is_smart_meter: bool) -> No async def _estimate_bill( self, - contract_id, - device_number, - is_private_producer, - future_consumption, - kwh_tariff, - kva_tariff, - last_invoice, - ): + contract_id: int, + device_number: str, + is_private_producer: bool, + future_consumption: dict[str, FutureConsumptionInfo | None], + kwh_tariff: float, + kva_tariff: float, + last_invoice: Invoice, + ) -> tuple[float, float, float, int, float, float, float, int]: last_meter_read: int | None = None last_meter_read_date: date | None = None phase_count: int | None = None @@ -1104,12 +1107,11 @@ async def _estimate_bill( if not is_private_producer: try: - devices_by_id: Devices = await self._get_devices_by_device_id( - device_number - ) + devices_by_id = await self._get_devices_by_device_id(device_number) if ( - devices_by_id.counter_devices + devices_by_id + and devices_by_id.counter_devices and len(devices_by_id.counter_devices) >= 1 ): last_meter_read = int(devices_by_id.counter_devices[0].last_mr) @@ -1190,18 +1192,18 @@ async def _estimate_bill( @staticmethod def _calculate_estimated_bill( - meter_id, + meter_id: str, future_consumptions: dict[str, FutureConsumptionInfo | None], - last_meter_read, - last_meter_read_date, - kwh_tariff, - kva_tariff, - distribution_tariff, - delivery_tariff, - power_size, - last_invoice, - ): - future_consumption_info: FutureConsumptionInfo = future_consumptions[meter_id] + last_meter_read: int | None, + last_meter_read_date: date | None, + kwh_tariff: float, + kva_tariff: float, + distribution_tariff: float, + delivery_tariff: float, + power_size: float, + last_invoice: Invoice, + ) -> tuple[float, float, float, int, float, float, float, int]: + future_consumption_info = future_consumptions.get(meter_id) future_consumption = 0 if last_meter_read and future_consumption_info: @@ -1210,7 +1212,7 @@ def _calculate_estimated_bill( future_consumption_info.total_import - last_meter_read ) else: - _LOGGER.warn( + _LOGGER.warning( f"Failed to calculate Future Consumption, Assuming last meter read \ ({last_meter_read}) as full consumption" ) @@ -1218,18 +1220,18 @@ def _calculate_estimated_bill( kva_price = power_size * kva_tariff / 365 - total_kva_price = 0 - distribution_price = 0 - delivery_price = 0 + total_kva_price = 0.0 + distribution_price = 0.0 + delivery_price = 0.0 consumption_price = round(future_consumption * kwh_tariff, 2) total_days = 0 today = TIMEZONE.localize(datetime.now()) - if last_invoice != EMPTY_INVOICE: + if last_invoice != EMPTY_INVOICE and last_meter_read_date: current_date = last_meter_read_date + timedelta(days=1) - month_counter = Counter() + month_counter: Counter[tuple[int, int]] = Counter() while current_date <= today.date(): # Use (year, month) as the key for counting diff --git a/custom_components/iec/iec_entity.py b/custom_components/iec/iec_entity.py index 9f10dbd..4dab052 100644 --- a/custom_components/iec/iec_entity.py +++ b/custom_components/iec/iec_entity.py @@ -2,8 +2,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity -from custom_components.iec import IecApiCoordinator -from custom_components.iec.commons import get_device_info, IecEntityType +from .commons import get_device_info, IecEntityType +from .coordinator import IecApiCoordinator class IecEntity(CoordinatorEntity[IecApiCoordinator]): @@ -17,7 +17,7 @@ def __init__( contract_id: str, meter_id: str | None, iec_entity_type: IecEntityType, - ): + ) -> None: """Set up a IEC entity.""" super().__init__(coordinator) self.contract_id = contract_id diff --git a/custom_components/iec/sensor.py b/custom_components/iec/sensor.py index 21eb6e7..8f4d379 100644 --- a/custom_components/iec/sensor.py +++ b/custom_components/iec/sensor.py @@ -5,7 +5,8 @@ import logging from collections.abc import Callable from dataclasses import dataclass -from datetime import date, datetime, timedelta +from datetime import datetime, timedelta +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -58,10 +59,8 @@ class IecEntityDescriptionMixin: """Mixin values for required keys.""" - value_fn: Callable[[dict | tuple], str | float | date] | None = None - custom_attrs_fn: ( - Callable[[dict | tuple], dict[str, str | int | float | date]] | None - ) = None + value_fn: Callable[[Any], StateType] | None = None + custom_attrs_fn: Callable[[Any], dict[str, Any] | None] | None = None @dataclass(frozen=True, kw_only=True) @@ -87,8 +86,8 @@ def get_previous_bill_kwh_price(invoice: Invoice) -> float: """ if not invoice.consumption or not invoice.amount_origin: - return 0 - return invoice.consumption / invoice.amount_origin + return 0.0 + return float(invoice.consumption) / float(invoice.amount_origin) def _get_iec_type_by_class(description: IecEntityDescription) -> IecEntityType: @@ -411,13 +410,11 @@ async def async_setup_entry( ) ) else: + sensors_desc: tuple[IecEntityDescription, ...] if coordinator.data[contract_key][CONTRACT_DICT_NAME].smart_meter: - sensors_desc: tuple[IecEntityDescription, ...] = ( - ELEC_SENSORS + SMART_ELEC_SENSORS - ) + sensors_desc = ELEC_SENSORS + SMART_ELEC_SENSORS else: - sensors_desc: tuple[IecEntityDescription, ...] = ELEC_SENSORS - # sensors_desc: tuple[IecEntityDescription, ...] = ELEC_SENSORS + sensors_desc = ELEC_SENSORS contract_id = coordinator.data[contract_key][CONTRACT_DICT_NAME].contract_id for sensor_desc in sensors_desc: @@ -445,7 +442,7 @@ def __init__( description: IecEntityDescription, contract_id: str, is_multi_contract: bool, - attributes_to_add: dict | None = None, + attributes_to_add: dict[str, Any] | None = None, ) -> None: """Initialize the sensor.""" super().__init__( @@ -459,7 +456,7 @@ def __init__( self._attr_translation_key = f"{description.key}" self._attr_translation_placeholders = {"multi_contract": f"of {contract_id}"} - attributes = {"contract_id": contract_id} + attributes: dict[str, Any] = {"contract_id": contract_id} if attributes_to_add: attributes.update(attributes_to_add) @@ -484,7 +481,7 @@ def __init__( @property def native_value(self) -> StateType: """Return the state.""" - if self.coordinator.data is not None: + if self.coordinator.data is not None and self.entity_description.value_fn: if self.contract_id in (STATICS_DICT_NAME, JWT_DICT_NAME): return self.entity_description.value_fn( self.coordinator.data.get(self.contract_id, self.meter_id) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..a295c6f --- /dev/null +++ b/mypy.ini @@ -0,0 +1,66 @@ +# mypy configuration for IEC custom component +# Based on Home Assistant's mypy configuration + +[mypy] +python_version = 3.10 +show_error_codes = true +strict = true +explicit_package_bases = true + +# Allow untyped decorators (needed for Home Assistant decorators) +allow_untyped_decorators = true + +# Ignore missing imports for external libraries without type stubs +ignore_missing_imports = true + +# Follow imports for better type checking +follow_imports = normal + +# Set the project root to avoid module name conflicts +namespace_packages = true +mypy_path = . + +# Show error context +show_error_context = true +show_column_numbers = true + +# Warn about common issues +warn_return_any = true +warn_unused_ignores = true +warn_unused_configs = true +warn_redundant_casts = true +warn_no_return = true +warn_unreachable = true + +# Enable optional error codes +enable_error_code = ignore-without-code,redundant-self,truthy-iterable,possibly-undefined,explicit-override + +# Don't check tests folder +exclude = tests/ + +# Check untyped functions too +check_untyped_defs = true + +# Disallow implicit optional (require explicit None) +no_implicit_optional = true + +# Disallow incomplete defs +disallow_incomplete_defs = true + +# Disallow untyped calls +disallow_untyped_calls = false + +# Disallow untyped defs +disallow_untyped_defs = true + +# Disable subclassing Any errors for Home Assistant classes without type stubs +disable_error_code = misc + +[custom_components.iec.*] +disallow_untyped_defs = true + +[mypy-homeassistant.*] +ignore_missing_imports = true + +[mypy-iec_api.*] +ignore_missing_imports = true diff --git a/requirements.txt b/requirements.txt index 94f6dc0..fba2383 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ colorlog>=6.8.2 homeassistant==2024.2.0 iec-api==0.5.2 +mypy>=1.8.0 pip>=21.0 ruff>=0.5.6 +types-pytz>=2024.1.0.0 diff --git a/scripts/lint b/scripts/lint index 9b5b1df..13cdd90 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,4 +4,9 @@ set -e cd "$(dirname "$0")/.." +echo "Running ruff linter..." ruff check . --fix + +echo "" +echo "Running mypy type checker..." +mypy custom_components/iec diff --git a/scripts/setup b/scripts/setup index 141d19f..9db6828 100755 --- a/scripts/setup +++ b/scripts/setup @@ -1,7 +1,94 @@ #!/usr/bin/env bash +# +# Setup script for development environment +# Installs dependencies and sets up git hooks set -e cd "$(dirname "$0")/.." +echo "Installing Python dependencies..." python3 -m pip install --requirement requirements.txt + +echo "" +echo "Setting up git hooks..." + +# Create pre-commit hook +if [ ! -f .git/hooks/pre-commit ]; then + cat > .git/hooks/pre-commit << 'EOF' +#!/bin/sh +# +# Pre-commit hook to run linting and type checking +# This hook is called by "git commit" with no arguments + +# Get the directory where this script is located +HOOK_DIR="$(dirname "$0")" +REPO_ROOT="$(cd "$HOOK_DIR/../.." && pwd)" + +cd "$REPO_ROOT" + +echo "Running pre-commit checks..." +echo "" + +# Check if Python is available +if ! command -v python3 >/dev/null 2>&1; then + echo "Error: python3 not found" + exit 1 +fi + +# Run Ruff linting +echo "Running Ruff linter..." +if command -v ruff >/dev/null 2>&1; then + ruff check . + RUFF_EXIT=$? +else + # Try running via python module + python3 -m ruff check . + RUFF_EXIT=$? +fi + +if [ $RUFF_EXIT -ne 0 ]; then + echo "" + echo "Ruff found issues. You can try to auto-fix with: ruff check . --fix" + echo "Commit aborted." + exit 1 +fi + +echo "Ruff passed!" +echo "" + +# Run MyPy type checking +echo "Running MyPy type checker..." +if command -v mypy >/dev/null 2>&1; then + mypy custom_components/iec + MYPY_EXIT=$? +else + # Try running via python module + python3 -m mypy custom_components/iec + MYPY_EXIT=$? +fi + +if [ $MYPY_EXIT -ne 0 ]; then + echo "" + echo "MyPy found type errors. Please fix them before committing." + echo "Commit aborted." + exit 1 +fi + +echo "MyPy passed!" +echo "" + +echo "All pre-commit checks passed!" +exit 0 +EOF + chmod +x .git/hooks/pre-commit + echo "Pre-commit hook installed successfully!" +else + echo "Pre-commit hook already exists. Skipping..." +fi + +echo "" +echo "Setup complete! You can now use:" +echo " - ./scripts/lint : Run linting and type checking" +echo " - ./scripts/typecheck : Run type checking only" +echo " - git commit : Will automatically run checks before committing" diff --git a/scripts/typecheck b/scripts/typecheck new file mode 100755 index 0000000..4809484 --- /dev/null +++ b/scripts/typecheck @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "Running mypy type checker..." +mypy custom_components/iec