diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index d487edee4a4dda..ce6276ef4d4845 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -9,6 +9,7 @@ from pathlib import Path from subprocess import PIPE, Popen import sys +from urllib.parse import urlparse from packaging.requirements import InvalidRequirement, Requirement @@ -40,14 +41,32 @@ def is_installed(requirement_str: str) -> bool: expected input is a pip compatible package specifier (requirement string) e.g. "package==1.0.0" or "package>=1.0.0,<2.0.0" + For backward compatibility, it also accepts a URL with a fragment + e.g. "git+https://github.com/pypa/pip#pip>=1" + 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: - _LOGGER.error("Invalid requirement '%s'", requirement_str) - return False + 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 try: if (installed_version := version(req.name)) is None: diff --git a/tests/util/test_package.py b/tests/util/test_package.py index e940fdf6f9cd8a..42ba0131d715ff 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -217,7 +217,7 @@ async def test_async_get_user_site(mock_env_copy) -> None: assert ret == os.path.join(deps_dir, "lib_dir") -def test_check_package_global() -> None: +def test_check_package_global(caplog: pytest.LogCaptureFixture) -> None: """Test for an installed package.""" pkg = metadata("homeassistant") installed_package = pkg["name"] @@ -229,10 +229,19 @@ def test_check_package_global() -> None: assert package.is_installed(f"{installed_package}<={installed_version}") assert not package.is_installed(f"{installed_package}<{installed_version}") + assert package.is_installed("-1 invalid_package") is False + assert "Invalid requirement '-1 invalid_package'" in caplog.text -def test_check_package_zip() -> None: - """Test for an installed zip package.""" + +def test_check_package_fragment(caplog: pytest.LogCaptureFixture) -> None: + """Test for an installed package with a fragment.""" assert not package.is_installed(TEST_ZIP_REQ) + assert package.is_installed("git+https://github.com/pypa/pip#pip>=1") + assert not package.is_installed("git+https://github.com/pypa/pip#-1 invalid") + assert ( + "Invalid requirement 'git+https://github.com/pypa/pip#-1 invalid'" + in caplog.text + ) def test_get_is_installed() -> None: