diff --git a/.travis.yml b/.travis.yml index b1bc787..09a59f2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,8 +4,6 @@ dist: focal matrix: fast_finish: true include: - - python: '3.7' - env: TOXENV=py37 - python: '3.8' env: TOXENV=py38 - python: '3.9' @@ -28,7 +26,7 @@ deploy: tags: true branch: main repo: markallanson/octopus-energy - python: '3.7' + python: '3.8' skip_existing: true env: global: diff --git a/octopus_energy/__init__.py b/octopus_energy/__init__.py index 895a302..180f488 100644 --- a/octopus_energy/__init__.py +++ b/octopus_energy/__init__.py @@ -1,16 +1,26 @@ """Python client for the Octopus Energy RESTful API""" -from .rest_client import OctopusEnergyRestClient from .models import ( + Address, + Aggregate, Consumption, + EnergyType, + EnergyTariffType, IntervalConsumption, - MeterType, - UnitType, - Aggregate, - SortOrder, + Meter, + MeterDirection, + MeterGeneration, + MeterPoint, RateType, + SortOrder, + Tariff, + UnitType, + ElectricityMeter, + GasMeter, ) from .exceptions import ApiAuthenticationError, ApiError, ApiNotFoundError, ApiBadRequestError +from .rest_client import OctopusEnergyRestClient +from .client import OctopusEnergyConsumerClient __all__ = [ "OctopusEnergyRestClient", @@ -20,9 +30,19 @@ "ApiBadRequestError", "Consumption", "IntervalConsumption", - "MeterType", + "MeterGeneration", "UnitType", "Aggregate", "SortOrder", "RateType", + "Meter", + "EnergyType", + "EnergyTariffType", + "OctopusEnergyConsumerClient", + "Tariff", + "MeterDirection", + "MeterPoint", + "Address", + "ElectricityMeter", + "GasMeter", ] diff --git a/octopus_energy/client.py b/octopus_energy/client.py new file mode 100644 index 0000000..1db1eb3 --- /dev/null +++ b/octopus_energy/client.py @@ -0,0 +1,51 @@ +from typing import List + +from .mappers import meters_from_response +from octopus_energy import Meter, OctopusEnergyRestClient + + +class OctopusEnergyConsumerClient: + """An opinionated take on the consumer features of the Octopus Energy API. + + This client uses python model classes instead of raw json-as-a-dict. It does not expose all of + the octopus energy API, only bits that are useful for a consumer of octopus energy products. It + focuses on consumption and prices, and blending the two of them together to provide useful + data for a consumer. + + This client is useful if you want to know your own personal energy consumption statistics. + + This client uses async i/o. + """ + + def __init__(self, account_number: str, api_token: str): + """Initializes the Octopus Energy Consumer Client. + + Args: + account_number: Your Octopus Energy Account Number. + api_token: Your Octopus Energy API Key. + """ + self.account_number = account_number + self.rest_client = OctopusEnergyRestClient(api_token) + + def __enter__(self): + raise TypeError("Use async context manager (async with) instead") + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + async def close(self): + """Clean up resources used by the client. + + Once the client is closed, you cannot use it to make any further calls. + """ + await self.rest_client.close() + + 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)) diff --git a/octopus_energy/exceptions.py b/octopus_energy/exceptions.py index 073f579..b3998fc 100644 --- a/octopus_energy/exceptions.py +++ b/octopus_energy/exceptions.py @@ -6,7 +6,7 @@ def __init__(self, response, message="") -> None: self.message = message def __str__(self) -> str: - return f"{self.response.status} - {self.response.text}" + return f"{self.response.status}: {self.response.text}" class ApiAuthenticationError(Exception): @@ -15,7 +15,7 @@ class ApiAuthenticationError(Exception): pass -class ApiBadRequestError(Exception): +class ApiBadRequestError(ApiError): """Data posted to an API is incorrect. Typically the response code was 400.""" pass diff --git a/octopus_energy/mappers.py b/octopus_energy/mappers.py index f134838..b9a9233 100644 --- a/octopus_energy/mappers.py +++ b/octopus_energy/mappers.py @@ -1,8 +1,24 @@ +from typing import List, Optional + from datetime import datetime +import dateutil from dateutil.parser import isoparse -from .models import IntervalConsumption, UnitType, Consumption, MeterType +from .models import ( + IntervalConsumption, + UnitType, + Consumption, + ElectricityMeter, + MeterDirection, + EnergyType, + MeterGeneration, + GasMeter, + Tariff, + Meter, + Address, + MeterPoint, +) _CUBIC_METERS_TO_KWH_MULTIPLIER = 11.1868 _KWH_TO_KWH_MULTIPLIER = 1 @@ -12,14 +28,88 @@ } +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 from_timestamp_str(timestamp: str) -> Optional[datetime]: + """Convert am Octopus Energy timestamp string to an datetime. + + Args: + timestamp: The timestamp to convert. + + Returns: + The timestamp as a datetime object. + """ + return dateutil.parser.isoparse(timestamp) if timestamp else None + + +def _map_tariffs(input_tariffs: List[dict]) -> List[Tariff]: + return [ + Tariff( + t["tariff_code"], from_timestamp_str(t["valid_from"]), from_timestamp_str(t["valid_to"]) + ) + for t in input_tariffs + ] + + +def meters_from_response(response: dict) -> List[Meter]: + meters = [] + for property_ in response["properties"]: + address = Address( + property_.get("address_line_1", None), + property_.get("address_line_2", None), + property_.get("address_line_3", None), + property_.get("county", None), + property_.get("town", None), + property_.get("postcode", None), + # the property is active if it has no moved out date + property_.get("moved_out_at", None) is None, + ) + for meter_point in property_.get("electricity_meter_points", []): + for meter in meter_point["meters"]: + meters.append( + ElectricityMeter( + meter_point=MeterPoint(meter_point["mpan"], address), + serial_number=meter["serial_number"], + tariffs=_map_tariffs(meter_point["agreements"]), + direction=MeterDirection.EXPORT + if meter_point["is_export"] + else MeterDirection.IMPORT, + energy_type=EnergyType.ELECTRICITY, + generation=MeterGeneration.SMETS1_ELECTRICITY, + ) + ) + for meter_point in property_.get("gas_meter_points", []): + for meter in meter_point["meters"]: + meters.append( + GasMeter( + meter_point=MeterPoint(meter_point["mprn"], address), + serial_number=meter["serial_number"], + tariffs=_map_tariffs(meter_point["agreements"]), + energy_type=EnergyType.GAS, + generation=MeterGeneration.SMETS1_GAS, + ) + ) + return meters + + def consumption_from_response( - response: dict, meter_type: MeterType, desired_unit_type: UnitType + response: dict, meter: Meter, desired_unit_type: UnitType ) -> Consumption: """Generates the Consumption model from an octopus energy API response. Args: response: The API response object. - meter_type: The type of meter the reading is from. + meter: The meter the consumption is related to. desired_unit_type: The desired unit for the consumption intervals. The mapping will convert from the meters units to the desired units. @@ -28,14 +118,14 @@ def consumption_from_response( """ if "results" not in response: - return Consumption(unit_type=desired_unit_type, meter_type=meter_type) + return Consumption(unit_type=desired_unit_type, meter=meter) return Consumption( desired_unit_type, - meter_type, + meter, [ IntervalConsumption( consumed_units=_calculate_unit( - result["consumption"], meter_type.unit_type, desired_unit_type + result["consumption"], meter.generation.unit_type, desired_unit_type ), interval_start=isoparse(result["interval_start"]), interval_end=isoparse(result["interval_end"]), @@ -45,18 +135,6 @@ 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 20a8c16..09aaaf9 100644 --- a/octopus_energy/models.py +++ b/octopus_energy/models.py @@ -2,7 +2,25 @@ from datetime import datetime from decimal import Decimal from enum import Enum -from typing import List +from typing import List, Optional + + +class _DocEnum(Enum): + """Wrapper to create enumerations with useful docstrings.""" + + def __new__(cls, value, doc): + self = object.__new__(cls) + self._value_ = value + if doc is not None: + self.__doc__ = doc + return self + + +@dataclass +class Tariff: + code: str + valid_from: datetime + valid_to: Optional[datetime] class UnitType(Enum): @@ -20,7 +38,7 @@ def __eq__(self, other): return self.value == other.value -class MeterType(Enum): +class MeterGeneration(Enum): """Energy meter types, the units the measure in and description in english.""" SMETS1_GAS = ("SMETS1_GAS", UnitType.KWH, "1st Generation Smart Gas Meter") @@ -50,6 +68,87 @@ def __eq__(self, other): return self.value == other.value +class EnergyType(Enum): + """Represents a type of energy.""" + + GAS = "gas" + ELECTRICITY = "electricity" + + +class MeterDirection(_DocEnum): + """Represents the direction that energy flows through the meter.""" + + IMPORT = ("import", "Electricity that is consumed from the electricity grid") + EXPORT = ("export", "Electricity that is sent to the electricity grid") + + +@dataclass +class Address: + line_1: str + line_2: str + line_3: str + county: str + town: str + postcode: str + active: bool + + 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 ''}" + ) + + +@dataclass +class MeterPoint: + """Represents an energy meter point which has an identifier and is located at an address. + + Many meter points can share the same address. + """ + + id: str + address: Address + + +@dataclass +class Meter: + """Represents an energy meter, either gas or electric.""" + + meter_point: MeterPoint + serial_number: str + energy_type: EnergyType + generation: MeterGeneration + tariffs: List[Tariff] + + def get_tariff_at(self, timestamp: datetime): + """Gets the tariff in effect on a meter at a specific date/time. + + This automatically takes into account open ended tariffs that have no end.""" + return next( + ( + tariff + for tariff in self.tariffs + if timestamp >= tariff.valid_from + and (not tariff.valid_to or timestamp < tariff.valid_to) + ), + None, + ) + + +@dataclass +class ElectricityMeter(Meter): + direction: MeterDirection + + +@dataclass +class GasMeter(Meter): + pass + + @dataclass class IntervalConsumption: """Represents the consumption of energy over a single interval of time.""" @@ -64,21 +163,10 @@ class Consumption: """Consumption of energy for a list of time intervals.""" unit_type: UnitType - meter_type: MeterType + meter: Meter intervals: List[IntervalConsumption] = field(default_factory=lambda: []) -class _DocEnum(Enum): - """Wrapper to create enumerations with useful docstrings.""" - - def __new__(cls, value, doc): - self = object.__new__(cls) - self._value_ = value - if doc is not None: - self.__doc__ = doc - return self - - class EnergyTariffType(_DocEnum): """Represents a type of energy tariff.""" @@ -86,7 +174,7 @@ class EnergyTariffType(_DocEnum): "electricity-tariffs", "Represents a type of tariff related to electricity consumption.", ) - GAS = "gas-tariffs", "Represents a type of tariff relate to gas consumption." + GAS = ("gas-tariffs", "Represents a type of tariff related to gas consumption.") class RateType(_DocEnum): diff --git a/octopus_energy/rest_client.py b/octopus_energy/rest_client.py index 45d521f..6904189 100644 --- a/octopus_energy/rest_client.py +++ b/octopus_energy/rest_client.py @@ -45,7 +45,7 @@ def __init__(self, api_token: Optional[str] = None, base_url: str = _API_BASE): ) def __enter__(self): - raise TypeError("Use async context manager (await with) instead") + raise TypeError("Use async context manager (async with) instead") def __exit__(self, exc_type, exc_val, exc_tb): pass @@ -340,6 +340,6 @@ async def _execute( if response.status == HTTPStatus.NOT_FOUND: raise ApiNotFoundError() if response.status == HTTPStatus.BAD_REQUEST: - raise ApiBadRequestError() + raise ApiBadRequestError(response) raise ApiError(response, "API Call Failed") return await response.json() diff --git a/poetry.lock b/poetry.lock index 58fcce6..408ac6e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -44,14 +44,6 @@ category = "main" optional = false python-versions = ">=3.5.3" -[[package]] -name = "atomicwrites" -version = "1.4.0" -description = "Atomic file writes." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - [[package]] name = "attrs" version = "20.3.0" @@ -145,14 +137,13 @@ optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" [package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 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" +version = "1.1.0" description = "Let your Python tests travel through time" category = "dev" optional = false @@ -182,27 +173,17 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] -name = "importlib-metadata" -version = "2.1.1" -description = "Read metadata from Python packages" +name = "jsonpickle" +version = "1.5.0" +description = "Python library for serializing any arbitrary object graph into JSON" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" - -[package.dependencies] -zipp = ">=0.5" +python-versions = ">=2.7" [package.extras] -docs = ["sphinx", "rst.linker"] -testing = ["packaging", "pep517", "unittest2", "importlib-resources (>=1.3)"] - -[[package]] -name = "iniconfig" -version = "1.1.1" -description = "iniconfig: brain-dead simple config-ini parsing" -category = "dev" -optional = false -python-versions = "*" +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["coverage (<5)", "pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-black-multipy", "pytest-cov", "ecdsa", "feedparser", "numpy", "pandas", "pymongo", "sqlalchemy", "enum34", "jsonlib"] +"testing.libs" = ["demjson", "simplejson", "ujson", "yajl"] [[package]] name = "mccabe" @@ -266,9 +247,6 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - [package.extras] dev = ["pre-commit", "tox"] @@ -304,53 +282,6 @@ category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -[[package]] -name = "pytest" -version = "6.2.1" -description = "pytest: simple powerful testing with Python" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} -attrs = ">=19.2.0" -colorama = {version = "*", markers = "sys_platform == \"win32\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<1.0.0a1" -py = ">=1.8.2" -toml = "*" - -[package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "0.14.0" -description = "Pytest support for asyncio." -category = "dev" -optional = false -python-versions = ">= 3.5" - -[package.dependencies] -pytest = ">=5.4.0" - -[package.extras] -testing = ["async-generator (>=1.3)", "coverage", "hypothesis (>=5.7.1)"] - -[[package]] -name = "pytest-subtests" -version = "0.4.0" -description = "unittest subTest() support and subtests fixture" -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.dependencies] -pytest = ">=5.3.0" - [[package]] name = "python-dateutil" version = "2.8.1" @@ -422,7 +353,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "tox" -version = "3.20.1" +version = "3.21.2" description = "tox is a generic virtualenv management and test command line tool" category = "dev" optional = false @@ -431,7 +362,6 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} filelock = ">=3.0.0" -importlib-metadata = {version = ">=0.12,<3", markers = "python_version < \"3.8\""} packaging = ">=14" pluggy = ">=0.12.0" py = ">=1.4.17" @@ -441,7 +371,7 @@ virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2, [package.extras] docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] -testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)"] +testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)", "pathlib2 (>=2.3.3)"] [[package]] name = "typed-ast" @@ -474,7 +404,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.2.2" +version = "20.4.0" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -484,12 +414,11 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" appdirs = ">=1.4.3,<2" distlib = ">=0.3.1,<1" filelock = ">=3.0.0,<4" -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} six = ">=1.9.0,<2" [package.extras] docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] [[package]] name = "yarl" @@ -502,24 +431,11 @@ python-versions = ">=3.6" [package.dependencies] idna = ">=2.0" multidict = ">=4.0" -typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} - -[[package]] -name = "zipp" -version = "3.4.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [metadata] lock-version = "1.1" -python-versions = "^3.7" -content-hash = "afc545962c6bce3f5fdbf89b1afd8288373dc55c1ae1c5f9f3f856192827b270" +python-versions = "^3.8" +content-hash = "b9da5424497dc913888abb44448e590071ed5c186db55e41c5edefa184ec08d6" [metadata.files] aiohttp = [ @@ -573,10 +489,6 @@ async-timeout = [ {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, ] -atomicwrites = [ - {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, - {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, -] attrs = [ {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, @@ -613,8 +525,8 @@ flake8 = [ {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"}, + {file = "freezegun-1.1.0-py2.py3-none-any.whl", hash = "sha256:2ae695f7eb96c62529f03a038461afe3c692db3465e215355e1bb4b0ab408712"}, + {file = "freezegun-1.1.0.tar.gz", hash = "sha256:177f9dd59861d871e27a484c3332f35a6e3f5d14626f2bf91be37891f18927f3"}, ] furl = [ {file = "furl-2.1.0-py2.py3-none-any.whl", hash = "sha256:f4d6f1e5479c376a5b7bdc62795d736d8c1b2a754f366a2ad2816e46e946e22e"}, @@ -624,13 +536,9 @@ idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, ] -importlib-metadata = [ - {file = "importlib_metadata-2.1.1-py2.py3-none-any.whl", hash = "sha256:c2d6341ff566f609e89a2acb2db190e5e1d23d5409d6cc8d2fe34d72443876d4"}, - {file = "importlib_metadata-2.1.1.tar.gz", hash = "sha256:b8de9eff2b35fb037368f28a7df1df4e6436f578fa74423505b6c6a778d5b5dd"}, -] -iniconfig = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +jsonpickle = [ + {file = "jsonpickle-1.5.0-py2.py3-none-any.whl", hash = "sha256:423d7b5e6c606d4c0efd93819913191e375f3a23c0874f39df94d2e20dd21c93"}, + {file = "jsonpickle-1.5.0.tar.gz", hash = "sha256:1bd34a2ae8e51d3adbcafe83dc2d5cc81be53ada8bb16959ca6aca499bceada2"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, @@ -711,18 +619,6 @@ pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] -pytest = [ - {file = "pytest-6.2.1-py3-none-any.whl", hash = "sha256:1969f797a1a0dbd8ccf0fecc80262312729afea9c17f1d70ebf85c5e76c6f7c8"}, - {file = "pytest-6.2.1.tar.gz", hash = "sha256:66e419b1899bc27346cb2c993e12c5e5e8daba9073c1fbce33b9807abc95c306"}, -] -pytest-asyncio = [ - {file = "pytest-asyncio-0.14.0.tar.gz", hash = "sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700"}, - {file = "pytest_asyncio-0.14.0-py3-none-any.whl", hash = "sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d"}, -] -pytest-subtests = [ - {file = "pytest-subtests-0.4.0.tar.gz", hash = "sha256:8d9e2c1d1dce11f7b7d2c9d09202ebfc7757b7ff0cac9b72ad328edfe7ee037b"}, - {file = "pytest_subtests-0.4.0-py3-none-any.whl", hash = "sha256:3755a42b7416b99d90bb3cb2bd1ac4767d5e4b93b8853cb3565200a4e3a10b7e"}, -] python-dateutil = [ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, @@ -787,8 +683,8 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] tox = [ - {file = "tox-3.20.1-py2.py3-none-any.whl", hash = "sha256:42ce19ce5dc2f6d6b1fdc5666c476e1f1e2897359b47e0aa3a5b774f335d57c2"}, - {file = "tox-3.20.1.tar.gz", hash = "sha256:4321052bfe28f9d85082341ca8e233e3ea901fdd14dab8a5d3fbd810269fbaf6"}, + {file = "tox-3.21.2-py2.py3-none-any.whl", hash = "sha256:0aa777ee466f2ef18e6f58428c793c32378779e0a321dbb8934848bc3e78998c"}, + {file = "tox-3.21.2.tar.gz", hash = "sha256:f501808381c01c6d7827c2f17328be59c0a715046e94605ddca15fb91e65827d"}, ] typed-ast = [ {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"}, @@ -832,8 +728,8 @@ urllib3 = [ {file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"}, ] virtualenv = [ - {file = "virtualenv-20.2.2-py2.py3-none-any.whl", hash = "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c"}, - {file = "virtualenv-20.2.2.tar.gz", hash = "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b"}, + {file = "virtualenv-20.4.0-py2.py3-none-any.whl", hash = "sha256:227a8fed626f2f20a6cdb0870054989f82dd27b2560a911935ba905a2a5e0034"}, + {file = "virtualenv-20.4.0.tar.gz", hash = "sha256:219ee956e38b08e32d5639289aaa5bd190cfbe7dafcb8fa65407fca08e808f9c"}, ] yarl = [ {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, @@ -874,7 +770,3 @@ yarl = [ {file = "yarl-1.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6"}, {file = "yarl-1.6.3.tar.gz", hash = "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10"}, ] -zipp = [ - {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, - {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, -] diff --git a/pyproject.toml b/pyproject.toml index 3a1ae73..1b456d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "octopus-energy" -version = "0.1.9" +version = "0.1.10" description = "Python client for the Octopus Energy RESTful API" authors = ["Mark Allanson "] license = "MIT" @@ -9,26 +9,23 @@ homepage="https://github.com/markallanson/octopus-energy" include=["*.md"] [tool.poetry.dependencies] -python = "^3.7" +python = "^3.8" python-dateutil = "^2.8.1" aiohttp = "^3.7.1" furl = "^2.1.0" [tool.poetry.dev-dependencies] -pytest = "^6.2.1" requests-mock = "^1.8.0" black = "^20.8b1" flake8 = "^3.8.4" tox = "^3.20.1" aioresponses = "^0.7.1" -pytest-asyncio = "^0.14.0" -pytest-subtests = "^0.4.0" freezegun = "^1.0.0" +jsonpickle = "^1.4.2" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" - [tool.black] line-length = 100 -target-version = ['py37'] +target-version = ['py38'] diff --git a/tests/__init__.py b/tests/__init__.py index 2c06dff..95b4aed 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,8 +2,10 @@ import os from asyncio import get_event_loop +import jsonpickle -def load_fixture(filename: str) -> str: + +def load_json(filename: str) -> str: """Load a fixture for a test from the fixtures directory. Args: @@ -12,7 +14,7 @@ def load_fixture(filename: str) -> str: Returns: The content of the file """ - path = os.path.join(os.path.dirname(__file__), "fixtures", filename) + path = os.path.join(os.path.dirname(__file__), filename) with open(path) as fptr: return fptr.read() @@ -26,7 +28,7 @@ def load_fixture_json(filename: str) -> dict: Returns: The content of the file parsed as json. """ - return json.loads(load_fixture(filename)) + return json.loads(load_json(os.path.join("fixtures", filename))) def does_asyncio(func): diff --git a/tests/fixtures/account_response.json b/tests/fixtures/account_response.json index 93a6a7e..695943a 100644 --- a/tests/fixtures/account_response.json +++ b/tests/fixtures/account_response.json @@ -3,9 +3,9 @@ "properties": [ { "address_line_1": "XXXXXXX", - "address_line_2": "XXXXXXX", - "address_line_3": "XXXXXXX", - "county": "XXXXXXX", + "address_line_2": "YYYYYYY", + "address_line_3": "ZZZZZZZ", + "county": "AAAAAAA", "electricity_meter_points": [ { "agreements": [ @@ -46,6 +46,31 @@ ], "mpan": "9999999999999", "profile_class": 1 + }, + { + "agreements": [ + { + "tariff_code": "E-1R-VAR-17-01-11-A", + "valid_from": "2018-02-16T00:00:00Z", + "valid_to": null + } + ], + "consumption_standard": 7015, + "is_export": true, + "meters": [ + { + "registers": [ + { + "identifier": "01", + "is_settlement_register": true, + "rate": "STANDARD" + } + ], + "serial_number": "EXPORT_SN" + } + ], + "mpan": "EXPORT_MPAN", + "profile_class": 1 } ], "gas_meter_points": [ @@ -75,7 +100,7 @@ "moved_in_at": "2014-09-05T20:37:46.732538Z", "moved_out_at": null, "postcode": "AA1 1AA", - "town": "" + "town": "ANYTOWN GB" } ] } diff --git a/tests/mapping_results/account_mapping.json b/tests/mapping_results/account_mapping.json new file mode 100644 index 0000000..18d882a --- /dev/null +++ b/tests/mapping_results/account_mapping.json @@ -0,0 +1,374 @@ +[ + { + "py/object": "octopus_energy.models.ElectricityMeter", + "meter_point": { + "py/object": "octopus_energy.models.MeterPoint", + "id": "9999999999999", + "address": { + "py/object": "octopus_energy.models.Address", + "line_1": "XXXXXXX", + "line_2": "YYYYYYY", + "line_3": "ZZZZZZZ", + "county": "AAAAAAA", + "town": "ANYTOWN GB", + "postcode": "AA1 1AA", + "active": true + } + }, + "serial_number": "ESESESESES", + "energy_type": { + "py/reduce": [ + { + "py/type": "octopus_energy.models.EnergyType" + }, + { + "py/tuple": [ + "electricity" + ] + } + ] + }, + "generation": { + "py/reduce": [ + { + "py/type": "octopus_energy.models.MeterGeneration" + }, + { + "py/tuple": [ + { + "py/tuple": [ + "SMETS1_ELECTRICITY", + { + "py/reduce": [ + { + "py/type": "octopus_energy.models.UnitType" + }, + { + "py/tuple": [ + { + "py/tuple": [ + "kWh", + "Kilowatt Hours" + ] + } + ] + } + ] + }, + "1st Generation Smart Electricity Meter" + ] + } + ] + } + ] + }, + "tariffs": [ + { + "py/object": "octopus_energy.models.Tariff", + "code": "E-1R-VAR-16-10-31-A", + "valid_from": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+EBAgAAAAAAAA==", + { + "py/reduce": [ + { + "py/function": "copyreg._reconstructor" + }, + { + "py/tuple": [ + { + "py/type": "dateutil.tz.tz.tzutc" + }, + { + "py/type": "datetime.tzinfo" + }, + { + "py/reduce": [ + { + "py/type": "datetime.tzinfo" + }, + { + "py/tuple": [] + } + ] + } + ] + } + ] + } + ] + ] + }, + "valid_to": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+ICEAAAAAAAAA==", + { + "py/id": 10 + } + ] + ] + } + }, + { + "py/object": "octopus_energy.models.Tariff", + "code": "E-1R-VAR-17-01-11-A", + "valid_from": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+ICEAAAAAAAAA==", + { + "py/id": 10 + } + ] + ] + }, + "valid_to": null + } + ], + "direction": { + "py/reduce": [ + { + "py/type": "octopus_energy.models.MeterDirection" + }, + { + "py/tuple": [ + "import" + ] + } + ] + } + }, + { + "py/object": "octopus_energy.models.ElectricityMeter", + "meter_point": { + "py/object": "octopus_energy.models.MeterPoint", + "id": "9999999999999", + "address": { + "py/id": 3 + } + }, + "serial_number": "ESESESESES", + "energy_type": { + "py/id": 4 + }, + "generation": { + "py/id": 5 + }, + "tariffs": [ + { + "py/object": "octopus_energy.models.Tariff", + "code": "E-1R-VAR-16-10-31-A", + "valid_from": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+EBAgAAAAAAAA==", + { + "py/id": 10 + } + ] + ] + }, + "valid_to": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+ICEAAAAAAAAA==", + { + "py/id": 10 + } + ] + ] + } + }, + { + "py/object": "octopus_energy.models.Tariff", + "code": "E-1R-VAR-17-01-11-A", + "valid_from": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+ICEAAAAAAAAA==", + { + "py/id": 10 + } + ] + ] + }, + "valid_to": null + } + ], + "direction": { + "py/id": 15 + } + }, + { + "py/object": "octopus_energy.models.ElectricityMeter", + "meter_point": { + "py/object": "octopus_energy.models.MeterPoint", + "id": "EXPORT_MPAN", + "address": { + "py/id": 3 + } + }, + "serial_number": "EXPORT_SN", + "energy_type": { + "py/id": 4 + }, + "generation": { + "py/id": 5 + }, + "tariffs": [ + { + "py/object": "octopus_energy.models.Tariff", + "code": "E-1R-VAR-17-01-11-A", + "valid_from": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+ICEAAAAAAAAA==", + { + "py/id": 10 + } + ] + ] + }, + "valid_to": null + } + ], + "direction": { + "py/reduce": [ + { + "py/type": "octopus_energy.models.MeterDirection" + }, + { + "py/tuple": [ + "export" + ] + } + ] + } + }, + { + "py/object": "octopus_energy.models.GasMeter", + "meter_point": { + "py/object": "octopus_energy.models.MeterPoint", + "id": "9999999999", + "address": { + "py/id": 3 + } + }, + "serial_number": "GSGSGSGSGSGSGS", + "energy_type": { + "py/reduce": [ + { + "py/type": "octopus_energy.models.EnergyType" + }, + { + "py/tuple": [ + "gas" + ] + } + ] + }, + "generation": { + "py/reduce": [ + { + "py/type": "octopus_energy.models.MeterGeneration" + }, + { + "py/tuple": [ + { + "py/tuple": [ + "SMETS1_GAS", + { + "py/id": 6 + }, + "1st Generation Smart Gas Meter" + ] + } + ] + } + ] + }, + "tariffs": [ + { + "py/object": "octopus_energy.models.Tariff", + "code": "G-1R-VAR-16-10-31-A", + "valid_from": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+EBAgAAAAAAAA==", + { + "py/id": 10 + } + ] + ] + }, + "valid_to": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+ICEAAAAAAAAA==", + { + "py/id": 10 + } + ] + ] + } + }, + { + "py/object": "octopus_energy.models.Tariff", + "code": "G-1R-VAR-17-01-11-A", + "valid_from": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+ICEAAAAAAAAA==", + { + "py/id": 10 + } + ] + ] + }, + "valid_to": null + } + ] + } +] diff --git a/tests/test_client.py b/tests/test_client.py index 1b6942e..ee66762 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,107 +1,35 @@ -import re -from http import HTTPStatus from unittest import TestCase +from unittest.mock import patch, Mock -from aioresponses import aioresponses - -from octopus_energy import ( - OctopusEnergyRestClient, - ApiAuthenticationError, - ApiError, - ApiNotFoundError, - ApiBadRequestError, -) +from octopus_energy import OctopusEnergyConsumerClient from tests import does_asyncio -_MOCK_TOKEN = "sk_live_xxxxxx" - - -class ClientTest(TestCase): - def setUp(self) -> None: - super().setUp() - - @staticmethod - async def get_electricity_consumption_v1() -> dict: - async with OctopusEnergyRestClient(_MOCK_TOKEN) as client: - return await client.get_electricity_consumption_v1("mpan", "serial_number") - - @staticmethod - async def get_gas_consumption_v1() -> dict: - async with OctopusEnergyRestClient(_MOCK_TOKEN) as client: - return await client.get_gas_consumption_v1("mprn", "serial_number") - - @does_asyncio - @aioresponses() - async def test_raises_api_auth_error_when_authentication_fails(self, mock_aioresponses): - with self.subTest("elec consumption v1"): - with self.assertRaises(ApiAuthenticationError): - mock_aioresponses.get(re.compile(".*"), status=HTTPStatus.UNAUTHORIZED.value) - await self.get_electricity_consumption_v1() - - with self.subTest("gas consumption v1"): - with self.assertRaises(ApiAuthenticationError): - mock_aioresponses.get(re.compile(".*"), status=HTTPStatus.UNAUTHORIZED.value) - await self.get_gas_consumption_v1() - - @does_asyncio - @aioresponses() - async def test_raises_api_error_when_not_ok(self, aiomock: aioresponses): - with self.subTest("elec consumption v1"): - with self.assertRaises(ApiError): - aiomock.get(re.compile(".*"), status=HTTPStatus.INTERNAL_SERVER_ERROR.value) - await self.get_electricity_consumption_v1() - - with self.subTest("gas consumption v1"): - with self.assertRaises(ApiError): - aiomock.get(re.compile(".*"), status=HTTPStatus.INTERNAL_SERVER_ERROR.value) - await self.get_gas_consumption_v1() - - @does_asyncio - @aioresponses() - async def test_raises_not_found_api_error_when_not_ok(self, aiomock: aioresponses): - with self.subTest("elec consumption v1"): - with self.assertRaises(ApiNotFoundError): - aiomock.get(re.compile(".*"), status=HTTPStatus.NOT_FOUND.value) - await self.get_electricity_consumption_v1() - - with self.subTest("gas consumption v1"): - with self.assertRaises(ApiNotFoundError): - aiomock.get(re.compile(".*"), status=HTTPStatus.NOT_FOUND.value) - await self.get_gas_consumption_v1() - - @does_asyncio - @aioresponses() - async def test_raises_bad_request_api_error_when_not_ok(self, aiomock: aioresponses): - with self.subTest("elec consumption v1"): - with self.assertRaises(ApiBadRequestError): - aiomock.get(re.compile(".*"), status=HTTPStatus.BAD_REQUEST.value) - await self.get_electricity_consumption_v1() - with self.subTest("gas consumption v1"): - with self.assertRaises(ApiBadRequestError): - aiomock.get(re.compile(".*"), status=HTTPStatus.BAD_REQUEST.value) - await self.get_gas_consumption_v1() +class ClientTestCase(TestCase): + def test_cannot_use_client_without_async(self): + with self.assertRaises(TypeError): + with OctopusEnergyConsumerClient("", ""): + pass @does_asyncio - @aioresponses() - async def test_get_elec_consumption_v1(self, aiomock: aioresponses): - aiomock.get(re.compile(".*"), status=HTTPStatus.OK.value, payload={"results": []}) - resp = await self.get_electricity_consumption_v1() - with self.subTest("call made"): - self.assertEqual(len(aiomock.requests), 1) - with self.subTest("response returned"): - self.assertIsNotNone(resp) - with self.subTest("response is dictionary"): - self.assertIsNotNone(type(resp), dict) + @patch("octopus_energy.client.OctopusEnergyRestClient", autospec=True) + async def test_init(self, mock_rest_client: Mock): + account_number = "A_BVC" + api_token = "A_BVC" + async with OctopusEnergyConsumerClient(account_number, api_token) as client: + with self.subTest("remembers account number"): + self.assertEqual(client.account_number, account_number) + with self.subTest("passes api token to rest client"): + mock_rest_client.assert_called_once_with(api_token) @does_asyncio - @aioresponses() - async def test_get_gas_consumption_v1(self, aiomock: aioresponses): - aiomock.get(re.compile(".*"), status=HTTPStatus.OK.value, payload={"results": []}) - resp = await self.get_gas_consumption_v1() - with self.subTest("call made"): - self.assertEqual(len(aiomock.requests), 1) - with self.subTest("response returned"): - self.assertIsNotNone(resp) - with self.subTest("response is dictionary"): - self.assertIsNotNone(type(resp), dict) + @patch("octopus_energy.client.OctopusEnergyRestClient", autospec=True) + @patch("octopus_energy.client.meters_from_response", autospec=True) + async def test_get_meters(self, mock_mapper: Mock, mock_rest_client: Mock): + account = "Account" + async with OctopusEnergyConsumerClient(account, "") as client: + response = await client.get_meters() + with self.subTest("calls get account details on rest client"): + mock_rest_client.return_value.get_account_details.assert_called_with(account) + with self.subTest("returns the result of mapping"): + self.assertIsNotNone(response) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index a780cc9..be6e526 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Callable +from unittest import TestCase -import pytest from aioresponses import aioresponses from dateutil.tz import tzoffset from freezegun import freeze_time @@ -10,220 +10,217 @@ from octopus_energy.models import EnergyTariffType, RateType, SortOrder, Aggregate from octopus_energy.rest_client import _API_BASE from tests import load_fixture_json +from tests import does_asyncio + _FAKE_API_TOKEN = "sk_live_xxxxxxxxxxxx" -@pytest.fixture -def mock_aioresponses(): - with aioresponses() as mock_aioresponses: - yield mock_aioresponses - - -@pytest.mark.asyncio -async def test_create_account_v1(mock_aioresponses: aioresponses): - async def post(client, request): - return await client.create_account(request) - - await _run_post_test( - "v1/accounts", - "create_account_request.json", - "create_account_response.json", - post, - mock_aioresponses, - ) - - -@pytest.mark.asyncio -async def test_create_quote_v1(mock_aioresponses: aioresponses): - async def post(client, request): - return await client.create_quote(request) - - await _run_post_test( - "v1/quotes", - "create_quote_request.json", - "create_quote_response.json", - post, - mock_aioresponses, - ) - - -@pytest.mark.asyncio -async def test_get_account_details_v1(mock_aioresponses: aioresponses): - async def get(client): - return await client.get_account_details("account_number") - - await _run_get_test( - "v1/accounts/account_number", "account_response.json", get, mock_aioresponses - ) - - -@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", - 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, +class E2eTestCase(TestCase): + @does_asyncio + @aioresponses() + async def test_create_account_v1(self, mock_aioresponses: aioresponses): + async def post(client, request): + return await client.create_account(request) + + await self._run_post_test( + "v1/accounts", + "create_account_request.json", + "create_account_response.json", + post, + mock_aioresponses, + ) + + @does_asyncio + @aioresponses() + async def test_create_quote_v1(self, mock_aioresponses: aioresponses): + async def post(client, request): + return await client.create_quote(request) + + await self._run_post_test( + "v1/quotes", + "create_quote_request.json", + "create_quote_response.json", + post, + mock_aioresponses, + ) + + @does_asyncio + @aioresponses() + async def test_get_account_details_v1(self, mock_aioresponses: aioresponses): + async def get(client): + return await client.get_account_details("account_number") + + await self._run_get_test( + "v1/accounts/account_number", "account_response.json", get, mock_aioresponses ) - await _run_get_test( - "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, - ) - - -@pytest.mark.asyncio -async def test_get_electricity_meter_points_v1(mock_aioresponses: aioresponses): - async def get(client): - return await client.get_electricity_meter_points_v1("mpan") - - await _run_get_test( - "v1/electricity-meter-points/mpan", - "get_electricity_meter_points.json", - get, - mock_aioresponses, - ) - - -@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", - 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, + @freeze_time("2021-01-01 01:11:11") + @does_asyncio + @aioresponses() + async def test_get_elec_consumption_v1(self, mock_aioresponses: aioresponses): + async def get(client): + 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 self._run_get_test( + "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, ) - await _run_get_test( - "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, - ) - - -@freeze_time("2021-01-01 01:11:11") -@pytest.mark.asyncio -async def test_get_products_v1(mock_aioresponses: aioresponses): - async def get(client): - return await client.get_products_v1( - page_num=1, - page_size=10, - is_business=True, - is_green=True, - is_tracker=True, - is_variable=True, - is_prepay=True, - available_at=datetime.now(tz=tzoffset("CEST", 2)), + @does_asyncio + @aioresponses() + async def test_get_electricity_meter_points_v1(self, mock_aioresponses: aioresponses): + async def get(client): + return await client.get_electricity_meter_points_v1("mpan") + + await self._run_get_test( + "v1/electricity-meter-points/mpan", + "get_electricity_meter_points.json", + get, + mock_aioresponses, ) - await _run_get_test( - "v1/products?page=1&page_size=10&is_variable=True&is_green=True&is_tracker=True" - "&is_prepay=True&is_business=True&available_at=2021-01-01T01%3A11%3A13%2B00%3A00%3A02", - "get_products_response.json", - get, - mock_aioresponses, - ) - - -@freeze_time("2021-01-01 01:11:11") -@pytest.mark.asyncio -async def test_get_product_v1(mock_aioresponses: aioresponses): - async def get(client): - return await client.get_product_v1( - "product_id", tariffs_active_at=datetime.now(tz=tzoffset("CEST", 2)) + @freeze_time("2021-01-01 01:11:11") + @does_asyncio + @aioresponses() + async def test_get_gas_consumption_v1(self, mock_aioresponses: aioresponses): + async def get(client): + 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 self._run_get_test( + "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, ) - await _run_get_test( - "v1/products/product_id?tariffs_active_at=2021-01-01T01%3A11%3A13%2B00%3A00%3A02", - "get_product_response.json", - get, - mock_aioresponses, - ) - - -@freeze_time("2021-01-01 01:11:11") -@pytest.mark.asyncio -async def test_get_tariff_v1(mock_aioresponses: aioresponses): - async def get(client): - return await client.get_tariff_v1( - "product_id", - EnergyTariffType.GAS, - "tariff_code", - RateType.STANDING_CHARGES, - page_num=1, - page_size=10, - period_from=datetime.now(tz=tzoffset("CEST", 2)), - period_to=datetime.now(tz=tzoffset("CEST", 2)), + @freeze_time("2021-01-01 01:11:11") + @does_asyncio + @aioresponses() + async def test_get_products_v1(self, mock_aioresponses: aioresponses): + async def get(client): + return await client.get_products_v1( + page_num=1, + page_size=10, + is_business=True, + is_green=True, + is_tracker=True, + is_variable=True, + is_prepay=True, + available_at=datetime.now(tz=tzoffset("CEST", 2)), + ) + + await self._run_get_test( + "v1/products?page=1&page_size=10&is_variable=True&is_green=True&is_tracker=True" + "&is_prepay=True&is_business=True&available_at=2021-01-01T01%3A11%3A13%2B00%3A00%3A02", + "get_products_response.json", + get, + mock_aioresponses, ) - await _run_get_test( - "v1/products/product_id/gas-tariffs/tariff_code/standing-charges?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", - "get_tariff_response.json", - get, - mock_aioresponses, - ) - - -@pytest.mark.asyncio -async def test_renew_business_tariff(mock_aioresponses: aioresponses): - async def post(client, request): - return await client.renew_business_tariff("account_num", request) - - await _run_post_test( - "v1/accounts/account_num/tariff-renewal", - "business_tariff_renewal_request.json", - "business_tariff_renewal_response.json", - post, - mock_aioresponses, - ) - - -async def _run_get_test( - path: str, response_resource: str, func: Callable, mock_aioresponses: aioresponses -): - mock_aioresponses.get( - f"{_API_BASE}/{path}", - payload=load_fixture_json(response_resource), - ) - async with OctopusEnergyRestClient(_FAKE_API_TOKEN) as client: - response = await func(client) - assert response == load_fixture_json(response_resource) - - -async def _run_post_test( - path: str, - request_resource: str, - response_resource: str, - func: Callable, - mock_aioresponses: aioresponses, -): - request = load_fixture_json(request_resource) - mock_aioresponses.post( - f"{_API_BASE}/{path}", - payload=load_fixture_json(response_resource), - ) - async with OctopusEnergyRestClient(_FAKE_API_TOKEN) as client: - response = await func(client, request) - assert response == load_fixture_json(response_resource) + @freeze_time("2021-01-01 01:11:11") + @does_asyncio + @aioresponses() + async def test_get_product_v1(self, mock_aioresponses: aioresponses): + async def get(client): + return await client.get_product_v1( + "product_id", tariffs_active_at=datetime.now(tz=tzoffset("CEST", 2)) + ) + + await self._run_get_test( + "v1/products/product_id?tariffs_active_at=2021-01-01T01%3A11%3A13%2B00%3A00%3A02", + "get_product_response.json", + get, + mock_aioresponses, + ) + + @freeze_time("2021-01-01 01:11:11") + @does_asyncio + @aioresponses() + async def test_get_tariff_v1(self, mock_aioresponses: aioresponses): + async def get(client): + return await client.get_tariff_v1( + "product_id", + EnergyTariffType.GAS, + "tariff_code", + RateType.STANDING_CHARGES, + page_num=1, + page_size=10, + period_from=datetime.now(tz=tzoffset("CEST", 2)), + period_to=datetime.now(tz=tzoffset("CEST", 2)), + ) + + await self._run_get_test( + "v1/products/product_id/gas-tariffs/tariff_code/standing-charges?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", + "get_tariff_response.json", + get, + mock_aioresponses, + ) + + @does_asyncio + @aioresponses() + async def test_renew_business_tariff(self, mock_aioresponses: aioresponses): + async def post(client, request): + return await client.renew_business_tariff("account_num", request) + + await self._run_post_test( + "v1/accounts/account_num/tariff-renewal", + "business_tariff_renewal_request.json", + "business_tariff_renewal_response.json", + post, + mock_aioresponses, + ) + + async def _run_get_test( + self, path: str, response_resource: str, func: Callable, mock_aioresponses: aioresponses + ): + mock_aioresponses.get( + f"{_API_BASE}/{path}", + payload=load_fixture_json(response_resource), + ) + async with OctopusEnergyRestClient(_FAKE_API_TOKEN) as client: + response = await func(client) + assert response == load_fixture_json(response_resource) + + async def _run_post_test( + self, + path: str, + request_resource: str, + response_resource: str, + func: Callable, + mock_aioresponses: aioresponses, + ): + request = load_fixture_json(request_resource) + mock_aioresponses.post( + f"{_API_BASE}/{path}", + payload=load_fixture_json(response_resource), + ) + async with OctopusEnergyRestClient(_FAKE_API_TOKEN) as client: + response = await func(client, request) + assert response == load_fixture_json(response_resource) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..b9c1556 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,38 @@ +from unittest import TestCase +from unittest.mock import Mock + +from octopus_energy.exceptions import ApiError, ApiBadRequestError + + +class ApiErrorTestCase(TestCase): + def test_error(self): + response = Mock() + message = "Message" + response.status = 500 + response.text = message + with self.subTest("response"): + try: + raise ApiError(response, message) + except ApiError as e: + self.assertEqual(e.response, response) + with self.subTest("message"): + try: + raise ApiError(response, message) + except ApiError as e: + self.assertEqual(e.message, message) + with self.subTest("__str__"): + try: + raise ApiError(response, message) + except ApiError as e: + self.assertIn(message, str(e)) + self.assertIn("500", str(e)) + + +class ApiBadRequestErrorTestCase(TestCase): + def test_error(self): + response = Mock() + with self.subTest("response"): + try: + raise ApiBadRequestError(response) + except ApiError as e: + self.assertEqual(e.response, response) diff --git a/tests/test_mappers.py b/tests/test_mappers.py index ed4fe2c..195299a 100644 --- a/tests/test_mappers.py +++ b/tests/test_mappers.py @@ -1,22 +1,39 @@ +import os from datetime import datetime from unittest import TestCase +from unittest.mock import Mock -import pytest +import jsonpickle from dateutil.tz import tzoffset from octopus_energy.mappers import ( _calculate_unit, consumption_from_response, to_timestamp_str, + meters_from_response, ) -from octopus_energy.models import UnitType, MeterType -from tests import load_fixture_json +from octopus_energy.models import UnitType, MeterGeneration +from tests import load_fixture_json, load_json -class TestMappers(TestCase): +def load_mapping_response_json(filename: str) -> dict: + return jsonpickle.decode(load_json(os.path.join("mapping_results", filename))) + + +class TestAccountMappers(TestCase): + 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")) + expected_response = load_mapping_response_json("account_mapping.json") + self.assertCountEqual(meters, expected_response) + + +class TestConsumptionMappers(TestCase): def test_smets1_gas_mapping_kwh(self): response = load_fixture_json("consumption_response.json") - consumption = consumption_from_response(response, MeterType.SMETS1_GAS, UnitType.KWH) + consumption = consumption_from_response( + response, Mock(generation=MeterGeneration.SMETS1_GAS), UnitType.KWH + ) with self.subTest("interval count"): self.assertEqual(len(consumption.intervals), 3, "Contains 3 periods of consumption") with self.subTest("units"): @@ -37,7 +54,7 @@ def test_smets1_gas_mapping_kwh(self): def test_smets1_gas_mapping_cubic_meters(self): response = load_fixture_json("consumption_response.json") consumption = consumption_from_response( - response, MeterType.SMETS1_GAS, UnitType.CUBIC_METERS + response, Mock(generation=MeterGeneration.SMETS1_GAS), UnitType.CUBIC_METERS ) with self.subTest("units"): self.assertEqual(consumption.unit_type, UnitType.CUBIC_METERS) @@ -46,7 +63,9 @@ def test_smets1_gas_mapping_cubic_meters(self): def test_smets2_gas_mapping_kwh(self): response = load_fixture_json("consumption_response.json") - consumption = consumption_from_response(response, MeterType.SMETS2_GAS, UnitType.KWH) + consumption = consumption_from_response( + response, Mock(generation=MeterGeneration.SMETS2_GAS), UnitType.KWH + ) with self.subTest("interval count"): self.assertEqual(len(consumption.intervals), 3, "Contains 3 periods of consumption") with self.subTest("units"): @@ -67,7 +86,7 @@ def test_smets2_gas_mapping_kwh(self): def test_smets2_gas_mapping_cubic_meters(self): response = load_fixture_json("consumption_response.json") consumption = consumption_from_response( - response, MeterType.SMETS2_GAS, UnitType.CUBIC_METERS + response, Mock(generation=MeterGeneration.SMETS2_GAS), UnitType.CUBIC_METERS ) with self.subTest("units"): self.assertEqual(consumption.unit_type, UnitType.CUBIC_METERS) @@ -77,7 +96,7 @@ def test_smets2_gas_mapping_cubic_meters(self): def test_smets1_elec_mapping(self): response = load_fixture_json("consumption_response.json") consumption = consumption_from_response( - response, MeterType.SMETS1_ELECTRICITY, UnitType.KWH + response, Mock(generation=MeterGeneration.SMETS1_ELECTRICITY), UnitType.KWH ) with self.subTest("interval count"): self.assertEqual(len(consumption.intervals), 3, "Contains 3 periods of consumption") @@ -99,7 +118,7 @@ def test_smets1_elec_mapping(self): def test_smets2_elec_mapping(self): response = load_fixture_json("consumption_response.json") consumption = consumption_from_response( - response, MeterType.SMETS2_ELECTRICITY, UnitType.KWH + response, Mock(generation=MeterGeneration.SMETS2_ELECTRICITY), UnitType.KWH ) with self.subTest("interval count"): self.assertEqual(len(consumption.intervals), 3, "Contains 3 periods of consumption") @@ -121,7 +140,9 @@ def test_smets2_elec_mapping(self): def test_consumption_no_intervals(self): response = load_fixture_json("consumption_no_results_response.json") consumption = consumption_from_response( - response, meter_type=MeterType.SMETS1_GAS, desired_unit_type=UnitType.KWH + response, + meter=Mock(generation=MeterGeneration.SMETS1_GAS), + desired_unit_type=UnitType.KWH, ) with self.subTest("interval count"): self.assertEqual(len(consumption.intervals), 0, "Contains 0 periods of consumption") @@ -131,34 +152,31 @@ def test_consumption_no_intervals(self): def test_consumption_missing_intervals(self): response = load_fixture_json("consumption_missing_results_response.json") consumption = consumption_from_response( - response, meter_type=MeterType.SMETS1_GAS, desired_unit_type=UnitType.KWH + response, + meter=Mock(generation=MeterGeneration.SMETS1_GAS), + desired_unit_type=UnitType.KWH, ) with self.subTest("interval count"): self.assertEqual(len(consumption.intervals), 0, "Contains 0 periods of consumption") with self.subTest("units"): 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", - [ - (UnitType.CUBIC_METERS, UnitType.KWH, 100, 1118.68), - (UnitType.KWH, UnitType.CUBIC_METERS, 100, 8.9391068044481), - (UnitType.KWH, UnitType.KWH, 100, 100), - (UnitType.CUBIC_METERS, UnitType.CUBIC_METERS, 100, 100), - ], -) -def test_conversions(source_unit_type, desired_unit_type, source_unit, desired_unit): - """Tests the unit conversions supported by the library.""" - assert _calculate_unit(source_unit, source_unit_type, desired_unit_type) == desired_unit + def test_timestamp_format(self): + """Verifies timestamps. + + * microseconds are stripped + * formatted as iso8601 + """ + timestamp = datetime.utcnow().replace(microsecond=0) + str = to_timestamp_str(timestamp) + assert str == timestamp.isoformat() + + def test_conversions(self): + """Tests the unit conversions supported by the library.""" + for test in [ + (UnitType.CUBIC_METERS, UnitType.KWH, 100, 1118.68), + (UnitType.KWH, UnitType.CUBIC_METERS, 100, 8.9391068044481), + (UnitType.KWH, UnitType.KWH, 100, 100), + (UnitType.CUBIC_METERS, UnitType.CUBIC_METERS, 100, 100), + ]: + self.assertEqual(_calculate_unit(test[2], test[0], test[1]), test[3]) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..8cbab56 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,37 @@ +from datetime import datetime, timedelta +from unittest import TestCase + +from octopus_energy.models import Tariff, Meter, MeterGeneration, EnergyType + + +class MeterTests(TestCase): + def test_meter_get_tariff(self): + for test in [ + ( + "Open ended, check date within range", + datetime.utcnow() + timedelta(days=-10), + None, + datetime.utcnow(), + False, + ), + ( + "Closed, check date within range", + datetime.utcnow() + timedelta(days=-10), + datetime.utcnow() + timedelta(days=1), + datetime.utcnow(), + False, + ), + ( + "Check date outside of range", + datetime.utcnow() + timedelta(days=-10), + datetime.utcnow() + timedelta(days=1), + datetime.utcnow(), + False, + ), + ]: + with self.subTest(test[0]): + tariff = Tariff("ABC", test[1], test[2]) + meter = Meter("", "", EnergyType.GAS, MeterGeneration.SMETS1_GAS, [tariff]) + self.assertTrue( + (tariff is None and test[4]) or meter.get_tariff_at(test[3]) == tariff + ) diff --git a/tests/test_rest_client.py b/tests/test_rest_client.py new file mode 100644 index 0000000..0495150 --- /dev/null +++ b/tests/test_rest_client.py @@ -0,0 +1,88 @@ +import re +from http import HTTPStatus +from unittest import TestCase + +from aioresponses import aioresponses + +from octopus_energy import ( + OctopusEnergyRestClient, + ApiAuthenticationError, + ApiError, + ApiNotFoundError, + ApiBadRequestError, +) +from tests import does_asyncio + +_MOCK_TOKEN = "sk_live_xxxxxx" + + +class ClientTest(TestCase): + def setUp(self) -> None: + super().setUp() + + @staticmethod + async def get_electricity_consumption_v1() -> dict: + async with OctopusEnergyRestClient(_MOCK_TOKEN) as client: + return await client.get_electricity_consumption_v1("mpan", "serial_number") + + @staticmethod + async def get_gas_consumption_v1() -> dict: + async with OctopusEnergyRestClient(_MOCK_TOKEN) as client: + return await client.get_gas_consumption_v1("mprn", "serial_number") + + @does_asyncio + @aioresponses() + async def test_raises_api_auth_error_when_authentication_fails(self, mock_aioresponses): + with self.subTest("elec consumption v1"): + with self.assertRaises(ApiAuthenticationError): + mock_aioresponses.get(re.compile(".*"), status=HTTPStatus.UNAUTHORIZED.value) + await self.get_electricity_consumption_v1() + + with self.subTest("gas consumption v1"): + with self.assertRaises(ApiAuthenticationError): + mock_aioresponses.get(re.compile(".*"), status=HTTPStatus.UNAUTHORIZED.value) + await self.get_gas_consumption_v1() + + @does_asyncio + @aioresponses() + async def test_raises_api_error_when_not_ok(self, aiomock: aioresponses): + with self.subTest("elec consumption v1"): + with self.assertRaises(ApiError): + aiomock.get(re.compile(".*"), status=HTTPStatus.INTERNAL_SERVER_ERROR.value) + await self.get_electricity_consumption_v1() + + with self.subTest("gas consumption v1"): + with self.assertRaises(ApiError): + aiomock.get(re.compile(".*"), status=HTTPStatus.INTERNAL_SERVER_ERROR.value) + await self.get_gas_consumption_v1() + + @does_asyncio + @aioresponses() + async def test_raises_not_found_api_error_when_not_ok(self, aiomock: aioresponses): + with self.subTest("elec consumption v1"): + with self.assertRaises(ApiNotFoundError): + aiomock.get(re.compile(".*"), status=HTTPStatus.NOT_FOUND.value) + await self.get_electricity_consumption_v1() + + with self.subTest("gas consumption v1"): + with self.assertRaises(ApiNotFoundError): + aiomock.get(re.compile(".*"), status=HTTPStatus.NOT_FOUND.value) + await self.get_gas_consumption_v1() + + @does_asyncio + @aioresponses() + async def test_raises_bad_request_api_error_when_not_ok(self, aiomock: aioresponses): + with self.subTest("elec consumption v1"): + with self.assertRaises(ApiBadRequestError): + aiomock.get(re.compile(".*"), status=HTTPStatus.BAD_REQUEST.value) + await self.get_electricity_consumption_v1() + + with self.subTest("gas consumption v1"): + with self.assertRaises(ApiBadRequestError): + aiomock.get(re.compile(".*"), status=HTTPStatus.BAD_REQUEST.value) + await self.get_gas_consumption_v1() + + def test_cannot_use_client_without_async(self): + with self.assertRaises(TypeError): + with OctopusEnergyRestClient(""): + pass diff --git a/tox.ini b/tox.ini index 250db7c..7f7b41a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] skipsdist = true -envlist = py37, py38, py39, black, flake8 +envlist = py38, py39, black, flake8 [testenv] whitelist_externals = poetry @@ -9,7 +9,8 @@ deps= coveralls commands = poetry install -v - coverage run --source=octopus_energy -m pytest tests/ + coverage run --source=octopus_energy -m unittest discover -v + coverage report [testenv:black] whitelist_externals = poetry