diff --git a/octopus_energy/__init__.py b/octopus_energy/__init__.py index 843fd9f..895a302 100644 --- a/octopus_energy/__init__.py +++ b/octopus_energy/__init__.py @@ -1,7 +1,15 @@ """Python client for the Octopus Energy RESTful API""" from .rest_client import OctopusEnergyRestClient -from .models import Consumption, IntervalConsumption, MeterType, UnitType +from .models import ( + Consumption, + IntervalConsumption, + MeterType, + UnitType, + Aggregate, + SortOrder, + RateType, +) from .exceptions import ApiAuthenticationError, ApiError, ApiNotFoundError, ApiBadRequestError __all__ = [ @@ -14,4 +22,7 @@ "IntervalConsumption", "MeterType", "UnitType", + "Aggregate", + "SortOrder", + "RateType", ] diff --git a/octopus_energy/mappers.py b/octopus_energy/mappers.py index 13d1e97..f134838 100644 --- a/octopus_energy/mappers.py +++ b/octopus_energy/mappers.py @@ -1,6 +1,8 @@ +from datetime import datetime + from dateutil.parser import isoparse -from octopus_energy.models import IntervalConsumption, UnitType, Consumption, MeterType +from .models import IntervalConsumption, UnitType, Consumption, MeterType _CUBIC_METERS_TO_KWH_MULTIPLIER = 11.1868 _KWH_TO_KWH_MULTIPLIER = 1 @@ -43,6 +45,18 @@ def consumption_from_response( ) +def to_timestamp_str(timestamp: datetime) -> str: + """Convert a datetime to an iso timestamp string expected by the octopus energy APIs. + + Args: + timestamp: The timestamp to convert. + + Returns: + The timestamp in a iso format required by the octopus energy APIs + """ + return timestamp.replace(microsecond=0).isoformat() if timestamp else None + + def _calculate_unit(consumption, actual_unit, desired_unit): """Converts unit values from one unit to another unit. diff --git a/octopus_energy/models.py b/octopus_energy/models.py index cb89385..20a8c16 100644 --- a/octopus_energy/models.py +++ b/octopus_energy/models.py @@ -108,3 +108,42 @@ class RateType(_DocEnum): "night-unit-rates", "Represents the rate charged during the night for dual rate tariffs.", ) + + +class SortOrder(_DocEnum): + """The order to use when sorting results from search type API alls""" + + NEWEST_FIRST = ( + "-period", + "Newest result first", + ) + OLDEST_FIRST = ( + "period", + "Oldest result first", + ) + + +class Aggregate(_DocEnum): + """How to aggregate data returned from consumption based APIs""" + + HALF_HOURLY = None, "Aggregate consumption half hourly (the default)" + HOUR = ( + "hour", + "Aggregate consumption by hours", + ) + DAY = ( + "day", + "Aggregate consumption by days", + ) + WEEK = ( + "week", + "Aggregate consumption by weeks", + ) + MONTH = ( + "month", + "Aggregate consumption by months", + ) + QUARTER = ( + "quarter", + "Aggregate consumption quarterly", + ) diff --git a/octopus_energy/rest_client.py b/octopus_energy/rest_client.py index a2696fa..4b59d6c 100644 --- a/octopus_energy/rest_client.py +++ b/octopus_energy/rest_client.py @@ -1,3 +1,4 @@ +from datetime import datetime from functools import partial from http import HTTPStatus from typing import Optional, Callable @@ -5,13 +6,14 @@ from aiohttp import BasicAuth, ClientSession from furl import furl -from octopus_energy.exceptions import ( +from .mappers import to_timestamp_str +from .exceptions import ( ApiError, ApiAuthenticationError, ApiNotFoundError, ApiBadRequestError, ) -from octopus_energy.models import RateType, EnergyTariffType +from .models import RateType, EnergyTariffType, Aggregate, SortOrder _API_BASE = "https://api.octopus.energy" @@ -106,19 +108,44 @@ async def get_account_details(self, account_number: str) -> dict: """ return await self._get(["v1", "accounts", account_number]) - async def get_electricity_consumption_v1(self, mpan: str, serial_number: str) -> dict: + async def get_electricity_consumption_v1( + self, + mpan: str, + serial_number: str, + page_num: int = None, + page_size: int = None, + period_from: datetime = None, + period_to: datetime = None, + order: SortOrder = None, + aggregate: Aggregate = None, + ) -> dict: """Gets the consumption of electricity from a specific meter. Args: mpan: The MPAN (Meter Point Administration Number) of the location to query. serial_number: The serial number of the meter to query. - + page_num: (Optional) The page number to load. + page_size: (Optional) How many results per page. + period_from: (Optional) The timestamp from where to begin returning results. + period_to: (Optional) The timestamp at which to end returning results. + order: (Optional) The ordering to apply to the results. + aggregate: (Optional) Over what period to aggregate the results. By default consumption + results are aggregated half hourly. You can override this setting by + explicitly stating an alternate aggregate. Returns: A dictionary containing the electricity consumption response. """ return await self._get( - ["v1", "electricity-meter-points", mpan, "meters", serial_number, "consumption"] + ["v1", "electricity-meter-points", mpan, "meters", serial_number, "consumption"], + { + "page": page_num, + "page_size": page_size, + "period_from": to_timestamp_str(period_from), + "period_to": to_timestamp_str(period_to), + "order": order.value if order is not None else None, + "group_by": aggregate.value if aggregate is not None else None, + }, ) async def get_electricity_meter_points_v1(self, mpan: str) -> dict: @@ -133,19 +160,44 @@ async def get_electricity_meter_points_v1(self, mpan: str) -> dict: """ return await self._get(["v1", "electricity-meter-points", mpan]) - async def get_gas_consumption_v1(self, mprn: str, serial_number: str) -> dict: + async def get_gas_consumption_v1( + self, + mprn: str, + serial_number: str, + page_num: int = None, + page_size: int = None, + period_from: datetime = None, + period_to: datetime = None, + order: SortOrder = None, + aggregate: Aggregate = None, + ) -> dict: """Gets the consumption of gas from a specific meter. Args: mprn: The MPRN (Meter Point Reference Number) of the location to query. serial_number: The serial number of the meter to query. - + page_num: (Optional) The page number to load. + page_size: (Optional) How many results per page. + period_from: (Optional) The timestamp from where to begin returning results. + period_to: (Optional) The timestamp at which to end returning results. + order: (Optional) The ordering to apply to the results. + aggregate: (Optional) Over what period to aggregate the results. By default consumption + results are aggregated half hourly. You can override this setting by + explicitly stating an alternate aggregate. Returns: A dictionary containing the gas consumption response. """ return await self._get( - ["v1", "gas-meter-points", mprn, "meters", serial_number, "consumption"] + ["v1", "gas-meter-points", mprn, "meters", serial_number, "consumption"], + { + "page": page_num, + "page_size": page_size, + "period_from": to_timestamp_str(period_from), + "period_to": to_timestamp_str(period_to), + "order": order.value if order is not None else None, + "group_by": aggregate.value if aggregate is not None else None, + }, ) async def get_products_v1(self) -> dict: @@ -209,16 +261,21 @@ async def renew_business_tariff(self, account_number: str, renewal_data: dict) - """ return await self._post(["v1", "accounts", account_number, "tariff-renewal"], renewal_data) - async def _get(self, url_parts: list, **kwargs) -> dict: - return await self._execute(self.session.get, url_parts, **kwargs) + async def _get(self, url_parts: list, query_params: dict = {}, **kwargs) -> dict: + return await self._execute(self.session.get, url_parts, query_params, **kwargs) - async def _post(self, url_parts: list, data: dict, **kwargs) -> dict: - return await self._execute(partial(self.session.post, data=data), url_parts, **kwargs) + async def _post(self, url_parts: list, data: dict, query_params: dict = {}, **kwargs) -> dict: + return await self._execute( + partial(self.session.post, data=data), url_parts, query_params, **kwargs + ) - async def _execute(self, func: Callable, url_parts: list, **kwargs) -> dict: + async def _execute( + self, func: Callable, url_parts: list, query_params: dict = {}, **kwargs + ) -> dict: """Executes an API call to Octopus energy and maps the response.""" url = self.base_url.copy() url.path.segments.extend(url_parts) + url.query.params.update(query_params) response = await func(url=str(url), **kwargs) if response.status > 399: if response.status == HTTPStatus.UNAUTHORIZED: diff --git a/poetry.lock b/poetry.lock index dd280c2..58fcce6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -150,6 +150,17 @@ mccabe = ">=0.6.0,<0.7.0" pycodestyle = ">=2.6.0a1,<2.7.0" pyflakes = ">=2.2.0,<2.3.0" +[[package]] +name = "freezegun" +version = "1.0.0" +description = "Let your Python tests travel through time" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "furl" version = "2.1.0" @@ -508,7 +519,7 @@ testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "7da58ed905fc37beeb67d9d82208ddb88a62eb6871e9b7462393359d57b7fe84" +content-hash = "afc545962c6bce3f5fdbf89b1afd8288373dc55c1ae1c5f9f3f856192827b270" [metadata.files] aiohttp = [ @@ -601,6 +612,10 @@ flake8 = [ {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, ] +freezegun = [ + {file = "freezegun-1.0.0-py2.py3-none-any.whl", hash = "sha256:02b35de52f4699a78f6ac4518e4cd3390dddc43b0aeb978335a8f270a2d9668b"}, + {file = "freezegun-1.0.0.tar.gz", hash = "sha256:1cf08e441f913ff5e59b19cc065a8faa9dd1ddc442eaf0375294f344581a0643"}, +] furl = [ {file = "furl-2.1.0-py2.py3-none-any.whl", hash = "sha256:f4d6f1e5479c376a5b7bdc62795d736d8c1b2a754f366a2ad2816e46e946e22e"}, {file = "furl-2.1.0.tar.gz", hash = "sha256:c0e0231a1feee2acd256574b7033df3144775451c610cb587060d6a0d7e0b621"}, diff --git a/pyproject.toml b/pyproject.toml index 3eae23f..4d026cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "octopus-energy" -version = "0.1.6" +version = "0.1.7" description = "Python client for the Octopus Energy RESTful API" authors = ["Mark Allanson "] license = "MIT" @@ -23,6 +23,7 @@ tox = "^3.20.1" aioresponses = "^0.7.1" pytest-asyncio = "^0.14.0" pytest-subtests = "^0.4.0" +freezegun = "^1.0.0" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 0f26b0f..b5939d0 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,10 +1,13 @@ +from datetime import datetime from typing import Callable import pytest from aioresponses import aioresponses +from dateutil.tz import tzoffset +from freezegun import freeze_time from octopus_energy import OctopusEnergyRestClient -from octopus_energy.models import EnergyTariffType, RateType +from octopus_energy.models import EnergyTariffType, RateType, SortOrder, Aggregate from octopus_energy.rest_client import _API_BASE from tests import load_fixture_json @@ -55,13 +58,25 @@ async def get(client): ) +@freeze_time("2021-01-01 01:11:11") @pytest.mark.asyncio async def test_get_elec_consumption_v1(mock_aioresponses: aioresponses): async def get(client): - return await client.get_electricity_consumption_v1("mpan", "serial_number") + return await client.get_electricity_consumption_v1( + "mpan", + "serial_number", + page_num=1, + page_size=10, + period_from=datetime.now(tz=tzoffset("CEST", 2)), + period_to=datetime.now(tz=tzoffset("CEST", 2)), + order=SortOrder.NEWEST_FIRST, + aggregate=Aggregate.DAY, + ) await _run_get_test( - "v1/electricity-meter-points/mpan/meters/serial_number/consumption", + "v1/electricity-meter-points/mpan/meters/serial_number/consumption?page=1&page_size=10" + "&period_from=2021-01-01T01%3A11%3A13%2B00%3A00%3A02" + "&period_to=2021-01-01T01%3A11%3A13%2B00%3A00%3A02&order=-period&group_by=day", "consumption_response.json", get, mock_aioresponses, @@ -81,13 +96,25 @@ async def get(client): ) +@freeze_time("2021-01-01 01:11:11") @pytest.mark.asyncio async def test_get_gas_consumption_v1(mock_aioresponses: aioresponses): async def get(client): - return await client.get_gas_consumption_v1("mprn", "serial_number") + return await client.get_gas_consumption_v1( + "mprn", + "serial_number", + page_num=1, + page_size=10, + period_from=datetime.now(tz=tzoffset("CEST", 2)), + period_to=datetime.now(tz=tzoffset("CEST", 2)), + order=SortOrder.NEWEST_FIRST, + aggregate=Aggregate.DAY, + ) await _run_get_test( - "v1/gas-meter-points/mprn/meters/serial_number/consumption", + "v1/gas-meter-points/mprn/meters/serial_number/consumption?page=1&page_size=10" + "&period_from=2021-01-01T01%3A11%3A13%2B00%3A00%3A02" + "&period_to=2021-01-01T01%3A11%3A13%2B00%3A00%3A02&order=-period&group_by=day", "consumption_response.json", get, mock_aioresponses, diff --git a/tests/test_mappers.py b/tests/test_mappers.py index bfa22fa..ed4fe2c 100644 --- a/tests/test_mappers.py +++ b/tests/test_mappers.py @@ -7,6 +7,7 @@ from octopus_energy.mappers import ( _calculate_unit, consumption_from_response, + to_timestamp_str, ) from octopus_energy.models import UnitType, MeterType from tests import load_fixture_json @@ -138,6 +139,17 @@ def test_consumption_missing_intervals(self): self.assertEqual(consumption.unit_type, UnitType.KWH) +def test_timestamp_format(): + """Verifies timestamps. + + * microseconds are stripped + * formatted as iso8601 + """ + timestamp = datetime.utcnow().replace(microsecond=0) + str = to_timestamp_str(timestamp) + assert str == timestamp.isoformat() + + @pytest.mark.parametrize( "source_unit_type,desired_unit_type,source_unit,desired_unit", [