Skip to content

Add get_electric_bill_history and get_gas_bill_history via business portal silent SSO#6

Merged
virtitnerd merged 6 commits into
mainfrom
feature/bill-history
May 21, 2026
Merged

Add get_electric_bill_history and get_gas_bill_history via business portal silent SSO#6
virtitnerd merged 6 commits into
mainfrom
feature/bill-history

Conversation

@virtitnerd
Copy link
Copy Markdown
Owner

@virtitnerd virtitnerd commented May 21, 2026

Summary

  • Adds get_electric_bill_history(account_number, customer_number) and get_gas_bill_history(account_number, customer_number) typed methods returning list[ElectricBillRecord] / list[GasBillRecord]
  • These pull from the business portal API (accountservice-cu-mba-exp) which has fields not available in the standard GraphQL bills endpoint: utility/supplier charge breakdown, total kWh/therms, avg daily usage, and demand data (TOU, peak kW) for electric
  • Also adds get_premise() for address-based premise/meter lookup (public endpoint, no auth required)

Authentication approach

The business portal validates that the Bearer id_token has aud=a26ad492 (a different client registration from the consumer portal). HAR analysis confirmed the browser silently re-authenticates after consumer portal login via Azure AD B2C SSO cookies.

Implementation uses prompt=none silent SSO:

  1. Consumer portal OIDC login runs first, storing B2C session cookies on the shared aiohttp session
  2. Business portal authorize request with prompt=none gets an immediate 302 redirect — no login UI
  3. Auth code is captured from the Location header (before following the redirect) and exchanged for a token with the correct aud

This required:

  • _get_auth_silent() helper in oidchelper.py (non-redirecting GET, reads Location header)
  • extra_auth_params threaded through async_auth_oidc / _get_auth
  • NationalGridBusinessAuth with the correct tenant path (loginnationalgridus.onmicrosoft.com)
  • _request_business_rest in client bypassing build_headers to avoid subscription key collisions

Cleanup

  • Removed dead _get_id_token() method and _id_token field (set but never read after business portal switched to its own token)
  • Removed LoginData.refresh_token (was populated during an earlier failed cross-client refresh approach; never read)

Test plan

  • uv run pytest — 202 tests pass, 96% coverage
  • uv run python examples/bill-history.py --username ... --password ... — returns electric and gas bill records
  • uv run python examples/premise-lookup.py --street-name ... --city ... --state NY --zip ... — returns premise/meter info

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added premise lookup plus electric and gas bill history APIs; new CLI examples for bill-history and premise-lookup.
  • Documentation

    • README and docs updated with API table entries, usage examples, and runnable example commands.
  • Chores

    • .gitignore updated to ignore build, local, and coverage artifacts; author metadata simplified.
  • Tests

    • Expanded coverage for premise/bill extraction, business-auth flows, REST/GraphQL behaviors, and related client logic.

Review Change Stack

…ortal

Adds two new typed client methods that fetch per-billing-period detail from the
National Grid business portal API (accountservice-cu-mba-exp), which returns
fields not available through the standard GraphQL bills endpoint: utility/supplier
charge breakdown, total kWh/therms, average daily usage, and demand data (TOU,
peak kW) for electric.

Authentication uses a prompt=none silent SSO flow: after the consumer portal OIDC
login populates B2C session cookies on the shared aiohttp session, the business
portal authorize request redirects immediately without any login UI. The auth code
is captured from the Location header (before following the redirect) and exchanged
for a token with the correct audience (aud=a26ad492).

Also adds:
- get_premise() for address-based premise/meter lookup (public, no auth)
- NationalGridBusinessAuth with correct tenant path (loginnationalgridus.onmicrosoft.com)
- _get_auth_silent() in oidchelper for prompt=none redirect capture
- extra_auth_params support threaded through async_auth_oidc/_get_auth
- ElectricBillRecord and GasBillRecord TypedDicts in models.py
- Extractors for both bill history response envelopes
- examples/bill-history.py and examples/premise-lookup.py
- Cleanup: removed dead _get_id_token method, _id_token field, LoginData.refresh_token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented May 21, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Add unit tests covering all previously uncovered branches introduced in the
bill history PR:

- _request_business_rest: success path, 4xx/5xx raises RestAPIError, and the
  inner response.text() failure case (sets response_text=None)
- _get_business_id_token: cache hit, no-credentials early return, exception
  from async_login, None access token, successful caching, missing id_token,
  and the double-checked locking inner cache hit (concurrent refresh simulation)
