Skip to content

Allow for a external session.#505

Open
c00w wants to merge 3 commits intofirstof9:mainfrom
c00w:main
Open

Allow for a external session.#505
c00w wants to merge 3 commits intofirstof9:mainfrom
c00w:main

Conversation

@c00w
Copy link

@c00w c00w commented Jan 31, 2026

This lets us inject the session externally, which homeassistant wants to reduce memory usage.

Summary by CodeRabbit

  • New Features

    • Accept an optional external HTTP session to reuse connections.
  • Improvements

    • Shared, lazily-created session is reused across HTTP and websocket flows for more efficient, consistent network behavior and cleanup.
    • Websocket now uses the shared session to ensure consistent connection handling.
  • Tests

    • Expanded async tests and fixtures to validate session creation/reuse, websocket integration, and consolidated charger-focused scenarios.

@coderabbitai
Copy link

coderabbitai bot commented Jan 31, 2026

📝 Walkthrough

Walkthrough

Adds optional aiohttp.ClientSession injection and lazy session properties to OpenEVSE and OpenEVSEWebsocket; HTTP flows (process_request, firmware_check) and websocket initialization now reuse a shared session. Tests converted to async, propagate session through a new charger factory, and add session lifecycle/unit tests.

Changes

Cohort / File(s) Summary
Core session support
openevsehttp/__main__.py, openevsehttp/websocket.py
Constructors accept an optional session; added session property that lazily creates/returns an aiohttp.ClientSession. process_request() and firmware_check() reuse self.session; update() passes self.session into OpenEVSEWebsocket.
Tests — fixtures & factories
tests/conftest.py
Added async, parameterized session fixture (internal vs external). Converted fixtures to async def, introduced an async charger factory, and propagate session=session to all OpenEVSE instances.
Tests — main
tests/test_main.py
Refactored many tests to use the charger indirect fixture pattern (tests now accept charger/await charger.update()); consolidated/renamed tests to a charger-centered style.
Tests — edge cases
tests/test_main_edge_cases.py
Added tests for lazy session creation, reuse of provided session, constructor session storage, URL formatting, and that update() passes session to websocket init.
Tests — websocket behavior
tests/test_websocket.py
Added tests asserting OpenEVSEWebsocket accepts/returns a provided session, lazily creates a session, defers creation when none provided, and that close() closes the internal _client without closing shared session.
Manifests / metadata
manifest_file, requirements.txt, pyproject.toml
Referenced by tests; no functional code changes detailed here.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant OpenEVSE
  participant OpenEVSEWebsocket
  participant aiohttp as "aiohttp.ClientSession"

  Client->>OpenEVSE: instantiate(host, ..., session=None|provided)
  alt session not provided
    OpenEVSE-->>aiohttp: lazily create session on first access
  else session provided
    OpenEVSE-->>aiohttp: use provided session
  end
  Client->>OpenEVSE: update()
  OpenEVSE->>aiohttp: HTTP requests via self.session (process_request / firmware_check)
  OpenEVSE->>OpenEVSEWebsocket: init(..., session=self.session)
  OpenEVSEWebsocket->>aiohttp: use provided session for websocket connection
  Client->>OpenEVSEWebsocket: close()
  OpenEVSEWebsocket->>OpenEVSEWebsocket: close internal _client (do not close shared session)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped in with a client, calm and merry,
One session to share—no more ferry.
Lazy when wanted, returned when supplied,
Websocket kept tidy, connections abide.
Hop—update—reuse; the tests cheerfully tried.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Allow for a external session' directly relates to the main objective of the PR, which is to support external session injection throughout the library.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Pylint (4.0.4)
tests/test_main.py

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.

Copy link

@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: 0

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)

28-55: ⚠️ Potential issue | 🟠 Major

Close internally created aiohttp sessions when the websocket is stopped.

The session property lazily creates a ClientSession when none is provided, but close() only closes _client. This leaves internally created sessions open, causing resource leaks and "unclosed session" warnings when using OpenEVSEWebsocket directly. Track session ownership and close only when this instance created it.

🔧 Proposed fix (track ownership and close owned sessions)
 class OpenEVSEWebsocket:
@@
     def __init__(
         self,
         server,
         callback,
         user=None,
         password=None,
         session=None,
     ):
         """Initialize a OpenEVSEWebsocket instance."""
         self._session = session
