Skip to content

refactor: add typing to everything#601

Merged
firstof9 merged 6 commits into
firstof9:mainfrom
c00w:typing
Jun 2, 2026
Merged

refactor: add typing to everything#601
firstof9 merged 6 commits into
firstof9:mainfrom
c00w:typing

Conversation

@c00w
Copy link
Copy Markdown
Contributor

@c00w c00w commented Jun 2, 2026

This adds strict typing, and fixes some type gaps in the library.

Summary by CodeRabbit

  • Bug Fixes

    • Improved websocket state management and lifecycle handling for more stable connections.
    • Enhanced JSON parsing with better error detection and handling.
  • Tests

    • Added test coverage for websocket state transitions and error scenarios.
    • Expanded validation tests for JSON response handling.
  • Chores

    • Strengthened type annotations throughout the codebase for improved reliability.
    • Refined internal error tracking and callback scheduling logic.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 2, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 47182239-0938-4efe-a91d-b3adcb0f1378

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@firstof9 firstof9 added the code-quality Code quality improvements label Jun 2, 2026
@firstof9
Copy link
Copy Markdown
Owner

firstof9 commented Jun 2, 2026

Looks like there's some mypy issues to fix.

Copy link
Copy Markdown

@secondof9 secondof9 left a comment

Choose a reason for hiding this comment

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

Hermes Agent Review

Found 1 warning and 5 suggestions. See inline comments below.

@secondof9
Copy link
Copy Markdown

Code Review Summary

Verdict: Changes Requested 🔴 (1 warning, 5 suggestions)

PR: #601 — Add typing to everything
Author: @c00w
Files changed: 6 (+82 -61)

🔴 Critical

None.

⚠️ Warnings

  • openevsehttp/client.py:178 — Removed JSON schema validation that caught invalid JSON before attempting json.loads. The defensive check that validated the response schema now just silently logs an error and raises ParseJSONError, but this loses the diagnostic detail of why the response was invalid. The original JSON schema validation provided better error messages for malformed API responses.

💡 Suggestions

  • openevsehttp/client.py:8 — Added MutableMapping to imports. Since data is never mutated in the codebase, importing it creates a false positive in mypy type checking. Removing it simplifies the import and eliminates unnecessary type noise.
  • openevsehttp/client.py:56-71 — Changed generic dict to dict[str, Any] for _status and _config. While this is technically more precise, both keys actually contain Mapping[str, Any] (dict-like objects), so the stricter subscript hints at mutations that never happen. Consider whether the precision is worth the noise.
  • openevsehttp/client.py:82 — Removed the JSON schema validation check that validated the response format. This defensive programming catch removed a helpful sanity check for malformed API responses.
  • openevsehttp/properties.py:343 — Changed self._status.get("vehicle", False) to bool(self._status.get("vehicle", False)). The bool() conversion is unnecessary since .get() already returns a boolean by default. This is redundant and slightly inefficient.
  • openevsehttp/properties.py:363 — Changed self._status.get("manual_override", False) to bool(self._status.get("manual_override", False)). Same redundancy as the previous point — bool() is unnecessary.

✅ Looks Good

  • All type annotations are correctly applied with proper typing imports and type stubs (e.g., dict[str, Any] subscript, MutableMapping for websocket data).
  • Consistent use of Callable[..., Any] for function signatures instead of bare Callable.
  • Added from __future__ import annotations in websocket.py which enables postponed evaluation and makes the code more future-proof.
  • The test file was updated to return proper JSON format ('{"msg": false}') instead of bare Python False, which is a good test improvement.

Reviewed by Hermes Agent

@c00w
Copy link
Copy Markdown
Contributor Author

c00w commented Jun 2, 2026

@firstof9 - Fixed.

@firstof9 firstof9 changed the title Add typing to everything refactor: add typing to everything Jun 2, 2026
@firstof9 firstof9 added refactor Code refactorings and removed code-quality Code quality improvements labels Jun 2, 2026
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)
openevsehttp/websocket.py (1)

87-90: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fallback uses deprecated asyncio.get_event_loop() and has flawed logic.

If ensure_future fails because there's no running event loop, calling get_event_loop() and call_soon_threadsafe won't help—that loop isn't running either. The callback won't execute, and get_event_loop() is deprecated in Python 3.10+ when called outside an async context.

