From 21b9b2e7bb7cd5209633f5242284432b9b3126c2 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 17 Nov 2025 17:06:59 +0100 Subject: [PATCH 01/38] Add full API --- .gitignore | 3 + README.md | 53 +++--- example.py | 36 ++-- pushpad/__init__.py | 22 +-- pushpad/_version.py | 3 + pushpad/exceptions.py | 33 ++++ pushpad/notification.py | 92 ---------- pushpad/pushpad.py | 355 ++++++++++++++++++++++++++++++++++++- setup.py | 2 +- tests/test_notification.py | 350 ------------------------------------ tests/test_pushpad.py | 133 +++++++++++--- 11 files changed, 555 insertions(+), 527 deletions(-) create mode 100644 .gitignore create mode 100644 pushpad/_version.py create mode 100644 pushpad/exceptions.py delete mode 100644 pushpad/notification.py delete mode 100644 tests/test_notification.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d114b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.eggs +/dist +/pushpad.egg-info diff --git a/README.md b/README.md index f05e837..4b774b1 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Then set your authentication credentials and project: ```python import pushpad -project = pushpad.Pushpad(auth_token='5374d7dfeffa2eb49965624ba7596a09', project_id=123) +client = pushpad.Pushpad(auth_token='5374d7dfeffa2eb49965624ba7596a09', project_id=123) ``` - `auth_token` can be found in the user account settings. @@ -36,19 +36,18 @@ You can subscribe the users to your notifications using the Javascript SDK, as d If you need to generate the HMAC signature for the `uid` you can use this helper: ```python -project.signature_for(current_user_id) +client.signature_for(current_user_id) ``` ## Sending push notifications ```python +import datetime import pushpad -project = pushpad.Pushpad(auth_token='5374d7dfeffa2eb49965624ba7596a09', project_id=123) - -notification = pushpad.Notification( - project, +client = pushpad.Pushpad(auth_token='5374d7dfeffa2eb49965624ba7596a09', project_id=123) +result = client.notifications.create( # required, the main content of the notification body="Hello world!", @@ -99,37 +98,33 @@ notification = pushpad.Notification( # optional, use this option only if you need to create scheduled notifications (max 5 days) # see https://pushpad.xyz/docs/schedule_notifications - send_at=datetime.datetime(2016, 7, 25, 10, 9, 0, 0), # you need to import datetime and use UTC + send_at=datetime.datetime(2016, 7, 25, 10, 9, 0, 0), # use UTC # optional, add the notification to custom categories for stats aggregation # see https://pushpad.xyz/docs/monitoring - custom_metrics=('examples', 'another_metric') # up to 3 metrics per notification -) - -# deliver to a user -notification.deliver_to(user_id) + custom_metrics=('examples', 'another_metric'), # up to 3 metrics per notification -# deliver to a group of users -notification.deliver_to((user1_id, user2_id, user3_id)) + # target specific users (omit both uids and tags to broadcast) + uids=('user1', 'user2', 'user3'), -# deliver to some users only if they have a given preference -# e.g. only the listed users who have also a interested in "events" will be reached -notification.deliver_to((user1_id, user2_id, user3_id), tags=('events',)) - -# deliver to segments -# e.g. any subscriber that has the tag "segment1" OR "segment2" -notification.broadcast(tags=('segment1', 'segment2')) + # target segments using tags or boolean expressions + tags=('segment1', 'segment2') +) -# you can use boolean expressions -# they can include parentheses and the operators !, &&, || (from highest to lowest precedence) -# https://pushpad.xyz/docs/tags -notification.broadcast(tags='zip_code:28865 && !optout:local_events || friend_of:Organizer123') -notification.deliver_to((user1_id, user2_id), tags=('tag1 && tag2', 'tag3')) # equal to 'tag1 && tag2 || tag3' +# Inspect the response +print(result['id'], result['scheduled']) -# deliver to everyone -notification.broadcast() +# List, inspect, or cancel notifications +client.notifications.all(page=1) +client.notifications.get(result['id']) +client.notifications.cancel(result['id']) ``` +Set `uids` to reach a list of user IDs, set `tags` to reach subscribers that match your segments +(`tags` accepts boolean expressions using `!`, `&&`, and `||`). When both are present a user must +match the uid filter *and* have at least one of the listed tags. When both are omitted the notification +is broadcast to everyone. + You can set the default values for most fields in the project settings. See also [the docs](https://pushpad.xyz/docs/rest_api#notifications_api_docs) for more information about notification fields. If you try to send a notification to a user ID, but that user is not subscribed, that ID is simply ignored. @@ -138,7 +133,7 @@ The methods above return a dictionary: - `'id'` is the id of the notification on Pushpad - `'scheduled'` is the estimated reach of the notification (i.e. the number of devices to which the notification will be sent, which can be different from the number of users, since a user may receive notifications on multiple devices) -- `'uids'` (`deliver_to` only) are the user IDs that will be actually reached by the notification because they are subscribed to your notifications. For example if you send a notification to `['uid1', 'uid2', 'uid3']`, but only `'uid1'` is subscribed, you will get `['uid1']` in response. Note that if a user has unsubscribed after the last notification sent to him, he may still be reported for one time as subscribed (this is due to the way the W3C Push API works). +- `'uids'` (when you pass `uids` while creating the notification) are the user IDs that will be actually reached by the notification because they are subscribed to your notifications. For example if you send a notification to `['uid1', 'uid2', 'uid3']`, but only `'uid1'` is subscribed, you will get `['uid1']` in response. Note that if a user has unsubscribed after the last notification sent to him, he may still be reported for one time as subscribed (this is due to the way the W3C Push API works). - `'send_at'` is present only for scheduled notifications. The fields `'scheduled'` and `'uids'` are not available in this case. ## License diff --git a/example.py b/example.py index 85dcb51..ae1d718 100644 --- a/example.py +++ b/example.py @@ -1,29 +1,29 @@ # -*- coding: utf-8 -*- +"""Example usage of the Pushpad Python client.""" import pushpad -user1 = 'user1' -user2 = 'user2' -user3 = 'user3' -users = [user1, user2, user3] -tags = ['segment1', 'segment2'] +TOKEN = "5374d7dfeffa2eb49965624ba7596a09" +PROJECT_ID = 123 -TOKEN='5374d7dfeffa2eb49965624ba7596a09' -PROJ_ID=123 +client = pushpad.Pushpad(auth_token=TOKEN, project_id=PROJECT_ID) -project = pushpad.Pushpad(TOKEN, PROJ_ID) +print(f"HMAC signature for 'user1': {client.signature_for('user1')}") -print("HMAC signature for the uid: %s is: %s" %(user1, project.signature_for(user1))) - -notification = pushpad.Notification( - project, +created = client.notifications.create( body="Hello world!", title="Website Name", - target_url="https://example.com" + target_url="https://example.com", + uids=["user1", "user2", "user3"], + tags=["segment1", "segment2"], ) +print(f"Notification accepted with id: {created['id']}") + +latest = client.notifications.all(page=1) +print(f"Latest notifications: {latest}") + +subscriptions = client.subscriptions.all(per_page=5) +print(f"First page of subscriptions: {subscriptions}") -print("Send notification to user: %s\nResult: %s" % (user1, notification.deliver_to(user1))) -print("Send notification to users: %s\nResult: %s" % (users, notification.deliver_to(users))) -print("Send broadcast notification\nResult: %s" % notification.broadcast()) -print("Send notification to users: %s if they are tagged: %s \nResult: %s" % (users, tags, notification.deliver_to(users, tags=tags))) -print("Send broadcast notification to segments: %s \nResult: %s" % (tags, notification.broadcast(tags=tags))) +count = client.subscriptions.count(tags=["segment1 && !optout"]) +print(f"Subscribers in the filtered segment: {count}") diff --git a/pushpad/__init__.py b/pushpad/__init__.py index 425c593..6f206f3 100644 --- a/pushpad/__init__.py +++ b/pushpad/__init__.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- -from .pushpad import Pushpad -from .notification import Notification - -class PushpadBaseException(BaseException): - """ - Generic pushpad exception - """ - def __init__(self, *args, **kwargs): - BaseException.__init__(self, *args, **kwargs) +"""Public package interface.""" +from ._version import __version__ +from .exceptions import PushpadAPIError, PushpadClientError, PushpadError +from .pushpad import Pushpad -class NotificationDeliveryError(PushpadBaseException): - pass \ No newline at end of file +__all__ = [ + "__version__", + "Pushpad", + "PushpadError", + "PushpadClientError", + "PushpadAPIError", +] diff --git a/pushpad/_version.py b/pushpad/_version.py new file mode 100644 index 0000000..a8e6871 --- /dev/null +++ b/pushpad/_version.py @@ -0,0 +1,3 @@ +__all__ = ["__version__"] + +__version__ = "2.0.0" diff --git a/pushpad/exceptions.py b/pushpad/exceptions.py new file mode 100644 index 0000000..1e6b959 --- /dev/null +++ b/pushpad/exceptions.py @@ -0,0 +1,33 @@ +"""Custom exceptions raised by the Pushpad client library.""" + +from __future__ import annotations + +from typing import Any, Optional + + +class PushpadError(Exception): + """Base class for all library errors.""" + + +class PushpadClientError(PushpadError): + """Raised for local/network errors before a response is received.""" + + def __init__(self, message: str, *, original_exception: Optional[BaseException] = None) -> None: + super().__init__(message) + self.original_exception = original_exception + + +class PushpadAPIError(PushpadError): + """Raised for HTTP errors returned by the Pushpad API.""" + + def __init__( + self, + status_code: int, + message: Optional[str] = None, + *, + response_body: Optional[Any] = None, + ) -> None: + msg = message or f"Pushpad API error (status_code={status_code})" + super().__init__(msg) + self.status_code = status_code + self.response_body = response_body diff --git a/pushpad/notification.py b/pushpad/notification.py deleted file mode 100644 index aab66f5..0000000 --- a/pushpad/notification.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import -import requests -import pushpad - - -class Notification(object): - def __init__(self, project, body=None, title=None, target_url=None, icon_url=None, badge_url=None, ttl=None, require_interaction=None, silent=None, urgent=None, image_url=None, custom_data=None, custom_metrics=None, actions=None, starred=None, send_at=None): - self._project = project - self._body = body - self._title = title - self._target_url = target_url - self._icon_url = icon_url - self._badge_url = badge_url - self._ttl = ttl - self._require_interaction = require_interaction - self._silent = silent - self._urgent = urgent - self._image_url = image_url - self._custom_data = custom_data - self._custom_metrics = custom_metrics - self._actions = actions - self._starred = starred - self._send_at = send_at - - def _req_headers(self): - return { - 'Authorization': 'Token token="%s"' % self._project.auth_token, - 'Content-Type': 'application/json;charset=UTF-8', - 'Accept': 'application/json', - } - - def _req_body(self, uids=None, tags=None): - res = { - 'notification': { - 'body': self._body, - } - } - if self._title: - res['notification']['title'] = self._title - if self._target_url: - res['notification']['target_url'] = self._target_url - if self._icon_url: - res['notification']['icon_url'] = self._icon_url - if self._badge_url: - res['notification']['badge_url'] = self._badge_url - if self._ttl: - res['notification']['ttl'] = self._ttl - if self._require_interaction is not None: - res['notification']['require_interaction'] = self._require_interaction - if self._silent is not None: - res['notification']['silent'] = self._silent - if self._urgent is not None: - res['notification']['urgent'] = self._urgent - if self._image_url: - res['notification']['image_url'] = self._image_url - if self._custom_data: - res['notification']['custom_data'] = self._custom_data - if self._custom_metrics: - res['notification']['custom_metrics'] = self._custom_metrics - if self._actions: - res['notification']['actions'] = self._actions - if self._starred is not None: - res['notification']['starred'] = self._starred - if self._send_at: - res['notification']['send_at'] = self._send_at.strftime('%Y-%m-%dT%R') - - if uids != None: - res['uids'] = uids - if tags != None: - res['tags'] = tags - return res - - def _deliver(self, req_body): - response = requests.post( - 'https://pushpad.xyz/api/v1/projects/%s/notifications' % self._project.project_id, - headers=self._req_headers(), - json=req_body, - ) - if response.status_code != 201: - raise pushpad.NotificationDeliveryError('Response %s: %s' %(response.status_code, response.text)) - return response.json() - - def broadcast(self, tags=None): - return self._deliver(self._req_body(None, tags)) - - def deliver_to(self, uids, tags=None): - if not uids: - uids = [] # prevent broadcasting - return self._deliver( - req_body=self._req_body(uids, tags) - ) diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index 9138963..4930095 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -1,12 +1,355 @@ -# -*- coding: utf-8 -*- -from hashlib import sha256 +"""High level Pushpad API client.""" + +from __future__ import annotations + import hmac +from datetime import date, datetime, timezone +from hashlib import sha256 +from typing import Any, Dict, Iterable, MutableMapping, Optional + +try: # pragma: no cover - exercised when requests is available + import requests # type: ignore + RequestException = requests.RequestException +except ModuleNotFoundError: # pragma: no cover - fallback for limited envs/tests + requests = None # type: ignore + + class RequestException(Exception): + """Fallback exception used when the requests package is not installed.""" + + pass + +from ._version import __version__ +from .exceptions import PushpadAPIError, PushpadClientError + +JSONDict = MutableMapping[str, Any] + + +class APIObject(dict): + """Dictionary that also exposes keys as attributes.""" + + def __getattr__(self, item: str) -> Any: + try: + return self[item] + except KeyError as exc: # pragma: no cover - defensive + raise AttributeError(item) from exc + + +def _wrap_response(data: Any) -> Any: + if isinstance(data, dict): + return APIObject({key: _wrap_response(value) for key, value in data.items()}) + if isinstance(data, list): + return [_wrap_response(item) for item in data] + return data + + +def _isoformat(value: datetime) -> str: + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + +def _serialize_value(value: Any) -> Any: + if isinstance(value, datetime): + return _isoformat(value) + if isinstance(value, date): + return value.isoformat() + if isinstance(value, dict): + return {key: _serialize_value(val) for key, val in value.items() if val is not None} + if isinstance(value, (list, tuple, set)): + return [_serialize_value(item) for item in value if item is not None] + return value -class Pushpad(object): - def __init__(self, auth_token, project_id): + +def _prepare_payload(data: Optional[JSONDict]) -> Optional[JSONDict]: + if not data: + return None + return _serialize_value(dict(data)) # type: ignore[arg-type] + + +def _prepare_params(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if not params: + return None + serialized = _serialize_value(dict(params)) + assert isinstance(serialized, dict) + return serialized # type: ignore[return-value] + + +class Pushpad: + """High level client used to interact with the Pushpad REST API.""" + + DEFAULT_BASE_URL = "https://pushpad.xyz/api/v1" + + def __init__( + self, + auth_token: str, + project_id: Optional[int] = None, + *, + base_url: Optional[str] = None, + timeout: int = 10, + session: Optional[Any] = None, + ) -> None: + if not auth_token: + raise ValueError("auth_token is required") self.auth_token = auth_token self.project_id = project_id + self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/") + self.timeout = timeout + if session is not None: + self._session = session + else: + if requests is None: + raise RuntimeError("The 'requests' package is required unless you provide a session instance.") + self._session = requests.Session() + self._session.headers.update( + { + "Authorization": f"Bearer {self.auth_token}", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": f"pushpad-python/{__version__}", + } + ) + + self.notifications = NotificationsResource(self) + self.subscriptions = SubscriptionsResource(self) + self.projects = ProjectsResource(self) + self.senders = SendersResource(self) + + def __enter__(self) -> "Pushpad": + return self + + def __exit__(self, exc_type, exc, exc_tb) -> None: + self.close() + + def close(self) -> None: + """Close the underlying HTTP session.""" + close = getattr(self._session, "close", None) + if callable(close): + close() + + def signature_for(self, data: str) -> str: + """Return the HMAC signature for a user identifier.""" + return hmac.new(self.auth_token.encode(), data.encode(), sha256).hexdigest() + + def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json: Optional[JSONDict] = None, + raw: bool = False, + ): + url = f"{self.base_url}{path}" + try: + response = self._session.request( + method, + url, + params=_prepare_params(params), + json=_prepare_payload(json), + timeout=self.timeout, + ) + except RequestException as exc: + raise PushpadClientError(str(exc), original_exception=exc) from exc + + if response.status_code >= 400: + message: Optional[str] = None + payload: Optional[Any] = None + try: + payload = response.json() + if isinstance(payload, dict): + message = payload.get("error") or payload.get("message") + except ValueError: + if response.text: + message = response.text + raise PushpadAPIError(response.status_code, message, response_body=payload or response.text) + + if raw: + return response + + if response.status_code in (202, 204) or not response.content: + return None + + content_type = response.headers.get("Content-Type", "") + if "application/json" in content_type: + try: + return _wrap_response(response.json()) + except ValueError as exc: # pragma: no cover - unexpected API behaviour + raise PushpadAPIError(response.status_code, "Invalid JSON in response") from exc + return response.content + + +class _ProjectBoundResource: + def __init__(self, client: Pushpad) -> None: + self._client = client + + def _project_id(self, project_id: Optional[int]) -> int: + pid = project_id if project_id is not None else self._client.project_id + if pid is None: + raise ValueError("project_id is required for this operation") + return pid + + +class NotificationsResource(_ProjectBoundResource): + def all(self, *, project_id: Optional[int] = None, page: Optional[int] = None, **filters: Any): + pid = self._project_id(project_id) + params = {k: v for k, v in {"page": page, **filters}.items() if v is not None} + return self._client._request("GET", f"/projects/{pid}/notifications", params=params) + + def create(self, *, project_id: Optional[int] = None, **notification: Any): + pid = self._project_id(project_id) + return self._client._request("POST", f"/projects/{pid}/notifications", json=notification) + + def get(self, notification_id: int): + if notification_id is None: + raise ValueError("notification_id is required") + return self._client._request("GET", f"/notifications/{notification_id}") + + def cancel(self, notification_id: int) -> bool: + if notification_id is None: + raise ValueError("notification_id is required") + self._client._request("DELETE", f"/notifications/{notification_id}/cancel") + return True + + +class SubscriptionsResource(_ProjectBoundResource): + def _build_filters(self, values: Dict[str, Any]) -> Dict[str, Any]: + params = dict(values) + uids = params.pop("uids", None) + tags = params.pop("tags", None) + + def _normalize(value: Optional[Iterable[str]]): + if value is None: + return None + if isinstance(value, (list, tuple, set)): + return list(value) + return [value] + + normalized_uids = _normalize(uids) + normalized_tags = _normalize(tags) + if normalized_uids is not None: + params["uids[]"] = normalized_uids + if normalized_tags is not None: + params["tags[]"] = normalized_tags + return {k: v for k, v in params.items() if v is not None} + + def all( + self, + *, + project_id: Optional[int] = None, + page: Optional[int] = None, + per_page: Optional[int] = None, + uids: Optional[Iterable[str]] = None, + tags: Optional[Iterable[str]] = None, + **filters: Any, + ): + pid = self._project_id(project_id) + params = self._build_filters( + {"page": page, "per_page": per_page, "uids": uids, "tags": tags, **filters} + ) + return self._client._request("GET", f"/projects/{pid}/subscriptions", params=params) + + def count( + self, + *, + project_id: Optional[int] = None, + uids: Optional[Iterable[str]] = None, + tags: Optional[Iterable[str]] = None, + **filters: Any, + ) -> int: + pid = self._project_id(project_id) + params = self._build_filters({"uids": uids, "tags": tags, **filters}) + params.setdefault("per_page", 1) + response = self._client._request( + "GET", + f"/projects/{pid}/subscriptions", + params=params, + raw=True, + ) + total = response.headers.get("X-Total-Count") + if total is not None: + try: + return int(total) + except ValueError: + pass + try: + data = response.json() + except ValueError: + data = [] + return len(data) + + def create(self, *, project_id: Optional[int] = None, **subscription: Any): + pid = self._project_id(project_id) + return self._client._request("POST", f"/projects/{pid}/subscriptions", json=subscription) + + def get(self, subscription_id: int, *, project_id: Optional[int] = None): + if subscription_id is None: + raise ValueError("subscription_id is required") + pid = self._project_id(project_id) + return self._client._request("GET", f"/projects/{pid}/subscriptions/{subscription_id}") + + def update(self, subscription_id: int, *, project_id: Optional[int] = None, **subscription: Any): + if subscription_id is None: + raise ValueError("subscription_id is required") + pid = self._project_id(project_id) + return self._client._request("PATCH", f"/projects/{pid}/subscriptions/{subscription_id}", json=subscription) + + def delete(self, subscription_id: int, *, project_id: Optional[int] = None) -> bool: + if subscription_id is None: + raise ValueError("subscription_id is required") + pid = self._project_id(project_id) + self._client._request("DELETE", f"/projects/{pid}/subscriptions/{subscription_id}") + return True + + +class ProjectsResource: + def __init__(self, client: Pushpad) -> None: + self._client = client + + def all(self): + return self._client._request("GET", "/projects") + + def create(self, **project: Any): + return self._client._request("POST", "/projects", json=project) + + def get(self, project_id: int): + if project_id is None: + raise ValueError("project_id is required") + return self._client._request("GET", f"/projects/{project_id}") + + def update(self, project_id: int, **project: Any): + if project_id is None: + raise ValueError("project_id is required") + return self._client._request("PATCH", f"/projects/{project_id}", json=project) + + def delete(self, project_id: int) -> bool: + if project_id is None: + raise ValueError("project_id is required") + self._client._request("DELETE", f"/projects/{project_id}") + return True + + +class SendersResource: + def __init__(self, client: Pushpad) -> None: + self._client = client + + def all(self): + return self._client._request("GET", "/senders") + + def create(self, **sender: Any): + return self._client._request("POST", "/senders", json=sender) + + def get(self, sender_id: int): + if sender_id is None: + raise ValueError("sender_id is required") + return self._client._request("GET", f"/senders/{sender_id}") + + def update(self, sender_id: int, **sender: Any): + if sender_id is None: + raise ValueError("sender_id is required") + return self._client._request("PATCH", f"/senders/{sender_id}", json=sender) - def signature_for(self, data): - return hmac.new(bytes(self.auth_token.encode()), data.encode(), sha256).hexdigest() + def delete(self, sender_id: int) -> bool: + if sender_id is None: + raise ValueError("sender_id is required") + self._client._request("DELETE", f"/senders/{sender_id}") + return True diff --git a/setup.py b/setup.py index c166f36..81e01e7 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ setup( name='pushpad', - version='1.0.0', + version='2.0.0', description='Pushpad: real push notifications for websites', url='https://pushpad.xyz', author='Pushpad', diff --git a/tests/test_notification.py b/tests/test_notification.py deleted file mode 100644 index 6b6c7ac..0000000 --- a/tests/test_notification.py +++ /dev/null @@ -1,350 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import -import unittest -import pushpad -import requests -import datetime -try: - import mock -except ImportError: - from unittest import mock - - -class TestNotification(unittest.TestCase): - _test_token = '5374d7dfeffa2eb49965624ba7596a09' - _test_project_id = 123 - - _project = pushpad.Pushpad(_test_token, _test_project_id) - - def test_instantiate(self): - self.assertIsNotNone( - pushpad.Notification(self._project, body="Hello world!") - ) - notification = pushpad.Notification( - self._project, - body="Hello world!", - title="Website Name", - target_url="https://example.com", - icon_url="https://example.com/assets/icon.png", - badge_url="https://example.com/assets/badge.png", - ttl=604800, - require_interaction=True, - silent=True, - urgent=True, - image_url="https://example.com/assets/image.png", - custom_data="123", - custom_metrics=('examples', 'another_metric'), - actions=( - { - 'title': "My Button 1", - 'target_url': "https://example.com/button-link", - 'icon': "https://example.com/assets/button-icon.png", - 'action': "myActionName" - }, - ), - starred=True, - send_at=datetime.datetime(2016, 7, 25, 10, 9, 0, 0) - ) - self.assertIsNotNone(notification) - self.assertEqual(notification._body, "Hello world!") - self.assertEqual(notification._title, "Website Name") - self.assertEqual(notification._target_url, "https://example.com") - self.assertEqual(notification._icon_url, "https://example.com/assets/icon.png") - self.assertEqual(notification._badge_url, "https://example.com/assets/badge.png") - self.assertEqual(notification._ttl, 604800) - self.assertEqual(notification._require_interaction, True) - self.assertEqual(notification._silent, True) - self.assertEqual(notification._urgent, True) - self.assertEqual(notification._image_url, "https://example.com/assets/image.png") - self.assertEqual(notification._custom_data, "123") - self.assertEqual(notification._custom_metrics, ('examples', 'another_metric')) - self.assertEqual(notification._actions, ( - { - 'title': "My Button 1", - 'target_url': "https://example.com/button-link", - 'icon': "https://example.com/assets/button-icon.png", - 'action': "myActionName" - }, - )) - self.assertEqual(notification._starred, True) - self.assertEqual(notification._send_at, datetime.datetime(2016, 7, 25, 10, 9, 0, 0)) - - def test_req_headers(self): - headers = { - 'Authorization': 'Token token="5374d7dfeffa2eb49965624ba7596a09"', - 'Content-Type': 'application/json;charset=UTF-8', - 'Accept': 'application/json', - } - notification = pushpad.Notification( - self._project, - body="Hello world!" - ) - self.assertDictEqual( - notification._req_headers(), - headers - ) - - def test_req_body_with_optional_fields(self): - body = { - 'notification': { - 'body': 'Hello world!', - 'title': 'Website Name', - 'target_url': 'https://example.com', - 'icon_url': 'https://example.com/assets/icon.png', - 'badge_url': 'https://example.com/assets/badge.png', - 'ttl': 604800, - 'require_interaction': True, - 'silent': True, - 'urgent': True, - 'image_url': 'https://example.com/assets/image.png', - 'custom_data': '123', - 'custom_metrics': ('examples', 'another_metric'), - 'actions': ( - { - 'title': 'My Button 1', - 'target_url': 'https://example.com/button-link', - 'icon': 'https://example.com/assets/button-icon.png', - 'action': 'myActionName' - }, - ), - 'starred': True, - 'send_at': '2016-07-25T10:09' - } - } - notification = pushpad.Notification( - self._project, - body="Hello world!", - title="Website Name", - target_url="https://example.com", - icon_url="https://example.com/assets/icon.png", - badge_url="https://example.com/assets/badge.png", - ttl=604800, - require_interaction=True, - silent=True, - urgent=True, - image_url="https://example.com/assets/image.png", - custom_data="123", - custom_metrics=('examples', 'another_metric'), - actions=( - { - 'title': "My Button 1", - 'target_url': "https://example.com/button-link", - 'icon': "https://example.com/assets/button-icon.png", - 'action': "myActionName" - }, - ), - starred=True, - send_at=datetime.datetime(2016, 7, 25, 10, 9, 0, 0) - ) - self.assertDictEqual( - notification._req_body(), - body - ) - - def test_req_body_uids(self): - body = { - 'notification': { - 'body': 'Hello world!' - }, - 'uids': ('user1', 'user2', 'user3') - } - notification = pushpad.Notification( - self._project, - body="Hello world!" - ) - self.assertDictEqual( - notification._req_body(('user1', 'user2', 'user3')), - body - ) - - def test_req_body_uid(self): - body = { - 'notification': { - 'body': 'Hello world!' - }, - 'uids': 'user1' - } - notification = pushpad.Notification( - self._project, - body="Hello world!" - ) - self.assertDictEqual( - notification._req_body('user1'), - body - ) - - def test_req_body_tags(self): - body = { - 'notification': { - 'body': 'Hello world!' - }, - 'tags': ('tag1', 'tag2') - } - notification = pushpad.Notification( - self._project, - body="Hello world!" - ) - self.assertDictEqual( - notification._req_body(tags=('tag1', 'tag2')), - body - ) - - def test_req_body_tag(self): - body = { - 'notification': { - 'body': 'Hello world!' - }, - 'tags': 'tag1' - } - notification = pushpad.Notification( - self._project, - body="Hello world!" - ) - self.assertDictEqual( - notification._req_body(tags='tag1'), - body - ) - - @mock.patch('requests.post') - def test_deliver(self, req_post_mock): - body = { - 'notification': { - 'body': 'Hello world!', - 'title': 'Website Name', - 'target_url': 'https://example.com', - }, - 'uids': 'user1' - } - - mock_response = mock.Mock() - resp_json = {u'scheduled': 76} - mock_response.status_code = 201 - mock_response.json.return_value = resp_json - req_post_mock.return_value = mock_response - - notification = pushpad.Notification( - self._project, - body="Hello world!", - title="Website Name", - target_url="https://example.com" - ) - - notification._deliver(body) - req_post_mock.assert_called_once_with( - 'https://pushpad.xyz/api/v1/projects/123/notifications', - headers={ - 'Content-Type': 'application/json;charset=UTF-8', - 'Accept': 'application/json', - 'Authorization': 'Token token="5374d7dfeffa2eb49965624ba7596a09"' - }, - json={ - 'notification': { - 'body': 'Hello world!', - 'target_url': 'https://example.com', - 'title': 'Website Name' - }, - 'uids': 'user1' - } - ) - - @mock.patch('pushpad.Notification._deliver') - def test_broadcast(self, deliver_mock): - notification = pushpad.Notification( - self._project, - body="Hello world!", - title="Website Name", - target_url="https://example.com" - ) - - notification.broadcast() - deliver_mock.assert_called_once_with( - { - 'notification': { - 'title': 'Website Name', - 'target_url': 'https://example.com', - 'body': 'Hello world!' - } - } - ) - - @mock.patch('pushpad.Notification._deliver') - def test_broadcast_with_tags(self, deliver_mock): - notification = pushpad.Notification( - self._project, - body="Hello world!" - ) - notification.broadcast(tags=('tag1', 'tag2')) - deliver_mock.assert_called_once_with( - { - 'notification': { - 'body': 'Hello world!' - }, - 'tags': ('tag1', 'tag2') - } - ) - - @mock.patch('pushpad.Notification._deliver') - def test_deliver_to(self, deliver_mock): - notification = pushpad.Notification( - self._project, - body="Hello world!", - title="Website Name", - target_url="https://example.com" - ) - - notification.deliver_to('user1') - deliver_mock.assert_called_once_with( - req_body={ - 'notification': { - 'body': 'Hello world!', - 'target_url':'https://example.com', - 'title': 'Website Name' - }, - 'uids': 'user1' - } - ) - - @mock.patch('pushpad.Notification._deliver') - def test_deliver_to_with_tags(self, deliver_mock): - notification = pushpad.Notification( - self._project, - body="Hello world!" - ) - notification.deliver_to(('user1', 'user2'), tags=('tag1', 'tag2')) - deliver_mock.assert_called_once_with( - req_body={ - 'notification': { - 'body': 'Hello world!' - }, - 'uids': ('user1', 'user2'), - 'tags': ('tag1', 'tag2') - } - ) - - @mock.patch('requests.post') - def test_deliver_to_never_broadcasts(self, req_post_mock): - notification = pushpad.Notification( - self._project, - body="Hello world!" - ) - - mock_response = mock.Mock() - mock_response.status_code = 201 - mock_response.json.return_value = {u'scheduled': 0} - req_post_mock.return_value = mock_response - - notification.deliver_to(None) - req_post_mock.assert_called_once_with( - 'https://pushpad.xyz/api/v1/projects/123/notifications', - headers={ - 'Content-Type': 'application/json;charset=UTF-8', - 'Accept': 'application/json', - 'Authorization': 'Token token="5374d7dfeffa2eb49965624ba7596a09"' - }, - json={ - 'notification': { - 'body': 'Hello world!' - }, - 'uids': [] - } - ) diff --git a/tests/test_pushpad.py b/tests/test_pushpad.py index 6cfaa82..51ae526 100644 --- a/tests/test_pushpad.py +++ b/tests/test_pushpad.py @@ -1,32 +1,125 @@ # -*- coding: utf-8 -*- +import json import unittest +from unittest import mock + import pushpad +from pushpad import PushpadAPIError + + +def make_response(status=200, payload=None, headers=None): + response = mock.Mock() + response.status_code = status + response.headers = headers or {"Content-Type": "application/json"} + if payload is None: + response.content = b"" + response.text = "" + response.json.side_effect = ValueError("No JSON") + else: + body = json.dumps(payload).encode() + response.content = body + response.text = body.decode() + response.json.return_value = payload + return response + + +class DummySession: + def __init__(self): + self.headers = {} + self.request = mock.Mock() + + def close(self): + pass + + +def make_client(token, project_id=None, response=None): + session = DummySession() + if response is not None: + session.request.return_value = response + client = pushpad.Pushpad(token, project_id, session=session) + return client, session + + +class PushpadClientTests(unittest.TestCase): + def setUp(self): + self.token = "5374d7dfeffa2eb49965624ba7596a09" + self.project_id = 1 + + def test_signature_for(self): + client, _ = make_client(self.token, self.project_id) + self.assertEqual( + client.signature_for("user12345"), + "6627820dab00a1971f2a6d3ff16a5ad8ba4048a02b2d402820afc61aefd0b69f", + ) + def test_notifications_create(self): + response = make_response(payload={"id": 123, "scheduled": 10}) + client, session = make_client(self.token, self.project_id, response) + result = client.notifications.create(body="Hello") + self.assertEqual(result["id"], 123) + self.assertEqual(result.id, 123) + method, url = session.request.call_args[0] + self.assertEqual(method, "POST") + self.assertIn("/projects/1/notifications", url) + self.assertEqual(session.request.call_args[1]["json"], {"body": "Hello"}) -class TestPushpad(unittest.TestCase): - _test_token = '5374d7dfeffa2eb49965624ba7596a09' - _test_project_id = 123 + def test_notifications_requires_project(self): + client, session = make_client(self.token) + with self.assertRaises(ValueError): + client.notifications.create(body="Hello") + session.request.assert_not_called() - def test_instantiate(self): - """ pushpad can be instantiated""" + def test_notifications_all(self): + response = make_response(payload=[{"id": 1}]) + client, session = make_client(self.token, self.project_id, response) + result = client.notifications.all(page=2) + self.assertEqual(result, [{"id": 1}]) + kwargs = session.request.call_args[1] + self.assertEqual(kwargs["params"], {"page": 2}) - project = pushpad.Pushpad(self._test_token, self._test_project_id) - self.assertIsNotNone(project) + def test_notifications_cancel(self): + response = make_response(status=204) + client, session = make_client(self.token, self.project_id, response) + self.assertTrue(client.notifications.cancel(10)) + method, url = session.request.call_args[0] + self.assertEqual(method, "DELETE") + self.assertIn("/notifications/10/cancel", url) - def test_set_token(self): - """ can change auth_token """ - project = pushpad.Pushpad(self._test_token, self._test_project_id) - self.assertEqual(project.auth_token, self._test_token) + def test_subscriptions_count_uses_header(self): + headers = {"Content-Type": "application/json", "X-Total-Count": "42"} + response = make_response(payload=[], headers=headers) + client, session = make_client(self.token, self.project_id, response) + count = client.subscriptions.count(tags=["paid"]) + self.assertEqual(count, 42) + kwargs = session.request.call_args[1] + self.assertEqual(kwargs["params"], {"tags[]": ["paid"], "per_page": 1}) - def test_set_project(self): - """ can change project_id """ + def test_subscriptions_all_accepts_boolean_expression(self): + response = make_response(payload=[]) + client, session = make_client(self.token, self.project_id, response) + client.subscriptions.all(tags="tag1 && tag2") + params = session.request.call_args[1]["params"] + self.assertEqual(params["tags[]"], ["tag1 && tag2"]) - project = pushpad.Pushpad(self._test_token, self._test_project_id) - self.assertEqual(project.project_id, self._test_project_id) + def test_projects_delete(self): + response = make_response(status=202) + client, session = make_client(self.token, response=response) + self.assertTrue(client.projects.delete(99)) + method, url = session.request.call_args[0] + self.assertEqual(method, "DELETE") + self.assertTrue(url.endswith("/projects/99")) - def test_get_signature(self): - data = "user12345" - data_sha1 = "6627820dab00a1971f2a6d3ff16a5ad8ba4048a02b2d402820afc61aefd0b69f" + def test_senders_update(self): + response = make_response(payload={"id": 55}) + client, session = make_client(self.token, response=response) + client.senders.update(55, name="Acme") + method, url = session.request.call_args[0] + self.assertEqual(method, "PATCH") + self.assertTrue(url.endswith("/senders/55")) + self.assertEqual(session.request.call_args[1]["json"], {"name": "Acme"}) - project = pushpad.Pushpad(self._test_token, self._test_project_id) - self.assertEqual(project.signature_for(data), data_sha1) + def test_error_response(self): + response = make_response(status=403, payload={"error": "Forbidden"}) + client, _ = make_client(self.token, self.project_id, response) + with self.assertRaises(PushpadAPIError): + client.notifications.all() From 2e786709345be5ac8eb419a04a08c2d8464e14ca Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 17 Nov 2025 17:55:10 +0100 Subject: [PATCH 02/38] Add CRUD tests for different resources --- tests/test_pushpad.py | 118 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/tests/test_pushpad.py b/tests/test_pushpad.py index 51ae526..a37799e 100644 --- a/tests/test_pushpad.py +++ b/tests/test_pushpad.py @@ -77,6 +77,15 @@ def test_notifications_all(self): kwargs = session.request.call_args[1] self.assertEqual(kwargs["params"], {"page": 2}) + def test_notifications_get(self): + response = make_response(payload={"id": 77}) + client, session = make_client(self.token, self.project_id, response) + result = client.notifications.get(77) + self.assertEqual(result.id, 77) + method, url = session.request.call_args[0] + self.assertEqual(method, "GET") + self.assertTrue(url.endswith("/notifications/77")) + def test_notifications_cancel(self): response = make_response(status=204) client, session = make_client(self.token, self.project_id, response) @@ -101,6 +110,80 @@ def test_subscriptions_all_accepts_boolean_expression(self): params = session.request.call_args[1]["params"] self.assertEqual(params["tags[]"], ["tag1 && tag2"]) + def test_subscriptions_create(self): + response = make_response(payload={"id": 11}) + client, session = make_client(self.token, self.project_id, response) + subscription = client.subscriptions.create(uid="u1") + self.assertEqual(subscription.id, 11) + method, url = session.request.call_args[0] + self.assertEqual(method, "POST") + self.assertIn("/projects/1/subscriptions", url) + self.assertEqual(session.request.call_args[1]["json"], {"uid": "u1"}) + + def test_subscriptions_get(self): + response = make_response(payload={"id": 22}) + client, session = make_client(self.token, self.project_id, response) + subscription = client.subscriptions.get(22) + self.assertEqual(subscription.id, 22) + method, url = session.request.call_args[0] + self.assertEqual(method, "GET") + self.assertTrue(url.endswith("/projects/1/subscriptions/22")) + + def test_subscriptions_update(self): + response = make_response(payload={"id": 33, "tags": ["a"]}) + client, session = make_client(self.token, self.project_id, response) + subscription = client.subscriptions.update(33, tags=["a"]) + self.assertEqual(subscription.tags, ["a"]) + method, url = session.request.call_args[0] + self.assertEqual(method, "PATCH") + self.assertTrue(url.endswith("/projects/1/subscriptions/33")) + self.assertEqual(session.request.call_args[1]["json"], {"tags": ["a"]}) + + def test_subscriptions_delete(self): + response = make_response(status=204) + client, session = make_client(self.token, self.project_id, response) + self.assertTrue(client.subscriptions.delete(44)) + method, url = session.request.call_args[0] + self.assertEqual(method, "DELETE") + self.assertTrue(url.endswith("/projects/1/subscriptions/44")) + + def test_projects_all(self): + response = make_response(payload=[{"id": 1}]) + client, session = make_client(self.token, response=response) + self.assertEqual(client.projects.all(), [{"id": 1}]) + method, url = session.request.call_args[0] + self.assertEqual(method, "GET") + self.assertTrue(url.endswith("/projects")) + + def test_projects_create(self): + response = make_response(payload={"id": 2}) + client, session = make_client(self.token, response=response) + project = client.projects.create(name="Demo") + self.assertEqual(project.id, 2) + method, url = session.request.call_args[0] + self.assertEqual(method, "POST") + self.assertTrue(url.endswith("/projects")) + self.assertEqual(session.request.call_args[1]["json"], {"name": "Demo"}) + + def test_projects_get(self): + response = make_response(payload={"id": 3}) + client, session = make_client(self.token, response=response) + project = client.projects.get(3) + self.assertEqual(project.id, 3) + method, url = session.request.call_args[0] + self.assertEqual(method, "GET") + self.assertTrue(url.endswith("/projects/3")) + + def test_projects_update(self): + response = make_response(payload={"id": 4, "name": "Demo"}) + client, session = make_client(self.token, response=response) + project = client.projects.update(4, name="Demo") + self.assertEqual(project.name, "Demo") + method, url = session.request.call_args[0] + self.assertEqual(method, "PATCH") + self.assertTrue(url.endswith("/projects/4")) + self.assertEqual(session.request.call_args[1]["json"], {"name": "Demo"}) + def test_projects_delete(self): response = make_response(status=202) client, session = make_client(self.token, response=response) @@ -109,6 +192,33 @@ def test_projects_delete(self): self.assertEqual(method, "DELETE") self.assertTrue(url.endswith("/projects/99")) + def test_senders_all(self): + response = make_response(payload=[{"id": 1}]) + client, session = make_client(self.token, response=response) + self.assertEqual(client.senders.all(), [{"id": 1}]) + method, url = session.request.call_args[0] + self.assertEqual(method, "GET") + self.assertTrue(url.endswith("/senders")) + + def test_senders_create(self): + response = make_response(payload={"id": 2}) + client, session = make_client(self.token, response=response) + sender = client.senders.create(name="News") + self.assertEqual(sender.id, 2) + method, url = session.request.call_args[0] + self.assertEqual(method, "POST") + self.assertTrue(url.endswith("/senders")) + self.assertEqual(session.request.call_args[1]["json"], {"name": "News"}) + + def test_senders_get(self): + response = make_response(payload={"id": 3}) + client, session = make_client(self.token, response=response) + sender = client.senders.get(3) + self.assertEqual(sender.id, 3) + method, url = session.request.call_args[0] + self.assertEqual(method, "GET") + self.assertTrue(url.endswith("/senders/3")) + def test_senders_update(self): response = make_response(payload={"id": 55}) client, session = make_client(self.token, response=response) @@ -118,6 +228,14 @@ def test_senders_update(self): self.assertTrue(url.endswith("/senders/55")) self.assertEqual(session.request.call_args[1]["json"], {"name": "Acme"}) + def test_senders_delete(self): + response = make_response(status=204) + client, session = make_client(self.token, response=response) + self.assertTrue(client.senders.delete(66)) + method, url = session.request.call_args[0] + self.assertEqual(method, "DELETE") + self.assertTrue(url.endswith("/senders/66")) + def test_error_response(self): response = make_response(status=403, payload={"error": "Forbidden"}) client, _ = make_client(self.token, self.project_id, response) From 16ed2220d3879330720126cd4840c20a40a359ab Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 17 Nov 2025 19:48:48 +0100 Subject: [PATCH 03/38] Extract resources to separate files --- pushpad/pushpad.py | 180 +---------------------------- pushpad/resources/__init__.py | 13 +++ pushpad/resources/base.py | 19 +++ pushpad/resources/notifications.py | 29 +++++ pushpad/resources/projects.py | 35 ++++++ pushpad/resources/senders.py | 35 ++++++ pushpad/resources/subscriptions.py | 97 ++++++++++++++++ 7 files changed, 230 insertions(+), 178 deletions(-) create mode 100644 pushpad/resources/__init__.py create mode 100644 pushpad/resources/base.py create mode 100644 pushpad/resources/notifications.py create mode 100644 pushpad/resources/projects.py create mode 100644 pushpad/resources/senders.py create mode 100644 pushpad/resources/subscriptions.py diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index 4930095..ae46dd0 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -5,7 +5,7 @@ import hmac from datetime import date, datetime, timezone from hashlib import sha256 -from typing import Any, Dict, Iterable, MutableMapping, Optional +from typing import Any, Dict, MutableMapping, Optional try: # pragma: no cover - exercised when requests is available import requests # type: ignore @@ -20,6 +20,7 @@ class RequestException(Exception): from ._version import __version__ from .exceptions import PushpadAPIError, PushpadClientError +from .resources import NotificationsResource, ProjectsResource, SendersResource, SubscriptionsResource JSONDict = MutableMapping[str, Any] @@ -176,180 +177,3 @@ def _request( except ValueError as exc: # pragma: no cover - unexpected API behaviour raise PushpadAPIError(response.status_code, "Invalid JSON in response") from exc return response.content - - -class _ProjectBoundResource: - def __init__(self, client: Pushpad) -> None: - self._client = client - - def _project_id(self, project_id: Optional[int]) -> int: - pid = project_id if project_id is not None else self._client.project_id - if pid is None: - raise ValueError("project_id is required for this operation") - return pid - - -class NotificationsResource(_ProjectBoundResource): - def all(self, *, project_id: Optional[int] = None, page: Optional[int] = None, **filters: Any): - pid = self._project_id(project_id) - params = {k: v for k, v in {"page": page, **filters}.items() if v is not None} - return self._client._request("GET", f"/projects/{pid}/notifications", params=params) - - def create(self, *, project_id: Optional[int] = None, **notification: Any): - pid = self._project_id(project_id) - return self._client._request("POST", f"/projects/{pid}/notifications", json=notification) - - def get(self, notification_id: int): - if notification_id is None: - raise ValueError("notification_id is required") - return self._client._request("GET", f"/notifications/{notification_id}") - - def cancel(self, notification_id: int) -> bool: - if notification_id is None: - raise ValueError("notification_id is required") - self._client._request("DELETE", f"/notifications/{notification_id}/cancel") - return True - - -class SubscriptionsResource(_ProjectBoundResource): - def _build_filters(self, values: Dict[str, Any]) -> Dict[str, Any]: - params = dict(values) - uids = params.pop("uids", None) - tags = params.pop("tags", None) - - def _normalize(value: Optional[Iterable[str]]): - if value is None: - return None - if isinstance(value, (list, tuple, set)): - return list(value) - return [value] - - normalized_uids = _normalize(uids) - normalized_tags = _normalize(tags) - if normalized_uids is not None: - params["uids[]"] = normalized_uids - if normalized_tags is not None: - params["tags[]"] = normalized_tags - return {k: v for k, v in params.items() if v is not None} - - def all( - self, - *, - project_id: Optional[int] = None, - page: Optional[int] = None, - per_page: Optional[int] = None, - uids: Optional[Iterable[str]] = None, - tags: Optional[Iterable[str]] = None, - **filters: Any, - ): - pid = self._project_id(project_id) - params = self._build_filters( - {"page": page, "per_page": per_page, "uids": uids, "tags": tags, **filters} - ) - return self._client._request("GET", f"/projects/{pid}/subscriptions", params=params) - - def count( - self, - *, - project_id: Optional[int] = None, - uids: Optional[Iterable[str]] = None, - tags: Optional[Iterable[str]] = None, - **filters: Any, - ) -> int: - pid = self._project_id(project_id) - params = self._build_filters({"uids": uids, "tags": tags, **filters}) - params.setdefault("per_page", 1) - response = self._client._request( - "GET", - f"/projects/{pid}/subscriptions", - params=params, - raw=True, - ) - total = response.headers.get("X-Total-Count") - if total is not None: - try: - return int(total) - except ValueError: - pass - try: - data = response.json() - except ValueError: - data = [] - return len(data) - - def create(self, *, project_id: Optional[int] = None, **subscription: Any): - pid = self._project_id(project_id) - return self._client._request("POST", f"/projects/{pid}/subscriptions", json=subscription) - - def get(self, subscription_id: int, *, project_id: Optional[int] = None): - if subscription_id is None: - raise ValueError("subscription_id is required") - pid = self._project_id(project_id) - return self._client._request("GET", f"/projects/{pid}/subscriptions/{subscription_id}") - - def update(self, subscription_id: int, *, project_id: Optional[int] = None, **subscription: Any): - if subscription_id is None: - raise ValueError("subscription_id is required") - pid = self._project_id(project_id) - return self._client._request("PATCH", f"/projects/{pid}/subscriptions/{subscription_id}", json=subscription) - - def delete(self, subscription_id: int, *, project_id: Optional[int] = None) -> bool: - if subscription_id is None: - raise ValueError("subscription_id is required") - pid = self._project_id(project_id) - self._client._request("DELETE", f"/projects/{pid}/subscriptions/{subscription_id}") - return True - - -class ProjectsResource: - def __init__(self, client: Pushpad) -> None: - self._client = client - - def all(self): - return self._client._request("GET", "/projects") - - def create(self, **project: Any): - return self._client._request("POST", "/projects", json=project) - - def get(self, project_id: int): - if project_id is None: - raise ValueError("project_id is required") - return self._client._request("GET", f"/projects/{project_id}") - - def update(self, project_id: int, **project: Any): - if project_id is None: - raise ValueError("project_id is required") - return self._client._request("PATCH", f"/projects/{project_id}", json=project) - - def delete(self, project_id: int) -> bool: - if project_id is None: - raise ValueError("project_id is required") - self._client._request("DELETE", f"/projects/{project_id}") - return True - - -class SendersResource: - def __init__(self, client: Pushpad) -> None: - self._client = client - - def all(self): - return self._client._request("GET", "/senders") - - def create(self, **sender: Any): - return self._client._request("POST", "/senders", json=sender) - - def get(self, sender_id: int): - if sender_id is None: - raise ValueError("sender_id is required") - return self._client._request("GET", f"/senders/{sender_id}") - - def update(self, sender_id: int, **sender: Any): - if sender_id is None: - raise ValueError("sender_id is required") - return self._client._request("PATCH", f"/senders/{sender_id}", json=sender) - - def delete(self, sender_id: int) -> bool: - if sender_id is None: - raise ValueError("sender_id is required") - self._client._request("DELETE", f"/senders/{sender_id}") - return True diff --git a/pushpad/resources/__init__.py b/pushpad/resources/__init__.py new file mode 100644 index 0000000..a6ed568 --- /dev/null +++ b/pushpad/resources/__init__.py @@ -0,0 +1,13 @@ +"""Resource modules for the Pushpad client.""" + +from .notifications import NotificationsResource +from .projects import ProjectsResource +from .senders import SendersResource +from .subscriptions import SubscriptionsResource + +__all__ = [ + "NotificationsResource", + "SubscriptionsResource", + "ProjectsResource", + "SendersResource", +] diff --git a/pushpad/resources/base.py b/pushpad/resources/base.py new file mode 100644 index 0000000..e77369b --- /dev/null +++ b/pushpad/resources/base.py @@ -0,0 +1,19 @@ +"""Shared helper classes for resource modules.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - only used for typing + from ..pushpad import Pushpad + + +class _ProjectBoundResource: + def __init__(self, client: "Pushpad") -> None: + self._client = client + + def _project_id(self, project_id: Optional[int]) -> int: + pid = project_id if project_id is not None else self._client.project_id + if pid is None: + raise ValueError("project_id is required for this operation") + return pid diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py new file mode 100644 index 0000000..8bea3aa --- /dev/null +++ b/pushpad/resources/notifications.py @@ -0,0 +1,29 @@ +"""Notifications API resource.""" + +from __future__ import annotations + +from typing import Any, Optional + +from .base import _ProjectBoundResource + + +class NotificationsResource(_ProjectBoundResource): + def all(self, *, project_id: Optional[int] = None, page: Optional[int] = None, **filters: Any): + pid = self._project_id(project_id) + params = {k: v for k, v in {"page": page, **filters}.items() if v is not None} + return self._client._request("GET", f"/projects/{pid}/notifications", params=params) + + def create(self, *, project_id: Optional[int] = None, **notification: Any): + pid = self._project_id(project_id) + return self._client._request("POST", f"/projects/{pid}/notifications", json=notification) + + def get(self, notification_id: int): + if notification_id is None: + raise ValueError("notification_id is required") + return self._client._request("GET", f"/notifications/{notification_id}") + + def cancel(self, notification_id: int) -> bool: + if notification_id is None: + raise ValueError("notification_id is required") + self._client._request("DELETE", f"/notifications/{notification_id}/cancel") + return True diff --git a/pushpad/resources/projects.py b/pushpad/resources/projects.py new file mode 100644 index 0000000..6e4cd17 --- /dev/null +++ b/pushpad/resources/projects.py @@ -0,0 +1,35 @@ +"""Projects API resource.""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - only used for typing + from ..pushpad import Pushpad + + +class ProjectsResource: + def __init__(self, client: "Pushpad") -> None: + self._client = client + + def all(self): + return self._client._request("GET", "/projects") + + def create(self, **project: Any): + return self._client._request("POST", "/projects", json=project) + + def get(self, project_id: int): + if project_id is None: + raise ValueError("project_id is required") + return self._client._request("GET", f"/projects/{project_id}") + + def update(self, project_id: int, **project: Any): + if project_id is None: + raise ValueError("project_id is required") + return self._client._request("PATCH", f"/projects/{project_id}", json=project) + + def delete(self, project_id: int) -> bool: + if project_id is None: + raise ValueError("project_id is required") + self._client._request("DELETE", f"/projects/{project_id}") + return True diff --git a/pushpad/resources/senders.py b/pushpad/resources/senders.py new file mode 100644 index 0000000..550d5e7 --- /dev/null +++ b/pushpad/resources/senders.py @@ -0,0 +1,35 @@ +"""Senders API resource.""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - only used for typing + from ..pushpad import Pushpad + + +class SendersResource: + def __init__(self, client: "Pushpad") -> None: + self._client = client + + def all(self): + return self._client._request("GET", "/senders") + + def create(self, **sender: Any): + return self._client._request("POST", "/senders", json=sender) + + def get(self, sender_id: int): + if sender_id is None: + raise ValueError("sender_id is required") + return self._client._request("GET", f"/senders/{sender_id}") + + def update(self, sender_id: int, **sender: Any): + if sender_id is None: + raise ValueError("sender_id is required") + return self._client._request("PATCH", f"/senders/{sender_id}", json=sender) + + def delete(self, sender_id: int) -> bool: + if sender_id is None: + raise ValueError("sender_id is required") + self._client._request("DELETE", f"/senders/{sender_id}") + return True diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py new file mode 100644 index 0000000..96b1cc9 --- /dev/null +++ b/pushpad/resources/subscriptions.py @@ -0,0 +1,97 @@ +"""Subscriptions API resource.""" + +from __future__ import annotations + +from typing import Any, Dict, Iterable, Optional + +from .base import _ProjectBoundResource + + +class SubscriptionsResource(_ProjectBoundResource): + def _build_filters(self, values: Dict[str, Any]) -> Dict[str, Any]: + params = dict(values) + uids = params.pop("uids", None) + tags = params.pop("tags", None) + + def _normalize(value: Optional[Iterable[str]]): + if value is None: + return None + if isinstance(value, (list, tuple, set)): + return list(value) + return [value] + + normalized_uids = _normalize(uids) + normalized_tags = _normalize(tags) + if normalized_uids is not None: + params["uids[]"] = normalized_uids + if normalized_tags is not None: + params["tags[]"] = normalized_tags + return {k: v for k, v in params.items() if v is not None} + + def all( + self, + *, + project_id: Optional[int] = None, + page: Optional[int] = None, + per_page: Optional[int] = None, + uids: Optional[Iterable[str]] = None, + tags: Optional[Iterable[str]] = None, + **filters: Any, + ): + pid = self._project_id(project_id) + params = self._build_filters( + {"page": page, "per_page": per_page, "uids": uids, "tags": tags, **filters} + ) + return self._client._request("GET", f"/projects/{pid}/subscriptions", params=params) + + def count( + self, + *, + project_id: Optional[int] = None, + uids: Optional[Iterable[str]] = None, + tags: Optional[Iterable[str]] = None, + **filters: Any, + ) -> int: + pid = self._project_id(project_id) + params = self._build_filters({"uids": uids, "tags": tags, **filters}) + params.setdefault("per_page", 1) + response = self._client._request( + "GET", + f"/projects/{pid}/subscriptions", + params=params, + raw=True, + ) + total = response.headers.get("X-Total-Count") + if total is not None: + try: + return int(total) + except ValueError: + pass + try: + data = response.json() + except ValueError: + data = [] + return len(data) + + def create(self, *, project_id: Optional[int] = None, **subscription: Any): + pid = self._project_id(project_id) + return self._client._request("POST", f"/projects/{pid}/subscriptions", json=subscription) + + def get(self, subscription_id: int, *, project_id: Optional[int] = None): + if subscription_id is None: + raise ValueError("subscription_id is required") + pid = self._project_id(project_id) + return self._client._request("GET", f"/projects/{pid}/subscriptions/{subscription_id}") + + def update(self, subscription_id: int, *, project_id: Optional[int] = None, **subscription: Any): + if subscription_id is None: + raise ValueError("subscription_id is required") + pid = self._project_id(project_id) + return self._client._request("PATCH", f"/projects/{pid}/subscriptions/{subscription_id}", json=subscription) + + def delete(self, subscription_id: int, *, project_id: Optional[int] = None) -> bool: + if subscription_id is None: + raise ValueError("subscription_id is required") + pid = self._project_id(project_id) + self._client._request("DELETE", f"/projects/{pid}/subscriptions/{subscription_id}") + return True From 2277bec96a86de5816df7f2a5be4f4fdad0ff1d2 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 12:11:10 +0100 Subject: [PATCH 04/38] Extract resource tests to separate files --- tests/helpers.py | 45 +++++ tests/resources/test_notifications.py | 46 ++++++ tests/resources/test_projects.py | 49 ++++++ tests/resources/test_senders.py | 48 ++++++ tests/resources/test_subscriptions.py | 57 +++++++ tests/test_pushpad.py | 228 +------------------------- 6 files changed, 247 insertions(+), 226 deletions(-) create mode 100644 tests/helpers.py create mode 100644 tests/resources/test_notifications.py create mode 100644 tests/resources/test_projects.py create mode 100644 tests/resources/test_senders.py create mode 100644 tests/resources/test_subscriptions.py diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..183ac93 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +import json +import unittest +from unittest import mock + +import pushpad + + +def make_response(status=200, payload=None, headers=None): + response = mock.Mock() + response.status_code = status + response.headers = headers or {"Content-Type": "application/json"} + if payload is None: + response.content = b"" + response.text = "" + response.json.side_effect = ValueError("No JSON") + else: + body = json.dumps(payload).encode() + response.content = body + response.text = body.decode() + response.json.return_value = payload + return response + + +class DummySession: + def __init__(self): + self.headers = {} + self.request = mock.Mock() + + def close(self): + pass + + +def make_client(token, project_id=None, response=None): + session = DummySession() + if response is not None: + session.request.return_value = response + client = pushpad.Pushpad(token, project_id, session=session) + return client, session + + +class BasePushpadTestCase(unittest.TestCase): + def setUp(self): + self.token = "5374d7dfeffa2eb49965624ba7596a09" + self.project_id = 1 diff --git a/tests/resources/test_notifications.py b/tests/resources/test_notifications.py new file mode 100644 index 0000000..badc1cc --- /dev/null +++ b/tests/resources/test_notifications.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from tests.helpers import BasePushpadTestCase, make_client, make_response + + +class NotificationsResourceTests(BasePushpadTestCase): + def test_notifications_create(self): + response = make_response(payload={"id": 123, "scheduled": 10}) + client, session = make_client(self.token, self.project_id, response) + result = client.notifications.create(body="Hello") + self.assertEqual(result["id"], 123) + self.assertEqual(result.id, 123) + method, url = session.request.call_args[0] + self.assertEqual(method, "POST") + self.assertIn("/projects/1/notifications", url) + self.assertEqual(session.request.call_args[1]["json"], {"body": "Hello"}) + + def test_notifications_requires_project(self): + client, session = make_client(self.token) + with self.assertRaises(ValueError): + client.notifications.create(body="Hello") + session.request.assert_not_called() + + def test_notifications_all(self): + response = make_response(payload=[{"id": 1}]) + client, session = make_client(self.token, self.project_id, response) + result = client.notifications.all(page=2) + self.assertEqual(result, [{"id": 1}]) + kwargs = session.request.call_args[1] + self.assertEqual(kwargs["params"], {"page": 2}) + + def test_notifications_get(self): + response = make_response(payload={"id": 77}) + client, session = make_client(self.token, self.project_id, response) + result = client.notifications.get(77) + self.assertEqual(result.id, 77) + method, url = session.request.call_args[0] + self.assertEqual(method, "GET") + self.assertTrue(url.endswith("/notifications/77")) + + def test_notifications_cancel(self): + response = make_response(status=204) + client, session = make_client(self.token, self.project_id, response) + self.assertTrue(client.notifications.cancel(10)) + method, url = session.request.call_args[0] + self.assertEqual(method, "DELETE") + self.assertIn("/notifications/10/cancel", url) diff --git a/tests/resources/test_projects.py b/tests/resources/test_projects.py new file mode 100644 index 0000000..d9b790a --- /dev/null +++ b/tests/resources/test_projects.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +from tests.helpers import BasePushpadTestCase, make_client, make_response + + +class ProjectsResourceTests(BasePushpadTestCase): + def test_projects_create(self): + response = make_response(payload={"id": 2}) + client, session = make_client(self.token, response=response) + project = client.projects.create(name="Demo") + self.assertEqual(project.id, 2) + method, url = session.request.call_args[0] + self.assertEqual(method, "POST") + self.assertTrue(url.endswith("/projects")) + self.assertEqual(session.request.call_args[1]["json"], {"name": "Demo"}) + + def test_projects_all(self): + response = make_response(payload=[{"id": 1}]) + client, session = make_client(self.token, response=response) + self.assertEqual(client.projects.all(), [{"id": 1}]) + method, url = session.request.call_args[0] + self.assertEqual(method, "GET") + self.assertTrue(url.endswith("/projects")) + + def test_projects_get(self): + response = make_response(payload={"id": 3}) + client, session = make_client(self.token, response=response) + project = client.projects.get(3) + self.assertEqual(project.id, 3) + method, url = session.request.call_args[0] + self.assertEqual(method, "GET") + self.assertTrue(url.endswith("/projects/3")) + + def test_projects_update(self): + response = make_response(payload={"id": 4, "name": "Demo"}) + client, session = make_client(self.token, response=response) + project = client.projects.update(4, name="Demo") + self.assertEqual(project.name, "Demo") + method, url = session.request.call_args[0] + self.assertEqual(method, "PATCH") + self.assertTrue(url.endswith("/projects/4")) + self.assertEqual(session.request.call_args[1]["json"], {"name": "Demo"}) + + def test_projects_delete(self): + response = make_response(status=202) + client, session = make_client(self.token, response=response) + self.assertTrue(client.projects.delete(99)) + method, url = session.request.call_args[0] + self.assertEqual(method, "DELETE") + self.assertTrue(url.endswith("/projects/99")) diff --git a/tests/resources/test_senders.py b/tests/resources/test_senders.py new file mode 100644 index 0000000..122cfbb --- /dev/null +++ b/tests/resources/test_senders.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from tests.helpers import BasePushpadTestCase, make_client, make_response + + +class SendersResourceTests(BasePushpadTestCase): + def test_senders_create(self): + response = make_response(payload={"id": 2}) + client, session = make_client(self.token, response=response) + sender = client.senders.create(name="News") + self.assertEqual(sender.id, 2) + method, url = session.request.call_args[0] + self.assertEqual(method, "POST") + self.assertTrue(url.endswith("/senders")) + self.assertEqual(session.request.call_args[1]["json"], {"name": "News"}) + + def test_senders_all(self): + response = make_response(payload=[{"id": 1}]) + client, session = make_client(self.token, response=response) + self.assertEqual(client.senders.all(), [{"id": 1}]) + method, url = session.request.call_args[0] + self.assertEqual(method, "GET") + self.assertTrue(url.endswith("/senders")) + + def test_senders_get(self): + response = make_response(payload={"id": 3}) + client, session = make_client(self.token, response=response) + sender = client.senders.get(3) + self.assertEqual(sender.id, 3) + method, url = session.request.call_args[0] + self.assertEqual(method, "GET") + self.assertTrue(url.endswith("/senders/3")) + + def test_senders_update(self): + response = make_response(payload={"id": 55}) + client, session = make_client(self.token, response=response) + client.senders.update(55, name="Acme") + method, url = session.request.call_args[0] + self.assertEqual(method, "PATCH") + self.assertTrue(url.endswith("/senders/55")) + self.assertEqual(session.request.call_args[1]["json"], {"name": "Acme"}) + + def test_senders_delete(self): + response = make_response(status=204) + client, session = make_client(self.token, response=response) + self.assertTrue(client.senders.delete(66)) + method, url = session.request.call_args[0] + self.assertEqual(method, "DELETE") + self.assertTrue(url.endswith("/senders/66")) diff --git a/tests/resources/test_subscriptions.py b/tests/resources/test_subscriptions.py new file mode 100644 index 0000000..c8e4509 --- /dev/null +++ b/tests/resources/test_subscriptions.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +from tests.helpers import BasePushpadTestCase, make_client, make_response + + +class SubscriptionsResourceTests(BasePushpadTestCase): + def test_subscriptions_create(self): + response = make_response(payload={"id": 11}) + client, session = make_client(self.token, self.project_id, response) + subscription = client.subscriptions.create(uid="u1") + self.assertEqual(subscription.id, 11) + method, url = session.request.call_args[0] + self.assertEqual(method, "POST") + self.assertIn("/projects/1/subscriptions", url) + self.assertEqual(session.request.call_args[1]["json"], {"uid": "u1"}) + + def test_subscriptions_all_accepts_boolean_expression(self): + response = make_response(payload=[]) + client, session = make_client(self.token, self.project_id, response) + client.subscriptions.all(tags="tag1 && tag2") + params = session.request.call_args[1]["params"] + self.assertEqual(params["tags[]"], ["tag1 && tag2"]) + + def test_subscriptions_count_uses_header(self): + headers = {"Content-Type": "application/json", "X-Total-Count": "42"} + response = make_response(payload=[], headers=headers) + client, session = make_client(self.token, self.project_id, response) + count = client.subscriptions.count(tags=["paid"]) + self.assertEqual(count, 42) + kwargs = session.request.call_args[1] + self.assertEqual(kwargs["params"], {"tags[]": ["paid"], "per_page": 1}) + + def test_subscriptions_get(self): + response = make_response(payload={"id": 22}) + client, session = make_client(self.token, self.project_id, response) + subscription = client.subscriptions.get(22) + self.assertEqual(subscription.id, 22) + method, url = session.request.call_args[0] + self.assertEqual(method, "GET") + self.assertTrue(url.endswith("/projects/1/subscriptions/22")) + + def test_subscriptions_update(self): + response = make_response(payload={"id": 33, "tags": ["a"]}) + client, session = make_client(self.token, self.project_id, response) + subscription = client.subscriptions.update(33, tags=["a"]) + self.assertEqual(subscription.tags, ["a"]) + method, url = session.request.call_args[0] + self.assertEqual(method, "PATCH") + self.assertTrue(url.endswith("/projects/1/subscriptions/33")) + self.assertEqual(session.request.call_args[1]["json"], {"tags": ["a"]}) + + def test_subscriptions_delete(self): + response = make_response(status=204) + client, session = make_client(self.token, self.project_id, response) + self.assertTrue(client.subscriptions.delete(44)) + method, url = session.request.call_args[0] + self.assertEqual(method, "DELETE") + self.assertTrue(url.endswith("/projects/1/subscriptions/44")) diff --git a/tests/test_pushpad.py b/tests/test_pushpad.py index a37799e..a84873c 100644 --- a/tests/test_pushpad.py +++ b/tests/test_pushpad.py @@ -1,50 +1,10 @@ # -*- coding: utf-8 -*- -import json -import unittest -from unittest import mock - -import pushpad from pushpad import PushpadAPIError +from tests.helpers import BasePushpadTestCase, make_client, make_response -def make_response(status=200, payload=None, headers=None): - response = mock.Mock() - response.status_code = status - response.headers = headers or {"Content-Type": "application/json"} - if payload is None: - response.content = b"" - response.text = "" - response.json.side_effect = ValueError("No JSON") - else: - body = json.dumps(payload).encode() - response.content = body - response.text = body.decode() - response.json.return_value = payload - return response - - -class DummySession: - def __init__(self): - self.headers = {} - self.request = mock.Mock() - - def close(self): - pass - - -def make_client(token, project_id=None, response=None): - session = DummySession() - if response is not None: - session.request.return_value = response - client = pushpad.Pushpad(token, project_id, session=session) - return client, session - - -class PushpadClientTests(unittest.TestCase): - def setUp(self): - self.token = "5374d7dfeffa2eb49965624ba7596a09" - self.project_id = 1 +class PushpadClientTests(BasePushpadTestCase): def test_signature_for(self): client, _ = make_client(self.token, self.project_id) self.assertEqual( @@ -52,190 +12,6 @@ def test_signature_for(self): "6627820dab00a1971f2a6d3ff16a5ad8ba4048a02b2d402820afc61aefd0b69f", ) - def test_notifications_create(self): - response = make_response(payload={"id": 123, "scheduled": 10}) - client, session = make_client(self.token, self.project_id, response) - result = client.notifications.create(body="Hello") - self.assertEqual(result["id"], 123) - self.assertEqual(result.id, 123) - method, url = session.request.call_args[0] - self.assertEqual(method, "POST") - self.assertIn("/projects/1/notifications", url) - self.assertEqual(session.request.call_args[1]["json"], {"body": "Hello"}) - - def test_notifications_requires_project(self): - client, session = make_client(self.token) - with self.assertRaises(ValueError): - client.notifications.create(body="Hello") - session.request.assert_not_called() - - def test_notifications_all(self): - response = make_response(payload=[{"id": 1}]) - client, session = make_client(self.token, self.project_id, response) - result = client.notifications.all(page=2) - self.assertEqual(result, [{"id": 1}]) - kwargs = session.request.call_args[1] - self.assertEqual(kwargs["params"], {"page": 2}) - - def test_notifications_get(self): - response = make_response(payload={"id": 77}) - client, session = make_client(self.token, self.project_id, response) - result = client.notifications.get(77) - self.assertEqual(result.id, 77) - method, url = session.request.call_args[0] - self.assertEqual(method, "GET") - self.assertTrue(url.endswith("/notifications/77")) - - def test_notifications_cancel(self): - response = make_response(status=204) - client, session = make_client(self.token, self.project_id, response) - self.assertTrue(client.notifications.cancel(10)) - method, url = session.request.call_args[0] - self.assertEqual(method, "DELETE") - self.assertIn("/notifications/10/cancel", url) - - def test_subscriptions_count_uses_header(self): - headers = {"Content-Type": "application/json", "X-Total-Count": "42"} - response = make_response(payload=[], headers=headers) - client, session = make_client(self.token, self.project_id, response) - count = client.subscriptions.count(tags=["paid"]) - self.assertEqual(count, 42) - kwargs = session.request.call_args[1] - self.assertEqual(kwargs["params"], {"tags[]": ["paid"], "per_page": 1}) - - def test_subscriptions_all_accepts_boolean_expression(self): - response = make_response(payload=[]) - client, session = make_client(self.token, self.project_id, response) - client.subscriptions.all(tags="tag1 && tag2") - params = session.request.call_args[1]["params"] - self.assertEqual(params["tags[]"], ["tag1 && tag2"]) - - def test_subscriptions_create(self): - response = make_response(payload={"id": 11}) - client, session = make_client(self.token, self.project_id, response) - subscription = client.subscriptions.create(uid="u1") - self.assertEqual(subscription.id, 11) - method, url = session.request.call_args[0] - self.assertEqual(method, "POST") - self.assertIn("/projects/1/subscriptions", url) - self.assertEqual(session.request.call_args[1]["json"], {"uid": "u1"}) - - def test_subscriptions_get(self): - response = make_response(payload={"id": 22}) - client, session = make_client(self.token, self.project_id, response) - subscription = client.subscriptions.get(22) - self.assertEqual(subscription.id, 22) - method, url = session.request.call_args[0] - self.assertEqual(method, "GET") - self.assertTrue(url.endswith("/projects/1/subscriptions/22")) - - def test_subscriptions_update(self): - response = make_response(payload={"id": 33, "tags": ["a"]}) - client, session = make_client(self.token, self.project_id, response) - subscription = client.subscriptions.update(33, tags=["a"]) - self.assertEqual(subscription.tags, ["a"]) - method, url = session.request.call_args[0] - self.assertEqual(method, "PATCH") - self.assertTrue(url.endswith("/projects/1/subscriptions/33")) - self.assertEqual(session.request.call_args[1]["json"], {"tags": ["a"]}) - - def test_subscriptions_delete(self): - response = make_response(status=204) - client, session = make_client(self.token, self.project_id, response) - self.assertTrue(client.subscriptions.delete(44)) - method, url = session.request.call_args[0] - self.assertEqual(method, "DELETE") - self.assertTrue(url.endswith("/projects/1/subscriptions/44")) - - def test_projects_all(self): - response = make_response(payload=[{"id": 1}]) - client, session = make_client(self.token, response=response) - self.assertEqual(client.projects.all(), [{"id": 1}]) - method, url = session.request.call_args[0] - self.assertEqual(method, "GET") - self.assertTrue(url.endswith("/projects")) - - def test_projects_create(self): - response = make_response(payload={"id": 2}) - client, session = make_client(self.token, response=response) - project = client.projects.create(name="Demo") - self.assertEqual(project.id, 2) - method, url = session.request.call_args[0] - self.assertEqual(method, "POST") - self.assertTrue(url.endswith("/projects")) - self.assertEqual(session.request.call_args[1]["json"], {"name": "Demo"}) - - def test_projects_get(self): - response = make_response(payload={"id": 3}) - client, session = make_client(self.token, response=response) - project = client.projects.get(3) - self.assertEqual(project.id, 3) - method, url = session.request.call_args[0] - self.assertEqual(method, "GET") - self.assertTrue(url.endswith("/projects/3")) - - def test_projects_update(self): - response = make_response(payload={"id": 4, "name": "Demo"}) - client, session = make_client(self.token, response=response) - project = client.projects.update(4, name="Demo") - self.assertEqual(project.name, "Demo") - method, url = session.request.call_args[0] - self.assertEqual(method, "PATCH") - self.assertTrue(url.endswith("/projects/4")) - self.assertEqual(session.request.call_args[1]["json"], {"name": "Demo"}) - - def test_projects_delete(self): - response = make_response(status=202) - client, session = make_client(self.token, response=response) - self.assertTrue(client.projects.delete(99)) - method, url = session.request.call_args[0] - self.assertEqual(method, "DELETE") - self.assertTrue(url.endswith("/projects/99")) - - def test_senders_all(self): - response = make_response(payload=[{"id": 1}]) - client, session = make_client(self.token, response=response) - self.assertEqual(client.senders.all(), [{"id": 1}]) - method, url = session.request.call_args[0] - self.assertEqual(method, "GET") - self.assertTrue(url.endswith("/senders")) - - def test_senders_create(self): - response = make_response(payload={"id": 2}) - client, session = make_client(self.token, response=response) - sender = client.senders.create(name="News") - self.assertEqual(sender.id, 2) - method, url = session.request.call_args[0] - self.assertEqual(method, "POST") - self.assertTrue(url.endswith("/senders")) - self.assertEqual(session.request.call_args[1]["json"], {"name": "News"}) - - def test_senders_get(self): - response = make_response(payload={"id": 3}) - client, session = make_client(self.token, response=response) - sender = client.senders.get(3) - self.assertEqual(sender.id, 3) - method, url = session.request.call_args[0] - self.assertEqual(method, "GET") - self.assertTrue(url.endswith("/senders/3")) - - def test_senders_update(self): - response = make_response(payload={"id": 55}) - client, session = make_client(self.token, response=response) - client.senders.update(55, name="Acme") - method, url = session.request.call_args[0] - self.assertEqual(method, "PATCH") - self.assertTrue(url.endswith("/senders/55")) - self.assertEqual(session.request.call_args[1]["json"], {"name": "Acme"}) - - def test_senders_delete(self): - response = make_response(status=204) - client, session = make_client(self.token, response=response) - self.assertTrue(client.senders.delete(66)) - method, url = session.request.call_args[0] - self.assertEqual(method, "DELETE") - self.assertTrue(url.endswith("/senders/66")) - def test_error_response(self): response = make_response(status=403, payload={"error": "Forbidden"}) client, _ = make_client(self.token, self.project_id, response) From ff83258833d42c9a32e368a5e527af44ca1ac54f Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 12:28:02 +0100 Subject: [PATCH 05/38] Improve .gitignore (use template from github/gitignore) --- .gitignore | 219 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 216 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 4d114b1..e15106e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,216 @@ -/.eggs -/dist -/pushpad.egg-info +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml From ad8fc1d2750e26c5d6f143a8d09f640a596f4334 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 12:46:26 +0100 Subject: [PATCH 06/38] Update python version in CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c17d611..c2641e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} From 16af1dbfa5cde79eccf2715ad08742ae70eb38d8 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 13:23:44 +0100 Subject: [PATCH 07/38] Add pyproject.toml and remove legacy setup.py --- pyproject.toml | 27 +++++++++++++++++++++++++++ setup.py | 39 --------------------------------------- 2 files changed, 27 insertions(+), 39 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f5f48c2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "pushpad" +version = "2.0.0" +readme = "README.md" +description = "Pushpad API: web push notifications" +authors = [{ name = "Pushpad", email = "support@pushpad.xyz" }] +license = { text = "MIT" } +keywords = ["pushpad", "api", "web", "push", "notifications"] +requires-python = ">=3.10" +classifiers = [ + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] + +dependencies = ["requests"] + +[project.urls] +homepage = "https://pushpad.xyz" +source = "https://github.com/pushpad/pushpad-python" diff --git a/setup.py b/setup.py deleted file mode 100644 index 81e01e7..0000000 --- a/setup.py +++ /dev/null @@ -1,39 +0,0 @@ -"""A setuptools based setup module. - -See: -https://packaging.python.org/en/latest/distributing.html -""" - -from setuptools import setup, find_packages -from os import path - -here = path.abspath(path.dirname(__file__)) - - -setup( - name='pushpad', - version='2.0.0', - description='Pushpad: real push notifications for websites', - url='https://pushpad.xyz', - author='Pushpad', - author_email='support@pushpad.xyz', - license='MIT', - - classifiers=[ - 'Intended Audience :: Developers', - "Topic :: Software Development :: Libraries", - 'License :: OSI Approved :: MIT License', - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 3", - ], - keywords='pushpad web push notifications api', - packages=find_packages(exclude=['contrib', 'docs', 'tests*']), - install_requires=['requests'], - tests_require=['mock', 'nose'], - test_suite='nose.collector', - extras_require={ - 'dev': ['check-manifest'], - 'test': ['coverage'], - }, -) From 5ad7d40d4c1c526a36a2dbabb0da5759cd3e3896 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 13:42:56 +0100 Subject: [PATCH 08/38] Update CI to use pip install instead of legacy setuptools --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2641e8..237f9c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip pytest setuptools - python setup.py develop + python -m pip install --upgrade pip pytest + python -m pip install -e . - name: Test run: pytest From 8b88cc99170519674453f743029c3e54ef4166bf Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 17:31:06 +0100 Subject: [PATCH 09/38] Use normal import for requests --- pushpad/pushpad.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index ae46dd0..727e7b8 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -2,22 +2,14 @@ from __future__ import annotations +import requests +from requests import RequestException + import hmac from datetime import date, datetime, timezone from hashlib import sha256 from typing import Any, Dict, MutableMapping, Optional -try: # pragma: no cover - exercised when requests is available - import requests # type: ignore - RequestException = requests.RequestException -except ModuleNotFoundError: # pragma: no cover - fallback for limited envs/tests - requests = None # type: ignore - - class RequestException(Exception): - """Fallback exception used when the requests package is not installed.""" - - pass - from ._version import __version__ from .exceptions import PushpadAPIError, PushpadClientError from .resources import NotificationsResource, ProjectsResource, SendersResource, SubscriptionsResource @@ -98,8 +90,6 @@ def __init__( if session is not None: self._session = session else: - if requests is None: - raise RuntimeError("The 'requests' package is required unless you provide a session instance.") self._session = requests.Session() self._session.headers.update( { From c794ec3cb54b63252bf3c1ddba6312de8e9de089 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 18:21:35 +0100 Subject: [PATCH 10/38] Keep fields with value None in the request payload and add test --- pushpad/pushpad.py | 4 ++-- tests/resources/test_subscriptions.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index 727e7b8..bd60966 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -47,9 +47,9 @@ def _serialize_value(value: Any) -> Any: if isinstance(value, date): return value.isoformat() if isinstance(value, dict): - return {key: _serialize_value(val) for key, val in value.items() if val is not None} + return {key: _serialize_value(val) for key, val in value.items()} if isinstance(value, (list, tuple, set)): - return [_serialize_value(item) for item in value if item is not None] + return [_serialize_value(item) for item in value] return value diff --git a/tests/resources/test_subscriptions.py b/tests/resources/test_subscriptions.py index c8e4509..d3193a0 100644 --- a/tests/resources/test_subscriptions.py +++ b/tests/resources/test_subscriptions.py @@ -48,6 +48,16 @@ def test_subscriptions_update(self): self.assertTrue(url.endswith("/projects/1/subscriptions/33")) self.assertEqual(session.request.call_args[1]["json"], {"tags": ["a"]}) + def test_subscriptions_update_can_set_fields_to_null(self): + response = make_response(payload={"id": 33, "uid": None}) + client, session = make_client(self.token, self.project_id, response) + subscription = client.subscriptions.update(33, uid=None) + self.assertEqual(subscription.uid, None) + method, url = session.request.call_args[0] + self.assertEqual(method, "PATCH") + self.assertTrue(url.endswith("/projects/1/subscriptions/33")) + self.assertEqual(session.request.call_args[1]["json"], {"uid": None}) + def test_subscriptions_delete(self): response = make_response(status=204) client, session = make_client(self.token, self.project_id, response) From 58d4f74278aa93937d7c0992e228540d326ad3bc Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 18:34:51 +0100 Subject: [PATCH 11/38] Remove _ProjectBoundResource and move _project_id helper to Pushpad --- pushpad/pushpad.py | 6 ++++++ pushpad/resources/base.py | 19 ------------------- pushpad/resources/notifications.py | 14 +++++++++----- pushpad/resources/subscriptions.py | 22 +++++++++++++--------- 4 files changed, 28 insertions(+), 33 deletions(-) delete mode 100644 pushpad/resources/base.py diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index bd60966..c0bdd71 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -121,6 +121,12 @@ def signature_for(self, data: str) -> str: """Return the HMAC signature for a user identifier.""" return hmac.new(self.auth_token.encode(), data.encode(), sha256).hexdigest() + def _project_id(self, project_id: Optional[int]) -> int: + pid = project_id if project_id is not None else self.project_id + if pid is None: + raise ValueError("project_id is required for this operation") + return pid + def _request( self, method: str, diff --git a/pushpad/resources/base.py b/pushpad/resources/base.py deleted file mode 100644 index e77369b..0000000 --- a/pushpad/resources/base.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Shared helper classes for resource modules.""" - -from __future__ import annotations - -from typing import Optional, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - only used for typing - from ..pushpad import Pushpad - - -class _ProjectBoundResource: - def __init__(self, client: "Pushpad") -> None: - self._client = client - - def _project_id(self, project_id: Optional[int]) -> int: - pid = project_id if project_id is not None else self._client.project_id - if pid is None: - raise ValueError("project_id is required for this operation") - return pid diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py index 8bea3aa..907df97 100644 --- a/pushpad/resources/notifications.py +++ b/pushpad/resources/notifications.py @@ -2,19 +2,23 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any, Optional, TYPE_CHECKING -from .base import _ProjectBoundResource +if TYPE_CHECKING: # pragma: no cover - only used for typing + from ..pushpad import Pushpad -class NotificationsResource(_ProjectBoundResource): +class NotificationsResource: + def __init__(self, client: "Pushpad") -> None: + self._client = client + def all(self, *, project_id: Optional[int] = None, page: Optional[int] = None, **filters: Any): - pid = self._project_id(project_id) + pid = self._client._project_id(project_id) params = {k: v for k, v in {"page": page, **filters}.items() if v is not None} return self._client._request("GET", f"/projects/{pid}/notifications", params=params) def create(self, *, project_id: Optional[int] = None, **notification: Any): - pid = self._project_id(project_id) + pid = self._client._project_id(project_id) return self._client._request("POST", f"/projects/{pid}/notifications", json=notification) def get(self, notification_id: int): diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py index 96b1cc9..709ed6b 100644 --- a/pushpad/resources/subscriptions.py +++ b/pushpad/resources/subscriptions.py @@ -2,12 +2,16 @@ from __future__ import annotations -from typing import Any, Dict, Iterable, Optional +from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING -from .base import _ProjectBoundResource +if TYPE_CHECKING: # pragma: no cover - only used for typing + from ..pushpad import Pushpad -class SubscriptionsResource(_ProjectBoundResource): +class SubscriptionsResource: + def __init__(self, client: "Pushpad") -> None: + self._client = client + def _build_filters(self, values: Dict[str, Any]) -> Dict[str, Any]: params = dict(values) uids = params.pop("uids", None) @@ -38,7 +42,7 @@ def all( tags: Optional[Iterable[str]] = None, **filters: Any, ): - pid = self._project_id(project_id) + pid = self._client._project_id(project_id) params = self._build_filters( {"page": page, "per_page": per_page, "uids": uids, "tags": tags, **filters} ) @@ -52,7 +56,7 @@ def count( tags: Optional[Iterable[str]] = None, **filters: Any, ) -> int: - pid = self._project_id(project_id) + pid = self._client._project_id(project_id) params = self._build_filters({"uids": uids, "tags": tags, **filters}) params.setdefault("per_page", 1) response = self._client._request( @@ -74,24 +78,24 @@ def count( return len(data) def create(self, *, project_id: Optional[int] = None, **subscription: Any): - pid = self._project_id(project_id) + pid = self._client._project_id(project_id) return self._client._request("POST", f"/projects/{pid}/subscriptions", json=subscription) def get(self, subscription_id: int, *, project_id: Optional[int] = None): if subscription_id is None: raise ValueError("subscription_id is required") - pid = self._project_id(project_id) + pid = self._client._project_id(project_id) return self._client._request("GET", f"/projects/{pid}/subscriptions/{subscription_id}") def update(self, subscription_id: int, *, project_id: Optional[int] = None, **subscription: Any): if subscription_id is None: raise ValueError("subscription_id is required") - pid = self._project_id(project_id) + pid = self._client._project_id(project_id) return self._client._request("PATCH", f"/projects/{pid}/subscriptions/{subscription_id}", json=subscription) def delete(self, subscription_id: int, *, project_id: Optional[int] = None) -> bool: if subscription_id is None: raise ValueError("subscription_id is required") - pid = self._project_id(project_id) + pid = self._client._project_id(project_id) self._client._request("DELETE", f"/projects/{pid}/subscriptions/{subscription_id}") return True From ff58a8a130ff665eb0d06b3c18b00959801e0820 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 19:44:45 +0100 Subject: [PATCH 12/38] Rename _project_id to _resolve_project_id --- pushpad/pushpad.py | 2 +- pushpad/resources/notifications.py | 4 ++-- pushpad/resources/subscriptions.py | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index c0bdd71..f53b7ca 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -121,7 +121,7 @@ def signature_for(self, data: str) -> str: """Return the HMAC signature for a user identifier.""" return hmac.new(self.auth_token.encode(), data.encode(), sha256).hexdigest() - def _project_id(self, project_id: Optional[int]) -> int: + def _resolve_project_id(self, project_id: Optional[int]) -> int: pid = project_id if project_id is not None else self.project_id if pid is None: raise ValueError("project_id is required for this operation") diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py index 907df97..b59cedb 100644 --- a/pushpad/resources/notifications.py +++ b/pushpad/resources/notifications.py @@ -13,12 +13,12 @@ def __init__(self, client: "Pushpad") -> None: self._client = client def all(self, *, project_id: Optional[int] = None, page: Optional[int] = None, **filters: Any): - pid = self._client._project_id(project_id) + pid = self._client._resolve_project_id(project_id) params = {k: v for k, v in {"page": page, **filters}.items() if v is not None} return self._client._request("GET", f"/projects/{pid}/notifications", params=params) def create(self, *, project_id: Optional[int] = None, **notification: Any): - pid = self._client._project_id(project_id) + pid = self._client._resolve_project_id(project_id) return self._client._request("POST", f"/projects/{pid}/notifications", json=notification) def get(self, notification_id: int): diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py index 709ed6b..8dfd1ee 100644 --- a/pushpad/resources/subscriptions.py +++ b/pushpad/resources/subscriptions.py @@ -42,7 +42,7 @@ def all( tags: Optional[Iterable[str]] = None, **filters: Any, ): - pid = self._client._project_id(project_id) + pid = self._client._resolve_project_id(project_id) params = self._build_filters( {"page": page, "per_page": per_page, "uids": uids, "tags": tags, **filters} ) @@ -56,7 +56,7 @@ def count( tags: Optional[Iterable[str]] = None, **filters: Any, ) -> int: - pid = self._client._project_id(project_id) + pid = self._client._resolve_project_id(project_id) params = self._build_filters({"uids": uids, "tags": tags, **filters}) params.setdefault("per_page", 1) response = self._client._request( @@ -78,24 +78,24 @@ def count( return len(data) def create(self, *, project_id: Optional[int] = None, **subscription: Any): - pid = self._client._project_id(project_id) + pid = self._client._resolve_project_id(project_id) return self._client._request("POST", f"/projects/{pid}/subscriptions", json=subscription) def get(self, subscription_id: int, *, project_id: Optional[int] = None): if subscription_id is None: raise ValueError("subscription_id is required") - pid = self._client._project_id(project_id) + pid = self._client._resolve_project_id(project_id) return self._client._request("GET", f"/projects/{pid}/subscriptions/{subscription_id}") def update(self, subscription_id: int, *, project_id: Optional[int] = None, **subscription: Any): if subscription_id is None: raise ValueError("subscription_id is required") - pid = self._client._project_id(project_id) + pid = self._client._resolve_project_id(project_id) return self._client._request("PATCH", f"/projects/{pid}/subscriptions/{subscription_id}", json=subscription) def delete(self, subscription_id: int, *, project_id: Optional[int] = None) -> bool: if subscription_id is None: raise ValueError("subscription_id is required") - pid = self._client._project_id(project_id) + pid = self._client._resolve_project_id(project_id) self._client._request("DELETE", f"/projects/{pid}/subscriptions/{subscription_id}") return True From 5864e7791646c984eda7daaefbc02467ff20fc22 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 19:57:05 +0100 Subject: [PATCH 13/38] Make client settings private --- pushpad/pushpad.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index f53b7ca..ad79b40 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -83,17 +83,17 @@ def __init__( ) -> None: if not auth_token: raise ValueError("auth_token is required") - self.auth_token = auth_token - self.project_id = project_id - self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/") - self.timeout = timeout + self._auth_token = auth_token + self._project_id = project_id + self._base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/") + self._timeout = timeout if session is not None: self._session = session else: self._session = requests.Session() self._session.headers.update( { - "Authorization": f"Bearer {self.auth_token}", + "Authorization": f"Bearer {self._auth_token}", "Accept": "application/json", "Content-Type": "application/json", "User-Agent": f"pushpad-python/{__version__}", @@ -119,10 +119,10 @@ def close(self) -> None: def signature_for(self, data: str) -> str: """Return the HMAC signature for a user identifier.""" - return hmac.new(self.auth_token.encode(), data.encode(), sha256).hexdigest() + return hmac.new(self._auth_token.encode(), data.encode(), sha256).hexdigest() def _resolve_project_id(self, project_id: Optional[int]) -> int: - pid = project_id if project_id is not None else self.project_id + pid = project_id if project_id is not None else self._project_id if pid is None: raise ValueError("project_id is required for this operation") return pid @@ -136,14 +136,14 @@ def _request( json: Optional[JSONDict] = None, raw: bool = False, ): - url = f"{self.base_url}{path}" + url = f"{self._base_url}{path}" try: response = self._session.request( method, url, params=_prepare_params(params), json=_prepare_payload(json), - timeout=self.timeout, + timeout=self._timeout, ) except RequestException as exc: raise PushpadClientError(str(exc), original_exception=exc) from exc From 7161315c2d31a9c509603cb772aba4e7f864618c Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 20:08:45 +0100 Subject: [PATCH 14/38] Instead of "resource"_id use id in methods --- pushpad/resources/notifications.py | 18 +++++++++--------- pushpad/resources/projects.py | 28 ++++++++++++++-------------- pushpad/resources/senders.py | 28 ++++++++++++++-------------- pushpad/resources/subscriptions.py | 24 ++++++++++++------------ 4 files changed, 49 insertions(+), 49 deletions(-) diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py index b59cedb..b28c6f9 100644 --- a/pushpad/resources/notifications.py +++ b/pushpad/resources/notifications.py @@ -21,13 +21,13 @@ def create(self, *, project_id: Optional[int] = None, **notification: Any): pid = self._client._resolve_project_id(project_id) return self._client._request("POST", f"/projects/{pid}/notifications", json=notification) - def get(self, notification_id: int): - if notification_id is None: - raise ValueError("notification_id is required") - return self._client._request("GET", f"/notifications/{notification_id}") - - def cancel(self, notification_id: int) -> bool: - if notification_id is None: - raise ValueError("notification_id is required") - self._client._request("DELETE", f"/notifications/{notification_id}/cancel") + def get(self, id: int): + if id is None: + raise ValueError("id is required") + return self._client._request("GET", f"/notifications/{id}") + + def cancel(self, id: int) -> bool: + if id is None: + raise ValueError("id is required") + self._client._request("DELETE", f"/notifications/{id}/cancel") return True diff --git a/pushpad/resources/projects.py b/pushpad/resources/projects.py index 6e4cd17..417db4d 100644 --- a/pushpad/resources/projects.py +++ b/pushpad/resources/projects.py @@ -18,18 +18,18 @@ def all(self): def create(self, **project: Any): return self._client._request("POST", "/projects", json=project) - def get(self, project_id: int): - if project_id is None: - raise ValueError("project_id is required") - return self._client._request("GET", f"/projects/{project_id}") - - def update(self, project_id: int, **project: Any): - if project_id is None: - raise ValueError("project_id is required") - return self._client._request("PATCH", f"/projects/{project_id}", json=project) - - def delete(self, project_id: int) -> bool: - if project_id is None: - raise ValueError("project_id is required") - self._client._request("DELETE", f"/projects/{project_id}") + def get(self, id: int): + if id is None: + raise ValueError("id is required") + return self._client._request("GET", f"/projects/{id}") + + def update(self, id: int, **project: Any): + if id is None: + raise ValueError("id is required") + return self._client._request("PATCH", f"/projects/{id}", json=project) + + def delete(self, id: int) -> bool: + if id is None: + raise ValueError("id is required") + self._client._request("DELETE", f"/projects/{id}") return True diff --git a/pushpad/resources/senders.py b/pushpad/resources/senders.py index 550d5e7..4a75b7d 100644 --- a/pushpad/resources/senders.py +++ b/pushpad/resources/senders.py @@ -18,18 +18,18 @@ def all(self): def create(self, **sender: Any): return self._client._request("POST", "/senders", json=sender) - def get(self, sender_id: int): - if sender_id is None: - raise ValueError("sender_id is required") - return self._client._request("GET", f"/senders/{sender_id}") - - def update(self, sender_id: int, **sender: Any): - if sender_id is None: - raise ValueError("sender_id is required") - return self._client._request("PATCH", f"/senders/{sender_id}", json=sender) - - def delete(self, sender_id: int) -> bool: - if sender_id is None: - raise ValueError("sender_id is required") - self._client._request("DELETE", f"/senders/{sender_id}") + def get(self, id: int): + if id is None: + raise ValueError("id is required") + return self._client._request("GET", f"/senders/{id}") + + def update(self, id: int, **sender: Any): + if id is None: + raise ValueError("id is required") + return self._client._request("PATCH", f"/senders/{id}", json=sender) + + def delete(self, id: int) -> bool: + if id is None: + raise ValueError("id is required") + self._client._request("DELETE", f"/senders/{id}") return True diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py index 8dfd1ee..58e820e 100644 --- a/pushpad/resources/subscriptions.py +++ b/pushpad/resources/subscriptions.py @@ -81,21 +81,21 @@ def create(self, *, project_id: Optional[int] = None, **subscription: Any): pid = self._client._resolve_project_id(project_id) return self._client._request("POST", f"/projects/{pid}/subscriptions", json=subscription) - def get(self, subscription_id: int, *, project_id: Optional[int] = None): - if subscription_id is None: - raise ValueError("subscription_id is required") + def get(self, id: int, *, project_id: Optional[int] = None): + if id is None: + raise ValueError("id is required") pid = self._client._resolve_project_id(project_id) - return self._client._request("GET", f"/projects/{pid}/subscriptions/{subscription_id}") + return self._client._request("GET", f"/projects/{pid}/subscriptions/{id}") - def update(self, subscription_id: int, *, project_id: Optional[int] = None, **subscription: Any): - if subscription_id is None: - raise ValueError("subscription_id is required") + def update(self, id: int, *, project_id: Optional[int] = None, **subscription: Any): + if id is None: + raise ValueError("id is required") pid = self._client._resolve_project_id(project_id) - return self._client._request("PATCH", f"/projects/{pid}/subscriptions/{subscription_id}", json=subscription) + return self._client._request("PATCH", f"/projects/{pid}/subscriptions/{id}", json=subscription) - def delete(self, subscription_id: int, *, project_id: Optional[int] = None) -> bool: - if subscription_id is None: - raise ValueError("subscription_id is required") + def delete(self, id: int, *, project_id: Optional[int] = None) -> bool: + if id is None: + raise ValueError("id is required") pid = self._client._resolve_project_id(project_id) - self._client._request("DELETE", f"/projects/{pid}/subscriptions/{subscription_id}") + self._client._request("DELETE", f"/projects/{pid}/subscriptions/{id}") return True From 779f42ebe7abcb132abdaf5f76716dc18b273f9d Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 21:47:09 +0100 Subject: [PATCH 15/38] Add types for API responses --- README.md | 18 ++- example.py | 2 +- pushpad/__init__.py | 14 ++ pushpad/pushpad.py | 56 +++++-- pushpad/resources/notifications.py | 31 +++- pushpad/resources/projects.py | 31 ++-- pushpad/resources/senders.py | 31 ++-- pushpad/resources/subscriptions.py | 34 +++-- pushpad/types.py | 204 ++++++++++++++++++++++++++ tests/resources/test_notifications.py | 6 +- tests/resources/test_projects.py | 6 +- tests/resources/test_senders.py | 6 +- tests/resources/test_subscriptions.py | 2 +- 13 files changed, 369 insertions(+), 72 deletions(-) create mode 100644 pushpad/types.py diff --git a/README.md b/README.md index 4b774b1..e015552 100644 --- a/README.md +++ b/README.md @@ -112,12 +112,12 @@ result = client.notifications.create( ) # Inspect the response -print(result['id'], result['scheduled']) +print(result.id, result.scheduled) # List, inspect, or cancel notifications client.notifications.all(page=1) -client.notifications.get(result['id']) -client.notifications.cancel(result['id']) +client.notifications.get(result.id) +client.notifications.cancel(result.id) ``` Set `uids` to reach a list of user IDs, set `tags` to reach subscribers that match your segments @@ -129,12 +129,14 @@ You can set the default values for most fields in the project settings. See also If you try to send a notification to a user ID, but that user is not subscribed, that ID is simply ignored. -The methods above return a dictionary: +`client.notifications.create` returns a `NotificationCreateResult`: -- `'id'` is the id of the notification on Pushpad -- `'scheduled'` is the estimated reach of the notification (i.e. the number of devices to which the notification will be sent, which can be different from the number of users, since a user may receive notifications on multiple devices) -- `'uids'` (when you pass `uids` while creating the notification) are the user IDs that will be actually reached by the notification because they are subscribed to your notifications. For example if you send a notification to `['uid1', 'uid2', 'uid3']`, but only `'uid1'` is subscribed, you will get `['uid1']` in response. Note that if a user has unsubscribed after the last notification sent to him, he may still be reported for one time as subscribed (this is due to the way the W3C Push API works). -- `'send_at'` is present only for scheduled notifications. The fields `'scheduled'` and `'uids'` are not available in this case. +- `result.id` is the id of the notification on Pushpad +- `result.scheduled` is the estimated reach of the notification (i.e. the number of devices to which the notification will be sent, which can be different from the number of users, since a user may receive notifications on multiple devices) +- `result.uids` (when you pass `uids` while creating the notification) are the user IDs that will be actually reached by the notification because they are subscribed to your notifications. For example if you send a notification to `['uid1', 'uid2', 'uid3']`, but only `'uid1'` is subscribed, you will get `['uid1']` in response. Note that if a user has unsubscribed after the last notification sent to him, he may still be reported for one time as subscribed (this is due to the way the W3C Push API works). +- `result.send_at` is present only for scheduled notifications. The fields `scheduled` and `uids` are not available in this case. + +`client.notifications.all()` and `client.notifications.get()` return fully populated `Notification` objects that include metadata such as stats counters and delivery information. ## License diff --git a/example.py b/example.py index ae1d718..bafeff1 100644 --- a/example.py +++ b/example.py @@ -17,7 +17,7 @@ uids=["user1", "user2", "user3"], tags=["segment1", "segment2"], ) -print(f"Notification accepted with id: {created['id']}") +print(f"Notification accepted with id: {created.id}") latest = client.notifications.all(page=1) print(f"Latest notifications: {latest}") diff --git a/pushpad/__init__.py b/pushpad/__init__.py index 6f206f3..7edd361 100644 --- a/pushpad/__init__.py +++ b/pushpad/__init__.py @@ -4,6 +4,14 @@ from ._version import __version__ from .exceptions import PushpadAPIError, PushpadClientError, PushpadError from .pushpad import Pushpad +from .types import ( + Notification, + NotificationAction, + NotificationCreateResult, + Project, + Sender, + Subscription, +) __all__ = [ "__version__", @@ -11,4 +19,10 @@ "PushpadError", "PushpadClientError", "PushpadAPIError", + "Notification", + "NotificationAction", + "NotificationCreateResult", + "Subscription", + "Project", + "Sender", ] diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index ad79b40..5436840 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -3,16 +3,15 @@ from __future__ import annotations import requests -from requests import RequestException +from requests import RequestException, Response import hmac from datetime import date, datetime, timezone from hashlib import sha256 -from typing import Any, Dict, MutableMapping, Optional +from typing import Any, Dict, MutableMapping, Optional, Union from ._version import __version__ from .exceptions import PushpadAPIError, PushpadClientError -from .resources import NotificationsResource, ProjectsResource, SendersResource, SubscriptionsResource JSONDict = MutableMapping[str, Any] @@ -27,6 +26,9 @@ def __getattr__(self, item: str) -> Any: raise AttributeError(item) from exc +APIResponse = Union[APIObject, list[APIObject], None] + + def _wrap_response(data: Any) -> Any: if isinstance(data, dict): return APIObject({key: _wrap_response(value) for key, value in data.items()}) @@ -35,6 +37,22 @@ def _wrap_response(data: Any) -> Any: return data +def _ensure_api_object(data: APIResponse) -> APIObject: + if isinstance(data, APIObject): + return data + raise PushpadClientError(f"API response is not an object: {type(data).__name__}") + + +def _ensure_api_list(data: APIResponse) -> list[APIObject]: + if data is None: + return [] + if isinstance(data, list): + if all(isinstance(item, APIObject) for item in data): + return data + raise PushpadClientError("API response list contains invalid entries") + raise PushpadClientError(f"API response is not a list: {type(data).__name__}") + + def _isoformat(value: datetime) -> str: if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) @@ -67,6 +85,9 @@ def _prepare_params(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any] return serialized # type: ignore[return-value] +from .resources import NotificationsResource, ProjectsResource, SendersResource, SubscriptionsResource + + class Pushpad: """High level client used to interact with the Pushpad REST API.""" @@ -127,15 +148,14 @@ def _resolve_project_id(self, project_id: Optional[int]) -> int: raise ValueError("project_id is required for this operation") return pid - def _request( + def _raw_request( self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json: Optional[JSONDict] = None, - raw: bool = False, - ): + ) -> Response: url = f"{self._base_url}{path}" try: response = self._session.request( @@ -160,16 +180,22 @@ def _request( message = response.text raise PushpadAPIError(response.status_code, message, response_body=payload or response.text) - if raw: - return response + return response + def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json: Optional[JSONDict] = None, + ) -> APIResponse: + response = self._raw_request(method, path, params=params, json=json) if response.status_code in (202, 204) or not response.content: return None - content_type = response.headers.get("Content-Type", "") - if "application/json" in content_type: - try: - return _wrap_response(response.json()) - except ValueError as exc: # pragma: no cover - unexpected API behaviour - raise PushpadAPIError(response.status_code, "Invalid JSON in response") from exc - return response.content + try: + data = response.json() + except ValueError as exc: # pragma: no cover - unexpected API behaviour + raise PushpadAPIError(response.status_code, "Invalid JSON in response") from exc + return _wrap_response(data) diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py index b28c6f9..ab30661 100644 --- a/pushpad/resources/notifications.py +++ b/pushpad/resources/notifications.py @@ -4,6 +4,9 @@ from typing import Any, Optional, TYPE_CHECKING +from ..pushpad import _ensure_api_list, _ensure_api_object +from ..types import Notification, NotificationCreateResult + if TYPE_CHECKING: # pragma: no cover - only used for typing from ..pushpad import Pushpad @@ -12,22 +15,34 @@ class NotificationsResource: def __init__(self, client: "Pushpad") -> None: self._client = client - def all(self, *, project_id: Optional[int] = None, page: Optional[int] = None, **filters: Any): + def all( + self, + *, + project_id: Optional[int] = None, + page: Optional[int] = None, + **filters: Any, + ) -> list[Notification]: pid = self._client._resolve_project_id(project_id) params = {k: v for k, v in {"page": page, **filters}.items() if v is not None} - return self._client._request("GET", f"/projects/{pid}/notifications", params=params) + response = self._client._request("GET", f"/projects/{pid}/notifications", params=params) + payload = _ensure_api_list(response) + return [Notification.from_api(item) for item in payload] - def create(self, *, project_id: Optional[int] = None, **notification: Any): + def create(self, *, project_id: Optional[int] = None, **notification: Any) -> NotificationCreateResult: pid = self._client._resolve_project_id(project_id) - return self._client._request("POST", f"/projects/{pid}/notifications", json=notification) + response = self._client._request("POST", f"/projects/{pid}/notifications", json=notification) + payload = _ensure_api_object(response) + return NotificationCreateResult.from_api(payload) - def get(self, id: int): + def get(self, id: int) -> Notification: if id is None: raise ValueError("id is required") - return self._client._request("GET", f"/notifications/{id}") + response = self._client._request("GET", f"/notifications/{id}") + payload = _ensure_api_object(response) + return Notification.from_api(payload) - def cancel(self, id: int) -> bool: + def cancel(self, id: int) -> None: if id is None: raise ValueError("id is required") self._client._request("DELETE", f"/notifications/{id}/cancel") - return True + return None diff --git a/pushpad/resources/projects.py b/pushpad/resources/projects.py index 417db4d..ada2d81 100644 --- a/pushpad/resources/projects.py +++ b/pushpad/resources/projects.py @@ -4,6 +4,9 @@ from typing import Any, TYPE_CHECKING +from ..pushpad import _ensure_api_list, _ensure_api_object +from ..types import Project + if TYPE_CHECKING: # pragma: no cover - only used for typing from ..pushpad import Pushpad @@ -12,24 +15,32 @@ class ProjectsResource: def __init__(self, client: "Pushpad") -> None: self._client = client - def all(self): - return self._client._request("GET", "/projects") + def all(self) -> list[Project]: + response = self._client._request("GET", "/projects") + payload = _ensure_api_list(response) + return [Project.from_api(item) for item in payload] - def create(self, **project: Any): - return self._client._request("POST", "/projects", json=project) + def create(self, **project: Any) -> Project: + response = self._client._request("POST", "/projects", json=project) + payload = _ensure_api_object(response) + return Project.from_api(payload) - def get(self, id: int): + def get(self, id: int) -> Project: if id is None: raise ValueError("id is required") - return self._client._request("GET", f"/projects/{id}") + response = self._client._request("GET", f"/projects/{id}") + payload = _ensure_api_object(response) + return Project.from_api(payload) - def update(self, id: int, **project: Any): + def update(self, id: int, **project: Any) -> Project: if id is None: raise ValueError("id is required") - return self._client._request("PATCH", f"/projects/{id}", json=project) + response = self._client._request("PATCH", f"/projects/{id}", json=project) + payload = _ensure_api_object(response) + return Project.from_api(payload) - def delete(self, id: int) -> bool: + def delete(self, id: int) -> None: if id is None: raise ValueError("id is required") self._client._request("DELETE", f"/projects/{id}") - return True + return None diff --git a/pushpad/resources/senders.py b/pushpad/resources/senders.py index 4a75b7d..add6b2d 100644 --- a/pushpad/resources/senders.py +++ b/pushpad/resources/senders.py @@ -4,6 +4,9 @@ from typing import Any, TYPE_CHECKING +from ..pushpad import _ensure_api_list, _ensure_api_object +from ..types import Sender + if TYPE_CHECKING: # pragma: no cover - only used for typing from ..pushpad import Pushpad @@ -12,24 +15,32 @@ class SendersResource: def __init__(self, client: "Pushpad") -> None: self._client = client - def all(self): - return self._client._request("GET", "/senders") + def all(self) -> list[Sender]: + response = self._client._request("GET", "/senders") + payload = _ensure_api_list(response) + return [Sender.from_api(item) for item in payload] - def create(self, **sender: Any): - return self._client._request("POST", "/senders", json=sender) + def create(self, **sender: Any) -> Sender: + response = self._client._request("POST", "/senders", json=sender) + payload = _ensure_api_object(response) + return Sender.from_api(payload) - def get(self, id: int): + def get(self, id: int) -> Sender: if id is None: raise ValueError("id is required") - return self._client._request("GET", f"/senders/{id}") + response = self._client._request("GET", f"/senders/{id}") + payload = _ensure_api_object(response) + return Sender.from_api(payload) - def update(self, id: int, **sender: Any): + def update(self, id: int, **sender: Any) -> Sender: if id is None: raise ValueError("id is required") - return self._client._request("PATCH", f"/senders/{id}", json=sender) + response = self._client._request("PATCH", f"/senders/{id}", json=sender) + payload = _ensure_api_object(response) + return Sender.from_api(payload) - def delete(self, id: int) -> bool: + def delete(self, id: int) -> None: if id is None: raise ValueError("id is required") self._client._request("DELETE", f"/senders/{id}") - return True + return None diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py index 58e820e..ad8f28f 100644 --- a/pushpad/resources/subscriptions.py +++ b/pushpad/resources/subscriptions.py @@ -4,6 +4,9 @@ from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING +from ..pushpad import _ensure_api_list, _ensure_api_object +from ..types import Subscription + if TYPE_CHECKING: # pragma: no cover - only used for typing from ..pushpad import Pushpad @@ -41,12 +44,14 @@ def all( uids: Optional[Iterable[str]] = None, tags: Optional[Iterable[str]] = None, **filters: Any, - ): + ) -> list[Subscription]: pid = self._client._resolve_project_id(project_id) params = self._build_filters( {"page": page, "per_page": per_page, "uids": uids, "tags": tags, **filters} ) - return self._client._request("GET", f"/projects/{pid}/subscriptions", params=params) + response = self._client._request("GET", f"/projects/{pid}/subscriptions", params=params) + payload = _ensure_api_list(response) + return [Subscription.from_api(item) for item in payload] def count( self, @@ -59,11 +64,10 @@ def count( pid = self._client._resolve_project_id(project_id) params = self._build_filters({"uids": uids, "tags": tags, **filters}) params.setdefault("per_page", 1) - response = self._client._request( + response = self._client._raw_request( "GET", f"/projects/{pid}/subscriptions", params=params, - raw=True, ) total = response.headers.get("X-Total-Count") if total is not None: @@ -77,25 +81,31 @@ def count( data = [] return len(data) - def create(self, *, project_id: Optional[int] = None, **subscription: Any): + def create(self, *, project_id: Optional[int] = None, **subscription: Any) -> Subscription: pid = self._client._resolve_project_id(project_id) - return self._client._request("POST", f"/projects/{pid}/subscriptions", json=subscription) + response = self._client._request("POST", f"/projects/{pid}/subscriptions", json=subscription) + payload = _ensure_api_object(response) + return Subscription.from_api(payload) - def get(self, id: int, *, project_id: Optional[int] = None): + def get(self, id: int, *, project_id: Optional[int] = None) -> Subscription: if id is None: raise ValueError("id is required") pid = self._client._resolve_project_id(project_id) - return self._client._request("GET", f"/projects/{pid}/subscriptions/{id}") + response = self._client._request("GET", f"/projects/{pid}/subscriptions/{id}") + payload = _ensure_api_object(response) + return Subscription.from_api(payload) - def update(self, id: int, *, project_id: Optional[int] = None, **subscription: Any): + def update(self, id: int, *, project_id: Optional[int] = None, **subscription: Any) -> Subscription: if id is None: raise ValueError("id is required") pid = self._client._resolve_project_id(project_id) - return self._client._request("PATCH", f"/projects/{pid}/subscriptions/{id}", json=subscription) + response = self._client._request("PATCH", f"/projects/{pid}/subscriptions/{id}", json=subscription) + payload = _ensure_api_object(response) + return Subscription.from_api(payload) - def delete(self, id: int, *, project_id: Optional[int] = None) -> bool: + def delete(self, id: int, *, project_id: Optional[int] = None) -> None: if id is None: raise ValueError("id is required") pid = self._client._resolve_project_id(project_id) self._client._request("DELETE", f"/projects/{pid}/subscriptions/{id}") - return True + return None diff --git a/pushpad/types.py b/pushpad/types.py new file mode 100644 index 0000000..d8c3b91 --- /dev/null +++ b/pushpad/types.py @@ -0,0 +1,204 @@ +"""Typed resource objects returned by the client.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Mapping, Optional + + +def _to_str_list(values: Any) -> Optional[list[str]]: + if values is None: + return None + if isinstance(values, list): + return [str(item) for item in values] + if isinstance(values, (tuple, set)): + return [str(item) for item in values] + return [str(values)] + + +@dataclass +class NotificationAction: + title: Optional[str] = None + target_url: Optional[str] = None + icon: Optional[str] = None + action: Optional[str] = None + + @classmethod + def from_api(cls, data: Mapping[str, Any]) -> "NotificationAction": + return cls( + title=data.get("title"), + target_url=data.get("target_url"), + icon=data.get("icon"), + action=data.get("action"), + ) + + +def _to_actions(values: Any) -> list[NotificationAction]: + if not values: + return [] + actions: list[NotificationAction] = [] + for entry in values: + if isinstance(entry, Mapping): + actions.append(NotificationAction.from_api(entry)) + return actions + + +@dataclass +class NotificationCreateResult: + id: Optional[int] = None + scheduled: Optional[int] = None + uids: Optional[list[str]] = None + send_at: Optional[str] = None + + @classmethod + def from_api(cls, data: Mapping[str, Any]) -> "NotificationCreateResult": + return cls( + id=data.get("id"), + scheduled=data.get("scheduled"), + uids=_to_str_list(data.get("uids")), + send_at=data.get("send_at"), + ) + + +@dataclass +class Notification: + id: Optional[int] = None + project_id: Optional[int] = None + title: Optional[str] = None + body: Optional[str] = None + target_url: Optional[str] = None + icon_url: Optional[str] = None + badge_url: Optional[str] = None + image_url: Optional[str] = None + ttl: Optional[int] = None + require_interaction: Optional[bool] = None + silent: Optional[bool] = None + urgent: Optional[bool] = None + custom_data: Optional[str] = None + actions: list[NotificationAction] = field(default_factory=list) + starred: Optional[bool] = None + send_at: Optional[str] = None + custom_metrics: Optional[list[str]] = None + uids: Optional[list[str]] = None + tags: Optional[list[str]] = None + created_at: Optional[str] = None + successfully_sent_count: Optional[int] = None + opened_count: Optional[int] = None + scheduled_count: Optional[int] = None + scheduled: bool | int | None = None + cancelled: Optional[bool] = None + + @classmethod + def from_api(cls, data: Mapping[str, Any]) -> "Notification": + return cls( + id=data.get("id"), + project_id=data.get("project_id"), + title=data.get("title"), + body=data.get("body"), + target_url=data.get("target_url"), + icon_url=data.get("icon_url"), + badge_url=data.get("badge_url"), + image_url=data.get("image_url"), + ttl=data.get("ttl"), + require_interaction=data.get("require_interaction"), + silent=data.get("silent"), + urgent=data.get("urgent"), + custom_data=data.get("custom_data"), + actions=_to_actions(data.get("actions")), + starred=data.get("starred"), + send_at=data.get("send_at"), + custom_metrics=_to_str_list(data.get("custom_metrics")), + uids=_to_str_list(data.get("uids")), + tags=_to_str_list(data.get("tags")), + created_at=data.get("created_at"), + successfully_sent_count=data.get("successfully_sent_count"), + opened_count=data.get("opened_count"), + scheduled_count=data.get("scheduled_count"), + scheduled=data.get("scheduled"), + cancelled=data.get("cancelled"), + ) + + +@dataclass +class Subscription: + id: Optional[int] = None + project_id: Optional[int] = None + endpoint: Optional[str] = None + p256dh: Optional[str] = None + auth: Optional[str] = None + uid: Optional[str] = None + tags: Optional[list[str]] = None + last_click_at: Optional[str] = None + created_at: Optional[str] = None + + @classmethod + def from_api(cls, data: Mapping[str, Any]) -> "Subscription": + return cls( + id=data.get("id"), + project_id=data.get("project_id"), + endpoint=data.get("endpoint"), + p256dh=data.get("p256dh"), + auth=data.get("auth"), + uid=data.get("uid"), + tags=_to_str_list(data.get("tags")), + last_click_at=data.get("last_click_at"), + created_at=data.get("created_at"), + ) + + +@dataclass +class Project: + id: Optional[int] = None + sender_id: Optional[int] = None + name: Optional[str] = None + website: Optional[str] = None + icon_url: Optional[str] = None + badge_url: Optional[str] = None + notifications_ttl: Optional[int] = None + notifications_require_interaction: Optional[bool] = None + notifications_silent: Optional[bool] = None + created_at: Optional[str] = None + + @classmethod + def from_api(cls, data: Mapping[str, Any]) -> "Project": + return cls( + id=data.get("id"), + sender_id=data.get("sender_id"), + name=data.get("name"), + website=data.get("website"), + icon_url=data.get("icon_url"), + badge_url=data.get("badge_url"), + notifications_ttl=data.get("notifications_ttl"), + notifications_require_interaction=data.get("notifications_require_interaction"), + notifications_silent=data.get("notifications_silent"), + created_at=data.get("created_at"), + ) + + +@dataclass +class Sender: + id: Optional[int] = None + name: Optional[str] = None + vapid_private_key: Optional[str] = None + vapid_public_key: Optional[str] = None + created_at: Optional[str] = None + + @classmethod + def from_api(cls, data: Mapping[str, Any]) -> "Sender": + return cls( + id=data.get("id"), + name=data.get("name"), + vapid_private_key=data.get("vapid_private_key"), + vapid_public_key=data.get("vapid_public_key"), + created_at=data.get("created_at"), + ) + + +__all__ = [ + "Notification", + "NotificationAction", + "NotificationCreateResult", + "Subscription", + "Project", + "Sender", +] diff --git a/tests/resources/test_notifications.py b/tests/resources/test_notifications.py index badc1cc..9b8dfe0 100644 --- a/tests/resources/test_notifications.py +++ b/tests/resources/test_notifications.py @@ -7,7 +7,6 @@ def test_notifications_create(self): response = make_response(payload={"id": 123, "scheduled": 10}) client, session = make_client(self.token, self.project_id, response) result = client.notifications.create(body="Hello") - self.assertEqual(result["id"], 123) self.assertEqual(result.id, 123) method, url = session.request.call_args[0] self.assertEqual(method, "POST") @@ -24,7 +23,8 @@ def test_notifications_all(self): response = make_response(payload=[{"id": 1}]) client, session = make_client(self.token, self.project_id, response) result = client.notifications.all(page=2) - self.assertEqual(result, [{"id": 1}]) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 1) kwargs = session.request.call_args[1] self.assertEqual(kwargs["params"], {"page": 2}) @@ -40,7 +40,7 @@ def test_notifications_get(self): def test_notifications_cancel(self): response = make_response(status=204) client, session = make_client(self.token, self.project_id, response) - self.assertTrue(client.notifications.cancel(10)) + self.assertIsNone(client.notifications.cancel(10)) method, url = session.request.call_args[0] self.assertEqual(method, "DELETE") self.assertIn("/notifications/10/cancel", url) diff --git a/tests/resources/test_projects.py b/tests/resources/test_projects.py index d9b790a..82b2c3e 100644 --- a/tests/resources/test_projects.py +++ b/tests/resources/test_projects.py @@ -16,7 +16,9 @@ def test_projects_create(self): def test_projects_all(self): response = make_response(payload=[{"id": 1}]) client, session = make_client(self.token, response=response) - self.assertEqual(client.projects.all(), [{"id": 1}]) + projects = client.projects.all() + self.assertEqual(len(projects), 1) + self.assertEqual(projects[0].id, 1) method, url = session.request.call_args[0] self.assertEqual(method, "GET") self.assertTrue(url.endswith("/projects")) @@ -43,7 +45,7 @@ def test_projects_update(self): def test_projects_delete(self): response = make_response(status=202) client, session = make_client(self.token, response=response) - self.assertTrue(client.projects.delete(99)) + self.assertIsNone(client.projects.delete(99)) method, url = session.request.call_args[0] self.assertEqual(method, "DELETE") self.assertTrue(url.endswith("/projects/99")) diff --git a/tests/resources/test_senders.py b/tests/resources/test_senders.py index 122cfbb..a183c9a 100644 --- a/tests/resources/test_senders.py +++ b/tests/resources/test_senders.py @@ -16,7 +16,9 @@ def test_senders_create(self): def test_senders_all(self): response = make_response(payload=[{"id": 1}]) client, session = make_client(self.token, response=response) - self.assertEqual(client.senders.all(), [{"id": 1}]) + senders = client.senders.all() + self.assertEqual(len(senders), 1) + self.assertEqual(senders[0].id, 1) method, url = session.request.call_args[0] self.assertEqual(method, "GET") self.assertTrue(url.endswith("/senders")) @@ -42,7 +44,7 @@ def test_senders_update(self): def test_senders_delete(self): response = make_response(status=204) client, session = make_client(self.token, response=response) - self.assertTrue(client.senders.delete(66)) + self.assertIsNone(client.senders.delete(66)) method, url = session.request.call_args[0] self.assertEqual(method, "DELETE") self.assertTrue(url.endswith("/senders/66")) diff --git a/tests/resources/test_subscriptions.py b/tests/resources/test_subscriptions.py index d3193a0..80625c1 100644 --- a/tests/resources/test_subscriptions.py +++ b/tests/resources/test_subscriptions.py @@ -61,7 +61,7 @@ def test_subscriptions_update_can_set_fields_to_null(self): def test_subscriptions_delete(self): response = make_response(status=204) client, session = make_client(self.token, self.project_id, response) - self.assertTrue(client.subscriptions.delete(44)) + self.assertIsNone(client.subscriptions.delete(44)) method, url = session.request.call_args[0] self.assertEqual(method, "DELETE") self.assertTrue(url.endswith("/projects/1/subscriptions/44")) From 4ec2b5a158cd54e6f799ac47b5e6d788939d2dc9 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 22:03:55 +0100 Subject: [PATCH 16/38] Subscription count should read from headers and never from response body --- pushpad/resources/subscriptions.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py index ad8f28f..06bd26f 100644 --- a/pushpad/resources/subscriptions.py +++ b/pushpad/resources/subscriptions.py @@ -70,16 +70,9 @@ def count( params=params, ) total = response.headers.get("X-Total-Count") - if total is not None: - try: - return int(total) - except ValueError: - pass - try: - data = response.json() - except ValueError: - data = [] - return len(data) + if total is None: + raise ValueError("response missing X-Total-Count header") + return int(total) def create(self, *, project_id: Optional[int] = None, **subscription: Any) -> Subscription: pid = self._client._resolve_project_id(project_id) From ad0d4cd3b5b478d063e11dd3add8221c6764d45e Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 18 Nov 2025 23:03:41 +0100 Subject: [PATCH 17/38] Add keyword arguments with types to resource methods (create, update) --- pushpad/_sentinel.py | 26 ++++++++++++++ pushpad/resources/notifications.py | 52 ++++++++++++++++++++++++--- pushpad/resources/projects.py | 58 +++++++++++++++++++++++++----- pushpad/resources/senders.py | 34 +++++++++++++----- pushpad/resources/subscriptions.py | 44 ++++++++++++++++++----- 5 files changed, 185 insertions(+), 29 deletions(-) create mode 100644 pushpad/_sentinel.py diff --git a/pushpad/_sentinel.py b/pushpad/_sentinel.py new file mode 100644 index 0000000..3f52756 --- /dev/null +++ b/pushpad/_sentinel.py @@ -0,0 +1,26 @@ +"""Sentinel objects shared across the client implementation.""" + +from __future__ import annotations + +from typing import TypeVar + + +class _MissingType: + __slots__ = () + + def __repr__(self) -> str: # pragma: no cover - trivial representation + return "MISSING" + + +_MISSING = _MissingType() + +T = TypeVar("T") + + +def remove_missing(**values: object | _MissingType) -> dict[str, object]: + """Return a dict with all entries whose value is not the sentinel.""" + + return {key: value for key, value in values.items() if value is not _MISSING} + + +__all__ = ["_MISSING", "_MissingType", "remove_missing"] diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py index ab30661..e6af717 100644 --- a/pushpad/resources/notifications.py +++ b/pushpad/resources/notifications.py @@ -2,8 +2,10 @@ from __future__ import annotations -from typing import Any, Optional, TYPE_CHECKING +from datetime import datetime +from typing import Any, Iterable, Mapping, Optional, TYPE_CHECKING +from .._sentinel import _MISSING, _MissingType, remove_missing from ..pushpad import _ensure_api_list, _ensure_api_object from ..types import Notification, NotificationCreateResult @@ -28,11 +30,51 @@ def all( payload = _ensure_api_list(response) return [Notification.from_api(item) for item in payload] - def create(self, *, project_id: Optional[int] = None, **notification: Any) -> NotificationCreateResult: + def create( + self, + *, + body: str, + project_id: Optional[int] = None, + title: str | None | _MissingType = _MISSING, + target_url: str | None | _MissingType = _MISSING, + icon_url: str | None | _MissingType = _MISSING, + badge_url: str | None | _MissingType = _MISSING, + image_url: str | None | _MissingType = _MISSING, + ttl: int | None | _MissingType = _MISSING, + require_interaction: bool | None | _MissingType = _MISSING, + silent: bool | None | _MissingType = _MISSING, + urgent: bool | None | _MissingType = _MISSING, + custom_data: str | None | _MissingType = _MISSING, + actions: Iterable[Mapping[str, Any]] | None | _MissingType = _MISSING, + starred: bool | None | _MissingType = _MISSING, + send_at: datetime | str | None | _MissingType = _MISSING, + custom_metrics: Iterable[str] | str | None | _MissingType = _MISSING, + uids: Iterable[str] | str | None | _MissingType = _MISSING, + tags: Iterable[str] | str | None | _MissingType = _MISSING, + ) -> NotificationCreateResult: pid = self._client._resolve_project_id(project_id) - response = self._client._request("POST", f"/projects/{pid}/notifications", json=notification) - payload = _ensure_api_object(response) - return NotificationCreateResult.from_api(payload) + payload = remove_missing( + body=body, + title=title, + target_url=target_url, + icon_url=icon_url, + badge_url=badge_url, + image_url=image_url, + ttl=ttl, + require_interaction=require_interaction, + silent=silent, + urgent=urgent, + custom_data=custom_data, + starred=starred, + send_at=send_at, + actions=actions, + custom_metrics=custom_metrics, + uids=uids, + tags=tags, + ) + response = self._client._request("POST", f"/projects/{pid}/notifications", json=payload) + data = _ensure_api_object(response) + return NotificationCreateResult.from_api(data) def get(self, id: int) -> Notification: if id is None: diff --git a/pushpad/resources/projects.py b/pushpad/resources/projects.py index ada2d81..7ff4d6a 100644 --- a/pushpad/resources/projects.py +++ b/pushpad/resources/projects.py @@ -4,6 +4,7 @@ from typing import Any, TYPE_CHECKING +from .._sentinel import _MISSING, _MissingType, remove_missing from ..pushpad import _ensure_api_list, _ensure_api_object from ..types import Project @@ -20,10 +21,31 @@ def all(self) -> list[Project]: payload = _ensure_api_list(response) return [Project.from_api(item) for item in payload] - def create(self, **project: Any) -> Project: - response = self._client._request("POST", "/projects", json=project) - payload = _ensure_api_object(response) - return Project.from_api(payload) + def create( + self, + *, + sender_id: int | None | _MissingType = _MISSING, + name: str, + website: str | None | _MissingType = _MISSING, + icon_url: str | None | _MissingType = _MISSING, + badge_url: str | None | _MissingType = _MISSING, + notifications_ttl: int | None | _MissingType = _MISSING, + notifications_require_interaction: bool | None | _MissingType = _MISSING, + notifications_silent: bool | None | _MissingType = _MISSING, + ) -> Project: + payload = remove_missing( + sender_id=sender_id, + name=name, + website=website, + icon_url=icon_url, + badge_url=badge_url, + notifications_ttl=notifications_ttl, + notifications_require_interaction=notifications_require_interaction, + notifications_silent=notifications_silent, + ) + response = self._client._request("POST", "/projects", json=payload) + data = _ensure_api_object(response) + return Project.from_api(data) def get(self, id: int) -> Project: if id is None: @@ -32,12 +54,32 @@ def get(self, id: int) -> Project: payload = _ensure_api_object(response) return Project.from_api(payload) - def update(self, id: int, **project: Any) -> Project: + def update( + self, + id: int, + *, + name: str | None | _MissingType = _MISSING, + website: str | None | _MissingType = _MISSING, + icon_url: str | None | _MissingType = _MISSING, + badge_url: str | None | _MissingType = _MISSING, + notifications_ttl: int | None | _MissingType = _MISSING, + notifications_require_interaction: bool | None | _MissingType = _MISSING, + notifications_silent: bool | None | _MissingType = _MISSING, + ) -> Project: if id is None: raise ValueError("id is required") - response = self._client._request("PATCH", f"/projects/{id}", json=project) - payload = _ensure_api_object(response) - return Project.from_api(payload) + payload = remove_missing( + name=name, + website=website, + icon_url=icon_url, + badge_url=badge_url, + notifications_ttl=notifications_ttl, + notifications_require_interaction=notifications_require_interaction, + notifications_silent=notifications_silent, + ) + response = self._client._request("PATCH", f"/projects/{id}", json=payload) + data = _ensure_api_object(response) + return Project.from_api(data) def delete(self, id: int) -> None: if id is None: diff --git a/pushpad/resources/senders.py b/pushpad/resources/senders.py index add6b2d..33c81cd 100644 --- a/pushpad/resources/senders.py +++ b/pushpad/resources/senders.py @@ -4,6 +4,7 @@ from typing import Any, TYPE_CHECKING +from .._sentinel import _MISSING, _MissingType, remove_missing from ..pushpad import _ensure_api_list, _ensure_api_object from ..types import Sender @@ -20,10 +21,21 @@ def all(self) -> list[Sender]: payload = _ensure_api_list(response) return [Sender.from_api(item) for item in payload] - def create(self, **sender: Any) -> Sender: - response = self._client._request("POST", "/senders", json=sender) - payload = _ensure_api_object(response) - return Sender.from_api(payload) + def create( + self, + *, + name: str, + vapid_private_key: str | None | _MissingType = _MISSING, + vapid_public_key: str | None | _MissingType = _MISSING, + ) -> Sender: + payload = remove_missing( + name=name, + vapid_private_key=vapid_private_key, + vapid_public_key=vapid_public_key, + ) + response = self._client._request("POST", "/senders", json=payload) + data = _ensure_api_object(response) + return Sender.from_api(data) def get(self, id: int) -> Sender: if id is None: @@ -32,12 +44,18 @@ def get(self, id: int) -> Sender: payload = _ensure_api_object(response) return Sender.from_api(payload) - def update(self, id: int, **sender: Any) -> Sender: + def update( + self, + id: int, + *, + name: str | None | _MissingType = _MISSING, + ) -> Sender: if id is None: raise ValueError("id is required") - response = self._client._request("PATCH", f"/senders/{id}", json=sender) - payload = _ensure_api_object(response) - return Sender.from_api(payload) + payload = remove_missing(name=name) + response = self._client._request("PATCH", f"/senders/{id}", json=payload) + data = _ensure_api_object(response) + return Sender.from_api(data) def delete(self, id: int) -> None: if id is None: diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py index 06bd26f..6283f71 100644 --- a/pushpad/resources/subscriptions.py +++ b/pushpad/resources/subscriptions.py @@ -4,6 +4,7 @@ from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING +from .._sentinel import _MISSING, _MissingType, remove_missing from ..pushpad import _ensure_api_list, _ensure_api_object from ..types import Subscription @@ -74,11 +75,27 @@ def count( raise ValueError("response missing X-Total-Count header") return int(total) - def create(self, *, project_id: Optional[int] = None, **subscription: Any) -> Subscription: + def create( + self, + *, + project_id: Optional[int] = None, + endpoint: str | None | _MissingType = _MISSING, + p256dh: str | None | _MissingType = _MISSING, + auth: str | None | _MissingType = _MISSING, + uid: str | None | _MissingType = _MISSING, + tags: Iterable[str] | str | None | _MissingType = _MISSING, + ) -> Subscription: pid = self._client._resolve_project_id(project_id) - response = self._client._request("POST", f"/projects/{pid}/subscriptions", json=subscription) - payload = _ensure_api_object(response) - return Subscription.from_api(payload) + payload = remove_missing( + endpoint=endpoint, + p256dh=p256dh, + auth=auth, + uid=uid, + tags=tags, + ) + response = self._client._request("POST", f"/projects/{pid}/subscriptions", json=payload) + data = _ensure_api_object(response) + return Subscription.from_api(data) def get(self, id: int, *, project_id: Optional[int] = None) -> Subscription: if id is None: @@ -88,13 +105,24 @@ def get(self, id: int, *, project_id: Optional[int] = None) -> Subscription: payload = _ensure_api_object(response) return Subscription.from_api(payload) - def update(self, id: int, *, project_id: Optional[int] = None, **subscription: Any) -> Subscription: + def update( + self, + id: int, + *, + project_id: Optional[int] = None, + uid: str | None | _MissingType = _MISSING, + tags: Iterable[str] | str | None | _MissingType = _MISSING, + ) -> Subscription: if id is None: raise ValueError("id is required") pid = self._client._resolve_project_id(project_id) - response = self._client._request("PATCH", f"/projects/{pid}/subscriptions/{id}", json=subscription) - payload = _ensure_api_object(response) - return Subscription.from_api(payload) + payload = remove_missing( + uid=uid, + tags=tags, + ) + response = self._client._request("PATCH", f"/projects/{pid}/subscriptions/{id}", json=payload) + data = _ensure_api_object(response) + return Subscription.from_api(data) def delete(self, id: int, *, project_id: Optional[int] = None) -> None: if id is None: From 953b8f96f16e78435afebdafd3a5afe1f921bb57 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 00:10:06 +0100 Subject: [PATCH 18/38] Improve resource method signatures with more precise types for fields --- pushpad/resources/notifications.py | 32 +++++++++++++++--------------- pushpad/resources/projects.py | 28 +++++++++++++------------- pushpad/resources/senders.py | 6 +++--- pushpad/resources/subscriptions.py | 10 +++++----- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py index e6af717..d96e675 100644 --- a/pushpad/resources/notifications.py +++ b/pushpad/resources/notifications.py @@ -35,22 +35,22 @@ def create( *, body: str, project_id: Optional[int] = None, - title: str | None | _MissingType = _MISSING, - target_url: str | None | _MissingType = _MISSING, - icon_url: str | None | _MissingType = _MISSING, - badge_url: str | None | _MissingType = _MISSING, - image_url: str | None | _MissingType = _MISSING, - ttl: int | None | _MissingType = _MISSING, - require_interaction: bool | None | _MissingType = _MISSING, - silent: bool | None | _MissingType = _MISSING, - urgent: bool | None | _MissingType = _MISSING, - custom_data: str | None | _MissingType = _MISSING, - actions: Iterable[Mapping[str, Any]] | None | _MissingType = _MISSING, - starred: bool | None | _MissingType = _MISSING, - send_at: datetime | str | None | _MissingType = _MISSING, - custom_metrics: Iterable[str] | str | None | _MissingType = _MISSING, - uids: Iterable[str] | str | None | _MissingType = _MISSING, - tags: Iterable[str] | str | None | _MissingType = _MISSING, + title: str | _MissingType = _MISSING, + target_url: str | _MissingType = _MISSING, + icon_url: str | _MissingType = _MISSING, + badge_url: str | _MissingType = _MISSING, + image_url: str | _MissingType = _MISSING, + ttl: int | _MissingType = _MISSING, + require_interaction: bool | _MissingType = _MISSING, + silent: bool | _MissingType = _MISSING, + urgent: bool | _MissingType = _MISSING, + custom_data: str | _MissingType = _MISSING, + actions: Iterable[Mapping[str, str]] | _MissingType = _MISSING, + starred: bool | _MissingType = _MISSING, + send_at: datetime | str | _MissingType = _MISSING, + custom_metrics: Iterable[str] | _MissingType = _MISSING, + uids: Iterable[str] | _MissingType = _MISSING, + tags: Iterable[str] | _MissingType = _MISSING, ) -> NotificationCreateResult: pid = self._client._resolve_project_id(project_id) payload = remove_missing( diff --git a/pushpad/resources/projects.py b/pushpad/resources/projects.py index 7ff4d6a..c709d74 100644 --- a/pushpad/resources/projects.py +++ b/pushpad/resources/projects.py @@ -24,14 +24,14 @@ def all(self) -> list[Project]: def create( self, *, - sender_id: int | None | _MissingType = _MISSING, + sender_id: int, name: str, - website: str | None | _MissingType = _MISSING, - icon_url: str | None | _MissingType = _MISSING, - badge_url: str | None | _MissingType = _MISSING, - notifications_ttl: int | None | _MissingType = _MISSING, - notifications_require_interaction: bool | None | _MissingType = _MISSING, - notifications_silent: bool | None | _MissingType = _MISSING, + website: str, + icon_url: str | _MissingType = _MISSING, + badge_url: str | _MissingType = _MISSING, + notifications_ttl: int | _MissingType = _MISSING, + notifications_require_interaction: bool | _MissingType = _MISSING, + notifications_silent: bool | _MissingType = _MISSING, ) -> Project: payload = remove_missing( sender_id=sender_id, @@ -58,13 +58,13 @@ def update( self, id: int, *, - name: str | None | _MissingType = _MISSING, - website: str | None | _MissingType = _MISSING, - icon_url: str | None | _MissingType = _MISSING, - badge_url: str | None | _MissingType = _MISSING, - notifications_ttl: int | None | _MissingType = _MISSING, - notifications_require_interaction: bool | None | _MissingType = _MISSING, - notifications_silent: bool | None | _MissingType = _MISSING, + name: str | _MissingType = _MISSING, + website: str | _MissingType = _MISSING, + icon_url: str | _MissingType = _MISSING, + badge_url: str | _MissingType = _MISSING, + notifications_ttl: int | _MissingType = _MISSING, + notifications_require_interaction: bool | _MissingType = _MISSING, + notifications_silent: bool | _MissingType = _MISSING, ) -> Project: if id is None: raise ValueError("id is required") diff --git a/pushpad/resources/senders.py b/pushpad/resources/senders.py index 33c81cd..ef6b416 100644 --- a/pushpad/resources/senders.py +++ b/pushpad/resources/senders.py @@ -25,8 +25,8 @@ def create( self, *, name: str, - vapid_private_key: str | None | _MissingType = _MISSING, - vapid_public_key: str | None | _MissingType = _MISSING, + vapid_private_key: str | _MissingType = _MISSING, + vapid_public_key: str | _MissingType = _MISSING, ) -> Sender: payload = remove_missing( name=name, @@ -48,7 +48,7 @@ def update( self, id: int, *, - name: str | None | _MissingType = _MISSING, + name: str | _MissingType = _MISSING, ) -> Sender: if id is None: raise ValueError("id is required") diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py index 6283f71..bd9135f 100644 --- a/pushpad/resources/subscriptions.py +++ b/pushpad/resources/subscriptions.py @@ -79,11 +79,11 @@ def create( self, *, project_id: Optional[int] = None, - endpoint: str | None | _MissingType = _MISSING, - p256dh: str | None | _MissingType = _MISSING, - auth: str | None | _MissingType = _MISSING, + endpoint: str, + p256dh: str | _MissingType = _MISSING, + auth: str | _MissingType = _MISSING, uid: str | None | _MissingType = _MISSING, - tags: Iterable[str] | str | None | _MissingType = _MISSING, + tags: Iterable[str] | _MissingType = _MISSING, ) -> Subscription: pid = self._client._resolve_project_id(project_id) payload = remove_missing( @@ -111,7 +111,7 @@ def update( *, project_id: Optional[int] = None, uid: str | None | _MissingType = _MISSING, - tags: Iterable[str] | str | None | _MissingType = _MISSING, + tags: Iterable[str] | _MissingType = _MISSING, ) -> Subscription: if id is None: raise ValueError("id is required") From 51bf70d13cdf32952a4f4e3d6f1b393cc140f134 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 00:22:41 +0100 Subject: [PATCH 19/38] Add required arguments to tests --- tests/resources/test_projects.py | 11 +++++++++-- tests/resources/test_subscriptions.py | 10 ++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/resources/test_projects.py b/tests/resources/test_projects.py index 82b2c3e..0b66245 100644 --- a/tests/resources/test_projects.py +++ b/tests/resources/test_projects.py @@ -6,12 +6,19 @@ class ProjectsResourceTests(BasePushpadTestCase): def test_projects_create(self): response = make_response(payload={"id": 2}) client, session = make_client(self.token, response=response) - project = client.projects.create(name="Demo") + project = client.projects.create( + sender_id=99, + name="Demo", + website="https://example.com", + ) self.assertEqual(project.id, 2) method, url = session.request.call_args[0] self.assertEqual(method, "POST") self.assertTrue(url.endswith("/projects")) - self.assertEqual(session.request.call_args[1]["json"], {"name": "Demo"}) + self.assertEqual( + session.request.call_args[1]["json"], + {"sender_id": 99, "name": "Demo", "website": "https://example.com"}, + ) def test_projects_all(self): response = make_response(payload=[{"id": 1}]) diff --git a/tests/resources/test_subscriptions.py b/tests/resources/test_subscriptions.py index 80625c1..c18b0f0 100644 --- a/tests/resources/test_subscriptions.py +++ b/tests/resources/test_subscriptions.py @@ -6,12 +6,18 @@ class SubscriptionsResourceTests(BasePushpadTestCase): def test_subscriptions_create(self): response = make_response(payload={"id": 11}) client, session = make_client(self.token, self.project_id, response) - subscription = client.subscriptions.create(uid="u1") + subscription = client.subscriptions.create( + endpoint="https://pushpad.example/endpoint", + uid="u1", + ) self.assertEqual(subscription.id, 11) method, url = session.request.call_args[0] self.assertEqual(method, "POST") self.assertIn("/projects/1/subscriptions", url) - self.assertEqual(session.request.call_args[1]["json"], {"uid": "u1"}) + self.assertEqual( + session.request.call_args[1]["json"], + {"endpoint": "https://pushpad.example/endpoint", "uid": "u1"}, + ) def test_subscriptions_all_accepts_boolean_expression(self): response = make_response(payload=[]) From 8eac0b87a0439618e08a011fa3ea27ca331181d8 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 01:00:12 +0100 Subject: [PATCH 20/38] Improve types of API response fields --- pushpad/types.py | 124 +++++++++++++++++++++++------------------------ 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/pushpad/types.py b/pushpad/types.py index d8c3b91..70aa486 100644 --- a/pushpad/types.py +++ b/pushpad/types.py @@ -2,11 +2,11 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any, Mapping, Optional +from dataclasses import dataclass +from typing import Any, Mapping -def _to_str_list(values: Any) -> Optional[list[str]]: +def _to_str_list(values: Any) -> list[str] | None: if values is None: return None if isinstance(values, list): @@ -18,10 +18,10 @@ def _to_str_list(values: Any) -> Optional[list[str]]: @dataclass class NotificationAction: - title: Optional[str] = None - target_url: Optional[str] = None - icon: Optional[str] = None - action: Optional[str] = None + title: str | None + target_url: str | None + icon: str | None + action: str | None @classmethod def from_api(cls, data: Mapping[str, Any]) -> "NotificationAction": @@ -45,10 +45,10 @@ def _to_actions(values: Any) -> list[NotificationAction]: @dataclass class NotificationCreateResult: - id: Optional[int] = None - scheduled: Optional[int] = None - uids: Optional[list[str]] = None - send_at: Optional[str] = None + id: int + scheduled: int | None + uids: list[str] | None + send_at: str | None @classmethod def from_api(cls, data: Mapping[str, Any]) -> "NotificationCreateResult": @@ -62,31 +62,31 @@ def from_api(cls, data: Mapping[str, Any]) -> "NotificationCreateResult": @dataclass class Notification: - id: Optional[int] = None - project_id: Optional[int] = None - title: Optional[str] = None - body: Optional[str] = None - target_url: Optional[str] = None - icon_url: Optional[str] = None - badge_url: Optional[str] = None - image_url: Optional[str] = None - ttl: Optional[int] = None - require_interaction: Optional[bool] = None - silent: Optional[bool] = None - urgent: Optional[bool] = None - custom_data: Optional[str] = None - actions: list[NotificationAction] = field(default_factory=list) - starred: Optional[bool] = None - send_at: Optional[str] = None - custom_metrics: Optional[list[str]] = None - uids: Optional[list[str]] = None - tags: Optional[list[str]] = None - created_at: Optional[str] = None - successfully_sent_count: Optional[int] = None - opened_count: Optional[int] = None - scheduled_count: Optional[int] = None - scheduled: bool | int | None = None - cancelled: Optional[bool] = None + id: int + project_id: int + title: str + body: str + target_url: str + icon_url: str | None + badge_url: str | None + image_url: str | None + ttl: int + require_interaction: bool + silent: bool + urgent: bool + custom_data: str | None + actions: list[NotificationAction] + starred: bool + send_at: str | None + custom_metrics: list[str] + uids: list[str] | None + tags: list[str] | None + created_at: str + successfully_sent_count: int | None + opened_count: int | None + scheduled_count: int | None + scheduled: bool | None + cancelled: bool | None @classmethod def from_api(cls, data: Mapping[str, Any]) -> "Notification": @@ -107,7 +107,7 @@ def from_api(cls, data: Mapping[str, Any]) -> "Notification": actions=_to_actions(data.get("actions")), starred=data.get("starred"), send_at=data.get("send_at"), - custom_metrics=_to_str_list(data.get("custom_metrics")), + custom_metrics=_to_str_list(data.get("custom_metrics")) or [], uids=_to_str_list(data.get("uids")), tags=_to_str_list(data.get("tags")), created_at=data.get("created_at"), @@ -121,15 +121,15 @@ def from_api(cls, data: Mapping[str, Any]) -> "Notification": @dataclass class Subscription: - id: Optional[int] = None - project_id: Optional[int] = None - endpoint: Optional[str] = None - p256dh: Optional[str] = None - auth: Optional[str] = None - uid: Optional[str] = None - tags: Optional[list[str]] = None - last_click_at: Optional[str] = None - created_at: Optional[str] = None + id: int + project_id: int + endpoint: str + p256dh: str | None + auth: str | None + uid: str | None + tags: list[str] + last_click_at: str | None + created_at: str @classmethod def from_api(cls, data: Mapping[str, Any]) -> "Subscription": @@ -140,7 +140,7 @@ def from_api(cls, data: Mapping[str, Any]) -> "Subscription": p256dh=data.get("p256dh"), auth=data.get("auth"), uid=data.get("uid"), - tags=_to_str_list(data.get("tags")), + tags=_to_str_list(data.get("tags")) or [], last_click_at=data.get("last_click_at"), created_at=data.get("created_at"), ) @@ -148,16 +148,16 @@ def from_api(cls, data: Mapping[str, Any]) -> "Subscription": @dataclass class Project: - id: Optional[int] = None - sender_id: Optional[int] = None - name: Optional[str] = None - website: Optional[str] = None - icon_url: Optional[str] = None - badge_url: Optional[str] = None - notifications_ttl: Optional[int] = None - notifications_require_interaction: Optional[bool] = None - notifications_silent: Optional[bool] = None - created_at: Optional[str] = None + id: int + sender_id: int + name: str + website: str + icon_url: str | None + badge_url: str | None + notifications_ttl: int + notifications_require_interaction: bool + notifications_silent: bool + created_at: str @classmethod def from_api(cls, data: Mapping[str, Any]) -> "Project": @@ -177,11 +177,11 @@ def from_api(cls, data: Mapping[str, Any]) -> "Project": @dataclass class Sender: - id: Optional[int] = None - name: Optional[str] = None - vapid_private_key: Optional[str] = None - vapid_public_key: Optional[str] = None - created_at: Optional[str] = None + id: int + name: str + vapid_private_key: str + vapid_public_key: str + created_at: str @classmethod def from_api(cls, data: Mapping[str, Any]) -> "Sender": From 573c770bf31512ebdfb3e3667f8bcc95756aba3b Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 12:23:34 +0100 Subject: [PATCH 21/38] Add tests for resource get with all fields --- tests/resources/test_notifications.py | 68 +++++++++++++++++++++++++++ tests/resources/test_projects.py | 30 ++++++++++++ tests/resources/test_senders.py | 17 +++++++ tests/resources/test_subscriptions.py | 25 ++++++++++ 4 files changed, 140 insertions(+) diff --git a/tests/resources/test_notifications.py b/tests/resources/test_notifications.py index 9b8dfe0..e31492a 100644 --- a/tests/resources/test_notifications.py +++ b/tests/resources/test_notifications.py @@ -37,6 +37,74 @@ def test_notifications_get(self): self.assertEqual(method, "GET") self.assertTrue(url.endswith("/notifications/77")) + def test_notifications_get_with_all_fields(self): + payload = { + "id": 101, + "project_id": 7, + "title": "New promotion", + "body": "Buy now", + "target_url": "https://example.com/promo", + "icon_url": "https://example.com/icon.png", + "badge_url": "https://example.com/badge.png", + "image_url": "https://example.com/image.png", + "ttl": 600, + "require_interaction": True, + "silent": False, + "urgent": True, + "custom_data": "metadata", + "actions": [ + { + "title": "Open cart", + "target_url": "https://example.com/cart", + "icon": "https://example.com/cart.png", + "action": "open_cart", + } + ], + "starred": True, + "send_at": "2024-01-01T00:00:00.000Z", + "custom_metrics": ["metric1", "metric2"], + "uids": ["uid-a", "uid-b"], + "tags": ["tag1", "tag2"], + "created_at": "2023-12-31T12:00:00.000Z", + "successfully_sent_count": 10, + "opened_count": 3, + "scheduled_count": 2, + "scheduled": False, + "cancelled": False, + } + response = make_response(payload=payload) + client, _ = make_client(self.token, self.project_id, response) + notification = client.notifications.get(payload["id"]) + self.assertEqual(notification.id, payload["id"]) + self.assertEqual(notification.project_id, payload["project_id"]) + self.assertEqual(notification.title, payload["title"]) + self.assertEqual(notification.body, payload["body"]) + self.assertEqual(notification.target_url, payload["target_url"]) + self.assertEqual(notification.icon_url, payload["icon_url"]) + self.assertEqual(notification.badge_url, payload["badge_url"]) + self.assertEqual(notification.image_url, payload["image_url"]) + self.assertEqual(notification.ttl, payload["ttl"]) + self.assertEqual(notification.require_interaction, payload["require_interaction"]) + self.assertEqual(notification.silent, payload["silent"]) + self.assertEqual(notification.urgent, payload["urgent"]) + self.assertEqual(notification.custom_data, payload["custom_data"]) + self.assertEqual(len(notification.actions), 1) + self.assertEqual(notification.actions[0].title, payload["actions"][0]["title"]) + self.assertEqual(notification.actions[0].target_url, payload["actions"][0]["target_url"]) + self.assertEqual(notification.actions[0].icon, payload["actions"][0]["icon"]) + self.assertEqual(notification.actions[0].action, payload["actions"][0]["action"]) + self.assertEqual(notification.starred, payload["starred"]) + self.assertEqual(notification.send_at, payload["send_at"]) + self.assertEqual(notification.custom_metrics, payload["custom_metrics"]) + self.assertEqual(notification.uids, payload["uids"]) + self.assertEqual(notification.tags, payload["tags"]) + self.assertEqual(notification.created_at, payload["created_at"]) + self.assertEqual(notification.successfully_sent_count, payload["successfully_sent_count"]) + self.assertEqual(notification.opened_count, payload["opened_count"]) + self.assertEqual(notification.scheduled_count, payload["scheduled_count"]) + self.assertEqual(notification.scheduled, payload["scheduled"]) + self.assertEqual(notification.cancelled, payload["cancelled"]) + def test_notifications_cancel(self): response = make_response(status=204) client, session = make_client(self.token, self.project_id, response) diff --git a/tests/resources/test_projects.py b/tests/resources/test_projects.py index 0b66245..0e1ea29 100644 --- a/tests/resources/test_projects.py +++ b/tests/resources/test_projects.py @@ -39,6 +39,36 @@ def test_projects_get(self): self.assertEqual(method, "GET") self.assertTrue(url.endswith("/projects/3")) + def test_projects_get_with_all_fields(self): + payload = { + "id": 10, + "sender_id": 77, + "name": "Marketing Site", + "website": "https://example.com", + "icon_url": "https://example.com/icon.png", + "badge_url": "https://example.com/badge.png", + "notifications_ttl": 3600, + "notifications_require_interaction": True, + "notifications_silent": False, + "created_at": "2025-09-14T10:30:00.123Z", + } + response = make_response(payload=payload) + client, _ = make_client(self.token, response=response) + project = client.projects.get(payload["id"]) + self.assertEqual(project.id, payload["id"]) + self.assertEqual(project.sender_id, payload["sender_id"]) + self.assertEqual(project.name, payload["name"]) + self.assertEqual(project.website, payload["website"]) + self.assertEqual(project.icon_url, payload["icon_url"]) + self.assertEqual(project.badge_url, payload["badge_url"]) + self.assertEqual(project.notifications_ttl, payload["notifications_ttl"]) + self.assertEqual( + project.notifications_require_interaction, + payload["notifications_require_interaction"], + ) + self.assertEqual(project.notifications_silent, payload["notifications_silent"]) + self.assertEqual(project.created_at, payload["created_at"]) + def test_projects_update(self): response = make_response(payload={"id": 4, "name": "Demo"}) client, session = make_client(self.token, response=response) diff --git a/tests/resources/test_senders.py b/tests/resources/test_senders.py index a183c9a..6d83379 100644 --- a/tests/resources/test_senders.py +++ b/tests/resources/test_senders.py @@ -32,6 +32,23 @@ def test_senders_get(self): self.assertEqual(method, "GET") self.assertTrue(url.endswith("/senders/3")) + def test_senders_get_with_all_fields(self): + payload = { + "id": 11, + "name": "My Sender 1", + "vapid_private_key": "-----BEGIN EC PRIVATE KEY----- ...", + "vapid_public_key": "-----BEGIN PUBLIC KEY----- ...", + "created_at": "2025-09-13T10:30:00.123Z", + } + response = make_response(payload=payload) + client, _ = make_client(self.token, response=response) + sender = client.senders.get(payload["id"]) + self.assertEqual(sender.id, payload["id"]) + self.assertEqual(sender.name, payload["name"]) + self.assertEqual(sender.vapid_private_key, payload["vapid_private_key"]) + self.assertEqual(sender.vapid_public_key, payload["vapid_public_key"]) + self.assertEqual(sender.created_at, payload["created_at"]) + def test_senders_update(self): response = make_response(payload={"id": 55}) client, session = make_client(self.token, response=response) diff --git a/tests/resources/test_subscriptions.py b/tests/resources/test_subscriptions.py index c18b0f0..95d3556 100644 --- a/tests/resources/test_subscriptions.py +++ b/tests/resources/test_subscriptions.py @@ -44,6 +44,31 @@ def test_subscriptions_get(self): self.assertEqual(method, "GET") self.assertTrue(url.endswith("/projects/1/subscriptions/22")) + def test_subscriptions_get_with_all_fields(self): + payload = { + "id": 50, + "project_id": 1, + "endpoint": "https://push.example.com/push/abcdef", + "p256dh": "BAbcd123", + "auth": "abcd==", + "uid": "user-42", + "tags": ["tagA", "tagB"], + "last_click_at": "2025-09-15T11:00:00.123Z", + "created_at": "2025-09-15T10:30:00.123Z", + } + response = make_response(payload=payload) + client, _ = make_client(self.token, self.project_id, response) + subscription = client.subscriptions.get(payload["id"]) + self.assertEqual(subscription.id, payload["id"]) + self.assertEqual(subscription.project_id, payload["project_id"]) + self.assertEqual(subscription.endpoint, payload["endpoint"]) + self.assertEqual(subscription.p256dh, payload["p256dh"]) + self.assertEqual(subscription.auth, payload["auth"]) + self.assertEqual(subscription.uid, payload["uid"]) + self.assertEqual(subscription.tags, payload["tags"]) + self.assertEqual(subscription.last_click_at, payload["last_click_at"]) + self.assertEqual(subscription.created_at, payload["created_at"]) + def test_subscriptions_update(self): response = make_response(payload={"id": 33, "tags": ["a"]}) client, session = make_client(self.token, self.project_id, response) From 18dfb0569949999881c642cc5814312ad132cb6b Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 12:45:02 +0100 Subject: [PATCH 22/38] Remove unnecessary _to_str_list in types.py --- pushpad/types.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/pushpad/types.py b/pushpad/types.py index 70aa486..b702ed4 100644 --- a/pushpad/types.py +++ b/pushpad/types.py @@ -6,16 +6,6 @@ from typing import Any, Mapping -def _to_str_list(values: Any) -> list[str] | None: - if values is None: - return None - if isinstance(values, list): - return [str(item) for item in values] - if isinstance(values, (tuple, set)): - return [str(item) for item in values] - return [str(values)] - - @dataclass class NotificationAction: title: str | None @@ -55,7 +45,7 @@ def from_api(cls, data: Mapping[str, Any]) -> "NotificationCreateResult": return cls( id=data.get("id"), scheduled=data.get("scheduled"), - uids=_to_str_list(data.get("uids")), + uids=data.get("uids"), send_at=data.get("send_at"), ) @@ -107,9 +97,9 @@ def from_api(cls, data: Mapping[str, Any]) -> "Notification": actions=_to_actions(data.get("actions")), starred=data.get("starred"), send_at=data.get("send_at"), - custom_metrics=_to_str_list(data.get("custom_metrics")) or [], - uids=_to_str_list(data.get("uids")), - tags=_to_str_list(data.get("tags")), + custom_metrics=data.get("custom_metrics"), + uids=data.get("uids"), + tags=data.get("tags"), created_at=data.get("created_at"), successfully_sent_count=data.get("successfully_sent_count"), opened_count=data.get("opened_count"), @@ -127,7 +117,7 @@ class Subscription: p256dh: str | None auth: str | None uid: str | None - tags: list[str] + tags: list[str] | None last_click_at: str | None created_at: str @@ -140,7 +130,7 @@ def from_api(cls, data: Mapping[str, Any]) -> "Subscription": p256dh=data.get("p256dh"), auth=data.get("auth"), uid=data.get("uid"), - tags=_to_str_list(data.get("tags")) or [], + tags=data.get("tags"), last_click_at=data.get("last_click_at"), created_at=data.get("created_at"), ) From 236c32a6b625e6c6067226dd0e08098b7894e622 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 12:48:23 +0100 Subject: [PATCH 23/38] Set default request timeout to 30 seconds --- pushpad/pushpad.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index 5436840..a97df98 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -99,7 +99,7 @@ def __init__( project_id: Optional[int] = None, *, base_url: Optional[str] = None, - timeout: int = 10, + timeout: int = 30, session: Optional[Any] = None, ) -> None: if not auth_token: From ae46f51fe670474b8986a71f620181423668460a Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 12:56:32 +0100 Subject: [PATCH 24/38] Remove class NotificationAction and use a simple dict for actions --- pushpad/__init__.py | 10 +--------- pushpad/types.py | 32 ++------------------------------ 2 files changed, 3 insertions(+), 39 deletions(-) diff --git a/pushpad/__init__.py b/pushpad/__init__.py index 7edd361..b32c740 100644 --- a/pushpad/__init__.py +++ b/pushpad/__init__.py @@ -4,14 +4,7 @@ from ._version import __version__ from .exceptions import PushpadAPIError, PushpadClientError, PushpadError from .pushpad import Pushpad -from .types import ( - Notification, - NotificationAction, - NotificationCreateResult, - Project, - Sender, - Subscription, -) +from .types import Notification, NotificationCreateResult, Project, Sender, Subscription __all__ = [ "__version__", @@ -20,7 +13,6 @@ "PushpadClientError", "PushpadAPIError", "Notification", - "NotificationAction", "NotificationCreateResult", "Subscription", "Project", diff --git a/pushpad/types.py b/pushpad/types.py index b702ed4..d3a69fb 100644 --- a/pushpad/types.py +++ b/pushpad/types.py @@ -6,33 +6,6 @@ from typing import Any, Mapping -@dataclass -class NotificationAction: - title: str | None - target_url: str | None - icon: str | None - action: str | None - - @classmethod - def from_api(cls, data: Mapping[str, Any]) -> "NotificationAction": - return cls( - title=data.get("title"), - target_url=data.get("target_url"), - icon=data.get("icon"), - action=data.get("action"), - ) - - -def _to_actions(values: Any) -> list[NotificationAction]: - if not values: - return [] - actions: list[NotificationAction] = [] - for entry in values: - if isinstance(entry, Mapping): - actions.append(NotificationAction.from_api(entry)) - return actions - - @dataclass class NotificationCreateResult: id: int @@ -65,7 +38,7 @@ class Notification: silent: bool urgent: bool custom_data: str | None - actions: list[NotificationAction] + actions: list[dict[str, Any]] | None starred: bool send_at: str | None custom_metrics: list[str] @@ -94,7 +67,7 @@ def from_api(cls, data: Mapping[str, Any]) -> "Notification": silent=data.get("silent"), urgent=data.get("urgent"), custom_data=data.get("custom_data"), - actions=_to_actions(data.get("actions")), + actions=data.get("actions"), starred=data.get("starred"), send_at=data.get("send_at"), custom_metrics=data.get("custom_metrics"), @@ -186,7 +159,6 @@ def from_api(cls, data: Mapping[str, Any]) -> "Sender": __all__ = [ "Notification", - "NotificationAction", "NotificationCreateResult", "Subscription", "Project", From 601a032d090ba1319569b3eb6bcd8af255d76dd4 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 13:05:53 +0100 Subject: [PATCH 25/38] Remove generic **filters in resources --- pushpad/resources/notifications.py | 5 ++--- pushpad/resources/subscriptions.py | 8 ++------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py index d96e675..6c2f6be 100644 --- a/pushpad/resources/notifications.py +++ b/pushpad/resources/notifications.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Iterable, Mapping, Optional, TYPE_CHECKING +from typing import Iterable, Mapping, Optional, TYPE_CHECKING from .._sentinel import _MISSING, _MissingType, remove_missing from ..pushpad import _ensure_api_list, _ensure_api_object @@ -22,10 +22,9 @@ def all( *, project_id: Optional[int] = None, page: Optional[int] = None, - **filters: Any, ) -> list[Notification]: pid = self._client._resolve_project_id(project_id) - params = {k: v for k, v in {"page": page, **filters}.items() if v is not None} + params = {"page": page} if page is not None else None response = self._client._request("GET", f"/projects/{pid}/notifications", params=params) payload = _ensure_api_list(response) return [Notification.from_api(item) for item in payload] diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py index bd9135f..16b0cd7 100644 --- a/pushpad/resources/subscriptions.py +++ b/pushpad/resources/subscriptions.py @@ -44,12 +44,9 @@ def all( per_page: Optional[int] = None, uids: Optional[Iterable[str]] = None, tags: Optional[Iterable[str]] = None, - **filters: Any, ) -> list[Subscription]: pid = self._client._resolve_project_id(project_id) - params = self._build_filters( - {"page": page, "per_page": per_page, "uids": uids, "tags": tags, **filters} - ) + params = self._build_filters({"page": page, "per_page": per_page, "uids": uids, "tags": tags}) response = self._client._request("GET", f"/projects/{pid}/subscriptions", params=params) payload = _ensure_api_list(response) return [Subscription.from_api(item) for item in payload] @@ -60,10 +57,9 @@ def count( project_id: Optional[int] = None, uids: Optional[Iterable[str]] = None, tags: Optional[Iterable[str]] = None, - **filters: Any, ) -> int: pid = self._client._resolve_project_id(project_id) - params = self._build_filters({"uids": uids, "tags": tags, **filters}) + params = self._build_filters({"uids": uids, "tags": tags}) params.setdefault("per_page", 1) response = self._client._raw_request( "GET", From 6f0e788e2f2575ea3421ec5716fa6ca1e065694a Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 13:53:40 +0100 Subject: [PATCH 26/38] Add an alias method "send" for "create" in notifications --- pushpad/resources/notifications.py | 2 ++ tests/resources/test_notifications.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py index 6c2f6be..697eae3 100644 --- a/pushpad/resources/notifications.py +++ b/pushpad/resources/notifications.py @@ -75,6 +75,8 @@ def create( data = _ensure_api_object(response) return NotificationCreateResult.from_api(data) + send = create + def get(self, id: int) -> Notification: if id is None: raise ValueError("id is required") diff --git a/tests/resources/test_notifications.py b/tests/resources/test_notifications.py index e31492a..4370d74 100644 --- a/tests/resources/test_notifications.py +++ b/tests/resources/test_notifications.py @@ -13,6 +13,13 @@ def test_notifications_create(self): self.assertIn("/projects/1/notifications", url) self.assertEqual(session.request.call_args[1]["json"], {"body": "Hello"}) + def test_notifications_send_is_create_alias(self): + client, _ = make_client(self.token, self.project_id) + self.assertIs( + client.notifications.send.__func__, + client.notifications.create.__func__, + ) + def test_notifications_requires_project(self): client, session = make_client(self.token) with self.assertRaises(ValueError): From a6d80b3a3a7d5eae2d5525971d74ac406126777d Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 14:02:52 +0100 Subject: [PATCH 27/38] Move the project_id keyword argument in the last position --- pushpad/resources/notifications.py | 4 ++-- pushpad/resources/subscriptions.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py index 697eae3..21e0a9e 100644 --- a/pushpad/resources/notifications.py +++ b/pushpad/resources/notifications.py @@ -20,8 +20,8 @@ def __init__(self, client: "Pushpad") -> None: def all( self, *, - project_id: Optional[int] = None, page: Optional[int] = None, + project_id: Optional[int] = None, ) -> list[Notification]: pid = self._client._resolve_project_id(project_id) params = {"page": page} if page is not None else None @@ -33,7 +33,6 @@ def create( self, *, body: str, - project_id: Optional[int] = None, title: str | _MissingType = _MISSING, target_url: str | _MissingType = _MISSING, icon_url: str | _MissingType = _MISSING, @@ -50,6 +49,7 @@ def create( custom_metrics: Iterable[str] | _MissingType = _MISSING, uids: Iterable[str] | _MissingType = _MISSING, tags: Iterable[str] | _MissingType = _MISSING, + project_id: Optional[int] = None, ) -> NotificationCreateResult: pid = self._client._resolve_project_id(project_id) payload = remove_missing( diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py index 16b0cd7..27e3cfa 100644 --- a/pushpad/resources/subscriptions.py +++ b/pushpad/resources/subscriptions.py @@ -39,11 +39,11 @@ def _normalize(value: Optional[Iterable[str]]): def all( self, *, - project_id: Optional[int] = None, page: Optional[int] = None, per_page: Optional[int] = None, uids: Optional[Iterable[str]] = None, tags: Optional[Iterable[str]] = None, + project_id: Optional[int] = None, ) -> list[Subscription]: pid = self._client._resolve_project_id(project_id) params = self._build_filters({"page": page, "per_page": per_page, "uids": uids, "tags": tags}) @@ -54,9 +54,9 @@ def all( def count( self, *, - project_id: Optional[int] = None, uids: Optional[Iterable[str]] = None, tags: Optional[Iterable[str]] = None, + project_id: Optional[int] = None, ) -> int: pid = self._client._resolve_project_id(project_id) params = self._build_filters({"uids": uids, "tags": tags}) @@ -74,12 +74,12 @@ def count( def create( self, *, - project_id: Optional[int] = None, endpoint: str, p256dh: str | _MissingType = _MISSING, auth: str | _MissingType = _MISSING, uid: str | None | _MissingType = _MISSING, tags: Iterable[str] | _MissingType = _MISSING, + project_id: Optional[int] = None, ) -> Subscription: pid = self._client._resolve_project_id(project_id) payload = remove_missing( @@ -105,9 +105,9 @@ def update( self, id: int, *, - project_id: Optional[int] = None, uid: str | None | _MissingType = _MISSING, tags: Iterable[str] | _MissingType = _MISSING, + project_id: Optional[int] = None, ) -> Subscription: if id is None: raise ValueError("id is required") From 48f07c556d4a52fdfcc4790c901642e3b8af1c00 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 14:14:15 +0100 Subject: [PATCH 28/38] Rename _MissingType to _Missing --- pushpad/_sentinel.py | 8 +++---- pushpad/resources/notifications.py | 34 +++++++++++++++--------------- pushpad/resources/projects.py | 26 +++++++++++------------ pushpad/resources/senders.py | 8 +++---- pushpad/resources/subscriptions.py | 14 ++++++------ 5 files changed, 45 insertions(+), 45 deletions(-) diff --git a/pushpad/_sentinel.py b/pushpad/_sentinel.py index 3f52756..89b9fab 100644 --- a/pushpad/_sentinel.py +++ b/pushpad/_sentinel.py @@ -5,22 +5,22 @@ from typing import TypeVar -class _MissingType: +class _Missing: __slots__ = () def __repr__(self) -> str: # pragma: no cover - trivial representation return "MISSING" -_MISSING = _MissingType() +_MISSING = _Missing() T = TypeVar("T") -def remove_missing(**values: object | _MissingType) -> dict[str, object]: +def remove_missing(**values: object | _Missing) -> dict[str, object]: """Return a dict with all entries whose value is not the sentinel.""" return {key: value for key, value in values.items() if value is not _MISSING} -__all__ = ["_MISSING", "_MissingType", "remove_missing"] +__all__ = ["_MISSING", "_Missing", "remove_missing"] diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py index 21e0a9e..85a4c76 100644 --- a/pushpad/resources/notifications.py +++ b/pushpad/resources/notifications.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import Iterable, Mapping, Optional, TYPE_CHECKING -from .._sentinel import _MISSING, _MissingType, remove_missing +from .._sentinel import _MISSING, _Missing, remove_missing from ..pushpad import _ensure_api_list, _ensure_api_object from ..types import Notification, NotificationCreateResult @@ -33,22 +33,22 @@ def create( self, *, body: str, - title: str | _MissingType = _MISSING, - target_url: str | _MissingType = _MISSING, - icon_url: str | _MissingType = _MISSING, - badge_url: str | _MissingType = _MISSING, - image_url: str | _MissingType = _MISSING, - ttl: int | _MissingType = _MISSING, - require_interaction: bool | _MissingType = _MISSING, - silent: bool | _MissingType = _MISSING, - urgent: bool | _MissingType = _MISSING, - custom_data: str | _MissingType = _MISSING, - actions: Iterable[Mapping[str, str]] | _MissingType = _MISSING, - starred: bool | _MissingType = _MISSING, - send_at: datetime | str | _MissingType = _MISSING, - custom_metrics: Iterable[str] | _MissingType = _MISSING, - uids: Iterable[str] | _MissingType = _MISSING, - tags: Iterable[str] | _MissingType = _MISSING, + title: str | _Missing = _MISSING, + target_url: str | _Missing = _MISSING, + icon_url: str | _Missing = _MISSING, + badge_url: str | _Missing = _MISSING, + image_url: str | _Missing = _MISSING, + ttl: int | _Missing = _MISSING, + require_interaction: bool | _Missing = _MISSING, + silent: bool | _Missing = _MISSING, + urgent: bool | _Missing = _MISSING, + custom_data: str | _Missing = _MISSING, + actions: Iterable[Mapping[str, str]] | _Missing = _MISSING, + starred: bool | _Missing = _MISSING, + send_at: datetime | str | _Missing = _MISSING, + custom_metrics: Iterable[str] | _Missing = _MISSING, + uids: Iterable[str] | _Missing = _MISSING, + tags: Iterable[str] | _Missing = _MISSING, project_id: Optional[int] = None, ) -> NotificationCreateResult: pid = self._client._resolve_project_id(project_id) diff --git a/pushpad/resources/projects.py b/pushpad/resources/projects.py index c709d74..a7b460d 100644 --- a/pushpad/resources/projects.py +++ b/pushpad/resources/projects.py @@ -4,7 +4,7 @@ from typing import Any, TYPE_CHECKING -from .._sentinel import _MISSING, _MissingType, remove_missing +from .._sentinel import _MISSING, _Missing, remove_missing from ..pushpad import _ensure_api_list, _ensure_api_object from ..types import Project @@ -27,11 +27,11 @@ def create( sender_id: int, name: str, website: str, - icon_url: str | _MissingType = _MISSING, - badge_url: str | _MissingType = _MISSING, - notifications_ttl: int | _MissingType = _MISSING, - notifications_require_interaction: bool | _MissingType = _MISSING, - notifications_silent: bool | _MissingType = _MISSING, + icon_url: str | _Missing = _MISSING, + badge_url: str | _Missing = _MISSING, + notifications_ttl: int | _Missing = _MISSING, + notifications_require_interaction: bool | _Missing = _MISSING, + notifications_silent: bool | _Missing = _MISSING, ) -> Project: payload = remove_missing( sender_id=sender_id, @@ -58,13 +58,13 @@ def update( self, id: int, *, - name: str | _MissingType = _MISSING, - website: str | _MissingType = _MISSING, - icon_url: str | _MissingType = _MISSING, - badge_url: str | _MissingType = _MISSING, - notifications_ttl: int | _MissingType = _MISSING, - notifications_require_interaction: bool | _MissingType = _MISSING, - notifications_silent: bool | _MissingType = _MISSING, + name: str | _Missing = _MISSING, + website: str | _Missing = _MISSING, + icon_url: str | _Missing = _MISSING, + badge_url: str | _Missing = _MISSING, + notifications_ttl: int | _Missing = _MISSING, + notifications_require_interaction: bool | _Missing = _MISSING, + notifications_silent: bool | _Missing = _MISSING, ) -> Project: if id is None: raise ValueError("id is required") diff --git a/pushpad/resources/senders.py b/pushpad/resources/senders.py index ef6b416..1980519 100644 --- a/pushpad/resources/senders.py +++ b/pushpad/resources/senders.py @@ -4,7 +4,7 @@ from typing import Any, TYPE_CHECKING -from .._sentinel import _MISSING, _MissingType, remove_missing +from .._sentinel import _MISSING, _Missing, remove_missing from ..pushpad import _ensure_api_list, _ensure_api_object from ..types import Sender @@ -25,8 +25,8 @@ def create( self, *, name: str, - vapid_private_key: str | _MissingType = _MISSING, - vapid_public_key: str | _MissingType = _MISSING, + vapid_private_key: str | _Missing = _MISSING, + vapid_public_key: str | _Missing = _MISSING, ) -> Sender: payload = remove_missing( name=name, @@ -48,7 +48,7 @@ def update( self, id: int, *, - name: str | _MissingType = _MISSING, + name: str | _Missing = _MISSING, ) -> Sender: if id is None: raise ValueError("id is required") diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py index 27e3cfa..fc83d54 100644 --- a/pushpad/resources/subscriptions.py +++ b/pushpad/resources/subscriptions.py @@ -4,7 +4,7 @@ from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING -from .._sentinel import _MISSING, _MissingType, remove_missing +from .._sentinel import _MISSING, _Missing, remove_missing from ..pushpad import _ensure_api_list, _ensure_api_object from ..types import Subscription @@ -75,10 +75,10 @@ def create( self, *, endpoint: str, - p256dh: str | _MissingType = _MISSING, - auth: str | _MissingType = _MISSING, - uid: str | None | _MissingType = _MISSING, - tags: Iterable[str] | _MissingType = _MISSING, + p256dh: str | _Missing = _MISSING, + auth: str | _Missing = _MISSING, + uid: str | None | _Missing = _MISSING, + tags: Iterable[str] | _Missing = _MISSING, project_id: Optional[int] = None, ) -> Subscription: pid = self._client._resolve_project_id(project_id) @@ -105,8 +105,8 @@ def update( self, id: int, *, - uid: str | None | _MissingType = _MISSING, - tags: Iterable[str] | _MissingType = _MISSING, + uid: str | None | _Missing = _MISSING, + tags: Iterable[str] | _Missing = _MISSING, project_id: Optional[int] = None, ) -> Subscription: if id is None: From b20532b2a71e46cb052b1c0e066812d90bd881a1 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 15:23:14 +0100 Subject: [PATCH 29/38] Improve exceptions --- pushpad/exceptions.py | 7 ++++--- pushpad/pushpad.py | 13 ++----------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/pushpad/exceptions.py b/pushpad/exceptions.py index 1e6b959..90be66c 100644 --- a/pushpad/exceptions.py +++ b/pushpad/exceptions.py @@ -23,11 +23,12 @@ class PushpadAPIError(PushpadError): def __init__( self, status_code: int, - message: Optional[str] = None, *, - response_body: Optional[Any] = None, + reason: Optional[str] = None, + response_body: Optional[str] = None, ) -> None: - msg = message or f"Pushpad API error (status_code={status_code})" + msg = f"API error: {status_code} {reason}: {response_body}" super().__init__(msg) self.status_code = status_code + self.reason = reason self.response_body = response_body diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index a97df98..cb96211 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -169,16 +169,7 @@ def _raw_request( raise PushpadClientError(str(exc), original_exception=exc) from exc if response.status_code >= 400: - message: Optional[str] = None - payload: Optional[Any] = None - try: - payload = response.json() - if isinstance(payload, dict): - message = payload.get("error") or payload.get("message") - except ValueError: - if response.text: - message = response.text - raise PushpadAPIError(response.status_code, message, response_body=payload or response.text) + raise PushpadAPIError(response.status_code, reason=response.reason, response_body=response.text) return response @@ -197,5 +188,5 @@ def _request( try: data = response.json() except ValueError as exc: # pragma: no cover - unexpected API behaviour - raise PushpadAPIError(response.status_code, "Invalid JSON in response") from exc + raise PushpadClientError("Invalid JSON in response", original_exception=exc) from exc return _wrap_response(data) From 293ca263cf4030c1d48e88080b15f478307946dd Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 19 Nov 2025 15:32:57 +0100 Subject: [PATCH 30/38] Add test for exception error message --- tests/test_pushpad.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_pushpad.py b/tests/test_pushpad.py index a84873c..4f9b0db 100644 --- a/tests/test_pushpad.py +++ b/tests/test_pushpad.py @@ -15,5 +15,7 @@ def test_signature_for(self): def test_error_response(self): response = make_response(status=403, payload={"error": "Forbidden"}) client, _ = make_client(self.token, self.project_id, response) - with self.assertRaises(PushpadAPIError): + with self.assertRaises(PushpadAPIError) as ctx: client.notifications.all() + self.assertIn("API error: 403", str(ctx.exception)) + self.assertIn("Forbidden", str(ctx.exception)) From 1e29fd7bd0edad66c3e63e91dcc342476f2db79e Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 20 Nov 2025 13:26:05 +0100 Subject: [PATCH 31/38] Remove the serialization of json payload and params --- pushpad/pushpad.py | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index cb96211..2ffa446 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -6,7 +6,6 @@ from requests import RequestException, Response import hmac -from datetime import date, datetime, timezone from hashlib import sha256 from typing import Any, Dict, MutableMapping, Optional, Union @@ -53,38 +52,6 @@ def _ensure_api_list(data: APIResponse) -> list[APIObject]: raise PushpadClientError(f"API response is not a list: {type(data).__name__}") -def _isoformat(value: datetime) -> str: - if value.tzinfo is None: - value = value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - - -def _serialize_value(value: Any) -> Any: - if isinstance(value, datetime): - return _isoformat(value) - if isinstance(value, date): - return value.isoformat() - if isinstance(value, dict): - return {key: _serialize_value(val) for key, val in value.items()} - if isinstance(value, (list, tuple, set)): - return [_serialize_value(item) for item in value] - return value - - -def _prepare_payload(data: Optional[JSONDict]) -> Optional[JSONDict]: - if not data: - return None - return _serialize_value(dict(data)) # type: ignore[arg-type] - - -def _prepare_params(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: - if not params: - return None - serialized = _serialize_value(dict(params)) - assert isinstance(serialized, dict) - return serialized # type: ignore[return-value] - - from .resources import NotificationsResource, ProjectsResource, SendersResource, SubscriptionsResource @@ -161,8 +128,8 @@ def _raw_request( response = self._session.request( method, url, - params=_prepare_params(params), - json=_prepare_payload(json), + params=params, + json=json, timeout=self._timeout, ) except RequestException as exc: From 7cef73d35ac11303e06c4cbf8854072d1fb5faa1 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 20 Nov 2025 13:51:47 +0100 Subject: [PATCH 32/38] Remove unnecessary wrapping and checks on API responses --- pushpad/pushpad.py | 38 ++------------------------- pushpad/resources/notifications.py | 10 +++---- pushpad/resources/projects.py | 15 ++++------- pushpad/resources/senders.py | 15 ++++------- pushpad/resources/subscriptions.py | 13 +++------ tests/resources/test_notifications.py | 8 +++--- 6 files changed, 23 insertions(+), 76 deletions(-) diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index 2ffa446..a4a6a7a 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -15,41 +15,7 @@ JSONDict = MutableMapping[str, Any] -class APIObject(dict): - """Dictionary that also exposes keys as attributes.""" - - def __getattr__(self, item: str) -> Any: - try: - return self[item] - except KeyError as exc: # pragma: no cover - defensive - raise AttributeError(item) from exc - - -APIResponse = Union[APIObject, list[APIObject], None] - - -def _wrap_response(data: Any) -> Any: - if isinstance(data, dict): - return APIObject({key: _wrap_response(value) for key, value in data.items()}) - if isinstance(data, list): - return [_wrap_response(item) for item in data] - return data - - -def _ensure_api_object(data: APIResponse) -> APIObject: - if isinstance(data, APIObject): - return data - raise PushpadClientError(f"API response is not an object: {type(data).__name__}") - - -def _ensure_api_list(data: APIResponse) -> list[APIObject]: - if data is None: - return [] - if isinstance(data, list): - if all(isinstance(item, APIObject) for item in data): - return data - raise PushpadClientError("API response list contains invalid entries") - raise PushpadClientError(f"API response is not a list: {type(data).__name__}") +APIResponse = Union[Dict[str, Any], list[Dict[str, Any]], None] from .resources import NotificationsResource, ProjectsResource, SendersResource, SubscriptionsResource @@ -156,4 +122,4 @@ def _request( data = response.json() except ValueError as exc: # pragma: no cover - unexpected API behaviour raise PushpadClientError("Invalid JSON in response", original_exception=exc) from exc - return _wrap_response(data) + return data diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py index 85a4c76..315852e 100644 --- a/pushpad/resources/notifications.py +++ b/pushpad/resources/notifications.py @@ -6,7 +6,6 @@ from typing import Iterable, Mapping, Optional, TYPE_CHECKING from .._sentinel import _MISSING, _Missing, remove_missing -from ..pushpad import _ensure_api_list, _ensure_api_object from ..types import Notification, NotificationCreateResult if TYPE_CHECKING: # pragma: no cover - only used for typing @@ -26,8 +25,7 @@ def all( pid = self._client._resolve_project_id(project_id) params = {"page": page} if page is not None else None response = self._client._request("GET", f"/projects/{pid}/notifications", params=params) - payload = _ensure_api_list(response) - return [Notification.from_api(item) for item in payload] + return [Notification.from_api(item) for item in response] def create( self, @@ -72,8 +70,7 @@ def create( tags=tags, ) response = self._client._request("POST", f"/projects/{pid}/notifications", json=payload) - data = _ensure_api_object(response) - return NotificationCreateResult.from_api(data) + return NotificationCreateResult.from_api(response) send = create @@ -81,8 +78,7 @@ def get(self, id: int) -> Notification: if id is None: raise ValueError("id is required") response = self._client._request("GET", f"/notifications/{id}") - payload = _ensure_api_object(response) - return Notification.from_api(payload) + return Notification.from_api(response) def cancel(self, id: int) -> None: if id is None: diff --git a/pushpad/resources/projects.py b/pushpad/resources/projects.py index a7b460d..5475c98 100644 --- a/pushpad/resources/projects.py +++ b/pushpad/resources/projects.py @@ -2,10 +2,9 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING from .._sentinel import _MISSING, _Missing, remove_missing -from ..pushpad import _ensure_api_list, _ensure_api_object from ..types import Project if TYPE_CHECKING: # pragma: no cover - only used for typing @@ -18,8 +17,7 @@ def __init__(self, client: "Pushpad") -> None: def all(self) -> list[Project]: response = self._client._request("GET", "/projects") - payload = _ensure_api_list(response) - return [Project.from_api(item) for item in payload] + return [Project.from_api(item) for item in response] def create( self, @@ -44,15 +42,13 @@ def create( notifications_silent=notifications_silent, ) response = self._client._request("POST", "/projects", json=payload) - data = _ensure_api_object(response) - return Project.from_api(data) + return Project.from_api(response) def get(self, id: int) -> Project: if id is None: raise ValueError("id is required") response = self._client._request("GET", f"/projects/{id}") - payload = _ensure_api_object(response) - return Project.from_api(payload) + return Project.from_api(response) def update( self, @@ -78,8 +74,7 @@ def update( notifications_silent=notifications_silent, ) response = self._client._request("PATCH", f"/projects/{id}", json=payload) - data = _ensure_api_object(response) - return Project.from_api(data) + return Project.from_api(response) def delete(self, id: int) -> None: if id is None: diff --git a/pushpad/resources/senders.py b/pushpad/resources/senders.py index 1980519..32de8c3 100644 --- a/pushpad/resources/senders.py +++ b/pushpad/resources/senders.py @@ -2,10 +2,9 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING from .._sentinel import _MISSING, _Missing, remove_missing -from ..pushpad import _ensure_api_list, _ensure_api_object from ..types import Sender if TYPE_CHECKING: # pragma: no cover - only used for typing @@ -18,8 +17,7 @@ def __init__(self, client: "Pushpad") -> None: def all(self) -> list[Sender]: response = self._client._request("GET", "/senders") - payload = _ensure_api_list(response) - return [Sender.from_api(item) for item in payload] + return [Sender.from_api(item) for item in response] def create( self, @@ -34,15 +32,13 @@ def create( vapid_public_key=vapid_public_key, ) response = self._client._request("POST", "/senders", json=payload) - data = _ensure_api_object(response) - return Sender.from_api(data) + return Sender.from_api(response) def get(self, id: int) -> Sender: if id is None: raise ValueError("id is required") response = self._client._request("GET", f"/senders/{id}") - payload = _ensure_api_object(response) - return Sender.from_api(payload) + return Sender.from_api(response) def update( self, @@ -54,8 +50,7 @@ def update( raise ValueError("id is required") payload = remove_missing(name=name) response = self._client._request("PATCH", f"/senders/{id}", json=payload) - data = _ensure_api_object(response) - return Sender.from_api(data) + return Sender.from_api(response) def delete(self, id: int) -> None: if id is None: diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py index fc83d54..12b5c88 100644 --- a/pushpad/resources/subscriptions.py +++ b/pushpad/resources/subscriptions.py @@ -5,7 +5,6 @@ from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING from .._sentinel import _MISSING, _Missing, remove_missing -from ..pushpad import _ensure_api_list, _ensure_api_object from ..types import Subscription if TYPE_CHECKING: # pragma: no cover - only used for typing @@ -48,8 +47,7 @@ def all( pid = self._client._resolve_project_id(project_id) params = self._build_filters({"page": page, "per_page": per_page, "uids": uids, "tags": tags}) response = self._client._request("GET", f"/projects/{pid}/subscriptions", params=params) - payload = _ensure_api_list(response) - return [Subscription.from_api(item) for item in payload] + return [Subscription.from_api(item) for item in response] def count( self, @@ -90,16 +88,14 @@ def create( tags=tags, ) response = self._client._request("POST", f"/projects/{pid}/subscriptions", json=payload) - data = _ensure_api_object(response) - return Subscription.from_api(data) + return Subscription.from_api(response) def get(self, id: int, *, project_id: Optional[int] = None) -> Subscription: if id is None: raise ValueError("id is required") pid = self._client._resolve_project_id(project_id) response = self._client._request("GET", f"/projects/{pid}/subscriptions/{id}") - payload = _ensure_api_object(response) - return Subscription.from_api(payload) + return Subscription.from_api(response) def update( self, @@ -117,8 +113,7 @@ def update( tags=tags, ) response = self._client._request("PATCH", f"/projects/{pid}/subscriptions/{id}", json=payload) - data = _ensure_api_object(response) - return Subscription.from_api(data) + return Subscription.from_api(response) def delete(self, id: int, *, project_id: Optional[int] = None) -> None: if id is None: diff --git a/tests/resources/test_notifications.py b/tests/resources/test_notifications.py index 4370d74..7c2f8a8 100644 --- a/tests/resources/test_notifications.py +++ b/tests/resources/test_notifications.py @@ -96,10 +96,10 @@ def test_notifications_get_with_all_fields(self): self.assertEqual(notification.urgent, payload["urgent"]) self.assertEqual(notification.custom_data, payload["custom_data"]) self.assertEqual(len(notification.actions), 1) - self.assertEqual(notification.actions[0].title, payload["actions"][0]["title"]) - self.assertEqual(notification.actions[0].target_url, payload["actions"][0]["target_url"]) - self.assertEqual(notification.actions[0].icon, payload["actions"][0]["icon"]) - self.assertEqual(notification.actions[0].action, payload["actions"][0]["action"]) + self.assertEqual(notification.actions[0]["title"], payload["actions"][0]["title"]) + self.assertEqual(notification.actions[0]["target_url"], payload["actions"][0]["target_url"]) + self.assertEqual(notification.actions[0]["icon"], payload["actions"][0]["icon"]) + self.assertEqual(notification.actions[0]["action"], payload["actions"][0]["action"]) self.assertEqual(notification.starred, payload["starred"]) self.assertEqual(notification.send_at, payload["send_at"]) self.assertEqual(notification.custom_metrics, payload["custom_metrics"]) From e48a8964922e8aa5d1740e2be925e236df9f3873 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 20 Nov 2025 13:56:06 +0100 Subject: [PATCH 33/38] Reordered imports in pushpad/pushpad.py --- pushpad/pushpad.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pushpad/pushpad.py b/pushpad/pushpad.py index a4a6a7a..647439e 100644 --- a/pushpad/pushpad.py +++ b/pushpad/pushpad.py @@ -2,15 +2,16 @@ from __future__ import annotations -import requests -from requests import RequestException, Response - import hmac from hashlib import sha256 from typing import Any, Dict, MutableMapping, Optional, Union +import requests +from requests import RequestException, Response + from ._version import __version__ from .exceptions import PushpadAPIError, PushpadClientError +from .resources import NotificationsResource, ProjectsResource, SendersResource, SubscriptionsResource JSONDict = MutableMapping[str, Any] @@ -18,9 +19,6 @@ APIResponse = Union[Dict[str, Any], list[Dict[str, Any]], None] -from .resources import NotificationsResource, ProjectsResource, SendersResource, SubscriptionsResource - - class Pushpad: """High level client used to interact with the Pushpad REST API.""" From 6658b2ee2da59965a7b099b4266b0c5235ff1ccc Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 20 Nov 2025 14:04:28 +0100 Subject: [PATCH 34/38] Show correct usage / format for send_at in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e015552..28df3a6 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ result = client.notifications.create( # optional, use this option only if you need to create scheduled notifications (max 5 days) # see https://pushpad.xyz/docs/schedule_notifications - send_at=datetime.datetime(2016, 7, 25, 10, 9, 0, 0), # use UTC + send_at=datetime.datetime(2025, 11, 20, 23, 15, 0, 0).isoformat(), # optional, add the notification to custom categories for stats aggregation # see https://pushpad.xyz/docs/monitoring From 72c7250c82b5400c384cf3f7d22b2a1899ed1f4a Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 20 Nov 2025 14:46:27 +0100 Subject: [PATCH 35/38] Replace Iterable[str] with list[str] Otherwise a normal string would be accepted, since it is already a Iterable[str] --- pushpad/resources/notifications.py | 6 +++--- pushpad/resources/subscriptions.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pushpad/resources/notifications.py b/pushpad/resources/notifications.py index 315852e..d231a51 100644 --- a/pushpad/resources/notifications.py +++ b/pushpad/resources/notifications.py @@ -44,9 +44,9 @@ def create( actions: Iterable[Mapping[str, str]] | _Missing = _MISSING, starred: bool | _Missing = _MISSING, send_at: datetime | str | _Missing = _MISSING, - custom_metrics: Iterable[str] | _Missing = _MISSING, - uids: Iterable[str] | _Missing = _MISSING, - tags: Iterable[str] | _Missing = _MISSING, + custom_metrics: list[str] | _Missing = _MISSING, + uids: list[str] | _Missing = _MISSING, + tags: list[str] | _Missing = _MISSING, project_id: Optional[int] = None, ) -> NotificationCreateResult: pid = self._client._resolve_project_id(project_id) diff --git a/pushpad/resources/subscriptions.py b/pushpad/resources/subscriptions.py index 12b5c88..ab7c251 100644 --- a/pushpad/resources/subscriptions.py +++ b/pushpad/resources/subscriptions.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING +from typing import Any, Dict, Optional, TYPE_CHECKING from .._sentinel import _MISSING, _Missing, remove_missing from ..types import Subscription @@ -20,7 +20,7 @@ def _build_filters(self, values: Dict[str, Any]) -> Dict[str, Any]: uids = params.pop("uids", None) tags = params.pop("tags", None) - def _normalize(value: Optional[Iterable[str]]): + def _normalize(value: Optional[list[str]]): if value is None: return None if isinstance(value, (list, tuple, set)): @@ -40,8 +40,8 @@ def all( *, page: Optional[int] = None, per_page: Optional[int] = None, - uids: Optional[Iterable[str]] = None, - tags: Optional[Iterable[str]] = None, + uids: Optional[list[str]] = None, + tags: Optional[list[str]] = None, project_id: Optional[int] = None, ) -> list[Subscription]: pid = self._client._resolve_project_id(project_id) @@ -52,8 +52,8 @@ def all( def count( self, *, - uids: Optional[Iterable[str]] = None, - tags: Optional[Iterable[str]] = None, + uids: Optional[list[str]] = None, + tags: Optional[list[str]] = None, project_id: Optional[int] = None, ) -> int: pid = self._client._resolve_project_id(project_id) @@ -76,7 +76,7 @@ def create( p256dh: str | _Missing = _MISSING, auth: str | _Missing = _MISSING, uid: str | None | _Missing = _MISSING, - tags: Iterable[str] | _Missing = _MISSING, + tags: list[str] | _Missing = _MISSING, project_id: Optional[int] = None, ) -> Subscription: pid = self._client._resolve_project_id(project_id) @@ -102,7 +102,7 @@ def update( id: int, *, uid: str | None | _Missing = _MISSING, - tags: Iterable[str] | _Missing = _MISSING, + tags: list[str] | _Missing = _MISSING, project_id: Optional[int] = None, ) -> Subscription: if id is None: From c821a7f3ef9102166c62adf80271510a89da8d48 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 20 Nov 2025 16:20:23 +0100 Subject: [PATCH 36/38] Update README --- README.md | 362 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 321 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 28df3a6..7ebdedb 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,18 @@ client = pushpad.Pushpad(auth_token='5374d7dfeffa2eb49965624ba7596a09', project_ - `auth_token` can be found in the user account settings. - `project_id` can be found in the project settings. +If your application uses multiple projects, instead of setting a `project_id` on client, you can pass the `project_id` as a param to methods: + +```python +client.notifications.create(body="Your message", project_id=123) + +client.notifications.all(page=1, project_id=123) + +client.subscriptions.count(project_id=123) + +# ... +``` + ## Collecting user subscriptions to push notifications You can subscribe the users to your notifications using the Javascript SDK, as described in the [getting started guide](https://pushpad.xyz/docs/pushpad_pro_getting_started). @@ -41,13 +53,14 @@ client.signature_for(current_user_id) ## Sending push notifications -```python -import datetime -import pushpad +Use `client.notifications.create()` (or the `send()` alias) to create and send a notification: -client = pushpad.Pushpad(auth_token='5374d7dfeffa2eb49965624ba7596a09', project_id=123) +```python +# send a simple notification +client.notifications.send(body="Your message") -result = client.notifications.create( +# a more complex notification with all the optional fields +client.notifications.create( # required, the main content of the notification body="Hello world!", @@ -63,6 +76,10 @@ result = client.notifications.create( # optional, the small icon displayed in the status bar (defaults to the project badge) badge_url="https://example.com/assets/badge.png", + # optional, an image to display in the notification content + # see https://pushpad.xyz/docs/sending_images + image_url="https://example.com/assets/image.png", + # optional, drop the notification after this number of seconds if a device is offline ttl=604800, @@ -75,68 +92,331 @@ result = client.notifications.create( # optional, enable this option only for time-sensitive alerts (e.g. incoming phone call) urgent=False, - # optional, an image to display in the notification content - # see https://pushpad.xyz/docs/sending_images - image_url="https://example.com/assets/image.png", - # optional, a string that is passed as an argument to action button callbacks custom_data="123", # optional, add some action buttons to the notification # see https://pushpad.xyz/docs/action_buttons - actions=( - { - 'title': "My Button 1", - 'target_url': "https://example.com/button-link", # optional - 'icon': "https://example.com/assets/button-icon.png", # optional - 'action': "myActionName" # optional - }, - ), + actions=[ + { + "title": "My Button 1", + "target_url": "https://example.com/button-link", # optional + "icon": "https://example.com/assets/button-icon.png", # optional + "action": "myActionName" # optional + } + ], # optional, bookmark the notification in the Pushpad dashboard (e.g. to highlight manual notifications) starred=True, # optional, use this option only if you need to create scheduled notifications (max 5 days) # see https://pushpad.xyz/docs/schedule_notifications - send_at=datetime.datetime(2025, 11, 20, 23, 15, 0, 0).isoformat(), + send_at="2025-11-20T10:09:00Z", # optional, add the notification to custom categories for stats aggregation # see https://pushpad.xyz/docs/monitoring - custom_metrics=('examples', 'another_metric'), # up to 3 metrics per notification + custom_metrics=["examples", "another_metric"], # up to 3 metrics per notification +) - # target specific users (omit both uids and tags to broadcast) - uids=('user1', 'user2', 'user3'), +# deliver to a group of users +client.notifications.create(body="Hello world!", uids=["user1", "user2"]) - # target segments using tags or boolean expressions - tags=('segment1', 'segment2') -) +# deliver to some users only if they have a given preference +# e.g. only "users" who have a interested in "events" will be reached +client.notifications.create(body="Hello world!", uids=["user1", "user2"], tags=["events"]) -# Inspect the response -print(result.id, result.scheduled) +# deliver to segments +# e.g. any subscriber that has the tag "segment1" OR "segment2" +client.notifications.create(body="Hello world!", tags=["segment1", "segment2"]) -# List, inspect, or cancel notifications -client.notifications.all(page=1) -client.notifications.get(result.id) -client.notifications.cancel(result.id) -``` +# you can use boolean expressions +# they can include parentheses and the operators !, &&, || (from highest to lowest precedence) +# https://pushpad.xyz/docs/tags +client.notifications.create(body="Hello world!", tags=["zip_code:28865 && !optout:local_events || friend_of:Organizer123"]) +client.notifications.create(body="Hello world!", tags=["tag1 && tag2", "tag3"]) # equal to 'tag1 && tag2 || tag3' -Set `uids` to reach a list of user IDs, set `tags` to reach subscribers that match your segments -(`tags` accepts boolean expressions using `!`, `&&`, and `||`). When both are present a user must -match the uid filter *and* have at least one of the listed tags. When both are omitted the notification -is broadcast to everyone. +# deliver to everyone +client.notifications.create(body="Hello world!") +``` You can set the default values for most fields in the project settings. See also [the docs](https://pushpad.xyz/docs/rest_api#notifications_api_docs) for more information about notification fields. If you try to send a notification to a user ID, but that user is not subscribed, that ID is simply ignored. -`client.notifications.create` returns a `NotificationCreateResult`: +These fields are returned by the API: + +```python +result = client.notifications.create(**payload) + +# Notification ID +print(result.id) # => 1000 + +# Estimated number of devices that will receive the notification +# Not available for notifications that use send_at +print(result.scheduled) # => 5 + +# Available only if you specify some user IDs (uids) in the request: +# it indicates which of those users are subscribed to notifications. +# Not available for notifications that use send_at +print(result.uids) # => ["user1", "user2"] + +# The time when the notification will be sent. +# Available for notifications that use send_at +print(result.send_at) # => "2025-10-30T10:09:00.000Z" +``` + +## Getting push notification data + +You can retrieve data for past notifications: + +```python +notification = client.notifications.get(42) + +# get basic attributes +notification.id # => 42 +notification.title # => 'Foo Bar' +notification.body # => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' +notification.target_url # => 'https://example.com' +notification.ttl # => 604800 +notification.require_interaction # => False +notification.silent # => False +notification.urgent # => False +notification.icon_url # => 'https://example.com/assets/icon.png' +notification.badge_url # => 'https://example.com/assets/badge.png' +notification.created_at # => '2025-07-06T10:09:14.000Z' + +# get statistics +notification.scheduled_count # => 1 +notification.successfully_sent_count # => 4 +notification.opened_count # => 2 +``` + +Or for multiple notifications of a project at once: + +```python +notifications = client.notifications.all(page=1) + +# same attributes as for single notification in example above +notifications[0].id # => 42 +notifications[0].title # => 'Foo Bar' +``` + +The REST API paginates the result set. You can pass a `page` parameter to get the full list in multiple requests. + +```python +notifications = client.notifications.all(page=2) +``` + +## Scheduled notifications + +You can create scheduled notifications that will be sent in the future: + +```python +import datetime + +scheduled = client.notifications.create( + body="This notification will be sent after 60 seconds", + send_at=(datetime.datetime.utcnow() + datetime.timedelta(seconds=60)).isoformat() +) +``` + +You can also cancel a scheduled notification: + +```python +client.notifications.cancel(scheduled.id) +``` + +## Getting subscription count + +You can retrieve the number of subscriptions for a given project, optionally filtered by `tags` or `uids`: + +```python +client.subscriptions.count() # => 100 +client.subscriptions.count(uids=["user1"]) # => 2 +client.subscriptions.count(tags=["sports"]) # => 10 +client.subscriptions.count(tags=["sports && travel"]) # => 5 +client.subscriptions.count(uids=["user1"], tags=["sports && travel"]) # => 1 +``` + +## Getting push subscription data + +You can retrieve the subscriptions for a given project, optionally filtered by `tags` or `uids`: + +```python +client.subscriptions.all() +client.subscriptions.all(uids=["user1"]) +client.subscriptions.all(tags=["sports"]) +client.subscriptions.all(tags=["sports && travel"]) +client.subscriptions.all(uids=["user1"], tags=["sports && travel"]) +``` + +The REST API paginates the result set. You can pass `page` and `per_page` parameters to get the full list in multiple requests. + +```python +subscriptions = client.subscriptions.all(page=2) +``` + +You can also retrieve the data of a specific subscription if you already know its id: + +```python +client.subscriptions.get(123) +``` + +## Updating push subscription data + +Usually you add data, like user IDs and tags, to the push subscriptions using the [JavaScript SDK](https://pushpad.xyz/docs/javascript_sdk_reference) in the frontend. + +However you can also update the subscription data from your server: + +```python +subscriptions = client.subscriptions.all(uids=["user1"]) + +for subscription in subscriptions: + # update the user ID associated to the push subscription + client.subscriptions.update(subscription.id, uid="myuser1") + + # update the tags associated to the push subscription + tags = list(subscription.tags or []) + tags.append("another_tag") + client.subscriptions.update(subscription.id, tags=tags) +``` + +## Importing push subscriptions + +If you need to [import](https://pushpad.xyz/docs/import) some existing push subscriptions (from another service to Pushpad, or from your backups) or if you simply need to create some test data, you can use this method: + +```python +attributes = { + "endpoint": "https://example.com/push/f7Q1Eyf7EyfAb1", + "p256dh": "BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4=", + "auth": "cdKMlhgVeSPzCXZ3V7FtgQ==", + "uid": "exampleUid", + "tags": ["exampleTag1", "exampleTag2"] +} + +subscription = client.subscriptions.create(**attributes) +``` + +Please note that this is not the standard way to collect subscriptions on Pushpad: usually you subscribe the users to the notifications using the [JavaScript SDK](https://pushpad.xyz/docs/javascript_sdk_reference) in the frontend. + +## Deleting push subscriptions + +Usually you unsubscribe a user from push notifications using the [JavaScript SDK](https://pushpad.xyz/docs/javascript_sdk_reference) in the frontend (recommended). + +However you can also delete the subscriptions using this library. Be careful, the subscriptions are permanently deleted! + +```python +client.subscriptions.delete(id) +``` + +## Managing projects + +Projects are usually created manually from the Pushpad dashboard. However you can also create projects from code if you need advanced automation or if you manage [many different domains](https://pushpad.xyz/docs/multiple_domains). + +```python +attributes = { + # required attributes + "sender_id": 123, + "name": "My project", + "website": "https://example.com", + + # optional configurations + "icon_url": "https://example.com/icon.png", + "badge_url": "https://example.com/badge.png", + "notifications_ttl": 604800, + "notifications_require_interaction": False, + "notifications_silent": False +} + +project = client.projects.create(**attributes) +``` + +You can also find, update and delete projects: + +```python +projects = client.projects.all() +for p in projects: + print(f"Project {p.id}: {p.name}") + +existing_project = client.projects.get(123) + +client.projects.update(existing_project.id, name="The New Project Name") + +client.projects.delete(existing_project.id) +``` + +## Managing senders + +Senders are usually created manually from the Pushpad dashboard. However you can also create senders from code. + +```python +attributes = { + # required attributes + "name": "My sender", + + # optional configurations + # do not include these fields if you want to generate them automatically + "vapid_private_key": "-----BEGIN EC PRIVATE KEY----- ...", + "vapid_public_key": "-----BEGIN PUBLIC KEY----- ..." +} + +sender = client.senders.create(**attributes) +``` + +You can also find, update and delete senders: + +```python +senders = client.senders.all() +for s in senders: + print(f"Sender {s.id}: {s.name}") + +existing_sender = client.senders.get(987) + +client.senders.update(existing_sender.id, name="The New Sender Name") + +client.senders.delete(existing_sender.id) +``` + +## Error handling + +API requests can raise errors, described by a `PushpadAPIError` that exposes the HTTP status code, reason, and response body. Network issues and other errors raise a `PushpadClientError`. + +```python +from pushpad import Pushpad, PushpadAPIError, PushpadClientError + +client = Pushpad(auth_token="token", project_id=123) + +try: + client.notifications.create(body="Hello") +except PushpadAPIError as error: # HTTP error from the API + print(error.status_code, error.reason, error.response_body) +except PushpadClientError as error: # network error or other errors + print(error) +``` + +## Type hints + +This library includes types for request parameters and responses to improve the developer experience. We recommend enabling Pylance, Pyright, or Python IntelliSense in your code editor for the best experience. + +```python +from pushpad import Pushpad + +client = Pushpad(auth_token="token", project_id=123) + +# parameters, like body, have a specific type +# you can immediately see if a parameter is wrong +result = client.notifications.create(body="Hello") + +# the result is a structured object +# it is always clear what attributes can be accessed on the resource +print(result.id) +``` -- `result.id` is the id of the notification on Pushpad -- `result.scheduled` is the estimated reach of the notification (i.e. the number of devices to which the notification will be sent, which can be different from the number of users, since a user may receive notifications on multiple devices) -- `result.uids` (when you pass `uids` while creating the notification) are the user IDs that will be actually reached by the notification because they are subscribed to your notifications. For example if you send a notification to `['uid1', 'uid2', 'uid3']`, but only `'uid1'` is subscribed, you will get `['uid1']` in response. Note that if a user has unsubscribed after the last notification sent to him, he may still be reported for one time as subscribed (this is due to the way the W3C Push API works). -- `result.send_at` is present only for scheduled notifications. The fields `scheduled` and `uids` are not available in this case. +## Documentation -`client.notifications.all()` and `client.notifications.get()` return fully populated `Notification` objects that include metadata such as stats counters and delivery information. +- Pushpad REST API reference: https://pushpad.xyz/docs/rest_api +- Getting started guide (for collecting subscriptions): https://pushpad.xyz/docs/pushpad_pro_getting_started +- JavaScript SDK reference (frontend): https://pushpad.xyz/docs/javascript_sdk_reference ## License From 6e9e5dfd251c91f122e1d0733417154c1ac6b630 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 20 Nov 2025 16:26:56 +0100 Subject: [PATCH 37/38] Improve import example in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7ebdedb..5408986 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ First you need to sign up to Pushpad and create a project there. Then set your authentication credentials and project: ```python -import pushpad +from pushpad import Pushpad -client = pushpad.Pushpad(auth_token='5374d7dfeffa2eb49965624ba7596a09', project_id=123) +client = Pushpad(auth_token='token', project_id=123) ``` - `auth_token` can be found in the user account settings. From 42bf73e6929e5c7e9f6f77e78a0f7ccaedb979d9 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 20 Nov 2025 17:03:50 +0100 Subject: [PATCH 38/38] Fix imports in tests for CI --- tests/resources/__init__.py | 1 + tests/resources/test_notifications.py | 2 +- tests/resources/test_projects.py | 2 +- tests/resources/test_senders.py | 2 +- tests/resources/test_subscriptions.py | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 tests/resources/__init__.py diff --git a/tests/resources/__init__.py b/tests/resources/__init__.py new file mode 100644 index 0000000..7c68785 --- /dev/null +++ b/tests/resources/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- \ No newline at end of file diff --git a/tests/resources/test_notifications.py b/tests/resources/test_notifications.py index 7c2f8a8..ce0e75f 100644 --- a/tests/resources/test_notifications.py +++ b/tests/resources/test_notifications.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from tests.helpers import BasePushpadTestCase, make_client, make_response +from ..helpers import BasePushpadTestCase, make_client, make_response class NotificationsResourceTests(BasePushpadTestCase): diff --git a/tests/resources/test_projects.py b/tests/resources/test_projects.py index 0e1ea29..a8a246b 100644 --- a/tests/resources/test_projects.py +++ b/tests/resources/test_projects.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from tests.helpers import BasePushpadTestCase, make_client, make_response +from ..helpers import BasePushpadTestCase, make_client, make_response class ProjectsResourceTests(BasePushpadTestCase): diff --git a/tests/resources/test_senders.py b/tests/resources/test_senders.py index 6d83379..3c1bb7d 100644 --- a/tests/resources/test_senders.py +++ b/tests/resources/test_senders.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from tests.helpers import BasePushpadTestCase, make_client, make_response +from ..helpers import BasePushpadTestCase, make_client, make_response class SendersResourceTests(BasePushpadTestCase): diff --git a/tests/resources/test_subscriptions.py b/tests/resources/test_subscriptions.py index 95d3556..a50f5ae 100644 --- a/tests/resources/test_subscriptions.py +++ b/tests/resources/test_subscriptions.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from tests.helpers import BasePushpadTestCase, make_client, make_response +from ..helpers import BasePushpadTestCase, make_client, make_response class SubscriptionsResourceTests(BasePushpadTestCase):