+        self._owns_session = session is None
@@
     `@property`
     def session(self):
         """Return the aiohttp session, creating one if needed."""
         if self._session is None:
             self._session = aiohttp.ClientSession()
+            self._owns_session = True
         return self._session
@@
     async def close(self):
         """Close the listening websocket."""
         await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED)
         if self._client:
             await self._client.close()
+        if self._owns_session and self._session and not self._session.closed:
+            await self._session.close()
openevsehttp/__main__.py (1)

87-114: ⚠️ Potential issue | 🟠 Major

Add cleanup method for internally-created aiohttp session.

OpenEVSE and OpenEVSEWebsocket both lazily create and retain ClientSession instances when none is provided, but neither class exposes a cleanup method. This causes socket/connector leaks in short-lived integrations (tests, CLI scripts). Tracking session ownership is necessary since OpenEVSEWebsocket receives self.session from OpenEVSE and must not close sessions it doesn't own.

Recommend adding an _owns_session flag and async close() method to OpenEVSE to release the websocket and any internally-created session.

🔧 Proposed fix (owning session + close method)
     def __init__(
         self,
         host: str,
         user: str = "",
         pwd: str = "",
         session: aiohttp.ClientSession | None = None,
     ) -> None:
@@
         self.url = f"http://{host}/"
         self._session = session
+        self._owns_session = session is None
@@
     `@property`
     def session(self) -> aiohttp.ClientSession:
         """Return the aiohttp session, creating one if needed."""
         if self._session is None:
             self._session = aiohttp.ClientSession()
+            self._owns_session = True
         return self._session
+
+    async def close(self) -> None:
+        """Close websocket and any owned aiohttp session."""
+        if self.websocket:
+            await self.websocket.close()
+        if self._owns_session and self._session and not self._session.closed:
+            await self._session.close()

@firstof9
Copy link
Owner

Thanks man, this was on my todo list lol

@firstof9 firstof9 added the code quality Improves code quality label Jan 31, 2026
Copy link
Owner

@firstof9 firstof9 left a comment

Choose a reason for hiding this comment

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

Can you run black on this to fix the formatting? I haven't swapped to ruff yet on this one.

@c00w
Copy link
Author

c00w commented Feb 2, 2026

@firstof9 Done

@firstof9
Copy link
Owner

firstof9 commented Feb 2, 2026

I'll try to update the tests this week as well.

Copy link

@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

Caution

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

⚠️ Outside diff range comments (2)
tests/conftest.py (1)

31-65: ⚠️ Potential issue | 🟡 Minor

Add noqa: S106 comments to suppress hardcoded-password lints in test fixtures.

S106 is flagged at lines 50 and 64 for pwd="fakepassword". Since ruff's bandit rules (S) are enabled in the project configuration and S106 is not suppressed for conftest.py, these violations will fail CI. Add an inline noqa: S106 comment to each return statement.

Suggested fix
-    return main.OpenEVSE(TEST_TLD, user="testuser", pwd="fakepassword", session=session)
+    return main.OpenEVSE(
+        TEST_TLD,
+        user="testuser",
+        pwd="fakepassword",  # noqa: S106 - test credential
+        session=session,
+    )
openevsehttp/__main__.py (1)

87-113: ⚠️ Potential issue | 🟠 Major

Add explicit shutdown for internally created sessions.

self.session now lazily creates a ClientSession, but there is no teardown path. This can leak connectors on long-running processes or reloads (e.g., Home Assistant). Track ownership and add a close() method that shuts down the websocket and internal session only.

✅ Suggested fix
 class OpenEVSE:
@@
     def __init__(
         self,
         host: str,
         user: str = "",
         pwd: str = "",
         session: aiohttp.ClientSession | None = None,
     ) -> None:
@@
         self.url = f"http://{host}/"
         self._session = session
+        self._owns_session = False
@@
     `@property`
     def session(self) -> aiohttp.ClientSession:
         """Return the aiohttp session, creating one if needed."""
         if self._session is None:
             self._session = aiohttp.ClientSession()
+            self._owns_session = True
         return self._session
+
+    async def close(self) -> None:
+        """Close websocket and any internally created session."""
+        if self.websocket:
+            await self.websocket.close()
+        if self._owns_session and self._session and not self._session.closed:
+            await self._session.close()
🤖 Fix all issues with AI agents
In `@openevsehttp/websocket.py`:
- Around line 49-54: The session property lazily creates an
aiohttp.ClientSession but doesn't track ownership, causing a connection-pool
leak because close() only closes _client; add an _owns_session boolean on
OpenEVSEWebsocket, set _owns_session = True when session() creates a new
aiohttp.ClientSession and set _owns_session = False when an external session is
injected or passed into __init__, then update the close() method to await/close
self._session only if self._session is not None and self._owns_session is True
(and still close self._client as before); ensure any assignment paths to
self._session set the ownership flag appropriately.

In `@tests/test_main.py`:
- Around line 346-355: The test_get_ir_temperature function currently ignores
the parametrized expected value causing ARG001; modify the assertion to compare
charger.ir_temperature to the expected parameter (replace the hardcoded "assert
status is None" with "assert status == expected") so the test uses the provided
expected values; keep the surrounding calls (charger.update() and
charger.ws_disconnect()) as-is and ensure the test signature (async def
test_get_ir_temperature(charger, expected)) remains unchanged.

Comment on lines +49 to +54
@property
def session(self):
"""Return the aiohttp session, creating one if needed."""
if self._session is None:
self._session = aiohttp.ClientSession()
return self._session
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Get the file structure and size
wc -l openevsehttp/websocket.py

Repository: firstof9/python-openevse-http

Length of output: 102


🏁 Script executed:

# Find all close methods and session cleanup in the file
grep -n "def close\|async def close\|__aexit__\|_session" openevsehttp/websocket.py | head -30

Repository: firstof9/python-openevse-http

Length of output: 262


🏁 Script executed:

# Look for session-related patterns
rg -n "session.*close|close.*session" openevsehttp/ --type py

Repository: firstof9/python-openevse-http

Length of output: 55


🏁 Script executed:

# Check for context manager implementation
rg -n "__aenter__|__aexit__" openevsehttp/ --type py

Repository: firstof9/python-openevse-http

Length of output: 55


🏁 Script executed:

sed -n '30,60p' openevsehttp/websocket.py

Repository: firstof9/python-openevse-http

Length of output: 909


🏁 Script executed:

sed -n '145,165p' openevsehttp/websocket.py

Repository: firstof9/python-openevse-http

Length of output: 880


Track ownership and close internally created sessions.

The session property lazily creates an aiohttp.ClientSession without tracking ownership, but close() only closes _client and never closes _session. If OpenEVSEWebsocket is used directly without injecting a session, the internally created session's connection pool leaks and is never cleaned up. Add an _owns_session flag to track when you create the session internally, and close it only in that case.

Suggested fix
 class OpenEVSEWebsocket:
     def __init__(
         self,
         server,
         callback,
         user=None,
         password=None,
         session=None,
     ):
         """Initialize a OpenEVSEWebsocket instance."""
         self._session = session
+        self._owns_session = False
 
     `@property`
     def session(self):
         """Return the aiohttp session, creating one if needed."""
         if self._session is None:
             self._session = aiohttp.ClientSession()
+            self._owns_session = True
         return self._session
 
     async def close(self):
         """Close the listening websocket."""
         await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED)
         if self._client:
             await self._client.close()
+        if self._owns_session and self._session and not self._session.closed:
+            await self._session.close()
🤖 Prompt for AI Agents
In `@openevsehttp/websocket.py` around lines 49 - 54, The session property lazily
creates an aiohttp.ClientSession but doesn't track ownership, causing a
connection-pool leak because close() only closes _client; add an _owns_session
boolean on OpenEVSEWebsocket, set _owns_session = True when session() creates a
new aiohttp.ClientSession and set _owns_session = False when an external session
is injected or passed into __init__, then update the close() method to
await/close self._session only if self._session is not None and
self._owns_session is True (and still close self._client as before); ensure any
assignment paths to self._session set the ownership flag appropriately.

Comment on lines +346 to 351
"charger, expected",
[("test_charger", None), ("test_charger_v2", None)],
indirect=["charger"],
)
async def test_get_ir_temperature(fixture, expected, request):
async def test_get_ir_temperature(charger, expected):
"""Test v4 Status reply."""
charger = request.getfixturevalue(fixture)
await charger.update()
status = charger.ir_temperature
assert status is None
await charger.ws_disconnect()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for ARG001 warnings in the test file
rg -n "def test_get_ir_temperature" tests/test_main.py -A 10

Repository: firstof9/python-openevse-http

Length of output: 435


🏁 Script executed:

