Compare commits

..

1 Commits

Author SHA1 Message Date
Erik 2a1baa9573 Queue nested firing of events 2026-06-11 15:08:56 +02:00
34 changed files with 442 additions and 915 deletions
+19 -17
View File
@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"e4fcdd04986da27ef3059faa0cea3d64bb879fe12085ebfdec0041bbc31ec181","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"}]}
# ___ _ _
# / _ \ | | (_)
@@ -59,13 +59,15 @@ permissions: {}
concurrency:
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
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_2fc32253e89940f3_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
<system>
GH_AW_PROMPT_2fc32253e89940f3_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_2fc32253e89940f3_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
<safe-output-tools>
Tools: add_comment, missing_tool, missing_data, noop
</safe-output-tools>
GH_AW_PROMPT_2fc32253e89940f3_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
cat << 'GH_AW_PROMPT_2fc32253e89940f3_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_2fc32253e89940f3_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
</system>
{{#runtime-import .github/workflows/check-requirements.md}}
GH_AW_PROMPT_2fc32253e89940f3_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -321,6 +323,7 @@ jobs:
permissions:
actions: read
contents: read
issues: read
pull-requests: read
concurrency:
group: "gh-aw-copilot-${{ github.workflow }}"
@@ -450,9 +453,9 @@ 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_eaae5443153d0b45_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_eaae5443153d0b45_EOF
GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF
- name: Generate Safe Outputs Tools
env:
GH_AW_TOOLS_META_JSON: |
@@ -644,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_d99df59573a98681_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": {
@@ -654,7 +657,7 @@ jobs:
"GITHUB_HOST": "\${GITHUB_SERVER_URL}",
"GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}",
"GITHUB_READ_ONLY": "1",
"GITHUB_TOOLSETS": "repos,pull_requests"
"GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions"
},
"guard-policies": {
"allow-only": {
@@ -688,7 +691,7 @@ jobs:
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
}
}
GH_AW_MCP_CONFIG_d99df59573a98681_EOF
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
- name: Mount MCP servers as CLIs
id: mount-mcp-clis
continue-on-error: true
@@ -1281,7 +1284,6 @@ jobs:
}
extract_pr_number:
needs: activation
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
+251 -243
View File
@@ -6,6 +6,7 @@ on:
permissions:
contents: read
actions: read
issues: read
pull-requests: read
network:
allowed:
@@ -13,7 +14,7 @@ network:
tools:
web-fetch: {}
github:
toolsets: [repos, pull_requests]
toolsets: [default, actions]
min-integrity: unapproved
safe-outputs:
add-comment:
@@ -43,7 +44,7 @@ jobs:
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
steps:
- name: Download deterministic-results artifact
@@ -82,289 +83,296 @@ description: >
# Check requirements (AW)
You are a code-review assistant for Home Assistant. The deterministic
stage already evaluated every check it can and produced an artifact at
`/tmp/gh-aw/deterministic/results.json`. Your only job is to resolve any
`needs_agent` checks and post the rendered comment.
You are a code review assistant for the Home Assistant project. The
deterministic stage has already evaluated every check it can on its own
and produced an artifact containing the PR number, per-package check
results, and a pre-rendered comment with placeholders. **Your only job is
to read that artifact, resolve any `needs_agent` checks, and post the
final comment.**
## Step 1 — Read the artifact
## Step 1 — Read the deterministic-stage artifact
Read the JSON directly for the full schema. Key fields:
The deterministic stage uploaded its results to the runner at
`/tmp/gh-aw/deterministic/results.json`.
- `pr_number`, `needs_agent` (bool), `packages[]`, `rendered_comment`.
- Each `package`: `name`, `old_version` (`null` if new), `new_version`,
`repo_url`, `publisher_kind`, `checks` (keyed by check-kind, each
with `status` of `pass`/`warn`/`fail`/`needs_agent` and `details`).
- `rendered_comment` contains, for each `needs_agent` check, two
placeholders to replace:
- `{{CHECK_CELL:<pkg>:<kind>}}` → exactly one of `✅`, `☑️`, `⚠️`, `❌`. The
**`security`** check kind uses `☑️` instead of `✅` for the success
case — see its section below for why.
- `{{CHECK_DETAIL:<pkg>:<kind>}}``<icon> <one-line explanation>`
(the bullet's `- **<label>**:` prefix is already rendered; replace
only the placeholder).
The JSON has this shape:
Do not modify other content in `rendered_comment`, do not re-evaluate
deterministic checks, do not add or remove packages. If `needs_agent`
is `false`, emit `rendered_comment` unchanged.
- `pr_number` — the PR being checked. The `add_comment` safe-output is
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
previous pin), `new_version`, `repo_url`, `publisher_kind`.
- `checks` — a dict keyed by **check kind** (string). Each value has a
`status` (`pass`, `warn`, `fail`, or `needs_agent`) and `details`.
- `rendered_comment` — the final PR comment body, already rendered. For
every check whose status is `needs_agent` it contains two placeholders
you must replace:
- `{{CHECK_CELL:<pkg-name>:<check-kind>}}` — one cell of the summary
table. Replace with exactly one of `✅`, `⚠️`, `❌`.
- `{{CHECK_DETAIL:<pkg-name>:<check-kind>}}` — the body of one bullet
in the package's `<details>` block. Replace with
`<icon> <one-line explanation>` (the bullet's leading
`- **<label>**:` is already rendered — replace only the placeholder).
You **must not** modify any other content in `rendered_comment`. Do not
re-evaluate checks that already have a deterministic status. Do not add
or remove packages.
## Step 2 — Resolve each `needs_agent` check
For each `(package, check_kind)` with `status == "needs_agent"`, find
the matching `### Check kind: <check_kind>` section below and follow
it. If no section matches, emit a single `add_comment` with:
For each `package` in `packages`:
```
<!-- requirements-check -->
## Check requirements
For each `(check_kind, result)` in `package.checks` where
`result.status == "needs_agent"`:
❌ Internal error: deterministic artifact contains an unknown check kind
(`<check_kind>` on `<pkg>`).
```
1. Look up `## Check kind: <check_kind>` in the **Check instructions**
section below.
2. **If no matching section exists**: emit a single `add_comment` whose
body is:
Then stop. Do not improvise a verdict.
```
<!-- requirements-check -->
## Check requirements
❌ Internal error: the deterministic artifact contains a check kind
(`<check_kind>` on package `<pkg-name>`) that this workflow has no
instructions for. Update `.github/workflows/check-requirements.md`
to add a matching `## Check kind: <check_kind>` section, or remove
the kind from the deterministic stage.
```
Then stop. **Do not improvise** a verdict for an unknown check kind.
3. Otherwise, follow the instructions in that section. They tell you
which icon (✅/⚠️/❌) and one-line explanation to produce.
## Step 3 — Post the comment
Replace every placeholder with the resolved value and emit
`rendered_comment` via `add_comment`. Preserve the leading
`<!-- requirements-check -->` marker. The PR target is already wired;
do not pass `item_number`.
1. Replace every `{{CHECK_CELL:…}}` and `{{CHECK_DETAIL:…}}` placeholder
in `rendered_comment` with the resolved value.
2. Emit the resulting markdown using `add_comment` — set `body` to the
merged `rendered_comment` verbatim (the leading
`<!-- requirements-check -->` marker must be preserved). The PR
target is already set by the workflow; do not pass `item_number`.
If the artifact's top-level `needs_agent` is `false` (no checks need
you), emit `rendered_comment` unchanged.
## Check instructions
### Check kind: `repo_public`
`web-fetch` GET `package.repo_url`.
- 200 + public repo page → ✅ `<repo_url> is publicly accessible.`
- 4xx/5xx or login redirect → ❌ `Source repository at <repo_url> is
not publicly accessible. Home Assistant requires dependencies to
have publicly available source code.`
- Otherwise → ⚠️ with a one-line description.
Verify that the package's source repository is publicly reachable.
If ❌, also mark this package's `release_pipeline` and `async_blocking`
cells/details as `` and explain `Skipped because the source
repository is not publicly accessible.`.
1. Read `package.repo_url`.
2. Use the `web-fetch` tool to GET that URL.
3. Decide the verdict:
- HTTP 200, returns a public repository page → ✅
`<repo_url> is publicly accessible.`
- HTTP 4xx/5xx, or the response redirects to a login / sign-in page →
❌ `Source repository at <repo_url> is not publicly accessible.
Home Assistant requires all dependencies to have publicly available
source code.`
- Any other inconclusive result → ⚠️ with a one-line description.
If `repo_public` resolves to ❌ for a package, **also** mark that
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`
Fetch the PR body via the `pull_requests` MCP using `pr_number`. Extract URLs.
Verify the PR description contains the right link for the change.
- **New package** (`old_version == null`): body must contain a URL
pointing at `repo_url`'s `owner/repo` on the same host (any
sub-path OK). PyPI is not sufficient.
- ✅ if present; otherwise ❌ `PR description must link to the
source repository at <repo_url>. A PyPI page link is not
sufficient.`
- **Version bump**: body must contain a URL on the same host as
`repo_url` that mentions **both** `old_version` and `new_version`
(compare URL, changelog, release page).
- ✅ if present and versions match; otherwise ❌ `PR description
should link to a changelog or compare URL on <repo_url> that
mentions both <old_version> and <new_version>.`
1. Fetch the PR body via the GitHub MCP tool, using the `pr_number`
field from the artifact.
2. Extract all URLs from the body.
3. For a **new package** (`package.old_version` is `null`):
- The PR body must contain a URL that points at `package.repo_url`
(any sub-path of the same `owner/repo` on the same host is
acceptable). A PyPI link is **not** sufficient.
- ✅ if such a URL is present.
- ❌ otherwise:
`PR description must link to the source repository at <repo_url>.
A PyPI page link is not sufficient.`
4. For a **version bump** (`package.old_version` is not `null`):
- The PR body must contain a URL on the same host as
`package.repo_url` that references **both** `package.old_version`
and `package.new_version` (e.g. a GitHub compare URL
`compare/vX...vY`, a release / changelog URL containing both
versions, etc.).
- ✅ if such a URL is present and the versions match the actual bump.
- ❌ otherwise:
`PR description should link to a changelog or compare URL on
<repo_url> that mentions both <old_version> and <new_version>.`
### Check kind: `release_pipeline`
Inspect the upstream's publish-to-PyPI CI. Host-specific lookup, same
rubric:
Inspect the upstream project's release / publish CI pipeline.
1. Locate the publish workflow / job (name or filename contains
`release`, `publish`, `pypi`, or `deploy`).
- GitHub: list `.github/workflows/` via the `repos` MCP, pick the
promising file by name, fetch its contents.
- GitLab: fetch `.gitlab-ci.yml` from the default ref via
`https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
- Other hosts: `web-fetch` an obvious CI config
(`.circleci/config.yml`, `bitbucket-pipelines.yml`, etc.).
2. Apply this rubric:
- **Trigger**: tag push / `release: published` / protected branch —
not solely manual dispatch without an environment guard.
- **Credentials**: OIDC (`id-token: write` +
`pypa/gh-action-pypi-publish` or equivalent) preferred; static
`PYPI_TOKEN` from a CI secret acceptable for a bump.
- **No bypass**: no ungated `twine upload` / `pip upload`.
3. Verdict:
- ✅ — OIDC + sane triggers + no bypass.
- ⚠️ — static token on a bump, details unclear, or
non-GitHub/GitLab host with limited CI visibility.
- ❌ — static token on a new package, or manual-only triggers
without environment protection.
For each package needing inspection, determine the source repository
host from `package.repo_url`, then apply the corresponding checklist.
#### GitHub repositories (`github.com`)
1. List workflows: `GET /repos/{owner}/{repo}/actions/workflows`.
2. Identify any workflow whose name or filename suggests publishing to
PyPI (`release`, `publish`, `pypi`, or `deploy`).
3. Fetch the workflow file and check:
- **Trigger sanity**: triggered by `push` to tags,
`release: published`, or `workflow_run` on a release job —
**not** solely `workflow_dispatch` with no environment-protection
guard.
- **OIDC / Trusted Publisher**: look for `id-token: write` and one of
`pypa/gh-action-pypi-publish`, `actions/attest-build-provenance`,
or `TWINE_PASSWORD` from a static `secrets.PYPI_TOKEN`.
- **No manual upload bypass**: no ungated `twine upload` or
`pip upload`.
4. Verdict:
- ✅ if OIDC + sane triggers + no bypass.
- ⚠️ if static token but version bump, or details unclear.
- ❌ if static token on a new package, or only-manual triggers with
no environment protection.
#### GitLab repositories (`gitlab.com` or self-hosted GitLab)
1. Resolve the project ID via
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`.
2. Fetch `.gitlab-ci.yml` via
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
3. Apply the same conceptual checks: tag-only / protected-branch
triggers, GitLab OIDC `id_tokens` or CI/CD protected `PYPI_TOKEN`, no
ungated `twine upload`. Same verdict rules as GitHub.
#### Other code hosting providers (Bitbucket, Codeberg, Gitea, Sourcehut, …)
1. Use `web-fetch` to retrieve any visible CI configuration
(`.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml`,
`bitbucket-pipelines.yml`, `.builds/*.yml`).
2. Apply the conceptual checks: automated triggers, CI-injected
credentials, no manual `twine upload`.
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 the dependency does not call blocking APIs inside `async def`
bodies. Home Assistant runs on a single asyncio loop, so blocking
calls from the async surface stall the whole loop. A purely sync
library is fine — integrations wrap its calls in an executor.
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.
**Mode** (decided by `old_version`):
- `null` → new package: review the entire current source tree.
- string → version bump: review only the diff between the two tags.
Blocking calls already present in `old_version` are not regressions.
**Two modes — pick by inspecting `package.old_version`:**
**Step 1 — async surface?**
- `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.
Fetch `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` at the
tag matching `new_version` (try `v{version}`, `{version}`,
`release-{version}` — at most three attempts). Use the `repos` MCP for
github.com, `web-fetch` otherwise.
#### Step 1 — Decide whether the library exposes an async surface
If sync-only (no `async def` in public modules; no
asyncio/aiohttp/httpx/anyio in deps; no `Framework :: AsyncIO`
classifier) → ✅ `Sync-only library; Home Assistant integrations must
wrap calls in an executor.` (Same verdict for both modes.)
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}`).
**Step 2 — review the surface**
- 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.
- New package: grep public modules for `async def`, inspect each
async body and transitive helpers.
- Bump: fetch the compare diff
(`/repos/{owner}/{repo}/compare/{old}...{new}` on GitHub, equivalent
on GitLab/other hosts). Only flag patterns on **added** lines that
are inside or reachable from `async def`. If no tag format resolves,
fall back to a full review and note that the diff was unavailable.
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.*
**Blocking patterns to flag inside `async def`:**
#### Step 2a — Mode: new package (`old_version` is `null`)
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct,
`http.client.`, sync `httpx.Client(` / `httpx.get(`, `pycurl`.
- `time.sleep(` (use `await asyncio.sleep(`).
- Sync sockets/SSL: bare `socket.socket` I/O, `ssl.wrap_socket`,
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 on the request path: `open(` /
`pathlib.Path.read_*` / `.write_*` for non-trivial sizes (small
one-shot reads during import are OK).
- Sync DB drivers: `sqlite3`, `psycopg2`, `pymysql`, sync `pymongo` /
`redis.Redis`.
- `subprocess.run` / `subprocess.call` / `os.system`.
- 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_*`).
Calls dispatched to an executor (`run_in_executor`,
`asyncio.to_thread`, `anyio.to_thread.run_sync`) do **not** count as
blocking.
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.
**Verdict:**
#### Step 4 — Verdict
- ✅ — no offending pattern. Bumps: phrase as `No new blocking calls
introduced in {old_version} → {new_version}.`.
- ⚠️ — blocking only in sync helpers the async API never calls, or
clearly off the hot path (e.g. one-shot pre-loop setup). Cite at
least one `<file>:<line>` and say why it's not hot.
- ❌ — blocking call reachable from a public `async def` on the
request/polling path (bump: introduced or moved onto the hot path
by this version). Cite the offending `<file>:<line>` as a clickable
link on the repo host.
### Check kind: `security`
**Baseline** scan of the upstream source for obvious supply-chain red
flags — a cheap first pass, **not** a security review or malware audit.
A clean result means "nothing obvious stood out", not "this package is
safe". The success icon is `☑️` — **never** `` — so a passing scan is
not read as an endorsement.
If `repo_public` resolves to ❌ for the same package, mark `security`'s
cell and detail as `` and explain `Skipped because the source
repository is not publicly accessible.` — the source cannot be fetched.
**Step 1 — Fetch a representative slice**
Locate the source from `package.repo_url`.
- GitHub: resolve the default branch (`GET /repos/{owner}/{repo}`), list
the tree (`GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1`),
find the module dir (`{name}/` or `src/{name}/`, normalising `-` ↔ `_`).
- GitLab: equivalent REST calls. Other hosts: `web-fetch` raw file URLs.
Fetch the **raw contents** of `setup.py` (install-time code runs on every
consumer), `pyproject.toml` (`[build-system]` / custom backend), the
package's `__init__.py`, and co — prioritising `entry_points` targets, plus any name suggesting
bootstrap / loader / self-update (`update*.py`, `loader*.py`,
`bootstrap*.py`, `_native.py`, `_post_install*.py`, …).
If the tree is too large for the API budget, inspect at least `setup.py`,
`pyproject.toml`, and `__init__.py`, then return ⚠️ noting the partial scan.
**Step 2 — Patterns to flag**
Reason from principles, not a fixed checklist: for each file ask *would a
well-behaved library doing what this package's PyPI description claims
need to do this?* If "no" or "unclear", record a finding. The categories
describe the **shape** of concerning behavior; the named APIs, filenames,
and keys are illustrative — treat any equivalent construct (including ones
that did not exist when this was written) the same way.
For every finding include the file path, line number, a snippet
(≤ 120 chars), a permalink
(`https://github.com/{owner}/{repo}/blob/{sha}/{path}#L{line}` or the
GitLab equivalent), and one sentence on why it is out of scope.
1. **Reaches into Home Assistant internals.** A library should touch HA
only through its documented Python API — never the `config_dir`
filesystem or internal auth / session state. Flag code that opens,
reads, writes, or resolves paths to artifacts it does not own
(top-level YAML it did not create, anything under `.storage/`, other
integrations' files) or reads tokens / refresh tokens / auth providers
(e.g. `secrets.yaml`, `.storage/auth*`, `hass.auth`). The principle is
*out-of-scope access*, not a static list of names.
2. **Network input flows into an execution sink (download-and-execute).**
Flag any data-flow from a network response body (any HTTP / WebSocket /
raw-socket client, sync or async) to an execution sink: `exec`, `eval`,
`compile`, `marshal.loads`, `pickle.loads`, `types.FunctionType`,
`importlib.util.spec_from_loader`, `subprocess.*`, `os.system`, shell
pipelines (`curl … | sh`), or a file later imported / executed — plus
package-manager calls (`pip install` / `download`) with args resolved
from network responses at runtime.
3. **Build / install-time code is non-deterministic or non-local.**
`setup.py`, `setup.cfg` `cmdclass`, custom PEP 517 backends, and other
build hooks must only compile and copy files shipped in the sdist. Flag
build-stage code that opens a socket, shells out, writes outside the
build / install tree, or pulls a build backend not on PyPI (Git URL /
local path).
4. **Reads secrets and combines them with an egress path.** The shape is
*secret-source → outbound-channel*. Flag code that reads credential
material (token-like env vars, credential files under the user's home,
OS keychain APIs, browser-profile dirs, HA token stores) **and** in the
same path sends it to a destination the package needn't talk to.
Reading or sending alone is not enough — the *combination* is the signal.
5. **Hides what it does.** Flag opaque data flowing into an execution
sink: large encoded / compressed / hex strings (`base64`, `codecs`,
`zlib`, `lzma`, `bytes.fromhex`, or any equivalent) passed to `exec` /
`eval` / `compile` / `__import__`; identifiers assembled at runtime
then imported; or any construct whose evident purpose is to make the
behavior unreadable.
6. **Hard-coded network destination off-purpose.** Flag outbound URLs or
hosts absent from the package's PyPI `project_urls` with no obvious
connection to its function — short-link / paste services, ephemeral
tunnels, raw IPs, non-default ports against unknown hosts — and any
network call at module top-level / `__init__.py` (runs on import for
every consumer).
A clearly out-of-scope behavior that fits none of the above: flag under
the closest category and explain. The categories guide reasoning, not bound it.
**Verdict**
Aggregate the findings into one of:
- `☑️ Baseline scan found nothing obvious in <list of inspected files>.
This is not a security review — only the cheap checks were run.`
Use `☑️` (**not** ``) so a passing scan is not read as an endorsement.
- `⚠️ <one-line summary>` — patterns with plausible legitimate uses;
include path / line / snippet / permalink per match for the reviewer.
- `❌ <one-line summary>` — patterns with no legitimate explanation
(install-time network execution, decode-and-exec of opaque blobs, reads
of `secrets.yaml` / `.storage/auth*`, token exfiltration to an external
host); same detail.
Be precise. False positives are expected — when in doubt prefer `⚠️` with
context over ``. This check is informational and never blocks the
workflow on its own; a human reviewer decides whether to merge.
- ✅ — 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; reference the inspected file by URL when useful.
- Comment dedup is handled by gh-aw's `add_comment` safe-output via
the `<!-- requirements-check -->` marker.
- If `/tmp/gh-aw/deterministic/results.json` is missing (upstream
cancelled/failed), emit nothing — the post-step verification is
gated and won't complain.
- Be constructive and helpful. Reference the inspected workflow / CI
file by URL where useful so the contributor can fix the issue.
- The dedup of the requirements-check comment is handled by gh-aw's
`add_comment` safe-output via the `<!-- requirements-check -->`
marker on the first line of `rendered_comment`.
- If the deterministic workflow concluded with a non-success status,
this workflow's `if:` guard on `Download deterministic-results
artifact` skipped the download. If you find no file at
`/tmp/gh-aw/deterministic/results.json`, emit nothing — the post-step
verification is also gated and will not complain.
+2 -4
View File
@@ -31,7 +31,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -144,9 +144,7 @@ def _sensor_device_info_to_hass(
adv: Aranet4Advertisement,
) -> DeviceInfo:
"""Convert a sensor device info to hass device info."""
hass_device_info = DeviceInfo(
connections={(CONNECTION_BLUETOOTH, adv.device.address)}
)
hass_device_info = DeviceInfo({})
if adv.readings and adv.readings.name:
hass_device_info[ATTR_NAME] = adv.readings.name
hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["mozart-api==6.2.0.44.0"],
"requirements": ["mozart-api==5.3.1.108.2"],
"zeroconf": ["_bangolufsen._tcp.local."]
}
@@ -25,9 +25,6 @@ BINARY_SENSOR_TYPES = (
key="open",
device_class=BinarySensorDeviceClass.WINDOW,
),
BinarySensorEntityDescription(
key="input",
),
)
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.6"]
"requirements": ["home-assistant-frontend==20260527.5"]
}
+18 -36
View File
@@ -40,8 +40,6 @@ CONF_COMMANDS = "commands"
CONF_DATA = "data"
CONF_IR_COUNT = "ir_count"
EMPTY_COMMAND_PLACEHOLDER = '""'
PLATFORM_SCHEMA = REMOTE_PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_MAC): cv.string,
@@ -71,35 +69,6 @@ PLATFORM_SCHEMA = REMOTE_PLATFORM_SCHEMA.extend(
)
def _format_command_value(value: str) -> str:
"""Format a command name or command data value."""
value = value.strip()
return value or EMPTY_COMMAND_PLACEHOLDER
def _format_command_table(commands: Iterable[dict[str, str]]) -> str:
"""Format YAML commands for pyitachip2ir."""
return "".join(
f"{_format_command_value(command[CONF_NAME])}\n"
f"{_format_command_value(command[CONF_DATA])}\n"
for command in commands
)
def _setup_remote_entity(
itachip2ir: Any, device_config: dict[str, Any]
) -> ITachIP2IRRemote:
"""Create an iTach remote entity from YAML device config."""
name = device_config.get(CONF_NAME)
modaddr = int(device_config.get(CONF_MODADDR, DEFAULT_MODADDR))
connaddr = int(device_config.get(CONF_CONNADDR, DEFAULT_CONNADDR))
ir_count = int(device_config.get(CONF_IR_COUNT, DEFAULT_IR_COUNT))
command_table = _format_command_table(device_config[CONF_COMMANDS])
itachip2ir.addDevice(name, modaddr, connaddr, command_table)
return ITachIP2IRRemote(itachip2ir, name, ir_count)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
@@ -115,17 +84,30 @@ def setup_platform(
_LOGGER.error("Unable to find iTach")
return
devices = [
_setup_remote_entity(itachip2ir, device_config)
for device_config in config[CONF_DEVICES]
]
devices = []
for data in config[CONF_DEVICES]:
name = data.get(CONF_NAME)
modaddr = int(data.get(CONF_MODADDR, DEFAULT_MODADDR))
connaddr = int(data.get(CONF_CONNADDR, DEFAULT_CONNADDR))
ir_count = int(data.get(CONF_IR_COUNT, DEFAULT_IR_COUNT))
cmddatas = ""
for cmd in data.get(CONF_COMMANDS):
cmdname = cmd[CONF_NAME].strip()
if not cmdname:
cmdname = '""'
cmddata = cmd[CONF_DATA].strip()
if not cmddata:
cmddata = '""'
cmddatas += f"{cmdname}\n{cmddata}\n"
itachip2ir.addDevice(name, modaddr, connaddr, cmddatas)
devices.append(ITachIP2IRRemote(itachip2ir, name, ir_count))
add_entities(devices, True)
class ITachIP2IRRemote(remote.RemoteEntity):
"""Device that sends commands to an ITachIP2IR device."""
def __init__(self, itachip2ir: Any, name: str | None, ir_count: int) -> None:
def __init__(self, itachip2ir, name, ir_count):
"""Initialize device."""
self.itachip2ir = itachip2ir
self._attr_is_on = False
@@ -422,7 +422,9 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity):
},
)
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
"Invalid alarm code provided",
translation_domain=DOMAIN,
translation_key="invalid_code",
)
@@ -8,7 +8,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE]
PLATFORMS: list[Platform] = [Platform.CLIMATE]
async def async_setup_entry(
@@ -1,146 +0,0 @@
"""Binary sensor platform for MELCloud Home."""
from collections.abc import Callable
from dataclasses import dataclass
from aiomelcloudhome import ATAUnit, ATWUnit
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ATABinarySensorEntityDescription(BinarySensorEntityDescription):
"""Class to hold MELCloud Home ATA binary sensor description."""
state_fn: Callable[[ATAUnit], bool | None]
@dataclass(frozen=True, kw_only=True)
class ATWBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Class to hold MELCloud Home ATW binary sensor description."""
state_fn: Callable[[ATWUnit], bool | None]
ATA_SENSORS: tuple[ATABinarySensorEntityDescription, ...] = (
ATABinarySensorEntityDescription(
key="error",
translation_key="error",
device_class=BinarySensorDeviceClass.PROBLEM,
state_fn=lambda unit: unit.is_in_error,
entity_category=EntityCategory.DIAGNOSTIC,
),
ATABinarySensorEntityDescription(
key="standby",
translation_key="standby",
state_fn=lambda unit: unit.in_standby_mode,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
ATW_SENSORS: tuple[ATWBinarySensorEntityDescription, ...] = (
ATWBinarySensorEntityDescription(
key="error",
translation_key="error",
device_class=BinarySensorDeviceClass.PROBLEM,
state_fn=lambda unit: unit.is_in_error,
entity_category=EntityCategory.DIAGNOSTIC,
),
ATWBinarySensorEntityDescription(
key="standby",
translation_key="standby",
state_fn=lambda unit: unit.in_standby_mode,
entity_category=EntityCategory.DIAGNOSTIC,
),
ATWBinarySensorEntityDescription(
key="forced_hot_water",
translation_key="forced_hot_water",
state_fn=lambda unit: unit.forced_hot_water_mode,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MelCloudHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud Home binary sensors."""
coordinator = entry.runtime_data
def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
async_add_entities(
ATABinarySensor(coordinator, entity_description, unit)
for entity_description in ATA_SENSORS
for unit in units
)
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
async_add_entities(
ATWBinarySensor(coordinator, entity_description, unit)
for entity_description in ATW_SENSORS
for unit in units
)
coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
_async_add_new_ata_units(list(coordinator.ata_units.values()))
_async_add_new_atw_units(list(coordinator.atw_units.values()))
class ATABinarySensor(MelCloudHomeATAUnitEntity, BinarySensorEntity):
"""Representation of a MELCloud Home ATA binary sensor."""
entity_description: ATABinarySensorEntityDescription
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
entity_description: ATABinarySensorEntityDescription,
unit: ATAUnit,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, unit)
self.entity_description = entity_description
self._attr_unique_id = f"{unit.id}_{entity_description.key}"
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.state_fn(self.unit)
class ATWBinarySensor(MelCloudHomeATWUnitEntity, BinarySensorEntity):
"""Representation of a MELCloud Home ATW binary sensor."""
entity_description: ATWBinarySensorEntityDescription
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
entity_description: ATWBinarySensorEntityDescription,
unit: ATWUnit,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, unit)
self.entity_description = entity_description
self._attr_unique_id = f"{unit.id}_{entity_description.key}"
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.state_fn(self.unit)
@@ -103,6 +103,7 @@ async def async_setup_entry(
async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units)
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
# Erwin: create zone 1 for all units, and zone 2 only when the unit supports it.
async_add_entities(
ATWZoneClimateEntity(coordinator, unit, zone_number)
for unit in units
@@ -185,12 +186,12 @@ class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current room temperature."""
return self.unit.room_temperature
return self.unit.room_temperature if self.unit else None
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
return self.unit.set_temperature
return self.unit.set_temperature if self.unit else None
@property
def hvac_mode(self) -> HVACMode:
@@ -26,9 +26,7 @@ _LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): TextSelector(
TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username")
),
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
@@ -15,6 +15,7 @@ class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]):
"""Base entity for MELCloud Home."""
_attr_has_entity_name = True
_attr_name: str | None = None
class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity):
@@ -1,12 +0,0 @@
{
"entity": {
"binary_sensor": {
"forced_hot_water": {
"default": "mdi:water-boiler"
},
"standby": {
"default": "mdi:power-sleep"
}
}
}
}
@@ -24,17 +24,6 @@
}
},
"entity": {
"binary_sensor": {
"error": {
"name": "Error"
},
"forced_hot_water": {
"name": "Forced hot water"
},
"standby": {
"name": "Standby"
}
},
"climate": {
"ata_unit": {
"state_attributes": {
+67 -2
View File
@@ -5,7 +5,7 @@ of entities and react to changes.
"""
import asyncio
from collections import UserDict, defaultdict
from collections import UserDict, defaultdict, deque
from collections.abc import (
Callable,
Collection,
@@ -1428,10 +1428,23 @@ def _verify_event_type_length_or_raise(event_type: EventType[_DataT] | str) -> N
raise MaxLengthExceeded(event_type, "event_type", MAX_LENGTH_EVENT_EVENT_TYPE)
# Maximum number of events fired by event listeners which will be dispatched
# from a single top-level fire, to guard against event listeners firing
# events in an endless loop.
_MAX_QUEUED_EVENT_DISPATCHES: Final = 10_000
class EventBus:
"""Allow the firing of and listening for events."""
__slots__ = ("_debug", "_hass", "_listeners", "_match_all_listeners")
__slots__ = (
"_debug",
"_dispatching",
"_fire_queue",
"_hass",
"_listeners",
"_match_all_listeners",
)
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize a new event bus."""
@@ -1441,6 +1454,10 @@ class EventBus:
self._match_all_listeners: list[_FilterableJobType[Any]] = []
self._listeners[MATCH_ALL] = self._match_all_listeners
self._hass = hass
self._fire_queue: deque[
tuple[EventType[Any] | str, Any, EventOrigin, Context | None, float]
] = deque()
self._dispatching = False
self._async_logging_changed()
self.async_listen(EVENT_LOGGING_CHANGED, self._async_logging_changed)
@@ -1520,6 +1537,54 @@ class EventBus:
"Bus:Handling %s", _event_repr(event_type, origin, event_data)
)
if self._dispatching:
# Non-reentrant dispatch: an event fired from within an event
# listener is queued and dispatched after the current dispatch
# completes, so all listeners observe events in fire order. The
# fire time is captured now since dispatch is deferred.
self._fire_queue.append(
(event_type, event_data, origin, context, time_fired or time.time())
)
return
self._dispatching = True
try:
self._async_dispatch(event_type, event_data, origin, context, time_fired)
if self._fire_queue:
self._async_drain_fire_queue()
finally:
self._dispatching = False
@callback
def _async_drain_fire_queue(self) -> None:
"""Dispatch events queued by event listeners, in fire order."""
fire_queue = self._fire_queue
dispatched = 0
while fire_queue:
if dispatched >= _MAX_QUEUED_EVENT_DISPATCHES:
_LOGGER.error(
"Aborting event dispatch: %d events were fired by event"
" listeners while dispatching a single event; event"
" listeners are likely firing events in an endless loop."
" Dropping queued events: %s",
dispatched,
", ".join(sorted({str(item[0]) for item in fire_queue})),
)
fire_queue.clear()
return
self._async_dispatch(*fire_queue.popleft())
dispatched += 1
@callback
def _async_dispatch(
self,
event_type: EventType[_DataT] | str,
event_data: _DataT | None,
origin: EventOrigin,
context: Context | None,
time_fired: float | None,
) -> None:
"""Dispatch an event to its listeners."""
listeners = self._listeners.get(event_type, EMPTY_LIST)
if event_type not in EVENTS_EXCLUDED_FROM_MATCH_ALL:
match_all_listeners = self._match_all_listeners
+1 -1
View File
@@ -39,7 +39,7 @@ habluetooth==6.8.3
hass-nabucasa==2.2.0
hassil==3.7.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260527.6
home-assistant-frontend==20260527.5
home-assistant-intents==2026.6.1
httpx==0.28.1
ifaddr==0.2.0
+1 -1
View File
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
from typing import Final
FRONTEND_VERSION: Final[str] = "20260527.6"
FRONTEND_VERSION: Final[str] = "20260527.5"
MDI_ICONS: Final[set[str]] = {
"ab-testing",
+2 -2
View File
@@ -1272,7 +1272,7 @@ hole==0.9.0
holidays==0.98
# homeassistant.components.frontend
home-assistant-frontend==20260527.6
home-assistant-frontend==20260527.5
# homeassistant.components.conversation
home-assistant-intents==2026.6.1
@@ -1610,7 +1610,7 @@ motionblindsble==0.1.3
motioneye-client==0.3.14
# homeassistant.components.bang_olufsen
mozart-api==6.2.0.44.0
mozart-api==5.3.1.108.2
# homeassistant.components.mullvad
mullvad-api==1.0.0
-1
View File
@@ -25,7 +25,6 @@ class CheckKind(StrEnum):
REPO_PUBLIC = "repo_public"
CI_UPLOAD = "ci_upload"
RELEASE_PIPELINE = "release_pipeline"
SECURITY = "security"
PR_LINK = "pr_link"
ASYNC_BLOCKING = "async_blocking"
YANKED = "yanked"
-1
View File
@@ -21,7 +21,6 @@ _CHECK_DISPLAY: tuple[tuple[CheckKind, str], ...] = (
(CheckKind.REPO_PUBLIC, "Repo Public"),
(CheckKind.CI_UPLOAD, "CI Upload"),
(CheckKind.RELEASE_PIPELINE, "Release Pipeline"),
(CheckKind.SECURITY, "Security"),
(CheckKind.PR_LINK, "PR Link"),
(CheckKind.ASYNC_BLOCKING, "Async Safe"),
)
-11
View File
@@ -16,8 +16,6 @@ What the runner defers to the LLM (NEEDS_AGENT):
- `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.
- `security`: lightweight scan of the upstream source for supply-chain red
flags. Always deferred — the agent fetches the source and inspects it.
"""
from .diff import parse_diff
@@ -139,7 +137,6 @@ def run_checks(
pkg.checks[CheckKind.REPO_PUBLIC] = fail
pkg.checks[CheckKind.PR_LINK] = fail
pkg.checks[CheckKind.ASYNC_BLOCKING] = fail
pkg.checks[CheckKind.SECURITY] = fail
elif pkg.repo_url:
pkg.checks[CheckKind.REPO_PUBLIC] = CheckResult(
CheckStatus.NEEDS_AGENT,
@@ -149,10 +146,6 @@ def run_checks(
CheckStatus.NEEDS_AGENT,
"Presence of the required link in the PR description must be verified by the agent.",
)
pkg.checks[CheckKind.SECURITY] = CheckResult(
CheckStatus.NEEDS_AGENT,
"Baseline supply-chain source scan must be performed by the agent.",
)
if pkg.old_version is None:
async_reason = (
"New dependency: agent must review the entire source tree "
@@ -175,10 +168,6 @@ def run_checks(
pkg.checks[CheckKind.REPO_PUBLIC] = fail
pkg.checks[CheckKind.PR_LINK] = fail
pkg.checks[CheckKind.ASYNC_BLOCKING] = fail
pkg.checks[CheckKind.SECURITY] = CheckResult(
CheckStatus.FAIL,
"No source repository URL on PyPI — source cannot be inspected.",
)
result = CheckRunResult(pr_number=pr_number, packages=packages)
result.rendered_comment = render_comment(result)
return result
-4
View File
@@ -85,7 +85,6 @@ async def test_sensors_aranet_radiation(
assert device.model == "Aranet Radiation"
assert device.sw_version == "v1.4.38"
assert device.manufacturer == "SAF Tehnika"
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@@ -147,7 +146,6 @@ async def test_sensors_aranet2(
assert device.model == "Aranet2"
assert device.sw_version == "v1.4.4"
assert device.manufacturer == "SAF Tehnika"
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@@ -229,7 +227,6 @@ async def test_sensors_aranet4(
assert device.model == "Aranet4"
assert device.sw_version == "v1.2.0"
assert device.manufacturer == "SAF Tehnika"
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@@ -313,7 +310,6 @@ async def test_sensors_aranetrn(
assert device.model == "Aranet Radon"
assert device.sw_version == "v1.6.4"
assert device.manufacturer == "SAF Tehnika"
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
+11 -16
View File
@@ -2,7 +2,6 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
from uuid import UUID
from mozart_api.models import (
Action,
@@ -255,8 +254,8 @@ def mock_mozart_client() -> Generator[AsyncMock]:
dynamic_list=None,
first_child_menu_item_id=None,
label="Yle Radio Suomi Helsinki",
next_sibling_menu_item_id=UUID("0b4552f8-7ac6-5046-9d44-5410a815b8d6"),
parent_menu_item_id=UUID("eee0c2d0-2b3a-4899-a708-658475c38926"),
next_sibling_menu_item_id="0b4552f8-7ac6-5046-9d44-5410a815b8d6",
parent_menu_item_id="eee0c2d0-2b3a-4899-a708-658475c38926",
available=None,
content=ContentItem(
categories=["music"],
@@ -265,7 +264,7 @@ def mock_mozart_client() -> Generator[AsyncMock]:
source=SourceTypeEnum(value="netRadio"),
),
fixed=True,
id=UUID("b355888b-2cde-5f94-8592-d47b71d52a27"),
id="b355888b-2cde-5f94-8592-d47b71d52a27",
),
# Has "hdmi" as category, so should be included in video sources
"b6591565-80f4-4356-bcd9-c92ca247f0a9": RemoteMenuItem(
@@ -294,8 +293,8 @@ def mock_mozart_client() -> Generator[AsyncMock]:
dynamic_list="none",
first_child_menu_item_id=None,
label="HDMI A",
next_sibling_menu_item_id=UUID("0ba98974-7b1f-40dc-bc48-fbacbb0f1793"),
parent_menu_item_id=UUID("b66c835b-6b98-4400-8f84-6348043792c7"),
next_sibling_menu_item_id="0ba98974-7b1f-40dc-bc48-fbacbb0f1793",
parent_menu_item_id="b66c835b-6b98-4400-8f84-6348043792c7",
available=True,
content=ContentItem(
categories=["hdmi"],
@@ -304,7 +303,7 @@ def mock_mozart_client() -> Generator[AsyncMock]:
source=SourceTypeEnum(value="tv"),
),
fixed=False,
id=UUID("b6591565-80f4-4356-bcd9-c92ca247f0a9"),
id="b6591565-80f4-4356-bcd9-c92ca247f0a9",
),
# The parent remote menu item. Has the TV label and
# should therefore not be included in video sources
@@ -313,14 +312,14 @@ def mock_mozart_client() -> Generator[AsyncMock]:
scene_list=None,
disabled=False,
dynamic_list="none",
first_child_menu_item_id=UUID("b6591565-80f4-4356-bcd9-c92ca247f0a9"),
first_child_menu_item_id="b6591565-80f4-4356-bcd9-c92ca247f0a9",
label="TV",
next_sibling_menu_item_id=UUID("0c4547fe-d3cc-4348-a425-473595b8c9fb"),
next_sibling_menu_item_id="0c4547fe-d3cc-4348-a425-473595b8c9fb",
parent_menu_item_id=None,
available=True,
content=None,
fixed=True,
id=UUID("b66c835b-6b98-4400-8f84-6348043792c7"),
id="b66c835b-6b98-4400-8f84-6348043792c7",
),
# Has an empty content, so should not be included
"64c9da45-3682-44a4-8030-09ed3ef44160": RemoteMenuItem(
@@ -331,11 +330,11 @@ def mock_mozart_client() -> Generator[AsyncMock]:
first_child_menu_item_id=None,
label="ListeningPosition",
next_sibling_menu_item_id=None,
parent_menu_item_id=UUID("0c4547fe-d3cc-4348-a425-473595b8c9fb"),
parent_menu_item_id="0c4547fe-d3cc-4348-a425-473595b8c9fb",
available=True,
content=None,
fixed=True,
id=UUID("64c9da45-3682-44a4-8030-09ed3ef44160"),
id="64c9da45-3682-44a4-8030-09ed3ef44160",
),
}
client.get_beolink_peers = AsyncMock()
@@ -344,13 +343,11 @@ def mock_mozart_client() -> Generator[AsyncMock]:
friendly_name=TEST_FRIENDLY_NAME_3,
jid=TEST_JID_3,
ip_address=TEST_HOST_3,
audio_transport="v2",
),
BeolinkPeer(
friendly_name=TEST_FRIENDLY_NAME_4,
jid=TEST_JID_4,
ip_address=TEST_HOST_4,
audio_transport="v2",
),
]
client.get_beolink_listeners = AsyncMock()
@@ -359,13 +356,11 @@ def mock_mozart_client() -> Generator[AsyncMock]:
friendly_name=TEST_FRIENDLY_NAME_3,
jid=TEST_JID_3,
ip_address=TEST_HOST_3,
audio_transport="v2",
),
BeolinkPeer(
friendly_name=TEST_FRIENDLY_NAME_4,
jid=TEST_JID_4,
ip_address=TEST_HOST_4,
audio_transport="v2",
),
]
+1 -1
View File
@@ -203,7 +203,7 @@ TEST_PLAYBACK_METADATA_VIDEO = PlaybackContentMetadata(
title="HDMI A",
source_internal_id="hdmi_1",
output_channel_processing="TrueImage",
output_channels="5.0.2",
output_Channels="5.0.2",
)
TEST_PLAYBACK_ERROR = PlaybackError(error="Test error")
TEST_PLAYBACK_PROGRESS = PlaybackProgress(progress=123)
@@ -582,7 +582,7 @@ async def test_async_update_beolink_listener(
playback_metadata_callback(
PlaybackContentMetadata(
remote_leader=BeolinkLeader(
friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2, audio_transport="v2"
friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2
)
)
)
+9 -61
View File
@@ -45,75 +45,23 @@ def open_sensor_fixture() -> tuple[AsyncMock, str]:
return feature, "binary_sensor.my_open_sensor_opensensor_0_open"
@pytest.fixture(name="inputsensor")
def inputsensor_fixture() -> tuple[AsyncMock, str]:
"""Return a default inputSensor fixture."""
feature: AsyncMock = mock_feature(
"binary_sensors",
blebox_uniapi.binary_sensor.Input,
unique_id="BleBox-inputSensorD-aa11bb22cc33-0.input",
full_name="inputSensorD-0.input",
device_class="input",
)
product = feature.product
type(product).name = PropertyMock(return_value="My input sensor")
type(product).model = PropertyMock(return_value="inputSensorD")
return feature, "binary_sensor.my_input_sensor_inputsensord_0_input"
@pytest.mark.parametrize(
(
"fixture_name",
"unique_id",
"expected_name",
"expected_device_class",
"expected_state",
"expected_device_name",
),
[
pytest.param(
"rainsensor",
"BleBox-windRainSensor-ea68e74f4f49-0.rain",
"My rain sensor windRainSensor-0.rain",
BinarySensorDeviceClass.MOISTURE,
STATE_ON,
"My rain sensor",
id="moisture",
),
pytest.param(
"inputsensor",
"BleBox-inputSensorD-aa11bb22cc33-0.input",
"My input sensor inputSensorD-0.input",
None,
STATE_ON,
"My input sensor",
id="input",
),
],
)
async def test_init(
hass: HomeAssistant,
fixture_name: str,
unique_id: str,
expected_name: str,
expected_device_class: BinarySensorDeviceClass | None,
expected_state: str,
expected_device_name: str,
device_registry: dr.DeviceRegistry,
request: pytest.FixtureRequest,
rainsensor: AsyncMock, device_registry: dr.DeviceRegistry, hass: HomeAssistant
) -> None:
"""Test binary_sensor initialisation."""
_, entity_id = request.getfixturevalue(fixture_name)
_, entity_id = rainsensor
entry = await async_setup_entity(hass, entity_id)
assert entry.unique_id == unique_id
assert entry.unique_id == "BleBox-windRainSensor-ea68e74f4f49-0.rain"
state = hass.states.get(entity_id)
assert state.name == expected_name
assert state.attributes.get(ATTR_DEVICE_CLASS) == expected_device_class
assert state.state == expected_state
assert state.name == "My rain sensor windRainSensor-0.rain"
assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOISTURE
assert state.state == STATE_ON
device = device_registry.async_get(entry.device_id)
assert device.name == expected_device_name
assert device.name == "My rain sensor"
async def test_open_sensor_init(
@@ -240,7 +240,7 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) -
assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED
with pytest.raises(ServiceValidationError) as err:
with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"):
await hass.services.async_call(
alarm_control_panel.DOMAIN,
service,
@@ -250,7 +250,6 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) -
},
blocking=True,
)
assert err.value.translation_key == "invalid_code"
assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED
@@ -1109,9 +1108,8 @@ async def test_disarm_during_trigger_with_invalid_code(hass: HomeAssistant) -> N
assert hass.states.get(entity_id).state == AlarmControlPanelState.PENDING
with pytest.raises(ServiceValidationError) as err:
with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"):
await common.async_alarm_disarm(hass, entity_id=entity_id)
assert err.value.translation_key == "invalid_code"
assert hass.states.get(entity_id).state == AlarmControlPanelState.PENDING
@@ -1228,9 +1226,8 @@ async def test_disarm_with_template_code(hass: HomeAssistant) -> None:
state = hass.states.get(entity_id)
assert state.state == AlarmControlPanelState.ARMED_HOME
with pytest.raises(ServiceValidationError) as err:
with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"):
await common.async_alarm_disarm(hass, "def")
assert err.value.translation_key == "invalid_code"
state = hass.states.get(entity_id)
assert state.state == AlarmControlPanelState.ARMED_HOME
@@ -1,253 +0,0 @@
# serializer version: 1
# name: test_all_entities[binary_sensor.heat_pump_error-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.heat_pump_error',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Error',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Error',
'platform': 'melcloud_home',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'error',
'unique_id': 'atw-unit-uuid-1_error',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.heat_pump_error-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Heat Pump Error',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.heat_pump_error',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.heat_pump_forced_hot_water-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.heat_pump_forced_hot_water',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Forced hot water',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Forced hot water',
'platform': 'melcloud_home',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'forced_hot_water',
'unique_id': 'atw-unit-uuid-1_forced_hot_water',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.heat_pump_forced_hot_water-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Heat Pump Forced hot water',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.heat_pump_forced_hot_water',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.heat_pump_standby-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.heat_pump_standby',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Standby',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Standby',
'platform': 'melcloud_home',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'standby',
'unique_id': 'atw-unit-uuid-1_standby',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.heat_pump_standby-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Heat Pump Standby',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.heat_pump_standby',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.living_room_ac_error-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.living_room_ac_error',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Error',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Error',
'platform': 'melcloud_home',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'error',
'unique_id': 'ata-unit-uuid-1_error',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.living_room_ac_error-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Living Room AC Error',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.living_room_ac_error',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.living_room_ac_standby-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.living_room_ac_standby',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Standby',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Standby',
'platform': 'melcloud_home',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'standby',
'unique_id': 'ata-unit-uuid-1_standby',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.living_room_ac_standby-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Living Room AC Standby',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.living_room_ac_standby',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
@@ -1,39 +0,0 @@
"""Tests for the MELCloud Home binary sensor platform."""
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
ATA_ERROR_ENTITY_ID = "binary_sensor.living_room_ac_error"
@pytest.fixture(autouse=True)
def enable_all_entities(entity_registry_enabled_by_default: None) -> None:
"""Make sure all entities are enabled."""
@pytest.mark.usefixtures("mock_melcloud_client")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch(
"homeassistant.components.melcloud_home.PLATFORMS",
[Platform.BINARY_SENSOR],
):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry.entry_id
)
+3 -10
View File
@@ -1,6 +1,6 @@
"""Test the MELCloud Home climate platform."""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
from aiomelcloudhome import (
ATAFanSpeed,
@@ -29,7 +29,6 @@ from homeassistant.const import (
ATTR_TEMPERATURE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -51,14 +50,8 @@ async def test_climate_platform(
entity_registry: er.EntityRegistry,
) -> None:
"""Test all climate entity states and attributes from fixture data."""
with patch(
"homeassistant.components.melcloud_home.PLATFORMS",
[Platform.CLIMATE],
):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry.entry_id
)
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
@@ -25,7 +25,6 @@ def test_render_all_conclusive_collapses_details() -> None:
CheckKind.REPO_PUBLIC: _pass("public"),
CheckKind.CI_UPLOAD: _pass("attestation found"),
CheckKind.RELEASE_PIPELINE: _pass("OIDC via attestation"),
CheckKind.SECURITY: _pass("baseline scan clean"),
CheckKind.PR_LINK: _pass("link found"),
CheckKind.ASYNC_BLOCKING: _pass("no blocking calls in async"),
},
@@ -50,7 +49,6 @@ def test_render_needs_agent_emits_generic_placeholders() -> None:
CheckKind.REPO_PUBLIC: CheckResult(CheckStatus.NEEDS_AGENT, ""),
CheckKind.CI_UPLOAD: CheckResult(CheckStatus.WARN, "no attestation"),
CheckKind.RELEASE_PIPELINE: CheckResult(CheckStatus.NEEDS_AGENT, ""),
CheckKind.SECURITY: CheckResult(CheckStatus.NEEDS_AGENT, ""),
CheckKind.PR_LINK: CheckResult(CheckStatus.NEEDS_AGENT, ""),
CheckKind.ASYNC_BLOCKING: CheckResult(CheckStatus.NEEDS_AGENT, ""),
},
@@ -60,8 +58,6 @@ def test_render_needs_agent_emits_generic_placeholders() -> None:
assert "{{CHECK_DETAIL:pkg:repo_public}}" in rendered
assert "{{CHECK_CELL:pkg:release_pipeline}}" in rendered
assert "{{CHECK_DETAIL:pkg:release_pipeline}}" in rendered
assert "{{CHECK_CELL:pkg:security}}" in rendered
assert "{{CHECK_DETAIL:pkg:security}}" 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
@@ -60,7 +60,6 @@ def test_runner_attestation_recognised(monkeypatch: pytest.MonkeyPatch) -> None:
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 pkg.checks[CheckKind.SECURITY].status == CheckStatus.NEEDS_AGENT
assert result.needs_agent is True
@@ -160,11 +159,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 → short-circuit to FAIL
# 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 pkg.checks[CheckKind.SECURITY].status == CheckStatus.FAIL
assert result.needs_agent is False
@@ -200,9 +198,7 @@ def test_runner_pypi_found_but_no_repo_url_fails_repo_checks(
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 pkg.checks[CheckKind.SECURITY].status == CheckStatus.FAIL
assert "does not advertise" in pkg.checks[CheckKind.REPO_PUBLIC].details
assert "cannot be inspected" in pkg.checks[CheckKind.SECURITY].details
def test_runner_async_blocking_new_package_full_review(
+42 -16
View File
@@ -1336,22 +1336,14 @@ async def test_eventbus_listen_once_run_immediately_coro(hass: HomeAssistant) ->
async def test_eventbus_nested_fire_dispatch_order(hass: HomeAssistant) -> None:
"""Test dispatch order when a listener fires an event synchronously.
Event dispatch is reentrant: an event fired from within a synchronous
listener is dispatched immediately, nested inside the dispatch of the
outer event.
The implementation of event listeners is such that listeners are called
in the order they were registered
As a result, the order in which a listener observes the two
events depends on its registration position relative to the listener
which fires the nested event: listeners registered before it observe
fire order, listeners registered after it observe the nested event
first.
This test documents the current behavior rather than guarantees it: a
non-reentrant (queued) dispatch would make all listeners observe fire
order.
Event dispatch is however non-reentrant: an event fired from within a
synchronous listener is queued and dispatched after the dispatch of the
outer event completes. All listeners therefore observe events in fire
order, regardless of their registration position relative to the
listener which fires the nested event.
"""
observed_before: list[str] = []
observed_after: list[str] = []
@@ -1378,15 +1370,49 @@ async def test_eventbus_nested_fire_dispatch_order(hass: HomeAssistant) -> None:
hass.bus.async_fire("test_outer")
# Registered before the nesting listener: observes fire order.
# All listeners observe fire order, regardless of registration position
# relative to the nesting listener.
assert observed_before == ["test_outer", "test_nested"]
# Registered after the nesting listener: observes inverted order.
assert observed_after == ["test_nested", "test_outer"]
assert observed_after == ["test_outer", "test_nested"]
for unsub in unsubs:
unsub()
async def test_eventbus_nested_fire_endless_loop_guard(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that event listeners firing events in an endless loop are stopped.
Without the guard, a listener which unconditionally fires an event it
also listens to would keep the dispatch drain loop running forever.
"""
calls: list[ha.Event] = []
@ha.callback
def refire(event: ha.Event) -> None:
calls.append(event)
hass.bus.async_fire("test_loop")
unsub = hass.bus.async_listen("test_loop", refire)
with patch.object(ha, "_MAX_QUEUED_EVENT_DISPATCHES", 10):
hass.bus.async_fire("test_loop")
# The top-level dispatch plus 10 queued dispatches, then the loop is
# aborted and the queued event dropped.
assert len(calls) == 11
assert "listeners are likely firing events in an endless loop" in caplog.text
assert "test_loop" in caplog.text
unsub()
# The bus remains functional after the aborted dispatch
events = async_capture_events(hass, "test_after")
hass.bus.async_fire("test_after")
assert len(events) == 1
async def test_eventbus_unsubscribe_listener(hass: HomeAssistant) -> None:
"""Test unsubscribe listener from returned function."""
calls = []