Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.13", "3.14"]
python-version: ["3.14"]

steps:
- name: Checkout code
Expand All @@ -21,7 +21,6 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true

- name: Install uv
uses: astral-sh/setup-uv@v7
Expand Down Expand Up @@ -50,7 +49,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
python-version: "3.14"

- name: Install uv
uses: astral-sh/setup-uv@v7
Expand Down Expand Up @@ -79,7 +78,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
python-version: "3.14"

- name: Install uv
uses: astral-sh/setup-uv@v7
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
python-version: "3.14"

- name: Install uv
uses: astral-sh/setup-uv@v7
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.4.2] - 03/2026

### Fixed

- **Moved SSL context creation to executor** — `httpx.AsyncClient()` eagerly calls `ssl.SSLContext.load_verify_locations()` with the system CA bundle, which is a blocking file I/O operation that triggers Home Assistant's event loop protection. The SSL
context is now created in an executor thread and passed to httpx via `verify=ctx`.

## [2.4.1] - 03/2026

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "span-panel-api"
version = "2.4.1"
version = "2.4.2"
description = "A client library for SPAN Panel API"
authors = [
{name = "SpanPanel"}
Expand Down
41 changes: 40 additions & 1 deletion src/span_panel_api/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,57 @@

from __future__ import annotations

import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
import ssl

import httpx


@dataclass
class _SSLCache:
"""Mutable container for the cached SSLContext and its async lock."""

context: ssl.SSLContext | None = None
lock: asyncio.Lock | None = field(default=None, repr=False)

def get_lock(self) -> asyncio.Lock:
"""Return the async lock, creating it lazily."""
if self.lock is None:
self.lock = asyncio.Lock()
return self.lock


_ssl_cache = _SSLCache()


def _build_url(host: str, port: int, path: str) -> str:
"""Build an HTTP URL, omitting the port when it is the default (80)."""
if port == 80:
return f"http://{host}{path}"
return f"http://{host}:{port}{path}"


async def _create_ssl_context() -> ssl.SSLContext:
"""Return a cached default SSL context, creating it in an executor on first call.

``ssl.create_default_context()`` calls ``load_verify_locations`` which
performs blocking file I/O on the system CA bundle. The resulting context
is thread-safe and reusable, so we cache it for the lifetime of the process.
"""
if _ssl_cache.context is not None:
return _ssl_cache.context
async with _ssl_cache.get_lock():
# Double-check after acquiring the lock.
if _ssl_cache.context is not None:
return _ssl_cache.context
loop = asyncio.get_running_loop()
_ssl_cache.context = await loop.run_in_executor(None, ssl.create_default_context)
return _ssl_cache.context


@asynccontextmanager
async def _get_client(
httpx_client: httpx.AsyncClient | None,
Expand All @@ -23,5 +61,6 @@ async def _get_client(
if httpx_client is not None:
yield httpx_client
return
async with httpx.AsyncClient(timeout=timeout) as client:
ctx = await _create_ssl_context()
async with httpx.AsyncClient(timeout=timeout, verify=ctx) as client:
yield client
9 changes: 9 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,18 @@
from paho.mqtt.client import ConnectFlags
from paho.mqtt.reasoncodes import ReasonCode

import span_panel_api._http as _http_mod
from span_panel_api.models import V2HomieSchema
from span_panel_api.mqtt.const import TOPIC_PREFIX, TYPE_CORE


@pytest.fixture(autouse=True)
def _reset_ssl_cache() -> None:
"""Ensure the module-level SSL context cache doesn't leak between tests."""
_http_mod._ssl_cache.context = None
_http_mod._ssl_cache.lock = None


# ---------------------------------------------------------------------------
# Constants shared across MQTT tests
# ---------------------------------------------------------------------------
Expand Down
7 changes: 5 additions & 2 deletions tests/test_auth_and_homie_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ async def test_download_ca_cert_fallback_uses_timeout_for_client(self) -> None:
headers={"content-type": "text/plain"},
request=httpx.Request("GET", "http://test"),
)
with patch("span_panel_api._http.httpx.AsyncClient") as cls:
with (
patch("span_panel_api._http.httpx.AsyncClient") as cls,
patch("span_panel_api._http._create_ssl_context", new_callable=AsyncMock) as mock_ctx,
):
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
Expand All @@ -154,7 +157,7 @@ async def test_download_ca_cert_fallback_uses_timeout_for_client(self) -> None:

await download_ca_cert("192.168.1.1", timeout=88.5)

cls.assert_called_once_with(timeout=88.5)
cls.assert_called_once_with(timeout=88.5, verify=mock_ctx.return_value)

@pytest.mark.asyncio
async def test_get_homie_schema_injected_skips_constructor(self) -> None:
Expand Down
18 changes: 15 additions & 3 deletions tests/test_detection_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ async def test_get_client_yields_injected_client_without_closing(self) -> None:

@pytest.mark.asyncio
async def test_get_client_creates_and_closes_fallback_client(self) -> None:
with patch("span_panel_api._http.httpx.AsyncClient") as mock_cls:
with (
patch("span_panel_api._http.httpx.AsyncClient") as mock_cls,
patch("span_panel_api._http._create_ssl_context", new_callable=AsyncMock) as mock_ctx,
):
mock_instance = AsyncMock()
mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
mock_instance.__aexit__ = AsyncMock(return_value=False)
Expand All @@ -63,6 +66,7 @@ async def test_get_client_creates_and_closes_fallback_client(self) -> None:

mock_cls.assert_called_once_with(
timeout=12.5,
verify=mock_ctx.return_value,
)
mock_instance.__aenter__.assert_awaited_once()
mock_instance.__aexit__.assert_awaited_once()
Expand Down Expand Up @@ -135,7 +139,10 @@ async def test_register_v2_uses_injected_client_and_does_not_close(self) -> None
@pytest.mark.asyncio
async def test_fallback_client_uses_register_v2_timeout(self) -> None:
mock_response = _mock_response(200, V2_AUTH_JSON)
with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls:
with (
patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls,
patch("span_panel_api._http._create_ssl_context", new_callable=AsyncMock) as mock_ctx,
):
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
Expand All @@ -146,6 +153,7 @@ async def test_fallback_client_uses_register_v2_timeout(self) -> None:

mock_client_cls.assert_called_once_with(
timeout=42.5,
verify=mock_ctx.return_value,
)

@pytest.mark.asyncio
Expand Down Expand Up @@ -179,7 +187,10 @@ async def test_detect_api_version_uses_injected_client(self) -> None:
@pytest.mark.asyncio
async def test_detect_api_version_fallback_uses_timeout(self) -> None:
mock_response = _mock_response(200, V2_STATUS_JSON)
with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls:
with (
patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls,
patch("span_panel_api._http._create_ssl_context", new_callable=AsyncMock) as mock_ctx,
):
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
Expand All @@ -190,6 +201,7 @@ async def test_detect_api_version_fallback_uses_timeout(self) -> None:

mock_client_cls.assert_called_once_with(
timeout=3.25,
verify=mock_ctx.return_value,
)


Expand Down
Loading