Skip to content
Draft
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -55,3 +56,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,
)
88 changes: 65 additions & 23 deletions pyemoncms/emoncms_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -30,19 +32,31 @@
MESSAGE_KEY = "message"
SUCCESS_KEY = "success"

logging.basicConfig()

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."""

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
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 @@ -61,30 +75,48 @@ 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()
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=ClientTimeout(total=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 All @@ -127,7 +169,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)
Expand All @@ -137,23 +179,23 @@ 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:
"""Close open client session."""
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

Expand Down
Empty file added pyemoncms/py.typed
Empty file.
3 changes: 2 additions & 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 All @@ -22,6 +22,7 @@
"Operating System :: OS Independent",
],
packages=setuptools.find_packages(),
package_data={"pyemoncms": ["py.typed"]},
install_requires=[
'aiohttp'
],
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")