diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml index 8fe5df7..edb3b81 100644 --- a/.github/workflows/hassfest.yaml +++ b/.github/workflows/hassfest.yaml @@ -10,5 +10,5 @@ jobs: validate: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v4" + - uses: "actions/checkout@v7" - uses: home-assistant/actions/hassfest@master diff --git a/.github/workflows/pythonpackage.yaml b/.github/workflows/pythonpackage.yaml index 9f1535e..d34e4c1 100644 --- a/.github/workflows/pythonpackage.yaml +++ b/.github/workflows/pythonpackage.yaml @@ -11,12 +11,12 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.10"] + python-version: ["3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db1b761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Python bytecode +__pycache__/ +*.py[cod] + +# Test and coverage output +.coverage +.coverage.* +htmlcov/ +coverage.xml +.pytest_cache/ + +# Tooling caches +.mypy_cache/ + +# Local environments +.python-version +.venv/ +venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3c3268e..3a65535 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,55 +1,55 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.7.4 + rev: v3.22.0 hooks: - id: pyupgrade - args: [--py37-plus] + args: [--py314-plus] - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 26.10.1 hooks: - id: black args: - --safe - --quiet - files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ + files: ^((custom_components|tests)/.+)?[^/]+\.py$ - repo: https://github.com/codespell-project/codespell - rev: v1.17.1 + rev: v2.4.3 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 + - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,thirdparty,ue,uint,visability,wan,wanna,withing - --skip="./.*,*.csv,*.json" - --quiet-level=2 exclude_types: [csv, json] - - repo: https://github.com/pycqa/flake8.git - rev: 3.9.2 + - repo: https://github.com/PyCQA/flake8 + rev: 7.4.1 hooks: - id: flake8 additional_dependencies: - - flake8-docstrings==1.5.0 - - pydocstyle==5.0.2 - files: ^(homeassistant|script|tests)/.+\.py$ + - flake8-docstrings==1.7.0 + - pydocstyle==6.3.0 + files: ^(custom_components|tests)/.+\.py$ - repo: https://github.com/PyCQA/bandit - rev: 1.6.2 + rev: 1.9.4 hooks: - id: bandit args: - --quiet - --format=custom - --configfile=tests/bandit.yaml - files: ^(homeassistant|script|tests)/.+\.py$ - - repo: https://github.com/pre-commit/mirrors-isort - rev: v5.6.4 + files: ^(custom_components|tests)/.+\.py$ + - repo: https://github.com/PyCQA/isort + rev: 9.0.2 hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.3.0 + rev: v6.0.0 hooks: - id: check-executables-have-shebangs stages: [manual] - id: check-json - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.790 + rev: v2.4.0 hooks: - id: mypy args: diff --git a/README.md b/README.md index 43d2c0d..1c7262a 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,13 @@ The tutorial walks through the steps to create a custom component for use in Hom An integration that monitors [GitHub](https://github.com/) repositories specified in a `configuration.yaml` file or optionally via the Integrations UI. +Requires Home Assistant 2024.11.0 or newer. + ## Running Tests -To run the test suite create a virtualenv (I recommend checking out [pyenv](https://github.com/pyenv/pyenv) and [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv) for this) and install the test requirements. +The test suite requires Python 3.14, the version required by current releases of Home Assistant. Create a virtualenv +(I recommend checking out [pyenv](https://github.com/pyenv/pyenv) and [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv) +for this) and install the test requirements. ```bash $ pip install -r requirements.test.txt @@ -34,26 +38,44 @@ the test suite. ```bash $ pytest -Test session starts (platform: linux, Python 3.7.5, pytest 5.4.3, pytest-sugar 0.9.4) -rootdir: /home/aaron/projects/github-custom, inifile: setup.cfg, testpaths: tests -plugins: forked-1.3.0, timeout-1.4.2, cov-2.10.1, aiohttp-0.3.0, requests-mock-1.8.0, xdist-2.1.0, sugar-0.9.4, test-groups-1.0.3, homeassistant-custom-component-0.0.20 -collecting ... - tests/test_config_flow.py ✓✓✓✓✓✓✓✓✓✓✓ 85% ████████▌ - tests/test_sensor.py ✓✓ 100% ██████████ - ------------ coverage: platform linux, python 3.7.5-final-0 ----------- +============================= test session starts ============================== +platform linux -- Python 3.14.8, pytest-9.0.3, pluggy-1.7.0 +rootdir: /home/aaron/projects/github-custom-component-tutorial +configfile: setup.cfg +testpaths: tests +plugins: codspeed-5.0.3, aiohttp-1.1.1, homeassistant-custom-component-0.13.374, xdist-3.8.0, asyncio-1.4.0, cov-7.1.0, anyio-4.15.1, timeout-2.4.0, pytest_freezer-0.4.9, respx-0.23.1, github-actions-annotate-failures-0.4.2, picked-0.5.1, requests-mock-1.12.1, socket-0.8.1, unordered-0.8.0, syrupy-6.1.1 +asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function +collected 14 items + +tests/test_config_flow.py ............ [ 85%] +tests/test_sensor.py .. [100%] + +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.8-final-0 ________________ + Name Stmts Miss Cover Missing ------------------------------------------------------------------------------ custom_components/__init__.py 0 0 100% -custom_components/github_custom/__init__.py 12 0 100% -custom_components/github_custom/config_flow.py 23 0 100% +custom_components/github_custom/__init__.py 24 1 96% 36 +custom_components/github_custom/config_flow.py 60 19 68% 49-50, 148-186 custom_components/github_custom/const.py 18 0 100% -custom_components/github_custom/sensor.py 97 5 95% 86-89, 121 +custom_components/github_custom/sensor.py 99 5 95% 72, 86-89 ------------------------------------------------------------------------------ -TOTAL 150 5 97% +TOTAL 201 25 88% +Required test coverage of 84.0% reached. Total coverage: 87.56% +============================== 14 passed in 0.41s ============================== +``` + +## Linting -Required test coverage of 93.0% reached. Total coverage: 96.67% +Linting and formatting checks are run with [pre-commit](https://pre-commit.com/). To run them against the whole repo: -Results (0.73s): - 13 passed +```bash +$ pre-commit run --all-files +``` + +To have them run automatically on every commit: + +```bash +$ pre-commit install ``` diff --git a/custom_components/__init__.py b/custom_components/__init__.py index e69de29..5ca6e19 100644 --- a/custom_components/__init__.py +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Custom components.""" diff --git a/custom_components/github_custom/__init__.py b/custom_components/github_custom/__init__.py index f2bf197..cf99d04 100644 --- a/custom_components/github_custom/__init__.py +++ b/custom_components/github_custom/__init__.py @@ -1,5 +1,5 @@ """GitHub Custom Component.""" -import asyncio + import logging from homeassistant import config_entries, core diff --git a/custom_components/github_custom/config_flow.py b/custom_components/github_custom/config_flow.py index a40aad5..26fe0a7 100644 --- a/custom_components/github_custom/config_flow.py +++ b/custom_components/github_custom/config_flow.py @@ -1,6 +1,8 @@ +"""Config flow for the GitHub Custom component.""" + from copy import deepcopy import logging -from typing import Any, Dict, Optional +from typing import Any from gidgethub import BadRequest from gidgethub.aiohttp import GitHubAPI @@ -34,7 +36,7 @@ async def validate_path(path: str, access_token: str, hass: core.HassJob) -> None: - """Validates a GitHub repo path. + """Validate a GitHub repo path. Raises a ValueError if the path is invalid. """ @@ -49,7 +51,7 @@ async def validate_path(path: str, access_token: str, hass: core.HassJob) -> Non async def validate_auth(access_token: str, hass: core.HomeAssistant) -> None: - """Validates a GitHub access token. + """Validate a GitHub access token. Raises a ValueError if the auth token is invalid. """ @@ -64,11 +66,11 @@ async def validate_auth(access_token: str, hass: core.HomeAssistant) -> None: class GithubCustomConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Github Custom config flow.""" - data: Optional[Dict[str, Any]] + data: dict[str, Any] | None - async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None): - """Invoked when a user initiates a flow via the user interface.""" - errors: Dict[str, str] = {} + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle a flow initiated by the user via the user interface.""" + errors: dict[str, str] = {} if user_input is not None: try: await validate_auth(user_input[CONF_ACCESS_TOKEN], self.hass) @@ -85,9 +87,9 @@ async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None): step_id="user", data_schema=AUTH_SCHEMA, errors=errors ) - async def async_step_repo(self, user_input: Optional[Dict[str, Any]] = None): + async def async_step_repo(self, user_input: dict[str, Any] | None = None): """Second step in config flow to add a repo to watch.""" - errors: Dict[str, str] = {} + errors: dict[str, str] = {} if user_input is not None: # Validate the path. try: @@ -121,20 +123,17 @@ async def async_step_repo(self, user_input: Optional[Dict[str, Any]] = None): @callback def async_get_options_flow(config_entry): """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(config_entries.OptionsFlow): """Handles options flow for the component.""" - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - self.config_entry = config_entry - async def async_step_init( - self, user_input: Dict[str, Any] = None - ) -> Dict[str, Any]: + self, user_input: dict[str, Any] = None + ) -> dict[str, Any]: """Manage the options for the custom component.""" - errors: Dict[str, str] = {} + errors: dict[str, str] = {} # Grab all configured repos from the entity registry so we can populate the # multi-select dropdown that will allow a user to remove a repo. entity_registry = async_get(self.hass) diff --git a/custom_components/github_custom/const.py b/custom_components/github_custom/const.py index 4837e42..a362d0a 100644 --- a/custom_components/github_custom/const.py +++ b/custom_components/github_custom/const.py @@ -1,3 +1,5 @@ +"""Constants for the GitHub Custom component.""" + DOMAIN = "github_custom" ATTR_CLONES = "clones" diff --git a/custom_components/github_custom/sensor.py b/custom_components/github_custom/sensor.py index 652b7fb..1b8622e 100644 --- a/custom_components/github_custom/sensor.py +++ b/custom_components/github_custom/sensor.py @@ -1,5 +1,4 @@ """GitHub sensor platform.""" -from __future__ import annotations from collections.abc import Callable from datetime import timedelta @@ -21,10 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.typing import ( - ConfigType, - DiscoveryInfoType, -) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import voluptuous as vol from .const import ( @@ -69,7 +65,7 @@ async def async_setup_entry( config_entry: config_entries.ConfigEntry, async_add_entities, ) -> None: - """Setup sensors from a config entry created in the integrations UI.""" + """Set up sensors from a config entry created in the integrations UI.""" config = hass.data[DOMAIN][config_entry.entry_id] # Update our config to include new repos and remove those that have been removed. if config_entry.options: @@ -97,6 +93,7 @@ class GitHubRepoSensor(Entity): """Representation of a GitHub Repo sensor.""" def __init__(self, github: GitHubAPI, repo: dict[str, str]): + """Initialize the sensor.""" super().__init__() self.github = github self.repo = repo["path"] @@ -122,10 +119,12 @@ def available(self) -> bool: @property def state(self) -> str | None: + """Return the state of the sensor.""" return self._state @property def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the sensor.""" return self.attrs async def async_update(self) -> None: diff --git a/hacs.json b/hacs.json index 891455c..8a24915 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,6 @@ { "name": "Github Custom", "render_readme": true, - "iot_class": "Cloud Polling" + "iot_class": "Cloud Polling", + "homeassistant": "2024.11.0" } diff --git a/requirements.test.txt b/requirements.test.txt index 4384da4..f8c91d5 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,10 +1,10 @@ # From our manifest.json for our custom component -gidgethub[aiohttp]==5.2.1 -cryptography==40.0.1 +gidgethub[aiohttp]==6.0.0 +# Pinned to match homeassistant's own pin +cryptography==50.0.1 -# Strictly for tests -coverage==7.2.1 -pytest==7.2.2 -pytest-asyncio==0.20.3 -pytest-cov==3.0.0 -pytest-homeassistant-custom-component==0.13.20 +# Strictly for tests. This also pins homeassistant, pytest, pytest-asyncio, +# pytest-cov and coverage to the versions HA itself tests with. +pytest-homeassistant-custom-component==0.13.374 +# Ubuntu 26.04 runners no longer ship legacy zones (e.g. US/Pacific) used by the HA test harness +tzdata diff --git a/setup.cfg b/setup.cfg index 9ce15c4..8762ac5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,6 +16,8 @@ testpaths = tests norecursedirs = .git addopts = --cov=custom_components +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function [flake8] # https://github.com/ambv/black#line-length @@ -51,7 +53,7 @@ forced_separate = tests combine_as_imports = true [mypy] -python_version = 3.7 +python_version = 3.14 ignore_errors = true follow_imports = silent ignore_missing_imports = true diff --git a/tests/conftest.py b/tests/conftest.py index 1d99892..99c94c8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ """pytest fixtures.""" + import pytest diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 50ad16c..839fd4f 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the config flow.""" + from unittest import mock from unittest.mock import AsyncMock, patch @@ -63,6 +64,7 @@ async def test_flow_user_init(hass): "flow_id": mock.ANY, "handler": "github_custom", "last_step": None, + "preview": None, "step_id": "user", "type": "form", } @@ -111,6 +113,7 @@ async def test_flow_repo_init_form(hass): "handler": "github_custom", "step_id": "repo", "last_step": None, + "preview": None, "type": "form", } assert expected == result @@ -178,6 +181,7 @@ async def test_flow_repo_creates_config_entry(m_github, hass): expected = { "context": {"source": "repo"}, "version": 1, + "minor_version": 1, "type": "create_entry", "flow_id": mock.ANY, "handler": "github_custom", @@ -192,6 +196,7 @@ async def test_flow_repo_creates_config_entry(m_github, hass): "description_placeholders": None, "options": {}, "result": mock.ANY, + "subentries": (), } assert expected == result diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 1e491f3..a1e1f4e 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,4 +1,5 @@ """Tests for the sensor module.""" + from unittest.mock import AsyncMock, MagicMock from gidgethub import GitHubException