Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/scripts/import_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Import every module of the integration; fail if Home Assistant reports one.

The test suite checks the same, but it needs
pytest-homeassistant-custom-component, which pins one Home Assistant release
and follows a beta days later, if at all. This needs Home Assistant alone, so
the look at the next release runs it on the beta itself
(.github/workflows/ha-next.yml).

python .github/scripts/import_check.py
"""

from __future__ import annotations

import logging
import sys
import warnings
from pathlib import Path

REPO = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(REPO), str(REPO / "tests")]

from deprecations import INTEGRATION, REPORTS, import_every_module


def main() -> int:
"""Exit 1 when Home Assistant reported anything while importing."""
logging.getLogger().addHandler(REPORTS)
for category in (DeprecationWarning, PendingDeprecationWarning):
warnings.filterwarnings(
"error", category=category, module=rf"custom_components\.{INTEGRATION}"
)
import_every_module()
reported = [record.getMessage() for record in REPORTS.take()]
for message in reported:
print(message, file=sys.stderr)
return 1 if reported else 0


if __name__ == "__main__":
sys.exit(main())
95 changes: 95 additions & 0 deletions .github/workflows/ha-next.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
name: Next Home Assistant

# The CI jobs test the Home Assistant releases hacs.json and the plugin pins
# promise. This takes the newest release on PyPI, beta included - while a
# beta is out, one release ahead - so whatever it removes or newly reports as
# deprecated turns up while there is time to change it. Weekly and by hand,
# and on a pull request that changes the check or what it installs. Red here
# means that release, not this branch: a warning, not a merge gate. In a
# public repository GitHub disables a schedule after 60 days without
# activity; re-enable it in the Actions tab.
on:
schedule:
- cron: "23 4 * * 1"
workflow_dispatch:
pull_request:
paths:
- .github/workflows/ha-next.yml
- .github/scripts/import_check.py
- tests/conftest.py
- tests/deprecations.py
- pytest.ini
- requirements_tools.txt
- custom_components/neumann_kh/manifest.json

permissions:
contents: read

jobs:
next:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.14"
# The newest release of either, betas included, and whether the test
# harness pins exactly that Home Assistant. It follows a beta days late,
# if at all; without it only the import check can run.
- name: Find the newest Home Assistant and its test harness
id: versions
shell: python
run: |
import json, os, re, urllib.request

def pypi(name):
with urllib.request.urlopen(
f"https://pypi.org/pypi/{name}/json", timeout=30
) as answer:
return json.load(answer)

def order(version):
year, month, patch, beta = re.fullmatch(
r"(\d+)\.(\d+)\.(\d+)(?:b(\d+))?", version
).groups()
final = beta is None
return int(year), int(month), int(patch), final, int(beta or 0)

releases = pypi("homeassistant")["releases"]
newest = max(
(
version
for version, files in releases.items()
if re.fullmatch(r"\d+\.\d+\.\d+(b\d+)?", version)
and files
and not all(file["yanked"] for file in files)
),
key=order,
)
harness = pypi("pytest-homeassistant-custom-component")["info"]
pinned = f"homeassistant=={newest}" in (harness["requires_dist"] or [])
print(f"Home Assistant {newest}; test harness for it: {pinned}")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write(f"homeassistant={newest}\nharness={str(pinned).lower()}\n")
- name: Install it
env:
HOMEASSISTANT: ${{ steps.versions.outputs.homeassistant }}
HARNESS: ${{ steps.versions.outputs.harness }}
run: |
if [[ "$HARNESS" == true ]]; then
pip install pytest-homeassistant-custom-component "homeassistant==$HOMEASSISTANT" -r requirements_tools.txt
else
pip install "homeassistant==$HOMEASSISTANT"
fi
- name: Import every module
run: python .github/scripts/import_check.py
- name: Run the tests
if: steps.versions.outputs.harness == 'true'
run: python -m pytest tests/ -q -m ""
- name: Say what did not run
if: steps.versions.outputs.harness != 'true'
env:
HOMEASSISTANT: ${{ steps.versions.outputs.homeassistant }}
run: |
echo "::notice::No test harness for Home Assistant $HOMEASSISTANT yet: only the import check ran"
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,10 @@ timeout = 30
# e2e only : pytest tests/ -q -m e2e
# full run : pytest tests/ -q -m ""
addopts = -m "not e2e"
# A DeprecationWarning raised from this package fails the test; one inside
# Home Assistant or a library does not (tests/test_deprecations.py).
filterwarnings =
error::DeprecationWarning:custom_components\.neumann_kh
error::PendingDeprecationWarning:custom_components\.neumann_kh
markers =
e2e: slow test - full Home Assistant boot or nested pytest run
1 change: 1 addition & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ the thing it prevents actually happened here.
| `test_ci_parity.py` | CI runs the same check script a developer runs, against the advertised minimum version |
| `test_comment_narration.py` | No comment merely restates the code it sits on |
| `test_constant_owners.py` | No constant is defined in two modules |
| `test_deprecations.py` | Home Assistant's deprecation reports about the integration, and DeprecationWarnings from the package, fail the suite; probes produce each wording from Home Assistant itself |
| `test_device_writes.py` | Every button press claims the device before writing |
| `test_log_privacy.py` | No log call or raised message hands out a serial or an address in full |
| `test_mutation_harness.py` | The mutation run fails loudly instead of reporting a breakage it never applied |
Expand Down
64 changes: 63 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,76 @@
"""Shared test configuration.

