Add check for packages restricting Python version (#145690)

* Add check for packages restricting Python version

* Apply suggestions from code review

* until

* until
This commit is contained in:
epenet 2025-05-27 10:44:00 +02:00 committed by GitHub
parent 7b1dfc35d1
commit 96c9636086
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 74 additions and 21 deletions

View File

@ -222,6 +222,15 @@ class Integration:
"""Add a warning.""" """Add a warning."""
self.warnings.append(Error(*args, **kwargs)) 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: def load_manifest(self) -> None:
"""Load manifest.""" """Load manifest."""
manifest_path = self.path / "manifest.json" manifest_path = self.path / "manifest.json"

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from collections import deque from collections import deque
from functools import cache from functools import cache
from importlib.metadata import metadata
import json import json
import os import os
import re 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: def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Handle requirements for integrations.""" """Handle requirements for integrations."""
@ -489,6 +517,11 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]:
) )
needs_package_version_check_exception = False 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: while to_check:
package = to_check.popleft() package = to_check.popleft()
@ -507,19 +540,29 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]:
) )
continue 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"] dependencies: dict[str, str] = item["dependencies"]
package_exceptions = forbidden_package_exceptions.get(package, set()) package_exceptions = forbidden_package_exceptions.get(package, set())
for pkg, version in dependencies.items(): for pkg, version in dependencies.items():
if pkg.startswith("types-") or pkg in FORBIDDEN_PACKAGES: if pkg.startswith("types-") or pkg in FORBIDDEN_PACKAGES:
reason = FORBIDDEN_PACKAGES.get(pkg, "not be a runtime dependency") reason = FORBIDDEN_PACKAGES.get(pkg, "not be a runtime dependency")
needs_forbidden_package_exceptions = True needs_forbidden_package_exceptions = True
if pkg in package_exceptions: integration.add_warning_or_error(
integration.add_warning( pkg in package_exceptions,
"requirements",
f"Package {pkg} should {reason} in {package}",
)
else:
integration.add_error(
"requirements", "requirements",
f"Package {pkg} should {reason} in {package}", f"Package {pkg} should {reason} in {package}",
) )
@ -546,6 +589,12 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]:
f"Integration {integration.domain} version restrictions checks have been " f"Integration {integration.domain} version restrictions checks have been "
"resolved, please remove from `PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS`", "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 return all_requirements
@ -571,13 +620,8 @@ def check_dependency_version_range(
): ):
return True return True
if pkg in package_exceptions: integration.add_warning_or_error(
integration.add_warning( pkg in package_exceptions,
"requirements",
f"Version restrictions for {pkg} are too strict ({version}) in {source}",
)
else:
integration.add_error(
"requirements", "requirements",
f"Version restrictions for {pkg} are too strict ({version}) in {source}", f"Version restrictions for {pkg} are too strict ({version}) in {source}",
) )
@ -585,7 +629,7 @@ def check_dependency_version_range(
def _is_dependency_version_range_valid(version_part: str, convention: str) -> bool: 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) operator = version_match.group(1)
version = version_match.group(2) version = version_match.group(2)