Skip to content

Commit

Permalink
Add SubscriptionClient for the Subscription API (#163)
Browse files Browse the repository at this point in the history
This API is a new revision of the original Platform API. I'll probably
start to deprecate the latter (our original `Client` interface) soon.
  • Loading branch information
jparise authored Apr 9, 2024
1 parent 38b73f7 commit 1d93e7c
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 4 deletions.
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

## 0.12.0 - Unreleased
### Added
- All clients now support a user-provided [httpx.Client](https://www.python-httpx.org/api/#client)
objects.
- All clients now support a user-provided [httpx.Client](https://www.python-httpx.org/api/#client).
- Added support for the VBML `absolutePosition` style.
- Added support for the VBML `rawCharacters` component field.
- `SubscriptionClient` provides a client interface to Vestaboard's Subscription API.

### Changed
- The `encode_*()` functions now consistently specify keyword-only arguments.
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,25 @@ assert rw_client.write_message(message)
assert rw_client.read_message() == message
```

#### `SubscriptionClient`

`SubscriptionClient` provides a client interface for interacting with multiple
Vestaboards using the [Subscription API](https://docs.vestaboard.com/docs/subscription-api/introduction).

Note that an API secret and key is required to get subscriptions or send
messages. These credentials can be created from the [Developer section of the
web app](https://web.vestaboard.com/).

```py
import vesta
subscription_client = vesta.SubscriptionClient("api_key", "api_secret")

# List subscriptions and send them messages:
subscriptions = subscription_client.get_subscriptions()
for subscription in subscriptions:
subscription_client.send_message(subscription["id"], "Hello World")
```

#### `VBMLClient`

`VBMLClient` provides a client interface for Vestaboard's [VBML (Vestaboard
Expand Down
21 changes: 19 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,29 @@ with a Vestaboard using the `Read / Write API
.. important::

A Read / Write API key is required to read or write messages. This key is
obtained by enabling the Vestaboard's Read / Write API via the Settings
section of the mobile app or from the Developer section of the web app.
obtained by enabling the Vestaboard's Read / Write API via the *Settings*
section of the mobile app or from the `Developer section of the web app
<https://web.vestaboard.com/>`_.

.. autoclass:: vesta.ReadWriteClient
:members:

``SubscriptionClient``
----------------------

:py:class:`vesta.SubscriptionClient` provides a client interface for interacting
with multiple Vestaboards using the `Subscription API
<https://docs.vestaboard.com/docs/subscription-api/introduction>`_.

.. important::

An API secret and key is required to get subscriptions or send messages.
These credentials can be created from the `Developer section of the web
app <https://web.vestaboard.com/>`_.

.. autoclass:: vesta.SubscriptionClient
:members:

``VBMLClient``
--------------

Expand Down
2 changes: 2 additions & 0 deletions src/vesta/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .clients import Client
from .clients import LocalClient
from .clients import ReadWriteClient
from .clients import SubscriptionClient
from .clients import VBMLClient

__all__ = (
Expand All @@ -17,6 +18,7 @@
"Client",
"LocalClient",
"ReadWriteClient",
"SubscriptionClient",
"VBMLClient",
)

Expand Down
74 changes: 74 additions & 0 deletions src/vesta/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,80 @@ def write_message(self, message: Union[str, Rows]) -> bool:
return r.is_success


class SubscriptionClient:
"""Provides a Vestaboard Subscription API client interface.
Credentials must be provided as an ``api_key`` and ``api_secret``.
Optionally, an alternate ``base_url`` can be specified, as well as any
additional HTTP ``headers`` that should be sent with every request
(such as a custom `User-Agent` header).
.. versionadded:: 0.12.0
"""

def __init__(
self,
api_key: str,
api_secret: str,
*,
http_client: Optional[httpx.Client] = None,
base_url: str = "https://subscriptions.vestaboard.com",
headers: Optional[Mapping[str, str]] = None,
):
self.http = http_client or httpx.Client()
self.http.base_url = httpx.URL(base_url)
self.http.headers["x-vestaboard-api-key"] = api_key
self.http.headers["x-vestaboard-api-secret"] = api_secret
if headers:
self.http.headers.update(headers)

def __repr__(self):
return f"{type(self).__name__}(base_url={self.http.base_url!r})"

def get_subscriptions(self) -> List[Dict[str, Any]]:
"""Lists all subscriptions to which the viewer has access."""
r = self.http.get("/subscriptions")
r.raise_for_status()
return r.json()

def send_message(
self,
subscription_id: str,
message: Union[str, Rows],
) -> Dict[str, Any]:
"""Send a new message to a subscription.
The authenticated viewer must have access to the subscription.
`message` can be either a string of text or a two-dimensional (6, 22)
array of character codes representing the exact positions of characters
on the board.
If text is specified, the lines will be centered horizontally and
vertically. Character codes will be inferred for alphanumeric and
punctuation characters, or they can be explicitly specified using curly
braces containing the character code (such as ``{5}`` or ``{65}``).
:raises ValueError: if ``message`` is a list with unsupported dimensions
"""
data: Dict[str, Union[str, Rows]]
if isinstance(message, str):
data = {"text": message}
elif isinstance(message, list):
validate_rows(message)
data = {"characters": message}
else:
raise TypeError(f"unsupported message type: {type(message)}")

r = self.http.post(
f"/subscriptions/{subscription_id}/message",
json=data,
)
r.raise_for_status()
return r.json()


class VBMLClient:
"""Provides a VBML (Vestaboard Markup Language) API client interface.
Expand Down
59 changes: 59 additions & 0 deletions tests/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from vesta.clients import Client
from vesta.clients import LocalClient
from vesta.clients import ReadWriteClient
from vesta.clients import SubscriptionClient
from vesta.clients import VBMLClient
from vesta.vbml import Component

Expand All @@ -32,6 +33,11 @@ def rw_client():
return ReadWriteClient("key")


@pytest.fixture
def subscription_client():
return SubscriptionClient("key", "secret")


@pytest.fixture
def vbml_client():
return VBMLClient()
Expand Down Expand Up @@ -231,6 +237,59 @@ def test_write_message_type(self, rw_client: ReadWriteClient):
rw_client.write_message(True) # type: ignore


class TestSubscriptionClient:
def test_base_url(self):
base_url = "https://www.example.com"
client = SubscriptionClient("key", "secret", base_url=base_url)
assert client.http.base_url == base_url
assert base_url in repr(client)

def test_headers(self):
client = SubscriptionClient("key", "secret", headers={"User-Agent": "Vesta"})
assert client.http.headers["X-Vestaboard-Api-Key"] == "key"
assert client.http.headers["X-Vestaboard-Api-Secret"] == "secret"
assert client.http.headers["User-Agent"] == "Vesta"

def test_get_subscriptions(
self, subscription_client: SubscriptionClient, respx_mock: MockRouter
):
subscriptions = [True]
respx_mock.get("https://subscriptions.vestaboard.com/subscriptions").respond(
json=subscriptions
)
assert subscription_client.get_subscriptions() == subscriptions

def test_send_message_text(
self, subscription_client: SubscriptionClient, respx_mock: MockRouter
):
text = "abc"
respx_mock.post(
"https://subscriptions.vestaboard.com/subscriptions/sub_id/message",
json={"text": text},
).respond(json={})
subscription_client.send_message("sub_id", text)

def test_send_message_list(
self, subscription_client: SubscriptionClient, respx_mock: MockRouter
):
chars = [[0] * COLS] * ROWS
respx_mock.post(
"https://subscriptions.vestaboard.com/subscriptions/sub_id/message",
json={"characters": chars},
).respond(json={})
subscription_client.send_message("sub_id", chars)

def test_send_message_list_dimensions(
self, subscription_client: SubscriptionClient
):
with pytest.raises(ValueError, match=rf"expected a \({COLS}, {ROWS}\) array"):
subscription_client.send_message("sub_id", [])

def test_send_message_type(self, subscription_client: SubscriptionClient):
with pytest.raises(TypeError, match=r"unsupported message type"):
subscription_client.send_message("sub_id", True) # type: ignore


class TestVBMLClient:
def test_base_url(self):
base_url = "http://example.local"
Expand Down

0 comments on commit 1d93e7c

Please sign in to comment.