Makes the repo root importable (`custom_components.neumann_kh...`)
regardless of the directory pytest is started from.
regardless of the directory pytest is started from, and fails a test during
which Home Assistant reported this integration (see deprecations.py).

The suite targets Linux (WSL2 or CI) and does not run on native Windows:
pytest-homeassistant-custom-component imports `homeassistant.runner` at
collection time, which imports the Unix-only `fcntl` module.
"""

from __future__ import annotations

import logging
import os
import sys
from collections.abc import Generator, Iterator

import pytest

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from deprecations import REPORTS, assert_listening, import_every_module

# Before any test module imports the integration: a deprecated constant is
# reported the moment a module imports it, while pytest collects.
logging.getLogger().addHandler(REPORTS)


@pytest.fixture(autouse=True)
def no_deprecation_reported() -> Iterator[None]:
"""Fail a test during which Home Assistant reported the integration.

What arrived before it fails it too: a deprecated constant reports while
the tests are collected, and the first test of a run is where that can
still fail something. Autouse, so it is set up before and torn down after
the hass fixture - Home Assistant reports while stopping as well.
"""
REPORTS.check("before this test, outside any test")
yield
REPORTS.check("during this test")


@pytest.hookimpl(wrapper=True)
def pytest_runtest_call() -> Generator[None, object, object]:
"""Fail a test that stopped the deprecation check hearing reports.

Checked right after the test body, while its fixtures still hold:
caplog puts the levels back at its own teardown.
"""
result = yield
assert_listening()
return result


def pytest_sessionfinish(session: pytest.Session) -> None:
"""Fail the run on what no test was there to see.

Reports after the last test's own check, such as from a session fixture
torn down with it, and deprecated constants in modules the selected tests
never imported, or that do not import at all. A run that already failed
is only told: its exit status says more about what went wrong, and
nothing is imported over a collection error.
"""
would_pass = session.exitstatus in (pytest.ExitCode.OK, pytest.ExitCode.NO_TESTS_COLLECTED)
problems = []
if would_pass:
try:
import_every_module()
except Exception as error: # noqa: BLE001 - any import failure fails the run
problems.append(f"importing the integration failed: {error!r}")
problems += [record.getMessage() for record in REPORTS.take()]
for problem in problems:
sys.stderr.write(f"Home Assistant reported after the last test: {problem}\n")
if problems and would_pass:
session.exitstatus = pytest.ExitCode.TESTS_FAILED
136 changes: 136 additions & 0 deletions tests/deprecations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Home Assistant's reports about this integration, collected to fail the tests.

Home Assistant reports a deprecated call by logging a warning and carrying
on, so a suite stays green on code that the release named in the warning
breaks for every user at once. This catches every wording of Home
Assistant's report helpers, helpers.frame and helpers.deprecation
(test_deprecations.py produces each from Home Assistant itself), on the
paths the tests run.

Not yet: the warnings other parts of Home Assistant log themselves and end
with "please report it to the custom integration author" (an entity without
a platform, an impossible state class, a duplicate unique ID).
"""

