From 1150c826123311b98b58377bb66bd0f73ab169ae Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:51:40 +0100 Subject: [PATCH 1/4] Test that Home Assistant's deprecation reports fail the suite --- tests/deprecations.py | 136 +++++++++++++ tests/test_deprecations.py | 400 +++++++++++++++++++++++++++++++++++++ 2 files changed, 536 insertions(+) create mode 100644 tests/deprecations.py create mode 100644 tests/test_deprecations.py diff --git a/tests/deprecations.py b/tests/deprecations.py new file mode 100644 index 0000000..e67be95 --- /dev/null +++ b/tests/deprecations.py @@ -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) diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py new file mode 100644 index 0000000..2e5f623 --- /dev/null +++ b/tests/test_deprecations.py @@ -0,0 +1,400 @@ +"""Guard: Home Assistant's reports about this integration fail the suite. + +Home Assistant reports a deprecated call by logging a warning and carrying on, +so a suite stays green until the release named in the warning breaks it for +every user at once. The probes below produce each wording from Home Assistant +itself, so a rewording turns this file red instead of the check going blind. +""" + +from __future__ import annotations + +import importlib +import logging +import os +import subprocess +import sys +import warnings +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace + +import pytest + +pytest.importorskip("pytest_homeassistant_custom_component.common") + +import conftest +from deprecations import INTEGRATION, REPORTS, assert_listening, import_every_module +from homeassistant.core import HomeAssistant +from homeassistant.helpers.deprecation import ( + deprecated_function, + deprecated_hass_argument, + deprecated_substitute, + get_deprecated, +) +from homeassistant.helpers.frame import ReportBehavior, report_usage + +# Where Home Assistant looks for the integration behind a call: the frame of a +# file under custom_components//. The module's own file name, as +# get_deprecated finds the caller's module by it. +SENSOR_MODULE = f"custom_components.{INTEGRATION}.sensor" +CALLER_FILE = str(importlib.import_module(SENSOR_MODULE).__file__) +# Code outside every integration, as a test helper is: Home Assistant then +# reports without naming anyone. +ELSEWHERE_FILE = str(Path(__file__).parent / "helper.py") +OTHER_INTEGRATION_FILE = "/config/custom_components/other/sensor.py" +CORE_INTEGRATION_FILE = "/usr/lib/homeassistant/components/demo/light.py" + +RENAMED_PROPERTY = """ +class Entity: + old_name = 1 + + @deprecated_substitute("old_name") + def new_name(self): + return 2 + +Entity().new_name() +""" + + +def _call_from(file_name: str, source: str, **names: object) -> None: + """Run source as if it were a line of that file.""" + code = compile(source, file_name, "exec") + exec(code, names) # noqa: S102 - the frame Home Assistant attributes reports by + + +def _helpers(hass: HomeAssistant) -> dict[str, object]: + return { + "replaced": deprecated_function("its replacement")(lambda: None), + "takes_no_hass": deprecated_hass_argument()(lambda: None), + "report_usage": report_usage, + "get_deprecated": get_deprecated, + "deprecated_substitute": deprecated_substitute, + "LOG": ReportBehavior.LOG, + "hass": hass, + } + + +# Every wording Home Assistant's report helpers use, each from the place that +# makes it ours. Each probe is caught by one rule only: a probe two rules catch +# leaves one of them untested. +REPORT_PROBES = { + "misuse, from the integration": ( + CALLER_FILE, + "report_usage('calls something that goes away')", + "Detected that custom integration", + ), + "misuse, from no integration": ( + ELSEWHERE_FILE, + "report_usage('calls something that goes away', core_behavior=LOG)", + "Detected code that", + ), + "deprecated function, from the integration": ( + CALLER_FILE, + "replaced()", + "The deprecated function", + ), + "deprecated function, from no integration": ( + ELSEWHERE_FILE, + "replaced()", + "The deprecated function", + ), + "hass argument, from no integration": ( + ELSEWHERE_FILE, + "takes_no_hass(hass)", + "The deprecated argument hass was passed to", + ), + "renamed configuration key, read by the integration": ( + CALLER_FILE, + "get_deprecated({'old': 1}, 'new', 'old')", + "'old' is deprecated", + ), + "renamed property, on an integration class": ( + CALLER_FILE, + RENAMED_PROPERTY, + "'old_name' is deprecated", + ), +} + + +@pytest.mark.parametrize("probe", REPORT_PROBES) +async def test_the_deprecation_check_recognises_home_assistants_reports( + hass: HomeAssistant, probe: str +) -> None: + """Produced by Home Assistant itself: a rewording fails here, not silently.""" + file_name, source, opening = REPORT_PROBES[probe] + names = {"__name__": SENSOR_MODULE} if file_name == CALLER_FILE else {} + _call_from(file_name, source, **names, **_helpers(hass)) + + reported = [record.getMessage() for record in REPORTS.take()] + assert len(reported) == 1, reported + assert reported[0].startswith(opening), reported + + +def _logs_with_a_traceback() -> None: + try: + raise ValueError(f"custom_components/{INTEGRATION}/deprecation-test/ssc_client.py") + except ValueError: + logging.getLogger(f"custom_components.{INTEGRATION}.ssc_client").warning( + "Write failed", exc_info=True + ) + + +NOT_OURS: dict[str, Callable[[HomeAssistant], None]] = { + "misuse, from another integration": lambda hass: _call_from( + OTHER_INTEGRATION_FILE, "report_usage('does x')", **_helpers(hass) + ), + "deprecated function, from a core integration": lambda hass: _call_from( + CORE_INTEGRATION_FILE, "replaced()", **_helpers(hass) + ), + "renamed configuration key, read elsewhere": lambda hass: _call_from( + ELSEWHERE_FILE, "get_deprecated({'old': 1}, 'new', 'old')", **_helpers(hass) + ), + "own warning quoting the word": lambda _hass: logging.getLogger( + f"custom_components.{INTEGRATION}.coordinator" + ).warning("The speaker calls %s deprecated", "this mode"), + "own warning with a traceback through a path named so": ( + lambda _hass: _logs_with_a_traceback() + ), + "renamed configuration key, from a longer logger name": lambda _hass: logging.getLogger( + f"custom_components.{INTEGRATION}_dev.sensor" + ).warning("'old' is deprecated. Please rename 'old' to 'new' in your configuration file."), + "own warning naming the integration on a later line": lambda _hass: logging.getLogger( + f"custom_components.{INTEGRATION}" + ).warning("Speaker unavailable\nThe deprecated x was used from %s.", INTEGRATION), +} + + +@pytest.mark.parametrize("case", NOT_OURS) +async def test_the_deprecation_check_lets_other_reports_pass( + hass: HomeAssistant, case: str, caplog: pytest.LogCaptureFixture +) -> None: + """Counter-check: each case logs a warning, and none of them is ours.""" + NOT_OURS[case](hass) + + assert any(record.levelno >= logging.WARNING for record in caplog.records) + assert REPORTS.take() == [] + + +def _a_report(when: str = "does a thing") -> None: + logging.getLogger("homeassistant.helpers.frame").warning( + "Detected that custom integration '%s' %s", INTEGRATION, when + ) + + +def test_a_report_during_a_test_fails_it() -> None: + _a_report() + with pytest.raises(AssertionError, match="does a thing"): + REPORTS.check("during this test") + REPORTS.check("after the take") + + +# Deprecation reports arrive on the deprecated object's module: any of Home +# Assistant's loggers, not only the few the helpers name. +UNLISTED = "homeassistant.helpers.device_registry" + + +@contextmanager +def _restoring_logging() -> Iterator[None]: + """Undo what a case does to logging before the test body ends.""" + root = logging.getLogger() + touched = [ + logging.getLogger(name) + for name in (f"custom_components.{INTEGRATION}", "homeassistant", UNLISTED) + ] + saved = [(logger.level, logger.propagate, logger.disabled) for logger in touched] + root_level = root.level + try: + yield + finally: + logging.disable(logging.NOTSET) + root.setLevel(root_level) + for logger, (level, propagate, disabled) in zip(touched, saved, strict=True): + logger.setLevel(level) + logger.propagate = propagate + logger.disabled = disabled + if REPORTS not in root.handlers: + root.addHandler(REPORTS) + + +SILENCERS: dict[str, Callable[[], None]] = { + "root level raised": lambda: logging.getLogger().setLevel(logging.ERROR), + "own logger raised": lambda: logging.getLogger(f"custom_components.{INTEGRATION}").setLevel( + logging.ERROR + ), + "logging disabled": lambda: logging.disable(logging.WARNING), + "Home Assistant's loggers cut off": lambda: setattr( + logging.getLogger("homeassistant"), "propagate", False + ), + "check removed": lambda: logging.getLogger().removeHandler(REPORTS), + "a Home Assistant logger raised": lambda: logging.getLogger(UNLISTED).setLevel(logging.ERROR), + "a Home Assistant logger disabled": lambda: setattr( + logging.getLogger(UNLISTED), "disabled", True + ), +} + + +@pytest.mark.parametrize("silencer", SILENCERS) +def test_the_check_notices_when_it_cannot_hear(silencer: str) -> None: + """What the runtest hook asks after every test body - hence restored inside it.""" + assert_listening() + with _restoring_logging(): + SILENCERS[silencer]() + with pytest.raises(AssertionError): + assert_listening() + + +INNER_TESTS = """ +import logging + +import pytest + + +def _report(when): + logging.getLogger("homeassistant.helpers.frame").warning( + "Detected that custom integration '{domain}' reports %s", when + ) + + +_report("at collection") + + +@pytest.fixture(scope="session") +def reports_on_the_way_out(): + yield + _report("after the last test") + + +def test_first(): + pass + + +def test_reports(reports_on_the_way_out): + _report("during a test") + + +def test_hides_the_logs(caplog): + caplog.set_level(logging.ERROR) +""" + + +@pytest.mark.e2e +@pytest.mark.timeout(240) +def test_the_suite_fails_on_reports_where_they_happen( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The real conftest.py in a pytest run of its own. + + A report at collection, during a test and after the last one, and a test + that stops the check hearing, each end up as a failure. + """ + (tmp_path / "test_inner.py").write_text( + INNER_TESTS.format(domain=INTEGRATION), encoding="utf-8" + ) + tests_dir = Path(__file__).parent + result = subprocess.run( # noqa: S603 - the interpreter running this suite + [ + sys.executable, + *("-m", "pytest", "-p", "conftest", "-p", "no:cacheprovider"), + *("-o", "asyncio_mode=auto"), + *("-o", "asyncio_default_fixture_loop_scope=function"), + "test_inner.py", + ], + cwd=tmp_path, + env={ + # The outer run's options are not the inner run's: --maxfail=1 + # would stop it at the first of the failures it has to show. + **{name: value for name, value in os.environ.items() if not name.startswith("PYTEST_")}, + "PYTHONPATH": os.pathsep.join((str(tests_dir), str(tests_dir.parent))), + }, + capture_output=True, + text=True, + timeout=180, + check=False, + ) + output = result.stdout + result.stderr + + assert result.returncode == pytest.ExitCode.TESTS_FAILED, output + for expected in ( + "reported before this test, outside any test", + "reports at collection", + "reported during this test", + "reports during a test", + "no longer reach the deprecation check", + "reported after the last test", + "reports after the last test", + ): + assert expected in output, output + + +@pytest.mark.parametrize( + ("before", "after"), + [ + (pytest.ExitCode.OK, pytest.ExitCode.TESTS_FAILED), + (pytest.ExitCode.NO_TESTS_COLLECTED, pytest.ExitCode.TESTS_FAILED), + (pytest.ExitCode.INTERRUPTED, pytest.ExitCode.INTERRUPTED), + (pytest.ExitCode.USAGE_ERROR, pytest.ExitCode.USAGE_ERROR), + ], +) +def test_a_report_after_the_last_test_keeps_a_more_telling_exit_status( + before: pytest.ExitCode, after: pytest.ExitCode +) -> None: + """A run that already failed keeps the status that says why.""" + _a_report() + session = SimpleNamespace(exitstatus=before) + conftest.pytest_sessionfinish(session) + REPORTS.take() + assert session.exitstatus == after + + +def _breaks_on_import() -> None: + raise SyntaxError("invalid syntax (sensor.py, line 1)") + + +def test_a_module_that_does_not_import_fails_a_passing_run( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(conftest, "import_every_module", _breaks_on_import) + session = SimpleNamespace(exitstatus=pytest.ExitCode.OK) + conftest.pytest_sessionfinish(session) + assert session.exitstatus == pytest.ExitCode.TESTS_FAILED + assert "sensor.py" in capsys.readouterr().err + + +def test_a_failed_run_is_left_as_it_failed(monkeypatch: pytest.MonkeyPatch) -> None: + """A collection error stays exit 2: nothing is imported over it.""" + imported: list[bool] = [] + monkeypatch.setattr(conftest, "import_every_module", lambda: imported.append(True)) + session = SimpleNamespace(exitstatus=pytest.ExitCode.INTERRUPTED) + conftest.pytest_sessionfinish(session) + assert session.exitstatus == pytest.ExitCode.INTERRUPTED + assert imported == [] + + +def test_the_end_of_a_run_imports_what_no_selected_test_did( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A partial run still sees a deprecated constant in any module.""" + monkeypatch.setattr(conftest, "import_every_module", _a_report) + session = SimpleNamespace(exitstatus=pytest.ExitCode.OK) + conftest.pytest_sessionfinish(session) + assert session.exitstatus == pytest.ExitCode.TESTS_FAILED + + +def test_importing_the_integration_reports_no_deprecation() -> None: + """A deprecated constant reports when a module imports it.""" + import_every_module() + REPORTS.check("while importing the integration") + + +@pytest.mark.parametrize("category", [DeprecationWarning, PendingDeprecationWarning]) +def test_a_deprecation_warning_in_the_integration_fails(category: type[Warning]) -> None: + """pytest.ini turns them into errors, for this package only.""" + with pytest.raises(category): + warnings.warn_explicit( + "old", category, CALLER_FILE, 1, module=f"custom_components.{INTEGRATION}.x" + ) + with warnings.catch_warnings(record=True) as elsewhere: + warnings.simplefilter("always") + warnings.warn_explicit("old", category, "other.py", 1, module="other") + assert len(elsewhere) == 1 From 6372e928126e63c4d08a066e9e16c93a95c00e60 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:55:51 +0100 Subject: [PATCH 2/4] Fail the suite on Home Assistant's deprecation reports Home Assistant reports a deprecated call as a log warning and carries on. A root-log check fails the test, the run end imports every module, own DeprecationWarnings are errors, and a weekly job tries the next release. --- .github/scripts/import_check.py | 41 ++++++++++++++ .github/workflows/ha-next.yml | 95 +++++++++++++++++++++++++++++++++ pytest.ini | 5 ++ tests/README.md | 1 + tests/conftest.py | 64 +++++++++++++++++++++- tests/test_deprecations.py | 18 ++++++- tests/test_guards.py | 2 + 7 files changed, 224 insertions(+), 2 deletions(-) create mode 100755 .github/scripts/import_check.py create mode 100644 .github/workflows/ha-next.yml diff --git a/.github/scripts/import_check.py b/.github/scripts/import_check.py new file mode 100755 index 0000000..4ef7759 --- /dev/null +++ b/.github/scripts/import_check.py @@ -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()) diff --git a/.github/workflows/ha-next.yml b/.github/workflows/ha-next.yml new file mode 100644 index 0000000..e7c6523 --- /dev/null +++ b/.github/workflows/ha-next.yml @@ -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" diff --git a/pytest.ini b/pytest.ini index fd9f37f..767cf37 100644 --- a/pytest.ini +++ b/pytest.ini @@ -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 diff --git a/tests/README.md b/tests/README.md index 3047fdf..1c9b229 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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 | diff --git a/tests/conftest.py b/tests/conftest.py index 917618d..d59872c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 2e5f623..a3de4b1 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -9,6 +9,7 @@ from __future__ import annotations import importlib +import importlib.util import logging import os import subprocess @@ -292,7 +293,7 @@ def test_the_suite_fails_on_reports_where_they_happen( INNER_TESTS.format(domain=INTEGRATION), encoding="utf-8" ) tests_dir = Path(__file__).parent - result = subprocess.run( # noqa: S603 - the interpreter running this suite + result = subprocess.run( [ sys.executable, *("-m", "pytest", "-p", "conftest", "-p", "no:cacheprovider"), @@ -398,3 +399,18 @@ def test_a_deprecation_warning_in_the_integration_fails(category: type[Warning]) warnings.simplefilter("always") warnings.warn_explicit("old", category, "other.py", 1, module="other") assert len(elsewhere) == 1 + + +def test_the_import_check_fails_on_a_report(monkeypatch: pytest.MonkeyPatch) -> None: + """The next-release workflow runs it where no test harness exists yet.""" + path = Path(__file__).parents[1] / ".github" / "scripts" / "import_check.py" + spec = importlib.util.spec_from_file_location("import_check", path) + assert spec is not None + assert spec.loader is not None + import_check = importlib.util.module_from_spec(spec) + spec.loader.exec_module(import_check) + + monkeypatch.setattr(import_check, "import_every_module", _a_report) + assert import_check.main() == 1 + monkeypatch.setattr(import_check, "import_every_module", lambda: None) + assert import_check.main() == 0 diff --git a/tests/test_guards.py b/tests/test_guards.py index 4fedc57..ae5ecfa 100644 --- a/tests/test_guards.py +++ b/tests/test_guards.py @@ -26,6 +26,7 @@ GUARD_FILES = { "test_budgets.py": "no module grows past its ceiling, no function past its complexity ratchet", "test_comment_narration.py": "no comment merely restates the code it sits on", + "test_deprecations.py": "Home Assistant's deprecation reports and own DeprecationWarnings fail the suite", "test_constant_owners.py": "no constant is defined in two modules", "test_device_writes.py": "every device-writing button goes through the action lock", "test_ci_parity.py": "CI runs the same check script a developer runs", @@ -91,6 +92,7 @@ def test_every_guard_scans_a_real_directory(): "test_ci_parity.py": "CI runs the same check script a developer runs", "test_mutation_harness.py": "checks the harness script, not the package", "test_resolve_phcc.py": "checks the CI resolver script, not the package", + "test_deprecations.py": "probes Home Assistant's report helpers, not the package's files", } From 178c17aa330fe5c2e5a2bbbe8a86f456d1e7226e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:56:35 +0100 Subject: [PATCH 3/4] Scan for Home Assistant calls known to be deprecated --- tests/test_deprecations.py | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index a3de4b1..8a3ae72 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -8,6 +8,7 @@ from __future__ import annotations +import ast import importlib import importlib.util import logging @@ -414,3 +415,41 @@ def test_the_import_check_fails_on_a_report(monkeypatch: pytest.MonkeyPatch) -> assert import_check.main() == 1 monkeypatch.setattr(import_check, "import_every_module", lambda: None) assert import_check.main() == 0 + + +# Home Assistant calls that still exist but report as deprecated at runtime, +# each with the replacement the message names. The runtime check above sees +# only paths a test runs; this finds them in code nobody exercised. +DEPRECATED_HA_CALLS = { + # 2026.9: device identifiers are no longer unique across config entries. + "async_get_device": "async_get_device_by_identifier / _by_connection", +} + + +def _deprecated_calls(source: str) -> list[str]: + return [ + f"{node.lineno} {node.func.attr}" + for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in DEPRECATED_HA_CALLS + ] + + +def test_no_deprecated_home_assistant_call() -> None: + """Tests are scanned too: a deprecated call can last live in a fixture.""" + package = Path(importlib.import_module(f"custom_components.{INTEGRATION}").__file__).parent + offenders = [ + f"{path.name}:{call}" + for path in [*sorted(package.glob("*.py")), *sorted(Path(__file__).parent.glob("*.py"))] + if path.name != Path(__file__).name + for call in _deprecated_calls(path.read_text(encoding="utf-8")) + ] + assert not offenders, f"deprecated Home Assistant calls: {offenders}" + + +def test_the_static_scan_finds_a_deprecated_call() -> None: + """Counter-check: a scan that never matches passes on any code.""" + assert _deprecated_calls("registry.async_get_device(identifiers={('x', 'y')})") == [ + "1 async_get_device" + ] From 913bd22da6bbe0552bb7126719dcf6f5b5860adc Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:36:34 +0100 Subject: [PATCH 4/4] Make the package-only warning counter-check able to fail simplefilter("always") sat above the pytest.ini filters, so the check passed even with a filter that had lost its module field. --- tests/test_deprecations.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 8a3ae72..013e244 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -396,8 +396,9 @@ def test_a_deprecation_warning_in_the_integration_fails(category: type[Warning]) warnings.warn_explicit( "old", category, CALLER_FILE, 1, module=f"custom_components.{INTEGRATION}.x" ) + # No simplefilter here: it would sit above the ini filters and pass even + # if they had lost their module field. pytest already records the rest. with warnings.catch_warnings(record=True) as elsewhere: - warnings.simplefilter("always") warnings.warn_explicit("old", category, "other.py", 1, module="other") assert len(elsewhere) == 1