diff options
| author | Edward Betts <edward@4angle.com> | 2026-03-08 09:47:03 +0000 |
|---|---|---|
| committer | git-ubuntu importer <ubuntu-devel-discuss@lists.ubuntu.com> | 2026-03-08 16:50:17 +0000 |
| commit | 7188541e1e39489b1bb372d9a92d9d093d3f6377 (patch) | |
| tree | fbc23fec9f99d15973f8e8a07d727cdbac7f2995 | |
0.3.1-1 (patches unapplied)HEADimport/0.3.1-1ubuntu/stonking-proposedubuntu/stonking-develubuntu/stonkingubuntu/develdebian/sid
Imported using git-ubuntu import.
Notes
Notes:
| -rw-r--r-- | .github/workflows/publish.yml | 36 | ||||
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | HA_INTEGRATION_CHANGES.md | 117 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 53 | ||||
| -rw-r--r-- | debian/changelog | 5 | ||||
| -rw-r--r-- | debian/control | 40 | ||||
| -rw-r--r-- | debian/copyright | 35 | ||||
| -rw-r--r-- | debian/docs | 2 | ||||
| -rwxr-xr-x | debian/rules | 3 | ||||
| -rw-r--r-- | debian/salsa-ci.yml | 3 | ||||
| -rw-r--r-- | debian/source/format | 1 | ||||
| -rw-r--r-- | debian/upstream/metadata | 4 | ||||
| -rw-r--r-- | debian/watch | 5 | ||||
| -rw-r--r-- | pyproject.toml | 34 | ||||
| -rw-r--r-- | src/essent_dynamic_pricing/__init__.py | 21 | ||||
| -rw-r--r-- | src/essent_dynamic_pricing/client.py | 204 | ||||
| -rw-r--r-- | src/essent_dynamic_pricing/exceptions.py | 16 | ||||
| -rw-r--r-- | src/essent_dynamic_pricing/models.py | 84 | ||||
| -rw-r--r-- | src/essent_dynamic_pricing/py.typed | 1 | ||||
| -rw-r--r-- | tests/test_client.py | 568 |
21 files changed, 1258 insertions, 0 deletions
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6e12ab4 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,36 @@ +name: Publish to PyPI + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + + - name: Install build tooling + run: | + python -m pip install --upgrade pip + python -m pip install build + + - name: Build distribution + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88ea843 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +dist/ +__pycache__/ +*.pyc +.coverage diff --git a/HA_INTEGRATION_CHANGES.md b/HA_INTEGRATION_CHANGES.md new file mode 100644 index 0000000..3df91b1 --- /dev/null +++ b/HA_INTEGRATION_CHANGES.md @@ -0,0 +1,117 @@ +# Home Assistant Integration Changes Required + +This document describes the changes needed in the Home Assistant `essent` integration to support `essent-dynamic-pricing` v0.3.0. + +## Breaking Change Summary + +**Library v0.3.0** makes the `gas` field optional in `EssentPrices`: + +```python +# BEFORE (v0.2.x) +@dataclass +class EssentPrices: + electricity: EnergyData # Required + gas: EnergyData # Required + +# AFTER (v0.3.0) +@dataclass +class EssentPrices: + electricity: EnergyData # Required + gas: Optional[EnergyData] # Can be None +``` + +## Why This Change? + +The Essent API currently has a technical issue where gas data is missing. This causes the integration to completely fail, leaving all sensors (both electricity and gas) unavailable. By making gas optional: + +- ✅ Electricity sensors continue working when gas data is missing +- ✅ Gas sensors gracefully show "unavailable" instead of crashing +- ✅ When API is fixed, all sensors work normally again + +## Required Changes to HA Integration + +### File: `homeassistant/components/essent/sensor.py` + +**Update the `native_value` property** in the `EssentSensor` class to handle None: + +```python +@property +def native_value(self) -> float | None: + """Return the sensor value.""" + # Get the appropriate energy data based on sensor type + energy_data = ( + self.coordinator.data.electricity + if self._energy_type == "electricity" + else self.coordinator.data.gas + ) + + # Return None if this energy type is unavailable + if energy_data is None: + return None + + # Use the value function to extract the specific metric + return self._value_fn(energy_data) +``` + +**Alternative approach** - Check during sensor setup (if you want to skip creating sensors): + +```python +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Essent sensor platform.""" + coordinator: EssentDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[EssentSensor] = [] + + # Always create electricity sensors + for sensor_description in ELECTRICITY_SENSORS: + entities.append( + EssentSensor(coordinator, sensor_description, "electricity") + ) + + # Only create gas sensors if gas data is available + if coordinator.data.gas is not None: + for sensor_description in GAS_SENSORS: + entities.append( + EssentSensor(coordinator, sensor_description, "gas") + ) + + async_add_entities(entities) +``` + +## Testing the Changes + +### Test Case 1: Gas Missing (Current API State) +- **Expected**: Electricity sensors show values, gas sensors show "unavailable" +- **Library returns**: `EssentPrices(electricity=EnergyData(...), gas=None)` + +### Test Case 2: Both Available (Normal Operation) +- **Expected**: All sensors show values +- **Library returns**: `EssentPrices(electricity=EnergyData(...), gas=EnergyData(...))` + +### Test Case 3: Both Missing +- **Expected**: Integration fails with error (rare edge case) +- **Library raises**: `EssentDataError("Response missing both electricity and gas data")` + +## Migration Steps + +1. Update `manifest.json` dependency: + ```json + "requirements": ["essent-dynamic-pricing==0.3.0"] + ``` + +2. Update `sensor.py` with null-check logic (shown above) + +3. Test with current API (gas=None scenario) + +4. Submit PR to home-assistant/core with: + - Updated manifest.json + - Updated sensor.py + - Note in PR description about handling API outages + +## Rollback Plan + +If issues arise, revert to v0.2.7 and accept that the integration will be broken when gas data is missing from the API.
\ No newline at end of file @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Home Assistant Community + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..31f10b4 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Essent dynamic pricing client + +Async client for Essent's public dynamic price API, returning normalized electricity +and gas tariffs ready for Home Assistant or other consumers. Tariff start/end values +are returned as timezone-aware datetimes in the Europe/Amsterdam timezone. + +## Usage + +```python +import asyncio +from aiohttp import ClientSession +from essent_dynamic_pricing import EssentClient + +async def main(): + async with ClientSession() as session: + client = EssentClient(session=session) + data = await client.async_get_prices() + + # Electricity data is always available + print(f"Electricity: {data.electricity.min_price} - {data.electricity.max_price} €/{data.electricity.unit}") + + # Gas data may be None if unavailable from API + if data.gas: + print(f"Gas: {data.gas.min_price} - {data.gas.max_price} €/{data.gas.unit}") + else: + print("Gas data not available") + +asyncio.run(main()) +``` + +## Breaking Changes in v0.3.0 + +The `gas` field in `EssentPrices` is now `Optional[EnergyData]` instead of required. This handles cases where the Essent API temporarily doesn't provide gas data. Electricity data is still required. + +**Before (v0.2.x):** +```python +data = await client.async_get_prices() +print(data.gas.min_price) # Always worked +``` + +**After (v0.3.0):** +```python +data = await client.async_get_prices() +if data.gas: + print(data.gas.min_price) # Check for None first +``` + +## Development / tests + +1. Install dev deps (adds pytest and pytest-asyncio): + `pip install -e .[test]` +2. Run tests: + `pytest` diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..b96e0ef --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +python-essent-dynamic-pricing (0.3.1-1) unstable; urgency=medium + + * Initial release. (Closes: #1130075) + + -- Edward Betts <edward@4angle.com> Sun, 08 Mar 2026 09:47:03 +0000 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..81a68e6 --- /dev/null +++ b/debian/control @@ -0,0 +1,40 @@ +Source: python-essent-dynamic-pricing +Maintainer: Home Assistant Team <team+homeassistant@tracker.debian.org> +Uploaders: + Edward Betts <edward@4angle.com>, +Section: python +Build-Depends: + debhelper-compat (= 13), + dh-sequence-python3, + pybuild-plugin-pyproject, + python3-all, + python3-hatchling, +Build-Depends-Indep: + python3-aiohttp <!nocheck>, + python3-mashumaro <!nocheck>, + python3-pytest <!nocheck>, + python3-pytest-asyncio <!nocheck>, +Standards-Version: 4.7.3 +Homepage: https://github.com/jaapp/py-essent-dynamic-pricing +Vcs-Browser: https://salsa.debian.org/homeassistant-team/deps/python-essent-dynamic-pricing +Vcs-Git: https://salsa.debian.org/homeassistant-team/deps/python-essent-dynamic-pricing.git +Testsuite: autopkgtest-pkg-pybuild + +Package: python3-essent-dynamic-pricing +Architecture: all +Depends: + ${misc:Depends}, + ${python3:Depends}, +Description: Client for Essent dynamic energy price data + This library retrieves dynamic electricity and gas tariff data from Essent's + public pricing API and presents it in a normalized form for other software to + consume. + . + It provides electricity prices and, when available from the service, gas + prices. Returned tariff periods include start and end times as timezone-aware + datetimes in the Europe/Amsterdam timezone, which helps preserve the timing of + tariff changes. + . + The package talks to Essent's public dynamic price API and converts the + response into structured price data, including values such as minimum and + maximum prices and the unit used for each energy type. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..3766618 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,35 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: essent-dynamic-pricing +Upstream-Contact: Jaap Pieroen <github@pieroen.nl> +Source: https://github.com/jaapp/py-essent-dynamic-pricing + +Files: * +Copyright: 2025 Home Assistant Community +License: MIT + +Files: debian/* +Copyright: 2026 Edward Betts <edward@4angle.com> +License: MIT + + +License: MIT + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + . + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. diff --git a/debian/docs b/debian/docs new file mode 100644 index 0000000..972b40d --- /dev/null +++ b/debian/docs @@ -0,0 +1,2 @@ +HA_INTEGRATION_CHANGES.md +README.md diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..bc8aecb --- /dev/null +++ b/debian/rules @@ -0,0 +1,3 @@ +#!/usr/bin/make -f +%: + dh $@ --buildsystem=pybuild diff --git a/debian/salsa-ci.yml b/debian/salsa-ci.yml new file mode 100644 index 0000000..84d19ed --- /dev/null +++ b/debian/salsa-ci.yml @@ -0,0 +1,3 @@ +--- +include: + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/recipes/debian.yml
\ No newline at end of file diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..46ebe02 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt)
\ No newline at end of file diff --git a/debian/upstream/metadata b/debian/upstream/metadata new file mode 100644 index 0000000..d049362 --- /dev/null +++ b/debian/upstream/metadata @@ -0,0 +1,4 @@ +Bug-Database: https://github.com/jaapp/py-essent-dynamic-pricing/issues +Bug-Submit: https://github.com/jaapp/py-essent-dynamic-pricing/issues/new +Repository: https://github.com/jaapp/py-essent-dynamic-pricing.git +Repository-Browse: https://github.com/jaapp/py-essent-dynamic-pricing
\ No newline at end of file diff --git a/debian/watch b/debian/watch new file mode 100644 index 0000000..a93b0c8 --- /dev/null +++ b/debian/watch @@ -0,0 +1,5 @@ +Version: 5 + +Template: Github +Owner: jaapp +Project: py-essent-dynamic-pricing
\ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..454f091 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "essent-dynamic-pricing" +version = "0.3.1" +description = "Async client for Essent dynamic energy prices" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.13" +authors = [ + {name = "Home Assistant Community"}, +] +dependencies = [ + "aiohttp>=3.9", + "mashumaro>=3.17", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.2", + "pytest-asyncio>=0.24", +] + +[project.urls] +Homepage = "https://github.com/jaapp/py-essent-dynamic-pricing" +Repository = "https://github.com/jaapp/py-essent-dynamic-pricing" + +[tool.hatch.build.targets.wheel] +force-include = {"src/essent_dynamic_pricing/py.typed" = "essent_dynamic_pricing/py.typed"} + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/src/essent_dynamic_pricing/__init__.py b/src/essent_dynamic_pricing/__init__.py new file mode 100644 index 0000000..1ac81cb --- /dev/null +++ b/src/essent_dynamic_pricing/__init__.py @@ -0,0 +1,21 @@ +"""Async client for Essent dynamic energy prices.""" + +from .client import EssentClient +from .exceptions import ( + EssentConnectionError, + EssentDataError, + EssentError, + EssentResponseError, +) +from .models import EssentPrices, EnergyData, Tariff + +__all__ = [ + "EssentClient", + "EssentConnectionError", + "EssentPrices", + "EnergyData", + "Tariff", + "EssentDataError", + "EssentError", + "EssentResponseError", +] diff --git a/src/essent_dynamic_pricing/client.py b/src/essent_dynamic_pricing/client.py new file mode 100644 index 0000000..5d7b3cd --- /dev/null +++ b/src/essent_dynamic_pricing/client.py @@ -0,0 +1,204 @@ +"""Async client for Essent dynamic pricing.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from http import HTTPStatus +from zoneinfo import ZoneInfo + +from aiohttp import ClientError, ClientResponse, ClientSession, ClientTimeout +from mashumaro.exceptions import ExtraKeysError, InvalidFieldValue, MissingField + +from .exceptions import EssentConnectionError, EssentDataError, EssentResponseError +from .models import ( + EnergyBlock, + EnergyData, + EssentPrices, + PriceResponse, + PriceDay, + Tariff, +) + +API_ENDPOINT = "https://www.essent.nl/api/public/dynamicpricing/dynamic-prices/v1" +CLIENT_TIMEOUT = ClientTimeout(total=10) +ESSENT_TIME_ZONE = ZoneInfo("Europe/Amsterdam") + + +def _normalize_tariff_datetime(value: datetime | None) -> datetime | None: + """Normalize tariff datetimes to the Essent timezone.""" + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=ESSENT_TIME_ZONE) + return value.astimezone(ESSENT_TIME_ZONE) + + +def _tariff_sort_key(tariff: Tariff) -> datetime: + """Sort key for tariffs based on start time.""" + return tariff.start or datetime.max.replace(tzinfo=timezone.utc) + + +def _prepare_tariffs(tariffs: list[Tariff], energy_type: str) -> list[Tariff]: + """Normalize tariffs to timezone-aware datetimes and sort them.""" + prepared: list[Tariff] = [] + for tariff in tariffs: + if tariff.start is None or tariff.end is None: + raise EssentDataError(f"Tariff missing start or end for {energy_type}") + prepared.append( + Tariff( + start=_normalize_tariff_datetime(tariff.start), + end=_normalize_tariff_datetime(tariff.end), + total_amount=tariff.total_amount, + total_amount_ex=tariff.total_amount_ex, + total_amount_vat=tariff.total_amount_vat, + groups=list(tariff.groups), + ) + ) + return sorted(prepared, key=_tariff_sort_key) + + +def _normalize_unit(unit: str) -> str: + """Normalize unit strings to human-friendly values.""" + unit_normalized = unit.replace("³", "3").lower() + if unit_normalized == "kwh": + return "kWh" + if unit_normalized in {"m3", "m^3"}: + return "m³" + return unit + + +class EssentClient: + """Client for fetching Essent dynamic pricing data.""" + + def __init__( + self, + session: ClientSession, + *, + endpoint: str = API_ENDPOINT, + timeout: ClientTimeout = CLIENT_TIMEOUT, + ) -> None: + """Initialize the client.""" + self._session = session + self._endpoint = endpoint + self._timeout = timeout + + async def async_get_prices(self) -> EssentPrices: + """Fetch and normalize Essent dynamic pricing data.""" + response = await self._request() + body = await response.text() + + if response.status != HTTPStatus.OK: + raise EssentResponseError( + f"Unexpected status {response.status} from Essent API: {body}" + ) + + try: + price_response = PriceResponse.from_dict(await response.json()) + except (MissingField, InvalidFieldValue, ExtraKeysError) as err: + raise EssentDataError("Invalid data structure for current prices") from err + except ValueError as err: + raise EssentResponseError("Invalid JSON received from Essent API") from err + + if not price_response.prices: + raise EssentDataError("No price data available") + + today, tomorrow = self._select_days(price_response.prices) + + if today.electricity is None and today.gas is None: + raise EssentDataError("Response missing both electricity and gas data") + + electricity_data = None + if today.electricity is not None: + electricity_data = self._normalize_energy_block( + today.electricity, + "electricity", + tomorrow.electricity if tomorrow else None, + ) + + gas_data = None + if today.gas is not None: + gas_data = self._normalize_energy_block( + today.gas, + "gas", + tomorrow.gas if tomorrow else None, + ) + + if electricity_data is None: + raise EssentDataError("Response missing electricity data") + + return EssentPrices( + electricity=electricity_data, + gas=gas_data, + ) + + async def _request(self) -> ClientResponse: + """Perform the HTTP request.""" + try: + return await self._session.get( + self._endpoint, + timeout=self._timeout, + headers={"Accept": "application/json"}, + ) + except ClientError as err: + raise EssentConnectionError(f"Error communicating with API: {err}") from err + + @staticmethod + def _select_days( + prices: list[PriceDay], + ) -> tuple[PriceDay, PriceDay | None]: + """Find entries for today and tomorrow from the price list.""" + if not prices: + raise EssentDataError("No price data available") + + current_date = datetime.now(timezone.utc).astimezone().date().isoformat() + today_index = 0 + for idx, price in enumerate(prices): + if price.date == current_date: + today_index = idx + break + + today = prices[today_index] + tomorrow: PriceDay | None = None + if today_index + 1 < len(prices): + tomorrow = prices[today_index + 1] + + return today, tomorrow + + def _normalize_energy_block( + self, + data: EnergyBlock | None, + energy_type: str, + tomorrow: EnergyBlock | None, + ) -> EnergyData: + """Normalize the energy block into the client format.""" + if data is None: + raise EssentDataError(f"No {energy_type} data provided") + + tariffs_today = _prepare_tariffs(data.tariffs, energy_type) + if not tariffs_today: + raise EssentDataError(f"No tariffs found for {energy_type}") + + tariffs_tomorrow = ( + _prepare_tariffs(tomorrow.tariffs, energy_type) if tomorrow else [] + ) + unit_raw = (data.unit_of_measurement or data.unit or "").strip() + + amounts = [ + float(total) + for tariff in tariffs_today + if (total := tariff.total_amount) is not None + ] + if not amounts: + raise EssentDataError(f"No usable tariff values for {energy_type}") + + if not unit_raw: + raise EssentDataError(f"No unit provided for {energy_type}") + + return EnergyData( + tariffs=tariffs_today, + tariffs_tomorrow=tariffs_tomorrow, + unit=_normalize_unit(unit_raw), + min_price=min(amounts), + avg_price=sum(amounts) / len(amounts), + max_price=max(amounts), + ) diff --git a/src/essent_dynamic_pricing/exceptions.py b/src/essent_dynamic_pricing/exceptions.py new file mode 100644 index 0000000..08887ae --- /dev/null +++ b/src/essent_dynamic_pricing/exceptions.py @@ -0,0 +1,16 @@ +"""Exceptions for the Essent dynamic pricing client.""" + +class EssentError(Exception): + """Base exception for Essent client errors.""" + + +class EssentConnectionError(EssentError): + """Raised when communication with the API fails.""" + + +class EssentResponseError(EssentError): + """Raised when the API returns an unexpected response.""" + + +class EssentDataError(EssentError): + """Raised when the response data is missing or invalid.""" diff --git a/src/essent_dynamic_pricing/models.py b/src/essent_dynamic_pricing/models.py new file mode 100644 index 0000000..27ba39f --- /dev/null +++ b/src/essent_dynamic_pricing/models.py @@ -0,0 +1,84 @@ +"""Models used by the Essent dynamic client.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, List, Optional + +from mashumaro.mixins.dict import DataClassDictMixin + + +@dataclass +class Tariff(DataClassDictMixin): + """A single tariff entry.""" + + start: Optional[datetime] = field(default=None, metadata={"alias": "startDateTime"}) + end: Optional[datetime] = field(default=None, metadata={"alias": "endDateTime"}) + total_amount: Optional[float] = field(default=None, metadata={"alias": "totalAmount"}) + total_amount_ex: Optional[float] = field(default=None, metadata={"alias": "totalAmountEx"}) + total_amount_vat: Optional[float] = field(default=None, metadata={"alias": "totalAmountVat"}) + groups: List[dict[str, Any]] = field(default_factory=list) + + class Config: + serialize_by_alias = True + + +@dataclass +class EnergyBlock(DataClassDictMixin): + """Raw energy block from the API.""" + + tariffs: List[Tariff] = field(default_factory=list) + unit: Optional[str] = None + unit_of_measurement: Optional[str] = field(default=None, metadata={"alias": "unitOfMeasurement"}) + + class Config: + serialize_by_alias = True + + +@dataclass +class PriceDay(DataClassDictMixin): + """Prices for a day.""" + + date: str + electricity: Optional[EnergyBlock] = None + gas: Optional[EnergyBlock] = None + + class Config: + serialize_by_alias = True + + +@dataclass +class PriceResponse(DataClassDictMixin): + """Top level API response.""" + + prices: List[PriceDay] + + class Config: + serialize_by_alias = True + + +@dataclass +class EnergyData(DataClassDictMixin): + """Normalized energy data.""" + + tariffs: List[Tariff] + tariffs_tomorrow: List[Tariff] + unit: str + min_price: float + avg_price: float + max_price: float + + class Config: + serialize_by_alias = True + + +@dataclass +class EssentPrices(DataClassDictMixin): + """Normalized Essent prices for both energy types.""" + + electricity: EnergyData + gas: Optional[EnergyData] = None + + class Config: + serialize_by_alias = True diff --git a/src/essent_dynamic_pricing/py.typed b/src/essent_dynamic_pricing/py.typed new file mode 100644 index 0000000..7632ecf --- /dev/null +++ b/src/essent_dynamic_pricing/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..1711b28 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,568 @@ +"""Tests for the Essent client.""" + +from __future__ import annotations + +import asyncio +from typing import Any +from zoneinfo import ZoneInfo + +import pytest +from datetime import datetime, timedelta, timezone +from essent_dynamic_pricing.client import _normalize_unit +from essent_dynamic_pricing.models import PriceResponse + +from aiohttp import ClientError + +from essent_dynamic_pricing import ( + EssentClient, + EssentConnectionError, + EssentDataError, + EssentResponseError, +) + + +class _MockResponse: + """Simple mock response.""" + + def __init__(self, status: int, body: Any) -> None: + """Initialize the response.""" + self.status = status + self._body = body + + async def text(self) -> str: + """Return the raw body.""" + if isinstance(self._body, str): + return self._body + return repr(self._body) + + async def json(self) -> Any: + """Return the JSON body.""" + if isinstance(self._body, Exception): + raise self._body + return self._body + + +class _MockSession: + """Mock session returning fixed responses.""" + + def __init__(self, response: _MockResponse | Exception) -> None: + self._response = response + + async def get(self, *args: Any, **kwargs: Any) -> _MockResponse: + """Return the pre-seeded response or raise.""" + if isinstance(self._response, Exception): + raise self._response + return self._response + + +def _build_prices() -> dict[str, Any]: + """Build a sample prices payload.""" + return { + "prices": [ + { + "date": "2025-11-16", + "electricity": { + "unitOfMeasurement": "kWh", + "tariffs": [ + { + "startDateTime": "2025-11-16T00:00:00", + "endDateTime": "2025-11-16T01:00:00", + "totalAmount": 0.2, + }, + { + "startDateTime": "2025-11-16T01:00:00", + "endDateTime": "2025-11-16T02:00:00", + "totalAmount": 0.25, + }, + ], + }, + "gas": { + "unit": "m3", + "tariffs": [ + { + "startDateTime": "2025-11-16T00:00:00", + "endDateTime": "2025-11-16T01:00:00", + "totalAmount": 0.8, + }, + { + "startDateTime": "2025-11-16T01:00:00", + "endDateTime": "2025-11-16T02:00:00", + "totalAmount": 0.82, + }, + ], + }, + }, + { + "date": "2025-11-17", + "electricity": { + "unitOfMeasurement": "kWh", + "tariffs": [ + { + "startDateTime": "2025-11-17T00:00:00", + "endDateTime": "2025-11-17T01:00:00", + "totalAmount": 0.22, + }, + ], + }, + "gas": { + "unit": "m3", + "tariffs": [ + { + "startDateTime": "2025-11-17T00:00:00", + "endDateTime": "2025-11-17T01:00:00", + "totalAmount": 0.85, + }, + ], + }, + }, + ] + } + + +@pytest.mark.asyncio +async def test_fetch_success(monkeypatch: pytest.MonkeyPatch) -> None: + """Test a successful fetch.""" + response = _MockResponse(status=200, body=_build_prices()) + client = EssentClient(_MockSession(response)) + + data = await client.async_get_prices() + + assert data.electricity.min_price == 0.2 + assert data.electricity.max_price == 0.25 + assert data.gas.unit == "m³" + assert data.electricity.tariffs[0].start == datetime( + 2025, 11, 16, 0, 0, tzinfo=ZoneInfo("Europe/Amsterdam") + ) + assert data.gas.tariffs[0].start.tzinfo == ZoneInfo("Europe/Amsterdam") + + +@pytest.mark.asyncio +async def test_non_ok_status() -> None: + """Test non-200 handling.""" + response = _MockResponse(status=500, body="error") + client = EssentClient(_MockSession(response)) + + with pytest.raises(EssentResponseError): + await client.async_get_prices() + + +@pytest.mark.asyncio +async def test_invalid_json() -> None: + """Test invalid JSON handling.""" + response = _MockResponse(status=200, body=ValueError("boom")) + client = EssentClient(_MockSession(response)) + + with pytest.raises(EssentResponseError): + await client.async_get_prices() + + +@pytest.mark.asyncio +async def test_missing_prices() -> None: + """Test empty prices payload.""" + response = _MockResponse(status=200, body={"prices": []}) + client = EssentClient(_MockSession(response)) + + with pytest.raises(EssentDataError): + await client.async_get_prices() + + +@pytest.mark.asyncio +async def test_connection_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Test connection errors are raised properly.""" + client = EssentClient(_MockSession(ClientError("boom"))) + + with pytest.raises(EssentConnectionError): + await client.async_get_prices() + + +@pytest.mark.asyncio +async def test_missing_electricity_block() -> None: + """Response missing electricity should error.""" + today = datetime.now(timezone.utc).date().isoformat() + response = _MockResponse( + status=200, + body={"prices": [{"date": today, "electricity": None}]}, + ) + client = EssentClient(_MockSession(response)) + + with pytest.raises( + EssentDataError, match="Response missing both electricity and gas data" + ): + await client.async_get_prices() + + +@pytest.mark.asyncio +async def test_selects_today_entry() -> None: + """When today's date is present it should be preferred.""" + today = datetime.now(timezone.utc).date().isoformat() + body = { + "prices": [ + { + "date": today, + "electricity": { + "tariffs": [ + { + "startDateTime": "2025-11-16T00:00:00", + "endDateTime": "2025-11-16T01:00:00", + "totalAmount": 0.3, + } + ], + "unitOfMeasurement": "kWh", + }, + "gas": { + "tariffs": [ + { + "startDateTime": "2025-11-16T00:00:00", + "endDateTime": "2025-11-16T01:00:00", + "totalAmount": 0.9, + } + ], + "unit": "m3", + }, + }, + { + "date": "2025-11-17", + "electricity": { + "tariffs": [ + { + "startDateTime": "2025-11-17T00:00:00", + "endDateTime": "2025-11-17T01:00:00", + "totalAmount": 0.4, + } + ], + "unitOfMeasurement": "kWh", + }, + "gas": { + "tariffs": [ + { + "startDateTime": "2025-11-17T00:00:00", + "endDateTime": "2025-11-17T01:00:00", + "totalAmount": 1.0, + } + ], + "unit": "m3", + }, + }, + ] + } + client = EssentClient(_MockSession(_MockResponse(status=200, body=body))) + + data = await client.async_get_prices() + + assert data.electricity.min_price == 0.3 + assert data.electricity.tariffs_tomorrow[0].total_amount == 0.4 + assert data.gas.tariffs_tomorrow[0].total_amount == 1.0 + + +@pytest.mark.asyncio +async def test_missing_gas_for_future_day() -> None: + """Missing gas for a future day should not block electricity data.""" + today = datetime.now(timezone.utc).date() + tomorrow = today + timedelta(days=1) + response = _MockResponse( + status=200, + body={ + "prices": [ + { + "date": today.isoformat(), + "electricity": { + "tariffs": [ + { + "startDateTime": "2025-11-16T00:00:00", + "endDateTime": "2025-11-16T01:00:00", + "totalAmount": 0.3, + } + ], + "unitOfMeasurement": "kWh", + }, + "gas": { + "tariffs": [ + { + "startDateTime": "2025-11-16T00:00:00", + "endDateTime": "2025-11-16T01:00:00", + "totalAmount": 0.9, + } + ], + "unit": "m3", + }, + }, + { + "date": tomorrow.isoformat(), + "electricity": { + "tariffs": [ + { + "startDateTime": "2025-11-17T00:00:00", + "endDateTime": "2025-11-17T01:00:00", + "totalAmount": 0.4, + } + ], + "unitOfMeasurement": "kWh", + }, + }, + ] + }, + ) + client = EssentClient(_MockSession(response)) + + data = await client.async_get_prices() + + assert data.electricity.tariffs_tomorrow[0].total_amount == 0.4 + assert data.gas.tariffs_tomorrow == [] + + +@pytest.mark.asyncio +async def test_invalid_today_structure() -> None: + """Invalid today structure should raise.""" + today = datetime.now(timezone.utc).date().isoformat() + response = _MockResponse( + status=200, + body={ + "prices": [ + {"date": "not-today"}, + {"date": today, "electricity": "bad", "gas": "bad"}, + ] + }, + ) + client = EssentClient(_MockSession(response)) + + with pytest.raises(EssentDataError, match="Invalid data structure"): + await client.async_get_prices() + + +@pytest.mark.asyncio +async def test_no_tariffs() -> None: + """No tariffs should raise a data error.""" + today = datetime.now(timezone.utc).date().isoformat() + response = _MockResponse( + status=200, + body={ + "prices": [ + { + "date": today, + "electricity": {"tariffs": []}, + "gas": {"tariffs": [{"totalAmount": 0.8}], "unit": "m3"}, + } + ] + }, + ) + client = EssentClient(_MockSession(response)) + + with pytest.raises(EssentDataError, match="No tariffs found for electricity"): + await client.async_get_prices() + + +@pytest.mark.asyncio +async def test_no_amounts() -> None: + """Tariffs without totals should raise a data error.""" + today = datetime.now(timezone.utc).date().isoformat() + response = _MockResponse( + status=200, + body={ + "prices": [ + { + "date": today, + "electricity": { + "tariffs": [ + { + "startDateTime": "2025-11-16T00:00:00", + "endDateTime": "2025-11-16T01:00:00", + } + ] + }, + "gas": {"tariffs": [{"totalAmount": 0.8}], "unit": "m3"}, + } + ] + }, + ) + client = EssentClient(_MockSession(response)) + + with pytest.raises(EssentDataError, match="No usable tariff values for electricity"): + await client.async_get_prices() + + +@pytest.mark.asyncio +async def test_no_unit() -> None: + """Missing unit should raise a data error.""" + today = datetime.now(timezone.utc).date().isoformat() + response = _MockResponse( + status=200, + body={ + "prices": [ + { + "date": today, + "electricity": { + "tariffs": [ + { + "startDateTime": "2025-11-16T00:00:00", + "endDateTime": "2025-11-16T01:00:00", + "totalAmount": 0.25, + } + ] + }, + "gas": { + "tariffs": [ + { + "startDateTime": "2025-11-16T00:00:00", + "endDateTime": "2025-11-16T01:00:00", + "totalAmount": 0.82, + } + ], + "unit": "m³", + }, + } + ] + }, + ) + client = EssentClient(_MockSession(response)) + + with pytest.raises(EssentDataError, match="No unit provided for electricity"): + await client.async_get_prices() + + +@pytest.mark.asyncio +async def test_missing_tariff_bounds() -> None: + """Missing start or end should raise a data error.""" + today = datetime.now(timezone.utc).date().isoformat() + response = _MockResponse( + status=200, + body={ + "prices": [ + { + "date": today, + "electricity": { + "tariffs": [{"endDateTime": "2025-11-16T01:00:00"}], + "unit": "kWh", + }, + "gas": { + "tariffs": [ + { + "startDateTime": "2025-11-16T00:00:00", + "totalAmount": 0.8, + } + ], + "unit": "m3", + }, + } + ] + }, + ) + client = EssentClient(_MockSession(response)) + + with pytest.raises(EssentDataError, match="Tariff missing start or end for electricity"): + await client.async_get_prices() + + +def test_normalize_unit_passthrough() -> None: + """Unknown unit should be returned unchanged.""" + assert _normalize_unit("unknown") == "unknown" + + +def test_select_days_prefers_today_and_tomorrow() -> None: + """_select_days should return today entry and next as tomorrow when available.""" + today = datetime.now(timezone.utc).date().isoformat() + resp = PriceResponse.from_dict( + { + "prices": [ + { + "date": today, + "electricity": {"unit": "kWh", "tariffs": [{"totalAmount": 1}]}, + "gas": {"unit": "m3", "tariffs": [{"totalAmount": 1}]}, + }, + { + "date": "next", + "electricity": {"unit": "kWh", "tariffs": [{"totalAmount": 2}]}, + "gas": {"unit": "m3", "tariffs": [{"totalAmount": 2}]}, + }, + ] + } + ) + + selected_today, selected_tomorrow = EssentClient._select_days(resp.prices) + + assert selected_today.electricity.tariffs[0].total_amount == 1 + assert selected_tomorrow is not None + assert selected_tomorrow.electricity.tariffs[0].total_amount == 2 + + +def test_select_days_invalid_structure_raises() -> None: + """Empty prices should raise a data error.""" + with pytest.raises(EssentDataError, match="No price data available"): + EssentClient._select_days([]) + + +@pytest.mark.asyncio +async def test_electricity_only_success() -> None: + """Missing gas data should succeed if electricity is present.""" + today = datetime.now(timezone.utc).date().isoformat() + response = _MockResponse( + status=200, + body={ + "prices": [ + { + "date": today, + "electricity": { + "tariffs": [ + { + "startDateTime": "2025-11-16T00:00:00", + "endDateTime": "2025-11-16T01:00:00", + "totalAmount": 0.3, + } + ], + "unitOfMeasurement": "kWh", + }, + } + ] + }, + ) + client = EssentClient(_MockSession(response)) + + data = await client.async_get_prices() + + assert data.electricity.min_price == 0.3 + assert data.gas is None + + +@pytest.mark.asyncio +async def test_gas_only_fails() -> None: + """Missing electricity data should raise error even if gas is present.""" + today = datetime.now(timezone.utc).date().isoformat() + response = _MockResponse( + status=200, + body={ + "prices": [ + { + "date": today, + "gas": { + "tariffs": [ + { + "startDateTime": "2025-11-16T00:00:00", + "endDateTime": "2025-11-16T01:00:00", + "totalAmount": 0.9, + } + ], + "unit": "m3", + }, + } + ] + }, + ) + client = EssentClient(_MockSession(response)) + + with pytest.raises(EssentDataError, match="Response missing electricity data"): + await client.async_get_prices() + + +@pytest.mark.asyncio +async def test_both_missing_fails() -> None: + """Missing both energy types should raise error.""" + today = datetime.now(timezone.utc).date().isoformat() + response = _MockResponse( + status=200, + body={"prices": [{"date": today}]}, + ) + client = EssentClient(_MockSession(response)) + + with pytest.raises( + EssentDataError, match="Response missing both electricity and gas data" + ): + await client.async_get_prices() |