- NationalGridBusinessAuth.async_login: verifies it delegates to async_auth_oidc
  with extra_auth_params={"prompt": "none"}
- extractors: electricBillHistory/gasBillHistory inner-value-not-list branches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds GraphQL premise lookup and business-portal electric/gas billing history APIs; extends OIDC to return id_token and support silent SSO; adds TypedDict models, query/request builders, extractors, client helpers (business REST, headers, token cache), example scripts, docs updates, and comprehensive tests.

Changes

Premise Lookup and Bill History Features

Layer / File(s) Summary
Config and housekeeping
.gitignore, pyproject.toml
Ignore docs/build/, local/, coverage.xml; remove author email in pyproject.toml.
Documentation and examples
README.md, CLAUDE.md, examples/*
Document get_premise(), get_electric_bill_history(), get_gas_bill_history(); add “Premise Lookup” docs and runnable example commands; add examples/premise-lookup.py and examples/bill-history.py.
Typed models & exports
src/py_nationalgrid/models.py, src/py_nationalgrid/__init__.py
Add PremiseMeter, PremiseMeterConnection, PremiseNode, ElectricBillRecord, GasBillRecord; export via package __all__.
OIDC silent SSO & token shape
src/py_nationalgrid/oidchelper.py
async_auth_oidc(..., extra_auth_params) returns (access_token, id_token, expires_in) (or (None, None, None)); supports prompt=none via _get_auth_silent; treats TimeoutError as CannotConnectError.
Auth classes and login contract
src/py_nationalgrid/auth.py
NationalGridAuth.async_login now returns a 3-tuple; added NationalGridBusinessAuth.async_login that delegates to async_auth_oidc with extra_auth_params={"prompt":"none"}.
Query & REST request builders
src/py_nationalgrid/queries.py, src/py_nationalgrid/rest_queries.py
Add PREMISE_ENDPOINT, PREMISE_SELECTION_SET, premise_request(); add business REST BUSINESS_BASE_URL, BUSINESS_SUBSCRIPTION_KEY, and electric_bill_history_request() / gas_bill_history_request(); normalize startDateTime casing.
Response extractors
src/py_nationalgrid/extractors.py
Add extract_premise(), extract_electric_bill_history(), extract_gas_bill_history() with validation and 204/empty handling; update docstrings.
Client imports & infra
src/py_nationalgrid/client.py
Import business auth, new extractors/models/rest builders; add _business_id_token, _business_token_expires_at, _business_auth_lock.
Business REST helpers
src/py_nationalgrid/client.py
Implement _request_business_rest() to call business endpoints with explicit headers and raise RestAPIError on non-2xx; add _build_business_headers(id_token).
Business token management
src/py_nationalgrid/client.py
Add _get_business_id_token() to perform cached silent SSO, refresh near expiry under a dedicated lock, and return None on failures; adjust consumer token unpacking to new async_login shape.
Public client APIs
src/py_nationalgrid/client.py
Add get_premise() (GraphQL lookup), get_electric_bill_history() and get_gas_bill_history() (business REST calls using id_token and extractors).
Tests: auth, extractors & typed client
tests/*
Update test fakes/mocks to new async_login 3-tuple; add extractor tests, premise and bill-history tests, business REST and token caching/concurrency tests; adjust interval-reads tests for startDateTime.

🎯 4 (Complex) | ⏱️ ~60 minutes

🐰 Hop along the API trail,
Premises and bills now set sail,
Silent SSO blooms bright,
New queries extract insight,
All tested, all documented without fail! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically identifies the main change: adding two new bill history methods (get_electric_bill_history and get_gas_bill_history) via business portal silent SSO, which is the core feature of this changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/bill-history

Comment @coderabbitai help to get the list of available commands and usage tips.

@virtitnerd
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/py_nationalgrid/auth.py (1)

91-122: 💤 Low value

Unused username/password parameters in silent SSO flow may mislead callers.

When prompt=none, the _get_auth_silent path is taken and credentials are never used—B2C relies solely on session cookies. The method signature requiring these parameters could confuse callers into thinking credentials are validated during silent SSO.

Consider documenting this explicitly in the docstring or accepting empty strings as valid inputs for the silent case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/py_nationalgrid/auth.py` around lines 91 - 122, The async_login method
currently requires username and password although the silent SSO path uses
prompt=none and ignores credentials; update the function to avoid misleading
callers by either (A) changing the signature of async_login to accept username:
str | None and password: str | None (or provide default None) and propagate
those values into the async_auth_oidc call, or (B) keep the signature but
explicitly document in the async_login docstring that username/password are
unused for silent SSO (prompt=none) and callers may pass None or empty strings;
mention async_login, async_auth_oidc and the prompt=none extra_auth_params so
reviewers can locate the relevant code to update.
src/py_nationalgrid/client.py (1)

1618-1624: 💤 Low value

Requests proceed with empty bearer token when authentication fails.

When _get_business_id_token() returns None (e.g., missing credentials or SSO failure), the code uses id_token or "" to construct an empty bearer token. The subsequent request will fail with a 401, but the error won't clearly indicate that authentication was the root cause.

The docstrings state "Requires authentication" — consider failing early with a clear error when id_token is None to improve debuggability.

♻️ Example early-exit pattern
     async def get_electric_bill_history(...) -> list[ElectricBillRecord]:
         ...
         id_token = await self._get_business_id_token()
+        if not id_token:
+            raise RestAPIError(
+                "Business portal authentication required but id_token is unavailable",
+                url="",
+                method="POST",
+            )
         rest_request = electric_bill_history_request(...)
-        business_headers = self._build_business_headers(id_token or "")
+        business_headers = self._build_business_headers(id_token)

Apply the same pattern to get_gas_bill_history.

Also applies to: 1664-1670

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/py_nationalgrid/client.py` around lines 1618 - 1624, The code currently
continues with an empty bearer token when _get_business_id_token() returns None,
causing opaque 401s; update the call sites (electric_bill_history_request path
and likewise in get_gas_bill_history) to fail early when id_token is None by
raising a clear exception (e.g., AuthenticationError or ValueError) before
calling _build_business_headers(id_token) — locate uses of
_get_business_id_token, check the id_token result, and raise with a descriptive
message like "Missing business ID token: authentication required" so callers get
an explicit authentication error instead of proceeding with an empty token.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/py_nationalgrid/client.py`:
- Around line 678-680: The current assignment to self._business_token_expires_at
uses expires_in without guarding for None, which can raise TypeError if id_token
is truthy but expires_in is None; update the logic in the method where id_token
and expires_in are handled (the block that sets self._business_id_token and
self._business_token_expires_at) to mirror the consumer auth pattern by
requiring both id_token and expires_in truthy before computing
self._business_token_expires_at (e.g., only set self._business_token_expires_at
= time.time() + expires_in when expires_in is not None), otherwise set or leave
expires_at as None/unchanged.

In `@src/py_nationalgrid/rest_queries.py`:
- Line 15: The BUSINESS_SUBSCRIPTION_KEY constant is hard-coded; remove the
literal and instead load it from a runtime secret source (e.g., environment
variable or secret manager) where it is required, e.g., replace usages of
BUSINESS_SUBSCRIPTION_KEY with a getter that reads
os.environ.get("BUSINESS_SUBSCRIPTION_KEY") (or a secret client) and raises a
clear error if missing; update any functions that reference
BUSINESS_SUBSCRIPTION_KEY in this module (rest_queries.py) to use that getter
and ensure tests/configs set the env var for CI and local runs.

---

Nitpick comments:
In `@src/py_nationalgrid/auth.py`:
- Around line 91-122: The async_login method currently requires username and
password although the silent SSO path uses prompt=none and ignores credentials;
update the function to avoid misleading callers by either (A) changing the
signature of async_login to accept username: str | None and password: str | None
(or provide default None) and propagate those values into the async_auth_oidc
call, or (B) keep the signature but explicitly document in the async_login
docstring that username/password are unused for silent SSO (prompt=none) and
callers may pass None or empty strings; mention async_login, async_auth_oidc and
the prompt=none extra_auth_params so reviewers can locate the relevant code to
update.

In `@src/py_nationalgrid/client.py`:
- Around line 1618-1624: The code currently continues with an empty bearer token
when _get_business_id_token() returns None, causing opaque 401s; update the call
sites (electric_bill_history_request path and likewise in get_gas_bill_history)
to fail early when id_token is None by raising a clear exception (e.g.,
AuthenticationError or ValueError) before calling
_build_business_headers(id_token) — locate uses of _get_business_id_token, check
the id_token result, and raise with a descriptive message like "Missing business
ID token: authentication required" so callers get an explicit authentication
error instead of proceeding with an empty token.
🪄 Autofix (Beta)

❌ Autofix failed (check again to retry)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 06c1685b-b59b-4c6c-8def-152b1d21702c

📥 Commits

Reviewing files that changed from the base of the PR and between 86b34d4 and 3c51538.

📒 Files selected for processing (19)
  • .gitignore
  • CLAUDE.md
  • README.md
  • examples/bill-history.py
  • examples/premise-lookup.py
  • pyproject.toml
  • src/py_nationalgrid/__init__.py
  • src/py_nationalgrid/auth.py
  • src/py_nationalgrid/client.py
  • src/py_nationalgrid/extractors.py
  • src/py_nationalgrid/models.py
  • src/py_nationalgrid/oidchelper.py
  • src/py_nationalgrid/queries.py
  • src/py_nationalgrid/rest_queries.py
  • tests/test_client.py
  • tests/test_coverage.py
  • tests/test_interval_reads.py
  • tests/test_retry.py
  • tests/test_typed_client.py

Comment on lines +678 to +680
if id_token:
self._business_id_token = id_token
self._business_token_expires_at = time.time() + expires_in
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Potential TypeError if expires_in is None while id_token is truthy.

The condition on line 678 only checks if id_token:, but line 680 performs arithmetic with expires_in. If the auth returns a valid id_token but None for expires_in, this will raise TypeError: unsupported operand type(s) for +: 'float' and 'NoneType'.

The consumer auth pattern (lines 625–631) guards against this by checking if token and expires_in:.

🐛 Proposed fix
-            if id_token:
+            if id_token and expires_in:
                 self._business_id_token = id_token
                 self._business_token_expires_at = time.time() + expires_in
                 logger.debug("Business id_token obtained, expires in %d seconds", expires_in)
             else:
-                logger.error("Business portal silent SSO produced no id_token")
+                logger.error("Business portal silent SSO produced no id_token or expires_in")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/py_nationalgrid/client.py` around lines 678 - 680, The current assignment
to self._business_token_expires_at uses expires_in without guarding for None,
which can raise TypeError if id_token is truthy but expires_in is None; update
the logic in the method where id_token and expires_in are handled (the block
that sets self._business_id_token and self._business_token_expires_at) to mirror
the consumer auth pattern by requiring both id_token and expires_in truthy
before computing self._business_token_expires_at (e.g., only set
self._business_token_expires_at = time.time() + expires_in when expires_in is
not None), otherwise set or leave expires_at as None/unchanged.


# Business portal (accountservice-cu-mba-exp) — uses idToken auth
BUSINESS_BASE_URL = "https://gridapi-cm-prod-appgw.dpit.nationalgrid.com/api"
BUSINESS_SUBSCRIPTION_KEY = "f1098e143d4c4d5b81eb0a86667d0ddf"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Move the business subscription key out of source.

Hard-coding this key in code exposes credentials and makes rotation brittle. Pass it via runtime config/secret management instead of a committed literal.

🧰 Tools
🪛 Betterleaks (1.2.0)

[high] 15-15: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/py_nationalgrid/rest_queries.py` at line 15, The
BUSINESS_SUBSCRIPTION_KEY constant is hard-coded; remove the literal and instead
load it from a runtime secret source (e.g., environment variable or secret
manager) where it is required, e.g., replace usages of BUSINESS_SUBSCRIPTION_KEY
with a getter that reads os.environ.get("BUSINESS_SUBSCRIPTION_KEY") (or a
secret client) and raises a clear error if missing; update any functions that
reference BUSINESS_SUBSCRIPTION_KEY in this module (rest_queries.py) to use that
getter and ensure tests/configs set the env var for CI and local runs.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

⚠️ Branch updated during autofix.

The branch was updated while autofix was in progress. Please try again.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Note

Docstrings generation - SUCCESS
Generated docstrings and committed to branch feature/bill-history (commit: a6b73b197d0c1ebe3a043def8dc0423bcce16e32)

Docstrings generation was requested by @virtitnerd.

The following files were modified:

* `examples/bill-history.py`
* `examples/premise-lookup.py`
* `src/py_nationalgrid/auth.py`
* `src/py_nationalgrid/client.py`
* `src/py_nationalgrid/extractors.py`
* `src/py_nationalgrid/oidchelper.py`
* `src/py_nationalgrid/queries.py`
* `src/py_nationalgrid/rest_queries.py`
* `tests/test_client.py`
* `tests/test_coverage.py`
* `tests/test_retry.py`
* `tests/test_typed_client.py`

These files were kept as they were:
* `tests/test_interval_reads.py`

These file types are not supported:
* `.gitignore`
* `CLAUDE.md`
* `README.md`
* `pyproject.toml`
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/bill-history.py`:
- Around line 48-52: The docstring for the main() function is exceeding the max
line length (Ruff E501); rewrap the docstring text to shorter lines (e.g., 79
characters) so each line is within the lint limit, preserving the same content
and punctuation. Locate the multi-line triple-quoted string inside main() and
split long sentences into multiple lines or paragraphs while keeping
descriptions of behavior (CLI steps, printed summaries, per-fuel history
details, and no-linked-accounts message) intact.

In `@examples/premise-lookup.py`:
- Around line 19-32: The docstrings use tab characters and have overlong lines
causing E101/E501; update the docstring(s) for the argument-parsing function
(parse_args) and the subsequent docstring block to use spaces for indentation
and wrap lines to a safe length (e.g., 79 or 88 chars), reformat the
Parameters/Returns bullets so each line is wrapped and indented consistently (4
spaces) and ensure no tab characters remain; keep content the same but split
long sentences into shorter lines and align the hyphenated list items under the
same indentation level.

In `@src/py_nationalgrid/auth.py`:
- Around line 44-54: The docstring for the authentication function (the auth
method in src/py_nationalgrid/auth.py) contains lines that exceed the configured
max line length and trigger Ruff E501; shorten or wrap the long sentences (for
example the summary, the "Performs the OIDC..." sentence, the Parameters and
Returns descriptions) so each line fits the project's line-length limit (or
rephrase to be more concise), and apply the same fix to the second docstring
block around lines 106-116; keep the content the same but break sentences or
shorten phrases to meet the linter limit.

In `@src/py_nationalgrid/client.py`:
- Around line 140-149: The docstring blocks for the NationalGridClient
initializer are exceeding the project's 100-character line limit and causing
Ruff failures; reflow each docstring/comment to wrap at or below 100 characters
per line (preserve existing content and sentence breaks) in the
NationalGridClient.__init__ docstring and the other noted docstring blocks
(lines 267-281, 419-434, 481-498, 623-630, 665-672, 718-733, 748-755, 779-791,
1708-1725), ensuring parameter and description sections remain clear and aligned
with the project's docstring style.

In `@src/py_nationalgrid/extractors.py`:
- Around line 511-519: The docstrings in src/py_nationalgrid/extractors.py
(including the one starting "Extracts collection arrangement records..." and the
other docstrings at the ranges mentioned) exceed the project's line-length limit
and fail Ruff; reflow/wrap each docstring to respect the repository's max line
length (e.g., 88 chars) so each line is within the limit, preserving all text
and indentation and keeping the same triple-quoted docstring and paragraphs,
especially for the descriptions and the "Returns:" / "Raises:" sections in the
functions/methods such as the collection arrangements extractor and the other
extractors referenced at 545-555, 581-589, and 645-656. Ensure no code or markup
is changed, only the docstring line breaks.

In `@src/py_nationalgrid/oidchelper.py`:
- Around line 230-245: Reflow the long docstring blocks in
src/py_nationalgrid/oidchelper.py (the docstring that begins "Retrieve the
OpenID Connect discovery document..." and the other long docstrings around the
Parameters/Returns sections) so no line exceeds 100 characters; wrap
descriptions for Parameters, Returns, and Raises to 100-char width or less while
preserving the same text and formatting (indentation and parameter names).
Locate the docstring attached to the OpenID discovery retrieval function in
oidchelper.py and the other long docstrings referenced (the blocks around the
Parameters/Returns sections) and break long lines into shorter sentences/lines
to satisfy Ruff's line-length rule.

In `@src/py_nationalgrid/queries.py`:
- Around line 617-627: The docstrings around the "Builds a collection
arrangements GraphQL request" function contain mixed tabs/spaces and lines
exceeding 100 chars causing Ruff E101/E501; normalize indentation to spaces only
and reflow long lines to <=100 characters (prefer ~72 for summary and ~100 for
body), applying the same fixes to the other docstrings referenced at the regions
corresponding to 658-670 and 683-691; ensure triple-quoted strings keep
consistent indentation, wrap parameter/return descriptions to shorter lines, and
remove any embedded tabs so Ruff no longer flags E101/E501.

In `@src/py_nationalgrid/rest_queries.py`:
- Around line 35-45: Docstrings for the REST request builders (the GET
RestRequest that mentions startDateTime / start_datetime and RestRequest)
contain mixed tabs/spaces and overlong lines triggering Ruff E101/E501; fix by
normalizing indentation to spaces only, rewrapping long lines to a safe length
(e.g., <=88 chars), and ensuring consistent triple-quoted docstring formatting
for all similar builders (the blocks flagged at 35-45, 72-83, 100-110, 124-134,
143-151) so they use only spaces and no lines exceed the configured max line
length.

In `@tests/test_client.py`:
- Around line 110-119: The docstring for the OIDC test helper (the multi-line
docstring beginning "Test helper that simulates an OIDC login...") exceeds the
configured line-length (Ruff E501); reflow/wrap the text so each line is under
the project's limit (break at sentence or clause boundaries and keep
descriptions concise), and apply the same wrapping to the other expanded
test-helper docstrings in this file that currently exceed line length. Ensure
you only change docstring formatting (no logic) and preserve the same content
and parameter/return wording.

In `@tests/test_coverage.py`:
- Around line 421-426: The docstring return description exceeds the
100-character line limit; reflow the Returns section in this helper so each line
is under 100 chars (wrap the tuple description and the field explanations),
e.g., break the sentence describing the 3-tuple into multiple shorter lines so
the access_token/id_token/expires_in_seconds parts each fit; apply the same
wrapping style to the other helper docstrings referenced at the other
occurrences (the blocks around lines 469-474 and 630-635) so Ruff E501 is
resolved.

In `@tests/test_retry.py`:
- Around line 71-83: Shorten long docstring lines in tests/test_retry.py
(notably the docstring that starts "Return a fixed authentication token triple
used by tests." which documents the returned tuple `(access_token, id_token,
expires_in)` with values "token", "id-tok", 3600) and the other long docstrings
flagged by the reviewer by wrapping sentences so no line exceeds the linter
limit (e.g., 88 chars), preserving the exact content and wording but splitting
long sentences and parameter/return descriptions across multiple shorter lines;
apply the same wrapping/fix to the other test helper/docstrings mentioned in the
review so Ruff E501 errors are resolved.

In `@tests/test_typed_client.py`:
- Around line 2176-2181: Reflow the long test docstrings in
tests/test_typed_client.py so no line exceeds 100 characters: find the docstring
that begins "Verify that get_premise parses and returns premise nodes with
nested meter data." and the other long docstring referenced (the one around the
later test), and wrap the text into multiple lines under the project's 100-char
limit while preserving the same wording and triple-quote delimiters for the test
functions that assert get_premise returns premise fields and nested meter nodes
(e.g., premiseNumber, premiseStatus, compressedAddress, fuelType, meterNumber).
🪄 Autofix (Beta)

✅ Autofix completed


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7ec52024-70f4-4963-801e-9cfac20a7446

📥 Commits

Reviewing files that changed from the base of the PR and between 3c51538 and a6b73b1.

📒 Files selected for processing (12)
  • examples/bill-history.py
  • examples/premise-lookup.py
  • src/py_nationalgrid/auth.py
  • src/py_nationalgrid/client.py
  • src/py_nationalgrid/extractors.py
  • src/py_nationalgrid/oidchelper.py
  • src/py_nationalgrid/queries.py
  • src/py_nationalgrid/rest_queries.py
  • tests/test_client.py
  • tests/test_coverage.py
  • tests/test_retry.py
  • tests/test_typed_client.py

Comment thread examples/bill-history.py
Comment thread examples/premise-lookup.py
Comment thread src/py_nationalgrid/auth.py
Comment thread src/py_nationalgrid/client.py
Comment thread src/py_nationalgrid/extractors.py
Comment thread src/py_nationalgrid/rest_queries.py
Comment thread tests/test_client.py
Comment thread tests/test_coverage.py
Comment thread tests/test_retry.py
Comment thread tests/test_typed_client.py
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 12 file(s) based on 12 unresolved review comments.

Files modified:

  • examples/bill-history.py
  • examples/premise-lookup.py
  • src/py_nationalgrid/auth.py
  • src/py_nationalgrid/client.py
  • src/py_nationalgrid/extractors.py
  • src/py_nationalgrid/oidchelper.py
  • src/py_nationalgrid/queries.py
  • src/py_nationalgrid/rest_queries.py
  • tests/test_client.py
  • tests/test_coverage.py
  • tests/test_retry.py
  • tests/test_typed_client.py

Commit: bc1c2fe4bd2d9123e63548d8bde89b5738bd9c8d

The changes have been pushed to the feature/bill-history branch.

Time taken: 9m 52s

Fixed 12 file(s) based on 12 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/py_nationalgrid/oidchelper.py (1)

290-301: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle request timeouts in silent-auth errors explicitly.

File: src/py_nationalgrid/oidchelper.py (lines 290-301)

_get_auth_silent() catches only aiohttp.ClientError, but aiohttp.ClientTimeout(total=...) timeouts raise asyncio.TimeoutError (aiohttp timeout exceptions inherit from asyncio.TimeoutError), so timeouts can escape instead of being normalized to CannotConnectError.

  • _fetch() uses the same aiohttp.ClientTimeout(total=timeout) pattern but also catches only aiohttp.ClientError, so apply the same timeout normalization there too.
Suggested fix
@@
-    except aiohttp.ClientError as err:
+    except (aiohttp.ClientError, asyncio.TimeoutError) as err:
         raise CannotConnectError(f"Silent auth network error: {err}") from err
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/py_nationalgrid/oidchelper.py` around lines 290 - 301, Both
_get_auth_silent() and _fetch() currently only catch aiohttp.ClientError around
their aiohttp requests using aiohttp.ClientTimeout, but asyncio.TimeoutError
(raised by aiohttp timeouts) can escape; update the exception handling in the
async with session.get(...) / session.request(...) blocks to also catch
asyncio.TimeoutError (or Exception as needed) and normalize it to raise
CannotConnectError with the timeout error included (same pattern as catching
aiohttp.ClientError), ensuring the session.get/session.request calls that use
aiohttp.ClientTimeout(total=timeout) are wrapped by a try/except that raises
CannotConnectError on both aiohttp.ClientError and asyncio.TimeoutError so
timeouts are handled consistently.
♻️ Duplicate comments (1)
src/py_nationalgrid/client.py (1)

753-760: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard expires_in before computing business token expiry.

At Line 756, time.time() + expires_in can raise TypeError when silent SSO returns an id_token with a missing expiry value.

Proposed fix
-            _, id_token, expires_in = result
-            if id_token:
+            _, id_token, expires_in = result
+            if id_token and expires_in:
                 self._business_id_token = id_token
                 self._business_token_expires_at = time.time() + expires_in
                 logger.debug("Business id_token obtained, expires in %d seconds", expires_in)
             else:
-                logger.error("Business portal silent SSO produced no id_token")
+                logger.error("Business portal silent SSO produced no id_token or expires_in")
+                self._business_id_token = None
+                self._business_token_expires_at = None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/py_nationalgrid/client.py` around lines 753 - 760, The code assigns
self._business_token_expires_at = time.time() + expires_in without validating
expires_in; update the block that handles result (the variables id_token and
expires_in) to guard expires_in before computing expiry: when id_token is
present, verify expires_in is a valid number (int/float and not None) and only
then set self._business_token_expires_at = time.time() + expires_in, otherwise
log a warning/error and either set a sensible default TTL or leave
_business_token_expires_at unset/None; keep the existing assignment to
self._business_id_token and the logger.debug/ logger.error paths but add the
expires_in validation to avoid TypeError.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/py_nationalgrid/oidchelper.py`:
- Around line 290-301: Both _get_auth_silent() and _fetch() currently only catch
aiohttp.ClientError around their aiohttp requests using aiohttp.ClientTimeout,
but asyncio.TimeoutError (raised by aiohttp timeouts) can escape; update the
exception handling in the async with session.get(...) / session.request(...)
blocks to also catch asyncio.TimeoutError (or Exception as needed) and normalize
it to raise CannotConnectError with the timeout error included (same pattern as
catching aiohttp.ClientError), ensuring the session.get/session.request calls
that use aiohttp.ClientTimeout(total=timeout) are wrapped by a try/except that
raises CannotConnectError on both aiohttp.ClientError and asyncio.TimeoutError
so timeouts are handled consistently.

---

Duplicate comments:
In `@src/py_nationalgrid/client.py`:
- Around line 753-760: The code assigns self._business_token_expires_at =
time.time() + expires_in without validating expires_in; update the block that
handles result (the variables id_token and expires_in) to guard expires_in
before computing expiry: when id_token is present, verify expires_in is a valid
number (int/float and not None) and only then set
self._business_token_expires_at = time.time() + expires_in, otherwise log a
warning/error and either set a sensible default TTL or leave
_business_token_expires_at unset/None; keep the existing assignment to
self._business_id_token and the logger.debug/ logger.error paths but add the
expires_in validation to avoid TypeError.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0abb5b1f-d975-45c8-ac0d-d9a032937652

📥 Commits

Reviewing files that changed from the base of the PR and between a6b73b1 and bc1c2fe.

📒 Files selected for processing (12)
  • examples/bill-history.py
  • examples/premise-lookup.py
  • src/py_nationalgrid/auth.py
  • src/py_nationalgrid/client.py
  • src/py_nationalgrid/extractors.py
  • src/py_nationalgrid/oidchelper.py
  • src/py_nationalgrid/queries.py
  • src/py_nationalgrid/rest_queries.py
  • tests/test_client.py
  • tests/test_coverage.py
  • tests/test_retry.py
  • tests/test_typed_client.py

virtitnerd and others added 2 commits May 21, 2026 18:50
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
asyncio.TimeoutError (aliased as builtin TimeoutError in Python 3.11+)
is not a subclass of aiohttp.ClientError, so aiohttp.ClientTimeout
expiry could escape uncaught. Both _get_auth_silent and _fetch now
catch (aiohttp.ClientError, TimeoutError) and normalize to
CannotConnectError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/py_nationalgrid/oidchelper.py (2)

369-370: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Block overrides of core OAuth parameters.

auth_params.update(extra_auth_params) lets callers replace client_id, redirect_uri, response_type, scope, or the PKCE fields, while _get_access() still exchanges the code with the original arguments. One accidental override here is enough to make the authorize/token pair inconsistent.

🛡️ Proposed fix
     if extra_auth_params:
-        auth_params.update(extra_auth_params)
+        reserved = {
+            "client_id",
+            "response_type",
+            "redirect_uri",
+            "scope",
+            "code_challenge",
+            "code_challenge_method",
+        }
+        conflicting = reserved & extra_auth_params.keys()
+        if conflicting:
+            raise ValueError(
+                f"extra_auth_params cannot override reserved auth params: {sorted(conflicting)}"
+            )
+        auth_params.update(extra_auth_params)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/py_nationalgrid/oidchelper.py` around lines 369 - 370, The current
auth_params.update(extra_auth_params) in the auth flow allows callers to
override core OAuth fields and break the authorize/token consistency; instead,
in the function where auth_params and extra_auth_params are merged (the block
that currently calls auth_params.update(extra_auth_params) and the related
_get_access exchange), filter extra_auth_params to remove any core OAuth keys
before merging — e.g., disallow keys like client_id, redirect_uri,
response_type, scope, code, code_verifier, grant_type and any PKCE-related
fields — then update auth_params only with the remaining safe extras so the
original parameters used by _get_access remain intact.

160-161: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Declare id_token in TokenDict to match async_auth_oidc()

async_auth_oidc() reads tokens.get("id_token", "") and returns (access_token, id_token, expires_in), but TokenDict omits id_token (src/py_nationalgrid/oidchelper.py lines ~160-161 and 190-195), leaving the TypedDict typing inconsistent.

♻️ Proposed fix
 class TokenDict(TypedDict, total=False):
     """Dictionary to store OAuth tokens."""
 
     access_token: str
+    id_token: str
     expires_in: int  # Token lifetime in seconds
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/py_nationalgrid/oidchelper.py` around lines 160 - 161, TokenDict omits
the id_token key while async_auth_oidc() and code that does
tokens.get("id_token", "") expect it; update the TokenDict TypedDict to include
an optional/NotRequired "id_token" of type str (or Optional[str]) so the typing
matches usage, and ensure any places that construct the tokens dict (the code
that assigns access_token/id_token) populate id_token consistently with that
type.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/py_nationalgrid/oidchelper.py`:
- Around line 369-370: The current auth_params.update(extra_auth_params) in the
auth flow allows callers to override core OAuth fields and break the
authorize/token consistency; instead, in the function where auth_params and
extra_auth_params are merged (the block that currently calls
auth_params.update(extra_auth_params) and the related _get_access exchange),
filter extra_auth_params to remove any core OAuth keys before merging — e.g.,
disallow keys like client_id, redirect_uri, response_type, scope, code,
code_verifier, grant_type and any PKCE-related fields — then update auth_params
only with the remaining safe extras so the original parameters used by
_get_access remain intact.
- Around line 160-161: TokenDict omits the id_token key while async_auth_oidc()
and code that does tokens.get("id_token", "") expect it; update the TokenDict
TypedDict to include an optional/NotRequired "id_token" of type str (or
Optional[str]) so the typing matches usage, and ensure any places that construct
the tokens dict (the code that assigns access_token/id_token) populate id_token
consistently with that type.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fd9208f4-ed6c-460b-99c7-8db446b35f55

📥 Commits

Reviewing files that changed from the base of the PR and between f2fe3bc and f78a888.

📒 Files selected for processing (1)
  • src/py_nationalgrid/oidchelper.py

@virtitnerd virtitnerd merged commit 027556c into main May 21, 2026
7 checks passed
@virtitnerd virtitnerd deleted the feature/bill-history branch May 21, 2026 23:19
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