diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d8f13f --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ +# Octopus Energy Specifics +integ_tests/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ \ No newline at end of file diff --git a/octopus_energy/__init__.py b/octopus_energy/__init__.py index 180f488..985bb70 100644 --- a/octopus_energy/__init__.py +++ b/octopus_energy/__init__.py @@ -17,6 +17,7 @@ UnitType, ElectricityMeter, GasMeter, + PageReference, ) from .exceptions import ApiAuthenticationError, ApiError, ApiNotFoundError, ApiBadRequestError from .rest_client import OctopusEnergyRestClient @@ -45,4 +46,5 @@ "Address", "ElectricityMeter", "GasMeter", + "PageReference", ] diff --git a/octopus_energy/client.py b/octopus_energy/client.py index 1db1eb3..98e5d37 100644 --- a/octopus_energy/client.py +++ b/octopus_energy/client.py @@ -1,7 +1,15 @@ +from datetime import datetime from typing import List -from .mappers import meters_from_response -from octopus_energy import Meter, OctopusEnergyRestClient +from .mappers import meters_from_response, consumption_from_response +from octopus_energy import ( + Meter, + OctopusEnergyRestClient, + Consumption, + EnergyType, + SortOrder, + PageReference, +) class OctopusEnergyConsumerClient: @@ -49,3 +57,45 @@ async def close(self): async def get_meters(self) -> List[Meter]: """Gets all meters associated with your account.""" return meters_from_response(await self.rest_client.get_account_details(self.account_number)) + + async def get_consumption( + self, + meter: Meter, + period_from: datetime = None, + period_to: datetime = None, + page_reference: PageReference = None, + ) -> Consumption: + """Get the energy consumption for a meter + + Args: + meter: The meter to get consumption for. + period_from: The timestamp for the earliest period of consumption to return. + period_to: The timestamp for the latest period of consumption to return. + page_reference: Get a specific page of results based on a page reference returned by + a previous call to get_consumption + + Returns: + The consumption for the meter in the time period specified. The results are returned + in ascending timestamp order from the start of the period. + + """ + func = ( + self.rest_client.get_electricity_consumption_v1 + if meter.energy_type == EnergyType.ELECTRICITY + else self.rest_client.get_gas_consumption_v1 + ) + + params = {} + if page_reference: + params.update(page_reference.options) + else: + params.update( + { + "period_from": period_from, + "period_to": period_to, + "order": SortOrder.OLDEST_FIRST, + } + ) + + response = await func(meter.meter_point.id, meter.serial_number, **params) + return consumption_from_response(response, meter) diff --git a/octopus_energy/mappers.py b/octopus_energy/mappers.py index b9a9233..959416c 100644 --- a/octopus_energy/mappers.py +++ b/octopus_energy/mappers.py @@ -1,9 +1,9 @@ -from typing import List, Optional - from datetime import datetime +from typing import List, Optional import dateutil from dateutil.parser import isoparse +from furl import furl from .models import ( IntervalConsumption, @@ -18,6 +18,9 @@ Meter, Address, MeterPoint, + PageReference, + SortOrder, + Aggregate, ) _CUBIC_METERS_TO_KWH_MULTIPLIER = 11.1868 @@ -103,7 +106,7 @@ def meters_from_response(response: dict) -> List[Meter]: def consumption_from_response( - response: dict, meter: Meter, desired_unit_type: UnitType + response: dict, meter: Meter, desired_unit_type: UnitType = None ) -> Consumption: """Generates the Consumption model from an octopus energy API response. @@ -132,10 +135,33 @@ def consumption_from_response( ) for result in response["results"] ], + _get_page_reference(response, "previous"), + _get_page_reference(response, "next"), ) -def _calculate_unit(consumption, actual_unit, desired_unit): +def _get_page_reference(response: dict, page: str): + if page not in response: + return None + page_url = furl(response[page]) + if not page_url.args: + return None + + # Convert all args in the page reference to the types used by the APIs + args = dict(page_url.args) + if "period_from" in args: + args["period_from"] = from_timestamp_str(args["period_from"]) + if "period_to" in args: + args["period_to"] = from_timestamp_str(args["period_to"]) + if "order" in args: + args["order"] = SortOrder(args["order"]) + if "group_by" in args: + args["group_by"] = Aggregate(args["group_by"]) + + return PageReference(args) + + +def _calculate_unit(consumption, actual_unit, desired_unit: UnitType = None): """Converts unit values from one unit to another unit. If no mapping is available the value is returned unchanged. @@ -145,4 +171,8 @@ def _calculate_unit(consumption, actual_unit, desired_unit): :param desired_unit: The unit the convert the consumption to. :return: The consumption converted to the desired unit. """ - return consumption * _UNIT_MULTIPLIERS.get((actual_unit.value, desired_unit.value), 1) + return ( + consumption + if not desired_unit + else consumption * _UNIT_MULTIPLIERS.get((actual_unit.value, desired_unit.value), 1) + ) diff --git a/octopus_energy/models.py b/octopus_energy/models.py index 09aaaf9..73fc84c 100644 --- a/octopus_energy/models.py +++ b/octopus_energy/models.py @@ -16,6 +16,13 @@ def __new__(cls, value, doc): return self +@dataclass +class PageReference: + """Represents a reference to a page of information for API calls that support paging""" + + options: dict + + @dataclass class Tariff: code: str @@ -64,9 +71,6 @@ def description(self) -> str: """A description, in english, of the meter.""" return self.value[2] - def __eq__(self, other): - return self.value == other.value - class EnergyType(Enum): """Represents a type of energy.""" @@ -95,11 +99,12 @@ class Address: def __str__(self): """Gets a single line string representation of the address""" return ( - f"{self.line_1} " - f"{self.line_2 + ', ' if self.line_2 is not None else ''} " - f"{self.line_3 + ', ' if self.line_3 is not None else ''}" - f"{self.county + ', ' if self.county is not None else ''}" - f"{self.town + ', ' if self.town is not None else ''}" + f"{self.line_1}" + f"{', ' + self.line_2 if self.line_2 is not None else ''}" + f"{', ' + self.line_3 if self.line_3 is not None else ''}" + f"{', ' + self.county if self.county is not None else ''}" + f"{', ' + self.town if self.town is not None else ''}" + f"{', ' + self.postcode if self.postcode is not None else ''}" ) @@ -165,6 +170,8 @@ class Consumption: unit_type: UnitType meter: Meter intervals: List[IntervalConsumption] = field(default_factory=lambda: []) + previous_page: Optional[PageReference] = None + next_page: Optional[PageReference] = None class EnergyTariffType(_DocEnum): diff --git a/octopus_energy/rest_client.py b/octopus_energy/rest_client.py index 6904189..ffc0e79 100644 --- a/octopus_energy/rest_client.py +++ b/octopus_energy/rest_client.py @@ -112,24 +112,24 @@ async def get_electricity_consumption_v1( self, mpan: str, serial_number: str, - page_num: int = None, + page: int = None, page_size: int = None, period_from: datetime = None, period_to: datetime = None, order: SortOrder = None, - aggregate: Aggregate = None, + group_by: 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: (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 + group_by: (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: @@ -139,12 +139,12 @@ async def get_electricity_consumption_v1( return await self._get( ["v1", "electricity-meter-points", mpan, "meters", serial_number, "consumption"], { - "page": page_num, + "page": page, "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, + "group_by": group_by.value if group_by is not None else None, }, ) @@ -164,24 +164,24 @@ async def get_gas_consumption_v1( self, mprn: str, serial_number: str, - page_num: int = None, + page: int = None, page_size: int = None, period_from: datetime = None, period_to: datetime = None, order: SortOrder = None, - aggregate: Aggregate = None, + group_by: 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: (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 + group_by: (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: @@ -191,18 +191,18 @@ async def get_gas_consumption_v1( return await self._get( ["v1", "gas-meter-points", mprn, "meters", serial_number, "consumption"], { - "page": page_num, + "page": page, "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, + "group_by": group_by.value if group_by is not None else None, }, ) async def get_products_v1( self, - page_num: int = None, + page: int = None, page_size: int = None, is_variable: bool = None, is_green: bool = None, @@ -214,7 +214,7 @@ async def get_products_v1( """Gets octopus energy products. Args: - page_num: (Optional) The page number to load. + page: (Optional) The page number to load. page_size: (Optional) How many results per page. is_variable (Optional): Activate filter and include variable products in the results. is_green (Optional): Activate filter and include green products in the results. @@ -232,7 +232,7 @@ async def get_products_v1( return await self._get( ["v1", "products"], { - "page": page_num, + "page": page, "page_size": page_size, "is_variable": is_variable, "is_green": is_green, diff --git a/pyproject.toml b/pyproject.toml index 1b456d9..c093f9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "octopus-energy" -version = "0.1.10" +version = "0.1.11" description = "Python client for the Octopus Energy RESTful API" authors = ["Mark Allanson "] license = "MIT" diff --git a/tests/fixtures/consumption_no_results_response.json b/tests/fixtures/consumption_no_results_response.json index d2bde88..c0db067 100644 --- a/tests/fixtures/consumption_no_results_response.json +++ b/tests/fixtures/consumption_no_results_response.json @@ -1,6 +1,5 @@ { "count": 0, "next": null, - "previous": null, - "results": [] + "previous": null } \ No newline at end of file diff --git a/tests/fixtures/consumption_response.json b/tests/fixtures/consumption_response.json index 303f32d..3ed0269 100644 --- a/tests/fixtures/consumption_response.json +++ b/tests/fixtures/consumption_response.json @@ -1,7 +1,7 @@ { "count": 48, - "next": null, - "previous": null, + "next": "https://api.octopus.energy?order=period&page=3&period_from=2021-01-03T12%3A18%3A15&period_to=2021-01-23T12%3A18%3A15", + "previous": "https://api.octopus.energy?order=period&page=1&period_from=2021-01-03T12%3A18%3A15&period_to=2021-01-23T12%3A18%3A15", "results": [ { "consumption": 0.063, diff --git a/tests/test_client.py b/tests/test_client.py index ee66762..5324917 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,14 @@ from unittest import TestCase from unittest.mock import patch, Mock -from octopus_energy import OctopusEnergyConsumerClient +from octopus_energy import ( + OctopusEnergyConsumerClient, + Meter, + EnergyType, + SortOrder, + PageReference, + Aggregate, +) from tests import does_asyncio @@ -33,3 +40,59 @@ async def test_get_meters(self, mock_mapper: Mock, mock_rest_client: Mock): mock_rest_client.return_value.get_account_details.assert_called_with(account) with self.subTest("returns the result of mapping"): self.assertIsNotNone(response) + + @does_asyncio + @patch("octopus_energy.client.OctopusEnergyRestClient", autospec=True) + @patch("octopus_energy.client.consumption_from_response", autospec=True) + async def test_get_consumption(self, mock_mapper: Mock, mock_rest_client: Mock): + mpxn = "mpxn" + sn = "sn" + meter: Meter = Mock() + meter.meter_point.id = mpxn + meter.serial_number = sn + + with self.subTest("electricity meter"): + async with OctopusEnergyConsumerClient("", "") as client: + meter.energy_type = EnergyType.ELECTRICITY + response = await client.get_consumption(meter) + with self.subTest("calls rest client with expected parameters"): + mock_rest_client.return_value.get_electricity_consumption_v1.assert_called_with( + mpxn, sn, period_from=None, period_to=None, order=SortOrder.OLDEST_FIRST + ) + with self.subTest("returns the result of mapping"): + self.assertIsNotNone(response) + + with self.subTest("gas meter"): + async with OctopusEnergyConsumerClient("", "") as client: + meter.energy_type = EnergyType.GAS + response = await client.get_consumption(meter) + with self.subTest("calls rest client with expected parameters"): + mock_rest_client.return_value.get_gas_consumption_v1.assert_called_with( + mpxn, sn, period_from=None, period_to=None, order=SortOrder.OLDEST_FIRST + ) + with self.subTest("returns the result of mapping"): + self.assertIsNotNone(response) + + with self.subTest("paging support"): + async with OctopusEnergyConsumerClient("", "") as client: + meter.energy_type = EnergyType.GAS + page_reference = PageReference( + { + "page": 1, + "page_size": 100, + "order": SortOrder.NEWEST_FIRST, + "group_by": Aggregate.QUARTER, + } + ) + response = await client.get_consumption(meter, page_reference=page_reference) + with self.subTest("uses page reference parameters when supplied"): + mock_rest_client.return_value.get_gas_consumption_v1.assert_called_with( + mpxn, + sn, + page=1, + page_size=100, + order=SortOrder.NEWEST_FIRST, + group_by=Aggregate.QUARTER, + ) + with self.subTest("returns the result of mapping"): + self.assertIsNotNone(response) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index be6e526..a560146 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -63,12 +63,12 @@ async def get(client): return await client.get_electricity_consumption_v1( "mpan", "serial_number", - page_num=1, + page=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, + group_by=Aggregate.DAY, ) await self._run_get_test( @@ -101,12 +101,12 @@ async def get(client): return await client.get_gas_consumption_v1( "mprn", "serial_number", - page_num=1, + page=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, + group_by=Aggregate.DAY, ) await self._run_get_test( @@ -124,7 +124,7 @@ async def get(client): async def test_get_products_v1(self, mock_aioresponses: aioresponses): async def get(client): return await client.get_products_v1( - page_num=1, + page=1, page_size=10, is_business=True, is_green=True, diff --git a/tests/test_mappers.py b/tests/test_mappers.py index 195299a..eee1567 100644 --- a/tests/test_mappers.py +++ b/tests/test_mappers.py @@ -11,8 +11,9 @@ consumption_from_response, to_timestamp_str, meters_from_response, + _get_page_reference, ) -from octopus_energy.models import UnitType, MeterGeneration +from octopus_energy.models import UnitType, MeterGeneration, SortOrder, Aggregate from tests import load_fixture_json, load_json @@ -21,6 +22,10 @@ def load_mapping_response_json(filename: str) -> dict: class TestAccountMappers(TestCase): + def __init__(self, methodName: str = ...) -> None: + super().__init__(methodName) + self.maxDiff = None + def test_account_mapping(self): """Verifies that the result of mapping a known input produces a known output.""" meters = meters_from_response(load_fixture_json("account_response.json")) @@ -178,5 +183,65 @@ def test_conversions(self): (UnitType.KWH, UnitType.CUBIC_METERS, 100, 8.9391068044481), (UnitType.KWH, UnitType.KWH, 100, 100), (UnitType.CUBIC_METERS, UnitType.CUBIC_METERS, 100, 100), + (None, None, 100, 100), ]: self.assertEqual(_calculate_unit(test[2], test[0], test[1]), test[3]) + + def test_page_reference(self): + with self.subTest("Page that does not exist in the response returns None"): + response = {} + self.assertIsNone(_get_page_reference(response, "next")) + + with self.subTest("The page has no value in the response returns None"): + response = { + "next": None, + } + self.assertIsNone(_get_page_reference(response, "next")) + + with self.subTest("page url with no arguments returns None"): + response = { + "next": "http://octopus.energy", + } + self.assertIsNone(_get_page_reference(response, "next")) + + with self.subTest("period from extracted from url"): + response = { + "next": "http://octopus.energy?period_from=2020-01-01", + } + self.assertEqual( + _get_page_reference(response, "next").options["period_from"], + datetime(year=2020, month=1, day=1), + ) + + with self.subTest("period to extracted from url"): + response = { + "next": "http://octopus.energy?period_to=2020-01-01", + } + self.assertEqual( + _get_page_reference(response, "next").options["period_to"], + datetime(year=2020, month=1, day=1), + ) + + with self.subTest("order extracted from url"): + response = { + "next": f"http://octopus.energy?order={SortOrder.NEWEST_FIRST.value}", + } + self.assertEqual( + _get_page_reference(response, "next").options["order"], SortOrder.NEWEST_FIRST + ) + + with self.subTest("group by extracted from url"): + response = { + "next": f"http://octopus.energy?group_by={Aggregate.QUARTER.value}", + } + self.assertEqual( + _get_page_reference(response, "next").options["group_by"], Aggregate.QUARTER + ) + + with self.subTest("arguments with no special handling are stored in raw form"): + response = { + "next": "http://octopus.energy?some_arg=some_value", + } + self.assertEqual( + _get_page_reference(response, "next").options["some_arg"], "some_value" + ) diff --git a/tests/test_models.py b/tests/test_models.py index 8cbab56..294be6a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from unittest import TestCase -from octopus_energy.models import Tariff, Meter, MeterGeneration, EnergyType +from octopus_energy.models import Tariff, Meter, MeterGeneration, EnergyType, UnitType, Address class MeterTests(TestCase): @@ -35,3 +35,23 @@ def test_meter_get_tariff(self): self.assertTrue( (tariff is None and test[4]) or meter.get_tariff_at(test[3]) == tariff ) + + +class UnitTypeTests(TestCase): + def test_unit_type_description(self): + self.assertEqual(UnitType.KWH.description, "Kilowatt Hours") + + +class MeterGenerationTests(TestCase): + def test_meter_generation_description(self): + self.assertEqual( + MeterGeneration.SMETS1_ELECTRICITY.description, "1st Generation Smart Electricity Meter" + ) + + +class AddressTests(TestCase): + def test_address__str__(self): + self.assertEqual( + "line 1, line 2, line 3, county, town, postcode", + str(Address("line 1", "line 2", "line 3", "county", "town", "postcode", True)), + )