Consider removing this fallback entirely and letting the outer except RuntimeError handle it, or document that the setter must only be called from an async context or with _listener_loop set.

Proposed simplification
         try:
             if self._listener_loop:
                 self._listener_loop.call_soon_threadsafe(self._schedule_task, coro)
             else:
-                try:
-                    task = asyncio.ensure_future(coro)
-                    self._tasks.add(task)
-                    task.add_done_callback(self._tasks.discard)
-                except RuntimeError:
-                    # Fallback to get_event_loop if ensure_future fails and no _listener_loop
-                    loop = asyncio.get_event_loop()
-                    loop.call_soon_threadsafe(self._schedule_task, coro)
+                task = asyncio.ensure_future(coro)
+                self._tasks.add(task)
+                task.add_done_callback(self._tasks.discard)
         except RuntimeError:
             _LOGGER.error("Failed to schedule callback from sync context: %s", coro)
             if hasattr(coro, "close"):
                 coro.close()
🤖 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 `@openevsehttp/websocket.py` around lines 87 - 90, The fallback in the except
RuntimeError block uses asyncio.get_event_loop() (deprecated) and
call_soon_threadsafe on a non-running loop, which is incorrect; remove this
fallback and either re-raise the RuntimeError or document that the setter must
be invoked from an async context or with _listener_loop populated. Specifically,
update the except RuntimeError handling around asyncio.ensure_future(...) in
websocket.py: remove the call to asyncio.get_event_loop() and
loop.call_soon_threadsafe(self._schedule_task, coro), and ensure the logic
relies on the existing outer exception handling or clearly enforces/validates
that _listener_loop is set before scheduling (referencing ensure_future,
_schedule_task, and _listener_loop).
openevsehttp/client.py (1)

298-300: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keepalive exits before the socket ever reaches connected.

_start_listening() schedules repeat() while _ws_listening is still False, and repeat() uses that flag in its while condition. On a cold start the task returns immediately, so the websocket never sends periodic keepalives after it does connect.

Suggested fix
 async def repeat(
     self,
     interval: float,
     func: Callable[..., Any],
     *args: Any,
     **kwargs: Any,
 ) -> None:
@@
-    while self.ws_state != STATE_STOPPED and self._ws_listening:
+    while self.ws_state != STATE_STOPPED:
         await asyncio.sleep(interval)
-        if self.ws_state == STATE_STOPPED or not self._ws_listening:
+        if self.ws_state == STATE_STOPPED:
             break
+        if not self._ws_listening:
+            continue
         result = func(*args, **kwargs)
         if inspect.isawaitable(result):
             await result

Also applies to: 450-456

🤖 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 `@openevsehttp/client.py` around lines 298 - 300, The keepalive task is being
created while _ws_listening is still False so repeat() immediately exits; ensure
the keepalive loop is started only after the websocket is considered
listening/connected. Fix by moving creation of self._ws_keepalive_task
(currently creating repeat(300, self.websocket.keepalive)) to after
_ws_listening is set True or by changing repeat()'s condition to wait for
websocket.connected (or await a websocket.wait_until_connected() helper) before
entering its while loop; update both occurrences that schedule the task (the
_start_listening() spot and the second occurrence around lines 450-456) and
reference _start_listening, repeat, _ws_keepalive_task, _ws_listening, and
websocket.keepalive when applying the change.
🧹 Nitpick comments (2)
tests/test_client.py (1)

1728-1737: ⚡ Quick win

Parametrize the primitive rejection cases.

This only covers numeric primitives. The parser also rejects false and null, and those are easy to regress because plain text and JSON strings are still allowed.

