diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 6023ed7a4e6..75d3fbf46d1 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -9,8 +9,6 @@ import logging import os from typing import Any -from packaging.requirements import Requirement - from .core import HomeAssistant, callback from .exceptions import HomeAssistantError from .helpers import singleton @@ -260,8 +258,13 @@ class RequirementsManager: """ if DEPRECATED_PACKAGES or self.hass.config.skip_pip_packages: all_requirements = { - requirement_string: Requirement(requirement_string) + requirement_string: requirement_details for requirement_string in requirements + if ( + requirement_details := pkg_util.parse_requirement_safe( + requirement_string + ) + ) } if DEPRECATED_PACKAGES: for requirement_string, requirement_details in all_requirements.items(): @@ -272,9 +275,12 @@ class RequirementsManager: "" if is_built_in else "custom ", name, f"has requirement '{requirement_string}' which {reason}", - f"This will stop working in Home Assistant {breaks_in_ha_version}, please" - if breaks_in_ha_version - else "Please", + ( + "This will stop working in Home Assistant " + f"{breaks_in_ha_version}, please" + if breaks_in_ha_version + else "Please" + ), async_suggest_report_issue( self.hass, integration_domain=name ), diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 2c0ed363eef..eebcdb2bba6 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -44,6 +44,39 @@ def get_installed_versions(specifiers: set[str]) -> set[str]: return {specifier for specifier in specifiers if is_installed(specifier)} +def parse_requirement_safe(requirement_str: str) -> Requirement | None: + """Parse a requirement string into a Requirement object. + + expected input is a pip compatible package specifier (requirement string) + e.g. "package==1.0.0" or "package>=1.0.0,<2.0.0" or "package@git+https://..." + + For backward compatibility, it also accepts a URL with a fragment + e.g. "git+https://github.com/pypa/pip#pip>=1" + + Returns None on a badly-formed requirement string. + """ + try: + return Requirement(requirement_str) + except InvalidRequirement: + if "#" not in requirement_str: + _LOGGER.error("Invalid requirement '%s'", requirement_str) + return None + + # This is likely a URL with a fragment + # example: git+https://github.com/pypa/pip#pip>=1 + + # fragment support was originally used to install zip files, and + # we no longer do this in Home Assistant. However, custom + # components started using it to install packages from git + # urls which would make it would be a breaking change to + # remove it. + try: + return Requirement(urlparse(requirement_str).fragment) + except InvalidRequirement: + _LOGGER.error("Invalid requirement '%s'", requirement_str) + return None + + def is_installed(requirement_str: str) -> bool: """Check if a package is installed and will be loaded when we import it. @@ -56,26 +89,8 @@ def is_installed(requirement_str: str) -> bool: Returns True when the requirement is met. Returns False when the package is not installed or doesn't meet req. """ - try: - req = Requirement(requirement_str) - except InvalidRequirement: - if "#" not in requirement_str: - _LOGGER.error("Invalid requirement '%s'", requirement_str) - return False - - # This is likely a URL with a fragment - # example: git+https://github.com/pypa/pip#pip>=1 - - # fragment support was originally used to install zip files, and - # we no longer do this in Home Assistant. However, custom - # components started using it to install packages from git - # urls which would make it would be a breaking change to - # remove it. - try: - req = Requirement(urlparse(requirement_str).fragment) - except InvalidRequirement: - _LOGGER.error("Invalid requirement '%s'", requirement_str) - return False + if (req := parse_requirement_safe(requirement_str)) is None: + return False try: if (installed_version := version(req.name)) is None: diff --git a/tests/test_requirements.py b/tests/test_requirements.py index bb44f9df41a..0b9dc1c8a79 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -661,11 +661,12 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("requirement", "is_built_in", "deprecation_info"), + ("requirement", "is_built_in", "deprecation_prefix", "deprecation_info"), [ ( "hello", True, + "Detected that integration", "which is deprecated for testing. This will stop working in Home Assistant" " 2020.12, please create a bug report at https://github.com/home-assistant/" "core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_component%22", @@ -673,6 +674,7 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None: ( "hello>=1.0.0", False, + "Detected that custom integration", "which is deprecated for testing. This will stop working in Home Assistant" " 2020.12, please create a bug report at https://github.com/home-assistant/" "core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_component%22", @@ -680,6 +682,7 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None: ( "pyserial-asyncio", False, + "Detected that custom integration", "which should be replaced by pyserial-asyncio-fast. This will stop" " working in Home Assistant 2026.7, please create a bug report at " "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+" @@ -688,6 +691,7 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None: ( "pyserial-asyncio>=0.6", True, + "Detected that integration", "which should be replaced by pyserial-asyncio-fast. This will stop" " working in Home Assistant 2026.7, please create a bug report at " "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+" @@ -699,6 +703,7 @@ async def test_install_deprecated_package( hass: HomeAssistant, requirement: str, is_built_in: bool, + deprecation_prefix: str, deprecation_info: str, caplog: pytest.LogCaptureFixture, ) -> None: @@ -710,10 +715,16 @@ async def test_install_deprecated_package( patch("homeassistant.util.package.install_package", return_value=True), ): await async_process_requirements( - hass, "test_component", [requirement], is_built_in + hass, + "test_component", + [ + requirement, + "git+https://github.com/user/project.git@1.2.3", + ], + is_built_in, ) assert ( - f"Detected that {'' if is_built_in else 'custom '}integration " - f"'test_component' has requirement '{requirement}' {deprecation_info}" + f"{deprecation_prefix} 'test_component'" + f" has requirement '{requirement}' {deprecation_info}" ) in caplog.text