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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
7 changes: 6 additions & 1 deletion pyemoncms/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
"""pyemoncms module"""
from .emoncms_client import EmoncmsClient
from .emoncms_client import (
EmoncmsAuthError,
EmoncmsClient,
EmoncmsConnectionError,
EmoncmsError,
)
64 changes: 53 additions & 11 deletions pyemoncms/emoncms_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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],
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 61 additions & 1 deletion tests/test_async_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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")