From ca05051aa221f3f97580cb37fb70d7492493a436 Mon Sep 17 00:00:00 2001 From: jrester Date: Mon, 14 Feb 2022 12:43:20 +0100 Subject: [PATCH] improve type hints --- CHANGELOG | 6 +++ README.md | 38 +++----------- py.typed | 0 setup.py | 8 +-- tesla_powerwall/__init__.py | 8 +-- tesla_powerwall/api.py | 10 ++-- tesla_powerwall/error.py | 40 +++++++++------ tesla_powerwall/helpers.py | 4 +- tesla_powerwall/powerwall.py | 29 ++++++----- tesla_powerwall/responses.py | 78 ++++++++++++++--------------- tests/integration/test_powerwall.py | 11 ++-- tests/unit/__init__.py | 2 +- tests/unit/test_api.py | 3 +- tests/unit/test_powerwall.py | 22 ++++---- 14 files changed, 125 insertions(+), 134 deletions(-) create mode 100644 py.typed diff --git a/CHANGELOG b/CHANGELOG index d59187e..b9b4304 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,11 @@ # Changelog +## [0.3.16] + +- add `py.typed` file +- remove all the version pinning and drop support for powerwall version < 0.47.0 +- add more type hints +- fix 'login_time' attribute in `LoginResponse` ## [0.3.15] diff --git a/README.md b/README.md index 34ef25b..286eea0 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,12 @@ ![Licence](https://img.shields.io/github/license/jrester/tesla_powerwall?style=for-the-badge) ![PyPI - Downloads](https://img.shields.io/pypi/dm/tesla_powerwall?color=blue&style=for-the-badge) ![PyPI](https://img.shields.io/pypi/v/tesla_powerwall?style=for-the-badge) -![PyPI - Python Version](https://img.shields.io/pypi/pyversions/tesla_powerwall?style=for-the-badge) Python Tesla Powerwall API for consuming a local endpoint. The API is by no means complete and mainly features methods which are considered to be of common use. If you feel like methods should be included you are welcome to open an Issue or create a Pull Request. -> Note: This is not an official API provided by Tesla and as such might fail at any time. +> Note: This is not an official API provided by Tesla and not affilated in anyways with Tesla. -Powerwall Software versions from 1.45.0 to 1.50.1 as well as 20.40 to 21.39.1 are tested, but others will probably work too. If you encounter an error regarding a change in the API of the Powerwall because your Powerwall has a different version than listed here please open an Issue to report this change so it can be fixed. - -> For more information about versioning see [API versioning](#api-versioning). +Powerwall Software versions from 1.47.0 to 1.50.1 as well as 20.40 to 21.39.1 are tested, but others will probably work too. If you encounter an error regarding a change in the API of the Powerwall because your Powerwall has a different version than listed here please open an Issue to report this change so it can be fixed. # Table of Contents @@ -19,7 +16,6 @@ Powerwall Software versions from 1.45.0 to 1.50.1 as well as 20.40 to 21.39.1 ar - [Setup](#setup) - [Authentication](#authentication) - [General](#general) - - [API versioning](#api-versioning) - [Errors](#errors) - [Response](#response) - [Battery level](#battery-level) @@ -62,13 +58,11 @@ powerwall = Powerwall( endpoint="", # Configure timeout; default is 10 timeout=10, - # Provide a requests.Session + # Provide a requests.Session or None to have one created http_sesion=None, # Whether to verify the SSL certificate or not verify_ssl=False, - disable_insecure_warning=True, - # Set the API to expect a specific version of the powerwall software - pin_version=None + disable_insecure_warning=True ) #=> ``` @@ -79,7 +73,7 @@ powerwall = Powerwall( ### Authentication Since version 20.49.0 authentication is required for all methods. For that reason you must call `login` before making a request to the API. -When you perform a request without being loggedin a `AccessDeniedError` will probably be thrown if the endpoint requires authentication. +When you perform a request without being loggedin a `AccessDeniedError` will be thrown. To login you can either use `login` or `login_as`. `login` logs you in as `User.CUSTOMER` whereas with `login_as` you can choose a different user: @@ -129,26 +123,6 @@ api.get_system_status_soe() The `Powerwall` objet provides a wrapper around the API and exposes common methods. -#### API versioning - -The powerwall API is inconsistent across different versions. This is why some versions may return different responses. If no version is specified the newest version is assumed. - -If you are sure which version your powerwall has you can pin the Powerwall object to a version: - -```python -from tesla_powerwall import Version -# Pin powerwall object -powerwall = Powerwall("", pin_version="1.50.1") - -# You can also pin a version after the powerwall object was created -powerwall.pin_version("20.40.3") -``` - -Otherwise you can let the API try to detect the version and pin it. This method should be prefered over the manual detection and pinning of the version: -```python -powerwall.detect_and_pin_version() -``` - #### Errors As the powerwall REST API varies widley between version and country it may happen that an attribute may not be included in your response. If that is the case a `MissingAttributeError` will be thrown indicating what attribute wasn't available. @@ -170,6 +144,8 @@ assert_attribute(status.response, "version") status.assert_attribute("version) ``` +For retriving the version you could also alternativly use `powerwall.get_version`. + ### Battery level Get charge in percent: diff --git a/py.typed b/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/setup.py b/setup.py index 7056831..1d1a8ac 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ name="tesla_powerwall", author="Jrester", author_email="jrester379@gmail.com", - version='0.3.15', + version='0.3.16', description="API for Tesla Powerwall", long_description=long_description, long_description_content_type="text/markdown", @@ -15,7 +15,9 @@ packages=find_packages(), classifiers=[ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", ], - install_requires=["requests>=2.22.0", "packaging>=20.5"], + package_data={ + 'tesla_powerwall': ["py.typed"] + }, + install_requires=["requests>=2.22.0"], ) diff --git a/tesla_powerwall/__init__.py b/tesla_powerwall/__init__.py index 62130a1..6ba9498 100644 --- a/tesla_powerwall/__init__.py +++ b/tesla_powerwall/__init__.py @@ -15,13 +15,15 @@ from .error import ( AccessDeniedError, APIError, + MeterNotAvailableError, MissingAttributeError, PowerwallError, PowerwallUnreachableError, - MeterNotAvailableError, ) from .helpers import assert_attribute, convert_to_kw +from .powerwall import Powerwall from .responses import ( + Battery, LoginResponse, Meter, MetersAggregates, @@ -29,8 +31,6 @@ SiteInfo, SiteMaster, Solar, - Battery, ) -from .powerwall import Powerwall -VERSION = "0.3.15" +VERSION = "0.3.16" diff --git a/tesla_powerwall/api.py b/tesla_powerwall/api.py index 1ec979a..5872eb4 100644 --- a/tesla_powerwall/api.py +++ b/tesla_powerwall/api.py @@ -1,3 +1,4 @@ +from http.client import responses from json.decoder import JSONDecodeError from typing import List from urllib.parse import urljoin @@ -5,7 +6,6 @@ import requests from urllib3 import disable_warnings from urllib3.exceptions import InsecureRequestWarning -from http.client import responses from .error import AccessDeniedError, APIError, PowerwallUnreachableError @@ -161,13 +161,13 @@ def login( }, ) - def logout(self): + def logout(self) -> None: if not self.is_authenticated(): raise APIError("Must be logged in to log out") # The api unsets the auth cookie and the token is invalidated self.get("logout") - def close(self): + def close(self) -> None: # Close the HTTP Session # THis method is required for testing, so python doesn't complain about unclosed resources self._http_session.close() @@ -195,13 +195,13 @@ def get_sitemaster(self) -> dict: def get_status(self) -> dict: return self.get("status") - def get_customer_registration(self): + def get_customer_registration(self) -> dict: return self.get("customer/registration") def get_powerwalls(self): return self.get("powerwalls") - def get_operation(self): + def get_operation(self) -> dict: return self.get("operation") def get_networks(self) -> list: diff --git a/tesla_powerwall/error.py b/tesla_powerwall/error.py index 4a1b007..3e05f18 100644 --- a/tesla_powerwall/error.py +++ b/tesla_powerwall/error.py @@ -1,18 +1,23 @@ +from typing import List, Union + +from .const import MeterType + + class PowerwallError(Exception): - def __init__(self, msg): + def __init__(self, msg: str): super().__init__(msg) class APIError(PowerwallError): - def __init__(self, error): + def __init__(self, error: str): super().__init__("Powerwall api error: {}".format(error)) class MissingAttributeError(APIError): - def __init__(self, response: dict, attribute: str, url: str = None): - self.response = response - self.attribute = attribute - self.url = url + def __init__(self, response: dict, attribute: str, url: Union[str, None] = None): + self.response: dict = response + self.attribute: str = attribute + self.url: Union[str, None] = url if url is None: super().__init__( @@ -29,19 +34,24 @@ def __init__(self, response: dict, attribute: str, url: str = None): class PowerwallUnreachableError(PowerwallError): - def __init__(self, reason=None): + def __init__(self, reason: Union[str, None] = None): msg = "Powerwall is unreachable" - self.reason = reason + self.reason: Union[str, None] = reason if reason is not None: msg = "{}: {}".format(msg, reason) super().__init__(msg) class AccessDeniedError(PowerwallError): - def __init__(self, resource, error=None, message=None): - self.resource = resource - self.error = error - self.message = message + def __init__( + self, + resource: str, + error: Union[str, None] = None, + message: Union[str, None] = None, + ): + self.resource: str = resource + self.error: Union[str, None] = error + self.message: Union[str, None] = message msg = "Access denied for resource {}".format(resource) if error is not None: if message is not None: @@ -52,9 +62,9 @@ def __init__(self, resource, error=None, message=None): class MeterNotAvailableError(PowerwallError): - def __init__(self, meter, available_meters): - self.meter = meter - self.available_meters = available_meters + def __init__(self, meter: MeterType, available_meters: List[MeterType]): + self.mete: MeterType = meter + self.available_meters: List[MeterType] = available_meters super().__init__( "Meter {} is not available at your powerwall. Following meters are available: {} ".format( meter.value, available_meters diff --git a/tesla_powerwall/helpers.py b/tesla_powerwall/helpers.py index c4bff2d..519f257 100644 --- a/tesla_powerwall/helpers.py +++ b/tesla_powerwall/helpers.py @@ -1,3 +1,5 @@ +from typing import Union + from .error import MissingAttributeError @@ -10,7 +12,7 @@ def convert_to_kw(value: float, precision: int = 1) -> float: return round(value / 1000, precision) -def assert_attribute(response: dict, attr: str, url: str = None): +def assert_attribute(response: dict, attr: str, url: Union[str, None] = None): value = response.get(attr) if value is None: raise MissingAttributeError(response, attr, url) diff --git a/tesla_powerwall/powerwall.py b/tesla_powerwall/powerwall.py index 0649ffb..a5df47f 100644 --- a/tesla_powerwall/powerwall.py +++ b/tesla_powerwall/powerwall.py @@ -1,4 +1,5 @@ -from typing import Union, List +from typing import List, Union + import requests from .api import API @@ -15,17 +16,17 @@ SyncType, User, ) +from .helpers import assert_attribute from .responses import ( + Battery, LoginResponse, Meter, MetersAggregates, PowerwallStatus, - SiteMaster, SiteInfo, + SiteMaster, Solar, - Battery, ) -from .helpers import assert_attribute class Powerwall: @@ -33,9 +34,9 @@ def __init__( self, endpoint: str, timeout: int = 10, - http_session: requests.Session = None, + http_session: Union[requests.Session, None] = None, verify_ssl: bool = False, - disable_insecure_warning: bool = True + disable_insecure_warning: bool = True, ): self._api = API( endpoint, @@ -64,16 +65,16 @@ def login_as( def login(self, password: str, email: str = "", force_sm_off: bool = False) -> dict: return self.login_as(User.CUSTOMER, password, email, force_sm_off) - def logout(self): + def logout(self) -> None: self._api.logout() def is_authenticated(self) -> bool: return self._api.is_authenticated() - def run(self): + def run(self) -> None: self._api.get_sitemaster_run() - def stop(self): + def stop(self) -> None: self._api.get_sitemaster_stop() def get_charge(self) -> float: @@ -120,7 +121,7 @@ def get_site_info(self) -> SiteInfo: """Returns information about the powerwall site""" return SiteInfo(self._api.get_site_info()) - def set_site_name(self, site_name: str): + def set_site_name(self, site_name: str) -> str: return self._api.post_site_info_site_name({"site_name": site_name}) def get_status(self) -> PowerwallStatus: @@ -162,10 +163,12 @@ def get_vin(self) -> str: def get_version(self) -> str: version_str = assert_attribute(self._api.get_status(), "version", "status") - return version_str.split(' ')[0] # newer versions include a sha trailer '21.44.1 c58c2df3' + return version_str.split(" ")[ + 0 + ] # newer versions include a sha trailer '21.44.1 c58c2df3' - def get_api(self): + def get_api(self) -> API: return self._api - def close(self): + def close(self) -> None: self._api.close() diff --git a/tesla_powerwall/responses.py b/tesla_powerwall/responses.py index 06000f9..16f9d57 100644 --- a/tesla_powerwall/responses.py +++ b/tesla_powerwall/responses.py @@ -1,24 +1,20 @@ import re from datetime import datetime, timedelta +from typing import List -from .const import ( - DEFAULT_KW_ROUND_PERSICION, - DeviceType, - MeterType, - Roles, -) -from .helpers import assert_attribute, convert_to_kw +from .const import DEFAULT_KW_ROUND_PERSICION, DeviceType, MeterType, Roles from .error import MeterNotAvailableError +from .helpers import assert_attribute, convert_to_kw class Response: - def __init__(self, response: dict): + def __init__(self, response: dict) -> None: self.response = response - def assert_attribute(self, attr: str): + def assert_attribute(self, attr: str) -> any: return assert_attribute(self.response, attr) - def __repr__(self): + def __repr__(self) -> str: return str(self.response) @@ -40,45 +36,45 @@ class Meter(Response): - timeout """ - def __init__(self, meter: MeterType, response): + def __init__(self, meter: MeterType, response) -> None: self.meter = meter super().__init__(response) @property - def instant_power(self): + def instant_power(self) -> float: return self.assert_attribute("instant_power") @property - def last_communication_time(self): + def last_communication_time(self) -> str: return self.assert_attribute("last_communication_time") @property - def frequency(self): + def frequency(self) -> float: return self.assert_attribute("frequency") @property - def energy_exported(self): + def energy_exported(self) -> float: return self.assert_attribute("energy_exported") def get_energy_exported(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: return convert_to_kw(self.energy_exported, precision) @property - def energy_imported(self): + def energy_imported(self) -> float: return self.assert_attribute("energy_imported") def get_energy_imported(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: return convert_to_kw(self.energy_imported, precision) @property - def instant_total_current(self): + def instant_total_current(self) -> float: return self.assert_attribute("instant_total_current") def get_instant_total_current(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: return round(self.instant_total_current, precision) @property - def average_voltage(self): + def average_voltage(self) -> float: return self.assert_attribute("instant_average_voltage") def get_power(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: @@ -103,11 +99,11 @@ def is_sending_to(self, precision=DEFAULT_KW_ROUND_PERSICION) -> bool: class MetersAggregates(Response): - def __init__(self, response): + def __init__(self, response) -> str: super().__init__(response) self.meters = [MeterType(key) for key in response.keys()] - def __getattribute__(self, attr): + def __getattribute__(self, attr) -> any: if attr.upper() in MeterType.__dict__: m = MeterType(attr) if m in self.meters: @@ -133,11 +129,11 @@ class SiteMaster(Response): - power_supply_mode """ - def __init__(self, response): + def __init__(self, response) -> None: super().__init__(response) @property - def status(self): + def status(self) -> str: return self.assert_attribute("status") @property @@ -167,19 +163,19 @@ class SiteInfo(Response): - grid_code """ - def __init__(self, response): + def __init__(self, response) -> None: super().__init__(response) @property - def nominal_system_energy(self): + def nominal_system_energy(self) -> int: return self.assert_attribute("nominal_system_energy_kWh") @property - def site_name(self): + def site_name(self) -> str: return self.assert_attribute("site_name") @property - def timezone(self): + def timezone(self) -> str: return self.assert_attribute("timezone") @@ -201,7 +197,7 @@ class PowerwallStatus(Response): r"^((?P[\.\d]+?)d)?((?P[\.\d]+?)h)?((?P[\.\d]+?)m)?((?P[\.\d]+?)s)?$" ) - def _parse_uptime_seconds(self, up_time_seconds: str): + def _parse_uptime_seconds(self, up_time_seconds: str) -> timedelta: match = PowerwallStatus._UP_TIME_SECONDS_REGEX.match(up_time_seconds) if not match: raise ValueError( @@ -216,21 +212,21 @@ def _parse_uptime_seconds(self, up_time_seconds: str): return timedelta(**time_params) @property - def up_time_seconds(self): + def up_time_seconds(self) -> timedelta: up_time_seconds = assert_attribute(self.response, "up_time_seconds") return self._parse_uptime_seconds(up_time_seconds) @property - def start_time(self): + def start_time(self) -> datetime: start_time = assert_attribute(self.response, "start_time") return datetime.strptime(start_time, self._START_TIME_FORMAT) @property - def version(self): + def version(self) -> str: return self.assert_attribute("version") @property - def device_type(self): + def device_type(self) -> DeviceType: return DeviceType(self.assert_attribute("device_type")) @@ -247,24 +243,24 @@ class LoginResponse(Response): """ @property - def firstname(self): + def firstname(self) -> str: return self.assert_attribute("firstname") @property - def lastname(self): + def lastname(self) -> str: return self.assert_attribute("lastname") @property - def token(self): + def token(self) -> str: return self.assert_attribute("token") @property - def roles(self): + def roles(self) -> List[Roles]: return [Roles(role) for role in self.assert_attribute("roles")] @property def login_time(self): - return self.assert_attribute("login_time") + return self.assert_attribute("loginTime") class Solar(Response): @@ -276,25 +272,25 @@ class Solar(Response): """ @property - def brand(self): + def brand(self) -> str: return self.assert_attribute("brand") @property - def model(self): + def model(self) -> str: return self.assert_attribute("model") @property - def power_rating_watts(self): + def power_rating_watts(self) -> int: return self.assert_attribute("power_rating_watts") class Battery(Response): @property - def part_number(self): + def part_number(self) -> str: return self.assert_attribute("PackagePartNumber") @property - def serial_number(self): + def serial_number(self) -> str: return self.assert_attribute("PackageSerialNumber") @property diff --git a/tests/integration/test_powerwall.py b/tests/integration/test_powerwall.py index 59340ff..cfb7f52 100644 --- a/tests/integration/test_powerwall.py +++ b/tests/integration/test_powerwall.py @@ -1,16 +1,15 @@ -from tesla_powerwall.responses import PowerwallStatus import unittest from tesla_powerwall import ( - Powerwall, GridStatus, - SiteInfo, - SiteMaster, - MetersAggregates, Meter, + MetersAggregates, MeterType, + Powerwall, + SiteInfo, + SiteMaster, ) - +from tesla_powerwall.responses import PowerwallStatus from tests.integration import POWERWALL_IP, POWERWALL_PASSWORD diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 65003f6..1ccf1a0 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1,5 +1,5 @@ -import os import json +import os ENDPOINT = "https://1.1.1.1/api/" diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index f1fca44..70f1c91 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -1,12 +1,11 @@ -import unittest import json +import unittest import requests import responses from responses import GET, Response, add from tesla_powerwall import API, AccessDeniedError, APIError - from tests.unit import ENDPOINT diff --git a/tests/unit/test_powerwall.py b/tests/unit/test_powerwall.py index e52fe32..39991a0 100644 --- a/tests/unit/test_powerwall.py +++ b/tests/unit/test_powerwall.py @@ -1,35 +1,33 @@ -import unittest import datetime +import unittest import responses from responses import GET, Response, add from tesla_powerwall import ( API, + DeviceType, + GridStatus, Meter, + MeterNotAvailableError, MetersAggregates, + MeterType, + MissingAttributeError, Powerwall, - MeterNotAvailableError, SiteMaster, - GridStatus, - DeviceType, assert_attribute, convert_to_kw, - MissingAttributeError, - MeterType, ) from tesla_powerwall.const import OperationMode - - from tests.unit import ( ENDPOINT, - METERS_AGGREGATES_RESPONSE, - STATUS_RESPONSE, GRID_STATUS_RESPONSE, - SITE_INFO_RESPONSE, + METERS_AGGREGATES_RESPONSE, + OPERATION_RESPONSE, POWERWALLS_RESPONSE, + SITE_INFO_RESPONSE, SITEMASTER_RESPONSE, - OPERATION_RESPONSE, + STATUS_RESPONSE, SYSTEM_STATUS_RESPONSE, )