nitroping 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nitroping
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Zero-dependency Python SDK for nitroping push notifications. Send pushes, register devices, verify webhooks.
5
5
  Project-URL: Homepage, https://nitroping.dev
6
6
  Project-URL: Repository, https://github.com/productdevbook/nitroping-sdk
@@ -24,22 +24,17 @@ Requires-Dist: pytest>=8; extra == 'dev'
24
24
  Requires-Dist: ruff>=0.6; extra == 'dev'
25
25
  Description-Content-Type: text/markdown
26
26
 
27
- > Part of the [**nitroping-sdk**](https://github.com/productdevbook/nitroping-sdk) monorepo.
28
- > The PyPI package name (`nitroping`) is unchanged. See the [top-level README](../README.md) for SDKs in other languages.
27
+ # nitroping (Python SDK)
29
28
 
30
- <p align="center">
31
- <br>
32
- <b style="font-size: 2em;">nitroping-python</b>
33
- <br><br>
34
- Zero-dependency Python SDK for <a href="https://nitroping.dev">nitroping</a>.
35
- <br>
36
- Send push notifications, register devices, verify webhooks. Pure stdlib, runs on Python 3.10+.
37
- <br><br>
38
- <a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/v/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="PyPI version"></a>
39
- <a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/pyversions/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="Python versions"></a>
40
- <a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/dm/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="PyPI downloads"></a>
41
- <a href="https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE"><img src="https://img.shields.io/github/license/productdevbook/nitroping-sdk?style=flat&colorA=18181B&colorB=34d399" alt="license"></a>
42
- </p>
29
+ [![PyPI version](https://img.shields.io/pypi/v/nitroping?logo=pypi&color=3775a9)](https://pypi.org/project/nitroping/)
30
+ [![Python versions](https://img.shields.io/pypi/pyversions/nitroping?logo=python)](https://pypi.org/project/nitroping/)
31
+ [![PyPI downloads](https://img.shields.io/pypi/dm/nitroping?logo=pypi)](https://pypi.org/project/nitroping/)
32
+ [![license MIT](https://img.shields.io/pypi/l/nitroping)](https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE)
33
+ [![types](https://img.shields.io/pypi/types/nitroping?label=typed&logo=python)](https://pypi.org/project/nitroping/)
34
+
35
+ > Zero-dependency Python SDK for [nitroping](https://nitroping.dev) push notifications. Send pushes, register devices, verify webhooks. Pure stdlib, runs on Python 3.10+.
36
+
37
+ > 📦 Part of the [**nitroping-sdk**](https://github.com/productdevbook/nitroping-sdk) monorepo. The PyPI package name (`nitroping`) is unchanged. See the [root README](https://github.com/productdevbook/nitroping-sdk#readme) for SDKs in other languages.
43
38
 
44
39
  ## Why nitroping?
45
40
 
@@ -415,12 +410,12 @@ from nitroping import NotificationResult, RegisterDeviceResult, WebhookEvent
415
410
 
416
411
  ## License
417
412
 
418
- [MIT](../LICENSE) Copyright (c) 2026 productdevbook.
413
+ MIT — see [LICENSE](https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE). Copyright (c) 2026 productdevbook.
419
414
 
420
415
  ---
421
416
 
422
417
  <p align="center">
423
418
  <sub>
424
- Built by <a href="https://github.com/productdevbook">@productdevbook</a> — <a href="https://nitroping.dev">nitroping.dev</a> · <a href="https://github.com/productdevbook/nitroping">OSS core</a>
419
+ Built by <a href="https://github.com/productdevbook">@productdevbook</a> — <a href="https://nitroping.dev">nitroping.dev</a> · <a href="https://github.com/productdevbook/nitroping-sdk#readme">monorepo</a> · <a href="https://github.com/productdevbook/nitroping">OSS core</a>
425
420
  </sub>
426
421
  </p>
@@ -1,19 +1,14 @@
1
- > Part of the [**nitroping-sdk**](https://github.com/productdevbook/nitroping-sdk) monorepo.
2
- > The PyPI package name (`nitroping`) is unchanged. See the [top-level README](../README.md) for SDKs in other languages.
1
+ # nitroping (Python SDK)
3
2
 
4
- <p align="center">
5
- <br>
6
- <b style="font-size: 2em;">nitroping-python</b>
7
- <br><br>
8
- Zero-dependency Python SDK for <a href="https://nitroping.dev">nitroping</a>.
9
- <br>
10
- Send push notifications, register devices, verify webhooks. Pure stdlib, runs on Python 3.10+.
11
- <br><br>
12
- <a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/v/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="PyPI version"></a>
13
- <a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/pyversions/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="Python versions"></a>
14
- <a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/dm/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="PyPI downloads"></a>
15
- <a href="https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE"><img src="https://img.shields.io/github/license/productdevbook/nitroping-sdk?style=flat&colorA=18181B&colorB=34d399" alt="license"></a>
16
- </p>
3
+ [![PyPI version](https://img.shields.io/pypi/v/nitroping?logo=pypi&color=3775a9)](https://pypi.org/project/nitroping/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/nitroping?logo=python)](https://pypi.org/project/nitroping/)
5
+ [![PyPI downloads](https://img.shields.io/pypi/dm/nitroping?logo=pypi)](https://pypi.org/project/nitroping/)
6
+ [![license MIT](https://img.shields.io/pypi/l/nitroping)](https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE)
7
+ [![types](https://img.shields.io/pypi/types/nitroping?label=typed&logo=python)](https://pypi.org/project/nitroping/)
8
+
9
+ > Zero-dependency Python SDK for [nitroping](https://nitroping.dev) push notifications. Send pushes, register devices, verify webhooks. Pure stdlib, runs on Python 3.10+.
10
+
11
+ > 📦 Part of the [**nitroping-sdk**](https://github.com/productdevbook/nitroping-sdk) monorepo. The PyPI package name (`nitroping`) is unchanged. See the [root README](https://github.com/productdevbook/nitroping-sdk#readme) for SDKs in other languages.
17
12
 
18
13
  ## Why nitroping?
19
14
 
@@ -389,12 +384,12 @@ from nitroping import NotificationResult, RegisterDeviceResult, WebhookEvent
389
384
 
390
385
  ## License
391
386
 
392
- [MIT](../LICENSE) Copyright (c) 2026 productdevbook.
387
+ MIT — see [LICENSE](https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE). Copyright (c) 2026 productdevbook.
393
388
 
394
389
  ---
395
390
 
396
391
  <p align="center">
397
392
  <sub>
398
- Built by <a href="https://github.com/productdevbook">@productdevbook</a> — <a href="https://nitroping.dev">nitroping.dev</a> · <a href="https://github.com/productdevbook/nitroping">OSS core</a>
393
+ Built by <a href="https://github.com/productdevbook">@productdevbook</a> — <a href="https://nitroping.dev">nitroping.dev</a> · <a href="https://github.com/productdevbook/nitroping-sdk#readme">monorepo</a> · <a href="https://github.com/productdevbook/nitroping">OSS core</a>
399
394
  </sub>
400
395
  </p>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nitroping"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Zero-dependency Python SDK for nitroping push notifications. Send pushes, register devices, verify webhooks."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -21,8 +21,10 @@ from __future__ import annotations
21
21
 
22
22
  from ._client import AsyncNitroping, Nitroping
23
23
  from ._devices import DevicesClient
24
+ from ._events import EventsClient
24
25
  from ._http import DEFAULT_BASE_URL, HttpClient
25
26
  from ._notifications import NotificationsClient
27
+ from ._track import TrackClient
26
28
  from .errors import (
27
29
  ApiError,
28
30
  InvalidSignatureError,
@@ -32,27 +34,37 @@ from .errors import (
32
34
  TimestampOutOfRangeError,
33
35
  )
34
36
  from .types import (
37
+ CancelNotificationResult,
35
38
  DeactivateDeviceResult,
39
+ EngagementEvent,
36
40
  NotificationAction,
37
41
  NotificationResult,
38
42
  NotificationTarget,
39
43
  Platform,
40
44
  RegisterDeviceResult,
45
+ ReportEventResult,
41
46
  SendOptions,
42
47
  TargetAll,
43
48
  TargetDeviceIds,
49
+ TargetTags,
44
50
  TargetUserIds,
51
+ TrackEvent,
52
+ TrackResult,
53
+ UpdateDeviceResult,
45
54
  WebhookEvent,
46
55
  )
47
56
 
48
- __version__ = "0.1.0"
57
+ __version__ = "0.2.4"
49
58
 
50
59
  __all__ = [
51
60
  "DEFAULT_BASE_URL",
52
61
  "ApiError",
53
62
  "AsyncNitroping",
63
+ "CancelNotificationResult",
54
64
  "DeactivateDeviceResult",
55
65
  "DevicesClient",
66
+ "EngagementEvent",
67
+ "EventsClient",
56
68
  "HttpClient",
57
69
  "InvalidSignatureError",
58
70
  "MissingSignatureHeaderError",
@@ -65,11 +77,17 @@ __all__ = [
65
77
  "NotificationsClient",
66
78
  "Platform",
67
79
  "RegisterDeviceResult",
80
+ "ReportEventResult",
68
81
  "SendOptions",
69
82
  "TargetAll",
70
83
  "TargetDeviceIds",
84
+ "TargetTags",
71
85
  "TargetUserIds",
72
86
  "TimestampOutOfRangeError",
87
+ "TrackClient",
88
+ "TrackEvent",
89
+ "TrackResult",
90
+ "UpdateDeviceResult",
73
91
  "WebhookEvent",
74
92
  "__version__",
75
93
  ]
@@ -7,16 +7,24 @@ import os
7
7
  from typing import Any
8
8
 
9
9
  from ._devices import DevicesClient
10
+ from ._events import EventsClient
10
11
  from ._http import DEFAULT_BASE_URL, HttpClient
11
12
  from ._notifications import NotificationsClient
13
+ from ._track import TrackClient
12
14
  from .errors import NitropingError
13
15
  from .types import (
16
+ CancelNotificationResult,
14
17
  DeactivateDeviceResult,
18
+ EngagementEvent,
15
19
  NotificationAction,
16
20
  NotificationResult,
17
21
  NotificationTarget,
18
22
  Platform,
19
23
  RegisterDeviceResult,
24
+ ReportEventResult,
25
+ TrackEvent,
26
+ TrackResult,
27
+ UpdateDeviceResult,
20
28
  )
21
29
 
22
30
 
@@ -36,10 +44,14 @@ class Nitroping:
36
44
  print(result["id"], result["status"])
37
45
  """
38
46
 
39
- #: ``notifications`` resource — send, get.
47
+ #: ``notifications`` resource — send, get, cancel.
40
48
  notifications: NotificationsClient
41
- #: ``devices`` resource — register, deactivate.
49
+ #: ``devices`` resource — register, update, deactivate.
42
50
  devices: DevicesClient
51
+ #: ``track`` resource — delivery/open/click callbacks (``POST /track``).
52
+ track: TrackClient
53
+ #: ``events`` resource — public engagement events (``POST /events``).
54
+ events: EventsClient
43
55
  #: Internal HTTP client. Exposed for advanced use (custom requests).
44
56
  http: HttpClient
45
57
 
@@ -68,6 +80,8 @@ class Nitroping:
68
80
  )
69
81
  self.notifications = NotificationsClient(self.http)
70
82
  self.devices = DevicesClient(self.http)
83
+ self.track = TrackClient(self.http)
84
+ self.events = EventsClient(self.http)
71
85
 
72
86
 
73
87
  class _AsyncNotificationsClient:
@@ -128,6 +142,12 @@ class _AsyncNotificationsClient:
128
142
  None, self._inner.get, notification_id
129
143
  )
130
144
 
145
+ async def cancel(self, notification_id: str) -> CancelNotificationResult:
146
+ loop = asyncio.get_running_loop()
147
+ return await loop.run_in_executor(
148
+ None, self._inner.cancel, notification_id
149
+ )
150
+
131
151
 
132
152
  class _AsyncDevicesClient:
133
153
  """Awaitable façade over :class:`DevicesClient`."""
@@ -144,6 +164,7 @@ class _AsyncDevicesClient:
144
164
  web_push_p256dh: str | None = None,
145
165
  web_push_auth: str | None = None,
146
166
  metadata: dict[str, Any] | None = None,
167
+ tags: list[str] | None = None,
147
168
  ) -> RegisterDeviceResult:
148
169
  loop = asyncio.get_running_loop()
149
170
  return await loop.run_in_executor(
@@ -155,9 +176,22 @@ class _AsyncDevicesClient:
155
176
  web_push_p256dh=web_push_p256dh,
156
177
  web_push_auth=web_push_auth,
157
178
  metadata=metadata,
179
+ tags=tags,
158
180
  ),
159
181
  )
160
182
 
183
+ async def update(
184
+ self,
185
+ device_id: str,
186
+ *,
187
+ tags: list[str] | None = None,
188
+ ) -> UpdateDeviceResult:
189
+ loop = asyncio.get_running_loop()
190
+ return await loop.run_in_executor(
191
+ None,
192
+ lambda: self._inner.update(device_id, tags=tags),
193
+ )
194
+
161
195
  async def deactivate(self, device_id: str) -> DeactivateDeviceResult:
162
196
  loop = asyncio.get_running_loop()
163
197
  return await loop.run_in_executor(
@@ -165,6 +199,60 @@ class _AsyncDevicesClient:
165
199
  )
166
200
 
167
201
 
202
+ class _AsyncTrackClient:
203
+ """Awaitable façade over :class:`TrackClient`."""
204
+
205
+ def __init__(self, inner: TrackClient) -> None:
206
+ self._inner = inner
207
+
208
+ async def record(
209
+ self,
210
+ *,
211
+ event: TrackEvent,
212
+ delivery_log_id: str | None = None,
213
+ notification_id: str | None = None,
214
+ device_token: str | None = None,
215
+ ) -> TrackResult:
216
+ loop = asyncio.get_running_loop()
217
+ return await loop.run_in_executor(
218
+ None,
219
+ lambda: self._inner.record(
220
+ event=event,
221
+ delivery_log_id=delivery_log_id,
222
+ notification_id=notification_id,
223
+ device_token=device_token,
224
+ ),
225
+ )
226
+
227
+
228
+ class _AsyncEventsClient:
229
+ """Awaitable façade over :class:`EventsClient`."""
230
+
231
+ def __init__(self, inner: EventsClient) -> None:
232
+ self._inner = inner
233
+
234
+ async def report(
235
+ self,
236
+ *,
237
+ notification_id: str,
238
+ device_id: str,
239
+ type: EngagementEvent,
240
+ action_id: str | None = None,
241
+ happened_at: str | None = None,
242
+ ) -> ReportEventResult:
243
+ loop = asyncio.get_running_loop()
244
+ return await loop.run_in_executor(
245
+ None,
246
+ lambda: self._inner.report(
247
+ notification_id=notification_id,
248
+ device_id=device_id,
249
+ type=type,
250
+ action_id=action_id,
251
+ happened_at=happened_at,
252
+ ),
253
+ )
254
+
255
+
168
256
  class AsyncNitroping:
169
257
  """Async server-side SDK client.
170
258
 
@@ -180,6 +268,8 @@ class AsyncNitroping:
180
268
 
181
269
  notifications: _AsyncNotificationsClient
182
270
  devices: _AsyncDevicesClient
271
+ track: _AsyncTrackClient
272
+ events: _AsyncEventsClient
183
273
 
184
274
  def __init__(
185
275
  self,
@@ -197,6 +287,8 @@ class AsyncNitroping:
197
287
  )
198
288
  self.notifications = _AsyncNotificationsClient(self._sync.notifications)
199
289
  self.devices = _AsyncDevicesClient(self._sync.devices)
290
+ self.track = _AsyncTrackClient(self._sync.track)
291
+ self.events = _AsyncEventsClient(self._sync.events)
200
292
 
201
293
  @property
202
294
  def http(self) -> HttpClient:
@@ -1,7 +1,8 @@
1
1
  """``devices`` resource client.
2
2
 
3
3
  Mounted on :class:`nitroping.Nitroping` as ``np.devices``. Wraps
4
- ``POST /api/v1/devices`` and ``DELETE /api/v1/devices/:id``.
4
+ ``POST /api/v1/devices``, ``PUT /api/v1/devices/:id``, and
5
+ ``DELETE /api/v1/devices/:id``.
5
6
  """
6
7
 
7
8
  from __future__ import annotations
@@ -10,11 +11,16 @@ from typing import Any, cast
10
11
  from urllib.parse import quote
11
12
 
12
13
  from ._http import HttpClient
13
- from .types import DeactivateDeviceResult, Platform, RegisterDeviceResult
14
+ from .types import (
15
+ DeactivateDeviceResult,
16
+ Platform,
17
+ RegisterDeviceResult,
18
+ UpdateDeviceResult,
19
+ )
14
20
 
15
21
 
16
22
  class DevicesClient:
17
- """Register and deactivate device rows."""
23
+ """Register, update, and deactivate device rows."""
18
24
 
19
25
  def __init__(self, http: HttpClient) -> None:
20
26
  self._http = http
@@ -28,12 +34,15 @@ class DevicesClient:
28
34
  web_push_p256dh: str | None = None,
29
35
  web_push_auth: str | None = None,
30
36
  metadata: dict[str, Any] | None = None,
37
+ tags: list[str] | None = None,
31
38
  ) -> RegisterDeviceResult:
32
39
  """Register (or update) a device with the secret API key.
33
40
 
34
41
  Idempotent on ``(app_id, token, user_id)``. Returns
35
42
  ``{"id": ..., "created": True}`` when a new row was inserted,
36
43
  ``{"id": ..., "created": False}`` when an existing device matched.
44
+
45
+ ``tags`` enables tag-based targeting (``target={"tags": [...]}``).
37
46
  """
38
47
  wire: dict[str, Any] = {"token": token, "platform": platform}
39
48
  if user_id is not None:
@@ -44,10 +53,34 @@ class DevicesClient:
44
53
  wire["web_push_auth"] = web_push_auth
45
54
  if metadata is not None:
46
55
  wire["metadata"] = metadata
56
+ if tags is not None:
57
+ wire["tags"] = tags
47
58
 
48
- response = self._http.request("POST", "/api/v1/devices", body=wire)
59
+ path = "/api/v1/public/devices" if self._http.auth_scheme == "Public" else "/api/v1/devices"
60
+ response = self._http.request("POST", path, body=wire)
49
61
  return cast(RegisterDeviceResult, response)
50
62
 
63
+ def update(
64
+ self,
65
+ device_id: str,
66
+ *,
67
+ tags: list[str] | None = None,
68
+ ) -> UpdateDeviceResult:
69
+ """Update a device (e.g. replace its tags).
70
+
71
+ Wraps ``PUT /api/v1/devices/:id``. Returns ``{"id": ..., "tags":
72
+ [...]}``. Raises :class:`~nitroping.errors.ApiError` with
73
+ ``code = "not_found"`` if the id doesn't belong to your app.
74
+ """
75
+ wire: dict[str, Any] = {}
76
+ if tags is not None:
77
+ wire["tags"] = tags
78
+
79
+ response = self._http.request(
80
+ "PUT", f"/api/v1/devices/{quote(device_id, safe='')}", body=wire
81
+ )
82
+ return cast(UpdateDeviceResult, response)
83
+
51
84
  def deactivate(self, device_id: str) -> DeactivateDeviceResult:
52
85
  """Soft-delete a device (sets ``status = inactive``).
53
86
 
@@ -0,0 +1,53 @@
1
+ """``events`` resource client.
2
+
3
+ Mounted on :class:`nitroping.Nitroping` as ``np.events``. Wraps
4
+ ``POST /api/v1/events`` — the public, unauthenticated engagement
5
+ endpoint. The ``(notification_id, device_id)`` pair is the bearer
6
+ secret, so no ``Authorization`` header is required (and a ``pk_`` public
7
+ key is fine).
8
+
9
+ This is the endpoint a client app calls when a notification is opened or
10
+ a notification action is clicked.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, cast
16
+
17
+ from ._http import HttpClient
18
+ from .types import EngagementEvent, ReportEventResult
19
+
20
+
21
+ class EventsClient:
22
+ """Report public engagement events (``opened`` / ``clicked``)."""
23
+
24
+ def __init__(self, http: HttpClient) -> None:
25
+ self._http = http
26
+
27
+ def report(
28
+ self,
29
+ *,
30
+ notification_id: str,
31
+ device_id: str,
32
+ type: EngagementEvent,
33
+ action_id: str | None = None,
34
+ happened_at: str | None = None,
35
+ ) -> ReportEventResult:
36
+ """Report an engagement event (``opened`` or ``clicked``).
37
+
38
+ Returns ``{"accepted": True}`` on 202. Raises
39
+ :class:`~nitroping.errors.ApiError` with ``code = "not_found"``
40
+ (404) if the notification/device pair is unknown.
41
+ """
42
+ wire: dict[str, Any] = {
43
+ "notification_id": notification_id,
44
+ "device_id": device_id,
45
+ "type": type,
46
+ }
47
+ if action_id is not None:
48
+ wire["action_id"] = action_id
49
+ if happened_at is not None:
50
+ wire["happened_at"] = happened_at
51
+
52
+ response = self._http.request("POST", "/api/v1/events", body=wire)
53
+ return cast(ReportEventResult, response)
@@ -23,7 +23,7 @@ DEFAULT_BASE_URL = "https://nitroping.dev"
23
23
 
24
24
  #: SDK version — bumped via ``pyproject.toml``. Used in the User-Agent
25
25
  #: header so requests can be attributed during incident investigation.
26
- SDK_VERSION = "0.1.0"
26
+ SDK_VERSION = "0.2.3"
27
27
 
28
28
 
29
29
  class HttpClient:
@@ -36,6 +36,7 @@ class HttpClient:
36
36
 
37
37
  base_url: str
38
38
  api_key: str
39
+ auth_scheme: str
39
40
  timeout: float
40
41
  user_agent: str
41
42
 
@@ -46,10 +47,12 @@ class HttpClient:
46
47
  base_url: str = DEFAULT_BASE_URL,
47
48
  timeout: float = 30.0,
48
49
  user_agent: str | None = None,
50
+ auth_scheme: str | None = None,
49
51
  ) -> None:
50
52
  if not api_key:
51
53
  raise NitropingError("api_key is required", code="invalid_argument")
52
54
  self.api_key = api_key
55
+ self.auth_scheme = auth_scheme or ("Public" if api_key.startswith("pk_") else "ApiKey")
53
56
  self.base_url = base_url.rstrip("/")
54
57
  self.timeout = timeout
55
58
  self.user_agent = user_agent or f"nitroping-python/{SDK_VERSION}"
@@ -73,7 +76,7 @@ class HttpClient:
73
76
  body_bytes: bytes | None = None
74
77
 
75
78
  all_headers: dict[str, str] = {
76
- "Authorization": f"ApiKey {self.api_key}",
79
+ "Authorization": f"{self.auth_scheme} {self.api_key}",
77
80
  "Accept": "application/json",
78
81
  "User-Agent": self.user_agent,
79
82
  }
@@ -10,7 +10,12 @@ from typing import Any, cast
10
10
  from urllib.parse import quote
11
11
 
12
12
  from ._http import HttpClient
13
- from .types import NotificationAction, NotificationResult, NotificationTarget
13
+ from .types import (
14
+ CancelNotificationResult,
15
+ NotificationAction,
16
+ NotificationResult,
17
+ NotificationTarget,
18
+ )
14
19
 
15
20
 
16
21
  class NotificationsClient:
@@ -92,3 +97,17 @@ class NotificationsClient:
92
97
  "GET", f"/api/v1/notifications/{quote(notification_id, safe='')}"
93
98
  )
94
99
  return cast(dict[str, Any], response)
100
+
101
+ def cancel(self, notification_id: str) -> CancelNotificationResult:
102
+ """Cancel a scheduled or in-flight notification.
103
+
104
+ Wraps ``DELETE /api/v1/notifications/:id``. Returns
105
+ ``{"id": ..., "status": "canceled"}``. Raises
106
+ :class:`~nitroping.errors.ApiError` with ``code = "cannot_cancel"``
107
+ (409) if the notification already reached a terminal state, or
108
+ ``code = "not_found"`` (404).
109
+ """
110
+ response = self._http.request(
111
+ "DELETE", f"/api/v1/notifications/{quote(notification_id, safe='')}"
112
+ )
113
+ return cast(CancelNotificationResult, response)
@@ -0,0 +1,55 @@
1
+ """``track`` resource client.
2
+
3
+ Mounted on :class:`nitroping.Nitroping` as ``np.track``. Wraps
4
+ ``POST /api/v1/track`` — the server SDK delivery/open/click callback.
5
+ Returns 202 immediately; the write is absorbed by a background worker.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import cast
11
+
12
+ from ._http import HttpClient
13
+ from .errors import NitropingError
14
+ from .types import TrackEvent, TrackResult
15
+
16
+
17
+ class TrackClient:
18
+ """Record delivery/open/click events against a delivery log."""
19
+
20
+ def __init__(self, http: HttpClient) -> None:
21
+ self._http = http
22
+
23
+ def record(
24
+ self,
25
+ *,
26
+ event: TrackEvent,
27
+ delivery_log_id: str | None = None,
28
+ notification_id: str | None = None,
29
+ device_token: str | None = None,
30
+ ) -> TrackResult:
31
+ """Record a delivery/open/click event against a delivery log.
32
+
33
+ Identify the target either by ``delivery_log_id``, or by
34
+ ``notification_id`` + the device's ``device_token``. ``event`` is
35
+ one of ``delivered | opened | clicked``.
36
+
37
+ Returns ``{"accepted": True}`` on 202.
38
+ """
39
+ if delivery_log_id is not None:
40
+ wire = {"delivery_log_id": delivery_log_id, "event": event}
41
+ elif notification_id is not None and device_token is not None:
42
+ wire = {
43
+ "notification_id": notification_id,
44
+ "device_token": device_token,
45
+ "event": event,
46
+ }
47
+ else:
48
+ raise NitropingError(
49
+ "track.record requires either delivery_log_id, or both "
50
+ "notification_id and device_token",
51
+ code="invalid_argument",
52
+ )
53
+
54
+ response = self._http.request("POST", "/api/v1/track", body=wire)
55
+ return cast(TrackResult, response)
@@ -44,8 +44,14 @@ class TargetUserIds(TypedDict):
44
44
  user_ids: list[str]
45
45
 
46
46
 
47
- #: Target selector for a notification. Exactly one of the three.
48
- NotificationTarget = TargetAll | TargetDeviceIds | TargetUserIds
47
+ class TargetTags(TypedDict):
48
+ """Hit every device row tagged with one of the given tags."""
49
+
50
+ tags: list[str]
51
+
52
+
53
+ #: Target selector for a notification. Exactly one of the four.
54
+ NotificationTarget = TargetAll | TargetDeviceIds | TargetUserIds | TargetTags
49
55
 
50
56
 
51
57
  class SendOptions(TypedDict, total=False):
@@ -79,6 +85,15 @@ class RegisterDeviceResult(TypedDict):
79
85
  created: bool
80
86
 
81
87
 
88
+ class UpdateDeviceResult(TypedDict):
89
+ """Response from ``PUT /api/v1/devices/:id``."""
90
+
91
+ #: UUID of the device row.
92
+ id: str
93
+ #: The device's tags after the update.
94
+ tags: list[str]
95
+
96
+
82
97
  class DeactivateDeviceResult(TypedDict):
83
98
  """Response from ``DELETE /api/v1/devices/:id``."""
84
99
 
@@ -86,6 +101,32 @@ class DeactivateDeviceResult(TypedDict):
86
101
  status: str
87
102
 
88
103
 
104
+ class CancelNotificationResult(TypedDict):
105
+ """Response from ``DELETE /api/v1/notifications/:id``."""
106
+
107
+ id: str
108
+ status: str
109
+
110
+
111
+ #: Delivery-tracking event type for ``POST /api/v1/track``.
112
+ TrackEvent = Literal["delivered", "opened", "clicked"]
113
+
114
+ #: Engagement event type for ``POST /api/v1/events``.
115
+ EngagementEvent = Literal["opened", "clicked"]
116
+
117
+
118
+ class TrackResult(TypedDict):
119
+ """Response from ``POST /api/v1/track``."""
120
+
121
+ accepted: bool
122
+
123
+
124
+ class ReportEventResult(TypedDict):
125
+ """Response from ``POST /api/v1/events``."""
126
+
127
+ accepted: bool
128
+
129
+
89
130
  class WebhookEvent(TypedDict):
90
131
  """Outbound webhook event envelope (parsed from a
91
132
  :func:`nitroping.webhooks.verify` call)."""
@@ -52,6 +52,36 @@ def test_register_web_with_p256dh_and_auth(mock_urlopen):
52
52
  assert body["web_push_auth"] == "auth_secret_value"
53
53
 
54
54
 
55
+ def test_register_with_tags(mock_urlopen):
56
+ """tags kwarg lands on the wire as a `tags` array."""
57
+ mock_urlopen.enqueue_json(201, {"id": "dev-3", "created": True})
58
+
59
+ np = Nitroping(api_key="np_x")
60
+ np.devices.register(
61
+ platform="android",
62
+ token="fcm-token-xyz",
63
+ tags=["beta", "vip"],
64
+ )
65
+
66
+ body = mock_urlopen.calls[0].body_json
67
+ assert body is not None
68
+ assert body["tags"] == ["beta", "vip"]
69
+
70
+
71
+ def test_update_sends_put_with_tags(mock_urlopen):
72
+ """PUT /api/v1/devices/:id with {tags:[...]}, returns {id, tags}."""
73
+ mock_urlopen.enqueue_json(200, {"id": "dev-1", "tags": ["beta"]})
74
+
75
+ np = Nitroping(api_key="np_x")
76
+ result = np.devices.update("dev-1", tags=["beta"])
77
+
78
+ assert result == {"id": "dev-1", "tags": ["beta"]}
79
+ call = mock_urlopen.calls[0]
80
+ assert call.method == "PUT"
81
+ assert call.url == "https://nitroping.dev/api/v1/devices/dev-1"
82
+ assert call.body_json == {"tags": ["beta"]}
83
+
84
+
55
85
  def test_deactivate_sends_delete(mock_urlopen):
56
86
  """DELETE /api/v1/devices/:id with proper URL-encoded id."""
57
87
  mock_urlopen.enqueue_json(200, {"id": "dev-1", "status": "inactive"})
@@ -0,0 +1,80 @@
1
+ """Tests for ``np.events`` — mirrors js/test/events.test.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from nitroping import ApiError, AsyncNitroping, Nitroping
8
+
9
+
10
+ def test_report_opened(mock_urlopen):
11
+ """events.report(...) → POST /api/v1/events with snake_case body."""
12
+ mock_urlopen.enqueue_json(202, {"accepted": True})
13
+
14
+ np = Nitroping(api_key="np_x")
15
+ result = np.events.report(
16
+ notification_id="n1",
17
+ device_id="d1",
18
+ type="opened",
19
+ )
20
+
21
+ assert result == {"accepted": True}
22
+ call = mock_urlopen.calls[0]
23
+ assert call.method == "POST"
24
+ assert call.url == "https://nitroping.dev/api/v1/events"
25
+ assert call.body_json == {
26
+ "notification_id": "n1",
27
+ "device_id": "d1",
28
+ "type": "opened",
29
+ }
30
+
31
+
32
+ def test_report_clicked_with_action_and_timestamp(mock_urlopen):
33
+ """Optional action_id / happened_at forwarded as snake_case fields."""
34
+ mock_urlopen.enqueue_json(202, {"accepted": True})
35
+
36
+ np = Nitroping(api_key="np_x")
37
+ np.events.report(
38
+ notification_id="n1",
39
+ device_id="d1",
40
+ type="clicked",
41
+ action_id="track",
42
+ happened_at="2026-06-13T10:00:00Z",
43
+ )
44
+
45
+ assert mock_urlopen.calls[0].body_json == {
46
+ "notification_id": "n1",
47
+ "device_id": "d1",
48
+ "type": "clicked",
49
+ "action_id": "track",
50
+ "happened_at": "2026-06-13T10:00:00Z",
51
+ }
52
+
53
+
54
+ def test_report_404_raises_apierror(mock_urlopen):
55
+ """Unknown notification/device pair → ApiError with not_found code."""
56
+ mock_urlopen.enqueue_error(
57
+ 404,
58
+ {"error": {"code": "not_found", "message": "Unknown pair"}},
59
+ )
60
+
61
+ np = Nitroping(api_key="np_x")
62
+ with pytest.raises(ApiError) as exc:
63
+ np.events.report(notification_id="n1", device_id="d1", type="opened")
64
+ assert exc.value.code == "not_found"
65
+ assert exc.value.status == 404
66
+
67
+
68
+ async def test_async_events_report(mock_urlopen):
69
+ """AsyncNitroping.events.report awaits and returns the same result."""
70
+ mock_urlopen.enqueue_json(202, {"accepted": True})
71
+
72
+ np = AsyncNitroping(api_key="np_x")
73
+ result = await np.events.report(
74
+ notification_id="n1",
75
+ device_id="d1",
76
+ type="opened",
77
+ )
78
+
79
+ assert result == {"accepted": True}
80
+ assert mock_urlopen.calls[0].url == "https://nitroping.dev/api/v1/events"
@@ -145,6 +145,47 @@ def test_send_target_device_ids(mock_urlopen):
145
145
  assert mock_urlopen.calls[0].body_json["target"] == {"device_ids": ["d1", "d2"]}
146
146
 
147
147
 
148
+ def test_send_target_tags(mock_urlopen):
149
+ """target={'tags': [...]} passes through unchanged on the wire."""
150
+ mock_urlopen.enqueue_json(201, {"id": "n1", "status": "queued"})
151
+
152
+ np = Nitroping(api_key="np_x")
153
+ np.notifications.send(
154
+ title="x",
155
+ body="y",
156
+ target={"tags": ["beta", "vip"]},
157
+ )
158
+
159
+ assert mock_urlopen.calls[0].body_json is not None
160
+ assert mock_urlopen.calls[0].body_json["target"] == {"tags": ["beta", "vip"]}
161
+
162
+
163
+ def test_cancel_sends_delete(mock_urlopen):
164
+ """np.notifications.cancel(id) → DELETE /api/v1/notifications/<id>."""
165
+ mock_urlopen.enqueue_json(200, {"id": "n1", "status": "canceled"})
166
+
167
+ np = Nitroping(api_key="np_x")
168
+ result = np.notifications.cancel("n1")
169
+
170
+ assert result == {"id": "n1", "status": "canceled"}
171
+ assert mock_urlopen.calls[0].method == "DELETE"
172
+ assert mock_urlopen.calls[0].url == "https://nitroping.dev/api/v1/notifications/n1"
173
+
174
+
175
+ def test_cancel_409_raises_apierror(mock_urlopen):
176
+ """409 with `cannot_cancel` code is surfaced as ApiError."""
177
+ mock_urlopen.enqueue_error(
178
+ 409,
179
+ {"error": {"code": "cannot_cancel", "message": "Already delivered"}},
180
+ )
181
+
182
+ np = Nitroping(api_key="np_x")
183
+ with pytest.raises(ApiError) as exc:
184
+ np.notifications.cancel("n1")
185
+ assert exc.value.code == "cannot_cancel"
186
+ assert exc.value.status == 409
187
+
188
+
148
189
  def test_get_notification(mock_urlopen):
149
190
  """np.notifications.get(id) → GET /api/v1/notifications/<id>."""
150
191
  mock_urlopen.enqueue_json(
@@ -0,0 +1,49 @@
1
+ """Tests for ``np.track`` — mirrors js/test/track.test.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from nitroping import Nitroping, NitropingError
8
+
9
+
10
+ def test_record_by_delivery_log_id(mock_urlopen):
11
+ """track.record(delivery_log_id=..., event=...) → POST /api/v1/track."""
12
+ mock_urlopen.enqueue_json(202, {"accepted": True})
13
+
14
+ np = Nitroping(api_key="np_x")
15
+ result = np.track.record(delivery_log_id="dl-1", event="delivered")
16
+
17
+ assert result == {"accepted": True}
18
+ call = mock_urlopen.calls[0]
19
+ assert call.method == "POST"
20
+ assert call.url == "https://nitroping.dev/api/v1/track"
21
+ assert call.body_json == {"delivery_log_id": "dl-1", "event": "delivered"}
22
+
23
+
24
+ def test_record_by_notification_and_device_token(mock_urlopen):
25
+ """The alternative shape: notification_id + device_token."""
26
+ mock_urlopen.enqueue_json(202, {"accepted": True})
27
+
28
+ np = Nitroping(api_key="np_x")
29
+ result = np.track.record(
30
+ notification_id="n1",
31
+ device_token="apns-abc",
32
+ event="opened",
33
+ )
34
+
35
+ assert result == {"accepted": True}
36
+ assert mock_urlopen.calls[0].body_json == {
37
+ "notification_id": "n1",
38
+ "device_token": "apns-abc",
39
+ "event": "opened",
40
+ }
41
+
42
+
43
+ def test_record_requires_an_identifier(mock_urlopen):
44
+ """Missing both identifiers raises NitropingError, no HTTP call made."""
45
+ np = Nitroping(api_key="np_x")
46
+ with pytest.raises(NitropingError) as exc:
47
+ np.track.record(event="clicked")
48
+ assert exc.value.code == "invalid_argument"
49
+ assert mock_urlopen.calls == []
File without changes
File without changes