#!/bin/bash
# Run ruff check to confirm ARG001 warning
ruff check tests/test_main.py --select ARG001

Repository: firstof9/python-openevse-http

Length of output: 1466


Use the parametrized expected value (ARG001).

The expected parameter is unused and triggers an ARG001 lint error. Change the assertion to use it:

Suggested fix
 async def test_get_ir_temperature(charger, expected):
     """Test v4 Status reply."""
     await charger.update()
     status = charger.ir_temperature
-    assert status is None
+    assert status == expected
     await charger.ws_disconnect()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"charger, expected",
[("test_charger", None), ("test_charger_v2", None)],
indirect=["charger"],
)
async def test_get_ir_temperature(fixture, expected, request):
async def test_get_ir_temperature(charger, expected):
"""Test v4 Status reply."""
charger = request.getfixturevalue(fixture)
await charger.update()
status = charger.ir_temperature
assert status is None
await charger.ws_disconnect()
"charger, expected",
[("test_charger", None), ("test_charger_v2", None)],
indirect=["charger"],
)
async def test_get_ir_temperature(charger, expected):
"""Test v4 Status reply."""
await charger.update()
status = charger.ir_temperature
assert status == expected
await charger.ws_disconnect()
🧰 Tools
🪛 Ruff (0.14.14)

[warning] 350-350: Unused function argument: expected

(ARG001)

🤖 Prompt for AI Agents
In `@tests/test_main.py` around lines 346 - 355, The test_get_ir_temperature
function currently ignores the parametrized expected value causing ARG001;
modify the assertion to compare charger.ir_temperature to the expected parameter
(replace the hardcoded "assert status is None" with "assert status == expected")
so the test uses the provided expected values; keep the surrounding calls
(charger.update() and charger.ws_disconnect()) as-is and ensure the test
signature (async def test_get_ir_temperature(charger, expected)) remains
unchanged.

@firstof9
Copy link
Owner

firstof9 commented Feb 2, 2026

Oh boy, something's really wonky here.

@firstof9
Copy link
Owner

firstof9 commented Feb 2, 2026

Looks like a rebase is needed.

Copy link

@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: 0

Caution

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

⚠️ Outside diff range comments (1)
openevsehttp/__main__.py (1)

87-113: ⚠️ Potential issue | 🟠 Major

Add ownership tracking and explicit close method for internally created sessions.

The OpenEVSE instance can create an aiohttp.ClientSession internally via the session property, but has no mechanism to close it. This causes connection pool leaks, especially when the websocket is never initialized (only HTTP requests are made). While OpenEVSEWebsocket.close() does close its session, OpenEVSE itself lacks cleanup logic. Add an _owns_session flag to track internal session creation and provide an async close() method that closes only owned sessions, leaving injected sessions to callers.

Suggested implementation
     def __init__(
         self,
         host: str,
         user: str = "",
         pwd: str = "",
         session: aiohttp.ClientSession | None = None,
     ) -> None:
         """Connect to an OpenEVSE charger equipped with wifi or ethernet."""
         self._user = user
         self._pwd = pwd
         self.url = f"http://{host}/"
         self._session = session
+        self._owns_session = False
         self._status: dict = {}
         self._config: dict = {}

     `@property`
     def session(self) -> aiohttp.ClientSession:
         """Return the aiohttp session, creating one if needed."""
         if self._session is None:
             self._session = aiohttp.ClientSession()
+            self._owns_session = True
         return self._session
+
+    async def close(self) -> None:
+        """Close websocket and owned session."""
+        if self.websocket:
+            await self.websocket.close()
+        if self._owns_session and self._session and not self._session.closed:
+            await self._session.close()
🧹 Nitpick comments (1)
openevsehttp/__main__.py (1)

130-180: Preserve tracebacks with logging.exception + bare raise.
This keeps the original stack trace while still logging the error message.

♻️ Proposed tweak
-        except (TimeoutError, ServerTimeoutError) as err:
-            _LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
-            raise err
-        except ContentTypeError as err:
-            _LOGGER.error("Content error: %s", err.message)
-            raise err
+        except (TimeoutError, ServerTimeoutError):
+            _LOGGER.exception("%s: %s", ERROR_TIMEOUT, url)
+            raise
+        except ContentTypeError as err:
+            _LOGGER.exception("Content error: %s", err.message)
+            raise

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

Labels

code quality Improves code quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants