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

Re-architect library for future expansion #56

Merged
merged 5 commits into from
Aug 17, 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
110 changes: 91 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ cease operation at any point.
- [Usage](#usage)
- [Contributing](#contributing)

# NOTE: Version 5.0.0

Version 5.0.0 is a complete re-architecture of `pytile` – as such, the API has changed.
Please read the documentation carefully!

# Python Versions

`pytile` is currently supported on:
Expand All @@ -36,6 +41,32 @@ pip install pytile

# Usage

## Getting an API Object

`pytile` usage starts with an [`aiohttp`](https://github.com/aio-libs/aiohttp) `ClientSession` –
note that this ClientSession is required to properly authenticate the library:

```python
import asyncio

from aiohttp import ClientSession

from pytile import async_login


async def main() -> None:
"""Run!"""
async with ClientSession() as session:
api = await async_login("<EMAIL>", "<PASSWORD>", session)


asyncio.run(main())
```

If for some reason you need to use a specific client UUID (to, say, ensure that the
Tile API sees you as a client it's seen before) or a specific locale, you can do
so easily:

```python
import asyncio

Expand All @@ -46,20 +77,58 @@ from pytile import async_login

async def main() -> None:
"""Run!"""
client = await async_login("<EMAIL>", "<PASSWORD>")
async with ClientSession() as session:
api = await async_login(
"<EMAIL>", "<PASSWORD>", session, client_uuid="MY_UUID", locale="en-GB"
)


asyncio.run(main())
```

## Getting Tiles

```python
import asyncio

from aiohttp import ClientSession

from pytile import async_login


async def main() -> None:
"""Run!"""
async with ClientSession() as session:
api = await async_login("<EMAIL>", "<PASSWORD>", session)

# Get all Tiles associated with an account:
await client.tiles.all()
tiles = await api.async_get_tiles()


asyncio.run(main())
```

By default, the library creates a new connection to Tile 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:
The `async_get_tiles` coroutine returns a dict with Tile UUIDs as the keys and `Tile`
objects as the values.

### The `Tile` Object

The Tile object comes with several properties:

* `accuracy`: the location accuracy of the Tile
* `altitude`: the altitude of the Tile
* `archetype`: the internal reference string that describes the Tile's "family"
* `dead`: whether the Tile is inactive
* `firmware_version`: the Tile's firmware version
* `hardware_version`: the Tile's hardware version
* `kind`: the kind of Tile (e.g., `TILE`, `PHONE`)
* `last_timestamp`: the timestamp at which the current attributes were received
* `latitude`: the latitude of the Tile
* `longitude`: the latitude of the Tile
* `lost`: whether the Tile has been marked as "lost"
* `lost_timestamp`: the timestamp at which the Tile was last marked as "lost"
* `name`: the name of the Tile
* `uuid`: the Tile UUID
* `visible`: whether the Tile is visible in the mobile app

```python
import asyncio
Expand All @@ -72,18 +141,20 @@ from pytile import async_login
async def main() -> None:
"""Run!"""
async with ClientSession() as session:
client = await async_login("<EMAIL>", "<PASSWORD>", session)
api = await async_login("<EMAIL>", "<PASSWORD>", session)

# Get all Tiles associated with an account:
await client.tiles.all()
tiles = await api.async_get_tiles()

for tile_uuid, tile in tiles.items():
print(f"The Tile's name is {tile.name}")
# ...


asyncio.run(main())
```

If for some reason you need to use a specific client UUID (to, say, ensure that the
Tile API sees you as a client it's seen before) or a specific locale, you can do
so easily:
In addition to these properties, the `Tile` object comes with an `async_update` coroutine
which requests new data from the Tile cloud API for this Tile:

```python
import asyncio
Expand All @@ -95,12 +166,13 @@ from pytile import async_login

async def main() -> None:
"""Run!"""
client = await async_login(
"<EMAIL>", "<PASSWORD>", client_uuid="MY_UUID", locale="en-GB"
)
async with ClientSession() as session:
api = await async_login("<EMAIL>", "<PASSWORD>", session)

tiles = await api.async_get_tiles()

# Get all Tiles associated with an account:
await client.tiles.all()
for tile_uuid, tile in tiles.items():
await tile.async_update()


asyncio.run(main())
Expand Down
27 changes: 19 additions & 8 deletions examples/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,30 @@
from pytile import async_login
from pytile.errors import TileError

TILE_EMAIL = "[email protected]"
TILE_PASSWORD = "}oeoGpGpVFh8VTFhKDzi"


async def main():
"""Run."""
async with ClientSession() as session:
try:
# Create a client:
client = await async_login("<EMAIL>", "<PASSWORD>", session=session)

print("Showing active Tiles:")
print(await client.tiles.all())

print("Showing all Tiles:")
print(await client.tiles.all(show_inactive=True))
api = await async_login(TILE_EMAIL, TILE_PASSWORD, session)

tiles = await api.async_get_tiles()
print(f"Tile Count: {len(tiles)}")
print()

tile = tiles["7a60a79e818b1404"]
await tile.async_set_name("Aaron's Wallet 2")
# for tile in tiles.values():
# print(f"UUID: {tile.uuid}")
# print(f"Name: {tile.name}")
# print(f"Type: {tile.kind}")
# print(f"Latitude: {tile.latitude}")
# print(f"Longitude: {tile.longitude}")
# print(f"Last Timestamp: {tile.last_timestamp}")
# print()
except TileError as err:
print(err)

Expand Down
2 changes: 1 addition & 1 deletion pytile/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""Define module-level imports."""
from .client import async_login # noqa
from .api import async_login # noqa
128 changes: 62 additions & 66 deletions pytile/client.py → pytile/api.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,35 @@
"""Define a client to interact with Pollen.com."""
from typing import Optional
"""Define an object to work directly with the API."""
import asyncio
import logging
from time import time
from typing import Dict, Optional
from uuid import uuid4

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

from .errors import RequestError, SessionExpiredError
from .errors import RequestError
from .tile import Tile
from .util import current_epoch_time

_LOGGER = logging.getLogger(__name__)

API_URL_SCAFFOLD: str = "https://production.tile-api.com/api/v1"

DEFAULT_APP_ID: str = "ios-tile-production"
DEFAULT_APP_VERSION: str = "2.55.1.3707"
DEFAULT_APP_VERSION: str = "2.69.0.4123"
DEFAULT_LOCALE: str = "en-US"
DEFAULT_TIMEOUT: int = 10


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

def __init__(
self,
email: str,
password: str,
session: ClientSession,
*,
session: Optional[ClientSession] = None,
client_uuid: Optional[str] = None,
locale: str = DEFAULT_LOCALE,
) -> None:
Expand All @@ -35,58 +40,24 @@ def __init__(
self._password: str = password
self._session: ClientSession = session
self._session_expiry: Optional[int] = None
self.tiles: Optional[Tile] = None
self.client_uuid: str = str(uuid4()) if not client_uuid else client_uuid
self.user_uuid: Optional[str] = None

self.client_uuid: str
self.client_uuid = str(uuid4()) if not client_uuid else client_uuid
async def async_get_tiles(self) -> Dict[str, Tile]:
"""Get all active Tiles from the user's account."""
states = await self.async_request("get", "tiles/tile_states")

async def _request(
self,
method: str,
endpoint: str,
*,
headers: Optional[dict] = None,
params: Optional[dict] = None,
data: Optional[dict] = None,
) -> dict:
"""Make a request against AirVisual."""
if self._session_expiry and self._session_expiry <= current_epoch_time():
raise SessionExpiredError("Session has expired; make a new one!")
details_tasks = {
tile_uuid: self.async_request("get", f"tiles/{tile_uuid}")
for tile_uuid in [tile["tile_id"] for tile in states["result"]]
}

_headers = headers or {}
_headers.update(
{
"Tile_app_id": DEFAULT_APP_ID,
"Tile_app_version": DEFAULT_APP_VERSION,
"Tile_client_uuid": self.client_uuid,
}
)
results = await asyncio.gather(*details_tasks.values())

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,
f"{API_URL_SCAFFOLD}/{endpoint}",
headers=_headers,
params=params,
data=data,
) as resp:
resp.raise_for_status()
return await resp.json(content_type=None)
except ClientError as err:
raise RequestError(
f"Error requesting data from {endpoint}: {err}"
) from None
finally:
if not use_running_session:
await session.close()
return {
tile_uuid: Tile(self.async_request, tile_data["result"])
for tile_uuid, tile_data, in zip(details_tasks, results)
}

async def async_init(self) -> None:
"""Create a Tile session."""
Expand All @@ -95,7 +66,7 @@ async def async_init(self) -> None:
self._session_expiry = None

if not self._client_established:
await self._request(
await self.async_request(
"put",
f"clients/{self.client_uuid}",
data={
Expand All @@ -106,7 +77,7 @@ async def async_init(self) -> None:
)
self._client_established = True

resp: dict = await self._request(
resp = await self.async_request(
"post",
f"clients/{self.client_uuid}/sessions",
data={"email": self._email, "password": self._password},
Expand All @@ -116,20 +87,45 @@ async def async_init(self) -> None:
self.user_uuid = resp["result"]["user"]["user_uuid"]
self._session_expiry = resp["result"]["session_expiration_timestamp"]

self.tiles = Tile(self._request, user_uuid=self.user_uuid)
async def async_request(self, method: str, endpoint: str, **kwargs) -> dict:
"""Make a request against AirVisual."""
if self._session_expiry and self._session_expiry <= int(time() * 1000):
await self.async_init()

kwargs.setdefault("headers", {})
kwargs["headers"].update(
{
"Tile_app_id": DEFAULT_APP_ID,
"Tile_app_version": DEFAULT_APP_VERSION,
"Tile_client_uuid": self.client_uuid,
}
)

async with self._session.request(
method, f"{API_URL_SCAFFOLD}/{endpoint}", **kwargs
) as resp:
try:
resp.raise_for_status()
data = await resp.json()
except ClientError as err:
raise RequestError(
f"Error requesting data from {endpoint}: {err}"
) from None

_LOGGER.debug("Data received from /%s: %s", endpoint, data)

return data


async def async_login(
email: str,
password: str,
session: ClientSession,
*,
client_uuid: Optional[str] = None,
locale: str = DEFAULT_LOCALE,
session: Optional[ClientSession] = None,
) -> Client:
) -> API:
"""Return an authenticated client."""
client = Client(
email, password, client_uuid=client_uuid, locale=locale, session=session
)
await client.async_init()
return client
api = API(email, password, session, client_uuid=client_uuid, locale=locale)
await api.async_init()
return api
Loading