Compare commits

...

30 Commits

Author SHA1 Message Date
Franck Nijhof 8c8cc3acb9 Fix habitica ignoring zero values for interval and streak (#171468) 2026-05-21 08:06:08 +02:00
Franck Nijhof b0634bea35 Fix SmartThings crash when timestamp attribute is None (#171467) 2026-05-21 08:05:42 +02:00
Raphael Hehl 5ae31cad6f Fix unifiprotect exception translations (#171510) (#171619)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-21 08:04:21 +02:00
Brandon Rothweiler b45aaaa177 Update py-aosmith to 1.0.18 (#171647) 2026-05-21 07:42:07 +02:00
Jan-Philipp Benecke 6560496440 Add missing WebDAV exception translation (#171614) 2026-05-20 20:46:31 -04:00
Erwin Douna 489dda8efb SMA refactor to new pylint (#171630) 2026-05-20 20:45:39 -04:00
Alexey Masolov 30c942d139 Catch requests.Timeout and apply TIMEOUT constant across CalDAV integration (#171632)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 20:45:08 -04:00
On Freund c735e47e23 Bump pyrisco to 0.7.0 (#171644) 2026-05-20 20:42:29 -04:00
Robert Resch 3856405c72 Add aw check requirements async block check (#171642) 2026-05-21 01:28:32 +02:00
Robert Resch 323479ca44 Fix aw check requirements safe output (#171643) 2026-05-21 01:16:25 +02:00
Raphael Hehl c8bfe56975 Fix hardcoded exception strings in unifi_access (#171629)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-05-21 00:37:08 +02:00
A. Gideonse ab214b64f2 Implement final Indevolt exceptions translations (#171635) 2026-05-21 00:35:01 +02:00
Max Michels fea673d93a Replace duplicate constants with homeassistant.const imports (#171639) 2026-05-21 00:24:05 +02:00
Max Michels 5405151112 Replace duplicate constants with homeassistant.const imports (#171637) 2026-05-21 00:12:23 +02:00
Max Michels b3c210ef24 Replace duplicate constants with homeassistant.const imports (#171638) 2026-05-21 00:11:59 +02:00
Robert Resch 5f5d74cfbd Remove requirements_test_all file (#171530) 2026-05-20 23:54:31 +02:00
Josh Gustafson c188fdcc8b Clean up arcam_fmj config flow (#171161)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:58:10 +02:00
Michael Hansen a3b43fc19b Handle multiple intents in Wyoming conversation (#171615)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
2026-05-20 16:48:56 -04:00
Maciej Bieniek 894a68acb6 Fix media_image_hash and validate the MIME type in the Shelly media player (#171585) 2026-05-20 22:25:30 +02:00
Kamil Breguła 30bc3fc412 Bump wled to 0.23.0 and remove backoff exception (#171622) 2026-05-20 22:16:43 +02:00
Michael 3cc0cc38ab Add missing translation placeholders for SMA exceptions (#171625) 2026-05-20 21:31:44 +02:00
Michael 296caa90c1 Fix exception strings in FRITZ!Box tools (#171603) 2026-05-20 20:55:42 +02:00
A. Gideonse bb4c211fb6 Add DHCP discovery to Indevolt (#169597)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-20 20:53:23 +02:00
Nick Haghiri d4fa904386 Add invalid_auth exception translation key to backblaze_b2 (#171584)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-20 20:51:44 +02:00
Erik Montnemery db98f0b434 Remove advanced mode from homeassistant service actions (#171440)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-20 20:40:36 +02:00
Erwin Douna 7341ac91ee SMA add missing exceptions (#171550) 2026-05-20 20:32:19 +02:00
Jan Bouwhuis b2fb5df0fb Remove positional message strings when translation_key is set in mqtt (#171617) 2026-05-20 20:16:41 +02:00
Manu 265485a7d0 Fix positional message strings in exceptions in Notify for Android TV / Fire TV integration (#171581) 2026-05-20 20:00:30 +02:00
J. Nick Koston bf1b93fb66 Bump aioesphomeapi to 45.0.4 (#171601) 2026-05-20 12:54:08 -05:00
dontinelli be9d4bedfd Fix update error message key in solarlog (#171611) 2026-05-20 19:53:19 +02:00
71 changed files with 1294 additions and 3343 deletions
-1
View File
@@ -19,7 +19,6 @@ machine/* linguist-generated=true
mypy.ini linguist-generated=true
requirements.txt linguist-generated=true
requirements_all.txt linguist-generated=true
requirements_test_all.txt linguist-generated=true
requirements_test_pre_commit.txt linguist-generated=true
script/hassfest/docker/Dockerfile linguist-generated=true
.github/workflows/*.lock.yml linguist-generated=true
+55 -23
View File
@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"1ad29b8fb97f5df4466be54051779a3188f094d7efb041a8ed55211eab33c5f5","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"d3abfe96a194bce3a523ed2093ddedd5704cdf62","version":"v0.74.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.46"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]}
# ___ _ _
# / _ \ | | (_)
@@ -65,7 +65,9 @@ run-name: "Check requirements (AW)"
jobs:
activation:
needs: pre_activation
needs:
- extract_pr_number
- pre_activation
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
if: >
(needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
@@ -189,20 +191,20 @@ jobs:
run: |
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
{
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
<system>
GH_AW_PROMPT_bb296919e461941b_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
<safe-output-tools>
Tools: add_comment, missing_tool, missing_data, noop
</safe-output-tools>
GH_AW_PROMPT_bb296919e461941b_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
<github-context>
The following GitHub context information is available for this workflow:
{{#if github.actor}}
@@ -231,12 +233,12 @@ jobs:
{{/if}}
</github-context>
GH_AW_PROMPT_bb296919e461941b_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
</system>
{{#runtime-import .github/workflows/check-requirements.md}}
GH_AW_PROMPT_bb296919e461941b_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -314,7 +316,9 @@ jobs:
retention-days: 1
agent:
needs: activation
needs:
- activation
- extract_pr_number
runs-on: ubuntu-latest
permissions:
actions: read
@@ -385,11 +389,6 @@ jobs:
name: check-requirements-deterministic
path: /tmp/gh-aw/deterministic
run-id: ${{ github.event.workflow_run.id }}
- if: github.event.workflow_run.conclusion == 'success'
name: Extract PR number from artifact
run: |-
PR=$(python3 -c 'import json,sys;print(json.load(open("/tmp/gh-aw/deterministic/results.json"))["pr_number"])')
echo "PR_NUMBER=${PR}" >> "${GITHUB_ENV}"
- name: Configure Git credentials
env:
@@ -454,15 +453,15 @@ jobs:
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
mkdir -p /tmp/gh-aw/safeoutputs
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_c09f5151c817ddfc_EOF'
{"add_comment":{"max":1,"target":"${{ env.PR_NUMBER }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
GH_AW_SAFE_OUTPUTS_CONFIG_c09f5151c817ddfc_EOF
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF'
{"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF
- name: Generate Safe Outputs Tools
env:
GH_AW_TOOLS_META_JSON: |
{
"description_suffixes": {
"add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ env.PR_NUMBER }}. Supports reply_to_id for discussion threading."
"add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ needs.extract_pr_number.outputs.pr_number }}. Supports reply_to_id for discussion threading."
},
"repo_params": {},
"dynamic_tools": []
@@ -648,7 +647,7 @@ jobs:
mkdir -p /home/runner/.copilot
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
cat << GH_AW_MCP_CONFIG_d12799b4d7ffe5c2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
{
"mcpServers": {
"github": {
@@ -692,7 +691,7 @@ jobs:
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
}
}
GH_AW_MCP_CONFIG_d12799b4d7ffe5c2_EOF
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
- name: Mount MCP servers as CLIs
id: mount-mcp-clis
continue-on-error: true
@@ -939,6 +938,7 @@ jobs:
- activation
- agent
- detection
- extract_pr_number
- safe_outputs
if: >
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
@@ -1283,6 +1283,37 @@ jobs:
}
}
extract_pr_number:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
outputs:
pr_number: ${{ steps.extract.outputs.pr_number }}
steps:
- name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash
run: |
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
GH_HOST="${GITHUB_SERVER_URL#https://}"
GH_HOST="${GH_HOST#http://}"
echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
name: check-requirements-deterministic
path: /tmp/deterministic
run-id: ${{ github.event.workflow_run.id }}
- name: Extract PR number from artifact
id: extract
run: |
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
pre_activation:
runs-on: ubuntu-slim
outputs:
@@ -1321,6 +1352,7 @@ jobs:
- activation
- agent
- detection
- extract_pr_number
if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success'
runs-on: ubuntu-slim
permissions:
@@ -1393,7 +1425,7 @@ jobs:
GH_AW_ALLOWED_DOMAINS: "*.pythonhosted.org,anaconda.org,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,binstar.org,bootstrap.pypa.io,conda.anaconda.org,conda.binstar.org,files.pythonhosted.org,github.com,host.docker.internal,pip.pypa.io,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.npmjs.org,repo.anaconda.com,repo.continuum.io,telemetry.enterprise.githubcopilot.com"
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_API_URL: ${{ github.api_url }}
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"${{ env.PR_NUMBER }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"${{ needs.extract_pr_number.outputs.pr_number }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
+138 -13
View File
@@ -19,7 +19,30 @@ tools:
safe-outputs:
add-comment:
max: 1
target: "${{ env.PR_NUMBER }}"
target: "${{ needs.extract_pr_number.outputs.pr_number }}"
needs:
- extract_pr_number
jobs:
extract_pr_number:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
outputs:
pr_number: ${{ steps.extract.outputs.pr_number }}
steps:
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: check-requirements-deterministic
path: /tmp/deterministic
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract PR number from artifact
id: extract
run: |
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
@@ -32,11 +55,6 @@ steps:
path: /tmp/gh-aw/deterministic
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract PR number from artifact
if: github.event.workflow_run.conclusion == 'success'
run: |
PR=$(python3 -c 'import json,sys;print(json.load(open("/tmp/gh-aw/deterministic/results.json"))["pr_number"])')
echo "PR_NUMBER=${PR}" >> "${GITHUB_ENV}"
post-steps:
- name: Verify agent produced an add_comment safe-output
if: always() && github.event.workflow_run.conclusion == 'success'
@@ -80,10 +98,11 @@ The deterministic stage uploaded its results to the runner at
The JSON has this shape:
- `pr_number` — the PR being checked. The `add_comment` safe-output is
already targeted at this PR (the workflow extracted `pr_number` from
the artifact and wired it into the safe-output config), so **you do
not need to set `item_number` yourself** — just emit `add_comment`
with the rendered body.
already targeted at this PR (a pre-job extracts `pr_number` from the
artifact and the workflow wires it into the safe-output config via
`needs.extract_pr_number.outputs.pr_number`), so **you do not need to
set `item_number` yourself** — just emit `add_comment` with the
rendered body.
- `needs_agent``true` iff any package's check needs resolution.
- `packages[]` — one entry per changed package. Each entry has:
- `name`, `old_version` (`null` for a newly added package; otherwise the
@@ -161,9 +180,10 @@ Verify that the package's source repository is publicly reachable.
- Any other inconclusive result → ⚠️ with a one-line description.
If `repo_public` resolves to ❌ for a package, **also** mark that
package's `release_pipeline` cell/detail as `` (em dash) and explain
`Skipped because the source repository is not publicly accessible.` —
because the release pipeline cannot be inspected without a public repo.
package's `release_pipeline` and `async_blocking` cells/details as ``
(em dash) and explain `Skipped because the source repository is not
publicly accessible.` — neither check can be performed without a public
repo.
### Check kind: `pr_link`
@@ -239,6 +259,111 @@ host from `package.repo_url`, then apply the corresponding checklist.
3. If no CI config can be retrieved: ⚠️ `Release pipeline could not be
inspected; hosting provider is not GitHub or GitLab.`
### Check kind: `async_blocking`
Verify whether the dependency performs blocking I/O inside async code
paths. Home Assistant runs on a single asyncio event loop, so a library
that exposes an `async` surface must not call blocking APIs from inside
its `async def` functions — that stalls the whole loop. A purely sync
library is fine: Home Assistant integrations are expected to wrap such
calls in an executor.
**Two modes — pick by inspecting `package.old_version`:**
- `old_version` is `null` → **new package**: review the *entire current
source tree*. Nothing about this dependency has been vetted before.
- `old_version` is a string → **version bump**: review only the *diff
between `old_version` and `new_version`*. The previous version was
already accepted, so blocking calls that were present in
`old_version` are not regressions; report only what `new_version`
introduces.
#### Step 1 — Decide whether the library exposes an async surface
Use the `github` MCP tool (for `github.com` repos) or `web-fetch`
(other hosts) on `package.repo_url`. Always inspect the tag /
ref matching `new_version` (e.g. `v{new_version}` or `{new_version}`).
- Locate the top-level package directory (usually named after the
import name, often equal or close to `package.name`).
- Check `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` for
async indicators (`Framework :: AsyncIO` trove classifier, `asyncio`
/ `aiohttp` / `httpx` / `anyio` in dependencies, an async usage
example in the README).
- Grep the package source for `async def`. A handful of `async def`
entries in the public modules is enough to treat the library as
having an async surface.
If the library is **sync-only** (no `async def` in its public modules
and no async framework dependency) → ✅
`Sync-only library; Home Assistant integrations must wrap calls in an
executor.` *This verdict is the same in both modes.*
#### Step 2a — Mode: new package (`old_version` is `null`)
Inspect **every `async def` in the public modules** for blocking
patterns. Walk transitively into helpers the async functions call.
#### Step 2b — Mode: version bump (`old_version` is a string)
Fetch the diff between the two tags and review **only changed lines**:
- GitHub: `GET /repos/{owner}/{repo}/compare/{old_tag}...{new_tag}` via
the `github` MCP tool, or
`https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag}.diff`
via `web-fetch`. Try the common tag formats in order until one
resolves: `v{version}`, `{version}`, `release-{version}`.
- GitLab: `https://gitlab.com/{namespace}/{project}/-/compare/{old_tag}...{new_tag}.diff`.
- Other hosts: use the project's equivalent compare URL via
`web-fetch`.
If neither tag format resolves on the host, fall back to a full review
(Step 2a) and mention in the detail that the diff was unavailable.
When reviewing the diff, only flag blocking patterns that appear in
**added lines** *inside or reachable from* an `async def`. A blocking
call that existed in `old_version` and is unchanged is not a regression
for this bump.
#### Step 3 — Blocking patterns to look for
In both modes, the patterns to flag inside `async def` bodies are:
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct use,
`http.client.`, sync `httpx.Client(` / `httpx.get(` (NOT the
`AsyncClient`), `pycurl`.
- `time.sleep(` (must be `await asyncio.sleep(`).
- Sync sockets: bare `socket.socket` reads/writes, `ssl.wrap_socket`,
blocking `select.select`.
- File I/O: `open(` / `pathlib.Path.read_*` / `.write_*` for
non-trivial sizes (small one-shot reads during import are
acceptable; reads/writes on the request path are not — prefer
`aiofiles` / executor).
- Sync DB drivers used directly: `sqlite3`, `psycopg2`, `pymysql`,
`pymongo` (sync client), `redis.Redis` (sync client).
- `subprocess.run` / `subprocess.call` / `os.system` (must be
`asyncio.create_subprocess_*`).
A call that is clearly dispatched to an executor
(`run_in_executor`, `asyncio.to_thread`, `anyio.to_thread.run_sync`)
does NOT count as blocking.
#### Step 4 — Verdict
- ✅ — no offending blocking pattern in the surface being reviewed
(whole tree for a new package, added lines for a bump). For a bump,
phrase the detail as `No new blocking calls introduced in
{old_version} → {new_version}.`.
- ⚠️ — blocking calls exist only in sync helpers that the async API
does not call, or only on a clearly non-hot path (e.g. one-shot
setup before the event loop is running). Cite at least one
`<file>:<line>` and explain why it is not on the hot path.
- ❌ — a blocking call is reachable from an `async def` that is part
of the public API on the request / polling path (for a bump: the
call was introduced or moved onto the hot path by this version).
Cite the offending `<file>:<line>` as a clickable link on the repo
host so the contributor can jump to it.
## Notes
- Be constructive and helpful. Reference the inspected workflow / CI
+3 -3
View File
@@ -132,7 +132,7 @@
"problemMatcher": []
},
{
"label": "Install all Requirements",
"label": "Install all production Requirements",
"type": "shell",
"command": "uv pip install -r requirements_all.txt",
"group": {
@@ -146,9 +146,9 @@
"problemMatcher": []
},
{
"label": "Install all Test Requirements",
"label": "Install all (test & production) Requirements",
"type": "shell",
"command": "uv pip install -r requirements.txt -r requirements_test_all.txt",
"command": "uv pip install -r requirements_all.txt -r requirements_test.txt",
"group": {
"kind": "build",
"isDefault": true
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aosmith",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["py-aosmith==1.0.17"]
"requirements": ["py-aosmith==1.0.18"]
}
@@ -1,9 +1,11 @@
"""Config flow to configure the Arcam FMJ component."""
import socket
from typing import Any
from urllib.parse import urlparse
from arcam.fmj.client import Client, ConnectionFailed
from arcam.fmj import ConnectionFailed
from arcam.fmj.client import Client
from arcam.fmj.utils import get_uniqueid_from_host, get_uniqueid_from_udn
import voluptuous as vol
@@ -29,26 +31,19 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(uuid)
self._abort_if_unique_id_configured({CONF_HOST: host, CONF_PORT: port})
async def _async_check_and_create(self, host: str, port: int) -> ConfigFlowResult:
async def _async_try_connect(self, host: str, port: int) -> None:
"""Verify the device is reachable."""
client = Client(host, port)
try:
await client.start()
except ConnectionFailed:
return self.async_abort(reason="cannot_connect")
finally:
await client.stop()
return self.async_create_entry(
title=f"{DEFAULT_NAME} ({host})",
data={CONF_HOST: host, CONF_PORT: port},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
uuid = await get_uniqueid_from_host(
async_get_clientsession(self.hass), user_input[CONF_HOST]
@@ -58,18 +53,36 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
user_input[CONF_HOST], user_input[CONF_PORT], uuid
)
return await self._async_check_and_create(
user_input[CONF_HOST], user_input[CONF_PORT]
)
try:
await self._async_try_connect(
user_input[CONF_HOST], user_input[CONF_PORT]
)
except socket.gaierror:
errors["base"] = "invalid_host"
except TimeoutError:
errors["base"] = "timeout_connect"
except ConnectionRefusedError:
errors["base"] = "connection_refused"
except ConnectionFailed, OSError:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})",
data={
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
},
)
fields = {
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
}
schema = vol.Schema(fields)
if user_input is not None:
schema = self.add_suggested_values_to_schema(schema, user_input)
return self.async_show_form(
step_id="user", data_schema=vol.Schema(fields), errors=errors
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
@@ -79,7 +92,10 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
self.context["title_placeholders"] = placeholders
if user_input is not None:
return await self._async_check_and_create(self.host, self.port)
return self.async_create_entry(
title=f"{DEFAULT_NAME} ({self.host})",
data={CONF_HOST: self.host, CONF_PORT: self.port},
)
return self.async_show_form(
step_id="confirm", description_placeholders=placeholders
@@ -97,6 +113,11 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
await self._async_set_unique_id_and_update(host, port, uuid)
try:
await self._async_try_connect(host, port)
except ConnectionFailed, OSError:
return self.async_abort(reason="cannot_connect")
self.host = host
self.port = DEFAULT_PORT
self.port = port
return await self.async_step_confirm()
@@ -5,6 +5,12 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"connection_refused": "Host refused connection",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
},
"flow_title": "{host}",
"step": {
"confirm": {
@@ -67,6 +67,9 @@
"cannot_connect": {
"message": "Cannot connect to endpoint"
},
"invalid_auth": {
"message": "Authentication failed using the provided key ID and application key."
},
"invalid_bucket_name": {
"message": "Bucket does not exist or is not writable by the provided credentials."
},
@@ -45,6 +45,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: CalDavConfigEntry) -> bo
# on some other unexpected server response.
_LOGGER.warning("Unexpected CalDAV server response: %s", err)
return False
except requests.Timeout as err:
raise ConfigEntryNotReady("Timeout connecting to CalDAV server") from err
except requests.ConnectionError as err:
raise ConfigEntryNotReady("Connection error from CalDAV server") from err
except DAVError as err:
+8 -2
View File
@@ -38,6 +38,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import CalDavConfigEntry
from .api import async_get_calendars
from .const import TIMEOUT
from .coordinator import CalDavUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -91,7 +92,12 @@ async def async_setup_platform(
days = config[CONF_DAYS]
client = caldav.DAVClient(
url, None, username, password, ssl_verify_cert=config[CONF_VERIFY_SSL]
url,
None,
username,
password,
ssl_verify_cert=config[CONF_VERIFY_SSL],
timeout=TIMEOUT,
)
calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT)
@@ -231,7 +237,7 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE
await self.hass.async_add_executor_job(
partial(self.coordinator.calendar.add_event, **item_data),
)
except (requests.ConnectionError, DAVError) as err:
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err
@callback
+5 -5
View File
@@ -138,7 +138,7 @@ class WebDavTodoListEntity(TodoListEntity):
)
# refreshing async otherwise it would take too much time
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
except (requests.ConnectionError, DAVError) as err:
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err
async def async_update_todo_item(self, item: TodoItem) -> None:
@@ -150,7 +150,7 @@ class WebDavTodoListEntity(TodoListEntity):
)
except NotFoundError as err:
raise HomeAssistantError(f"Could not find To-do item {uid}") from err
except (requests.ConnectionError, DAVError) as err:
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
vtodo = todo.icalendar_component # type: ignore[attr-defined]
vtodo["SUMMARY"] = item.summary or ""
@@ -174,7 +174,7 @@ class WebDavTodoListEntity(TodoListEntity):
)
# refreshing async otherwise it would take too much time
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
except (requests.ConnectionError, DAVError) as err:
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err
async def async_delete_todo_items(self, uids: list[str]) -> None:
@@ -188,14 +188,14 @@ class WebDavTodoListEntity(TodoListEntity):
items = await asyncio.gather(*tasks)
except NotFoundError as err:
raise HomeAssistantError("Could not find To-do item") from err
except (requests.ConnectionError, DAVError) as err:
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
# Run serially as some CalDAV servers do not support concurrent modifications
for item in items:
try:
await self.hass.async_add_executor_job(item.delete)
except (requests.ConnectionError, DAVError) as err:
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
raise HomeAssistantError(f"CalDAV delete error: {err}") from err
# refreshing async otherwise it would take too much time
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==45.0.2",
"aioesphomeapi==45.0.4",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.7.3"
],
+9 -3
View File
@@ -61,14 +61,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo
except FRITZ_AUTH_EXCEPTIONS as ex:
raise ConfigEntryAuthFailed from ex
except FRITZ_EXCEPTIONS as ex:
raise ConfigEntryNotReady from ex
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="error_connecting",
translation_placeholders={"error": str(ex)},
) from ex
if (
"X_AVM-DE_UPnP1" in avm_wrapper.connection.services
and not (await avm_wrapper.async_get_upnp_configuration())["NewEnable"]
):
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed("Missing UPnP configuration")
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="error_upnp_disabled",
)
await avm_wrapper.async_config_entry_first_refresh()
+6 -3
View File
@@ -185,12 +185,18 @@
"config_entry_not_found": {
"message": "Failed to perform action \"{service}\". Config entry for target not found"
},
"error_connecting": {
"message": "Error connecting to the FRITZ!Box: {error}"
},
"error_parse_device_info": {
"message": "Error parsing device info. Please check the system event log of your FRITZ!Box for malformed data and clear the event list."
},
"error_refresh_hosts_info": {
"message": "Error refreshing hosts info"
},
"error_upnp_disabled": {
"message": "UPnP is disabled on the FRITZ!Box. Please enable UPnP to use this integration."
},
"service_dial_failed": {
"message": "Failed to dial, check if the click to dial service of the FRITZ!Box is activated"
},
@@ -200,9 +206,6 @@
"service_parameter_unknown": {
"message": "Action or parameter unknown"
},
"unable_to_connect": {
"message": "Unable to establish a connection"
},
"update_failed": {
"message": "Error while updating the data: {error}"
}
@@ -806,10 +806,10 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa:
data["daysOfMonth"] = [start_date.day]
data["weeksOfMonth"] = []
if interval := call.data.get(ATTR_INTERVAL):
if (interval := call.data.get(ATTR_INTERVAL)) is not None:
data["everyX"] = interval
if streak := call.data.get(ATTR_STREAK):
if (streak := call.data.get(ATTR_STREAK)) is not None:
data["streak"] = streak
try:
@@ -52,9 +52,7 @@ reload_config_entry:
target:
fields:
entry_id:
advanced: true
required: false
example: 8955375327824e14ba89e4b29cc3ec9a
selector:
config_entry:
@@ -223,10 +223,10 @@
"name": "Reload all Home Assistant configuration"
},
"reload_config_entry": {
"description": "Reloads the specified config entry.",
"description": "Reloads any explicitly provided config entry ID and any config entries referenced by entities or devices in the target. If both are provided, the union of those config entries is reloaded.",
"fields": {
"entry_id": {
"description": "The configuration entry ID of the entry to be reloaded.",
"description": "Optional configuration entry ID to reload.",
"name": "Config entry ID"
}
},
@@ -16,6 +16,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_COMMAND,
CONF_HOST,
CONF_ID,
CONF_NAME,
@@ -39,9 +40,6 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT]
# pylint: disable-next=home-assistant-duplicate-const
CONF_COMMAND = "command"
EVENT_BUTTON_PRESS = "homeworks_button_press"
EVENT_BUTTON_RELEASE = "homeworks_button_release"
@@ -10,6 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_GENERATION, CONF_SERIAL_NUMBER, DEFAULT_PORT, DOMAIN
@@ -21,6 +22,12 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._discovered_host: str | None = None
self._discovered_device_data: dict[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -83,6 +90,55 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery — probe the device to confirm it is an Indevolt device."""
host = discovery_info.ip
try:
device_data = await self._async_get_device_data(host)
except OSError, ClientError, KeyError:
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(device_data[CONF_SERIAL_NUMBER])
self._abort_if_unique_id_configured(
updates={CONF_HOST: host}, reload_on_update=True
)
self.context["title_placeholders"] = {"model": device_data[CONF_MODEL]}
self._discovered_host = host
self._discovered_device_data = device_data
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm DHCP discovery by user."""
assert self._discovered_host is not None
assert self._discovered_device_data is not None
# Attempt to setup from user input
if user_input is not None:
return self.async_create_entry(
title=f"INDEVOLT {self._discovered_device_data[CONF_MODEL]}",
data={
CONF_HOST: self._discovered_host,
**self._discovered_device_data,
},
)
# Retrieve user confirmation
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={
CONF_HOST: self._discovered_host,
CONF_MODEL: self._discovered_device_data[CONF_MODEL],
},
)
async def _async_validate_input(
self, user_input: dict[str, Any]
) -> tuple[dict[str, str], dict[str, Any] | None]:
@@ -72,7 +72,11 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
config_data = await self.api.get_config()
except (ClientError, OSError) as err:
raise ConfigEntryNotReady(f"Device config retrieval failed: {err}") from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="config_entry_not_ready",
translation_placeholders={"error": str(err)},
) from err
# Cache device information
device_data = config_data.get("device", {})
@@ -87,7 +91,11 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
return await self.api.fetch_data(sensor_keys)
except (ClientError, OSError) as err:
raise UpdateFailed(f"Device update failed: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
async def async_push_data(self, sensor_key: str, value: Any) -> bool:
"""Push/write data values to given key on the device."""
@@ -3,6 +3,12 @@
"name": "Indevolt",
"codeowners": ["@xirt"],
"config_flow": true,
"dhcp": [
{ "macaddress": "1C784B*" },
{ "macaddress": "34EAE7*" },
{ "macaddress": "7C3E82*" },
{ "registered_devices": true }
],
"documentation": "https://www.home-assistant.io/integrations/indevolt",
"integration_type": "device",
"iot_class": "local_polling",
@@ -40,12 +40,8 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: Integration does not support network discovery
discovery:
status: exempt
comment: Integration does not support network discovery
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
@@ -60,7 +56,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
@@ -12,6 +12,10 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"discovery_confirm": {
"description": "Do you want to add {model} ({host}) to Home Assistant?",
"title": "Discovered Indevolt {model}"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
@@ -369,6 +373,9 @@
}
},
"exceptions": {
"config_entry_not_ready": {
"message": "Device config retrieval failed: {error}"
},
"energy_mode_change_unavailable_outdoor_portable": {
"message": "Energy mode cannot be changed when the device is in outdoor/portable mode"
},
@@ -396,6 +403,9 @@
"soc_below_minimum": {
"message": "Target SOC ({target}%) is below the device minimum ({minimum_soc}%)"
},
"update_failed": {
"message": "Device update failed: {error}"
},
"write_error": {
"message": "Cannot update value for {name}"
}
+2 -2
View File
@@ -293,11 +293,11 @@ async def async_check_config_schema(
)
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
message,
translation_domain=DOMAIN,
translation_key="invalid_platform_config",
translation_key="invalid_platform_config_message",
translation_placeholders={
"domain": domain,
"message": message,
},
) from exc
+1 -4
View File
@@ -158,7 +158,6 @@ async def async_publish(
if not mqtt_config_entry_enabled(hass):
# pylint: disable-next=home-assistant-exception-message-with-translation
raise HomeAssistantError(
f"Cannot publish to topic '{topic}', MQTT is not enabled",
translation_key="mqtt_not_setup_cannot_publish",
translation_domain=DOMAIN,
translation_placeholders={"topic": topic},
@@ -284,7 +283,6 @@ def async_subscribe_internal(
except KeyError as exc:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise HomeAssistantError(
f"Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly",
translation_key="mqtt_not_setup_cannot_subscribe",
translation_domain=DOMAIN,
translation_placeholders={"topic": topic},
@@ -293,8 +291,7 @@ def async_subscribe_internal(
if not mqtt_config_entry_enabled(hass):
# pylint: disable-next=home-assistant-exception-message-with-translation
raise HomeAssistantError(
f"Cannot subscribe to topic '{topic}', MQTT is not enabled",
translation_key="mqtt_not_setup_cannot_subscribe",
translation_key="mqtt_not_enabled_cannot_subscribe",
translation_domain=DOMAIN,
translation_placeholders={"topic": topic},
)
-2
View File
@@ -75,8 +75,6 @@ class SubscriptionID:
if subscription_id > MAX_28BIT:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise HomeAssistantError(
"MQTT Subscription ID limit reached. "
"Cannot generate more IDs to subscribe",
translation_domain=DOMAIN,
translation_key="mqtt_max_subscription_id_reached",
)
+5 -2
View File
@@ -1090,8 +1090,8 @@
"command_template_error": {
"message": "Parsing template `{command_template}` for entity `{entity_id}` failed with error: {error}."
},
"invalid_platform_config": {
"message": "Reloading YAML config for manually configured MQTT `{domain}` item failed. See logs for more details."
"invalid_platform_config_message": {
"message": "Reloading YAML config for manually configured MQTT `{domain}` item failed. Message: {message}"
},
"invalid_publish_topic": {
"message": "Unable to publish: topic template `{topic_template}` produced an invalid topic `{topic}` after rendering ({error})"
@@ -1105,6 +1105,9 @@
"mqtt_message_expiry_interval_not_supported": {
"message": "Publishing to topic {topic} with a Message Expiry Interval is not supported for protocol version {protocol}."
},
"mqtt_not_enabled_cannot_subscribe": {
"message": "Cannot subscribe to topic \"{topic}\" because MQTT is not enabled, make sure MQTT is set up correctly."
},
"mqtt_not_setup_cannot_publish": {
"message": "Cannot publish to topic \"{topic}\", make sure MQTT is set up correctly."
},
@@ -162,7 +162,6 @@ class NFAndroidTVNotificationService(BaseNotificationService):
else:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
"Invalid image provided",
translation_domain=DOMAIN,
translation_key="invalid_notification_image",
translation_placeholders={"type": type(imagedata).__name__},
@@ -185,7 +184,6 @@ class NFAndroidTVNotificationService(BaseNotificationService):
else:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
"Invalid Icon provided",
translation_domain=DOMAIN,
translation_key="invalid_notification_icon",
translation_placeholders={"type": type(icondata).__name__},
@@ -15,8 +15,6 @@ ATTR_REMOTE = "remote"
ATTR_DEVICE_INFO = "device_info"
ATTR_FRIENDLY_NAME = "friendlyName"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MANUFACTURER = "manufacturer"
ATTR_MODEL_NUMBER = "modelNumber"
ATTR_UDN = "UDN"
@@ -15,7 +15,7 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
from homeassistant.const import CONF_NAME
from homeassistant.const import ATTR_MANUFACTURER, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -23,7 +23,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PanasonicVieraConfigEntry
from .const import (
ATTR_DEVICE_INFO,
ATTR_MANUFACTURER,
ATTR_MODEL_NUMBER,
ATTR_UDN,
DEFAULT_MANUFACTURER,
@@ -4,7 +4,7 @@ from collections.abc import Iterable
from typing import Any
from homeassistant.components.remote import RemoteEntity
from homeassistant.const import CONF_NAME, STATE_ON
from homeassistant.const import ATTR_MANUFACTURER, CONF_NAME, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PanasonicVieraConfigEntry, Remote
from .const import (
ATTR_DEVICE_INFO,
ATTR_MANUFACTURER,
ATTR_MODEL_NUMBER,
ATTR_UDN,
DEFAULT_MANUFACTURER,
+1 -1
View File
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/risco",
"iot_class": "local_push",
"loggers": ["pyrisco"],
"requirements": ["pyrisco==0.6.8"]
"requirements": ["pyrisco==0.7.0"]
}
+43 -10
View File
@@ -2,6 +2,7 @@
import base64
import binascii
import contextlib
from dataclasses import dataclass
import datetime
import hashlib
@@ -38,6 +39,15 @@ from .utils import get_device_entry_gen
CONTENT_TYPE_AUDIO = "audio"
CONTENT_TYPE_RADIO = "radio"
ALLOWED_IMAGE_MIME_TYPES: Final = frozenset(
{
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
}
)
PARALLEL_UPDATES = 0
@@ -102,6 +112,9 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
_last_media_position: int | None = None
_last_media_position_updated_at: datetime.datetime | None = None
_cached_thumb: str | None = None
_cached_thumb_result: tuple[bytes, str] | None = None
def __init__(
self,
coordinator: ShellyRpcCoordinator,
@@ -215,9 +228,11 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
@property
def media_image_hash(self) -> str | None:
"""Hash value for media image."""
if (thumb := self._media_meta.get("thumb")) and thumb.startswith("data"):
return hashlib.sha256(thumb.encode("utf-8")).hexdigest()[:16]
return super().media_image_hash
thumb = self._media_meta.get("thumb")
if not thumb or self._decode_image_data(thumb) is None:
return super().media_image_hash
return hashlib.sha256(thumb.encode("utf-8")).hexdigest()[:16]
def _get_updated_media_position(self) -> int | None:
"""Return the current playback position and update its timestamp."""
@@ -235,15 +250,11 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
"""Fetch media image of current playing track."""
thumb = self._media_meta["thumb"]
try:
prefix, image_data = thumb.split(",", 1)
image = base64.b64decode(image_data, validate=True)
mime = prefix.split(";", 1)[0].rsplit(":", 1)[-1]
except binascii.Error, ValueError:
thumb = self._media_meta.get("thumb")
if not thumb or (result := self._decode_image_data(thumb)) is None:
return await super().async_get_media_image()
return image, mime
return result
@rpc_call
async def async_media_play(self) -> None:
@@ -434,3 +445,25 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
translation_key="unsupported_media_type",
translation_placeholders={"media_type": str(media_type)},
)
def _decode_image_data(self, thumb: str) -> tuple[bytes, str] | None:
"""Return image_bytes and mime_type for a valid image data or None."""
if thumb == self._cached_thumb:
return self._cached_thumb_result
result: tuple[bytes, str] | None = None
if thumb.startswith("data"):
try:
prefix, image_data = thumb.split(",", 1)
mime = prefix.split(";", 1)[0].rsplit(":", 1)[-1]
except IndexError, ValueError:
pass
else:
if mime in ALLOWED_IMAGE_MIME_TYPES:
with contextlib.suppress(binascii.Error):
result = base64.b64decode(image_data, validate=True), mime
self._cached_thumb = thumb
self._cached_thumb_result = result
return result
@@ -69,18 +69,14 @@ class SMADataUpdateCoordinator(DataUpdateCoordinator[SMACoordinatorData]):
SmaConnectionException,
) as err:
await self.async_close_sma_session()
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except SmaAuthenticationException as err:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
async def _async_update_data(self) -> SMACoordinatorData:
@@ -91,18 +87,14 @@ class SMADataUpdateCoordinator(DataUpdateCoordinator[SMACoordinatorData]):
SmaReadException,
SmaConnectionException,
) as err:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except SmaAuthenticationException as err:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
return SMACoordinatorData(
@@ -67,6 +67,14 @@
}
}
},
"exceptions": {
"cannot_connect": {
"message": "Could not connect to SMA device"
},
"invalid_auth": {
"message": "Invalid authentication for SMA device"
}
},
"selector": {
"group": {
"options": {
@@ -394,7 +394,7 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.COMPLETION_TIME,
translation_key="completion_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
)
],
},
@@ -447,7 +447,7 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.COMPLETION_TIME,
translation_key="completion_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
)
],
},
@@ -565,7 +565,7 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.GAS_METER_TIME,
translation_key="gas_meter_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
)
],
Attribute.GAS_METER_VOLUME: [
@@ -726,7 +726,7 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.COMPLETION_TIME,
translation_key="completion_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
component_fn=lambda component: component == "cavity-01",
component_translation_key={
"cavity-01": "oven_completion_time_cavity_01",
@@ -1196,7 +1196,7 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.COMPLETION_TIME,
translation_key="completion_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
component_fn=lambda component: component == "sub",
component_translation_key={
"sub": "washer_sub_completion_time",
@@ -145,7 +145,7 @@
"config_entry_not_ready": {
"message": "Error while loading the config entry."
},
"update_error": {
"update_failed": {
"message": "Error while updating data from the API."
}
}
+1 -2
View File
@@ -11,7 +11,7 @@ from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MAX_LENGTH_STATE_STATE
from homeassistant.const import ATTR_MODE, MAX_LENGTH_STATE_STATE
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity, EntityDescription
@@ -23,7 +23,6 @@ from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_MAX,
ATTR_MIN,
ATTR_MODE,
ATTR_PATTERN,
ATTR_VALUE,
DOMAIN,
-2
View File
@@ -4,8 +4,6 @@ DOMAIN = "text"
ATTR_MAX = "max"
ATTR_MIN = "min"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODE = "mode"
ATTR_PATTERN = "pattern"
ATTR_VALUE = "value"
@@ -51,14 +51,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry)
try:
await client.authenticate()
except ApiAuthError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed(
f"Authentication failed for UniFi Access at {entry.data[CONF_HOST]}"
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"host": entry.data[CONF_HOST]},
) from err
except ApiConnectionError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryNotReady(
f"Unable to connect to UniFi Access at {entry.data[CONF_HOST]}"
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"host": entry.data[CONF_HOST]},
) from err
coordinator = UnifiAccessCoordinator(hass, entry, client)
@@ -197,17 +197,25 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
self.client.get_emergency_status(),
)
except ApiAuthError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="update_failed_auth",
) from err
except ApiConnectionError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(f"Error connecting to API: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_connection",
) from err
except ApiError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(f"Error communicating with API: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_api",
) from err
except TimeoutError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed("Timeout communicating with UniFi Access API") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_timeout",
) from err
previous_lock_rules = self.data.door_lock_rules.copy() if self.data else {}
door_lock_rules: dict[str, DoorLockRuleStatus] = {}
@@ -133,6 +133,12 @@
}
},
"exceptions": {
"auth_failed": {
"message": "Authentication failed for UniFi Access at {host}."
},
"cannot_connect": {
"message": "Unable to connect to UniFi Access at {host}."
},
"emergency_failed": {
"message": "Failed to set emergency status."
},
@@ -150,6 +156,18 @@
},
"unlock_failed": {
"message": "Failed to unlock the door."
},
"update_failed_api": {
"message": "Error communicating with the UniFi Access API."
},
"update_failed_auth": {
"message": "Authentication failed while updating data."
},
"update_failed_connection": {
"message": "Error connecting to the UniFi Access API."
},
"update_failed_timeout": {
"message": "Timeout communicating with the UniFi Access API."
}
},
"selector": {
@@ -84,8 +84,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
except NotAuthorized as err:
data_service.auth_retries += 1
if data_service.auth_retries > AUTH_RETRIES:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="entry_auth_failed",
) from err
raise ConfigEntryNotReady from err
except (TimeoutError, ClientError, ServerDisconnectedError) as err:
raise ConfigEntryNotReady from err
@@ -105,12 +105,14 @@ def _get_month_start_end(start: datetime) -> tuple[datetime, datetime]:
@callback
def _bad_identifier(identifier: str, err: Exception | None = None) -> NoReturn:
msg = f"Unexpected identifier: {identifier}"
exc = BrowseError(
translation_domain=DOMAIN,
translation_key="unexpected_identifier",
translation_placeholders={"identifier": identifier},
)
if err is None:
# pylint: disable-next=home-assistant-exception-not-translated
raise BrowseError(msg)
# pylint: disable-next=home-assistant-exception-not-translated
raise BrowseError(msg) from err
raise exc
raise exc from err
@callback
@@ -379,8 +381,10 @@ class ProtectMediaSource(MediaSource):
_bad_identifier(f"{data.api.bootstrap.nvr.id}:{subtype}:{event_id}", err)
if event.start is None or event.end is None:
# pylint: disable-next=home-assistant-exception-not-translated
raise BrowseError("Event is still ongoing")
raise BrowseError(
translation_domain=DOMAIN,
translation_key="event_ongoing",
)
return await self._build_event(data, event, thumbnail_only)
@@ -790,8 +794,11 @@ class ProtectMediaSource(MediaSource):
if camera_id != "all":
camera = data.api.bootstrap.cameras.get(camera_id)
if camera is None:
# pylint: disable-next=home-assistant-exception-not-translated
raise BrowseError(f"Unknown Camera ID: {camera_id}")
raise BrowseError(
translation_domain=DOMAIN,
translation_key="unknown_camera_id",
translation_placeholders={"camera_id": camera_id},
)
name = camera.name or camera.market_name or camera.type
is_doorbell = camera.feature_flags.is_doorbell
has_smart = camera.feature_flags.has_smart_detect
@@ -679,6 +679,12 @@
"device_not_found": {
"message": "No device found for device id: {device_id}"
},
"entry_auth_failed": {
"message": "Authentication failed, please reauthenticate"
},
"event_ongoing": {
"message": "Event is still ongoing"
},
"global_alarm_manager": {
"message": "The alarm manager on this UniFi Protect NVR is set to Global mode and cannot be controlled locally."
},
@@ -717,6 +723,12 @@
},
"stream_error": {
"message": "Error playing audio, check the logs for more details"
},
"unexpected_identifier": {
"message": "Unexpected identifier: {identifier}"
},
"unknown_camera_id": {
"message": "Unknown camera ID: {camera_id}"
}
},
"issues": {
@@ -30,6 +30,9 @@
}
},
"exceptions": {
"cannot_access_or_create_backup_path": {
"message": "Cannot access or create backup path"
},
"cannot_connect": {
"message": "Cannot connect to WebDAV server"
},
+1 -1
View File
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "platinum",
"requirements": ["wled==0.22.0"],
"requirements": ["wled==0.23.0"],
"zeroconf": ["_wled._tcp.local."]
}
+124 -50
View File
@@ -1,5 +1,6 @@
"""Support for Wyoming intent recognition services."""
import asyncio
import logging
from typing import Any, Literal
@@ -7,7 +8,7 @@ from wyoming.asr import Transcript
from wyoming.client import AsyncTcpClient
from wyoming.handle import Handled, NotHandled
from wyoming.info import HandleProgram, IntentProgram
from wyoming.intent import Intent, NotRecognized
from wyoming.intent import Intent, IntentsStart, IntentsStop, NotRecognized
from homeassistant.components import conversation
from homeassistant.const import MATCH_ALL
@@ -86,6 +87,10 @@ class WyomingConversationEntity(
model_languages.update(handle_model.languages)
self._attr_name = self._handle_service.name
if self._handle_service.supports_home_control:
self._attr_supported_features = (
conversation.ConversationEntityFeature.CONTROL
)
self._supported_languages = list(model_languages)
self._attr_unique_id = f"{config_entry.entry_id}-conversation"
@@ -165,62 +170,27 @@ class WyomingConversationEntity(
intent_response: intent.IntentResponse,
) -> intent.IntentResponse:
"""Process a sentence into an intent response."""
has_intents_list = False
intents: list[Intent] = []
while True:
event = await client.read_event()
if event is None:
raise WyomingError("Connection lost")
if IntentsStart.is_type(event.type):
# Multiple intents may be present
has_intents_list = True
continue
if Intent.is_type(event.type):
# Success
recognized_intent = Intent.from_event(event)
_LOGGER.debug("Recognized intent: %s", recognized_intent)
intent_type = recognized_intent.name
intent_slots = {
e.name: {"value": e.value} for e in recognized_intent.entities
}
# Add to trace and chat log
conversation.async_conversation_trace_append(
conversation.ConversationTraceEventType.TOOL_CALL,
{
"intent_name": intent_type,
"slots": intent_slots,
},
)
tool_input = llm.ToolInput(
tool_name=intent_type,
tool_args=intent_slots,
external=True,
)
chat_log.async_add_assistant_content_without_tools(
conversation.AssistantContent(
agent_id=user_input.agent_id,
content=None,
tool_calls=[tool_input],
)
)
intent_response = await intent.async_handle(
self.hass,
DOMAIN,
intent_type,
intent_slots,
text_input=user_input.text,
language=user_input.language,
satellite_id=user_input.satellite_id,
device_id=user_input.device_id,
)
if (not intent_response.speech) and recognized_intent.text:
response_text = recognized_intent.text
if template.is_template_string(response_text):
# Render text as a template
response_text = self._render_speech_template(
response_text, intent_response, intent_slots
)
intent_response.async_set_speech(response_text)
intents.append(Intent.from_event(event))
if not has_intents_list:
# Only one intent, no need to wait
break
if IntentsStop.is_type(event.type):
# End of intents list
break
if NotRecognized.is_type(event.type):
@@ -230,6 +200,9 @@ class WyomingConversationEntity(
intent.IntentResponseErrorCode.NO_INTENT_MATCH,
not_recognized.text or "",
)
# Don't process any intents if one was not recognized
intents.clear()
break
if Handled.is_type(event.type):
@@ -247,6 +220,107 @@ class WyomingConversationEntity(
)
break
if not intents:
return intent_response
# Process recognized intents with a task group.
# If any intent fails to be handled, the rest are cancelled.
intent_responses: list[intent.IntentResponse] = []
try:
async with asyncio.TaskGroup() as task_group:
intent_tasks: list[tuple[str, dict, str | None, asyncio.Task]] = []
for recognized_intent in intents:
_LOGGER.debug("Handling intent: %s", recognized_intent)
intent_type = recognized_intent.name
intent_slots = {
e.name: {"value": e.value} for e in recognized_intent.entities
}
# Add to trace
conversation.async_conversation_trace_append(
conversation.ConversationTraceEventType.TOOL_CALL,
{
"intent_name": intent_type,
"slots": intent_slots,
},
)
intent_tasks.append(
(
intent_type,
intent_slots,
recognized_intent.text,
task_group.create_task(
intent.async_handle(
self.hass,
DOMAIN,
intent_type,
intent_slots,
text_input=user_input.text,
language=user_input.language,
satellite_id=user_input.satellite_id,
device_id=user_input.device_id,
)
),
)
)
except* intent.IntentError as err_group:
# Bubble up first exception only.
# There's nothing the caller can do with multiple intent errors.
raise err_group.exceptions[0] from err_group
# Gather intent handling results
tool_calls: list[llm.ToolInput] = []
for intent_type, intent_slots, intent_text, intent_task in intent_tasks:
intent_task_response = await intent_task
intent_responses.append(intent_task_response)
# For the chat log
tool_calls.append(
llm.ToolInput(
tool_name=intent_type,
tool_args=intent_slots,
external=True,
)
)
# Process speech
if (not intent_task_response.speech) and intent_text:
if template.is_template_string(intent_text):
# Render text as a template
intent_text = self._render_speech_template(
intent_text, intent_task_response, intent_slots
)
intent_task_response.async_set_speech(intent_text)
# Add all tool calls to the chat log
chat_log.async_add_assistant_content_without_tools(
conversation.AssistantContent(
agent_id=user_input.agent_id,
content=None,
tool_calls=tool_calls,
)
)
# Must be the case because an exception would have been thrown otherwise
assert intent_responses
# Use the properties of the first intent (response_type, etc.) and
# combine the speech results.
intent_response = intent_responses[0]
speech_texts: list[str] = [
speech
for current_response in intent_responses
if (speech := current_response.speech.get("plain", {}).get("speech"))
]
if speech_texts:
# Combine response with newlines because punctuation would be
# language-dependent.
intent_response.async_set_speech("\n".join(speech_texts))
return intent_response
def _render_speech_template(
+16
View File
@@ -349,6 +349,22 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "incomfort",
"registered_devices": True,
},
{
"domain": "indevolt",
"macaddress": "1C784B*",
},
{
"domain": "indevolt",
"macaddress": "34EAE7*",
},
{
"domain": "indevolt",
"macaddress": "7C3E82*",
},
{
"domain": "indevolt",
"registered_devices": True,
},
{
"domain": "insteon",
"macaddress": "000EF3*",
+4 -4
View File
@@ -254,7 +254,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==45.0.2
aioesphomeapi==45.0.4
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -1890,7 +1890,7 @@ pushover_complete==1.2.0
pvo==3.0.0
# homeassistant.components.aosmith
py-aosmith==1.0.17
py-aosmith==1.0.18
# homeassistant.components.canary
py-canary==0.5.4
@@ -2479,7 +2479,7 @@ pyrecswitch==1.0.2
pyrepetierng==0.1.0
# homeassistant.components.risco
pyrisco==0.6.8
pyrisco==0.7.0
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.7
@@ -3356,7 +3356,7 @@ wiim==0.1.2
wirelesstagpy==0.8.1
# homeassistant.components.wled
wled==0.22.0
wled==0.23.0
# homeassistant.components.wolflink
wolf-comm==0.0.48
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -9,7 +9,8 @@ cd "$(realpath "$(dirname "$0")/..")"
echo "Installing development dependencies..."
uv pip install \
-e . \
-r requirements_test_all.txt \
-r requirements_all.txt \
-r requirements_test.txt \
colorlog \
--upgrade \
--config-settings editable_mode=compat
+1
View File
@@ -26,6 +26,7 @@ class CheckKind(StrEnum):
CI_UPLOAD = "ci_upload"
RELEASE_PIPELINE = "release_pipeline"
PR_LINK = "pr_link"
ASYNC_BLOCKING = "async_blocking"
@dataclass(slots=True)
+1
View File
@@ -20,6 +20,7 @@ _CHECK_DISPLAY: tuple[tuple[CheckKind, str], ...] = (
(CheckKind.CI_UPLOAD, "CI Upload"),
(CheckKind.RELEASE_PIPELINE, "Release Pipeline"),
(CheckKind.PR_LINK, "PR Link"),
(CheckKind.ASYNC_BLOCKING, "Async Safe"),
)
_ICONS: dict[CheckStatus, str] = {
+19
View File
@@ -10,6 +10,9 @@ What the runner defers to the LLM (NEEDS_AGENT):
- `pr_link`: presence of the right link in the PR description.
- `release_pipeline`: inspection of the publish workflow when the attestation
was missing or did not identify a recognised CI publisher.
- `async_blocking`: inspection of the dependency source for blocking I/O
inside `async def` functions. Always deferred when the source repo is
available — the deterministic stage cannot read the upstream source.
"""
from .diff import parse_diff
@@ -74,6 +77,7 @@ def run_checks(
)
pkg.checks[CheckKind.REPO_PUBLIC] = fail
pkg.checks[CheckKind.PR_LINK] = fail
pkg.checks[CheckKind.ASYNC_BLOCKING] = fail
elif pkg.repo_url:
pkg.checks[CheckKind.REPO_PUBLIC] = CheckResult(
CheckStatus.NEEDS_AGENT,
@@ -83,6 +87,20 @@ def run_checks(
CheckStatus.NEEDS_AGENT,
"Presence of the required link in the PR description must be verified by the agent.",
)
if pkg.old_version is None:
async_reason = (
"New dependency: agent must review the entire source tree "
"at the new version for blocking I/O inside async functions."
)
else:
async_reason = (
f"Version bump {pkg.old_version}{pkg.new_version}: "
"agent must review only the diff for newly introduced "
"blocking I/O inside async functions."
)
pkg.checks[CheckKind.ASYNC_BLOCKING] = CheckResult(
CheckStatus.NEEDS_AGENT, async_reason
)
else:
fail = CheckResult(
CheckStatus.FAIL,
@@ -90,6 +108,7 @@ def run_checks(
)
pkg.checks[CheckKind.REPO_PUBLIC] = fail
pkg.checks[CheckKind.PR_LINK] = fail
pkg.checks[CheckKind.ASYNC_BLOCKING] = fail
result = CheckRunResult(pr_number=pr_number, packages=packages)
result.rendered_comment = render_comment(result)
return result
-40
View File
@@ -262,19 +262,6 @@ IGNORE_PRE_COMMIT_HOOK_ID = (
PACKAGE_REGEX = re.compile(r"^(?:--.+\s)?([-_\.\w\d]+).*==.+$")
def has_tests(module: str) -> bool:
"""Test if a module has tests.
Module format: homeassistant.components.hue
Test if exists: tests/components/hue/__init__.py
"""
path = (
Path(module.replace(".", "/").replace("homeassistant", "tests", 1))
/ "__init__.py"
)
return path.exists()
def explore_module(package: str, explore_children: bool) -> list[str]:
"""Explore the modules."""
module = importlib.import_module(package)
@@ -511,31 +498,6 @@ def requirements_all_action_output(reqs: dict[str, list[str]], action: str) -> s
return "".join(output)
def requirements_test_all_output(reqs: dict[str, list[str]]) -> str:
"""Generate output for test_requirements."""
output = [
"# Home Assistant tests, full dependency set\n",
GENERATED_MESSAGE,
"-r requirements_test.txt\n",
]
filtered = {
requirement: modules
for requirement, modules in reqs.items()
if any(
# Always install requirements that are not part of integrations
not mdl.startswith("homeassistant.components.")
or
# Install tests for integrations that have tests
has_tests(mdl)
for mdl in modules
)
}
output.append(generate_requirements_list(filtered))
return "".join(output)
def requirements_pre_commit_output() -> str:
"""Generate output for pre-commit dependencies."""
source = ".pre-commit-config.yaml"
@@ -609,7 +571,6 @@ def main(validate: bool, ci: bool) -> int:
action: requirements_all_action_output(data, action)
for action in OVERRIDDEN_REQUIREMENTS_ACTIONS
}
reqs_test_all_file = requirements_test_all_output(data)
# Always calling requirements_pre_commit_output is intentional to ensure
# the code is called by the pre-commit hooks.
reqs_pre_commit_file = requirements_pre_commit_output()
@@ -619,7 +580,6 @@ def main(validate: bool, ci: bool) -> int:
("requirements.txt", reqs_file),
("requirements_all.txt", reqs_all_file),
("requirements_test_pre_commit.txt", reqs_pre_commit_file),
("requirements_test_all.txt", reqs_test_all_file),
("homeassistant/package_constraints.txt", constraints),
]
if ci:
-1
View File
@@ -233,7 +233,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
},
"velbus": {"velbus-aio": {"backoff"}},
"volkszaehler": {"volkszaehler": {"async-timeout"}},
"wled": {"wled": {"backoff"}},
"whirlpool": {"whirlpool-sixth-sense": {"async-timeout"}},
"zamg": {"zamg": {"async-timeout"}},
"zha": {
+62 -7
View File
@@ -2,6 +2,7 @@
from collections.abc import Generator
from dataclasses import replace
import socket
from unittest.mock import AsyncMock, MagicMock, patch
from arcam.fmj.client import ConnectionFailed
@@ -111,21 +112,29 @@ async def test_ssdp_abort(hass: HomeAssistant) -> None:
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
"connect_exception",
[
pytest.param(ConnectionFailed, id="connection_failed"),
pytest.param(ConnectionRefusedError, id="connection_refused"),
pytest.param(OSError, id="os_error"),
pytest.param(socket.gaierror, id="gaierror"),
pytest.param(TimeoutError, id="timeout"),
],
)
async def test_ssdp_unable_to_connect(
hass: HomeAssistant, dummy_client: MagicMock
hass: HomeAssistant,
dummy_client: MagicMock,
connect_exception: type[Exception],
) -> None:
"""Test a ssdp import flow."""
dummy_client.start.side_effect = AsyncMock(side_effect=ConnectionFailed)
"""Test a ssdp import flow aborts when the device is unreachable."""
dummy_client.start.side_effect = AsyncMock(side_effect=connect_exception)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_SSDP},
data=MOCK_DISCOVER,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
@@ -232,3 +241,49 @@ async def test_user_wrong(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"Arcam FMJ ({MOCK_HOST})"
assert result["result"].unique_id is None
@pytest.mark.parametrize(
("connect_exception", "expected_error"),
[
pytest.param(ConnectionFailed, "cannot_connect", id="connection_failed"),
pytest.param(
ConnectionRefusedError, "connection_refused", id="connection_refused"
),
pytest.param(OSError, "cannot_connect", id="os_error"),
pytest.param(socket.gaierror, "invalid_host", id="invalid_host"),
pytest.param(TimeoutError, "timeout_connect", id="timeout_connect"),
],
)
async def test_user_unable_to_connect(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
dummy_client: MagicMock,
connect_exception: type[Exception],
expected_error: str,
) -> None:
"""Test a manual user configuration flow where the device cannot be reached."""
dummy_client.start.side_effect = AsyncMock(side_effect=connect_exception)
aioclient_mock.get(MOCK_UPNP_LOCATION, status=404)
user_input = {
CONF_HOST: MOCK_HOST,
CONF_PORT: MOCK_PORT,
}
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_USER},
data=user_input,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": expected_error}
dummy_client.start.side_effect = AsyncMock(return_value=None)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"Arcam FMJ ({MOCK_HOST})"
assert result["data"] == MOCK_CONFIG_ENTRY
+1
View File
@@ -39,6 +39,7 @@ async def test_load_unload(
[
(Exception(), ConfigEntryState.SETUP_ERROR, []),
(requests.ConnectionError(), ConfigEntryState.SETUP_RETRY, []),
(requests.Timeout(), ConfigEntryState.SETUP_RETRY, []),
(DAVError(), ConfigEntryState.SETUP_RETRY, []),
(
AuthorizationError(reason="Unauthorized"),
+4 -6
View File
@@ -115,6 +115,8 @@ async def test_setup_fail(hass: HomeAssistant, error) -> None:
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert entry.state.recoverable is True
assert entry.error_reason_translation_key == "error_connecting"
async def test_setup_fail_parse_error(hass: HomeAssistant, fc_class_mock) -> None:
@@ -138,7 +140,6 @@ async def test_setup_fail_parse_error(hass: HomeAssistant, fc_class_mock) -> Non
async def test_upnp_missing(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
fc_class_mock,
fh_class_mock,
fs_class_mock,
@@ -157,12 +158,9 @@ async def test_upnp_missing(
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_ERROR
assert entry.state is ConfigEntryState.SETUP_RETRY
assert entry.state.recoverable is True
assert (
"Config entry 'Mock Title' for fritz integration"
" could not authenticate: Missing UPnP configuration" in caplog.text
)
assert entry.error_reason_translation_key == "error_upnp_disabled"
async def test_execute_action_while_shutdown(
@@ -1774,6 +1774,12 @@ async def test_create_todo(
},
Task(everyX=5),
),
(
{
ATTR_INTERVAL: 0,
},
Task(everyX=0),
),
(
{
ATTR_FREQUENCY: "weekly",
+3 -2
View File
@@ -15,8 +15,9 @@ from homeassistant.const import CONF_HOST, CONF_MODEL
from tests.common import MockConfigEntry, load_json_object_fixture
TEST_HOST = "192.168.1.100"
ALT_TEST_HOST = "192.168.1.101"
TEST_HOST_ALT = "192.168.1.101"
TEST_PORT = 8080
TEST_DEVICE_SN_GEN1 = "BK1600-12345678"
TEST_DEVICE_SN_GEN2 = "SolidFlex2000-87654321"
TEST_MODEL_GEN1 = "BK1600"
@@ -28,7 +29,7 @@ DEVICE_MAPPING = {
"device": TEST_MODEL_GEN1,
"generation": 1,
"sn": TEST_DEVICE_SN_GEN1,
"host": ALT_TEST_HOST,
"host": TEST_HOST_ALT,
"mac": "aa:bb:cc:11:22:33",
"fw": "1.2.3",
},
+146 -13
View File
@@ -10,18 +10,16 @@ from homeassistant.components.indevolt.const import (
CONF_SERIAL_NUMBER,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_RECONFIGURE, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .conftest import TEST_DEVICE_SN_GEN2, TEST_HOST
from .conftest import TEST_DEVICE_SN_GEN2, TEST_HOST, TEST_HOST_ALT, TEST_MODEL_GEN2
from tests.common import MockConfigEntry
# Used to mock host change
TEST_HOST_NEW = "192.168.1.200"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_success(hass: HomeAssistant, mock_indevolt: AsyncMock) -> None:
@@ -43,11 +41,11 @@ async def test_user_flow_success(hass: HomeAssistant, mock_indevolt: AsyncMock)
# Verify entry is created with correct data
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "INDEVOLT CMS-SF2000"
assert result["title"] == f"INDEVOLT {TEST_MODEL_GEN2}"
assert result["data"] == {
CONF_HOST: TEST_HOST,
CONF_SERIAL_NUMBER: TEST_DEVICE_SN_GEN2,
CONF_MODEL: "CMS-SF2000",
CONF_MODEL: TEST_MODEL_GEN2,
CONF_GENERATION: 2,
}
assert result["result"].unique_id == TEST_DEVICE_SN_GEN2
@@ -94,7 +92,7 @@ async def test_user_flow_error(
# Verify entry is created with correct data
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "INDEVOLT CMS-SF2000"
assert result["title"] == f"INDEVOLT {TEST_MODEL_GEN2}"
async def test_user_flow_duplicate_entry(
@@ -135,10 +133,8 @@ async def test_reconfigure_flow_success(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
# Mock new host input
new_host = TEST_HOST_NEW
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: new_host}
result["flow_id"], {CONF_HOST: TEST_HOST_ALT}
)
# Verify flow is aborted
@@ -149,7 +145,7 @@ async def test_reconfigure_flow_success(
await hass.async_block_till_done()
# Verify entry is updated
assert mock_config_entry.data[CONF_HOST] == new_host
assert mock_config_entry.data[CONF_HOST] == TEST_HOST_ALT
assert mock_config_entry.data[CONF_SERIAL_NUMBER] == TEST_DEVICE_SN_GEN2
@@ -228,7 +224,7 @@ async def test_reconfigure_flow_different_device(
# Configure mock to cause host collision with different device
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: TEST_HOST_NEW}
result["flow_id"], {CONF_HOST: TEST_HOST_ALT}
)
# Verify flow is aborted with correct reason
@@ -237,3 +233,140 @@ async def test_reconfigure_flow_different_device(
# Flush pending tasks
await hass.async_block_till_done()
async def test_dhcp_flow_success(
hass: HomeAssistant, mock_indevolt: AsyncMock, mock_setup_entry: AsyncMock
) -> None:
"""Test successful discovery flow."""
# Verify confirmation form is returned with correct device info
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DhcpServiceInfo(
ip=TEST_HOST,
hostname="indevolt",
macaddress="1c784b8d47bb",
),
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["description_placeholders"][CONF_HOST] == TEST_HOST
assert result["description_placeholders"][CONF_MODEL] == TEST_MODEL_GEN2
# Verify entry is created with correct data
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"INDEVOLT {TEST_MODEL_GEN2}"
assert result["data"] == {
CONF_HOST: TEST_HOST,
CONF_SERIAL_NUMBER: TEST_DEVICE_SN_GEN2,
CONF_MODEL: TEST_MODEL_GEN2,
CONF_GENERATION: 2,
}
assert result["result"].unique_id == TEST_DEVICE_SN_GEN2
async def test_dhcp_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_indevolt: AsyncMock
) -> None:
"""Test DHCP discovery aborts if already configured."""
mock_config_entry.add_to_hass(hass)
# Verify flow is aborted if device is already configured
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DhcpServiceInfo(
ip=TEST_HOST,
hostname="indevolt",
macaddress="1c784b8d47bb",
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_dhcp_ip_change(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_indevolt: AsyncMock
) -> None:
"""Test DHCP discovery updates config entry host if the device moved to a new IP."""
mock_config_entry.add_to_hass(hass)
assert mock_config_entry.data[CONF_HOST] == TEST_HOST
# Verify flow is aborted on ip change and existing entry host is updated
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DhcpServiceInfo(
ip=TEST_HOST_ALT,
hostname="indevolt",
macaddress="1c784b8d47bb",
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config_entry.data[CONF_HOST] == TEST_HOST_ALT
async def test_dhcp_ip_reuse_by_different_device(
hass: HomeAssistant,
alt_mock_config_entry: MockConfigEntry,
mock_indevolt: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test DHCP discovery proceeds when the discovered IP is used by a different device."""
alt_mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DhcpServiceInfo(
ip=TEST_HOST_ALT,
hostname="indevolt",
macaddress="1c784b8d47bb",
),
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
@pytest.mark.parametrize(
("exception", "reason"),
[
(TimeoutError, "cannot_connect"),
(ConnectionError, "cannot_connect"),
(ClientError, "cannot_connect"),
],
)
async def test_dhcp_cannot_connect(
hass: HomeAssistant,
mock_indevolt: AsyncMock,
exception: type[Exception],
reason: str,
) -> None:
"""Test discovery aborts on connection errors."""
# Initiate discovery flow with exception
mock_indevolt.get_config.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DhcpServiceInfo(
ip=TEST_HOST,
hostname="indevolt",
macaddress="1c784b8d47bb",
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason
+1 -2
View File
@@ -7,7 +7,6 @@ import pytest
from homeassistant.components.panasonic_viera.const import (
ATTR_FRIENDLY_NAME,
ATTR_MANUFACTURER,
ATTR_MODEL_NUMBER,
ATTR_UDN,
CONF_APP_ID,
@@ -19,7 +18,7 @@ from homeassistant.components.panasonic_viera.const import (
DEFAULT_PORT,
DOMAIN,
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.const import ATTR_MANUFACTURER, CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
+100 -9
View File
@@ -1,6 +1,7 @@
"""Tests for Shelly media player platform."""
from copy import deepcopy
from http import HTTPStatus
from unittest.mock import Mock
from aioshelly.const import MODEL_WALL_DISPLAY
@@ -372,7 +373,7 @@ async def test_get_image_http(
await init_integration(hass, 2, model=MODEL_WALL_DISPLAY)
state = hass.states.get(ENTITY_ID)
assert (state := hass.states.get(ENTITY_ID)) is not None
assert "entity_picture_local" not in state.attributes
client = await hass_client_no_auth()
@@ -383,13 +384,54 @@ async def test_get_image_http(
assert isinstance(content, bytes)
async def test_get_image_http_base64_decode_error(
@pytest.mark.parametrize(
"invalid_thumb",
[
"data:image/webp;base64,0",
"data invalid",
"data:video/mpg;base64,AAAA",
],
)
async def test_get_image_http_stale_url_after_thumb_invalidated(
hass: HomeAssistant,
mock_rpc_device: Mock,
monkeypatch: pytest.MonkeyPatch,
hass_client_no_auth: ClientSessionGenerator,
invalid_thumb: str,
) -> None:
"""Test get image via http command base64 decode error."""
"""Test image proxy with a stale URL after the thumb becomes invalid."""
status = deepcopy(mock_rpc_device.status)
status["media"] = STATUS_AUDIO_FILE
monkeypatch.setattr(mock_rpc_device, "status", status)
await init_integration(hass, 2, model=MODEL_WALL_DISPLAY)
assert (state := hass.states.get(ENTITY_ID)) is not None
entity_picture = state.attributes["entity_picture"]
monkeypatch.setitem(
mock_rpc_device.status["media"]["playback"]["media_meta"],
"thumb",
invalid_thumb,
)
mock_rpc_device.mock_update()
await hass.async_block_till_done()
assert (state := hass.states.get(ENTITY_ID)) is not None
assert "entity_picture" not in state.attributes
client = await hass_client_no_auth()
resp = await client.get(entity_picture)
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
async def test_entity_picture_absent_base64_data_invalid(
hass: HomeAssistant,
mock_rpc_device: Mock,
monkeypatch: pytest.MonkeyPatch,
hass_client: ClientSessionGenerator,
) -> None:
"""Test that entity_picture is absent when base64 data is invalid."""
status = deepcopy(mock_rpc_device.status)
status["media"] = STATUS_AUDIO_FILE
status["media"]["playback"]["media_meta"]["thumb"] = "data:image/webp;base64,0"
@@ -397,15 +439,64 @@ async def test_get_image_http_base64_decode_error(
await init_integration(hass, 2, model=MODEL_WALL_DISPLAY)
state = hass.states.get(ENTITY_ID)
assert "entity_picture_local" not in state.attributes
assert (state := hass.states.get(ENTITY_ID)) is not None
assert "entity_picture" not in state.attributes
client = await hass_client_no_auth()
client = await hass_client()
resp = await client.get(f"/api/media_player_proxy/{ENTITY_ID}")
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
resp = await client.get(state.attributes["entity_picture"])
content = await resp.read()
assert isinstance(content, bytes)
@pytest.mark.parametrize(
"invalid_thumb",
[
"data invalid",
"lorem ipsum",
],
)
async def test_entity_picture_absent_thumb_string_invalid(
hass: HomeAssistant,
mock_rpc_device: Mock,
monkeypatch: pytest.MonkeyPatch,
hass_client: ClientSessionGenerator,
invalid_thumb: str,
) -> None:
"""Test that entity_picture is absent when thumb string has invalid format."""
status = deepcopy(mock_rpc_device.status)
status["media"] = STATUS_AUDIO_FILE
status["media"]["playback"]["media_meta"]["thumb"] = invalid_thumb
monkeypatch.setattr(mock_rpc_device, "status", status)
await init_integration(hass, 2, model=MODEL_WALL_DISPLAY)
assert (state := hass.states.get(ENTITY_ID)) is not None
assert "entity_picture" not in state.attributes
client = await hass_client()
resp = await client.get(f"/api/media_player_proxy/{ENTITY_ID}")
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
async def test_entity_picture_absent_mime_type_not_allowed(
hass: HomeAssistant,
mock_rpc_device: Mock,
monkeypatch: pytest.MonkeyPatch,
hass_client: ClientSessionGenerator,
) -> None:
"""Test that entity_picture is absent when MIME type is not allowed."""
status = deepcopy(mock_rpc_device.status)
status["media"] = STATUS_AUDIO_FILE
status["media"]["playback"]["media_meta"]["thumb"] = "data:video/mpg;base64,0"
monkeypatch.setattr(mock_rpc_device, "status", status)
await init_integration(hass, 2, model=MODEL_WALL_DISPLAY)
assert (state := hass.states.get(ENTITY_ID)) is not None
assert "entity_picture" not in state.attributes
client = await hass_client()
resp = await client.get(f"/api/media_player_proxy/{ENTITY_ID}")
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
async def test_rpc_media_player_browse_media_root(
@@ -5,11 +5,11 @@ import pytest
from homeassistant.components.text.const import (
ATTR_MAX,
ATTR_MIN,
ATTR_MODE,
ATTR_PATTERN,
DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.state import async_reproduce_state
@@ -20,7 +20,6 @@
'111': 'Chunchun',
'112': 'Dancing Shadows',
'113': 'Washing Machine',
'114': 'RSVD',
'115': 'Blends',
'116': 'TV Simulator',
'117': 'Dynamic Smooth',
@@ -51,7 +50,6 @@
'14': 'Theater Rainbow',
'140': 'Waterfall',
'141': 'Freqpixels',
'142': 'RSVD',
'143': 'Noisefire',
'144': 'Puddlepeak',
'145': 'Noisemove',
@@ -61,7 +59,6 @@
'149': 'Firenoise',
'15': 'Running',
'150': 'Squared Swirl',
'151': 'RSVD',
'152': 'DNA',
'153': 'Matrix',
'154': 'Metaballs',
@@ -72,7 +69,6 @@
'159': 'DJ Light',
'16': 'Saw',
'160': 'Funky Plank',
'161': 'RSVD',
'162': 'Pulser',
'163': 'Blurz',
'164': 'Drift',
@@ -80,10 +76,7 @@
'166': 'Sun Radiation',
'167': 'Colored Bursts',
'168': 'Julia',
'169': 'RSVD',
'17': 'Twinkle',
'170': 'RSVD',
'171': 'RSVD',
'172': 'Game Of Life',
'173': 'Tartan',
'174': 'Polar Lights',
@@ -138,7 +131,6 @@
'50': 'Two Dots',
'51': 'Fairytwinkle',
'52': 'Running Dual',
'53': 'RSVD',
'54': 'Chase 3',
'55': 'Tri Wipe',
'56': 'Tri Fade',
@@ -227,6 +219,7 @@
'product': 'FOSS',
'str': False,
'udpport': 21324,
'umpalcount': 0,
'uptime': 966,
'ver': '0.14.4',
'vid': '2405180',
@@ -60,7 +60,6 @@
'Two Dots',
'Fairytwinkle',
'Running Dual',
'RSVD',
'Chase 3',
'Tri Wipe',
'Tri Fade',
@@ -149,7 +148,6 @@
'GEQ',
'Waterfall',
'Freqpixels',
'RSVD',
'Noisefire',
'Puddlepeak',
'Noisemove',
@@ -158,7 +156,6 @@
'Ripple Peak',
'Firenoise',
'Squared Swirl',
'RSVD',
'DNA',
'Matrix',
'Metaballs',
@@ -168,7 +165,6 @@
'Gravfreq',
'DJ Light',
'Funky Plank',
'RSVD',
'Pulser',
'Blurz',
'Drift',
@@ -176,9 +172,6 @@
'Sun Radiation',
'Colored Bursts',
'Julia',
'RSVD',
'RSVD',
'RSVD',
'Game Of Life',
'Tartan',
'Polar Lights',
@@ -293,7 +286,6 @@
'Two Dots',
'Fairytwinkle',
'Running Dual',
'RSVD',
'Chase 3',
'Tri Wipe',
'Tri Fade',
@@ -382,7 +374,6 @@
'GEQ',
'Waterfall',
'Freqpixels',
'RSVD',
'Noisefire',
'Puddlepeak',
'Noisemove',
@@ -391,7 +382,6 @@
'Ripple Peak',
'Firenoise',
'Squared Swirl',
'RSVD',
'DNA',
'Matrix',
'Metaballs',
@@ -401,7 +391,6 @@
'Gravfreq',
'DJ Light',
'Funky Plank',
'RSVD',
'Pulser',
'Blurz',
'Drift',
@@ -409,9 +398,6 @@
'Sun Radiation',
'Colored Bursts',
'Julia',
'RSVD',
'RSVD',
'RSVD',
'Game Of Life',
'Tartan',
'Polar Lights',
@@ -520,7 +506,6 @@
'Two Dots',
'Fairytwinkle',
'Running Dual',
'RSVD',
'Chase 3',
'Tri Wipe',
'Tri Fade',
@@ -581,7 +566,6 @@
'Chunchun',
'Dancing Shadows',
'Washing Machine',
'RSVD',
'Blends',
'TV Simulator',
'Dynamic Smooth',
@@ -609,7 +593,6 @@
'GEQ',
'Waterfall',
'Freqpixels',
'RSVD',
'Noisefire',
'Puddlepeak',
'Noisemove',
@@ -618,7 +601,6 @@
'Ripple Peak',
'Firenoise',
'Squared Swirl',
'RSVD',
'DNA',
'Matrix',
'Metaballs',
@@ -628,7 +610,6 @@
'Gravfreq',
'DJ Light',
'Funky Plank',
'RSVD',
'Pulser',
'Blurz',
'Drift',
@@ -636,9 +617,6 @@
'Sun Radiation',
'Colored Bursts',
'Julia',
'RSVD',
'RSVD',
'RSVD',
'Game Of Life',
'Tartan',
'Polar Lights',
@@ -749,7 +727,6 @@
'Two Dots',
'Fairytwinkle',
'Running Dual',
'RSVD',
'Chase 3',
'Tri Wipe',
'Tri Fade',
@@ -810,7 +787,6 @@
'Chunchun',
'Dancing Shadows',
'Washing Machine',
'RSVD',
'Blends',
'TV Simulator',
'Dynamic Smooth',
@@ -838,7 +814,6 @@
'GEQ',
'Waterfall',
'Freqpixels',
'RSVD',
'Noisefire',
'Puddlepeak',
'Noisemove',
@@ -847,7 +822,6 @@
'Ripple Peak',
'Firenoise',
'Squared Swirl',
'RSVD',
'DNA',
'Matrix',
'Metaballs',
@@ -857,7 +831,6 @@
'Gravfreq',
'DJ Light',
'Funky Plank',
'RSVD',
'Pulser',
'Blurz',
'Drift',
@@ -865,9 +838,6 @@
'Sun Radiation',
'Colored Bursts',
'Julia',
'RSVD',
'RSVD',
'RSVD',
'Game Of Life',
'Tartan',
'Polar Lights',
@@ -1040,7 +1010,6 @@
'Two Dots',
'Fairytwinkle',
'Running Dual',
'RSVD',
'Chase 3',
'Tri Wipe',
'Tri Fade',
@@ -1101,7 +1070,6 @@
'Chunchun',
'Dancing Shadows',
'Washing Machine',
'RSVD',
'Blends',
'TV Simulator',
'Dynamic Smooth',
@@ -1129,7 +1097,6 @@
'GEQ',
'Waterfall',
'Freqpixels',
'RSVD',
'Noisefire',
'Puddlepeak',
'Noisemove',
@@ -1138,7 +1105,6 @@
'Ripple Peak',
'Firenoise',
'Squared Swirl',
'RSVD',
'DNA',
'Matrix',
'Metaballs',
@@ -1148,7 +1114,6 @@
'Gravfreq',
'DJ Light',
'Funky Plank',
'RSVD',
'Pulser',
'Blurz',
'Drift',
@@ -1156,9 +1121,6 @@
'Sun Radiation',
'Colored Bursts',
'Julia',
'RSVD',
'RSVD',
'RSVD',
'Game Of Life',
'Tartan',
'Polar Lights',
@@ -1269,7 +1231,6 @@
'Two Dots',
'Fairytwinkle',
'Running Dual',
'RSVD',
'Chase 3',
'Tri Wipe',
'Tri Fade',
@@ -1330,7 +1291,6 @@
'Chunchun',
'Dancing Shadows',
'Washing Machine',
'RSVD',
'Blends',
'TV Simulator',
'Dynamic Smooth',
@@ -1358,7 +1318,6 @@
'GEQ',
'Waterfall',
'Freqpixels',
'RSVD',
'Noisefire',
'Puddlepeak',
'Noisemove',
@@ -1367,7 +1326,6 @@
'Ripple Peak',
'Firenoise',
'Squared Swirl',
'RSVD',
'DNA',
'Matrix',
'Metaballs',
@@ -1377,7 +1335,6 @@
'Gravfreq',
'DJ Light',
'Funky Plank',
'RSVD',
'Pulser',
'Blurz',
'Drift',
@@ -1385,9 +1342,6 @@
'Sun Radiation',
'Colored Bursts',
'Julia',
'RSVD',
'RSVD',
'RSVD',
'Game Of Life',
'Tartan',
'Polar Lights',
@@ -1492,7 +1446,6 @@
'Two Dots',
'Fairytwinkle',
'Running Dual',
'RSVD',
'Chase 3',
'Tri Wipe',
'Tri Fade',
@@ -1553,7 +1506,6 @@
'Chunchun',
'Dancing Shadows',
'Washing Machine',
'RSVD',
'Blends',
'TV Simulator',
'Dynamic Smooth',
@@ -1581,7 +1533,6 @@
'GEQ',
'Waterfall',
'Freqpixels',
'RSVD',
'Noisefire',
'Puddlepeak',
'Noisemove',
@@ -1590,7 +1541,6 @@
'Ripple Peak',
'Firenoise',
'Squared Swirl',
'RSVD',
'DNA',
'Matrix',
'Metaballs',
@@ -1600,7 +1550,6 @@
'Gravfreq',
'DJ Light',
'Funky Plank',
'RSVD',
'Pulser',
'Blurz',
'Drift',
@@ -1608,9 +1557,6 @@
'Sun Radiation',
'Colored Bursts',
'Julia',
'RSVD',
'RSVD',
'RSVD',
'Game Of Life',
'Tartan',
'Polar Lights',
@@ -1721,7 +1667,6 @@
'Two Dots',
'Fairytwinkle',
'Running Dual',
'RSVD',
'Chase 3',
'Tri Wipe',
'Tri Fade',
@@ -1782,7 +1727,6 @@
'Chunchun',
'Dancing Shadows',
'Washing Machine',
'RSVD',
'Blends',
'TV Simulator',
'Dynamic Smooth',
@@ -1810,7 +1754,6 @@
'GEQ',
'Waterfall',
'Freqpixels',
'RSVD',
'Noisefire',
'Puddlepeak',
'Noisemove',
@@ -1819,7 +1762,6 @@
'Ripple Peak',
'Firenoise',
'Squared Swirl',
'RSVD',
'DNA',
'Matrix',
'Metaballs',
@@ -1829,7 +1771,6 @@
'Gravfreq',
'DJ Light',
'Funky Plank',
'RSVD',
'Pulser',
'Blurz',
'Drift',
@@ -1837,9 +1778,6 @@
'Sun Radiation',
'Colored Bursts',
'Julia',
'RSVD',
'RSVD',
'RSVD',
'Game Of Life',
'Tartan',
'Polar Lights',
@@ -1944,7 +1882,6 @@
'Two Dots',
'Fairytwinkle',
'Running Dual',
'RSVD',
'Chase 3',
'Tri Wipe',
'Tri Fade',
@@ -2005,7 +1942,6 @@
'Chunchun',
'Dancing Shadows',
'Washing Machine',
'RSVD',
'Blends',
'TV Simulator',
'Dynamic Smooth',
@@ -2033,7 +1969,6 @@
'GEQ',
'Waterfall',
'Freqpixels',
'RSVD',
'Noisefire',
'Puddlepeak',
'Noisemove',
@@ -2042,7 +1977,6 @@
'Ripple Peak',
'Firenoise',
'Squared Swirl',
'RSVD',
'DNA',
'Matrix',
'Metaballs',
@@ -2052,7 +1986,6 @@
'Gravfreq',
'DJ Light',
'Funky Plank',
'RSVD',
'Pulser',
'Blurz',
'Drift',
@@ -2060,9 +1993,6 @@
'Sun Radiation',
'Colored Bursts',
'Julia',
'RSVD',
'RSVD',
'RSVD',
'Game Of Life',
'Tartan',
'Polar Lights',
@@ -2173,7 +2103,6 @@
'Two Dots',
'Fairytwinkle',
'Running Dual',
'RSVD',
'Chase 3',
'Tri Wipe',
'Tri Fade',
@@ -2234,7 +2163,6 @@
'Chunchun',
'Dancing Shadows',
'Washing Machine',
'RSVD',
'Blends',
'TV Simulator',
'Dynamic Smooth',
@@ -2262,7 +2190,6 @@
'GEQ',
'Waterfall',
'Freqpixels',
'RSVD',
'Noisefire',
'Puddlepeak',
'Noisemove',
@@ -2271,7 +2198,6 @@
'Ripple Peak',
'Firenoise',
'Squared Swirl',
'RSVD',
'DNA',
'Matrix',
'Metaballs',
@@ -2281,7 +2207,6 @@
'Gravfreq',
'DJ Light',
'Funky Plank',
'RSVD',
'Pulser',
'Blurz',
'Drift',
@@ -2289,9 +2214,6 @@
'Sun Radiation',
'Colored Bursts',
'Julia',
'RSVD',
'RSVD',
'RSVD',
'Game Of Life',
'Tartan',
'Polar Lights',
@@ -2396,7 +2318,6 @@
'Two Dots',
'Fairytwinkle',
'Running Dual',
'RSVD',
'Chase 3',
'Tri Wipe',
'Tri Fade',
@@ -2457,7 +2378,6 @@
'Chunchun',
'Dancing Shadows',
'Washing Machine',
'RSVD',
'Blends',
'TV Simulator',
'Dynamic Smooth',
@@ -2485,7 +2405,6 @@
'GEQ',
'Waterfall',
'Freqpixels',
'RSVD',
'Noisefire',
'Puddlepeak',
'Noisemove',
@@ -2494,7 +2413,6 @@
'Ripple Peak',
'Firenoise',
'Squared Swirl',
'RSVD',
'DNA',
'Matrix',
'Metaballs',
@@ -2504,7 +2422,6 @@
'Gravfreq',
'DJ Light',
'Funky Plank',
'RSVD',
'Pulser',
'Blurz',
'Drift',
@@ -2512,9 +2429,6 @@
'Sun Radiation',
'Colored Bursts',
'Julia',
'RSVD',
'RSVD',
'RSVD',
'Game Of Life',
'Tartan',
'Polar Lights',
@@ -2625,7 +2539,6 @@
'Two Dots',
'Fairytwinkle',
'Running Dual',
'RSVD',
'Chase 3',
'Tri Wipe',
'Tri Fade',
@@ -2686,7 +2599,6 @@
'Chunchun',
'Dancing Shadows',
'Washing Machine',
'RSVD',
'Blends',
'TV Simulator',
'Dynamic Smooth',
@@ -2714,7 +2626,6 @@
'GEQ',
'Waterfall',
'Freqpixels',
'RSVD',
'Noisefire',
'Puddlepeak',
'Noisemove',
@@ -2723,7 +2634,6 @@
'Ripple Peak',
'Firenoise',
'Squared Swirl',
'RSVD',
'DNA',
'Matrix',
'Metaballs',
@@ -2733,7 +2643,6 @@
'Gravfreq',
'DJ Light',
'Funky Plank',
'RSVD',
'Pulser',
'Blurz',
'Drift',
@@ -2741,9 +2650,6 @@
'Sun Radiation',
'Colored Bursts',
'Julia',
'RSVD',
'RSVD',
'RSVD',
'Game Of Life',
'Tartan',
'Polar Lights',
+212 -1
View File
@@ -7,7 +7,7 @@ from syrupy.assertion import SnapshotAssertion
from wyoming.asr import Transcript
from wyoming.handle import Handled, NotHandled
from wyoming.info import Info
from wyoming.intent import Entity, Intent, NotRecognized
from wyoming.intent import Entity, Intent, IntentsStart, IntentsStop, NotRecognized
from homeassistant.components import conversation
from homeassistant.components.conversation import chat_log
@@ -125,6 +125,99 @@ async def test_intent(
}
async def test_multiple_intents(
hass: HomeAssistant,
init_wyoming_intent: ConfigEntry,
mock_chat_log: MockChatLog, # noqa: F811
) -> None:
"""Test when more than one intent is recognized."""
agent_id = "conversation.test_intent"
conversation_id = mock_chat_log.conversation_id
satellite_id = "satellite-1234"
device_id = "device-1234"
test_intent1 = Intent(
name="TestIntent1",
entities=[Entity(name="entity1", value="value1")],
text="{{ slots.slot_name }}",
)
test_intent2 = Intent(
name="TestIntent2",
entities=[Entity(name="entity2", value="value2")],
text="{{ slots.slot_name }}",
)
class TestIntentHandler(intent.IntentHandler):
"""Test Intent Handler."""
def __init__(self, intent_type: str, slot_value: str) -> None:
"""Initialize the handler."""
self.intent_type = intent_type
self._slot_value = slot_value
async def async_handle(self, intent_obj: intent.Intent):
"""Handle the intent."""
response = intent_obj.create_response()
response.async_set_speech_slots({"slot_name": self._slot_value})
return response
intent.async_register(hass, TestIntentHandler("TestIntent1", "slot value 1"))
intent.async_register(hass, TestIntentHandler("TestIntent2", "slot value 2"))
# Send multiple intent events framed by intents-start and intents-stop.
client = MockAsyncTcpClient(
[
IntentsStart().event(),
test_intent1.event(),
test_intent2.event(),
IntentsStop().event(),
]
)
with patch(
"homeassistant.components.wyoming.conversation.AsyncTcpClient",
client,
):
result = await conversation.async_converse(
hass=hass,
text="test text",
conversation_id=conversation_id,
context=Context(),
language=hass.config.language,
agent_id=agent_id,
satellite_id=satellite_id,
device_id=device_id,
)
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.speech, "No speech"
# Speech results are joined with newlines because punctuation would be
# language-dependent.
assert (
result.response.speech.get("plain", {}).get("speech")
== "slot value 1\nslot value 2"
)
# Verify that chat log recorded all intents as tool calls
content: chat_log.AssistantContent | None = next(
filter(
lambda c: isinstance(c, chat_log.AssistantContent), mock_chat_log.content
),
None,
)
assert content is not None, "Missing assistant content"
assert content.tool_calls and len(content.tool_calls) == 2
for tool_call, test_intent in zip(
content.tool_calls, (test_intent1, test_intent2), strict=True
):
assert tool_call.tool_name == test_intent.name
assert tool_call.tool_args == {
e.name: {"value": e.value} for e in test_intent.entities
}
async def test_intent_handle_error(
hass: HomeAssistant, init_wyoming_intent: ConfigEntry
) -> None:
@@ -161,6 +254,67 @@ async def test_intent_handle_error(
assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE
async def test_multiple_intents_handle_error(
hass: HomeAssistant,
init_wyoming_intent: ConfigEntry,
mock_chat_log: MockChatLog, # noqa: F811
) -> None:
"""Test error during handling when multiple intents are recognized."""
agent_id = "conversation.test_intent"
test_intent_1 = Intent(name="TestIntent1", entities=[], text="success")
test_intent_2 = Intent(name="TestIntent2", entities=[], text="success")
class TestIntentHandler1(intent.IntentHandler):
"""Test Intent Handler."""
intent_type = "TestIntent1"
async def async_handle(self, intent_obj: intent.Intent):
"""Handle the intent."""
return intent_obj.create_response()
class TestIntentHandler2(intent.IntentHandler):
"""Test Intent Handler."""
intent_type = "TestIntent2"
async def async_handle(self, intent_obj: intent.Intent):
"""Handle the intent."""
raise intent.IntentError
intent.async_register(hass, TestIntentHandler1())
intent.async_register(hass, TestIntentHandler2())
with patch(
"homeassistant.components.wyoming.conversation.AsyncTcpClient",
MockAsyncTcpClient(
[
IntentsStart().event(),
test_intent_1.event(),
test_intent_2.event(),
IntentsStop().event(),
]
),
):
result = await conversation.async_converse(
hass=hass,
text="test text",
conversation_id=None,
context=Context(),
language=hass.config.language,
agent_id=agent_id,
)
assert result.response.response_type == intent.IntentResponseType.ERROR
assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE
# Ensure that no tool calls were recorded
assert not any(
isinstance(c, chat_log.AssistantContent) for c in mock_chat_log.content
)
async def test_not_recognized(
hass: HomeAssistant, init_wyoming_intent: ConfigEntry
) -> None:
@@ -343,3 +497,60 @@ async def test_supported_languages_empty_means_all(
agent = conversation.async_get_agent(hass, agent_id)
assert agent is not None
assert agent.supported_languages == MATCH_ALL
async def test_intent_supports_home_control(
hass: HomeAssistant, intent_config_entry: ConfigEntry
) -> None:
"""Test that the CONTROL supported feature is always set for intent services."""
agent_id = "conversation.test_intent"
with patch(
"homeassistant.components.wyoming.data.load_wyoming_info",
return_value=Info(intent=INTENT_INFO.intent),
):
await hass.config_entries.async_setup(intent_config_entry.entry_id)
agent = conversation.async_get_agent(hass, agent_id)
assert isinstance(agent, conversation.ConversationEntity)
assert agent.supported_features is not None
assert (
agent.supported_features & conversation.ConversationEntityFeature.CONTROL
) == conversation.ConversationEntityFeature.CONTROL
@pytest.mark.parametrize(
"supports_home_control",
[False, True],
)
async def test_handle_supports_home_control(
hass: HomeAssistant, intent_config_entry: ConfigEntry, supports_home_control: bool
) -> None:
"""Test that the CONTROL supported feature matches the Wyoming info."""
agent_id = "conversation.test_handle"
with (
patch.object(
HANDLE_INFO.handle[0], "supports_home_control", supports_home_control
),
patch(
"homeassistant.components.wyoming.data.load_wyoming_info",
return_value=Info(handle=HANDLE_INFO.handle),
),
):
await hass.config_entries.async_setup(intent_config_entry.entry_id)
agent = conversation.async_get_agent(hass, agent_id)
assert isinstance(agent, conversation.ConversationEntity)
supported_features = (
agent.supported_features or conversation.ConversationEntityFeature(0)
)
control_feature = (
supported_features & conversation.ConversationEntityFeature.CONTROL
)
if supports_home_control:
assert control_feature == conversation.ConversationEntityFeature.CONTROL
else:
assert control_feature == conversation.ConversationEntityFeature(0)
@@ -26,6 +26,7 @@ def test_render_all_conclusive_collapses_details() -> None:
CheckKind.CI_UPLOAD: _pass("attestation found"),
CheckKind.RELEASE_PIPELINE: _pass("OIDC via attestation"),
CheckKind.PR_LINK: _pass("link found"),
CheckKind.ASYNC_BLOCKING: _pass("no blocking calls in async"),
},
)
result = CheckRunResult(pr_number=1, packages=[pkg])
@@ -49,6 +50,7 @@ def test_render_needs_agent_emits_generic_placeholders() -> None:
CheckKind.CI_UPLOAD: CheckResult(CheckStatus.WARN, "no attestation"),
CheckKind.RELEASE_PIPELINE: CheckResult(CheckStatus.NEEDS_AGENT, ""),
CheckKind.PR_LINK: CheckResult(CheckStatus.NEEDS_AGENT, ""),
CheckKind.ASYNC_BLOCKING: CheckResult(CheckStatus.NEEDS_AGENT, ""),
},
)
rendered = render_comment(CheckRunResult(pr_number=1, packages=[pkg]))
@@ -57,6 +59,8 @@ def test_render_needs_agent_emits_generic_placeholders() -> None:
assert "{{CHECK_CELL:pkg:release_pipeline}}" in rendered
assert "{{CHECK_DETAIL:pkg:release_pipeline}}" in rendered
assert "{{CHECK_CELL:pkg:pr_link}}" in rendered
assert "{{CHECK_CELL:pkg:async_blocking}}" in rendered
assert "{{CHECK_DETAIL:pkg:async_blocking}}" in rendered
assert "<details open>" in rendered
@@ -55,6 +55,7 @@ def test_runner_attestation_recognised(monkeypatch: pytest.MonkeyPatch) -> None:
assert pkg.checks[CheckKind.RELEASE_PIPELINE].status == CheckStatus.PASS
assert pkg.checks[CheckKind.REPO_PUBLIC].status == CheckStatus.NEEDS_AGENT
assert pkg.checks[CheckKind.PR_LINK].status == CheckStatus.NEEDS_AGENT
assert pkg.checks[CheckKind.ASYNC_BLOCKING].status == CheckStatus.NEEDS_AGENT
assert result.needs_agent is True
@@ -154,9 +155,10 @@ def test_runner_marks_missing_version_as_fail(
pkg = result.packages[0]
assert pkg.checks[CheckKind.CI_UPLOAD].status == CheckStatus.FAIL
assert pkg.checks[CheckKind.RELEASE_PIPELINE].status == CheckStatus.FAIL
# No repo URL → repo_public and pr_link short-circuit to FAIL, not NEEDS_AGENT
# No repo URL → repo_public, pr_link and async_blocking short-circuit to FAIL
assert pkg.checks[CheckKind.REPO_PUBLIC].status == CheckStatus.FAIL
assert pkg.checks[CheckKind.PR_LINK].status == CheckStatus.FAIL
assert pkg.checks[CheckKind.ASYNC_BLOCKING].status == CheckStatus.FAIL
assert result.needs_agent is False
@@ -191,9 +193,81 @@ def test_runner_pypi_found_but_no_repo_url_fails_repo_checks(
pkg = result.packages[0]
assert pkg.checks[CheckKind.REPO_PUBLIC].status == CheckStatus.FAIL
assert pkg.checks[CheckKind.PR_LINK].status == CheckStatus.FAIL
assert pkg.checks[CheckKind.ASYNC_BLOCKING].status == CheckStatus.FAIL
assert "does not advertise" in pkg.checks[CheckKind.REPO_PUBLIC].details
def test_runner_async_blocking_new_package_full_review(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A newly added package asks the agent for a full-tree async review."""
_patch_pypi(
monkeypatch,
PypiPackageInfo(
project_urls={"Source": "https://github.com/x/y"},
repo_url="https://github.com/x/y",
file_provenance_urls=["whatever"],
found=True,
),
ProvenanceResult(
has_attestation=True,
publisher_kind="GitHub",
recognized_publisher=True,
detail="ok",
),
)
diff = (
"diff --git a/requirements_all.txt b/requirements_all.txt\n"
"--- a/requirements_all.txt\n"
"+++ b/requirements_all.txt\n"
"@@ -0,0 +1 @@\n"
"+pkg==1.0.0\n"
)
result = run_checks(pr_number=1, diff_text=diff)
pkg = result.packages[0]
assert pkg.old_version is None
detail = pkg.checks[CheckKind.ASYNC_BLOCKING].details
assert pkg.checks[CheckKind.ASYNC_BLOCKING].status == CheckStatus.NEEDS_AGENT
assert "New dependency" in detail
assert "entire source tree" in detail
def test_runner_async_blocking_version_bump_diff_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A version bump asks the agent to review only the diff for new blocking calls."""
_patch_pypi(
monkeypatch,
PypiPackageInfo(
project_urls={"Source": "https://github.com/x/y"},
repo_url="https://github.com/x/y",
file_provenance_urls=["whatever"],
found=True,
),
ProvenanceResult(
has_attestation=True,
publisher_kind="GitHub",
recognized_publisher=True,
detail="ok",
),
)
diff = (
"diff --git a/requirements_all.txt b/requirements_all.txt\n"
"--- a/requirements_all.txt\n"
"+++ b/requirements_all.txt\n"
"@@ -1 +1 @@\n"
"-pkg==1.0.0\n"
"+pkg==1.1.0\n"
)
result = run_checks(pr_number=1, diff_text=diff)
pkg = result.packages[0]
assert pkg.old_version == "1.0.0"
detail = pkg.checks[CheckKind.ASYNC_BLOCKING].details
assert pkg.checks[CheckKind.ASYNC_BLOCKING].status == CheckStatus.NEEDS_AGENT
assert "1.0.0" in detail and "1.1.0" in detail
assert "diff" in detail
def test_runner_serialises_to_json(monkeypatch: pytest.MonkeyPatch) -> None:
"""The artifact contract: `to_dict()` is JSON-serialisable with expected keys."""
_patch_pypi(