Show installed packages in cloud support package (#161516)

This commit is contained in:
Joakim Sørensen
2026-01-24 14:20:39 +01:00
committed by GitHub
parent 103097b74f
commit d68ac745d4
5 changed files with 169 additions and 0 deletions

View File

@@ -42,6 +42,7 @@ from homeassistant.loader import (
async_get_loaded_integration,
)
from homeassistant.util.location import async_detect_location_info
from homeassistant.util.package import async_get_installed_packages
from .alexa_config import entity_supported as entity_supported_by_alexa
from .assist_pipeline import async_create_cloud_pipeline
@@ -571,6 +572,25 @@ class DownloadSupportPackageView(HomeAssistantView):
"</details>\n\n"
)
# Add installed packages section
try:
installed_packages = await async_get_installed_packages()
except Exception: # noqa: BLE001
# Broad exception catch for robustness in support package generation
markdown += "## Installed packages\n\n"
markdown += "Unable to collect installed packages information\n\n"
else:
if installed_packages:
markdown += "## Installed packages\n\n"
markdown += (
"<details><summary>Installed packages</summary>\n\n"
"Package | Version\n"
"--- | ---\n"
)
for pkg in sorted(installed_packages, key=lambda p: p["name"].lower()):
markdown += f"{pkg['name']} | {pkg['version']}\n"
markdown += "\n</details>\n\n"
log_handler = hass.data[DATA_CLOUD_LOG_HANDLER]
logs = "\n".join(await log_handler.get_logs(hass))
markdown += (

View File

@@ -11,15 +11,24 @@ from pathlib import Path
import site
from subprocess import PIPE, Popen
import sys
from typing import TypedDict, cast
from urllib.parse import urlparse
from packaging.requirements import InvalidRequirement, Requirement
from .json import JSON_DECODE_EXCEPTIONS, json_loads_array
from .system_info import is_official_image
_LOGGER = logging.getLogger(__name__)
class InstalledPackage(TypedDict):
"""Represent an installed package."""
name: str
version: str
def is_virtual_env() -> bool:
"""Return if we run in a virtual environment."""
# Check supports venv && virtualenv
@@ -203,3 +212,26 @@ async def async_get_user_site(deps_dir: str) -> str:
)
stdout, _ = await process.communicate()
return stdout.decode().strip()
async def async_get_installed_packages() -> list[InstalledPackage]:
"""Return a list of installed packages and versions.
Returns a list of InstalledPackage dicts with 'name' and 'version' keys.
"""
args = [sys.executable, "-m", "uv", "pip", "list", "--format", "json"]
process = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
close_fds=False, # required for posix_spawn
)
stdout, _ = await process.communicate()
if process.returncode != 0:
return []
try:
return cast(list[InstalledPackage], json_loads_array(stdout.decode()))
except (*JSON_DECODE_EXCEPTIONS, ValueError):
return []

View File

@@ -87,6 +87,17 @@
</details>
## Installed packages
<details><summary>Installed packages</summary>
Package | Version
--- | ---
hass-nabucasa | 1.2.3
homeassistant | 3.2.1
</details>
## Full logs
<details><summary>Logs</summary>
@@ -181,6 +192,17 @@
</details>
## Installed packages
<details><summary>Installed packages</summary>
Package | Version
--- | ---
hass-nabucasa | 1.2.3
homeassistant | 3.2.1
</details>
## Full logs
<details><summary>Logs</summary>
@@ -246,6 +268,17 @@
</details>
## Installed packages
<details><summary>Installed packages</summary>
Package | Version
--- | ---
hass-nabucasa | 1.2.3
homeassistant | 3.2.1
</details>
## Full logs
<details><summary>Logs</summary>

View File

@@ -1953,6 +1953,13 @@ async def test_download_support_package(
"user": "hass",
},
),
patch(
"homeassistant.components.cloud.http_api.async_get_installed_packages",
return_value=[
{"name": "homeassistant", "version": "3.2.1"},
{"name": "hass-nabucasa", "version": "1.2.3"},
],
),
):
req = await cloud_client.get("/api/cloud/support_package")
assert req.status == HTTPStatus.OK
@@ -2065,6 +2072,13 @@ async def test_download_support_package_custom_components_error(
"homeassistant.components.cloud.http_api.async_get_custom_components",
side_effect=Exception("Custom components error"),
),
patch(
"homeassistant.components.cloud.http_api.async_get_installed_packages",
return_value=[
{"name": "homeassistant", "version": "3.2.1"},
{"name": "hass-nabucasa", "version": "1.2.3"},
],
),
):
req = await cloud_client.get("/api/cloud/support_package")
assert req.status == HTTPStatus.OK
@@ -2182,6 +2196,13 @@ async def test_download_support_package_integration_load_error(
if domain == "failing_integration"
else async_get_loaded_integration(hass, domain),
),
patch(
"homeassistant.components.cloud.http_api.async_get_installed_packages",
return_value=[
{"name": "homeassistant", "version": "3.2.1"},
{"name": "hass-nabucasa", "version": "1.2.3"},
],
),
):
req = await cloud_client.get("/api/cloud/support_package")
assert req.status == HTTPStatus.OK

View File

@@ -361,6 +361,69 @@ async def test_async_get_user_site(mock_env_copy) -> None:
assert ret == os.path.join(deps_dir, "lib_dir")
async def test_async_get_installed_packages() -> None:
"""Test async get installed packages."""
mock_output = b'[{"name": "package1", "version": "1.0.0"}, {"name": "package2", "version": "2.0.0"}]'
async_popen = MagicMock()
async_popen.returncode = 0
async def communicate(input=None):
return (mock_output, None)
async_popen.communicate = communicate
args = [sys.executable, "-m", "uv", "pip", "list", "--format", "json"]
with patch(
"homeassistant.util.package.asyncio.create_subprocess_exec",
return_value=async_popen,
) as popen_mock:
ret = await package.async_get_installed_packages()
assert popen_mock.call_count == 1
assert popen_mock.call_args == call(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
close_fds=False,
)
assert ret == [
{"name": "package1", "version": "1.0.0"},
{"name": "package2", "version": "2.0.0"},
]
@pytest.mark.parametrize(
("returncode", "stdout", "test_id"),
[
(1, b"", "nonzero_return"),
(0, b'{"name": "package1", "version": "1.0.0"}', "json_object"),
(0, b'"just a string"', "json_string"),
],
ids=lambda x: x if isinstance(x, str) else None,
)
async def test_async_get_installed_packages_returns_empty(
returncode: int, stdout: bytes, test_id: str
) -> None:
"""Test async get installed packages returns empty list on errors."""
async_popen = MagicMock()
async_popen.returncode = returncode
async def communicate(input=None):
return (stdout, None)
async_popen.communicate = communicate
with patch(
"homeassistant.util.package.asyncio.create_subprocess_exec",
return_value=async_popen,
):
ret = await package.async_get_installed_packages()
assert ret == []
def test_check_package_global(caplog: pytest.LogCaptureFixture) -> None:
"""Test for an installed package."""
pkg = metadata("homeassistant")