├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml ├── setup.cfg ├── setup.py ├── src └── hassapi │ ├── __init__.py │ ├── __main__.py │ ├── client │ ├── __init__.py │ ├── auth.py │ ├── base.py │ ├── events.py │ ├── services.py │ ├── states.py │ └── template.py │ ├── const.py │ ├── exceptions.py │ └── models │ ├── __init__.py │ ├── base.py │ ├── event.py │ ├── service.py │ └── state.py ├── tests ├── client │ ├── __init__.py │ ├── test_auth.py │ └── test_base.py └── models │ ├── __init__.py │ └── test_base.py └── tox.ini /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: '3.x' 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install setuptools wheel twine 31 | - name: Build and publish 32 | env: 33 | TWINE_USERNAME: __token__ 34 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 35 | run: | 36 | python setup.py sdist bdist_wheel 37 | twine upload dist/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Package version 132 | version.py 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 hass-api 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## [Home Assistant](https://www.home-assistant.io/) Web API Client for Python 2 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/hassapi?style=flat-square)](https://pypistats.org/packages/hassapi) 3 | 4 | ## Examples 5 | ```python 6 | from hassapi import Hass 7 | 8 | hass = Hass(hassurl="http://IP_ADDRESS:8123/", token="YOUR_HASS_TOKEN") 9 | 10 | hass.turn_on("light.bedroom_light") 11 | hass.run_script("good_morning") 12 | 13 | # If you want to bypass certificate verification. Default set to True 14 | hass = Hass(hassurl="http://IP_ADDRESS:8123/", token="YOUR_HASS_TOKEN", verify=False) 15 | ``` 16 | ## Installation 17 | ``` 18 | pip install hassapi 19 | ``` 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel>=0.36.2" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [tool.black] 9 | line-length = 99 10 | 11 | [tool.mypy] 12 | python_version = "3.8" 13 | warn_redundant_casts = true 14 | warn_unused_ignores = true 15 | disallow_subclassing_any = false 16 | disallow_untyped_calls = true 17 | disallow_untyped_defs = true 18 | check_untyped_defs = true 19 | warn_return_any = true 20 | no_implicit_optional = true 21 | strict_optional = true 22 | ignore_missing_imports = true 23 | 24 | 25 | [[tool.mypy.overrides]] 26 | module = "tenacity.*" 27 | ignore_missing_imports = true -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = hassapi 3 | author = Vadim Titov 4 | author_email = titov.hse@gmail.com 5 | description = Home Assistant Python API 6 | long_description = file: README.md 7 | long_description_content_type = text/markdown 8 | url = https://github.com/hass-api/hassapi 9 | project_urls = 10 | Bug Tracker = https://github.com/hass-api/hassapi/issues 11 | classifiers = 12 | Development Status :: 5 - Production/Stable 13 | Intended Audience :: Developers 14 | License :: OSI Approved :: MIT License 15 | Operating System :: OS Independent 16 | Programming Language :: Python :: 3.6 17 | Programming Language :: Python :: 3.7 18 | Programming Language :: Python :: 3.8 19 | Programming Language :: Python :: 3.9 20 | Programming Language :: Python :: 3.10 21 | Programming Language :: Python :: 3.11 22 | Topic :: Home Automation 23 | 24 | [options] 25 | package_dir= 26 | =src 27 | packages = find: 28 | python_requires = >=3.6 29 | install_requires = 30 | requests 31 | 32 | [options.packages.find] 33 | where=src 34 | 35 | [options.extras_require] 36 | test = pytest 37 | 38 | [coverage:run] 39 | branch = true 40 | parallel = true 41 | 42 | [coverage:report] 43 | skip_covered = true 44 | show_missing = true 45 | sort = Cover 46 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Handle module packaging.""" 2 | 3 | import re 4 | import subprocess 5 | import textwrap 6 | 7 | from setuptools import setup 8 | 9 | RE_VERSION = re.compile(r"(\d+\.\d+\.\d+)") 10 | 11 | 12 | def _get_release_version() -> str: 13 | """Get git release tag version.""" 14 | version_match = RE_VERSION.search( 15 | subprocess.run(["git", "describe", "--tags"], stdout=subprocess.PIPE) 16 | .stdout.decode("utf-8") 17 | .strip() 18 | ) 19 | version = version_match[0] if version_match else "0.0.0" 20 | print("Release version: ", version) 21 | return version 22 | 23 | 24 | def _write_version(version: str) -> None: 25 | """Write version to version.py.""" 26 | with open("src/hassapi/version.py", "w") as file: 27 | file.write( 28 | textwrap.dedent( 29 | f''' 30 | """Host package version, generated on build.""" 31 | __version__ = "{version}" 32 | ''' 33 | ).lstrip() 34 | ) 35 | 36 | 37 | version = _get_release_version() 38 | _write_version(version) 39 | setup(version=version) -------------------------------------------------------------------------------- /src/hassapi/__init__.py: -------------------------------------------------------------------------------- 1 | """Home Assistant Python API Client.""" 2 | 3 | # flake8: noqa 4 | from .client import Hass 5 | -------------------------------------------------------------------------------- /src/hassapi/__main__.py: -------------------------------------------------------------------------------- 1 | """HASS API main file.""" 2 | -------------------------------------------------------------------------------- /src/hassapi/client/__init__.py: -------------------------------------------------------------------------------- 1 | """Home Assistant API Client.""" 2 | 3 | from .events import EventsClient 4 | from .services import ServicesClient 5 | from .states import StatesClient 6 | from .template import TemplateClient 7 | 8 | 9 | class Hass(StatesClient, ServicesClient, TemplateClient, EventsClient): 10 | """Home Assistant API Client.""" 11 | -------------------------------------------------------------------------------- /src/hassapi/client/auth.py: -------------------------------------------------------------------------------- 1 | """Class for HASS API authentication.""" 2 | 3 | 4 | import os 5 | from typing import Dict, Optional 6 | 7 | 8 | class AuthenticatedClient: 9 | """Class for HASS API authentication.""" 10 | 11 | def __init__( 12 | self, hassurl: Optional[str] = None, token: Optional[str] = None, verify: bool = True 13 | ): 14 | """Create Authenticated client.""" 15 | self._url = self._resolve_api_url(hassurl or os.environ["HASS_URL"]) 16 | self._headers = self._get_headers(token or os.environ["HASS_TOKEN"]) 17 | self._verify = verify 18 | 19 | def _resolve_api_url(self, hassurl: str) -> str: 20 | """Resolve if needed and get full API url.""" 21 | hassurl = hassurl.strip("/") 22 | if hassurl.endswith("api"): 23 | return hassurl 24 | return f"{hassurl}/api" 25 | 26 | def _get_headers(self, token: str) -> Dict: 27 | """Get request headers.""" 28 | return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} 29 | -------------------------------------------------------------------------------- /src/hassapi/client/base.py: -------------------------------------------------------------------------------- 1 | """Class for basic API client functionality.""" 2 | 3 | import json 4 | from typing import Dict, List, Optional, Union 5 | 6 | import requests 7 | 8 | from hassapi.exceptions import ClientError, get_error 9 | 10 | from .auth import AuthenticatedClient 11 | 12 | JsonResponseType = Union[Dict, List, str] 13 | HassValueType = Union[int, float, str, bool] 14 | HassDictType = Dict[str, HassValueType] 15 | 16 | 17 | class BaseClient(AuthenticatedClient): 18 | """Class for basic API client functionality.""" 19 | 20 | def __init__( 21 | self, 22 | hassurl: Optional[str] = None, 23 | token: Optional[str] = None, 24 | verify: bool = True, 25 | timeout: float = 3, 26 | ): 27 | """Create Base Client object. 28 | 29 | Args: 30 | hassurl: Home Assistant url e.g. http://localhost:8123 31 | token: Home Assistant token 32 | verify: True or False verify secure connection 33 | """ 34 | super().__init__(hassurl, token, verify) 35 | self._timeout = timeout 36 | self._assert_api_running() 37 | 38 | def _assert_api_running(self) -> None: 39 | """Raise error if HASS API is not running.""" 40 | if not self._api_is_running(): 41 | raise ClientError("Home Assistant API is not running.") 42 | 43 | def _api_is_running(self) -> bool: 44 | """Check if Home Assistant API is running.""" 45 | try: 46 | return self._get("/")["message"] == "API running." # type: ignore 47 | except requests.exceptions.ConnectionError: 48 | return False 49 | 50 | def _get( 51 | self, endpoint: str, params: Optional[Dict] = None, **kwargs: HassValueType 52 | ) -> JsonResponseType: 53 | """Send GET request to HASS API `endpoint`.""" 54 | return self._process_response( 55 | requests.get( 56 | url=self._get_url(endpoint), 57 | headers=self._headers, 58 | timeout=self._timeout, 59 | params={**(params or {}), **kwargs} or None, 60 | verify=self._verify, 61 | ) 62 | ) 63 | 64 | def _post( 65 | self, endpoint: str, json: Optional[Dict] = None, **kwargs: HassValueType 66 | ) -> JsonResponseType: 67 | """Send POST request to home HASS API `endpoint`.""" 68 | return self._process_response( 69 | requests.post( 70 | url=self._get_url(endpoint), 71 | headers=self._headers, 72 | timeout=self._timeout, 73 | json={**(json or {}), **kwargs} or None, 74 | verify=self._verify, 75 | ) 76 | ) 77 | 78 | def _get_url(self, endpoint: str) -> str: 79 | """Get full endpoint url.""" 80 | return f"{self._url}/{endpoint.strip('/')}" 81 | 82 | def _process_response(self, response: requests.Response) -> JsonResponseType: # type: ignore 83 | """Validate response status and return response dict if ok.""" 84 | if response.ok: 85 | try: 86 | return response.json() # type: ignore 87 | except json.JSONDecodeError: 88 | return response.text 89 | else: 90 | self._raise_error(response.status_code, response.url) 91 | 92 | def _raise_error(self, status_code: int, url: str) -> None: 93 | """Raise custom error with description.""" 94 | error = get_error(status_code) 95 | raise error(f"{status_code} status code returned from {url}",) # type: ignore 96 | -------------------------------------------------------------------------------- /src/hassapi/client/events.py: -------------------------------------------------------------------------------- 1 | """Client for functionality related to HASS events.""" 2 | 3 | from typing import Dict, Optional 4 | 5 | from hassapi.models import EventList 6 | 7 | from .base import BaseClient 8 | 9 | 10 | class EventsClient(BaseClient): 11 | """Events Client.""" 12 | 13 | def get_events(self) -> EventList: 14 | """Get list of HASS events.""" 15 | return EventList(self._get("events")) 16 | 17 | def fire_event(self, event_type: str, event_data: Optional[Dict] = None) -> str: 18 | """Fire HASS event.""" 19 | return self._post(f"events/{event_type}", json=event_data)["message"] # type: ignore 20 | -------------------------------------------------------------------------------- /src/hassapi/client/services.py: -------------------------------------------------------------------------------- 1 | """Client for functionality related to calling HASS services.""" 2 | 3 | from typing import Union 4 | 5 | from hassapi.models import ServiceList, StateList 6 | 7 | from .base import BaseClient, HassValueType 8 | 9 | 10 | class ServicesClient(BaseClient): 11 | """Services Client.""" 12 | 13 | def get_services(self) -> ServiceList: 14 | """Get all available HASS services.""" 15 | return ServiceList(self._get("services")) 16 | 17 | def call_service(self, service: str, entity_id: str, **kwargs: HassValueType) -> StateList: 18 | """Call Home Assistant Service. 19 | 20 | Args: 21 | service: HASS service e.g. turn_on, toggle 22 | entity_id: HASS entity e.g. light.living_room 23 | kwargs: additional service data 24 | """ 25 | domain = entity_id.split(".")[0] 26 | 27 | return StateList( 28 | self._post( 29 | endpoint=f"/services/{domain}/{service}", 30 | entity_id=entity_id, 31 | **kwargs, # type: ignore 32 | ) 33 | ) 34 | 35 | def turn_on(self, entity_id: str) -> StateList: 36 | """Call 'turn_on' service for `entity_id`.""" 37 | return self.call_service("turn_on", entity_id=entity_id) 38 | 39 | def turn_off(self, entity_id: str) -> StateList: 40 | """Call 'turn_off' service for `entity_id`.""" 41 | return self.call_service("turn_off", entity_id=entity_id) 42 | 43 | def toggle(self, entity_id: str) -> StateList: 44 | """Call 'toggle' service for `entity_id`.""" 45 | return self.call_service("toggle", entity_id=entity_id) 46 | 47 | def select_option(self, entity_id: str, option: str) -> StateList: 48 | """Call 'select_option' service for `entity_id`.""" 49 | return self.call_service("select_option", entity_id=entity_id, option=option) 50 | 51 | def set_value(self, entity_id: str, value: Union[int, float, str]) -> StateList: 52 | """Call 'set_value' service for `entity_id`.""" 53 | return self.call_service("set_value", entity_id=entity_id, value=value) 54 | 55 | def open_cover(self, entity_id: str) -> StateList: 56 | """Call 'open_cover' service for `entity_id`.""" 57 | return self.call_service("open_cover", entity_id=entity_id) 58 | 59 | def close_cover(self, entity_id: str) -> StateList: 60 | """Call 'close_cover' service for `entity_id`.""" 61 | return self.call_service("close_cover", entity_id=entity_id) 62 | 63 | def set_cover_position(self, entity_id: str, position: int) -> StateList: 64 | """Call 'set_cover_position' service for `entity_id`.""" 65 | return self.call_service("set_cover_position", entity_id=entity_id, position=position) 66 | 67 | def run_script(self, script_id: str) -> StateList: 68 | """Run HASS-defined script.""" 69 | return StateList(self._post(f"/services/script/{script_id}")) 70 | 71 | def run_shell_command(self, command: str) -> StateList: 72 | """Run HASS-defined shell command.""" 73 | return StateList(self._post(f"/services/shell_command/{command}")) 74 | -------------------------------------------------------------------------------- /src/hassapi/client/states.py: -------------------------------------------------------------------------------- 1 | """Client for functionality related to HASS states.""" 2 | 3 | from typing import Optional 4 | 5 | from hassapi.models import State, StateList 6 | 7 | from .base import BaseClient, HassDictType, HassValueType 8 | 9 | 10 | class StatesClient(BaseClient): 11 | """States Client.""" 12 | 13 | def get_state(self, entity_id: str) -> State: 14 | """Get ``entity_id`` state.""" 15 | return State(**self._get(f"states/{entity_id}")) # type: ignore 16 | 17 | def set_state( 18 | self, entity_id: str, state: HassValueType, attributes: Optional[HassDictType] = None 19 | ) -> State: 20 | """Set ``entity_id`` state and optionally attributes.""" 21 | if attributes: 22 | return State(**self._post(f"states/{entity_id}", state=state, attributes=attributes)) # type: ignore 23 | return State(**self._post(f"states/{entity_id}", state=state)) # type: ignore 24 | 25 | def get_states(self) -> StateList: 26 | """Get states of all entities.""" 27 | return StateList(self._get("states")) 28 | -------------------------------------------------------------------------------- /src/hassapi/client/template.py: -------------------------------------------------------------------------------- 1 | """Client for functionality related to HASS templates.""" 2 | 3 | from .base import BaseClient 4 | 5 | 6 | class TemplateClient(BaseClient): 7 | """Template Client.""" 8 | 9 | def render_template(self, template: str) -> str: 10 | """Render Jinja2 template.""" 11 | return self._post("template", template=template) # type: ignore 12 | -------------------------------------------------------------------------------- /src/hassapi/const.py: -------------------------------------------------------------------------------- 1 | """Host constant variables.""" 2 | 3 | DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z" 4 | -------------------------------------------------------------------------------- /src/hassapi/exceptions.py: -------------------------------------------------------------------------------- 1 | """Host custom module exceptions.""" 2 | 3 | 4 | class HassapiBaseException(Exception): 5 | """Base HASS API Exception.""" 6 | 7 | 8 | class ClientError(HassapiBaseException): 9 | """HASS API Client Exception.""" 10 | 11 | 12 | class BadRequest(ClientError): 13 | """400 Bad Request Error.""" 14 | 15 | 16 | class Unauthorised(ClientError): 17 | """401 Unauthorised Error.""" 18 | 19 | 20 | class Forbidden(ClientError): 21 | """403 Forbidden Error.""" 22 | 23 | 24 | class NotFound(ClientError): 25 | """404 Not Found Error.""" 26 | 27 | 28 | class MethodNotAllowed(ClientError): 29 | """405 Method Not Allowed Error.""" 30 | 31 | 32 | class TooManyRequests(ClientError): 33 | """429 Too Many Requests Error.""" 34 | 35 | 36 | class InternalServerError(ClientError): 37 | """500 Internal Server Error.""" 38 | 39 | 40 | class BadGateway(ClientError): 41 | """502 BadGateway Error.""" 42 | 43 | 44 | class ServiceUnavailable(ClientError): 45 | """503 Service Unavailable Error.""" 46 | 47 | 48 | class ModelError(HassapiBaseException): 49 | """HASS API Model Exception.""" 50 | 51 | 52 | _errors = { 53 | 400: BadRequest, 54 | 401: Unauthorised, 55 | 403: Forbidden, 56 | 404: NotFound, 57 | 405: MethodNotAllowed, 58 | 429: TooManyRequests, 59 | 500: InternalServerError, 60 | 502: BadGateway, 61 | 503: ServiceUnavailable, 62 | } 63 | 64 | 65 | def get_error(status_code: int) -> HassapiBaseException: 66 | """Get error by HTTP response status code.""" 67 | return _errors.get(status_code, HassapiBaseException) # type: ignore 68 | -------------------------------------------------------------------------------- /src/hassapi/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Host Data Models.""" 2 | 3 | from .event import Event, EventList 4 | from .service import Service, ServiceList 5 | from .state import State, StateList 6 | 7 | __all__ = ["Event", "EventList", "Service", "ServiceList", "State", "StateList"] 8 | -------------------------------------------------------------------------------- /src/hassapi/models/base.py: -------------------------------------------------------------------------------- 1 | """Base Model classes.""" 2 | 3 | from dataclasses import asdict 4 | from typing import Dict, List 5 | 6 | 7 | class Model: 8 | """Base Model with repr() method.""" 9 | 10 | def __repr__(self) -> str: 11 | """Get object repr().""" 12 | name = type(self).__name__ 13 | return f"{name} with fields:\n" f"{_repr_dict(asdict(self))}" 14 | 15 | 16 | class ModelList(List): 17 | """Base List of Model with repr() method.""" 18 | 19 | def __repr__(self) -> str: 20 | """Get object repr().""" 21 | name = type(self).__name__ 22 | items = [f"{name} with items: ["] 23 | 24 | for model in self: 25 | item = repr(model) 26 | items.append(f"- {item}\n\n") 27 | 28 | return "\n".join(items + ["]"]) 29 | 30 | 31 | def _repr_dict(d: Dict, indent: int = 4) -> str: 32 | """Get dictionary string representation.""" 33 | lines = [] 34 | tab = " " * indent 35 | 36 | for key, value in d.items(): 37 | name = f"{tab}{key}: " 38 | if isinstance(value, dict): 39 | lines.append(name) 40 | lines.append(_repr_dict(value, indent * 2)) 41 | else: 42 | lines.append(f"{name}{value}") 43 | 44 | return "\n".join(lines) 45 | -------------------------------------------------------------------------------- /src/hassapi/models/event.py: -------------------------------------------------------------------------------- 1 | """Data Model for Service Object.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Iterable 5 | 6 | from .base import Model, ModelList 7 | 8 | 9 | @dataclass(repr=False) 10 | class Event(Model): 11 | """Representation of HASS Event JSON-object.""" 12 | 13 | event: str 14 | listener_count: int 15 | 16 | 17 | class EventList(ModelList): 18 | """List of HASS Event objects.""" 19 | 20 | def __init__(self, services: Iterable = ()): 21 | """Init EventList.""" 22 | super().__init__(Event(**s) for s in services) 23 | -------------------------------------------------------------------------------- /src/hassapi/models/service.py: -------------------------------------------------------------------------------- 1 | """Data Model for Service Object.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Dict, Iterable 5 | 6 | from .base import Model, ModelList 7 | 8 | 9 | @dataclass(repr=False) 10 | class Service(Model): 11 | """Representation of HASS Service JSON-object.""" 12 | 13 | domain: str 14 | services: Dict 15 | 16 | 17 | class ServiceList(ModelList): 18 | """List of HASS Service objects.""" 19 | 20 | def __init__(self, services: Iterable = ()): 21 | """Init ServiceList.""" 22 | super().__init__(Service(**s) for s in services) 23 | -------------------------------------------------------------------------------- /src/hassapi/models/state.py: -------------------------------------------------------------------------------- 1 | """Data Model for State Object.""" 2 | 3 | from dataclasses import dataclass, field 4 | from datetime import datetime 5 | from typing import Dict, Iterable, Optional 6 | 7 | from hassapi.const import DATE_FORMAT 8 | 9 | from .base import Model, ModelList 10 | 11 | 12 | @dataclass(repr=False) 13 | class Context(Model): 14 | """Representation of HASS Context JSON-object.""" 15 | 16 | id: str 17 | parent_id: Optional[str] 18 | user_id: Optional[str] 19 | 20 | 21 | @dataclass(repr=False) 22 | class State(Model): 23 | """Representation of HASS State JSON-object.""" 24 | 25 | entity_id: str 26 | state: str 27 | attributes: Dict 28 | context: Context 29 | last_changed: datetime 30 | last_updated: Optional[datetime] = field(default=None) 31 | last_reported: Optional[datetime] = field(default=None) 32 | 33 | def __post_init__(self) -> None: 34 | """Cast some attributes into more convenient types.""" 35 | self.context = Context(**self.context) # type: ignore 36 | self.last_changed = datetime.strptime(self.last_changed, DATE_FORMAT) # type: ignore 37 | if self.last_updated: 38 | self.last_updated = datetime.strptime(self.last_updated, DATE_FORMAT) # type: ignore 39 | if self.last_reported: 40 | self.last_reported = datetime.strptime(self.last_reported, DATE_FORMAT) # type: ignore 41 | 42 | 43 | class StateList(ModelList): 44 | """List of HASS State objects.""" 45 | 46 | def __init__(self, states: Iterable = ()): 47 | """Init StateList.""" 48 | super().__init__(State(**s) for s in states) 49 | -------------------------------------------------------------------------------- /tests/client/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for client subpackage.""" 2 | -------------------------------------------------------------------------------- /tests/client/test_auth.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from hassapi.client.auth import AuthenticatedClient 6 | 7 | 8 | @patch("hassapi.client.auth.AuthenticatedClient._get_headers", return_value={"a": "b"}) 9 | @patch("hassapi.client.auth.AuthenticatedClient._resolve_api_url", return_value="MOCK URL") 10 | def test_base_client_init(mock_get_headers, mock_resolve_api_url): 11 | client = AuthenticatedClient(hassurl="MOCK URL", token="MOCK TOKEN") 12 | assert client._url == "MOCK URL" 13 | assert client._headers == {"a": "b"} 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "given, expected", 18 | [ 19 | ("ADDRESS:8123/", "ADDRESS:8123/api"), 20 | ("ADDRESS:8123/api/", "ADDRESS:8123/api"), 21 | ("ADDRESS:8123/api", "ADDRESS:8123/api"), 22 | ], 23 | ) 24 | def test__resolve_api_url(given, expected): 25 | assert AuthenticatedClient()._resolve_api_url(given) == expected 26 | 27 | 28 | def test_get_headers(*args): 29 | assert AuthenticatedClient()._get_headers("MOCK TOKEN") == { 30 | "Authorization": f"Bearer MOCK TOKEN", 31 | "Content-Type": "application/json", 32 | } 33 | -------------------------------------------------------------------------------- /tests/client/test_base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from hassapi.client.base import BaseClient 7 | from hassapi.exceptions import ClientError 8 | 9 | 10 | class MockResponse: 11 | 12 | url = "" 13 | 14 | def __init__(self, return_value={}, status_code=200): 15 | self.return_value = return_value 16 | self.status_code = status_code 17 | 18 | @property 19 | def ok(self): 20 | return self.status_code < 400 21 | 22 | def json(self): 23 | return self.return_value 24 | 25 | 26 | class MockError(Exception): 27 | """Mock Error for testing.""" 28 | 29 | 30 | @patch("hassapi.client.base.BaseClient._assert_api_running") 31 | @patch("hassapi.client.base.AuthenticatedClient.__init__") 32 | def test_base_client_init(mock_auth_client_init, mock_assert_api_running): 33 | assert BaseClient(timeout=2)._timeout == 2 34 | mock_auth_client_init.assert_called_once() 35 | mock_assert_api_running.assert_called_once() 36 | 37 | 38 | @patch("hassapi.client.base.BaseClient.__init__", return_value=None) 39 | @patch("hassapi.client.base.BaseClient._api_is_running", return_value=False) 40 | def test_assert_api_running(mock_api_is_running, *args): 41 | with pytest.raises(ClientError): 42 | BaseClient()._assert_api_running() 43 | 44 | 45 | @patch("hassapi.client.base.BaseClient.__init__", return_value=None) 46 | @patch("hassapi.client.base.BaseClient._get", return_value={"message": "API running."}) 47 | def test_api_is_running(mock_get, *args): 48 | assert BaseClient()._api_is_running() 49 | 50 | 51 | @patch("hassapi.client.base.BaseClient.__init__", return_value=None) 52 | @patch("hassapi.client.base.BaseClient._get", return_value={"message": ""}) 53 | def test_api_is_not_running(mock_get, *args): 54 | assert not BaseClient()._api_is_running() 55 | 56 | 57 | @patch("hassapi.client.base.BaseClient._assert_api_running") 58 | @patch("hassapi.client.base.BaseClient._process_response", return_value={"a": "b"}) 59 | @patch("hassapi.client.base.requests.get") 60 | @patch("hassapi.client.base.BaseClient._get_url") 61 | def test_base_client_get(*args): 62 | assert {"a": "b"} == BaseClient()._get("some endpoint") 63 | for mock in args: 64 | mock.assert_called_once() 65 | 66 | 67 | @patch("hassapi.client.base.BaseClient._assert_api_running") 68 | @patch("hassapi.client.base.BaseClient._process_response", return_value={"a": "b"}) 69 | @patch("hassapi.client.base.requests.post") 70 | @patch("hassapi.client.base.BaseClient._get_url") 71 | def test_base_client_post(*args): 72 | assert {"a": "b"} == BaseClient()._post("some endpoint") 73 | for mock in args: 74 | mock.assert_called_once() 75 | 76 | 77 | @patch("hassapi.client.base.BaseClient._assert_api_running") 78 | def test_get_url(*args): 79 | assert BaseClient(hassurl="URL")._get_url("/ENDPOINT") == "URL/api/ENDPOINT" 80 | 81 | 82 | @patch("hassapi.client.base.BaseClient.__init__", return_value=None) 83 | def test_process_response_ok(*args): 84 | response = MockResponse({"a": "b"}, status_code=200) 85 | assert BaseClient()._process_response(response) == {"a": "b"} 86 | 87 | 88 | @patch("hassapi.client.base.BaseClient.__init__", return_value=None) 89 | @patch("hassapi.client.base.BaseClient._raise_error") 90 | def test_process_response_error(mock_raise_error, *args): 91 | response = MockResponse({"a": "b"}, status_code=400) 92 | BaseClient()._process_response(response) 93 | mock_raise_error.assert_called_once() 94 | 95 | 96 | @patch("hassapi.client.base.BaseClient.__init__", return_value=None) 97 | @patch("hassapi.client.base.get_error", return_value=MockError) 98 | def test_raise_error(*args): 99 | client = BaseClient() 100 | with pytest.raises(MockError): 101 | client._raise_error(status_code=666, url="") 102 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for models subpackage.""" 2 | -------------------------------------------------------------------------------- /tests/models/test_base.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from hassapi.models import base 6 | 7 | 8 | class DummyModel(base.Model): 9 | pass 10 | 11 | 12 | class DummyModelList(base.ModelList): 13 | def __init__(self): 14 | super().__init__(("a", "b")) 15 | 16 | 17 | @patch("hassapi.models.base.asdict") 18 | @patch("hassapi.models.base._repr_dict", return_value="dummy field repr") 19 | def test_model_repr(mock_repr_dict, mock_asdict): 20 | assert repr(DummyModel()) == "DummyModel with fields:\ndummy field repr" 21 | 22 | 23 | def test_model_list_repr(): 24 | assert repr(DummyModelList()) == "DummyModelList with items: [\n- 'a'\n\n\n- 'b'\n\n\n]" 25 | 26 | 27 | def test_repr_dict(): 28 | input_dict = {"a": "b", "c": {"d": "e", "f": {"g": "h"}}} 29 | expected_lines = ( 30 | " a: b", 31 | " c: ", 32 | " d: e", 33 | " f: ", 34 | " g: h", 35 | ) 36 | assert base._repr_dict(input_dict) == "\n".join(expected_lines) 37 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | minversion = 3.22.0 4 | envlist = 5 | pytest 6 | flake8 7 | format-check 8 | mypy 9 | 10 | [testenv] 11 | basepython = python3 12 | deps = 13 | pytest: coverage 14 | pytest: pytest 15 | flake8: flake8 >= 3.8.0, <4 16 | flake8: flake8-docstrings >= 1.5.0, <2 17 | flake8: pep8-naming >= 0.10.0, <1 18 | flake8: flake8-colors >= 0.1.6, <1 19 | flake8: pydocstyle == 5.0.2 20 | mypy: mypy == 0.812 21 | commands = 22 | pytest: pytest --verbose 23 | mypy: mypy --no-incremental src/hassapi 24 | setenv = 25 | HASS_URL=X 26 | HASS_TOKEN=X 27 | 28 | [testenv:flake8] 29 | skip_install = true 30 | commands = 31 | flake8 --max-line-length 99 src/hassapi setup.py 32 | 33 | [testenv:format] 34 | basepython = python3 35 | description = format source code 36 | deps = black == 19.10b0 37 | click == 8.0.2 38 | isort[pyproject] == 4.3.21 39 | seed-isort-config >= 1.2.0 40 | extras = 41 | skip_install = true 42 | commands = 43 | - seed-isort-config --application-directories src,tests 44 | black src tests setup.py 45 | isort -rc src tests setup.py 46 | 47 | [testenv:format-check] 48 | basepython = python3 49 | description = check that the source code is well formatted 50 | deps = {[testenv:format]deps} 51 | skip_install = {[testenv:format]skip_install} 52 | extras = {[testenv:format]extras} 53 | commands = 54 | seed-isort-config --application-directories src,tests 55 | black --diff --check src tests setup.py 56 | isort --diff -rc --check-only src tests setup.py 57 | 58 | [isort] 59 | not_skip = __init__.py 60 | multi_line_output = 3 61 | include_trailing_comma = True 62 | force_grid_wrap = 0 63 | line_length = 99 64 | known_first_party = hassapi 65 | known_third_party = pytest,requests,setuptools 66 | 67 | [covrage:run] 68 | source = 69 | src/hassapi 70 | 71 | [coverage:report] 72 | exclude_lines = 73 | pragma: no cover 74 | --------------------------------------------------------------------------------