from __future__ import annotations

import importlib
import json
import logging
import pkgutil
import re
from pathlib import Path

PACKAGE = Path(__file__).parents[1] / "custom_components" / "neumann_kh"
# Read from the manifest, not imported: importing the integration is what
# this has to watch.
INTEGRATION = json.loads((PACKAGE / "manifest.json").read_text(encoding="utf-8"))["domain"]
_OWN_LOGGER = f"custom_components.{INTEGRATION}"
_DOMAIN = re.escape(INTEGRATION)
# A deprecation report arrives on the logger of the deprecated object's
# module, which can be any of Home Assistant's.
_HOME_ASSISTANT_LOGGER = "homeassistant"

# The wordings of helpers.frame and helpers.deprecation (Home Assistant
# 2026.10). A call from no integration is, in this suite, the tests or what
# they drive, so it counts as well. A call from another integration does not.
# Matched at the start of the message and never across a line: a traceback
# or an argument below may quote a path or a word, and is not the report.
_REPORT = re.compile(
"|".join(
(
rf"Detected that custom integration '{_DOMAIN}' ",
r"Detected code that ",
rf"The deprecated .+? was [^.\n]+? from {_DOMAIN}\.",
r"The deprecated .+? was (?:(?! from )[^.\n])+\.",
)
)
)
# deprecated_substitute and get_deprecated name nobody; they log under the
# module that used the old name, which is ours or someone else's.
_RENAMED = re.compile(r"'[^']+' is deprecated\. Please rename ")


def _within(name: str, parent: str) -> bool:
"""Whether a logger is parent or below it; a longer name's prefix is not."""
return name == parent or name.startswith(f"{parent}.")


def is_report(record: logging.LogRecord) -> bool:
"""Whether Home Assistant reports something this integration does."""
if record.levelno < logging.WARNING:
return False
message = record.getMessage()
if _REPORT.match(message):
return True
return bool(_RENAMED.match(message)) and _within(record.name, _OWN_LOGGER)


class Reports(logging.Handler):
"""The reports since the last take()."""

def __init__(self) -> None:
super().__init__(logging.WARNING)
self._pending: list[logging.LogRecord] = []

def emit(self, record: logging.LogRecord) -> None:
if is_report(record):
self._pending.append(record)

def take(self) -> list[logging.LogRecord]:
taken, self._pending = self._pending, []
return taken

def check(self, when: str) -> None:
"""Fail on what arrived since the last take()."""
reported = [record.getMessage() for record in self.take()]
assert not reported, f"Home Assistant reported {when}: {reported[:3]}"


REPORTS = Reports()


def _reaches_the_check(logger: logging.Logger) -> bool:
"""Whether a WARNING on this logger gets to the root logger's handlers."""
if logger.disabled or logger.getEffectiveLevel() > logging.WARNING:
return False
current: logging.Logger | None = logger
while current is not None and current is not logging.root:
if not current.propagate:
return False
current = current.parent
return True


def assert_listening() -> None:
"""Fail when the check can no longer hear Home Assistant's reports.

Asked once the test body has run: a level raised and restored within the
body (caplog.at_level) goes unseen.
"""
assert REPORTS in logging.root.handlers, "the deprecation check was removed"
assert logging.root.manager.disable < logging.WARNING, (
"logging.disable hides Home Assistant's reports from the deprecation check"
)
existing = [
logging.getLogger(_HOME_ASSISTANT_LOGGER),
logging.getLogger(_OWN_LOGGER),
*(
logger
for name, logger in logging.root.manager.loggerDict.items()
if isinstance(logger, logging.Logger)
and (_within(name, _HOME_ASSISTANT_LOGGER) or _within(name, _OWN_LOGGER))
),
]
deaf = sorted({logger.name for logger in existing if not _reaches_the_check(logger)})
assert not deaf, (
f"warnings on {deaf} no longer reach the deprecation check; "
"pass logger= to caplog.set_level"
)


def import_every_module() -> None:
"""Import the integration and every module in it, as Home Assistant may."""
package = importlib.import_module(f"custom_components.{INTEGRATION}")
for module in pkgutil.walk_packages(package.__path__, f"{package.__name__}."):
importlib.import_module(module.name)
Loading
Loading