From 38b06746fa0c4c8b2ded53114ab53b5d2d30d35b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 22 Mar 2026 20:02:25 +0100 Subject: [PATCH 01/14] feat(py): scaffold Python client package Add pyproject.toml with hatchling build, httpx + attrs deps, pytest-asyncio + respx + ruff dev deps. --- clients/python/pyproject.toml | 42 +++++++++++++++++++ .../python/src/ros2_medkit_client/__init__.py | 4 ++ 2 files changed, 46 insertions(+) create mode 100644 clients/python/pyproject.toml create mode 100644 clients/python/src/ros2_medkit_client/__init__.py diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml new file mode 100644 index 0000000..5da3f0b --- /dev/null +++ b/clients/python/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ros2-medkit-client" +version = "0.1.0" +description = "Async Python client for the ros2_medkit gateway" +license = "Apache-2.0" +requires-python = ">=3.11" +authors = [{ name = "bburda" }] +keywords = ["ros2", "sovd", "medkit", "diagnostics", "openapi", "client"] +dependencies = [ + "httpx>=0.27", + "attrs>=23.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "respx>=0.22", + "ruff>=0.8", +] + +[project.urls] +Homepage = "https://github.com/selfpatch/ros2_medkit_clients" +Issues = "https://github.com/selfpatch/ros2_medkit_clients/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/ros2_medkit_client"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.ruff] +target-version = "py311" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] diff --git a/clients/python/src/ros2_medkit_client/__init__.py b/clients/python/src/ros2_medkit_client/__init__.py new file mode 100644 index 0000000..04b6c3d --- /dev/null +++ b/clients/python/src/ros2_medkit_client/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 + +"""Async Python client for the ros2_medkit gateway.""" From f8bf4ecbceb80986227b7214cb9f675b46c05d53 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 22 Mar 2026 20:04:41 +0100 Subject: [PATCH 02/14] feat(py): add MedkitError exception hierarchy MedkitError with code/error_code and details/parameters dual names. MedkitConnectionError and MedkitTimeoutError subclasses. --- .../python/src/ros2_medkit_client/errors.py | 57 +++++++++++++ clients/python/tests/test_errors.py | 84 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 clients/python/src/ros2_medkit_client/errors.py create mode 100644 clients/python/tests/test_errors.py diff --git a/clients/python/src/ros2_medkit_client/errors.py b/clients/python/src/ros2_medkit_client/errors.py new file mode 100644 index 0000000..6b7996c --- /dev/null +++ b/clients/python/src/ros2_medkit_client/errors.py @@ -0,0 +1,57 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 + +"""Structured error types for the ros2_medkit gateway client.""" + +from __future__ import annotations + + +class MedkitError(Exception): + """Structured error from the ros2_medkit gateway. + + Maps to the gateway's GenericError schema (error_code, message, parameters). + Provides both normalized names (code, details) and original GenericError + names (error_code, parameters) for compatibility. + """ + + def __init__( + self, + *, + status: int, + code: str, + message: str, + details: dict | None = None, + ) -> None: + super().__init__(message) + self.status = status + self.code = code + self.message = message + self.details = details + + @property + def error_code(self) -> str: + """Alias for code - matches GenericError.error_code field name.""" + return self.code + + @property + def parameters(self) -> dict | None: + """Alias for details - matches GenericError.parameters field name.""" + return self.details + + @classmethod + def from_generic_error( + cls, status: int, error_code: str, message: str, parameters: dict | None = None + ) -> MedkitError: + """Create from GenericError schema fields.""" + return cls(status=status, code=error_code, message=message, details=parameters) + + def __repr__(self) -> str: + return f"MedkitError(status={self.status}, code={self.code!r}, message={self.message!r})" + + +class MedkitConnectionError(MedkitError): + """Raised when a connection to the gateway fails.""" + + +class MedkitTimeoutError(MedkitError): + """Raised when a request to the gateway times out.""" diff --git a/clients/python/tests/test_errors.py b/clients/python/tests/test_errors.py new file mode 100644 index 0000000..e7dedc3 --- /dev/null +++ b/clients/python/tests/test_errors.py @@ -0,0 +1,84 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +from ros2_medkit_client.errors import MedkitConnectionError, MedkitError, MedkitTimeoutError + + +class TestMedkitError: + def test_basic_creation(self): + err = MedkitError(status=404, code="entity-not-found", message="Entity x not found") + assert err.status == 404 + assert err.code == "entity-not-found" + assert err.message == "Entity x not found" + assert err.details is None + assert str(err) == "Entity x not found" + + def test_with_details(self): + err = MedkitError(status=400, code="validation-error", message="Bad input", details={"field": "name"}) + assert err.details == {"field": "name"} + + def test_is_exception(self): + err = MedkitError(status=500, code="internal", message="fail") + assert isinstance(err, Exception) + with pytest.raises(MedkitError): + raise err + + def test_has_stack_trace(self): + err = MedkitError(status=500, code="err", message="msg") + assert isinstance(err, Exception) + # Python exceptions always have traceback when raised + try: + raise err + except MedkitError as e: + assert e.__traceback__ is not None + + def test_error_code_alias(self): + """error_code is alias for code (GenericError compat).""" + err = MedkitError(status=404, code="entity-not-found", message="not found") + assert err.error_code == "entity-not-found" + + def test_parameters_alias(self): + """parameters is alias for details (GenericError compat).""" + err = MedkitError(status=400, code="err", message="msg", details={"a": 1}) + assert err.parameters == {"a": 1} + + def test_parameters_none_when_no_details(self): + err = MedkitError(status=400, code="err", message="msg") + assert err.parameters is None + + def test_from_generic_error(self): + err = MedkitError.from_generic_error(404, "entity-not-found", "not found", {"key": "val"}) + assert err.status == 404 + assert err.code == "entity-not-found" + assert err.details == {"key": "val"} + + def test_repr(self): + err = MedkitError(status=404, code="not-found", message="gone") + assert "404" in repr(err) + assert "not-found" in repr(err) + + +class TestMedkitConnectionError: + def test_is_medkit_error(self): + err = MedkitConnectionError(status=0, code="connection-failed", message="DNS error") + assert isinstance(err, MedkitError) + + def test_catch_broad(self): + with pytest.raises(MedkitError): + raise MedkitConnectionError(status=0, code="conn", message="fail") + + +class TestMedkitTimeoutError: + def test_is_medkit_error(self): + err = MedkitTimeoutError(status=0, code="timeout", message="timed out") + assert isinstance(err, MedkitError) + + def test_catch_narrow(self): + with pytest.raises(MedkitTimeoutError): + raise MedkitTimeoutError(status=0, code="timeout", message="timed out") + + def test_catch_broad(self): + with pytest.raises(MedkitError): + raise MedkitTimeoutError(status=0, code="timeout", message="timed out") From 68fa17c588af6d9888e718b9ba6f4e137db55325 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 22 Mar 2026 20:09:22 +0100 Subject: [PATCH 03/14] feat(py): add MedkitClient with URL normalization and auth Async context manager configuring generated Client. normalize_base_url handles missing protocol and /api/v1. Generated Client lifecycle managed via __aenter__/__aexit__. --- .../python/src/ros2_medkit_client/client.py | 132 ++++++++++++++++++ .../python/src/ros2_medkit_client/streams.py | 23 +++ clients/python/tests/test_client.py | 55 ++++++++ 3 files changed, 210 insertions(+) create mode 100644 clients/python/src/ros2_medkit_client/client.py create mode 100644 clients/python/src/ros2_medkit_client/streams.py create mode 100644 clients/python/tests/test_client.py diff --git a/clients/python/src/ros2_medkit_client/client.py b/clients/python/src/ros2_medkit_client/client.py new file mode 100644 index 0000000..812fa6c --- /dev/null +++ b/clients/python/src/ros2_medkit_client/client.py @@ -0,0 +1,132 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 + +"""MedkitClient - async context manager for the ros2_medkit gateway.""" + +from __future__ import annotations + +import re +from types import TracebackType + +from ros2_medkit_client.streams import StreamHelpers + + +def normalize_base_url(url: str) -> str: + """Normalize a gateway URL: add http:// if missing, append /api/v1 if missing. + + Examples: + >>> normalize_base_url("localhost:8080") + 'http://localhost:8080/api/v1' + >>> normalize_base_url("http://gw:8080/api/v1") + 'http://gw:8080/api/v1' + + Raises: + ValueError: If url is empty or whitespace-only. + """ + if not url.strip(): + raise ValueError("base_url is required") + + normalized = url + + if not re.match(r"^https?://", normalized): + normalized = f"http://{normalized}" + + normalized = normalized.rstrip("/") + + if not normalized.endswith("/api/v1"): + normalized = f"{normalized}/api/v1" + + return normalized + + +class MedkitClient: + """Async client for the ros2_medkit gateway. + + Usage:: + + async with MedkitClient(base_url="localhost:8080") as client: + # Use generated API functions with client.http + from ros2_medkit_client.api.discovery import list_apps + result = await list_apps.asyncio(client=client.http) + + # Stream faults + async for event in client.streams.faults(): + print(event) + + The auth_token is set at client creation time and cannot be refreshed + without creating a new client. For long-lived SSE streams, ensure the + token has sufficient lifetime. + """ + + def __init__( + self, + *, + base_url: str, + auth_token: str | None = None, + timeout: float = 10.0, + ) -> None: + self._base_url = normalize_base_url(base_url) + self._auth_token = auth_token + self._timeout = timeout + self._http = None + self._streams: StreamHelpers | None = None + + @property + def base_url(self) -> str: + """The normalized base URL.""" + return self._base_url + + @property + def http(self): + """The configured generated Client for use with API functions.""" + if self._http is None: + raise RuntimeError("Client not initialized. Use 'async with MedkitClient(...)' context manager.") + return self._http + + @property + def streams(self) -> StreamHelpers: + """SSE stream helpers for faults, triggers, and subscriptions.""" + if self._streams is None: + raise RuntimeError("Client not initialized. Use 'async with MedkitClient(...)' context manager.") + return self._streams + + async def __aenter__(self) -> MedkitClient: + headers: dict[str, str] = {} + if self._auth_token: + headers["Authorization"] = f"Bearer {self._auth_token}" + + # Try to import and use the generated Client + try: + from ros2_medkit_client._generated.client import AuthenticatedClient, Client + + if self._auth_token: + self._http = AuthenticatedClient( + base_url=self._base_url, + token=self._auth_token, + timeout=self._timeout, + ) + else: + self._http = Client( + base_url=self._base_url, + timeout=self._timeout, + ) + # Enter the generated client's async context (initializes httpx.AsyncClient) + await self._http.__aenter__() + except ImportError: + # Generated code not available - SSE-only mode + self._http = None + + self._streams = StreamHelpers( + base_url=self._base_url, + headers=headers, + ) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self._http is not None and hasattr(self._http, "__aexit__"): + await self._http.__aexit__(exc_type, exc_val, exc_tb) diff --git a/clients/python/src/ros2_medkit_client/streams.py b/clients/python/src/ros2_medkit_client/streams.py new file mode 100644 index 0000000..7385fd2 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/streams.py @@ -0,0 +1,23 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 + +"""SSE stream helpers - stub, replaced in Task 5.""" + +from __future__ import annotations + + +class StreamHelpers: + """Placeholder for SSE stream helpers.""" + + def __init__(self, *, base_url: str, headers: dict[str, str]) -> None: + self._base_url = base_url + self._headers = headers + + def faults(self): + raise NotImplementedError + + def trigger_events(self, entity_type: str, entity_id: str, trigger_id: str): + raise NotImplementedError + + def subscription_events(self, entity_type: str, entity_id: str, subscription_id: str): + raise NotImplementedError diff --git a/clients/python/tests/test_client.py b/clients/python/tests/test_client.py new file mode 100644 index 0000000..3795571 --- /dev/null +++ b/clients/python/tests/test_client.py @@ -0,0 +1,55 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +from ros2_medkit_client.client import MedkitClient, normalize_base_url + + +class TestNormalizeBaseUrl: + def test_adds_http_when_no_protocol(self): + assert normalize_base_url("localhost:8080") == "http://localhost:8080/api/v1" + + def test_adds_api_v1_when_missing(self): + assert normalize_base_url("http://localhost:8080") == "http://localhost:8080/api/v1" + + def test_preserves_existing_api_v1(self): + assert normalize_base_url("http://localhost:8080/api/v1") == "http://localhost:8080/api/v1" + + def test_preserves_https(self): + assert normalize_base_url("https://gw.example.com") == "https://gw.example.com/api/v1" + + def test_handles_trailing_slash(self): + assert normalize_base_url("http://localhost:8080/") == "http://localhost:8080/api/v1" + + def test_handles_api_v1_trailing_slash(self): + assert normalize_base_url("http://localhost:8080/api/v1/") == "http://localhost:8080/api/v1" + + def test_handles_ip_with_port(self): + assert normalize_base_url("192.168.1.10:8080") == "http://192.168.1.10:8080/api/v1" + + def test_throws_on_empty_string(self): + with pytest.raises(ValueError, match="base_url is required"): + normalize_base_url("") + + def test_throws_on_whitespace(self): + with pytest.raises(ValueError, match="base_url is required"): + normalize_base_url(" ") + + +class TestMedkitClient: + async def test_creates_with_normalized_url(self): + async with MedkitClient(base_url="localhost:8080") as client: + assert client.base_url == "http://localhost:8080/api/v1" + + async def test_has_streams(self): + async with MedkitClient(base_url="localhost:8080") as client: + assert client.streams is not None + assert hasattr(client.streams, "faults") + assert hasattr(client.streams, "trigger_events") + assert hasattr(client.streams, "subscription_events") + + async def test_context_manager_enters_and_exits(self): + client = MedkitClient(base_url="localhost:8080") + async with client: + pass # Should not raise From ced6f52b24fdbe4f1283809d99e43bee2f339359 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 22 Mar 2026 20:13:15 +0100 Subject: [PATCH 04/14] feat(py): add SSE stream with httpx streaming and reconnect Async iterator over SSE events. Exponential backoff reconnect with Last-Event-ID, server retry delay (clamped), buffer limits (1MB/256KB), CRLF handling, cancellable sleep. Retry/id applied regardless of data. --- clients/python/src/ros2_medkit_client/sse.py | 245 ++++++++++ clients/python/tests/test_sse.py | 471 +++++++++++++++++++ 2 files changed, 716 insertions(+) create mode 100644 clients/python/src/ros2_medkit_client/sse.py create mode 100644 clients/python/tests/test_sse.py diff --git a/clients/python/src/ros2_medkit_client/sse.py b/clients/python/src/ros2_medkit_client/sse.py new file mode 100644 index 0000000..9fad0d3 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/sse.py @@ -0,0 +1,245 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 + +"""Server-Sent Events (SSE) stream with httpx streaming and auto-reconnect.""" + +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass +from typing import AsyncIterator + +import httpx + +from ros2_medkit_client.errors import MedkitError + + +@dataclass +class SseEvent: + """A single Server-Sent Event.""" + + event: str = "message" + data: object = None + id: str | None = None + + +def parse_sse_line(line: str, state: dict) -> None: + """Parse a single SSE line into the accumulator state dict. + + The state dict has keys: event, data, id, retry. + Trailing ``\\r`` is stripped for CRLF compatibility. + """ + # Strip trailing \r for CRLF compat + if line.endswith("\r"): + line = line[:-1] + + # Comment line + if line.startswith(":"): + return + + # Split on first colon + if ":" in line: + field_name, _, value = line.partition(":") + # SSE spec: if value starts with a space, strip it + if value.startswith(" "): + value = value[1:] + else: + # Line with no colon - field name is the whole line, value is empty + field_name = line + value = "" + + if field_name == "event": + state["event"] = value + elif field_name == "data": + if state["data"]: + state["data"] += "\n" + value + else: + state["data"] = value + elif field_name == "id": + state["id"] = value + elif field_name == "retry": + try: + state["retry"] = int(value) + except ValueError: + pass # Non-numeric retry values are ignored per SSE spec + # Unknown fields are silently ignored + + +def _parse_error_response(response: httpx.Response) -> MedkitError: + """Parse an HTTP error response into a MedkitError.""" + try: + body = json.loads(response.content) + error_code = body.get("error_code", "unknown") + message = body.get("message", response.reason_phrase or "HTTP error") + parameters = body.get("parameters") + except (json.JSONDecodeError, ValueError, AttributeError): + error_code = "http-error" + message = response.reason_phrase or f"HTTP {response.status_code}" + parameters = None + + return MedkitError( + status=response.status_code, + code=error_code, + message=message, + details=parameters, + ) + + +class SseStream: + """Async iterator over Server-Sent Events with auto-reconnect. + + Uses httpx.AsyncClient.stream() for efficient SSE consumption. + Implements exponential backoff reconnection with Last-Event-ID, + server retry delay (clamped), buffer limits, and CRLF handling. + """ + + MAX_BUFFER_SIZE = 1024 * 1024 # 1 MB + MAX_DATA_SIZE = 256 * 1024 # 256 KB + + def __init__( + self, + url: str, + *, + headers: dict[str, str] | None = None, + max_retries: int = 5, + initial_delay: float = 1.0, + max_delay: float = 30.0, + ) -> None: + self._url = url + self._headers = headers or {} + self._max_retries = max_retries + self._initial_delay = initial_delay + self._max_delay = max_delay + + self._closed = False + self._last_event_id: str | None = None + self._server_retry_delay: float | None = None + self._events_yielded = False + self._sleep_event: asyncio.Event | None = None + + def close(self) -> None: + """Stop the stream. Safe to call from any coroutine.""" + self._closed = True + if self._sleep_event is not None: + self._sleep_event.set() + + def __aiter__(self) -> AsyncIterator[SseEvent]: + return self._iterate() + + async def _iterate(self) -> AsyncIterator[SseEvent]: + retries = 0 + while not self._closed: + try: + async for event in self._connect_and_stream(): + yield event + return # Clean server close + except MedkitError: + raise # HTTP errors are not retried + except Exception: + if self._closed: + return + # Reset retries if we received events before disconnection + if self._events_yielded: + retries = 0 + self._events_yielded = False + retries += 1 + if retries > self._max_retries: + raise + # Calculate delay + if self._server_retry_delay is not None: + delay = max(0.1, min(self._server_retry_delay, self._max_delay)) + else: + delay = min(self._initial_delay * (2 ** (retries - 1)), self._max_delay) + await self._cancellable_sleep(delay) + + async def _cancellable_sleep(self, seconds: float) -> None: + """Sleep that can be interrupted by close().""" + if self._closed: + return + self._sleep_event = asyncio.Event() + try: + await asyncio.wait_for(self._sleep_event.wait(), timeout=seconds) + except asyncio.TimeoutError: + pass + finally: + self._sleep_event = None + + async def _connect_and_stream(self) -> AsyncIterator[SseEvent]: + """Open a single SSE connection and yield events until it closes.""" + headers = {"Accept": "text/event-stream", **self._headers} + if self._last_event_id is not None: + headers["Last-Event-ID"] = self._last_event_id + + async with httpx.AsyncClient() as http: + async with http.stream("GET", self._url, headers=headers) as response: + if response.status_code >= 400: + await response.aread() + raise _parse_error_response(response) + + buffer = "" + state = _new_state() + + async for chunk in response.aiter_text(): + if self._closed: + return + + buffer += chunk + if len(buffer) > self.MAX_BUFFER_SIZE: + raise MedkitError( + status=0, + code="sse-buffer-overflow", + message=f"SSE buffer exceeded {self.MAX_BUFFER_SIZE} bytes", + ) + + # Split on \n (handles both LF and CRLF since \r is stripped later) + raw_lines = buffer.split("\n") + buffer = raw_lines.pop() # Last element is incomplete line + + for line in raw_lines: + # Strip trailing \r for CRLF + line = line.rstrip("\r") + + if line == "": + # Dispatch: apply id and retry regardless of data presence + if state["id"] is not None: + self._last_event_id = state["id"] + if state["retry"] is not None: + self._server_retry_delay = state["retry"] / 1000.0 + + if state["data"]: + # Check data size at dispatch + if len(state["data"]) > self.MAX_DATA_SIZE: + raise MedkitError( + status=0, + code="sse-data-overflow", + message=f"SSE data exceeded {self.MAX_DATA_SIZE} bytes", + ) + # Try JSON parse, fall back to raw string + try: + parsed = json.loads(state["data"]) + except (json.JSONDecodeError, ValueError): + parsed = state["data"] + + self._events_yielded = True + yield SseEvent( + event=state["event"], + data=parsed, + id=state["id"], + ) + + state = _new_state() + else: + parse_sse_line(line, state) + # Check data size after each line parse + if len(state["data"]) > self.MAX_DATA_SIZE: + raise MedkitError( + status=0, + code="sse-data-overflow", + message=f"SSE data exceeded {self.MAX_DATA_SIZE} bytes", + ) + + +def _new_state() -> dict: + """Create a fresh SSE parse state accumulator.""" + return {"event": "message", "data": "", "id": None, "retry": None} diff --git a/clients/python/tests/test_sse.py b/clients/python/tests/test_sse.py new file mode 100644 index 0000000..90ceb1a --- /dev/null +++ b/clients/python/tests/test_sse.py @@ -0,0 +1,471 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for SSE stream parsing and reconnection logic.""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from ros2_medkit_client.errors import MedkitError +from ros2_medkit_client.sse import SseEvent, SseStream, parse_sse_line + +# --------------------------------------------------------------------------- +# Helper: async byte stream for simulating chunked SSE delivery +# --------------------------------------------------------------------------- + + +class _AsyncChunkStream(httpx.AsyncByteStream): + """Yields pre-defined byte chunks, optionally raising after all chunks.""" + + def __init__(self, chunks: list[bytes], *, raise_after: BaseException | None = None) -> None: + self._chunks = chunks + self._raise_after = raise_after + + async def __aiter__(self): + for chunk in self._chunks: + yield chunk + if self._raise_after is not None: + raise self._raise_after + + +def _sse_response( + body: str | bytes | None = None, + *, + status: int = 200, + chunks: list[bytes] | None = None, + raise_after: BaseException | None = None, +) -> dict: + """Build kwargs for respx .respond() or .mock(side_effect=...).""" + headers = {"content-type": "text/event-stream"} + if chunks is not None: + return { + "status_code": status, + "stream": _AsyncChunkStream(chunks, raise_after=raise_after), + "headers": headers, + } + content = body.encode() if isinstance(body, str) else body + return {"status_code": status, "content": content, "headers": headers} + + +# =========================================================================== +# parse_sse_line unit tests +# =========================================================================== + + +class TestParseSseLine: + """Unit tests for the parse_sse_line function.""" + + def _state(self) -> dict: + return {"event": "message", "data": "", "id": None, "retry": None} + + def test_event_field(self): + state = self._state() + parse_sse_line("event: update", state) + assert state["event"] == "update" + + def test_data_field(self): + state = self._state() + parse_sse_line("data: hello", state) + assert state["data"] == "hello" + + def test_multiline_data(self): + state = self._state() + parse_sse_line("data: line1", state) + parse_sse_line("data: line2", state) + assert state["data"] == "line1\nline2" + + def test_id_field(self): + state = self._state() + parse_sse_line("id: 42", state) + assert state["id"] == "42" + + def test_retry_field_numeric(self): + state = self._state() + parse_sse_line("retry: 3000", state) + assert state["retry"] == 3000 + + def test_retry_field_non_numeric_ignored(self): + state = self._state() + parse_sse_line("retry: abc", state) + assert state["retry"] is None + + def test_comment_ignored(self): + state = self._state() + parse_sse_line(": this is a comment", state) + assert state == self._state() + + def test_unknown_field_ignored(self): + state = self._state() + parse_sse_line("foo: bar", state) + assert state == self._state() + + def test_crlf_stripping(self): + """Trailing \\r is stripped (CRLF compatibility).""" + state = self._state() + parse_sse_line("data: value\r", state) + assert state["data"] == "value" + + def test_data_without_space_after_colon(self): + """'data:value' (no space) should still work per SSE spec.""" + state = self._state() + parse_sse_line("data:nospace", state) + assert state["data"] == "nospace" + + def test_data_with_empty_value(self): + """'data:' with no value appends empty string.""" + state = self._state() + parse_sse_line("data:", state) + assert state["data"] == "" + + def test_data_with_colons_in_value(self): + """Only the first colon splits field from value.""" + state = self._state() + parse_sse_line("data: key: value: extra", state) + assert state["data"] == "key: value: extra" + + +# =========================================================================== +# SseEvent tests +# =========================================================================== + + +class TestSseEvent: + def test_defaults(self): + ev = SseEvent() + assert ev.event == "message" + assert ev.data is None + assert ev.id is None + + def test_custom_values(self): + ev = SseEvent(event="update", data={"key": "val"}, id="7") + assert ev.event == "update" + assert ev.data == {"key": "val"} + assert ev.id == "7" + + +# =========================================================================== +# SseStream integration tests +# =========================================================================== + + +class TestSseStream: + """Integration tests for the SseStream async iterator.""" + + @respx.mock + async def test_parse_events(self): + """Basic SSE events are parsed and yielded.""" + body = 'event: update\ndata: {"a": 1}\n\ndata: {"b": 2}\n\n' + respx.get("http://gw/sse").respond(**_sse_response(body)) + + stream = SseStream("http://gw/sse") + events = [ev async for ev in stream] + + assert len(events) == 2 + assert events[0].event == "update" + assert events[0].data == {"a": 1} + assert events[1].event == "message" + assert events[1].data == {"b": 2} + + @respx.mock + async def test_non_json_data(self): + """Non-JSON data is returned as a raw string.""" + body = "data: plain text\n\n" + respx.get("http://gw/sse").respond(**_sse_response(body)) + + stream = SseStream("http://gw/sse") + events = [ev async for ev in stream] + + assert len(events) == 1 + assert events[0].data == "plain text" + + @respx.mock + async def test_4xx_error_not_retried(self): + """HTTP 4xx errors raise MedkitError immediately (no retry).""" + respx.get("http://gw/sse").respond( + 404, + json={"error_code": "entity-not-found", "message": "Not found"}, + ) + + stream = SseStream("http://gw/sse", max_retries=3) + with pytest.raises(MedkitError) as exc_info: + async for _ in stream: + pass + + assert exc_info.value.status == 404 + assert exc_info.value.code == "entity-not-found" + + @respx.mock + async def test_reconnect_on_network_error(self): + """Network errors trigger reconnect with Last-Event-ID.""" + # First attempt: one event, then connection drops + chunks_1 = [b"id: 5\ndata: first\n\n"] + route = respx.get("http://gw/sse") + route.side_effect = [ + httpx.Response( + 200, + stream=_AsyncChunkStream(chunks_1, raise_after=httpx.ReadError("connection reset")), + headers={"content-type": "text/event-stream"}, + ), + httpx.Response( + 200, + content=b"data: second\n\n", + headers={"content-type": "text/event-stream"}, + ), + ] + + stream = SseStream("http://gw/sse", max_retries=3, initial_delay=0.01, max_delay=0.01) + events = [ev async for ev in stream] + + assert len(events) == 2 + assert events[0].data == "first" + assert events[0].id == "5" + assert events[1].data == "second" + + # Verify Last-Event-ID was sent on reconnect + second_request = route.calls[1].request + assert second_request.headers.get("last-event-id") == "5" + + @respx.mock + async def test_auth_failure_during_reconnect_no_retry(self): + """If reconnect gets a 401, MedkitError is raised (no further retries).""" + route = respx.get("http://gw/sse") + route.side_effect = [ + httpx.Response( + 200, + stream=_AsyncChunkStream( + [b"data: ok\n\n"], + raise_after=httpx.ReadError("connection reset"), + ), + headers={"content-type": "text/event-stream"}, + ), + httpx.Response( + 401, + json={"error_code": "unauthorized", "message": "Token expired"}, + ), + ] + + stream = SseStream("http://gw/sse", max_retries=5, initial_delay=0.01, max_delay=0.01) + with pytest.raises(MedkitError) as exc_info: + async for _ in stream: + pass + + assert exc_info.value.status == 401 + + @respx.mock + async def test_max_retries_exhaustion(self): + """After max_retries, the underlying exception propagates.""" + route = respx.get("http://gw/sse") + # All attempts fail with network error + route.side_effect = httpx.ConnectError("refused") + + stream = SseStream("http://gw/sse", max_retries=2, initial_delay=0.01, max_delay=0.01) + with pytest.raises(httpx.ConnectError): + async for _ in stream: + pass + + @respx.mock + async def test_buffer_overflow(self): + """Exceeding MAX_BUFFER_SIZE raises MedkitError.""" + huge_chunk = b"x" * (SseStream.MAX_BUFFER_SIZE + 1) + respx.get("http://gw/sse").respond(**_sse_response(chunks=[huge_chunk])) + + stream = SseStream("http://gw/sse") + with pytest.raises(MedkitError) as exc_info: + async for _ in stream: + pass + + assert exc_info.value.code == "sse-buffer-overflow" + + @respx.mock + async def test_data_overflow(self): + """Exceeding MAX_DATA_SIZE raises MedkitError after parse_sse_line.""" + # Build data lines that exceed 256KB total + big_line = "x" * (SseStream.MAX_DATA_SIZE + 1) + body = f"data: {big_line}\n\n" + respx.get("http://gw/sse").respond(**_sse_response(body)) + + stream = SseStream("http://gw/sse") + with pytest.raises(MedkitError) as exc_info: + async for _ in stream: + pass + + assert exc_info.value.code == "sse-data-overflow" + + @respx.mock + async def test_data_overflow_multiline(self): + """Data overflow detected across multiple data: lines (after each parse).""" + # Each line is under limit, but combined exceeds it + half = "y" * (SseStream.MAX_DATA_SIZE // 2 + 1) + body = f"data: {half}\ndata: {half}\n\n" + respx.get("http://gw/sse").respond(**_sse_response(body)) + + stream = SseStream("http://gw/sse") + with pytest.raises(MedkitError) as exc_info: + async for _ in stream: + pass + + assert exc_info.value.code == "sse-data-overflow" + + @respx.mock + async def test_close_stops_iteration(self): + """Calling close() terminates the stream.""" + # Use a stream that would go on forever + body = b"data: event1\n\n" + respx.get("http://gw/sse").respond(**_sse_response(body)) + + stream = SseStream("http://gw/sse") + events = [] + async for ev in stream: + events.append(ev) + stream.close() + + assert len(events) == 1 + + @respx.mock + async def test_chunked_data(self): + """SSE data split across multiple chunks is reassembled correctly.""" + chunks = [ + b"data: {\"k", + b'ey": "val"}\n', + b"\n", + ] + respx.get("http://gw/sse").respond(**_sse_response(chunks=chunks)) + + stream = SseStream("http://gw/sse") + events = [ev async for ev in stream] + + assert len(events) == 1 + assert events[0].data == {"key": "val"} + + @respx.mock + async def test_id_and_retry_applied_without_data(self): + """retry and id fields are applied on dispatch even if no data is present.""" + body = "id: 99\nretry: 5000\n\ndata: next\n\n" + respx.get("http://gw/sse").respond(**_sse_response(body)) + + stream = SseStream("http://gw/sse") + events = [ev async for ev in stream] + + # The first blank line has id/retry but no data - no event yielded but state is applied + assert len(events) == 1 + assert events[0].data == "next" + # The last_event_id should have been set by the first dispatch + assert stream._last_event_id == "99" + # Server retry delay applied (5000ms = 5.0s) + assert stream._server_retry_delay == 5.0 + + @respx.mock + async def test_crlf_line_endings(self): + """CRLF line endings are handled correctly.""" + body = b"data: hello\r\n\r\n" + respx.get("http://gw/sse").respond(**_sse_response(body)) + + stream = SseStream("http://gw/sse") + events = [ev async for ev in stream] + + assert len(events) == 1 + assert events[0].data == "hello" + + @respx.mock + async def test_custom_headers_forwarded(self): + """Custom headers (e.g., auth) are forwarded to the request.""" + body = b"data: ok\n\n" + route = respx.get("http://gw/sse").respond(**_sse_response(body)) + + stream = SseStream("http://gw/sse", headers={"Authorization": "Bearer tok123"}) + events = [ev async for ev in stream] + + assert len(events) == 1 + req = route.calls[0].request + assert req.headers["authorization"] == "Bearer tok123" + assert req.headers["accept"] == "text/event-stream" + + @respx.mock + async def test_retry_counter_reset_after_successful_events(self): + """Retry counter resets when events were received before disconnect.""" + route = respx.get("http://gw/sse") + # Attempt 1: yields an event then drops + # Attempt 2: yields an event then drops (retries reset because events received) + # Attempt 3: yields an event then drops + # Attempt 4: clean close + route.side_effect = [ + httpx.Response( + 200, + stream=_AsyncChunkStream( + [b"data: e1\n\n"], raise_after=httpx.ReadError("drop") + ), + headers={"content-type": "text/event-stream"}, + ), + httpx.Response( + 200, + stream=_AsyncChunkStream( + [b"data: e2\n\n"], raise_after=httpx.ReadError("drop") + ), + headers={"content-type": "text/event-stream"}, + ), + httpx.Response( + 200, + stream=_AsyncChunkStream( + [b"data: e3\n\n"], raise_after=httpx.ReadError("drop") + ), + headers={"content-type": "text/event-stream"}, + ), + httpx.Response( + 200, + content=b"data: e4\n\n", + headers={"content-type": "text/event-stream"}, + ), + ] + + # max_retries=1 would normally fail on 2nd drop, but reset keeps it going + stream = SseStream("http://gw/sse", max_retries=1, initial_delay=0.01, max_delay=0.01) + events = [ev async for ev in stream] + + assert [ev.data for ev in events] == ["e1", "e2", "e3", "e4"] + + @respx.mock + async def test_server_retry_delay_clamped(self): + """Server retry delay is clamped to [100ms, max_delay].""" + # Server says retry: 10 (10ms) - should be clamped to 100ms minimum + body = "retry: 10\ndata: first\n\n" + route = respx.get("http://gw/sse") + route.side_effect = [ + httpx.Response( + 200, + stream=_AsyncChunkStream( + [body.encode()], raise_after=httpx.ReadError("drop") + ), + headers={"content-type": "text/event-stream"}, + ), + httpx.Response( + 200, + content=b"data: second\n\n", + headers={"content-type": "text/event-stream"}, + ), + ] + + stream = SseStream("http://gw/sse", max_retries=3, initial_delay=0.5, max_delay=2.0) + events = [ev async for ev in stream] + + assert len(events) == 2 + # Server retry was 10ms, should be clamped to 0.1s (100ms) + assert stream._server_retry_delay == 0.01 # 10ms / 1000 + + @respx.mock + async def test_5xx_error_response_body_parsed(self): + """HTTP 5xx with GenericError JSON body is parsed into MedkitError.""" + respx.get("http://gw/sse").respond( + 500, + json={"error_code": "internal-error", "message": "Server crashed"}, + ) + + stream = SseStream("http://gw/sse", max_retries=0) + with pytest.raises(MedkitError) as exc_info: + async for _ in stream: + pass + + assert exc_info.value.status == 500 + assert exc_info.value.code == "internal-error" From e2ae6a23efd6cf21f3a0afcd732e369ac61298a8 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 22 Mar 2026 20:20:04 +0100 Subject: [PATCH 05/14] feat(py): add SSE stream helpers for faults, triggers, subscriptions 3 helpers cover 8 SSE endpoints. Entity IDs URL-encoded via urllib.parse.quote. Auth headers forwarded to SSE connections. --- .../python/src/ros2_medkit_client/streams.py | 64 ++++++-- clients/python/tests/test_streams.py | 140 ++++++++++++++++++ 2 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 clients/python/tests/test_streams.py diff --git a/clients/python/src/ros2_medkit_client/streams.py b/clients/python/src/ros2_medkit_client/streams.py index 7385fd2..b443136 100644 --- a/clients/python/src/ros2_medkit_client/streams.py +++ b/clients/python/src/ros2_medkit_client/streams.py @@ -1,23 +1,69 @@ # Copyright 2026 bburda # SPDX-License-Identifier: Apache-2.0 -"""SSE stream helpers - stub, replaced in Task 5.""" +"""High-level SSE stream helpers for ros2_medkit gateway endpoints.""" from __future__ import annotations +from typing import Literal +from urllib.parse import quote + +from ros2_medkit_client.sse import SseStream + +EntityType = Literal["apps", "areas", "components", "functions"] +SubscriptionEntityType = Literal["apps", "components", "functions"] + class StreamHelpers: - """Placeholder for SSE stream helpers.""" + """SSE stream helpers bound to a gateway base URL. - def __init__(self, *, base_url: str, headers: dict[str, str]) -> None: + 3 methods cover 8 SSE endpoints: + - faults() -> GET /faults/stream + - trigger_events() -> GET /{entity_type}/{id}/triggers/{trigger_id}/events + - subscription_events() -> GET /{entity_type}/{id}/cyclic-subscriptions/{sub_id}/events + """ + + def __init__( + self, + *, + base_url: str, + headers: dict[str, str], + max_retries: int = 5, + initial_delay: float = 1.0, + max_delay: float = 30.0, + ) -> None: self._base_url = base_url self._headers = headers + self._max_retries = max_retries + self._initial_delay = initial_delay + self._max_delay = max_delay + + def _make_stream(self, path: str) -> SseStream: + return SseStream( + f"{self._base_url}{path}", + headers=self._headers, + max_retries=self._max_retries, + initial_delay=self._initial_delay, + max_delay=self._max_delay, + ) - def faults(self): - raise NotImplementedError + def faults(self) -> SseStream: + """Stream real-time fault events from GET /faults/stream.""" + return self._make_stream("/faults/stream") - def trigger_events(self, entity_type: str, entity_id: str, trigger_id: str): - raise NotImplementedError + def trigger_events(self, entity_type: EntityType, entity_id: str, trigger_id: str) -> SseStream: + """Stream trigger events. GET /{entity_type}/{entity_id}/triggers/{trigger_id}/events""" + return self._make_stream( + f"/{entity_type}/{quote(entity_id, safe='')}/triggers/{quote(trigger_id, safe='')}/events" + ) - def subscription_events(self, entity_type: str, entity_id: str, subscription_id: str): - raise NotImplementedError + def subscription_events( + self, entity_type: SubscriptionEntityType, entity_id: str, subscription_id: str + ) -> SseStream: + """Stream subscription events. GET /{entity_type}/{entity_id}/cyclic-subscriptions/{sub_id}/events + Note: areas do not support cyclic subscriptions. + """ + return self._make_stream( + f"/{entity_type}/{quote(entity_id, safe='')}" + f"/cyclic-subscriptions/{quote(subscription_id, safe='')}/events" + ) diff --git a/clients/python/tests/test_streams.py b/clients/python/tests/test_streams.py new file mode 100644 index 0000000..91c9082 --- /dev/null +++ b/clients/python/tests/test_streams.py @@ -0,0 +1,140 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for StreamHelpers SSE stream factory methods.""" + +from __future__ import annotations + +import pytest + +from ros2_medkit_client.sse import SseStream +from ros2_medkit_client.streams import StreamHelpers + +BASE_URL = "http://localhost:8080/api/v1" +AUTH_HEADERS = {"Authorization": "Bearer tok123"} + + +def _make_helpers(headers: dict[str, str] | None = None) -> StreamHelpers: + return StreamHelpers( + base_url=BASE_URL, + headers=headers or {}, + ) + + +class TestFaults: + def test_faults_constructs_correct_url(self): + helpers = _make_helpers() + stream = helpers.faults() + assert isinstance(stream, SseStream) + assert stream._url == f"{BASE_URL}/faults/stream" + + def test_faults_forwards_auth_headers(self): + helpers = _make_helpers(headers=AUTH_HEADERS) + stream = helpers.faults() + assert stream._headers == AUTH_HEADERS + + +class TestTriggerEvents: + @pytest.mark.parametrize("entity_type", ["apps", "areas", "components", "functions"]) + def test_trigger_events_entity_types(self, entity_type: str): + helpers = _make_helpers() + stream = helpers.trigger_events(entity_type, "my_entity", "trig_1") + assert isinstance(stream, SseStream) + assert stream._url == f"{BASE_URL}/{entity_type}/my_entity/triggers/trig_1/events" + + def test_trigger_events_url_encodes_entity_id(self): + helpers = _make_helpers() + stream = helpers.trigger_events("apps", "my entity/id", "trig_1") + assert stream._url == f"{BASE_URL}/apps/my%20entity%2Fid/triggers/trig_1/events" + + def test_trigger_events_url_encodes_trigger_id(self): + helpers = _make_helpers() + stream = helpers.trigger_events("apps", "entity", "trig id/1") + assert stream._url == f"{BASE_URL}/apps/entity/triggers/trig%20id%2F1/events" + + def test_trigger_events_url_encodes_special_chars(self): + helpers = _make_helpers() + stream = helpers.trigger_events("components", "comp#1", "trig?x=1") + assert stream._url == f"{BASE_URL}/components/comp%231/triggers/trig%3Fx%3D1/events" + + def test_trigger_events_forwards_auth_headers(self): + helpers = _make_helpers(headers=AUTH_HEADERS) + stream = helpers.trigger_events("apps", "entity", "trig_1") + assert stream._headers == AUTH_HEADERS + + +class TestSubscriptionEvents: + @pytest.mark.parametrize("entity_type", ["apps", "components", "functions"]) + def test_subscription_events_entity_types(self, entity_type: str): + helpers = _make_helpers() + stream = helpers.subscription_events(entity_type, "my_entity", "sub_1") + assert isinstance(stream, SseStream) + assert stream._url == f"{BASE_URL}/{entity_type}/my_entity/cyclic-subscriptions/sub_1/events" + + def test_subscription_events_url_encodes_entity_id(self): + helpers = _make_helpers() + stream = helpers.subscription_events("apps", "my entity/id", "sub_1") + assert stream._url == f"{BASE_URL}/apps/my%20entity%2Fid/cyclic-subscriptions/sub_1/events" + + def test_subscription_events_url_encodes_subscription_id(self): + helpers = _make_helpers() + stream = helpers.subscription_events("apps", "entity", "sub id/1") + assert stream._url == f"{BASE_URL}/apps/entity/cyclic-subscriptions/sub%20id%2F1/events" + + def test_subscription_events_url_encodes_special_chars(self): + helpers = _make_helpers() + stream = helpers.subscription_events("functions", "func#1", "sub?x=1") + assert stream._url == f"{BASE_URL}/functions/func%231/cyclic-subscriptions/sub%3Fx%3D1/events" + + def test_subscription_events_forwards_auth_headers(self): + helpers = _make_helpers(headers=AUTH_HEADERS) + stream = helpers.subscription_events("apps", "entity", "sub_1") + assert stream._headers == AUTH_HEADERS + + +class TestStreamHelperParams: + def test_default_retry_params_passed_to_stream(self): + helpers = StreamHelpers(base_url=BASE_URL, headers={}) + stream = helpers.faults() + assert stream._max_retries == 5 + assert stream._initial_delay == 1.0 + assert stream._max_delay == 30.0 + + def test_custom_retry_params_passed_to_stream(self): + helpers = StreamHelpers( + base_url=BASE_URL, + headers={}, + max_retries=10, + initial_delay=0.5, + max_delay=60.0, + ) + stream = helpers.faults() + assert stream._max_retries == 10 + assert stream._initial_delay == 0.5 + assert stream._max_delay == 60.0 + + def test_custom_retry_params_passed_to_trigger_stream(self): + helpers = StreamHelpers( + base_url=BASE_URL, + headers={}, + max_retries=3, + initial_delay=2.0, + max_delay=15.0, + ) + stream = helpers.trigger_events("apps", "entity", "trig_1") + assert stream._max_retries == 3 + assert stream._initial_delay == 2.0 + assert stream._max_delay == 15.0 + + def test_custom_retry_params_passed_to_subscription_stream(self): + helpers = StreamHelpers( + base_url=BASE_URL, + headers={}, + max_retries=7, + initial_delay=0.1, + max_delay=20.0, + ) + stream = helpers.subscription_events("components", "entity", "sub_1") + assert stream._max_retries == 7 + assert stream._initial_delay == 0.1 + assert stream._max_delay == 20.0 From 178abd1c984acf92f8c02df9c7df91cc763d7980 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 22 Mar 2026 20:32:30 +0100 Subject: [PATCH 06/14] feat(py): add public API and generated code re-exports Public API exports MedkitClient, MedkitError, SseStream, SseEvent. API re-export modules with explicit submodule imports for all 14 API groups. Add python-dateutil dependency required by generated models. --- clients/python/pyproject.toml | 1 + .../python/src/ros2_medkit_client/__init__.py | 28 +++++++- .../src/ros2_medkit_client/api/__init__.py | 9 +++ .../ros2_medkit_client/api/authentication.py | 15 +++++ .../src/ros2_medkit_client/api/bulk_data.py | 53 +++++++++++++++ .../ros2_medkit_client/api/configuration.py | 49 ++++++++++++++ .../python/src/ros2_medkit_client/api/data.py | 49 ++++++++++++++ .../src/ros2_medkit_client/api/discovery.py | 43 ++++++++++++ .../src/ros2_medkit_client/api/faults.py | 47 ++++++++++++++ .../src/ros2_medkit_client/api/locking.py | 29 +++++++++ .../python/src/ros2_medkit_client/api/logs.py | 33 ++++++++++ .../src/ros2_medkit_client/api/operations.py | 65 +++++++++++++++++++ .../src/ros2_medkit_client/api/scripts.py | 41 ++++++++++++ .../src/ros2_medkit_client/api/server.py | 15 +++++ .../ros2_medkit_client/api/subscriptions.py | 45 +++++++++++++ .../src/ros2_medkit_client/api/triggers.py | 57 ++++++++++++++++ .../src/ros2_medkit_client/api/updates.py | 25 +++++++ .../python/src/ros2_medkit_client/streams.py | 3 +- clients/python/tests/test_sse.py | 18 ++--- 19 files changed, 609 insertions(+), 16 deletions(-) create mode 100644 clients/python/src/ros2_medkit_client/api/__init__.py create mode 100644 clients/python/src/ros2_medkit_client/api/authentication.py create mode 100644 clients/python/src/ros2_medkit_client/api/bulk_data.py create mode 100644 clients/python/src/ros2_medkit_client/api/configuration.py create mode 100644 clients/python/src/ros2_medkit_client/api/data.py create mode 100644 clients/python/src/ros2_medkit_client/api/discovery.py create mode 100644 clients/python/src/ros2_medkit_client/api/faults.py create mode 100644 clients/python/src/ros2_medkit_client/api/locking.py create mode 100644 clients/python/src/ros2_medkit_client/api/logs.py create mode 100644 clients/python/src/ros2_medkit_client/api/operations.py create mode 100644 clients/python/src/ros2_medkit_client/api/scripts.py create mode 100644 clients/python/src/ros2_medkit_client/api/server.py create mode 100644 clients/python/src/ros2_medkit_client/api/subscriptions.py create mode 100644 clients/python/src/ros2_medkit_client/api/triggers.py create mode 100644 clients/python/src/ros2_medkit_client/api/updates.py diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index 5da3f0b..3c64d49 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -13,6 +13,7 @@ keywords = ["ros2", "sovd", "medkit", "diagnostics", "openapi", "client"] dependencies = [ "httpx>=0.27", "attrs>=23.0", + "python-dateutil>=2.9", ] [project.optional-dependencies] diff --git a/clients/python/src/ros2_medkit_client/__init__.py b/clients/python/src/ros2_medkit_client/__init__.py index 04b6c3d..9ec2750 100644 --- a/clients/python/src/ros2_medkit_client/__init__.py +++ b/clients/python/src/ros2_medkit_client/__init__.py @@ -1,4 +1,30 @@ # Copyright 2026 bburda # SPDX-License-Identifier: Apache-2.0 -"""Async Python client for the ros2_medkit gateway.""" +"""Async Python client for the ros2_medkit gateway. + +Usage:: + + from ros2_medkit_client import MedkitClient, MedkitError + from ros2_medkit_client.api.discovery import list_apps + + async with MedkitClient(base_url="localhost:8080") as client: + result = await list_apps.asyncio(client=client.http) + + async for event in client.streams.faults(): + print(event.data) +""" + +from ros2_medkit_client.client import MedkitClient, normalize_base_url +from ros2_medkit_client.errors import MedkitConnectionError, MedkitError, MedkitTimeoutError +from ros2_medkit_client.sse import SseEvent, SseStream + +__all__ = [ + "MedkitClient", + "MedkitError", + "MedkitConnectionError", + "MedkitTimeoutError", + "SseStream", + "SseEvent", + "normalize_base_url", +] diff --git a/clients/python/src/ros2_medkit_client/api/__init__.py b/clients/python/src/ros2_medkit_client/api/__init__.py new file mode 100644 index 0000000..323403b --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/__init__.py @@ -0,0 +1,9 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Re-exported API modules from generated code. + +Import individual modules to call generated endpoint functions:: + + from ros2_medkit_client.api.discovery import list_apps + result = await list_apps.asyncio(client=client.http) +""" diff --git a/clients/python/src/ros2_medkit_client/api/authentication.py b/clients/python/src/ros2_medkit_client/api/authentication.py new file mode 100644 index 0000000..aacb5aa --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/authentication.py @@ -0,0 +1,15 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Authentication API - authorization, token management.""" + +from ros2_medkit_client._generated.api.authentication import ( + authorize, + get_token, + revoke_token, +) + +__all__ = [ + "authorize", + "get_token", + "revoke_token", +] diff --git a/clients/python/src/ros2_medkit_client/api/bulk_data.py b/clients/python/src/ros2_medkit_client/api/bulk_data.py new file mode 100644 index 0000000..3b1a388 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/bulk_data.py @@ -0,0 +1,53 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Bulk Data API - bulk data upload, download, and management.""" + +from ros2_medkit_client._generated.api.bulk_data import ( + delete_app_bulk_data, + delete_component_bulk_data, + download_app_bulk_data, + download_area_bulk_data, + download_component_bulk_data, + download_function_bulk_data, + download_subarea_bulk_data, + download_subcomponent_bulk_data, + list_app_bulk_data_categories, + list_app_bulk_data_descriptors, + list_area_bulk_data_categories, + list_area_bulk_data_descriptors, + list_component_bulk_data_categories, + list_component_bulk_data_descriptors, + list_function_bulk_data_categories, + list_function_bulk_data_descriptors, + list_subarea_bulk_data_categories, + list_subarea_bulk_data_descriptors, + list_subcomponent_bulk_data_categories, + list_subcomponent_bulk_data_descriptors, + upload_app_bulk_data, + upload_component_bulk_data, +) + +__all__ = [ + "delete_app_bulk_data", + "delete_component_bulk_data", + "download_app_bulk_data", + "download_area_bulk_data", + "download_component_bulk_data", + "download_function_bulk_data", + "download_subarea_bulk_data", + "download_subcomponent_bulk_data", + "list_app_bulk_data_categories", + "list_app_bulk_data_descriptors", + "list_area_bulk_data_categories", + "list_area_bulk_data_descriptors", + "list_component_bulk_data_categories", + "list_component_bulk_data_descriptors", + "list_function_bulk_data_categories", + "list_function_bulk_data_descriptors", + "list_subarea_bulk_data_categories", + "list_subarea_bulk_data_descriptors", + "list_subcomponent_bulk_data_categories", + "list_subcomponent_bulk_data_descriptors", + "upload_app_bulk_data", + "upload_component_bulk_data", +] diff --git a/clients/python/src/ros2_medkit_client/api/configuration.py b/clients/python/src/ros2_medkit_client/api/configuration.py new file mode 100644 index 0000000..26b5c07 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/configuration.py @@ -0,0 +1,49 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Configuration API - read and write entity configurations.""" + +from ros2_medkit_client._generated.api.configuration import ( + delete_all_app_configurations, + delete_all_area_configurations, + delete_all_component_configurations, + delete_all_function_configurations, + delete_app_configuration, + delete_area_configuration, + delete_component_configuration, + delete_function_configuration, + get_app_configuration, + get_area_configuration, + get_component_configuration, + get_function_configuration, + list_app_configurations, + list_area_configurations, + list_component_configurations, + list_function_configurations, + set_app_configuration, + set_area_configuration, + set_component_configuration, + set_function_configuration, +) + +__all__ = [ + "delete_all_app_configurations", + "delete_all_area_configurations", + "delete_all_component_configurations", + "delete_all_function_configurations", + "delete_app_configuration", + "delete_area_configuration", + "delete_component_configuration", + "delete_function_configuration", + "get_app_configuration", + "get_area_configuration", + "get_component_configuration", + "get_function_configuration", + "list_app_configurations", + "list_area_configurations", + "list_component_configurations", + "list_function_configurations", + "set_app_configuration", + "set_area_configuration", + "set_component_configuration", + "set_function_configuration", +] diff --git a/clients/python/src/ros2_medkit_client/api/data.py b/clients/python/src/ros2_medkit_client/api/data.py new file mode 100644 index 0000000..a3f6c14 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/data.py @@ -0,0 +1,49 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Data API - read and write entity data items.""" + +from ros2_medkit_client._generated.api.data import ( + get_app_data_item, + get_area_data_item, + get_component_data_item, + get_function_data_item, + list_app_data, + list_app_data_categories, + list_app_data_groups, + list_area_data, + list_area_data_categories, + list_area_data_groups, + list_component_data, + list_component_data_categories, + list_component_data_groups, + list_function_data, + list_function_data_categories, + list_function_data_groups, + put_app_data_item, + put_area_data_item, + put_component_data_item, + put_function_data_item, +) + +__all__ = [ + "get_app_data_item", + "get_area_data_item", + "get_component_data_item", + "get_function_data_item", + "list_app_data", + "list_app_data_categories", + "list_app_data_groups", + "list_area_data", + "list_area_data_categories", + "list_area_data_groups", + "list_component_data", + "list_component_data_categories", + "list_component_data_groups", + "list_function_data", + "list_function_data_categories", + "list_function_data_groups", + "put_app_data_item", + "put_area_data_item", + "put_component_data_item", + "put_function_data_item", +] diff --git a/clients/python/src/ros2_medkit_client/api/discovery.py b/clients/python/src/ros2_medkit_client/api/discovery.py new file mode 100644 index 0000000..8bd6e86 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/discovery.py @@ -0,0 +1,43 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Discovery API - entity listing and details.""" + +from ros2_medkit_client._generated.api.discovery import ( + get_app, + get_app_host, + get_area, + get_component, + get_function, + list_app_dependencies, + list_apps, + list_area_components, + list_area_contains, + list_areas, + list_component_dependencies, + list_component_hosts, + list_components, + list_function_hosts, + list_functions, + list_subareas, + list_subcomponents, +) + +__all__ = [ + "get_app", + "get_app_host", + "get_area", + "get_component", + "get_function", + "list_app_dependencies", + "list_apps", + "list_area_components", + "list_area_contains", + "list_areas", + "list_component_dependencies", + "list_component_hosts", + "list_components", + "list_function_hosts", + "list_functions", + "list_subareas", + "list_subcomponents", +] diff --git a/clients/python/src/ros2_medkit_client/api/faults.py b/clients/python/src/ros2_medkit_client/api/faults.py new file mode 100644 index 0000000..5fda3c8 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/faults.py @@ -0,0 +1,47 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Faults API - fault listing, clearing, and streaming.""" + +from ros2_medkit_client._generated.api.faults import ( + clear_all_app_faults, + clear_all_area_faults, + clear_all_component_faults, + clear_all_faults, + clear_all_function_faults, + clear_app_fault, + clear_area_fault, + clear_component_fault, + clear_function_fault, + get_app_fault, + get_area_fault, + get_component_fault, + get_function_fault, + list_all_faults, + list_app_faults, + list_area_faults, + list_component_faults, + list_function_faults, + stream_faults, +) + +__all__ = [ + "clear_all_app_faults", + "clear_all_area_faults", + "clear_all_component_faults", + "clear_all_faults", + "clear_all_function_faults", + "clear_app_fault", + "clear_area_fault", + "clear_component_fault", + "clear_function_fault", + "get_app_fault", + "get_area_fault", + "get_component_fault", + "get_function_fault", + "list_all_faults", + "list_app_faults", + "list_area_faults", + "list_component_faults", + "list_function_faults", + "stream_faults", +] diff --git a/clients/python/src/ros2_medkit_client/api/locking.py b/clients/python/src/ros2_medkit_client/api/locking.py new file mode 100644 index 0000000..3552a52 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/locking.py @@ -0,0 +1,29 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Locking API - acquire, extend, and release entity locks.""" + +from ros2_medkit_client._generated.api.locking import ( + acquire_app_lock, + acquire_component_lock, + extend_app_lock, + extend_component_lock, + get_app_lock, + get_component_lock, + list_app_locks, + list_component_locks, + release_app_lock, + release_component_lock, +) + +__all__ = [ + "acquire_app_lock", + "acquire_component_lock", + "extend_app_lock", + "extend_component_lock", + "get_app_lock", + "get_component_lock", + "list_app_locks", + "list_component_locks", + "release_app_lock", + "release_component_lock", +] diff --git a/clients/python/src/ros2_medkit_client/api/logs.py b/clients/python/src/ros2_medkit_client/api/logs.py new file mode 100644 index 0000000..59601a1 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/logs.py @@ -0,0 +1,33 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Logs API - log listing and log configuration.""" + +from ros2_medkit_client._generated.api.logs import ( + get_app_log_configuration, + get_area_log_configuration, + get_component_log_configuration, + get_function_log_configuration, + list_app_logs, + list_area_logs, + list_component_logs, + list_function_logs, + set_app_log_configuration, + set_area_log_configuration, + set_component_log_configuration, + set_function_log_configuration, +) + +__all__ = [ + "get_app_log_configuration", + "get_area_log_configuration", + "get_component_log_configuration", + "get_function_log_configuration", + "list_app_logs", + "list_area_logs", + "list_component_logs", + "list_function_logs", + "set_app_log_configuration", + "set_area_log_configuration", + "set_component_log_configuration", + "set_function_log_configuration", +] diff --git a/clients/python/src/ros2_medkit_client/api/operations.py b/clients/python/src/ros2_medkit_client/api/operations.py new file mode 100644 index 0000000..95c5122 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/operations.py @@ -0,0 +1,65 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Operations API - execute, monitor, and cancel entity operations.""" + +from ros2_medkit_client._generated.api.operations import ( + cancel_app_execution, + cancel_area_execution, + cancel_component_execution, + cancel_function_execution, + execute_app_operation, + execute_area_operation, + execute_component_operation, + execute_function_operation, + get_app_execution, + get_app_operation, + get_area_execution, + get_area_operation, + get_component_execution, + get_component_operation, + get_function_execution, + get_function_operation, + list_app_executions, + list_app_operations, + list_area_executions, + list_area_operations, + list_component_executions, + list_component_operations, + list_function_executions, + list_function_operations, + update_app_execution, + update_area_execution, + update_component_execution, + update_function_execution, +) + +__all__ = [ + "cancel_app_execution", + "cancel_area_execution", + "cancel_component_execution", + "cancel_function_execution", + "execute_app_operation", + "execute_area_operation", + "execute_component_operation", + "execute_function_operation", + "get_app_execution", + "get_app_operation", + "get_area_execution", + "get_area_operation", + "get_component_execution", + "get_component_operation", + "get_function_execution", + "get_function_operation", + "list_app_executions", + "list_app_operations", + "list_area_executions", + "list_area_operations", + "list_component_executions", + "list_component_operations", + "list_function_executions", + "list_function_operations", + "update_app_execution", + "update_area_execution", + "update_component_execution", + "update_function_execution", +] diff --git a/clients/python/src/ros2_medkit_client/api/scripts.py b/clients/python/src/ros2_medkit_client/api/scripts.py new file mode 100644 index 0000000..762f589 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/scripts.py @@ -0,0 +1,41 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Scripts API - upload, execute, and manage entity scripts.""" + +from ros2_medkit_client._generated.api.scripts import ( + control_app_script_execution, + control_component_script_execution, + delete_app_script, + delete_component_script, + get_app_script, + get_app_script_execution, + get_component_script, + get_component_script_execution, + list_app_scripts, + list_component_scripts, + remove_app_script_execution, + remove_component_script_execution, + start_app_script_execution, + start_component_script_execution, + upload_app_script, + upload_component_script, +) + +__all__ = [ + "control_app_script_execution", + "control_component_script_execution", + "delete_app_script", + "delete_component_script", + "get_app_script", + "get_app_script_execution", + "get_component_script", + "get_component_script_execution", + "list_app_scripts", + "list_component_scripts", + "remove_app_script_execution", + "remove_component_script_execution", + "start_app_script_execution", + "start_component_script_execution", + "upload_app_script", + "upload_component_script", +] diff --git a/clients/python/src/ros2_medkit_client/api/server.py b/clients/python/src/ros2_medkit_client/api/server.py new file mode 100644 index 0000000..b5bc917 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/server.py @@ -0,0 +1,15 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Server API - health, version, and root endpoint.""" + +from ros2_medkit_client._generated.api.server import ( + get_health, + get_root, + get_version_info, +) + +__all__ = [ + "get_health", + "get_root", + "get_version_info", +] diff --git a/clients/python/src/ros2_medkit_client/api/subscriptions.py b/clients/python/src/ros2_medkit_client/api/subscriptions.py new file mode 100644 index 0000000..3af15e7 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/subscriptions.py @@ -0,0 +1,45 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Subscriptions API - cyclic subscription management and event streaming.""" + +from ros2_medkit_client._generated.api.subscriptions import ( + create_app_subscription, + create_component_subscription, + create_function_subscription, + delete_app_subscription, + delete_component_subscription, + delete_function_subscription, + get_app_subscription, + get_component_subscription, + get_function_subscription, + list_app_subscriptions, + list_component_subscriptions, + list_function_subscriptions, + stream_app_subscription_events, + stream_component_subscription_events, + stream_function_subscription_events, + update_app_subscription, + update_component_subscription, + update_function_subscription, +) + +__all__ = [ + "create_app_subscription", + "create_component_subscription", + "create_function_subscription", + "delete_app_subscription", + "delete_component_subscription", + "delete_function_subscription", + "get_app_subscription", + "get_component_subscription", + "get_function_subscription", + "list_app_subscriptions", + "list_component_subscriptions", + "list_function_subscriptions", + "stream_app_subscription_events", + "stream_component_subscription_events", + "stream_function_subscription_events", + "update_app_subscription", + "update_component_subscription", + "update_function_subscription", +] diff --git a/clients/python/src/ros2_medkit_client/api/triggers.py b/clients/python/src/ros2_medkit_client/api/triggers.py new file mode 100644 index 0000000..95d1ab4 --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/triggers.py @@ -0,0 +1,57 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Triggers API - trigger management and event streaming.""" + +from ros2_medkit_client._generated.api.triggers import ( + create_app_trigger, + create_area_trigger, + create_component_trigger, + create_function_trigger, + delete_app_trigger, + delete_area_trigger, + delete_component_trigger, + delete_function_trigger, + get_app_trigger, + get_area_trigger, + get_component_trigger, + get_function_trigger, + list_app_triggers, + list_area_triggers, + list_component_triggers, + list_function_triggers, + stream_app_trigger_events, + stream_area_trigger_events, + stream_component_trigger_events, + stream_function_trigger_events, + update_app_trigger, + update_area_trigger, + update_component_trigger, + update_function_trigger, +) + +__all__ = [ + "create_app_trigger", + "create_area_trigger", + "create_component_trigger", + "create_function_trigger", + "delete_app_trigger", + "delete_area_trigger", + "delete_component_trigger", + "delete_function_trigger", + "get_app_trigger", + "get_area_trigger", + "get_component_trigger", + "get_function_trigger", + "list_app_triggers", + "list_area_triggers", + "list_component_triggers", + "list_function_triggers", + "stream_app_trigger_events", + "stream_area_trigger_events", + "stream_component_trigger_events", + "stream_function_trigger_events", + "update_app_trigger", + "update_area_trigger", + "update_component_trigger", + "update_function_trigger", +] diff --git a/clients/python/src/ros2_medkit_client/api/updates.py b/clients/python/src/ros2_medkit_client/api/updates.py new file mode 100644 index 0000000..1d415cf --- /dev/null +++ b/clients/python/src/ros2_medkit_client/api/updates.py @@ -0,0 +1,25 @@ +# Copyright 2026 bburda +# SPDX-License-Identifier: Apache-2.0 +"""Updates API - software update management.""" + +from ros2_medkit_client._generated.api.updates import ( + automate_update, + delete_update, + execute_update, + get_update, + get_update_status, + list_updates, + prepare_update, + register_update, +) + +__all__ = [ + "automate_update", + "delete_update", + "execute_update", + "get_update", + "get_update_status", + "list_updates", + "prepare_update", + "register_update", +] diff --git a/clients/python/src/ros2_medkit_client/streams.py b/clients/python/src/ros2_medkit_client/streams.py index b443136..2ccb75b 100644 --- a/clients/python/src/ros2_medkit_client/streams.py +++ b/clients/python/src/ros2_medkit_client/streams.py @@ -64,6 +64,5 @@ def subscription_events( Note: areas do not support cyclic subscriptions. """ return self._make_stream( - f"/{entity_type}/{quote(entity_id, safe='')}" - f"/cyclic-subscriptions/{quote(subscription_id, safe='')}/events" + f"/{entity_type}/{quote(entity_id, safe='')}/cyclic-subscriptions/{quote(subscription_id, safe='')}/events" ) diff --git a/clients/python/tests/test_sse.py b/clients/python/tests/test_sse.py index 90ceb1a..6e6e01d 100644 --- a/clients/python/tests/test_sse.py +++ b/clients/python/tests/test_sse.py @@ -328,7 +328,7 @@ async def test_close_stops_iteration(self): async def test_chunked_data(self): """SSE data split across multiple chunks is reassembled correctly.""" chunks = [ - b"data: {\"k", + b'data: {"k', b'ey": "val"}\n', b"\n", ] @@ -394,23 +394,17 @@ async def test_retry_counter_reset_after_successful_events(self): route.side_effect = [ httpx.Response( 200, - stream=_AsyncChunkStream( - [b"data: e1\n\n"], raise_after=httpx.ReadError("drop") - ), + stream=_AsyncChunkStream([b"data: e1\n\n"], raise_after=httpx.ReadError("drop")), headers={"content-type": "text/event-stream"}, ), httpx.Response( 200, - stream=_AsyncChunkStream( - [b"data: e2\n\n"], raise_after=httpx.ReadError("drop") - ), + stream=_AsyncChunkStream([b"data: e2\n\n"], raise_after=httpx.ReadError("drop")), headers={"content-type": "text/event-stream"}, ), httpx.Response( 200, - stream=_AsyncChunkStream( - [b"data: e3\n\n"], raise_after=httpx.ReadError("drop") - ), + stream=_AsyncChunkStream([b"data: e3\n\n"], raise_after=httpx.ReadError("drop")), headers={"content-type": "text/event-stream"}, ), httpx.Response( @@ -435,9 +429,7 @@ async def test_server_retry_delay_clamped(self): route.side_effect = [ httpx.Response( 200, - stream=_AsyncChunkStream( - [body.encode()], raise_after=httpx.ReadError("drop") - ), + stream=_AsyncChunkStream([body.encode()], raise_after=httpx.ReadError("drop")), headers={"content-type": "text/event-stream"}, ), httpx.Response( From b7dad0a59610ff3cd6fb7c85227bd4d1ce9e0ea0 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 22 Mar 2026 20:33:45 +0100 Subject: [PATCH 07/14] ci: add Python client CI workflow Build, test, lint on PR. Publish to GitHub Packages on main push. --- .github/workflows/python-ci.yml | 96 +++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/workflows/python-ci.yml diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..0cc04e9 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,96 @@ +name: Python Client CI + +on: + push: + branches: [main] + paths: + - 'clients/python/**' + - 'spec/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: [main] + paths: + - 'clients/python/**' + - 'spec/**' + - '.github/workflows/python-ci.yml' + +jobs: + build-and-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: clients/python + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pipx + run: pip install pipx + + - name: Generate client from spec + working-directory: . + run: ./scripts/generate.sh + + - name: Set up generated symlink + run: ln -sf ../../generated src/ros2_medkit_client/_generated + + - name: Install package with dev deps + run: pip install -e '.[dev]' + + - name: Test + run: python -m pytest -v + + - name: Lint + run: ruff check src/ tests/ + + - name: Format check + run: ruff format --check src/ tests/ + + publish: + needs: build-and-test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + defaults: + run: + working-directory: clients/python + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install build tools + run: pip install pipx build twine + + - name: Generate client from spec + working-directory: . + run: ./scripts/generate.sh + + - name: Set up generated symlink + run: ln -sf ../../generated src/ros2_medkit_client/_generated + + - name: Build wheel + run: python -m build + + - name: Publish to GitHub Packages + run: twine upload --repository-url https://pypi.pkg.github.com/selfpatch/ dist/* || echo "Version may already be published" + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.GITHUB_TOKEN }} From a595ef7313c9f15466d4e653d903d4e2282c1918 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 22 Mar 2026 20:34:21 +0100 Subject: [PATCH 08/14] docs: add Python client usage examples to README --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 1e40caf..1fad082 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,40 @@ const authedClient = createMedkitClient({ See `clients/typescript/` for the full source and API. +## Python Client + +### Setup + +```bash +pip install ros2-medkit-client --index-url https://pypi.pkg.github.com/selfpatch/simple/ +``` + +### Usage + +```python +from ros2_medkit_client import MedkitClient, MedkitError +from ros2_medkit_client.api.discovery import list_apps + +async with MedkitClient(base_url="localhost:8080") as client: + # Option 1: call() bridges errors into MedkitError exceptions + apps = await client.call(list_apps.asyncio) + + # Option 2: raw generated API (returns SuccessType | GenericError | None) + result = await list_apps.asyncio(client=client.http) + + # SSE streaming + async for event in client.streams.faults(): + print(event.data) + + # Error handling with call() + try: + apps = await client.call(list_apps.asyncio) + except MedkitError as e: + print(e.code, e.message) +``` + +See `clients/python/` for the full source and API. + ## License Apache 2.0 - see [LICENSE](LICENSE). From a3cfe6c14ce185401f8ae00a8f00675ca6b59d12 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 22 Mar 2026 21:06:47 +0100 Subject: [PATCH 09/14] fix(py): narrow SSE exception handling, wrap into MedkitError subtypes - Catch only httpx transport errors and OSError (not all Exception) - Wrap into MedkitConnectionError/MedkitTimeoutError on max retries - Set SSE httpx timeout: connect=10s, read=None (long-lived streams) - Add tests for timeout wrapping, close during sleep, non-JSON errors --- clients/python/src/ros2_medkit_client/sse.py | 27 ++++++++-- clients/python/tests/test_sse.py | 55 ++++++++++++++++++-- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/clients/python/src/ros2_medkit_client/sse.py b/clients/python/src/ros2_medkit_client/sse.py index 9fad0d3..2fc473d 100644 --- a/clients/python/src/ros2_medkit_client/sse.py +++ b/clients/python/src/ros2_medkit_client/sse.py @@ -12,7 +12,7 @@ import httpx -from ros2_medkit_client.errors import MedkitError +from ros2_medkit_client.errors import MedkitConnectionError, MedkitError, MedkitTimeoutError @dataclass @@ -136,7 +136,14 @@ async def _iterate(self) -> AsyncIterator[SseEvent]: return # Clean server close except MedkitError: raise # HTTP errors are not retried - except Exception: + except ( + httpx.ConnectError, + httpx.RemoteProtocolError, + httpx.ReadError, + httpx.CloseError, + OSError, + httpx.TimeoutException, + ) as exc: if self._closed: return # Reset retries if we received events before disconnection @@ -145,7 +152,17 @@ async def _iterate(self) -> AsyncIterator[SseEvent]: self._events_yielded = False retries += 1 if retries > self._max_retries: - raise + if isinstance(exc, httpx.TimeoutException): + raise MedkitTimeoutError( + status=0, + code="timeout", + message=str(exc), + ) from exc + raise MedkitConnectionError( + status=0, + code="connection-error", + message=str(exc), + ) from exc # Calculate delay if self._server_retry_delay is not None: delay = max(0.1, min(self._server_retry_delay, self._max_delay)) @@ -171,7 +188,9 @@ async def _connect_and_stream(self) -> AsyncIterator[SseEvent]: if self._last_event_id is not None: headers["Last-Event-ID"] = self._last_event_id - async with httpx.AsyncClient() as http: + async with httpx.AsyncClient( + timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0), + ) as http: async with http.stream("GET", self._url, headers=headers) as response: if response.status_code >= 400: await response.aread() diff --git a/clients/python/tests/test_sse.py b/clients/python/tests/test_sse.py index 6e6e01d..484abac 100644 --- a/clients/python/tests/test_sse.py +++ b/clients/python/tests/test_sse.py @@ -5,11 +5,13 @@ from __future__ import annotations +import asyncio + import httpx import pytest import respx -from ros2_medkit_client.errors import MedkitError +from ros2_medkit_client.errors import MedkitConnectionError, MedkitError, MedkitTimeoutError from ros2_medkit_client.sse import SseEvent, SseStream, parse_sse_line # --------------------------------------------------------------------------- @@ -256,16 +258,19 @@ async def test_auth_failure_during_reconnect_no_retry(self): @respx.mock async def test_max_retries_exhaustion(self): - """After max_retries, the underlying exception propagates.""" + """After max_retries, MedkitConnectionError is raised wrapping the original.""" route = respx.get("http://gw/sse") # All attempts fail with network error route.side_effect = httpx.ConnectError("refused") stream = SseStream("http://gw/sse", max_retries=2, initial_delay=0.01, max_delay=0.01) - with pytest.raises(httpx.ConnectError): + with pytest.raises(MedkitConnectionError) as exc_info: async for _ in stream: pass + assert exc_info.value.code == "connection-error" + assert isinstance(exc_info.value.__cause__, httpx.ConnectError) + @respx.mock async def test_buffer_overflow(self): """Exceeding MAX_BUFFER_SIZE raises MedkitError.""" @@ -461,3 +466,47 @@ async def test_5xx_error_response_body_parsed(self): assert exc_info.value.status == 500 assert exc_info.value.code == "internal-error" + + @respx.mock + async def test_timeout_wrapped_in_medkit_timeout_error(self): + """Timeout exceptions are wrapped into MedkitTimeoutError.""" + respx.get("http://gw/sse").mock(side_effect=httpx.ReadTimeout("read timed out")) + + stream = SseStream("http://gw/sse", max_retries=0, initial_delay=0.01) + with pytest.raises(MedkitTimeoutError) as exc_info: + async for _ in stream: + pass + + assert exc_info.value.code == "timeout" + assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout) + + @respx.mock + async def test_close_during_reconnect_sleep(self): + """close() interrupts reconnect backoff sleep.""" + respx.get("http://gw/sse").mock(side_effect=httpx.ConnectError("refused")) + + stream = SseStream("http://gw/sse", max_retries=10, initial_delay=10.0, max_delay=10.0) + + async def close_soon(): + await asyncio.sleep(0.05) + stream.close() + + asyncio.create_task(close_soon()) + events = [ev async for ev in stream] + assert events == [] + + @respx.mock + async def test_non_json_error_body(self): + """Non-JSON error body still produces MedkitError with status.""" + respx.get("http://gw/sse").respond( + 502, + content=b"Bad Gateway", + headers={"content-type": "text/html"}, + ) + + stream = SseStream("http://gw/sse", max_retries=0) + with pytest.raises(MedkitError) as exc_info: + async for _ in stream: + pass + + assert exc_info.value.status == 502 From e62874fe19b2fac496ca39244f98af3a18be62a8 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 22 Mar 2026 21:20:32 +0100 Subject: [PATCH 10/14] fix(py): add call() error bridging, type http property, CI fix - MedkitClient.call() wraps generated API functions, raises MedkitError on GenericError responses and None returns - Type http property as Any for IDE support - Fix CI publish to fail properly (no silent error suppression) - Add py.typed marker for PEP 561 - Add tests for property guards, auth propagation, call() method --- .github/workflows/python-ci.yml | 9 +- .../python/src/ros2_medkit_client/__init__.py | 5 + .../python/src/ros2_medkit_client/client.py | 55 ++++++++- .../python/src/ros2_medkit_client/py.typed | 0 clients/python/tests/test_client.py | 104 ++++++++++++++++++ 5 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 clients/python/src/ros2_medkit_client/py.typed diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 0cc04e9..4e95d9d 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -89,8 +89,15 @@ jobs: - name: Build wheel run: python -m build + - name: Check if version already published + id: version-check + run: | + PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + echo "Publishing version ${PKG_VERSION}" + echo "version=${PKG_VERSION}" >> "$GITHUB_OUTPUT" + - name: Publish to GitHub Packages - run: twine upload --repository-url https://pypi.pkg.github.com/selfpatch/ dist/* || echo "Version may already be published" + run: twine upload --repository-url https://pypi.pkg.github.com/selfpatch/ dist/* env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.GITHUB_TOKEN }} diff --git a/clients/python/src/ros2_medkit_client/__init__.py b/clients/python/src/ros2_medkit_client/__init__.py index 9ec2750..6d57339 100644 --- a/clients/python/src/ros2_medkit_client/__init__.py +++ b/clients/python/src/ros2_medkit_client/__init__.py @@ -9,8 +9,13 @@ from ros2_medkit_client.api.discovery import list_apps async with MedkitClient(base_url="localhost:8080") as client: + # Option 1: call() bridges errors into MedkitError exceptions + apps = await client.call(list_apps.asyncio) + + # Option 2: raw API - returns SuccessType | GenericError | None result = await list_apps.asyncio(client=client.http) + # Stream faults via SSE async for event in client.streams.faults(): print(event.data) """ diff --git a/clients/python/src/ros2_medkit_client/client.py b/clients/python/src/ros2_medkit_client/client.py index 812fa6c..a2ba064 100644 --- a/clients/python/src/ros2_medkit_client/client.py +++ b/clients/python/src/ros2_medkit_client/client.py @@ -7,7 +7,9 @@ import re from types import TracebackType +from typing import Any +from ros2_medkit_client.errors import MedkitError from ros2_medkit_client.streams import StreamHelpers @@ -77,8 +79,11 @@ def base_url(self) -> str: return self._base_url @property - def http(self): - """The configured generated Client for use with API functions.""" + def http(self) -> Any: + """The configured generated Client for use with API functions. + + Returns either Client or AuthenticatedClient from the generated code. + """ if self._http is None: raise RuntimeError("Client not initialized. Use 'async with MedkitClient(...)' context manager.") return self._http @@ -90,6 +95,52 @@ def streams(self) -> StreamHelpers: raise RuntimeError("Client not initialized. Use 'async with MedkitClient(...)' context manager.") return self._streams + async def call(self, api_func: Any, **kwargs: Any) -> Any: + """Call a generated API function, raising MedkitError on errors. + + Bridges the generated code's return-value error handling into exceptions:: + + from ros2_medkit_client.api.discovery import list_apps + apps = await client.call(list_apps.asyncio) + + Args: + api_func: A generated async API function (e.g., ``list_apps.asyncio``). + **kwargs: Additional keyword arguments passed to the API function. + + Returns: + The success response (never GenericError or None). + + Raises: + MedkitError: If the API returns a GenericError response. + MedkitError: If the API returns None (unexpected status code). + """ + result = await api_func(client=self.http, **kwargs) + + if result is None: + raise MedkitError( + status=0, + code="unexpected-status", + message="API returned an unexpected status code", + ) + + # Check if result is a GenericError (has error_code and message attributes + # but not items, which would indicate a collection response) + if hasattr(result, "error_code") and hasattr(result, "message") and not hasattr(result, "items"): + error_code = getattr(result, "error_code", "unknown") + message = getattr(result, "message", "Unknown error") + parameters = getattr(result, "parameters", None) + # Convert Unset sentinel values to None + if parameters is not None and not isinstance(parameters, dict): + parameters = None + raise MedkitError( + status=0, + code=error_code, + message=message, + details=parameters if isinstance(parameters, dict) else None, + ) + + return result + async def __aenter__(self) -> MedkitClient: headers: dict[str, str] = {} if self._auth_token: diff --git a/clients/python/src/ros2_medkit_client/py.typed b/clients/python/src/ros2_medkit_client/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/clients/python/tests/test_client.py b/clients/python/tests/test_client.py index 3795571..b555635 100644 --- a/clients/python/tests/test_client.py +++ b/clients/python/tests/test_client.py @@ -4,6 +4,7 @@ import pytest from ros2_medkit_client.client import MedkitClient, normalize_base_url +from ros2_medkit_client.errors import MedkitError class TestNormalizeBaseUrl: @@ -53,3 +54,106 @@ async def test_context_manager_enters_and_exits(self): client = MedkitClient(base_url="localhost:8080") async with client: pass # Should not raise + + +class TestMedkitClientProperties: + async def test_http_before_enter_raises(self): + client = MedkitClient(base_url="localhost:8080") + with pytest.raises(RuntimeError, match="not initialized"): + _ = client.http + + async def test_streams_before_enter_raises(self): + client = MedkitClient(base_url="localhost:8080") + with pytest.raises(RuntimeError, match="not initialized"): + _ = client.streams + + async def test_auth_token_in_stream_headers(self): + async with MedkitClient(base_url="localhost:8080", auth_token="mytoken") as client: + assert client.streams._headers.get("Authorization") == "Bearer mytoken" + + +class TestMedkitClientCall: + async def test_call_returns_success_result(self): + """call() returns the result when API function succeeds.""" + + async def mock_api_func(*, client, **kwargs): + return {"items": []} + + async with MedkitClient(base_url="localhost:8080") as client: + result = await client.call(mock_api_func) + assert result == {"items": []} + + async def test_call_raises_on_none(self): + """call() raises MedkitError when API returns None.""" + + async def mock_api_func(*, client, **kwargs): + return None + + async with MedkitClient(base_url="localhost:8080") as client: + with pytest.raises(MedkitError) as exc_info: + await client.call(mock_api_func) + assert exc_info.value.code == "unexpected-status" + + async def test_call_raises_on_generic_error(self): + """call() raises MedkitError when API returns a GenericError-like object.""" + + class FakeGenericError: + error_code = "entity-not-found" + message = "Entity x not found" + parameters = None + + async def mock_api_func(*, client, **kwargs): + return FakeGenericError() + + async with MedkitClient(base_url="localhost:8080") as client: + with pytest.raises(MedkitError) as exc_info: + await client.call(mock_api_func) + assert exc_info.value.code == "entity-not-found" + assert exc_info.value.message == "Entity x not found" + + async def test_call_raises_on_generic_error_with_parameters(self): + """call() preserves error parameters as details.""" + + class FakeGenericError: + error_code = "invalid-parameter" + message = "Bad value" + parameters = {"field": "timeout", "reason": "must be positive"} + + async def mock_api_func(*, client, **kwargs): + return FakeGenericError() + + async with MedkitClient(base_url="localhost:8080") as client: + with pytest.raises(MedkitError) as exc_info: + await client.call(mock_api_func) + assert exc_info.value.details == {"field": "timeout", "reason": "must be positive"} + + async def test_call_converts_unset_parameters_to_none(self): + """call() converts non-dict parameters (e.g. Unset sentinel) to None.""" + + class UnsetType: + pass + + class FakeGenericError: + error_code = "some-error" + message = "Something failed" + parameters = UnsetType() + + async def mock_api_func(*, client, **kwargs): + return FakeGenericError() + + async with MedkitClient(base_url="localhost:8080") as client: + with pytest.raises(MedkitError) as exc_info: + await client.call(mock_api_func) + assert exc_info.value.details is None + + async def test_call_passes_kwargs(self): + """call() forwards kwargs to the API function.""" + received_kwargs = {} + + async def mock_api_func(*, client, **kwargs): + received_kwargs.update(kwargs) + return {"items": []} + + async with MedkitClient(base_url="localhost:8080") as client: + await client.call(mock_api_func, app_id="my_node") + assert received_kwargs == {"app_id": "my_node"} From 24f3091c55abfad58227ce76859b7a1f84396de7 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 22 Mar 2026 21:26:49 +0100 Subject: [PATCH 11/14] fix(py): restore full README, fix CI publish, fix usage examples - Restore original README content (structure table, spec docs, TS client) - Add Python client section with call() examples (not misleading try/except) - Use twine --skip-existing instead of broken version check - Show both call() and raw API usage patterns --- .github/workflows/python-ci.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 4e95d9d..e603cc1 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -89,15 +89,8 @@ jobs: - name: Build wheel run: python -m build - - name: Check if version already published - id: version-check - run: | - PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") - echo "Publishing version ${PKG_VERSION}" - echo "version=${PKG_VERSION}" >> "$GITHUB_OUTPUT" - - name: Publish to GitHub Packages - run: twine upload --repository-url https://pypi.pkg.github.com/selfpatch/ dist/* + run: twine upload --skip-existing --repository-url https://pypi.pkg.github.com/selfpatch/ dist/* env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.GITHUB_TOKEN }} From 91ca0f11715f0505b3a96a5a904230d1a0c7df01 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 22 Mar 2026 21:28:49 +0100 Subject: [PATCH 12/14] fix(py): remove duplicate CRLF stripping in parse_sse_line CRLF is stripped in _connect_and_stream before calling parse_sse_line. Remove redundant strip in parse_sse_line, update test to match. --- clients/python/src/ros2_medkit_client/sse.py | 6 +----- clients/python/tests/test_sse.py | 7 ++++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/clients/python/src/ros2_medkit_client/sse.py b/clients/python/src/ros2_medkit_client/sse.py index 2fc473d..cb4dabb 100644 --- a/clients/python/src/ros2_medkit_client/sse.py +++ b/clients/python/src/ros2_medkit_client/sse.py @@ -28,12 +28,8 @@ def parse_sse_line(line: str, state: dict) -> None: """Parse a single SSE line into the accumulator state dict. The state dict has keys: event, data, id, retry. - Trailing ``\\r`` is stripped for CRLF compatibility. + Caller is responsible for stripping trailing ``\\r`` (CRLF handling). """ - # Strip trailing \r for CRLF compat - if line.endswith("\r"): - line = line[:-1] - # Comment line if line.startswith(":"): return diff --git a/clients/python/tests/test_sse.py b/clients/python/tests/test_sse.py index 484abac..a1ed6e7 100644 --- a/clients/python/tests/test_sse.py +++ b/clients/python/tests/test_sse.py @@ -104,10 +104,11 @@ def test_unknown_field_ignored(self): parse_sse_line("foo: bar", state) assert state == self._state() - def test_crlf_stripping(self): - """Trailing \\r is stripped (CRLF compatibility).""" + def test_crlf_passed_through(self): + """parse_sse_line expects caller to strip \\r. CRLF handled in _connect_and_stream.""" state = self._state() - parse_sse_line("data: value\r", state) + # Caller (SseStream._connect_and_stream) strips \r before calling parse_sse_line + parse_sse_line("data: value", state) # Already stripped assert state["data"] == "value" def test_data_without_space_after_colon(self): From 69bbce70386a88d8c4e82c2a59a26c1b024fd8f8 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 23 Mar 2026 17:09:28 +0100 Subject: [PATCH 13/14] fix(py): type SseEvent.data as Any instead of object Improves IDE support - consumers can subscript event.data without casts. --- clients/python/src/ros2_medkit_client/sse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clients/python/src/ros2_medkit_client/sse.py b/clients/python/src/ros2_medkit_client/sse.py index cb4dabb..75031e3 100644 --- a/clients/python/src/ros2_medkit_client/sse.py +++ b/clients/python/src/ros2_medkit_client/sse.py @@ -8,7 +8,7 @@ import asyncio import json from dataclasses import dataclass -from typing import AsyncIterator +from typing import Any, AsyncIterator import httpx @@ -20,7 +20,7 @@ class SseEvent: """A single Server-Sent Event.""" event: str = "message" - data: object = None + data: Any = None id: str | None = None From 132734949c7a5ae84f86db2e3801eabaa968ac5d Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 24 Mar 2026 12:59:24 +0100 Subject: [PATCH 14/14] fix(py): address mfaferek93 review feedback - Distinguish SSE-only mode from uninitialized client in error messages - Document attrs and python-dateutil as generated code dependencies - Add build instructions comment for _generated symlink --- clients/python/pyproject.toml | 7 +++++-- clients/python/src/ros2_medkit_client/client.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index 3c64d49..9b4e5a4 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -12,8 +12,8 @@ authors = [{ name = "bburda" }] keywords = ["ros2", "sovd", "medkit", "diagnostics", "openapi", "client"] dependencies = [ "httpx>=0.27", - "attrs>=23.0", - "python-dateutil>=2.9", + "attrs>=23.0", # Required by generated client code (openapi-python-client) + "python-dateutil>=2.9", # Required by generated models (dateutil.parser.isoparse) ] [project.optional-dependencies] @@ -29,6 +29,9 @@ Homepage = "https://github.com/selfpatch/ros2_medkit_clients" Issues = "https://github.com/selfpatch/ros2_medkit_clients/issues" [tool.hatch.build.targets.wheel] +# Note: _generated/ is a symlink to ../../generated/ (created by generate.sh). +# Run ./scripts/generate.sh && ln -sf ../../generated src/ros2_medkit_client/_generated +# before building the wheel. packages = ["src/ros2_medkit_client"] [tool.pytest.ini_options] diff --git a/clients/python/src/ros2_medkit_client/client.py b/clients/python/src/ros2_medkit_client/client.py index a2ba064..806025a 100644 --- a/clients/python/src/ros2_medkit_client/client.py +++ b/clients/python/src/ros2_medkit_client/client.py @@ -72,6 +72,8 @@ def __init__( self._timeout = timeout self._http = None self._streams: StreamHelpers | None = None + self._entered = False + self._generated_available = False @property def base_url(self) -> str: @@ -85,6 +87,12 @@ def http(self) -> Any: Returns either Client or AuthenticatedClient from the generated code. """ if self._http is None: + if self._entered and not self._generated_available: + raise RuntimeError( + "Generated API client not available. Run code generation first " + "(./scripts/generate.sh && ln -sf ../../generated src/ros2_medkit_client/_generated). " + "SSE streaming via client.streams is still available." + ) raise RuntimeError("Client not initialized. Use 'async with MedkitClient(...)' context manager.") return self._http @@ -163,10 +171,13 @@ async def __aenter__(self) -> MedkitClient: ) # Enter the generated client's async context (initializes httpx.AsyncClient) await self._http.__aenter__() + self._generated_available = True except ImportError: # Generated code not available - SSE-only mode self._http = None + self._entered = True + self._streams = StreamHelpers( base_url=self._base_url, headers=headers,