Suggested test shape
-async def test_process_request_invalid_json_primitive(mock_aioclient):
-    """Test process_request with an unexpected JSON primitive (e.g., bool or int)."""
-    charger = OpenEVSE(SERVER_URL)
-    mock_aioclient.get(
-        TEST_URL_STATUS,
-        status=200,
-        body="123",
-    )
-    with pytest.raises(ParseJSONError):
-        await charger.process_request(TEST_URL_STATUS, method="get")
+@pytest.mark.parametrize("body", ["123", "false", "null"])
+async def test_process_request_invalid_json_primitive(mock_aioclient, body):
+    """Test process_request rejects unexpected JSON primitives."""
+    charger = OpenEVSE(SERVER_URL)
+    mock_aioclient.get(
+        TEST_URL_STATUS,
+        status=200,
+        body=body,
+    )
+    with pytest.raises(ParseJSONError):
+        await charger.process_request(TEST_URL_STATUS, method="get")
🤖 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 `@tests/test_client.py` around lines 1728 - 1737, The test
test_process_request_invalid_json_primitive only checks a numeric primitive;
update it to parametrize multiple JSON primitive responses (e.g., "123",
"false", "null") so process_request on OpenEVSE with method="get" raises
ParseJSONError for each primitive; locate the test function name
test_process_request_invalid_json_primitive and the call to
charger.process_request and replace the single-case mock_aioclient.get + assert
with a pytest.mark.parametrize over the primitive bodies, asserting
ParseJSONError for each.
openevsehttp/properties.py (1)

128-128: ⚡ Quick win

Use real type expressions in typing.cast() (no quoted type strings)

openevsehttp/properties.py uses cast("...") with quoted type targets (e.g., cast("int | None", ...), cast("float | None", ...), etc.) at lines 128, 306-321, 400, 421, 426, 439, 497, 502, 507, 512. Switch to cast(int | None, ...), cast(float | None, ...), cast(str | None, ...), etc.

Representative fix
-        return cast("int | None", self._status.get("max_current", None))
+        return cast(int | None, self._status.get("max_current", None))

-        return cast("float | None", self._status.get("total_day", None))
+        return cast(float | None, self._status.get("total_day", None))

-        return cast("str | None", self._config.get("wifi_serial", None))
+        return cast(str | None, self._config.get("wifi_serial", None))

wifi_firmware’s early-return on missing version matches the tests, so that part is fine as-is.

🤖 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 `@openevsehttp/properties.py` at line 128, Replace the string-literal type
arguments passed to typing.cast with real type expressions (use PEP 604 unions
like int | None, float | None, str | None) wherever cast is called; e.g., change
cast("int | None", self._status.get("max_current", None)) to cast(int | None,
self._status.get("max_current", None)) and make the equivalent replacements for
the other occurrences (the cast calls around the status/accessor properties —
the similar casts at the block handling voltage/temperature/firmware/version and
the other listed cast sites). Ensure you keep the same return values and imports
(no code logic changes), only replace the quoted type strings with actual type
expressions.
🤖 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 `@openevsehttp/client.py`:
- Around line 298-300: The keepalive task is being created while _ws_listening
is still False so repeat() immediately exits; ensure the keepalive loop is
started only after the websocket is considered listening/connected. Fix by
moving creation of self._ws_keepalive_task (currently creating repeat(300,
self.websocket.keepalive)) to after _ws_listening is set True or by changing
repeat()'s condition to wait for websocket.connected (or await a
websocket.wait_until_connected() helper) before entering its while loop; update
both occurrences that schedule the task (the _start_listening() spot and the
second occurrence around lines 450-456) and reference _start_listening, repeat,
_ws_keepalive_task, _ws_listening, and websocket.keepalive when applying the
change.

In `@openevsehttp/websocket.py`:
- Around line 87-90: The fallback in the except RuntimeError block uses
asyncio.get_event_loop() (deprecated) and call_soon_threadsafe on a non-running
loop, which is incorrect; remove this fallback and either re-raise the
RuntimeError or document that the setter must be invoked from an async context
or with _listener_loop populated. Specifically, update the except RuntimeError
handling around asyncio.ensure_future(...) in websocket.py: remove the call to
asyncio.get_event_loop() and loop.call_soon_threadsafe(self._schedule_task,
coro), and ensure the logic relies on the existing outer exception handling or
clearly enforces/validates that _listener_loop is set before scheduling
(referencing ensure_future, _schedule_task, and _listener_loop).

---

Nitpick comments:
In `@openevsehttp/properties.py`:
- Line 128: Replace the string-literal type arguments passed to typing.cast with
real type expressions (use PEP 604 unions like int | None, float | None, str |
None) wherever cast is called; e.g., change cast("int | None",
self._status.get("max_current", None)) to cast(int | None,
self._status.get("max_current", None)) and make the equivalent replacements for
the other occurrences (the cast calls around the status/accessor properties —
the similar casts at the block handling voltage/temperature/firmware/version and
the other listed cast sites). Ensure you keep the same return values and imports
(no code logic changes), only replace the quoted type strings with actual type
expressions.

