Skip to content

chore: migrate clean asyncio.gather fan-outs to TaskGroup#500

Merged
shbatm merged 1 commit into
v3.x.xfrom
chore/asyncio-taskgroup
May 8, 2026
Merged

chore: migrate clean asyncio.gather fan-outs to TaskGroup#500
shbatm merged 1 commit into
v3.x.xfrom
chore/asyncio-taskgroup

Conversation

@shbatm
Copy link
Copy Markdown
Collaborator

@shbatm shbatm commented May 8, 2026

Summary

  • Convert the three fail-fast asyncio.gather() call sites that don't rely on return_exceptions=True semantics to asyncio.TaskGroup:
    • ISY.initialize() setup fan-out (pyisy/isy.py)
    • Connection.get_variables() (pyisy/connection.py)
    • NodeServers profile-file download (pyisy/node_servers.py)
  • Bonus readability win in ISY.initialize(): replace index-based isy_setup_results[i] access with named locals (status_xml, time_xml, nodes_xml, …) so the "load-bearing response is None" guard and the subsequent manager construction are self-documenting.

Why these three (and not the other two)

The two remaining gather() sites are intentionally left alone:

  • Connection.get_variable_defs()Variables consumes the result best-effort: integer or state defs may legitimately be empty/missing on minimal controllers.
  • Node.send_cmd multi-command dispatch — per-command failures must not cancel the others.

Both rely on return_exceptions=True, which has no direct TaskGroup equivalent. A faithful migration would mean wrapping each task in a try/except inside tg.create_task(...), which is more code than the current one-liner for zero behavioral gain.

Test plan

  • pytest — 436 passed in 20.64s
  • pre-commit run --all-files — clean
  • Smoke test against a real eisy via python3 -m pyisy https://eisy.local:8443 admin <pw> (recommend before merge)

🤖 Generated with Claude Code

Convert the three fail-fast gather() call sites that don't rely on
return_exceptions=True semantics: ISY.initialize() setup, get_variables(),
and node-server profile download. The two remaining gather() sites
(get_variable_defs, Node.send_cmd multi-command dispatch) are intentionally
left as-is — both want best-effort/per-task failure isolation, which
TaskGroup doesn't provide without per-task try/except wrappers.

Bonus: ISY.initialize() now uses named locals for each task result instead
of indexing into a heterogenous list, which makes the load-bearing-response
None check and the manager construction much clearer to read.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@shbatm
Copy link
Copy Markdown
Collaborator Author

shbatm commented May 8, 2026

Smoke-tested against live eisy

  • HTTPS connection negotiated cleanly (TLS 1.3, tls_ver="auto")
  • initialize() (new TaskGroup path) loaded config / status / time / nodes / programs / variables / networking — full node tree dumped as expected
  • Total loading time: 1.35s — on par with the prior gather() baseline, no regression
  • Clean exit, no unraisable exceptions or warnings

@shbatm
Copy link
Copy Markdown
Collaborator Author

shbatm commented May 8, 2026

Re: the TaskGroup / ExceptionGroup note on connection.py:307 — fair callout, acknowledging publicly so it's on the record:

Connection.request() returns None on most error paths (4xx, 5xx, retries exhausted) rather than raising. The only flat raises are ISYInvalidAuthError (401) and ISYConnectionError (when retries=None, used only by test_connection).

ISY.initialize() runs test_connection() before the TaskGroup, so the realistic auth/connection failure modes surface flat, ahead of any TaskGroup. Inside the TaskGroup, requests use the default retry budget — failures return None and are converted to a flat ISYResponseParseError by the existing if any(x is None for x in ...) guard. So in practice, neither initialize() nor get_variables() should leak an ExceptionGroup to downstream consumers under normal failure modes.

That said, the contract has technically widened to include ExceptionGroup for the rare paths where a request raises post-auth (e.g. mid-request aiohttp protocol error during one of the parallel fetches). Downstream consumers (HA Core in particular) running on Python 3.11+ can handle that with except* if needed. I'm leaving the code as-is rather than adding a flattening shim — the realistic surface is narrow enough that the extra code isn't justified, and HA Core is moving to 3.14+ where this is idiomatic.

Happy to add a flattening wrapper if reviewers prefer the stricter contract preservation.

@shbatm shbatm merged commit 2ae313b into v3.x.x May 8, 2026
4 checks passed
@shbatm shbatm deleted the chore/asyncio-taskgroup branch May 8, 2026 03:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant