diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..0ece9c1 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,22 @@ +name: pre-commit + +on: + push: + pull_request: + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10-dev' + - uses: pre-commit/action@v3.0.0 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.test.txt + - name: Run pytest + run: | + pytest diff --git a/.gitignore b/.gitignore index bcc0968..5d56f0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ **/.DS_Store -.history \ No newline at end of file +.history +.idea +.mypy_cache +__pycache__/ +.coverage \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e3d0634 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,49 @@ +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v3.2.2 + hooks: + - id: pyupgrade + args: [--py310-plus] + - repo: https://github.com/psf/black + rev: 22.10.0 + hooks: + - id: black + args: + - --safe + - --quiet + files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ + - repo: https://github.com/codespell-project/codespell + rev: v2.2.2 + hooks: + - id: codespell + args: + - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing + - --skip="./.*,*.csv,*.json" + - --quiet-level=2 + exclude_types: [csv, json] + - repo: https://github.com/PyCQA/flake8 + rev: 5.0.4 + hooks: + - id: flake8 + additional_dependencies: + - flake8-docstrings==1.5.0 + - pydocstyle==5.0.2 + files: ^(homeassistant|script|tests)/.+\.py$ + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-executables-have-shebangs + stages: [manual] + - id: check-json + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.991 + hooks: + - id: mypy + args: + - --pretty + - --show-error-codes + - --show-error-context diff --git a/README.md b/README.md index 5462159..1d73ecc 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,53 @@ You can add a unique id and custom attributes to each birthday, for instance to To do this, add a dictionary under the `attributes` key in the configuration (see example above). The dictionary can contain any key-value pairs you want, and will be exposed as attributes on the entity. Fetching the attributes can be done using `state_attr` in a template, for instance `{{ state_attr('birthdays.einstein', 'occupation') }}` will return `Theoretical physicist`. +### Templated attributes +Attributes to an entity can also be a template. To do calculations based on data from the entity, use the `this`-keyword. +Be aware that if a template that cannot be correctly parsed it can lead to the entity not being loaded, +so if your entity is suddenly gone after adding a templated attribute, please check the logs. + +Example calculating age in number of days: +```yaml +birthdays: + - name: 'Frodo Baggins' + date_of_birth: 1921-09-22 + attributes: + days_since_birth: '{{ ((as_timestamp(now()) - as_timestamp(this.date_of_birth)) | int /60/1440) | int }}' +``` + +Properties of `this` that can be used: +* name +* unique_id +* state +* icon +* date_of_birth +* unit_of_measurement + +Note: Don't use `this.extra_state_attributes`, as that might trigger an infinite loop. + +### Global attributes: +It is possible to add global attributes that will be added to all birthdays. Global attributes work just the same as other attributes, +and can thus also be templated. + +This example will add the attribute `days_since_birth` on all entities: +```yaml +# Example configuration.yaml entry +birthdays: + config: + attributes: + days_since_birth: '{{ ((as_timestamp(now()) - as_timestamp(this.date_of_birth)) | int /60/1440) | int }}' + birthdays: + - name: 'Frodo Baggins' + date_of_birth: 1921-09-22 + - name: 'Bilbo Baggins' + date_of_birth: 1843-09-22 + - name: Elvis + date_of_birth: 1935-01-08 + icon: 'mdi:music' +``` + +Note that global attributes will be overridden by entity specific attributes. + ## Automation All birthdays are updated at midnight, and when a birthday occurs an event is sent on the HA bus that can be used for automations. The event is called `birthday` and contains the data `name` and `age`. Note that there will be two events fired if two persons have the same birthday. diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..b30fdbe --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +""" Custom components module""" diff --git a/custom_components/birthdays/__init__.py b/custom_components/birthdays/__init__.py index 530d8c3..23fe187 100644 --- a/custom_components/birthdays/__init__.py +++ b/custom_components/birthdays/__init__.py @@ -1,15 +1,14 @@ import asyncio import logging -import async_timeout import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later -from homeassistant.util import dt as dt_util -from homeassistant.util import slugify +from homeassistant.helpers.template import Template, is_template_string, render_complex +from homeassistant.util import dt as dt_util, slugify _LOGGER = logging.getLogger(__name__) @@ -18,37 +17,65 @@ CONF_DATE_OF_BIRTH = 'date_of_birth' CONF_ICON = 'icon' CONF_ATTRIBUTES = 'attributes' +CONF_GLOBAL_CONFIG = 'config' +CONF_BIRTHDAYS = 'birthdays' CONF_AGE_AT_NEXT_BIRTHDAY = 'age_at_next_birthday' DOMAIN = 'birthdays' BIRTHDAY_CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Required(CONF_NAME) : cv.string, - vol.Required(CONF_DATE_OF_BIRTH) : cv.date, - vol.Optional(CONF_ICON, default = 'mdi:cake'): cv.string, - vol.Optional(CONF_ATTRIBUTES, default = {}) : vol.Schema({cv.string: cv.string}), + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_DATE_OF_BIRTH): cv.date, + vol.Optional(CONF_ICON, default='mdi:cake'): cv.string, + vol.Optional(CONF_ATTRIBUTES, default={}): vol.Schema({cv.string: cv.string}), }) -CONFIG_SCHEMA = vol.Schema({ +GLOBAL_CONFIG_SCHEMA = vol.Schema({ + vol.Optional(CONF_ATTRIBUTES, default={}): vol.Schema({cv.string: cv.string}), +}) + +# Old schema (list of birthday configurations) +OLD_CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.All(cv.ensure_list, [BIRTHDAY_CONFIG_SCHEMA]) }, extra=vol.ALLOW_EXTRA) -async def async_setup(hass, config): +# New schema (supports both global and birthday configs) +NEW_CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + CONF_BIRTHDAYS: vol.All(cv.ensure_list, [BIRTHDAY_CONFIG_SCHEMA]), + vol.Optional(CONF_GLOBAL_CONFIG, default={}): GLOBAL_CONFIG_SCHEMA + } +}, extra=vol.ALLOW_EXTRA) +# Use vol.Any() to support both old and new schemas +CONFIG_SCHEMA = vol.Schema(vol.Any( + OLD_CONFIG_SCHEMA, + NEW_CONFIG_SCHEMA +), extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): devices = [] - for birthday_data in config[DOMAIN]: + is_new_config = isinstance(config[DOMAIN], dict) and config[DOMAIN].get(CONF_BIRTHDAYS) is not None + birthdays = config[DOMAIN][CONF_BIRTHDAYS] if is_new_config else config[DOMAIN] + + for birthday_data in birthdays: unique_id = birthday_data.get(CONF_UNIQUE_ID) name = birthday_data[CONF_NAME] date_of_birth = birthday_data[CONF_DATE_OF_BIRTH] icon = birthday_data[CONF_ICON] attributes = birthday_data[CONF_ATTRIBUTES] + if is_new_config: + global_config = config[DOMAIN][CONF_GLOBAL_CONFIG] # Empty dict or has attributes + global_attributes = global_config.get(CONF_ATTRIBUTES) or {} + attributes = dict(global_attributes, **attributes) # Add global_attributes but let local attributes be on top + devices.append(BirthdayEntity(unique_id, name, date_of_birth, icon, attributes, hass)) component = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_add_entities(devices) - tasks = [asyncio.create_task(device.update_data()) for device in devices] await asyncio.wait(tasks) @@ -64,7 +91,7 @@ def __init__(self, unique_id, name, date_of_birth, icon, attributes, hass): if unique_id is not None: self._unique_id = slugify(unique_id) - else: + else: self._unique_id = slugify(name) self._state = None @@ -75,10 +102,15 @@ def __init__(self, unique_id, name, date_of_birth, icon, attributes, hass): self._extra_state_attributes = { CONF_DATE_OF_BIRTH: str(self._date_of_birth), } + self._templated_attributes = {} if len(attributes) > 0 and attributes is not None: - for k,v in attributes.items(): - self._extra_state_attributes[k] = v + for k, v in attributes.items(): + if is_template_string(v): + _LOGGER.info(f'{v} is a template and will be evaluated at runtime') + self._templated_attributes[k] = Template(template=v, hass=hass) + else: + self._extra_state_attributes[k] = v @property def name(self): @@ -103,8 +135,16 @@ def icon(self): @property def extra_state_attributes(self): + for key, templated_value in self._templated_attributes.items(): + value = render_complex(templated_value, variables={"this": self}) + self._extra_state_attributes[key] = value + return self._extra_state_attributes + @property + def date_of_birth(self): + return self._date_of_birth + @property def unit_of_measurement(self): return 'days' @@ -117,12 +157,12 @@ def _get_seconds_until_midnight(self): one_day_in_seconds = 24 * 60 * 60 now = dt_util.now() - total_seconds_passed_today = (now.hour*60*60) + (now.minute*60) + now.second + total_seconds_passed_today = (now.hour * 60 * 60) + (now.minute * 60) + now.second return one_day_in_seconds - total_seconds_passed_today async def update_data(self, *_): - from datetime import date, timedelta + from datetime import date today = dt_util.start_of_local_day().date() next_birthday = date(today.year, self._date_of_birth.month, self._date_of_birth.day) @@ -130,12 +170,11 @@ async def update_data(self, *_): if next_birthday < today: next_birthday = next_birthday.replace(year=today.year + 1) - days_until_next_birthday = (next_birthday-today).days + days_until_next_birthday = (next_birthday - today).days age = next_birthday.year - self._date_of_birth.year self._extra_state_attributes[CONF_AGE_AT_NEXT_BIRTHDAY] = age - self._state = days_until_next_birthday if days_until_next_birthday == 0: diff --git a/requirements.test.txt b/requirements.test.txt new file mode 100644 index 0000000..2e25a41 --- /dev/null +++ b/requirements.test.txt @@ -0,0 +1,14 @@ +# linters such as flake8 and pylint should be pinned, as new releases +# make new things fail. Manually update these pins when pulling in a +# new version + +aioresponses==0.7.2 +algoliasearch==2.6.2 +codecov==2.1.13 +coverage>=6.4.4 +mypy==0.991 +pytest>=7.1.0 +pytest-cov>=3.0.0 +pytest-mock>=3.10 +pytest-homeassistant-custom-component==0.12.20 +typing-extensions>=4.6.3 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2812af5 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,61 @@ +[coverage:run] +source = + custom_components + +[coverage:report] +exclude_lines = + pragma: no cover + raise NotImplemented() + if __name__ == '__main__': + main() +fail_under = 40 +show_missing = true + +[tool:pytest] +testpaths = tests +norecursedirs = .git +addopts = + --strict + --cov=custom_components + +[flake8] +# https://github.com/ambv/black#line-length +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +ignore = + E501, + W503, + E203, + D202, + W504 + +[isort] +# https://github.com/timothycrosley/isort +# https://github.com/timothycrosley/isort/wiki/isort-Settings +# splits long import on multiple lines indented by 4 spaces +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = homeassistant,tests +forced_separate = tests +combine_as_imports = true + +[mypy] +python_version = 3.10 +ignore_errors = true +follow_imports = silent +ignore_missing_imports = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..95cde89 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the birthdays component.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1d99892 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +"""pytest fixtures.""" +import pytest + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + """Enable custom integrations defined in the test dir.""" + yield diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..750bf36 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,53 @@ +"""Test component setup.""" +from custom_components.birthdays import ( + CONF_ATTRIBUTES, + CONF_BIRTHDAYS, + CONF_GLOBAL_CONFIG, + DOMAIN, +) +from homeassistant.setup import async_setup_component + + +async def test_async_setup__old_config_0_birthday_is_not_ok(hass): + """Cannot have 0 birthdays configured in old config.""" + config = {DOMAIN: []} + await _test_setup(hass, config, False) + + +async def test_async_setup__old_config_1_birthday_is_ok(hass): + """1 birthday is OK in old config.""" + config = {DOMAIN: [{"name": "HomeAssistant", "date_of_birth": "2013-09-17"}]} + await _test_setup(hass, config, True) + + +async def test_async_setup__new_config_0_birthday_is_not_ok(hass): + """Cannot have 0 birthdays configured in old config.""" + config = {DOMAIN: {CONF_BIRTHDAYS: []}} + await _test_setup(hass, config, False) + + +async def test_async_setup__new_config_1_birthday_is_ok(hass): + """1 birthday is OK in new config.""" + config = { + DOMAIN: { + CONF_BIRTHDAYS: [{"name": "HomeAssistant", "date_of_birth": "2013-09-17"}] + } + } + await _test_setup(hass, config, True) + + +async def test_async_setup__new_config_has_global_attributes(hass): + """Global attributes are allowed in schema.""" + name = "HomeAssistant" + config = { + DOMAIN: { + CONF_BIRTHDAYS: [{"name": name, "date_of_birth": "2013-09-17"}], + CONF_GLOBAL_CONFIG: {CONF_ATTRIBUTES: {"message": "Hello World!"}}, + } + } + + await _test_setup(hass, config, True) + + +async def _test_setup(hass, config: dict, expected_result: bool): + assert await async_setup_component(hass, DOMAIN, config) is expected_result