From 6040a191364ea9188c61ce9773c93303e1ad0182 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Mon, 3 Nov 2025 15:54:01 +0000 Subject: [PATCH] update dev environment --- .devcontainer/devcontainer.json | 3 +- .github/ISSUE_TEMPLATE/bug.yml | 21 ++- .github/ISSUE_TEMPLATE/feature_request.yml | 3 +- .../ISSUE_TEMPLATE/pull_request_template.md | 34 +++++ .github/workflows/lint.yml | 15 ++- .github/workflows/validate.yml | 4 +- .gitignore | 18 ++- .ruff.toml | 31 ----- CONTRIBUTING.md | 123 +++++++++--------- config/configuration.yaml | 1 + custom_components/tibber_prices/__init__.py | 14 +- custom_components/tibber_prices/api.py | 22 +++- .../tibber_prices/binary_sensor.py | 27 +++- .../tibber_prices/config_flow.py | 5 +- .../tibber_prices/coordinator.py | 10 +- .../tibber_prices/diagnostics.py | 55 ++++---- custom_components/tibber_prices/entity.py | 7 +- custom_components/tibber_prices/sensor.py | 41 +++++- custom_components/tibber_prices/services.py | 5 +- pyproject.toml | 41 +++++- requirements.txt | 7 +- scripts/bootstrap | 25 ++++ scripts/help | 39 ++++++ scripts/lint | 15 ++- scripts/lint-check | 15 +++ scripts/motd | 24 ++++ scripts/setup | 5 +- scripts/update | 11 ++ tests/test_coordinator_basic.py | 4 +- tests/test_coordinator_enhanced.py | 19 ++- tests/test_midnight_turnover.py | 4 +- tests/test_price_utils_integration.py | 4 +- 32 files changed, 479 insertions(+), 173 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/pull_request_template.md delete mode 100644 .ruff.toml create mode 100755 scripts/bootstrap create mode 100755 scripts/help create mode 100755 scripts/lint-check create mode 100755 scripts/motd create mode 100755 scripts/update diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3f971d8..c239013 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,6 +2,7 @@ "name": "jpawlowski/hass.tibber_prices", "image": "mcr.microsoft.com/devcontainers/python:3.13", "postCreateCommand": "scripts/setup", + "postStartCommand": "scripts/motd", "containerEnv": { "PYTHONASYNCIODEBUG": "1" }, @@ -90,4 +91,4 @@ ] } } -} \ No newline at end of file +} diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 64c4eca..1a87995 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -1,16 +1,31 @@ --- name: "Bug report" -description: "Report a bug with the integration" +description: "Report a bug with the custom integration" +labels: ["bug"] body: - type: markdown attributes: value: Before you open a new issue, search through the existing issues to see if others have had the same problem. +- type: input + attributes: + label: "Home Assistant version" + description: "The version of Home Assistant you are using" + placeholder: "2025.1.0" + validations: + required: true +- type: input + attributes: + label: "Integration version" + description: "The version of this custom integration you are using" + placeholder: "1.0.0" + validations: + required: false - type: textarea attributes: label: "System Health details" description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" validations: - required: true + required: false - type: checkboxes attributes: label: Checklist @@ -52,3 +67,5 @@ body: attributes: label: "Diagnostics dump" description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 352adda..8ca0302 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,7 @@ --- name: "Feature request" -description: "Suggest an idea for this project" +description: "Suggest an idea for this custom integration" +labels: ["Feature request"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/pull_request_template.md b/.github/ISSUE_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000..99ff5f6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/pull_request_template.md @@ -0,0 +1,34 @@ +## Description + + + +## Related Issue + + + +Fixes #(issue) + +## Type of Change + + + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Code quality improvements + +## Checklist + +- [ ] I have read the [CONTRIBUTING](CONTRIBUTING.md) document +- [ ] My code follows the code style of this project (run `scripts/lint`) +- [ ] I have updated the documentation accordingly +- [ ] I have updated the translations if needed + +## Testing + + + +## Screenshots (if applicable) + + diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ea4788f..d46e200 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,13 +22,14 @@ jobs: uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: "3.13" - cache: "pip" + + - name: Install uv + uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0 + with: + version: "0.9.3" - name: Install requirements - run: python3 -m pip install -r requirements.txt + run: scripts/bootstrap - - name: Lint - run: python3 -m ruff check . - - - name: Format - run: python3 -m ruff format . --check + - name: Lint check + run: scripts/lint-check diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 33b795c..c45a142 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -3,7 +3,7 @@ name: Validate on: workflow_dispatch: schedule: - - cron: "0 0 * * *" + - cron: "0 0 * * *" push: branches: - main @@ -32,5 +32,5 @@ jobs: uses: hacs/action@d556e736723344f83838d08488c983a15381059a # 22.5.0 with: category: integration - # Remove this 'ignore' key when you have added brand images for your integration to https://github.com/home-assistant/brands + # Remove this 'ignore' key when you have added brand images for your custom integration to https://github.com/home-assistant/brands ignore: brands diff --git a/.gitignore b/.gitignore index 0a8519a..8286174 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__ *.egg-info */build/* */dist/* +.venv # misc @@ -11,8 +12,23 @@ __pycache__ .vscode coverage.xml .ruff_cache +uv.lock # Home Assistant configuration config/* -!config/configuration.yaml \ No newline at end of file +!config/configuration.yaml + +# Home Assistant database and logs +*.db +*.db-shm +*.db-wal +*.log +home-assistant.log* +home-assistant_v2.db* + +# Temporary files +*.tmp +*.temp +.DS_Store +Thumbs.db diff --git a/.ruff.toml b/.ruff.toml deleted file mode 100644 index 2833cdd..0000000 --- a/.ruff.toml +++ /dev/null @@ -1,31 +0,0 @@ -# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml - -target-version = "py313" -line-length = 120 - -[lint] -select = [ - "ALL", -] - -ignore = [ - "ANN101", # Missing type annotation for `self` in method - "ANN401", # Dynamically typed expressions (typing.Any) are disallowed - "D203", # no-blank-line-before-class (incompatible with formatter) - "D212", # multi-line-summary-first-line (incompatible with formatter) - "COM812", # incompatible with formatter - "ISC001", # incompatible with formatter -] - -[lint.flake8-pytest-style] -fixture-parentheses = false - -[lint.isort] -force-single-line = false -known-first-party = ["custom_components", "homeassistant"] - -[lint.pyupgrade] -keep-runtime-typing = true - -[lint.mccabe] -max-complexity = 25 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88f2fa7..4ca0898 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,61 +1,62 @@ -# 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! - -## Any contributions you make will be under the MIT Software License - -In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. - -## Report bugs using Github's [issues](../../issues) - -GitHub issues are used to track public bugs. -Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! - -## Write bug reports with detail, background, and sample code - -**Great Bug Reports** tend to have: - -- A quick summary and/or background -- Steps to reproduce - - Be specific! - - Give sample code if you can. -- What you expected would happen -- What actually happens -- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) - -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. - -## Test your code modification - -This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). - -It comes with development environment in a container, easy to launch -if you use Visual Studio Code. With this container you will have a stand alone -Home Assistant instance running and already configured with the included -[`configuration.yaml`](./config/configuration.yaml) -file. - -## License - -By contributing, you agree that your contributions will be licensed under its MIT License. +# 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. Run `scripts/bootstrap` to install dependencies and pre-commit hooks. +3. If you've changed something, update the documentation. +4. Make sure your code lints (using `scripts/lint`). +5. Test your contribution. +6. Issue that pull request! + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](../../issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People *love* thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +Use [Ruff](https://github.com/astral-sh/ruff) for linting and formatting. Run `scripts/lint` to format your code before submitting. + +## Test your code modification + +This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). + +It comes with development environment in a container, easy to launch +if you use Visual Studio Code. With this container you will have a stand alone +Home Assistant instance running and already configured with the included +[`configuration.yaml`](./config/configuration.yaml) +file. + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/config/configuration.yaml b/config/configuration.yaml index 760d711..b8b36d8 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -12,6 +12,7 @@ script: scene: +# https://www.home-assistant.io/integrations/logger/ logger: default: info logs: diff --git a/custom_components/tibber_prices/__init__.py b/custom_components/tibber_prices/__init__.py index 106ce58..1184d67 100644 --- a/custom_components/tibber_prices/__init__.py +++ b/custom_components/tibber_prices/__init__.py @@ -16,7 +16,12 @@ from homeassistant.helpers.storage import Store from homeassistant.loader import async_get_loaded_integration from .api import TibberPricesApiClient -from .const import DOMAIN, LOGGER, async_load_standard_translations, async_load_translations +from .const import ( + DOMAIN, + LOGGER, + async_load_standard_translations, + async_load_translations, +) from .coordinator import STORAGE_VERSION, TibberPricesDataUpdateCoordinator from .data import TibberPricesData from .services import async_setup_services @@ -88,7 +93,12 @@ async def async_unload_entry( # Unregister services if this was the last config entry if not hass.config_entries.async_entries(DOMAIN): - for service in ["get_price", "get_apexcharts_data", "get_apexcharts_yaml", "refresh_user_data"]: + for service in [ + "get_price", + "get_apexcharts_data", + "get_apexcharts_yaml", + "refresh_user_data", + ]: if hass.services.has_service(DOMAIN, service): hass.services.async_remove(DOMAIN, service) diff --git a/custom_components/tibber_prices/api.py b/custom_components/tibber_prices/api.py index 03476b2..e4a07e4 100644 --- a/custom_components/tibber_prices/api.py +++ b/custom_components/tibber_prices/api.py @@ -114,7 +114,11 @@ async def _verify_graphql_response(response_json: dict, query_type: QueryType) - if error_code in ["RATE_LIMITED", "TOO_MANY_REQUESTS"]: # Some GraphQL APIs return rate limit info in extensions retry_after = extensions.get("retryAfter", "unknown") - _LOGGER.warning("Tibber API rate limited via GraphQL: %s (retry after %s)", message, retry_after) + _LOGGER.warning( + "Tibber API rate limited via GraphQL: %s (retry after %s)", + message, + retry_after, + ) raise TibberPricesApiClientError( TibberPricesApiClientError.RATE_LIMIT_ERROR.format(retry_after=retry_after) ) @@ -178,7 +182,10 @@ def _is_data_empty(data: dict, query_type: str) -> bool: ) is_empty = not has_user_id or not has_homes _LOGGER.debug( - "Viewer check - has_user_id: %s, has_homes: %s, is_empty: %s", has_user_id, has_homes, is_empty + "Viewer check - has_user_id: %s, has_homes: %s, is_empty: %s", + has_user_id, + has_homes, + is_empty, ) elif query_type == "price_info": @@ -617,7 +624,8 @@ class TibberPricesApiClient: except TimeoutError as error: _LOGGER.exception( - "Request timeout after %d seconds - slow network or server overload", self._request_timeout + "Request timeout after %d seconds - slow network or server overload", + self._request_timeout, ) raise TibberPricesApiClientCommunicationError( TibberPricesApiClientCommunicationError.TIMEOUT_ERROR.format(exception=str(error)) @@ -714,7 +722,13 @@ class TibberPricesApiClient: return False, 0 # Non-retryable errors - authentication and permission issues - if isinstance(error, (TibberPricesApiClientAuthenticationError, TibberPricesApiClientPermissionError)): + if isinstance( + error, + ( + TibberPricesApiClientAuthenticationError, + TibberPricesApiClientPermissionError, + ), + ): return False, 0 # Handle API-specific errors diff --git a/custom_components/tibber_prices/binary_sensor.py b/custom_components/tibber_prices/binary_sensor.py index 2489b84..f67060f 100644 --- a/custom_components/tibber_prices/binary_sensor.py +++ b/custom_components/tibber_prices/binary_sensor.py @@ -585,7 +585,11 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): # Add basic description description = await async_get_entity_description( - self.hass, "binary_sensor", self.entity_description.translation_key, language, "description" + self.hass, + "binary_sensor", + self.entity_description.translation_key, + language, + "description", ) if description: attributes["description"] = description @@ -611,7 +615,11 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): # Add usage tips if available usage_tips = await async_get_entity_description( - self.hass, "binary_sensor", self.entity_description.translation_key, language, "usage_tips" + self.hass, + "binary_sensor", + self.entity_description.translation_key, + language, + "usage_tips", ) if usage_tips: attributes["usage_tips"] = usage_tips @@ -649,7 +657,10 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): # Add basic description from cache description = get_entity_description( - "binary_sensor", self.entity_description.translation_key, language, "description" + "binary_sensor", + self.entity_description.translation_key, + language, + "description", ) if description: attributes["description"] = description @@ -664,14 +675,20 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): if extended_descriptions: # Add long description if available in cache long_desc = get_entity_description( - "binary_sensor", self.entity_description.translation_key, language, "long_description" + "binary_sensor", + self.entity_description.translation_key, + language, + "long_description", ) if long_desc: attributes["long_description"] = long_desc # Add usage tips if available in cache usage_tips = get_entity_description( - "binary_sensor", self.entity_description.translation_key, language, "usage_tips" + "binary_sensor", + self.entity_description.translation_key, + language, + "usage_tips", ) if usage_tips: attributes["usage_tips"] = usage_tips diff --git a/custom_components/tibber_prices/config_flow.py b/custom_components/tibber_prices/config_flow.py index 2a3be55..1828782 100644 --- a/custom_components/tibber_prices/config_flow.py +++ b/custom_components/tibber_prices/config_flow.py @@ -71,7 +71,10 @@ class TibberPricesFlowHandler(ConfigFlow, domain=DOMAIN): @classmethod @callback - def async_get_supported_subentry_types(cls, config_entry: ConfigEntry) -> dict[str, type[ConfigSubentryFlow]]: # noqa: ARG003 + def async_get_supported_subentry_types( + cls, + config_entry: ConfigEntry, # noqa: ARG003 + ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" return {"home": TibberPricesSubentryFlowHandler} diff --git a/custom_components/tibber_prices/coordinator.py b/custom_components/tibber_prices/coordinator.py index 10ff92b..a799f62 100644 --- a/custom_components/tibber_prices/coordinator.py +++ b/custom_components/tibber_prices/coordinator.py @@ -30,7 +30,10 @@ from .const import ( DEFAULT_PRICE_RATING_THRESHOLD_LOW, DOMAIN, ) -from .price_utils import enrich_price_info_with_differences, find_price_data_for_interval +from .price_utils import ( + enrich_price_info_with_differences, + find_price_data_for_interval, +) _LOGGER = logging.getLogger(__name__) @@ -377,7 +380,10 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._cached_user_data = user_data self._last_user_update = current_time _LOGGER.debug("User data updated successfully") - except (TibberPricesApiClientError, TibberPricesApiClientCommunicationError) as ex: + except ( + TibberPricesApiClientError, + TibberPricesApiClientCommunicationError, + ) as ex: _LOGGER.warning("Failed to update user data: %s", ex) @callback diff --git a/custom_components/tibber_prices/diagnostics.py b/custom_components/tibber_prices/diagnostics.py index a34be2b..c178679 100644 --- a/custom_components/tibber_prices/diagnostics.py +++ b/custom_components/tibber_prices/diagnostics.py @@ -1,38 +1,47 @@ -"""Diagnostics support for tibber_prices.""" +""" +Diagnostics support for tibber_prices. + +Learn more about diagnostics: +https://developers.home-assistant.io/docs/core/integration_diagnostics +""" from __future__ import annotations from typing import TYPE_CHECKING, Any -from homeassistant.components.diagnostics import async_redact_data - -from .const import DOMAIN - if TYPE_CHECKING: - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -TO_REDACT = {"access_token"} + from .data import TibberPricesConfigEntry -async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: ConfigEntry) -> dict[str, Any]: +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, # noqa: ARG001 + entry: TibberPricesConfigEntry, +) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id].coordinator + coordinator = entry.runtime_data.coordinator return { - "entry": async_redact_data(entry.as_dict(), TO_REDACT), - "coordinator_data": coordinator.data, - "last_update_success": coordinator.last_update_success, - "update_timestamps": { - "price": coordinator.last_price_update.isoformat() if coordinator.last_price_update else None, - "hourly_rating": coordinator.last_rating_update_hourly.isoformat() - if coordinator.last_rating_update_hourly - else None, - "daily_rating": coordinator.last_rating_update_daily.isoformat() - if coordinator.last_rating_update_daily - else None, - "monthly_rating": coordinator.last_rating_update_monthly.isoformat() - if coordinator.last_rating_update_monthly - else None, + "entry": { + "entry_id": entry.entry_id, + "version": entry.version, + "minor_version": entry.minor_version, + "domain": entry.domain, + "title": entry.title, + "state": str(entry.state), + }, + "coordinator": { + "last_update_success": coordinator.last_update_success, + "update_interval": str(coordinator.update_interval), + "is_main_entry": coordinator.is_main_entry(), + "data": coordinator.data, + "update_timestamps": { + "price": coordinator._last_price_update.isoformat() if coordinator._last_price_update else None, # noqa: SLF001 + "user": coordinator._last_user_update.isoformat() if coordinator._last_user_update else None, # noqa: SLF001 + }, + }, + "error": { + "last_exception": str(coordinator.last_exception) if coordinator.last_exception else None, }, } diff --git a/custom_components/tibber_prices/entity.py b/custom_components/tibber_prices/entity.py index 72ebcdc..c6873dc 100644 --- a/custom_components/tibber_prices/entity.py +++ b/custom_components/tibber_prices/entity.py @@ -77,7 +77,12 @@ class TibberPricesEntity(CoordinatorEntity[TibberPricesDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.config_entry.unique_id or coordinator.config_entry.entry_id)}, + identifiers={ + ( + DOMAIN, + coordinator.config_entry.unique_id or coordinator.config_entry.entry_id, + ) + }, name=home_name, manufacturer="Tibber", model=translated_model, diff --git a/custom_components/tibber_prices/sensor.py b/custom_components/tibber_prices/sensor.py index ccff399..4d9d172 100644 --- a/custom_components/tibber_prices/sensor.py +++ b/custom_components/tibber_prices/sensor.py @@ -10,7 +10,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import CURRENCY_EURO, PERCENTAGE, EntityCategory, UnitOfPower, UnitOfTime +from homeassistant.const import ( + CURRENCY_EURO, + PERCENTAGE, + EntityCategory, + UnitOfPower, + UnitOfTime, +) from homeassistant.util import dt as dt_util from .const import ( @@ -239,10 +245,14 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): "highest_price_today": lambda: self._get_statistics_value(stat_func=max, in_euro=False, decimals=2), "highest_price_today_eur": lambda: self._get_statistics_value(stat_func=max, in_euro=True, decimals=4), "average_price_today": lambda: self._get_statistics_value( - stat_func=lambda prices: sum(prices) / len(prices), in_euro=False, decimals=2 + stat_func=lambda prices: sum(prices) / len(prices), + in_euro=False, + decimals=2, ), "average_price_today_eur": lambda: self._get_statistics_value( - stat_func=lambda prices: sum(prices) / len(prices), in_euro=True, decimals=4 + stat_func=lambda prices: sum(prices) / len(prices), + in_euro=True, + decimals=4, ), # Rating sensors "price_rating": lambda: self._get_rating_value(rating_type="current"), @@ -357,7 +367,11 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): return None def _get_statistics_value( - self, *, stat_func: Callable[[list[float]], float], in_euro: bool, decimals: int | None = None + self, + *, + stat_func: Callable[[list[float]], float], + in_euro: bool, + decimals: int | None = None, ) -> float | None: """ Handle statistics sensor values using the provided statistical function. @@ -794,7 +808,10 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): if self.entity_description.key == "price_level" and current_interval_data and "level" in current_interval_data: self._add_price_level_attributes(attributes, current_interval_data["level"]) - if self.entity_description.key in ["next_interval_price", "next_interval_price_eur"]: + if self.entity_description.key in [ + "next_interval_price", + "next_interval_price_eur", + ]: price_info = self.coordinator.data.get("priceInfo", {}) now = dt_util.now() next_interval_time = now + timedelta(minutes=MINUTES_PER_INTERVAL) @@ -815,7 +832,12 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): attributes["level_id"] = level def _find_price_timestamp( - self, attributes: dict, price_info: Any, day_key: str, target_hour: int, target_date: date + self, + attributes: dict, + price_info: Any, + day_key: str, + target_hour: int, + target_date: date, ) -> None: """Find a price timestamp for a specific hour and date.""" for price_data in price_info.get(day_key, []): @@ -842,7 +864,12 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): if hasattr(self, "_last_rating_level") and self._last_rating_level is not None: attributes["level_id"] = self._last_rating_level attributes["level_value"] = PRICE_RATING_MAPPING.get(self._last_rating_level, self._last_rating_level) - elif key in ["lowest_price_today", "lowest_price_today_eur", "highest_price_today", "highest_price_today_eur"]: + elif key in [ + "lowest_price_today", + "lowest_price_today_eur", + "highest_price_today", + "highest_price_today_eur", + ]: # Use the timestamp from the interval that has the extreme price (already stored during value calculation) if hasattr(self, "_last_extreme_interval") and self._last_extreme_interval: attributes["timestamp"] = self._last_extreme_interval.get("startsAt") diff --git a/custom_components/tibber_prices/services.py b/custom_components/tibber_prices/services.py index 4e205cc..1c10a1f 100644 --- a/custom_components/tibber_prices/services.py +++ b/custom_components/tibber_prices/services.py @@ -336,7 +336,10 @@ def _get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, d """Validate entry and extract coordinator and data.""" if not entry_id: raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id") - entry = next((e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id), None) + entry = next( + (e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id), + None, + ) if not entry or not hasattr(entry, "runtime_data") or not entry.runtime_data: raise ServiceValidationError(translation_domain=DOMAIN, translation_key="invalid_entry_id") coordinator = entry.runtime_data.coordinator diff --git a/pyproject.toml b/pyproject.toml index 1c4ac2a..c973290 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,37 @@ -# pyproject.toml +[build-system] +requires = ["setuptools==78.1.1"] +build-backend = "setuptools.build_meta" -[tool.black] +[project] +name = "tibber_prices" +version = "0.1.0" +requires-python = ">=3.13" + +[tool.ruff] +# Based on https://github.com/home-assistant/core/blob/dev/pyproject.toml +target-version = "py313" line-length = 120 -target-version = ['py313'] -skip-string-normalization = false -[tool.isort] -profile = "black" -line_length = 120 +[tool.ruff.lint] +select = ["ALL"] +ignore = [ +# "ANN101", # Missing type annotation for `self` in method + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed + "D203", # no-blank-line-before-class (incompatible with formatter) + "D212", # multi-line-summary-first-line (incompatible with formatter) + "COM812", # incompatible with formatter + "ISC001", # incompatible with formatter +] + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false + +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = true + +[tool.ruff.lint.mccabe] +max-complexity = 25 + +[tool.ruff.lint.isort] +force-single-line = false +known-first-party = ["custom_components", "homeassistant"] diff --git a/requirements.txt b/requirements.txt index ca82c3b..87f2c00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -colorlog>=6.9.0,<7.0.0 -homeassistant>=2025.10.0,<2025.11.0 +colorlog>=6.10.1,<6.11.0 +homeassistant>=2025.6.0,<2025.7.0 pytest-homeassistant-custom-component>=0.13.0,<0.14.0 pip>=21.3.1 -ruff>=0.11.6,<0.15.0 +pre-commit>=4.3.0,<4.4.0 +ruff>=0.14.1,<0.15.0 diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 0000000..8f39b10 --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,25 @@ +#!/bin/sh + +# script/bootstrap: Install/update all dependencies required to run the project + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Updating system packages..." +sudo apt-get update +sudo apt-get upgrade -y + +echo "==> Checking for uv..." +if ! command -v uv >/dev/null 2>&1; then + echo "UV not found, installing..." + pipx install uv +fi + +echo "==> Installing dependencies..." +python3 -m pip install --requirement requirements.txt + +echo "==> Installing pre-commit hooks..." +pre-commit install + +echo "==> Bootstrap completed!" diff --git a/scripts/help b/scripts/help new file mode 100755 index 0000000..7b0837e --- /dev/null +++ b/scripts/help @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +# script/help: Display information about available scripts. + +set -e + +cd "$(dirname "$0")/.." + +REPO_NAME=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || basename "$(pwd)") + +printf "\033[1m%s\033[36m %s\033[32m %s\033[0m \n\n" "Development environment for" "$REPO_NAME" "" + +echo "Available scripts:" +echo "" + +find scripts -type f -perm -111 -print0 | sort -z | while IFS= read -r -d '' script; do + script_name=$(basename "$script") + description=$(awk -v prefix="# script/$script_name:" ' + BEGIN {desc=""} + $0 ~ prefix { + line = $0 + sub(prefix, "", line) + sub(/^# */, "", line) + desc = desc (desc ? " " : "") line + next + } + desc != "" {exit} + END {print desc} + ' "$script") + if [ -z "$description" ]; then + description="No description available" + fi + if [ ${#description} -gt 60 ]; then + description=$(echo "$description" | cut -c1-57)... + fi + printf " \033[36m %-25s\033[0m %s\n" "scripts/$script_name" "$description" +done + +echo "" diff --git a/scripts/lint b/scripts/lint index 5d68d15..3cb7ccb 100755 --- a/scripts/lint +++ b/scripts/lint @@ -1,8 +1,17 @@ -#!/usr/bin/env bash +#!/bin/sh + +# script/lint: Run linting tools and apply formatting set -e cd "$(dirname "$0")/.." -ruff format . -ruff check . --fix +echo "==> Running linting tools..." + +echo "==> Running Ruff format..." +uv run ruff format . + +echo "==> Running Ruff check..." +uv run ruff check . --fix + +echo "==> Linting completed!" diff --git a/scripts/lint-check b/scripts/lint-check new file mode 100755 index 0000000..27579a4 --- /dev/null +++ b/scripts/lint-check @@ -0,0 +1,15 @@ +#!/bin/sh + +# script/lint-check: Check linting without making changes. + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Checking code format..." +uv run ruff format . --check + +echo "==> Checking code with Ruff..." +uv run ruff check . + +echo "==> Linting check completed!" diff --git a/scripts/motd b/scripts/motd new file mode 100755 index 0000000..379d6c9 --- /dev/null +++ b/scripts/motd @@ -0,0 +1,24 @@ +#/bin/sh + +# script/motd: Display Message of the Day for development environment + +set -e + +cd "$(dirname "$0")/.." + +REPO_NAME=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || basename "$(pwd)") + +echo "" +echo "🎉 Welcome to the $REPO_NAME development environment!" +echo "" +echo "📂 Project: $(pwd)" +echo "🌿 Git branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'Not a git repo')" +echo "🐍 Python: $(python3 --version 2>/dev/null || echo 'Not available')" +echo "" + +scripts/help + +echo "" +echo "💡 Tip: Run 'scripts/develop' to start Home Assistant with your custom integration." +echo " Access it at http://localhost:8123" +echo "" diff --git a/scripts/setup b/scripts/setup index 141d19f..80dcd84 100755 --- a/scripts/setup +++ b/scripts/setup @@ -4,4 +4,7 @@ set -e cd "$(dirname "$0")/.." -python3 -m pip install --requirement requirements.txt +#python3 -m pip install --requirement requirements.txt +scripts/bootstrap + +echo "==> Project is now ready to go!" diff --git a/scripts/update b/scripts/update new file mode 100755 index 0000000..fe90591 --- /dev/null +++ b/scripts/update @@ -0,0 +1,11 @@ +#!/bin/sh + +# script/update: Update project after a fresh pull + +set -e + +cd "$(dirname "$0")/.." + +scripts/bootstrap + +echo "==> Update completed!" diff --git a/tests/test_coordinator_basic.py b/tests/test_coordinator_basic.py index d2e0857..535e046 100644 --- a/tests/test_coordinator_basic.py +++ b/tests/test_coordinator_basic.py @@ -4,7 +4,9 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator +from custom_components.tibber_prices.coordinator import ( + TibberPricesDataUpdateCoordinator, +) class TestBasicCoordinator: diff --git a/tests/test_coordinator_enhanced.py b/tests/test_coordinator_enhanced.py index b9b03ab..265a1eb 100644 --- a/tests/test_coordinator_enhanced.py +++ b/tests/test_coordinator_enhanced.py @@ -9,7 +9,9 @@ import pytest from custom_components.tibber_prices.api import TibberPricesApiClientCommunicationError from custom_components.tibber_prices.const import DOMAIN -from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator +from custom_components.tibber_prices.coordinator import ( + TibberPricesDataUpdateCoordinator, +) class TestEnhancedCoordinator: @@ -63,7 +65,10 @@ class TestEnhancedCoordinator: "custom_components.tibber_prices.coordinator.aiohttp_client.async_get_clientsession", return_value=mock_session, ), - patch("custom_components.tibber_prices.coordinator.Store", return_value=mock_store), + patch( + "custom_components.tibber_prices.coordinator.Store", + return_value=mock_store, + ), ): coordinator = TibberPricesDataUpdateCoordinator( hass=mock_hass, @@ -88,7 +93,10 @@ class TestEnhancedCoordinator: "custom_components.tibber_prices.coordinator.aiohttp_client.async_get_clientsession", return_value=mock_session, ), - patch("custom_components.tibber_prices.coordinator.Store", return_value=mock_store), + patch( + "custom_components.tibber_prices.coordinator.Store", + return_value=mock_store, + ), ): main_coordinator = TibberPricesDataUpdateCoordinator( hass=mock_hass, @@ -112,7 +120,10 @@ class TestEnhancedCoordinator: "custom_components.tibber_prices.coordinator.aiohttp_client.async_get_clientsession", return_value=mock_session, ), - patch("custom_components.tibber_prices.coordinator.Store", return_value=mock_store), + patch( + "custom_components.tibber_prices.coordinator.Store", + return_value=mock_store, + ), ): sub_coordinator = TibberPricesDataUpdateCoordinator( hass=mock_hass, diff --git a/tests/test_midnight_turnover.py b/tests/test_midnight_turnover.py index 7edbeab..6a1530f 100644 --- a/tests/test_midnight_turnover.py +++ b/tests/test_midnight_turnover.py @@ -5,7 +5,9 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta from unittest.mock import Mock, patch -from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator +from custom_components.tibber_prices.coordinator import ( + TibberPricesDataUpdateCoordinator, +) # Constants for test validation INTERVALS_PER_DAY = 96 diff --git a/tests/test_price_utils_integration.py b/tests/test_price_utils_integration.py index c80cdc2..56c68ef 100644 --- a/tests/test_price_utils_integration.py +++ b/tests/test_price_utils_integration.py @@ -2,7 +2,9 @@ from datetime import datetime, timedelta -from custom_components.tibber_prices.price_utils import enrich_price_info_with_differences +from custom_components.tibber_prices.price_utils import ( + enrich_price_info_with_differences, +) from homeassistant.util import dt as dt_util # Constants for integration testing