Skip to content

feat(providers): add OpenElectricity wholesale-price client (Phase 7 PR-2)#86

Merged
Artic0din merged 1 commit into
devfrom
feat/openelectricity-client
May 22, 2026
Merged

feat(providers): add OpenElectricity wholesale-price client (Phase 7 PR-2)#86
Artic0din merged 1 commit into
devfrom
feat/openelectricity-client

Conversation

@Artic0din
Copy link
Copy Markdown
Owner

Summary

  • New standalone async client at custom_components/pricehawk/providers/openelectricity.py for OpenElectricity v4 wholesale-price queries.
  • Pins openelectricity>=0.10.1,<0.11 in manifest (minor-bounded per the SDK's "active development" notice).
  • Foundation only — not yet wired into the coordinator or config flow. That's PR-2 part 2 (Plan 07-02b).

Phase 7 / PR-2 of the v2 Silver Compliance + OpenElectricity roadmap. Consumers in 07-02b + Phase 8 reauth/diagnostics handlers will pull OpenElectricityPriceSource off entry.runtime_data (introduced in PR-1).

Scope

File Change Lines
custom_components/pricehawk/providers/openelectricity.py new 275
custom_components/pricehawk/manifest.json added requirement +1
tests/test_openelectricity_provider.py new — 12 contract tests 399
tests/conftest.py added ConfigEntryAuthFailed stub +4
CHANGELOG.md [Unreleased] > Added entry +5
DECISIONS.md D-P7-5 through D-P7-8 +26

Audit-required invariants (07-02-AUDIT.md)

# Invariant Implementation
M1 API key never appears in __repr__ or logs __repr__ redaction + _scrub() filter + test_log_scrubs_api_key_from_sdk_error via caplog
M2 Every external SDK call bounded asyncio.timeout(_FETCH_TIMEOUT_SECONDS=30.0)
M3 Missing SDK → ConfigEntryNotReady Lazy try/except ImportError inside the async method
M5 PyPI version embedded + re-verified 0.10.1 (re-checked at APPLY time before staging)
S1 Belt-and-braces auth-error detection Message-string match + exception-class-name probe
S2 429 rate-limit branch Distinct WARNING + cache preservation + no spurious AuthFailed
S3 CC BY-NC 4.0 attribution surface contract Plan boundaries note + verbatim test
S5 providers/__init__.py exclusion 0 references (verified)

Implementation note — plan pseudo-code diverged from real SDK

Plan template assumed a flat response.data shape. Real openelectricity==0.10.1 TimeSeriesResponse is nested 3 levels deep (dataresultsdata). Caught via Context7 cross-verification + venv introspection BEFORE implementation. _extract_latest_for_region walks the nested structure and returns tz-aware UTC. We skip response.to_records() because it emits naive-local timestamps.

Out of scope (deferred)

  • Coordinator wiring + config-flow region selector (PR-2b / Plan 07-02b)
  • NEMWeb DISPATCH no-API-key fallback (PR-3 / Plan 07-03)
  • API-key-gated comparator live-pricing (PR-4 / Plan 07-04)
  • Silver-compliance handlers — reauth / reconfigure / diagnostics / repairs (Phase 8)
  • coordinator.py pre-existing mypy errors (boundary-protected — Phase 8/11 cleanup)
  • Live network smoke test (requires real OE API key — waitlisted per SDK README)

Test plan

  • ruff check custom_components/pricehawk/providers/openelectricity.py tests/test_openelectricity_provider.py → all checks passed
  • pytest --tb=short -q827 passed (was 815; +12 new tests)
  • mypy custom_components/pricehawk/providers/openelectricity.py --ignore-missing-imports → clean (pre-existing coordinator.py baseline preserved)
  • M1 grep — zero direct _LOGGER references to self._api_key
  • M2 grep — _FETCH_TIMEOUT_SECONDS and asyncio.timeout used at the SDK call site
  • M3 grep — ConfigEntryNotReady imported and raised on missing SDK
  • S5 grep — 0 openelectricity / OpenElectricity* references in providers/__init__.py
  • Live network smoke (deferred to 07-02b when coordinator wires the client)

🤖 Generated with Claude Code

…PR-2)

