diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 1ca4178d9c2..659bdbc445b 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -222,6 +222,15 @@ class Integration: """Add a warning.""" self.warnings.append(Error(*args, **kwargs)) + def add_warning_or_error( + self, warning_only: bool, *args: Any, **kwargs: Any + ) -> None: + """Add an error or a warning.""" + if warning_only: + self.add_warning(*args, **kwargs) + else: + self.add_error(*args, **kwargs) + def load_manifest(self) -> None: """Load manifest.""" manifest_path = self.path / "manifest.json" diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 2fa82fe4f7f..0537b5edefc 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections import deque from functools import cache +from importlib.metadata import metadata import json import os import re @@ -319,6 +320,33 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { }, } +PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { + # In the form dict("domain": {"package": {"dependency1", "dependency2"}}) + # - domain is the integration domain + # - package is the package (can be transitive) referencing the dependency + # - dependencyX should be the name of the referenced dependency + "bluetooth": { + # https://github.com/hbldh/bleak/pull/1718 (not yet released) + "homeassistant": {"bleak"} + }, + "eq3btsmart": { + # https://github.com/EuleMitKeule/eq3btsmart/releases/tag/2.0.0 + "homeassistant": {"eq3btsmart"} + }, + "homekit_controller": { + # https://github.com/Jc2k/aiohomekit/issues/456 + "homeassistant": {"aiohomekit"} + }, + "netatmo": { + # https://github.com/jabesq-org/pyatmo/pull/533 (not yet released) + "homeassistant": {"pyatmo"} + }, + "python_script": { + # Security audits are needed for each Python version + "homeassistant": {"restrictedpython"} + }, +} + def validate(integrations: dict[str, Integration], config: Config) -> None: """Handle requirements for integrations.""" @@ -489,6 +517,11 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: ) needs_package_version_check_exception = False + python_version_check_exceptions = PYTHON_VERSION_CHECK_EXCEPTIONS.get( + integration.domain, {} + ) + needs_python_version_check_exception = False + while to_check: package = to_check.popleft() @@ -507,22 +540,32 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: ) continue + if ( + package in packages # Top-level checks only until bleak is resolved + and (requires_python := metadata(package)["Requires-Python"]) + and not all( + _is_dependency_version_range_valid(version_part, "SemVer") + for version_part in requires_python.split(",") + ) + ): + needs_python_version_check_exception = True + integration.add_warning_or_error( + package in python_version_check_exceptions.get("homeassistant", set()), + "requirements", + f"Version restrictions for Python are too strict ({requires_python}) in {package}", + ) + dependencies: dict[str, str] = item["dependencies"] package_exceptions = forbidden_package_exceptions.get(package, set()) for pkg, version in dependencies.items(): if pkg.startswith("types-") or pkg in FORBIDDEN_PACKAGES: reason = FORBIDDEN_PACKAGES.get(pkg, "not be a runtime dependency") needs_forbidden_package_exceptions = True - if pkg in package_exceptions: - integration.add_warning( - "requirements", - f"Package {pkg} should {reason} in {package}", - ) - else: - integration.add_error( - "requirements", - f"Package {pkg} should {reason} in {package}", - ) + integration.add_warning_or_error( + pkg in package_exceptions, + "requirements", + f"Package {pkg} should {reason} in {package}", + ) if not check_dependency_version_range( integration, package, @@ -546,6 +589,12 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: f"Integration {integration.domain} version restrictions checks have been " "resolved, please remove from `PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS`", ) + if python_version_check_exceptions and not needs_python_version_check_exception: + integration.add_error( + "requirements", + f"Integration {integration.domain} version restrictions for Python have " + "been resolved, please remove from `PYTHON_VERSION_CHECK_EXCEPTIONS`", + ) return all_requirements @@ -571,21 +620,16 @@ def check_dependency_version_range( ): return True - if pkg in package_exceptions: - integration.add_warning( - "requirements", - f"Version restrictions for {pkg} are too strict ({version}) in {source}", - ) - else: - integration.add_error( - "requirements", - f"Version restrictions for {pkg} are too strict ({version}) in {source}", - ) + integration.add_warning_or_error( + pkg in package_exceptions, + "requirements", + f"Version restrictions for {pkg} are too strict ({version}) in {source}", + ) return False def _is_dependency_version_range_valid(version_part: str, convention: str) -> bool: - version_match = PIP_VERSION_RANGE_SEPARATOR.match(version_part) + version_match = PIP_VERSION_RANGE_SEPARATOR.match(version_part.strip()) operator = version_match.group(1) version = version_match.group(2)