Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make aiohttp ClientSession optional #28

Merged
merged 1 commit into from
Apr 21, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 44 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 💧 aioflo: a Python3, asyncio-friendly library for Notion® Home Monitoring
# 💧 aioflo: a Python3, asyncio-friendly library for Flo Smart Water Detectors

[![CI](https://github.com/bachya/aioflo/workflows/CI/badge.svg)](https://github.com/bachya/aioflo/actions)
[![PyPi](https://img.shields.io/pypi/v/aioflo.svg)](https://pypi.python.org/pypi/aioflo)
Expand Down Expand Up @@ -27,27 +27,57 @@ pip install aioflo

# Usage

`aioflo` starts within an
[aiohttp](https://aiohttp.readthedocs.io/en/stable/) `ClientSession`:

```python
import asyncio

from aiohttp import ClientSession

from aioflo import Client
from aioflo import async_get_api


async def main() -> None:
"""Create the aiohttp session and run the example."""
async with ClientSession() as websession:
# YOUR CODE HERE
"""Run!"""
api = await async_get_api("<EMAIL>", "<PASSWORD>")

# Get user account information:
user_info = await api.user.get_info()
a_location_id = user_info["locations"][0]["id"]

# Get location (i.e., device) information:
location_info = await api.location.get_info(a_location_id)

# Get consumption info between a start and end datetime:
consumption_info = await api.water.get_consumption_info(
a_location_id,
datetime(2020, 1, 16, 0, 0),
datetime(2020, 1, 16, 23, 59, 59, 999000),
)

# Get various other metrics related to water usage:
metrics = await api.water.get_metrics(
"<DEVICE_MAC_ADDRESS>",
datetime(2020, 1, 16, 0, 0),
datetime(2020, 1, 16, 23, 59, 59, 999000),
)

asyncio.get_event_loop().run_until_complete(main())
# Set the device in "Away" mode:
await set_mode_away(a_location_id)

# Set the device in "Home" mode:
await set_mode_home(a_location_id)

# Set the device in "Sleep" mode for 120 minutes, then return to "Away" mode:
await set_mode_sleep(a_location_id, 120, "away")


asyncio.run(main())
```

Create an API object, initialize it, then get to it:
By default, library creates a new connection to Flo with each coroutine. If you are
calling a large number of coroutines (or merely want to squeeze out every second of
runtime savings possible), an
[`aiohttp`](https://github.com/aio-libs/aiohttp) `ClientSession` can be used for connection
pooling:

```python
import asyncio
Expand All @@ -60,7 +90,7 @@ from aioflo import async_get_api
async def main() -> None:
"""Create the aiohttp session and run the example."""
async with ClientSession() as websession:
api = await async_get_api(session, "<EMAIL>", "<PASSWORD>")
api = await async_get_api("<EMAIL>", "<PASSWORD>", session=session)

# Get user account information:
user_info = await api.user.get_info()
Expand Down Expand Up @@ -92,19 +122,17 @@ async def main() -> None:
# Set the device in "Sleep" mode for 120 minutes, then return to "Away" mode:
await set_mode_sleep(a_location_id, 120, "away")

asyncio.get_event_loop().run_until_complete(main())
```

See the module docstrings throughout the library for full info on all parameters, return
types, etc.
asyncio.run(main())
```

# Contributing

1. [Check for open features/bugs](https://github.com/bachya/aioflo/issues)
or [initiate a discussion on one](https://github.com/bachya/aioflo/issues/new).
2. [Fork the repository](https://github.com/bachya/aioflo/fork).
3. (_optional, but highly recommended_) Create a virtual environment: `python3 -m venv .venv`
4. (_optional, but highly recommended_) Enter the virtual environment: `source ./venv/bin/activate`
4. (_optional, but highly recommended_) Enter the virtual environment: `source ./.venv/bin/activate`
5. Install the dev environment: `script/setup`
6. Code your new feature or bug fix.
7. Write tests that cover your new functionality.
Expand Down
44 changes: 29 additions & 15 deletions aioflo/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Optional
from urllib.parse import urlparse

from aiohttp import ClientSession
from aiohttp import ClientSession, ClientTimeout
from aiohttp.client_exceptions import ClientError

from .alarm import Alarm
Expand All @@ -25,12 +25,15 @@
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36"
)
DEFAULT_TIMEOUT: int = 10


class API: # pylint: disable=too-few-public-methods,too-many-instance-attributes
"""Define the API object."""

def __init__(self, session: ClientSession, username: str, password: str) -> None:
def __init__(
self, username: str, password: str, *, session: Optional[ClientSession] = None
) -> None:
"""Initialize."""
self._password: str = password
self._session: ClientSession = session
Expand Down Expand Up @@ -68,9 +71,8 @@ async def _request(

await self.async_authenticate()

if not headers:
headers = {}
headers.update(
_headers = headers or {}
_headers.update(
{
"Accept": DEFAULT_HEADER_ACCEPT,
"Content-Type": DEFAULT_HEADER_CONTENT_TYPE,
Expand All @@ -82,17 +84,27 @@ async def _request(
)

if self._token:
headers["Authorization"] = self._token
_headers["Authorization"] = self._token

async with self._session.request(
method, url, headers=headers, params=params, json=json
) as resp:
data: dict = await resp.json(content_type=None)
try:
use_running_session = self._session and not self._session.closed

if use_running_session:
session = self._session
else:
session = ClientSession(timeout=ClientTimeout(total=DEFAULT_TIMEOUT))

try:
async with session.request(
method, url, headers=_headers, params=params, json=json
) as resp:
data: dict = await resp.json(content_type=None)
resp.raise_for_status()
return data
except ClientError as err:
raise RequestError(f"There was an error while requesting {url}: {err}")
except ClientError as err:
raise RequestError(f"There was an error while requesting {url}: {err}")
finally:
if not use_running_session:
await session.close()

async def async_authenticate(self) -> None:
"""Authenticate the user and set the access token with its expiration."""
Expand All @@ -113,7 +125,9 @@ async def async_authenticate(self) -> None:
self.user = User(self._request, self._user_id)


async def async_get_api(session: ClientSession, username: str, password: str) -> API:
async def async_get_api(
username: str, password: str, *, session: Optional[ClientSession] = None
) -> API:
"""Instantiate an authenticated API object.

:param session: An ``aiohttp`` ``ClientSession``
Expand All @@ -124,6 +138,6 @@ async def async_get_api(session: ClientSession, username: str, password: str) ->
:type password: ``str``
:rtype: :meth:`aioflo.api.API`
"""
api = API(session, username, password)
api = API(username, password, session=session)
await api.async_authenticate()
return api
8 changes: 4 additions & 4 deletions examples/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@

_LOGGER = logging.getLogger()

EMAIL = "jwilhelmy@gmail.com"
PASSWORD = "Jo3rulez"
EMAIL = "<EMAIL>"
PASSWORD = "<PASSWORD>"


async def main() -> None:
"""Create the aiohttp session and run the example."""
logging.basicConfig(level=logging.INFO)
async with ClientSession() as session:
try:
api = await async_get_api(session, EMAIL, PASSWORD)
api = await async_get_api(EMAIL, PASSWORD, session=session)

user_info = await api.user.get_info()
_LOGGER.info(user_info)
Expand All @@ -38,4 +38,4 @@ async def main() -> None:
_LOGGER.error("There was an error: %s", err)


asyncio.get_event_loop().run_until_complete(main())
asyncio.run(main())
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,6 @@ python = "^3.6.0"
[tool.poetry.dev-dependencies]
aresponses = "^2.0.0"
pre-commit = "^2.0.1"
pytest = "^5.3.5"
pytest = "^5.4.1"
pytest-aiohttp = "^0.3.0"
pytest-cov = "^2.8.1"
2 changes: 1 addition & 1 deletion requirements_test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ aiohttp==3.6.2
aresponses==1.1.2
pytest-aiohttp==0.3.0
pytest-cov==2.8.1
pytest==5.3.5
pytest==5.4.1
24 changes: 23 additions & 1 deletion tests/test_alarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,29 @@ async def test_get_user_info(aresponses, auth_success_response):
)

async with aiohttp.ClientSession() as session:
api = await async_get_api(session, TEST_EMAIL_ADDRESS, TEST_PASSWORD)
api = await async_get_api(TEST_EMAIL_ADDRESS, TEST_PASSWORD, session=session)
alarm_info = await api.alarm.get_all()
assert len(alarm_info["items"]) == 1
assert alarm_info["items"][0]["name"] == "health_test_skipped"


@pytest.mark.asyncio
async def test_get_user_info_no_session(aresponses, auth_success_response):
"""Test successfully retrieving user info with an auto-generated session."""
aresponses.add(
"api.meetflo.com",
"/api/v1/users/auth",
"post",
aresponses.Response(text=json.dumps(auth_success_response), status=200),
)
aresponses.add(
"api-gw.meetflo.com",
"/api/v2/alarms",
"get",
aresponses.Response(text=load_fixture("alarms_response.json"), status=200),
)

api = await async_get_api(TEST_EMAIL_ADDRESS, TEST_PASSWORD)
alarm_info = await api.alarm.get_all()
assert len(alarm_info["items"]) == 1
assert alarm_info["items"][0]["name"] == "health_test_skipped"
6 changes: 3 additions & 3 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async def test_bad_api_call(aresponses, auth_success_response):
)

async with aiohttp.ClientSession() as session:
api = await async_get_api(session, TEST_EMAIL_ADDRESS, TEST_PASSWORD)
api = await async_get_api(TEST_EMAIL_ADDRESS, TEST_PASSWORD, session=session)
with pytest.raises(RequestError):
await api._request("get", "https://api.meetflo.com/api/v1/bad")

Expand Down Expand Up @@ -60,7 +60,7 @@ async def test_expired_api_token(aresponses, auth_success_response, caplog):
)

async with aiohttp.ClientSession() as session:
api = await async_get_api(session, TEST_EMAIL_ADDRESS, TEST_PASSWORD)
api = await async_get_api(TEST_EMAIL_ADDRESS, TEST_PASSWORD, session=session)
print(api._token_expiration)
api._token_expiration = datetime.now() - timedelta(days=1)
print(api._token_expiration)
Expand All @@ -79,6 +79,6 @@ async def test_get_api(aresponses, auth_success_response):
)

async with aiohttp.ClientSession() as session:
api = await async_get_api(session, TEST_EMAIL_ADDRESS, TEST_PASSWORD)
api = await async_get_api(TEST_EMAIL_ADDRESS, TEST_PASSWORD, session=session)
assert api._token == TEST_TOKEN
assert api._user_id == TEST_USER_ID
4 changes: 2 additions & 2 deletions tests/test_location.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ async def test_get_location_info(aresponses, auth_success_response):
)

async with aiohttp.ClientSession() as session:
api = await async_get_api(session, TEST_EMAIL_ADDRESS, TEST_PASSWORD)
api = await async_get_api(TEST_EMAIL_ADDRESS, TEST_PASSWORD, session=session)
location_info = await api.location.get_info(TEST_LOCATION_ID)
assert location_info["address"] == "123 Main Street"
assert len(location_info["devices"]) == 1
Expand Down Expand Up @@ -83,7 +83,7 @@ async def test_system_modes(aresponses, auth_success_response):
)

async with aiohttp.ClientSession() as session:
api = await async_get_api(session, TEST_EMAIL_ADDRESS, TEST_PASSWORD)
api = await async_get_api(TEST_EMAIL_ADDRESS, TEST_PASSWORD, session=session)

await api.location.set_mode_away(TEST_LOCATION_ID)
await api.location.set_mode_home(TEST_LOCATION_ID)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async def test_get_user_info(aresponses, auth_success_response):
)

async with aiohttp.ClientSession() as session:
api = await async_get_api(session, TEST_EMAIL_ADDRESS, TEST_PASSWORD)
api = await async_get_api(TEST_EMAIL_ADDRESS, TEST_PASSWORD, session=session)
user_info = await api.user.get_info()
assert user_info["email"] == "email@address.com"
assert not user_info["alarmSettings"]
Expand Down
4 changes: 2 additions & 2 deletions tests/test_water.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async def test_get_consumption_info(aresponses, auth_success_response):
end = datetime(2020, 1, 16, 23, 59, 59, 999000)

async with aiohttp.ClientSession() as session:
api = await async_get_api(session, TEST_EMAIL_ADDRESS, TEST_PASSWORD)
api = await async_get_api(TEST_EMAIL_ADDRESS, TEST_PASSWORD, session=session)
consumption_info = await api.water.get_consumption_info(
TEST_LOCATION_ID, start, end
)
Expand Down Expand Up @@ -74,7 +74,7 @@ async def test_get_metrics(aresponses, auth_success_response):
end = datetime(2020, 1, 16, 23, 59, 59, 999000)

async with aiohttp.ClientSession() as session:
api = await async_get_api(session, TEST_EMAIL_ADDRESS, TEST_PASSWORD)
api = await async_get_api(TEST_EMAIL_ADDRESS, TEST_PASSWORD, session=session)
metrics = await api.water.get_metrics(TEST_MAC_ADDRESS, start, end)
assert len(metrics["items"]) == 3

Expand Down