Standalone async client for OpenElectricity v4 — not yet wired into the
coordinator or config flow (that's PR-2 part 2 / Plan 07-02b). Foundation for
Phase 8 Silver-compliance handlers and Phase 9 external-statistics integration.

* ``custom_components/pricehawk/providers/openelectricity.py`` — new module
  exposing ``OpenElectricityPriceSource`` async client + ``WholesalePrice``
  frozen dataclass. CC BY-NC 4.0 attribution baked into every result per
  research §1.3.

* ``manifest.json:requirements`` pinned to
  ``openelectricity>=0.10.1,<0.11`` — minor-bounded per the SDK README's
  "currently under active development" notice.

Audit invariants applied (07-02-AUDIT.md):

* M1 — API key never appears in ``__repr__`` output or log messages.
  ``_scrub()`` filters the full key plus its 8-char prefix from any text
  passed to ``_LOGGER.warning``. ``test_log_scrubs_api_key_from_sdk_error``
  uses ``caplog`` to verify against a leaky SDK exception.

* M2 — ``asyncio.timeout(_FETCH_TIMEOUT_SECONDS=30.0)`` bounds every SDK
  call. Hung connection → ``None`` + WARNING, never an HA-wide stall.

* M3 — Missing ``openelectricity`` install raises ``ConfigEntryNotReady``
  (HA retries setup), not ``ImportError`` (permanent crash). Lazy import
  inside the async method.

* S1 — Auth-error detection is belt-and-braces: message-string match plus
  exception-class-name probe. Defensive against the SDK's active-development
  error-message format drift.

* S2 — HTTP 429 rate-limit has a distinct branch: ``None`` + WARNING saying
  "rate-limited", last-good cache preserved, no spurious
  ``ConfigEntryAuthFailed``.

* S5 — Module is intentionally NOT exported from ``providers/__init__.py``.
  ``OpenElectricityPriceSource`` is a wholesale-price source, not a
  ``Provider`` Protocol implementation. Consumers import directly.

Implementation diverged from plan pseudo-code: real
``openelectricity==0.10.1`` ``TimeSeriesResponse`` is nested three levels
deep (``data`` → ``results`` → ``data``), not flat. ``_extract_latest_for_region``
walks the nested structure and returns tz-aware UTC. We skip
``response.to_records()`` because it emits naive-local timestamps.

12 contract tests (``tests/test_openelectricity_provider.py``) cover AC-1
through AC-7e: happy-path NEM, happy-path WEM, attribution-verbatim, 401
mapping, generic-error cache preservation, empty-data, invalid region,
empty API key, timeout, missing SDK, 429 rate limit, repr redaction, log
scrub. Test suite: 815 → 827 passed.

``tests/conftest.py`` gained one stub: ``ConfigEntryAuthFailed`` (was only
``ConfigEntryNotReady`` before). Necessary because the new module is the
first user of the auth-failed exception in this project.

Decisions D-P7-5 through D-P7-8 logged to DECISIONS.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Artic0din Artic0din marked this pull request as draft May 22, 2026 00:57
@Artic0din Artic0din marked this pull request as ready for review May 22, 2026 00:57
@Artic0din Artic0din merged commit ee0aaba into dev May 22, 2026
2 checks passed
@Artic0din Artic0din deleted the feat/openelectricity-client branch May 22, 2026 05:54
Artic0din added a commit that referenced this pull request May 23, 2026
…CI (#143)

Four findings from a 2026-05-23 @claude retro-review of merged PRs.

- providers/openelectricity.py: ``_is_auth_error`` no longer matches the
  bare word ``forbidden`` in exception messages — TLS/corporate-proxy
  errors with that word were mis-classified as ``ConfigEntryAuthFailed``,
  prompting users to re-enter their API key when the real problem is
  connectivity. Auth detection now keys on auth-specific message tokens
  (401, unauthorized, invalid api key); the class-name check still
  catches genuine ``*Forbidden*`` exception types. New regression test
  test_tls_error_with_forbidden_word_is_not_auth_failure. (Retro #86)

- tests/test_tariff_engine_hypothesis.py: three property tests used
  ``if k <= threshold: return`` to early-exit out-of-range draws,
  which Hypothesis treats as PASSED rather than discarded. With
  max_examples=200 and ~75% of draws hitting the early return, the
  effective constrained-region coverage was ~50 examples per invariant
  instead of 200. Switched to ``assume(k > threshold)`` / ``assume(k <
  threshold)`` / ``assume(k >= threshold)`` so Hypothesis discards and
  regenerates until 200 valid examples are spent in the interesting
  region. Tolerance also aligned to 1e-9 across invariants 2 + 3
  (single bounded-float multiplications have round-trip error <
  ~4e-12, so the tight value is safe). (Retro #102)

- .github/workflows/dual-loop-review.yml: dropped ``develop`` from the
  pull_request branches filter — ``gh api repos/.../branches`` confirms
  no ``develop`` branch exists; it was a stale alias from the template
  that silently never matched. Filter now ``[main, dev]``. (Retro #103)

PR #84 review noted no issues (after_dependencies fix verified correct);
no change here for that PR.

Full test suite: 1119 passing locally.
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