In `@tests/test_client.py`:
- Around line 1728-1737: The test test_process_request_invalid_json_primitive
only checks a numeric primitive; update it to parametrize multiple JSON
primitive responses (e.g., "123", "false", "null") so process_request on
OpenEVSE with method="get" raises ParseJSONError for each primitive; locate the
test function name test_process_request_invalid_json_primitive and the call to
charger.process_request and replace the single-case mock_aioclient.get + assert
with a pytest.mark.parametrize over the primitive bodies, asserting
ParseJSONError for each.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8f43340a-68ee-4429-b953-f1fe2b800b0c

📥 Commits

Reviewing files that changed from the base of the PR and between d0f7fa0 and 932db44.

📒 Files selected for processing (9)
  • openevsehttp/client.py
  • openevsehttp/commands.py
  • openevsehttp/properties.py
  • openevsehttp/py.typed
  • openevsehttp/websocket.py
  • pyproject.toml
  • tests/test_client.py
  • tests/test_commands.py
  • tests/test_properties.py

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)
openevsehttp/client.py (1)

323-331: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear _ws_listening on every STATE_STOPPED transition.

A clean stop currently leaves _ws_listening unchanged. If the socket reaches STATE_STOPPED without an error, update() will still treat websocket updates as live and skip /status polling, leaving status stale until a full reconnect/disconnect cycle resets the flag.

