Migrate PR download to async libraries

Replace sync PyGithub with aiogithubapi and requests with aiohttp.
- Use aiogithubapi.GitHubAPI for GitHub API calls
- Use aiohttp_client.async_get_clientsession for HTTP downloads
- Remove executor job calls for API operations (now fully async)
- Keep executor jobs only for blocking I/O (file operations, zip extraction)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Wendelin
2026-01-20 17:33:13 +01:00
parent 689415d021
commit bebc6a63f1
5 changed files with 92 additions and 86 deletions

View File

@@ -23,5 +23,8 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260107.2", "PyGithub==2.5.0"]
"requirements": [
"home-assistant-frontend==20260107.2",
"aiogithubapi==24.6.0"
]
}

View File

@@ -6,11 +6,14 @@ import io
import logging
import pathlib
import shutil
from typing import Any
import zipfile
from aiogithubapi import GitHubAPI, GitHubException
from aiohttp import ClientError, ClientResponseError, ClientTimeout
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
@@ -36,90 +39,99 @@ def _get_directory_size_mb(directory: pathlib.Path) -> float:
return total / (1024 * 1024)
def _get_pr_head_sha(pr_number: int, github_token: str) -> str:
"""Get the head SHA for the PR (runs in executor)."""
from github import ( # noqa: PLC0415
BadCredentialsException,
Github,
GithubException,
UnknownObjectException,
)
async def _get_pr_head_sha(client: GitHubAPI, pr_number: int) -> str:
"""Get the head SHA for the PR."""
try:
github_client = Github(github_token)
repo = github_client.get_repo(GITHUB_REPO)
pull_request = repo.get_pull(pr_number)
except BadCredentialsException:
raise HomeAssistantError(ERROR_INVALID_TOKEN) from None
except UnknownObjectException:
response = await client.generic(
endpoint=f"/repos/home-assistant/frontend/pulls/{pr_number}",
)
return str(response.data["head"]["sha"])
except GitHubException as err:
if err.status == 401:
raise HomeAssistantError(ERROR_INVALID_TOKEN) from err
if err.status == 403:
raise HomeAssistantError(ERROR_RATE_LIMIT) from err
if err.status == 404:
raise HomeAssistantError(
f"PR #{pr_number} does not exist in repository {GITHUB_REPO}"
) from err
raise HomeAssistantError(f"GitHub API error: {err}") from err
async def _find_pr_artifact(client: GitHubAPI, pr_number: int, head_sha: str) -> str:
"""Find the build artifact for the given PR and commit SHA.
Returns the artifact download URL.
"""
try:
# Get workflow runs for the commit
response = await client.generic(
endpoint="/repos/home-assistant/frontend/actions/workflows/ci.yaml/runs",
params={"head_sha": head_sha, "per_page": 10},
)
# Find the most recent successful run for this commit
for run in response.data.get("workflow_runs", []):
if run["status"] == "completed" and run["conclusion"] == "success":
# Get artifacts for this run
artifacts_response = await client.generic(
endpoint=f"/repos/home-assistant/frontend/actions/runs/{run['id']}/artifacts",
)
# Find the frontend-build artifact
for artifact in artifacts_response.data.get("artifacts", []):
if artifact["name"] == ARTIFACT_NAME:
_LOGGER.info(
"Found artifact '%s' from CI run #%s",
ARTIFACT_NAME,
run["id"],
)
return str(artifact["archive_download_url"])
raise HomeAssistantError(
f"PR #{pr_number} does not exist in repository {GITHUB_REPO}"
) from None
except GithubException as err:
f"No '{ARTIFACT_NAME}' artifact found for PR #{pr_number}. "
"Possible reasons: CI has not run yet or is running, "
"or the build failed, or the PR artifact expired. "
f"Check https://github.com/{GITHUB_REPO}/pull/{pr_number}/checks"
)
except GitHubException as err:
if err.status == 401:
raise HomeAssistantError(ERROR_INVALID_TOKEN) from err
if err.status == 403:
raise HomeAssistantError(ERROR_RATE_LIMIT) from err
raise HomeAssistantError(f"GitHub API error: {err}") from err
else:
return pull_request.head.sha
def _find_pr_artifact(pr_number: int, head_sha: str, github_token: str) -> Any:
"""Find the build artifact for the given PR and commit SHA (runs in executor)."""
from github import Github # noqa: PLC0415
github_client = Github(github_token)
repo = github_client.get_repo(GITHUB_REPO)
workflow = repo.get_workflow("ci.yaml")
# Find the most recent successful run for this commit
for run in workflow.get_runs(head_sha=head_sha):
if run.status == "completed" and run.conclusion == "success":
# Find the frontend-build artifact
for artifact in run.get_artifacts():
if artifact.name == ARTIFACT_NAME:
_LOGGER.info(
"Found artifact '%s' from CI run #%s",
ARTIFACT_NAME,
run.id,
)
return artifact
raise HomeAssistantError(
f"No '{ARTIFACT_NAME}' artifact found for PR #{pr_number}. "
"Possible reasons: CI has not run yet or is running, "
"or the build failed, or the PR artifact expired. "
f"Check https://github.com/{GITHUB_REPO}/pull/{pr_number}/checks"
)
def _download_artifact_data(artifact_url: str, github_token: str) -> bytes:
"""Download artifact data from GitHub (runs in executor)."""
import requests # noqa: PLC0415
async def _download_artifact_data(
hass: HomeAssistant, artifact_url: str, github_token: str
) -> bytes:
"""Download artifact data from GitHub."""
session = async_get_clientsession(hass)
headers = {
"Authorization": f"token {github_token}",
"Accept": "application/vnd.github+json",
}
try:
response = requests.get(artifact_url, headers=headers, timeout=60)
response = await session.get(
artifact_url, headers=headers, timeout=ClientTimeout(total=60)
)
response.raise_for_status()
except requests.HTTPError as err:
if err.response.status_code == 401:
return await response.read()
except ClientResponseError as err:
if err.status == 401:
raise HomeAssistantError(ERROR_INVALID_TOKEN) from err
if err.response.status_code == 403:
if err.status == 403:
raise HomeAssistantError(ERROR_RATE_LIMIT) from err
raise HomeAssistantError(
f"Failed to download artifact: HTTP {err.response.status_code}"
f"Failed to download artifact: HTTP {err.status}"
) from err
except requests.Timeout:
except TimeoutError as err:
raise HomeAssistantError(
"Timeout downloading artifact (>60s). Check your network connection"
) from None
except requests.RequestException as err:
) from err
except ClientError as err:
raise HomeAssistantError(f"Network error downloading artifact: {err}") from err
else:
return response.content
def _extract_artifact(
@@ -159,11 +171,15 @@ async def download_pr_artifact(
)
return None
# Create GitHub API client
client = GitHubAPI(
token=github_token,
session=async_get_clientsession(hass),
)
# Get the current head SHA for this PR
try:
head_sha = await hass.async_add_executor_job(
_get_pr_head_sha, pr_number, github_token
)
head_sha = await _get_pr_head_sha(client, pr_number)
except HomeAssistantError as err:
_LOGGER.error("%s", err)
return None
@@ -191,19 +207,13 @@ async def download_pr_artifact(
head_sha[:8],
)
from github import GithubException # noqa: PLC0415
try:
# Find the artifact
artifact = await hass.async_add_executor_job(
_find_pr_artifact, pr_number, head_sha, github_token
)
artifact_url = await _find_pr_artifact(client, pr_number, head_sha)
# Download artifact
_LOGGER.info("Downloading frontend PR #%s artifact", pr_number)
artifact_data = await hass.async_add_executor_job(
_download_artifact_data, artifact.archive_download_url, github_token
)
artifact_data = await _download_artifact_data(hass, artifact_url, github_token)
# Extract artifact
await hass.async_add_executor_job(
@@ -232,9 +242,6 @@ async def download_pr_artifact(
CACHE_WARNING_SIZE_MB,
cache_dir,
)
except GithubException as err:
_LOGGER.error("GitHub API error for PR #%s: %s", pr_number, err)
return None
except HomeAssistantError as err:
_LOGGER.error("%s", err)
return None

View File

@@ -3,6 +3,7 @@
aiodhcpwatcher==1.2.1
aiodiscover==2.7.1
aiodns==4.0.0
aiogithubapi==24.6.0
aiohasupervisor==0.3.3
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
@@ -52,7 +53,6 @@ paho-mqtt==2.1.0
Pillow==12.0.0
propcache==0.4.1
psutil-home-assistant==0.0.1
PyGithub==2.5.0
PyJWT==2.10.1
pymicro-vad==1.0.1
PyNaCl==1.6.2

4
requirements_all.txt generated
View File

@@ -55,9 +55,6 @@ PyFlume==0.6.5
# homeassistant.components.fronius
PyFronius==0.8.0
# homeassistant.components.frontend
PyGithub==2.5.0
# homeassistant.components.pyload
PyLoadAPI==1.4.2
@@ -267,6 +264,7 @@ aioflo==2021.11.0
# homeassistant.components.yi
aioftp==0.21.3
# homeassistant.components.frontend
# homeassistant.components.github
aiogithubapi==24.6.0

View File

@@ -55,9 +55,6 @@ PyFlume==0.6.5
# homeassistant.components.fronius
PyFronius==0.8.0
# homeassistant.components.frontend
PyGithub==2.5.0
# homeassistant.components.pyload
PyLoadAPI==1.4.2
@@ -255,6 +252,7 @@ aiofiles==24.1.0
# homeassistant.components.flo
aioflo==2021.11.0
# homeassistant.components.frontend
# homeassistant.components.github
aiogithubapi==24.6.0