From 9173b6c411a5559b4d3916951d33ddcce35e0a68 Mon Sep 17 00:00:00 2001 From: "David F. Severski" Date: Sat, 14 Nov 2026 14:50:12 -0500 Subject: [PATCH 1/3] feat: add raise_errors option with typed exceptions Opt-in raise_errors raises EmoncmsAuthError on HTTP 401, EmoncmsConnectionError on client errors/timeouts and EmoncmsError otherwise, from async_request and async_input_post. Closes #1 --- README.md | 19 +++++++++++ pyemoncms/__init__.py | 7 +++- pyemoncms/emoncms_client.py | 64 ++++++++++++++++++++++++++++++------- setup.py | 2 +- tests/test_async_request.py | 62 ++++++++++++++++++++++++++++++++++- 5 files changed, 140 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3bb042b..f1c68b2 100644 --- a/README.md +++ b/README.md @@ -55,3 +55,22 @@ async def main(): if __name__ == "__main__": asyncio.run(main()) ``` + +## error handling + +By default, failures are returned as `{"success": False, "message": "..."}` +(or `None` from the helpers). Pass `raise_errors=True` to get exceptions instead : + +``` +from pyemoncms import EmoncmsAuthError, EmoncmsClient, EmoncmsConnectionError, EmoncmsError + +client = EmoncmsClient(url, key, raise_errors=True) +try: + feeds = await client.async_list_feeds() +except EmoncmsAuthError: + ... # invalid API key (HTTP 401) +except EmoncmsConnectionError: + ... # timeout or server unreachable +except EmoncmsError: + ... # any other error response +``` diff --git a/pyemoncms/__init__.py b/pyemoncms/__init__.py index e353a6b..5b49e69 100644 --- a/pyemoncms/__init__.py +++ b/pyemoncms/__init__.py @@ -1,2 +1,7 @@ """pyemoncms module""" -from .emoncms_client import EmoncmsClient +from .emoncms_client import ( + EmoncmsAuthError, + EmoncmsClient, + EmoncmsConnectionError, + EmoncmsError, +) diff --git a/pyemoncms/emoncms_client.py b/pyemoncms/emoncms_client.py index b70e3e5..181a84a 100644 --- a/pyemoncms/emoncms_client.py +++ b/pyemoncms/emoncms_client.py @@ -34,6 +34,19 @@ Self = TypeVar("Self", bound="EmoncmsClient") + +class EmoncmsError(Exception): + """Generic emoncms error.""" + + +class EmoncmsAuthError(EmoncmsError): + """Invalid API key (HTTP 401).""" + + +class EmoncmsConnectionError(EmoncmsError): + """Emoncms server unreachable or timed out.""" + + @dataclass class EmoncmsClient: """Emoncms client.""" @@ -43,6 +56,7 @@ class EmoncmsClient: request_timeout: int = 20 session: ClientSession | None = None _close_session: bool = False + raise_errors: bool = False logger = logging.getLogger(__name__) async def async_request( @@ -51,7 +65,7 @@ async def async_request( """Request emoncms server.""" message = f"requesting emoncms on {path}" self.logger.debug(message) - data = {SUCCESS_KEY: False, MESSAGE_KEY: None} + data: dict[str, Any] = {SUCCESS_KEY: False, MESSAGE_KEY: None} if not params: params = {"apikey": self.api_key} if self.session is None: @@ -63,28 +77,46 @@ async def async_request( response = await self.session.get( url, timeout=self.request_timeout, params=params ) + if response.status == 200: + json_response = await response.json() except ClientError as er: message = f"client error : {er}" data[MESSAGE_KEY] = message - self.logger.error(message) + self._log_error(message) + if self.raise_errors: + raise EmoncmsConnectionError(message) from er return data - except asyncio.TimeoutError: + except asyncio.TimeoutError as er: message = "time out error" data[MESSAGE_KEY] = message - self.logger.error(message) + self._log_error(message) + if self.raise_errors: + raise EmoncmsConnectionError(message) from er return data if response.status == 200: data[SUCCESS_KEY] = True - json_response = await response.json() data[MESSAGE_KEY] = json_response else: message = f"error {response.status}" if response.status in HTTP_STATUS: message = f"{message} {HTTP_STATUS[response.status]}" data[MESSAGE_KEY] = message - self.logger.error(message) + self._log_error(message) + self._raise_for_status(response.status, message) return data + def _log_error(self, message: str) -> None: + """Log at debug when raising, the caller decides how to report.""" + self.logger.log(logging.DEBUG if self.raise_errors else logging.ERROR, message) + + def _raise_for_status(self, status: int, message: str) -> None: + """Raise the matching exception when raise_errors is enabled.""" + if not self.raise_errors: + return + if status == 401: + raise EmoncmsAuthError(message) + raise EmoncmsError(message) + async def async_input_post( self, data: Dict[str, Any], @@ -110,11 +142,21 @@ async def async_input_post( if time is not None: params["time"] = str(time) - async with self.session.post(endpoint, params=params) as resp: - text = await resp.text() - if resp.status != 200: - raise RuntimeError(f"POST failed ({resp.status}): {text}") - return True + try: + async with self.session.post( + endpoint, params=params, timeout=self.request_timeout + ) as resp: + text = await resp.text() + status = resp.status + except (ClientError, asyncio.TimeoutError) as er: + if self.raise_errors: + raise EmoncmsConnectionError(f"POST failed : {er!r}") from er + raise + if status != 200: + message = f"POST failed ({status}): {text}" + self._raise_for_status(status, message) + raise RuntimeError(message) + return True async def async_get_uuid(self) -> str | None: """Return the unique identifier or None. diff --git a/setup.py b/setup.py index 7854f0b..1e645e9 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setuptools.setup( name="pyemoncms", - version="0.1.3", + version="0.1.4", author="Alexandre CUER", author_email="alexandre.cuer@wanadoo.fr", description="A python library to interrogate emoncms API", diff --git a/tests/test_async_request.py b/tests/test_async_request.py index 3fba08f..0cd1529 100644 --- a/tests/test_async_request.py +++ b/tests/test_async_request.py @@ -3,11 +3,17 @@ import os import sys +import pytest from aiohttp import web root_path = os.getcwd() sys.path.insert(0, root_path) -from pyemoncms.emoncms_client import EmoncmsClient # pylint: disable=E0401,C0413 +from pyemoncms.emoncms_client import ( # pylint: disable=E0401,C0413 + EmoncmsAuthError, + EmoncmsClient, + EmoncmsConnectionError, + EmoncmsError, +) API_KEY = "123" @@ -37,10 +43,21 @@ async def feed_list_route(request): return web.json_response(FEEDS) +async def input_post_route(request): + """Emoncms input post route.""" + if request.query["apikey"] == "500": + raise web.HTTPInternalServerError(text="boom") + if request.query["apikey"] != API_KEY: + raise web.HTTPUnauthorized(text="Invalid API key") + return web.Response(text="ok") + + def create_feed_api(): """Emoncms server.""" app = web.Application() app.router.add_route("GET", "/feed/list.json", feed_list_route) + app.router.add_route("GET", "/user/getuuid.json", feed_list_route) + app.router.add_route("POST", "/input/post", input_post_route) return app async def test_response_ok(aiohttp_server): @@ -71,3 +88,46 @@ async def test_client_error(): datas = await client.async_request("/feed/list.json") assert not datas["success"] assert "client error" in datas["message"] + +async def test_raise_auth_error(aiohttp_server): + """Test incorrect API key raises with raise_errors.""" + await aiohttp_server(create_feed_api(), port=8080) + client = EmoncmsClient("http://127.0.0.1:8080", "666", raise_errors=True) + with pytest.raises(EmoncmsAuthError): + await client.async_list_feeds() + with pytest.raises(EmoncmsAuthError): + await client.async_get_uuid() + await client.close() + +async def test_raise_timeout(): + """Test timeout raises with raise_errors.""" + client = EmoncmsClient( + "http://192.16.1.32:8080", API_KEY, request_timeout=1, raise_errors=True + ) + with pytest.raises(EmoncmsConnectionError): + await client.async_request("/feed/list.json") + await client.close() + +async def test_raise_client_error(caplog): + """Test refused connection raises with raise_errors, without error logs.""" + client = EmoncmsClient("http://127.0.0.1:8087", API_KEY, raise_errors=True) + with pytest.raises(EmoncmsConnectionError): + await client.async_request("/feed/list.json") + with pytest.raises(EmoncmsConnectionError): + await client.async_input_post({"a": 1}) + await client.close() + assert not [r for r in caplog.records if r.levelname == "ERROR"] + +async def test_input_post(aiohttp_server): + """Test input post success and errors.""" + await aiohttp_server(create_feed_api(), port=8080) + url = "http://127.0.0.1:8080" + async with EmoncmsClient(url, API_KEY, raise_errors=True) as client: + assert await client.async_input_post({"a": 1}) + with pytest.raises(EmoncmsAuthError): + await client.async_input_post({"a": 1}, apikey="666") + with pytest.raises(EmoncmsError): + await client.async_input_post({"a": 1}, apikey="500") + async with EmoncmsClient(url, API_KEY) as client: + with pytest.raises(RuntimeError): + await client.async_input_post({"a": 1}, apikey="666") From a507e5fe9d5d444050fdb7a2f9ee82e192ba10e7 Mon Sep 17 00:00:00 2001 From: "David F. Severski" Date: Sat, 14 Nov 2026 14:50:16 -0500 Subject: [PATCH 2/3] feat: ship py.typed and pass mypy --strict --- pyemoncms/emoncms_client.py | 24 +++++++++++++----------- pyemoncms/py.typed | 0 setup.py | 1 + 3 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 pyemoncms/py.typed diff --git a/pyemoncms/emoncms_client.py b/pyemoncms/emoncms_client.py index 181a84a..f8f17a5 100644 --- a/pyemoncms/emoncms_client.py +++ b/pyemoncms/emoncms_client.py @@ -12,13 +12,15 @@ so there is a type error if you search for a key in the response """ +from __future__ import annotations + import asyncio from dataclasses import dataclass import json import logging -from typing import Any, Dict, TypeVar, Optional +from typing import Any, Dict, TypeVar, Optional, cast -from aiohttp import ClientError, ClientSession +from aiohttp import ClientError, ClientSession, ClientTimeout HTTP_STATUS = { 400: "invalid request", @@ -51,8 +53,8 @@ class EmoncmsConnectionError(EmoncmsError): class EmoncmsClient: """Emoncms client.""" - url: str = None - api_key: str = None + url: str = "" + api_key: str | None = None request_timeout: int = 20 session: ClientSession | None = None _close_session: bool = False @@ -75,7 +77,7 @@ async def async_request( url= f'{self.url}/{path}' try: response = await self.session.get( - url, timeout=self.request_timeout, params=params + url, timeout=ClientTimeout(total=self.request_timeout), params=params ) if response.status == 200: json_response = await response.json() @@ -144,7 +146,7 @@ async def async_input_post( try: async with self.session.post( - endpoint, params=params, timeout=self.request_timeout + endpoint, params=params, timeout=ClientTimeout(total=self.request_timeout) ) as resp: text = await resp.text() status = resp.status @@ -169,7 +171,7 @@ async def async_get_uuid(self) -> str | None: if json_response is False: return None if json_response[SUCCESS_KEY]: - return json_response[MESSAGE_KEY] + return cast(str, json_response[MESSAGE_KEY]) message = json_response[MESSAGE_KEY] message = f"{message} : {self.url}" self.logger.debug(message) @@ -179,15 +181,15 @@ async def async_list_feeds(self) -> list[dict[str, Any]] | None: """Request emoncms feeds list.""" feed_data = await self.async_request("/feed/list.json") if feed_data[SUCCESS_KEY]: - return feed_data[MESSAGE_KEY] + return cast(list[dict[str, Any]], feed_data[MESSAGE_KEY]) return None - async def async_get_feed_fields(self, feed_id: int) -> list[str, Any] | None: + async def async_get_feed_fields(self, feed_id: int) -> list[Any] | None: """Get all fields for a single feed.""" params = {"apikey": self.api_key, "id": feed_id} feed_data = await self.async_request("/feed/aget.json", params=params) if feed_data[SUCCESS_KEY]: - return feed_data[MESSAGE_KEY] + return cast(list[Any], feed_data[MESSAGE_KEY]) return None async def close(self) -> None: @@ -195,7 +197,7 @@ async def close(self) -> None: if self.session and self._close_session: await self.session.close() - async def __aenter__(self) -> Self: + async def __aenter__(self: Self) -> Self: """Async enter for context manager.""" return self diff --git a/pyemoncms/py.typed b/pyemoncms/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/setup.py b/setup.py index 1e645e9..33e181e 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ "Operating System :: OS Independent", ], packages=setuptools.find_packages(), + package_data={"pyemoncms": ["py.typed"]}, install_requires=[ 'aiohttp' ], From f068850b294f6e003b136ff8866f2db3e5fe9d79 Mon Sep 17 00:00:00 2001 From: "David F. Severski" Date: Sat, 14 Nov 2026 14:52:30 -0500 Subject: [PATCH 3/3] fix: stop configuring the root logger on import --- README.md | 1 + pyemoncms/emoncms_client.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index f1c68b2..a02e266 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ import logging import asyncio from pyemoncms import EmoncmsClient +logging.basicConfig() key = "your32bitsAPIkey" url = "http://url:8081" client = EmoncmsClient(url, key, request_timeout=1) diff --git a/pyemoncms/emoncms_client.py b/pyemoncms/emoncms_client.py index f8f17a5..080fdb5 100644 --- a/pyemoncms/emoncms_client.py +++ b/pyemoncms/emoncms_client.py @@ -32,8 +32,6 @@ MESSAGE_KEY = "message" SUCCESS_KEY = "success" -logging.basicConfig() - Self = TypeVar("Self", bound="EmoncmsClient")