Suggested fix
-            elif data == STATE_STOPPED and error:
-                _LOGGER.debug(
-                    "Websocket to %s failed, aborting [Error: %s]",
-                    uri,
-                    error,
-                )
-                self._ws_listening = False
+            elif data == STATE_STOPPED:
+                if error:
+                    _LOGGER.debug(
+                        "Websocket to %s failed, aborting [Error: %s]",
+                        uri,
+                        error,
+                    )
+                self._ws_listening = False
🤖 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 `@openevsehttp/client.py` around lines 323 - 331, The code only clears
self._ws_listening when STATE_STOPPED occurs with an error; change the
STATE_STOPPED handling so that self._ws_listening is set to False for every
STATE_STOPPED transition (regardless of the error value) in the websocket
handler in client.py (the block that currently checks "elif data ==
STATE_STOPPED and error"); either remove the "and error" from that condition or
add a branch that sets self._ws_listening = False when data == STATE_STOPPED
without error so update() will resume polling /status.
🧹 Nitpick comments (1)
tests/test_websocket.py (1)

291-328: ⚡ Quick win

Keep one success-path test for cross-thread state scheduling.

These additions cover the callback is None branch and the local scheduling failure path, but they no longer exercise a successful state change from outside the listener loop. Since this refactor hardens thread-safe callback scheduling, keeping one positive cross-thread case would catch regressions in the path that matters most.

🤖 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 `@tests/test_websocket.py` around lines 291 - 328, Add one positive
cross-thread scheduling test to cover the successful path for state changes
originating outside the listener loop: create a test (e.g.,
test_websocket_schedule_success_sync) that assigns an awaitable AsyncMock to
ws_client.callback, simulates setting ws_client.state from a non-event-loop
context (so the code path in the state setter that calls asyncio.ensure_future
is taken), and asserts the callback was scheduled/awaited and no error was
logged or stored in ws_client._error_reason; reference the ws_client.state
setter, the callback attribute, and ensure_future scheduling logic to locate the
code to exercise.
🤖 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 `@openevsehttp/client.py`:
- Around line 323-331: The code only clears self._ws_listening when
STATE_STOPPED occurs with an error; change the STATE_STOPPED handling so that
self._ws_listening is set to False for every STATE_STOPPED transition
(regardless of the error value) in the websocket handler in client.py (the block
that currently checks "elif data == STATE_STOPPED and error"); either remove the
"and error" from that condition or add a branch that sets self._ws_listening =
False when data == STATE_STOPPED without error so update() will resume polling
/status.

---

Nitpick comments:
In `@tests/test_websocket.py`:
- Around line 291-328: Add one positive cross-thread scheduling test to cover
the successful path for state changes originating outside the listener loop:
create a test (e.g., test_websocket_schedule_success_sync) that assigns an
awaitable AsyncMock to ws_client.callback, simulates setting ws_client.state
from a non-event-loop context (so the code path in the state setter that calls
asyncio.ensure_future is taken), and asserts the callback was scheduled/awaited
and no error was logged or stored in ws_client._error_reason; reference the
ws_client.state setter, the callback attribute, and ensure_future scheduling
logic to locate the code to exercise.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b183062d-6e83-44bf-9344-fc1bbd1a0566

📥 Commits

Reviewing files that changed from the base of the PR and between 932db44 and ec278df.

📒 Files selected for processing (5)
  • openevsehttp/client.py
  • openevsehttp/properties.py
  • openevsehttp/websocket.py
  • tests/test_client.py
  • tests/test_websocket.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • openevsehttp/websocket.py

@secondof9
Copy link
Copy Markdown

secondof9 commented Jun 2, 2026

Hermes Agent Code Review — PR #601

Verdict: Changes Requested (5 suggestions, 2 test improvements)


🔵 Suggestions

openevsehttp/client.py

Line 135 — Ambiguous variable name

message: Mapping[str, Any] | list[Any] | str = raw
  • message is reused as a variable name for a raw response, then reassigned to the parsed JSON. This shadows the earlier local variable (if any) and may confuse readers. Consider renaming message to response or data when reassigning, since message is a common shorthand for HTTP message bodies.

Lines 140–141 — Missing error handling context

except ValueError:
    _LOGGER.debug("Non JSON response: %s", raw)
  • The error message uses raw (the decoded string), but the context around it is unclear — is this expected to be JSON? What should a malformed response be? Add a brief docstring or inline comment explaining that the endpoint is expected to return JSON and that non-JSON responses indicate a server error or protocol mismatch.

openevsehttp/websocket.py

Line 130 — Redundant assertion

assert self.session is not None
  • This assertion is already guaranteed by the _ensure_session() call on line 128. Remove it to avoid confusion — a static analyzer will flag this as a dead assertion. The session could only be None if _ensure_session() failed, but it doesn't.

Lines 289–292 — Unclear type bounds in comment

# Trigger RuntimeError in both create_task and get_event_loop/call_soon_threadsafe
  • The comment suggests that both paths are being tested, but only create_task (via ensure_future) is being mocked. The get_event_loop path was previously tested but appears to be removed or refactored. Update the comment to reflect current behavior — only ensure_future is being mocked here.

tests/test_websocket.py

Lines 289–331 — Test refactoring removes error handling coverage

  • The old test test_state_setter_threadsafe_fallback tested both ensure_future and get_event_loop fallback paths. The new test test_state_setter_no_callback only tests the success path and explicitly sets callback = None to verify no exceptions are raised.
  • Add a new test test_state_setter_runtime_error that verifies RuntimeError during call_soon_threadsafe (or ensure_future) is caught and logged correctly, and that the state transitions to STOPPED as expected.

tests/test_commands.py

Line 757 — Regression in test data format

mock_aioclient.post(TEST_URL_RESTART, status=200, body='{"msg": false}')
  • The test now sends JSON "{\"msg\": false} instead of the original string "false". This breaks the test's intent — the original test was checking that the charger rejects false replies, but now it's testing a different response structure. Either revert to body="false" or update the test's expected error message to match the new payload.

✅ Looks Good

  • Strict type annotations throughout the codebase significantly improve mypy compatibility and IDE support
  • The py.typed marker file ensures downstream consumers (e.g., Home Assistant) will treat the package as typed
  • Mypy strict mode in pyproject.toml will catch type issues at CI time
  • Edge-case handling improvements (e.g., MutableMapping check vs plain Mapping for websocket data) are thoughtful and reduce runtime surprises
  • Test additions for JSON primitive handling (123, false, null) are valuable and cover previously untested error paths

⚠️ Warnings

  • The websocket data check now uses MutableMapping instead of Mapping. This is more precise, but ensure that the OpenEVSE protocol genuinely never sends immutable mappings (e.g., frozen dicts) — if it does, the MutableMapping check would incorrectly reject valid data
  • The assert in websocket.py should be removed to avoid confusion, but document why it's there if a reviewer wants to understand the intent

Reviewed by Hermes Agent

@firstof9 firstof9 merged commit 535f24c into firstof9:main Jun 2, 2026
10 checks passed
@firstof9
Copy link
Copy Markdown
Owner

firstof9 commented Jun 2, 2026

Thanks @c00w

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactor Code refactorings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants