nitroping 0.1.3__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.
- nitroping-0.1.3/.gitignore +47 -0
- nitroping-0.1.3/PKG-INFO +426 -0
- nitroping-0.1.3/README.md +400 -0
- nitroping-0.1.3/pyproject.toml +79 -0
- nitroping-0.1.3/src/nitroping/__init__.py +75 -0
- nitroping-0.1.3/src/nitroping/_client.py +204 -0
- nitroping-0.1.3/src/nitroping/_devices.py +61 -0
- nitroping-0.1.3/src/nitroping/_http.py +158 -0
- nitroping-0.1.3/src/nitroping/_notifications.py +94 -0
- nitroping-0.1.3/src/nitroping/errors.py +110 -0
- nitroping-0.1.3/src/nitroping/py.typed +0 -0
- nitroping-0.1.3/src/nitroping/types.py +100 -0
- nitroping-0.1.3/src/nitroping/webhooks.py +160 -0
- nitroping-0.1.3/tests/conftest.py +109 -0
- nitroping-0.1.3/tests/test_devices.py +78 -0
- nitroping-0.1.3/tests/test_notifications.py +164 -0
- nitroping-0.1.3/tests/test_webhooks.py +165 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
|
|
3
|
+
# Node
|
|
4
|
+
node_modules
|
|
5
|
+
dist
|
|
6
|
+
*.tsbuildinfo
|
|
7
|
+
.pnpm-debug.log
|
|
8
|
+
|
|
9
|
+
# Swift
|
|
10
|
+
.build
|
|
11
|
+
.swiftpm
|
|
12
|
+
Package.resolved
|
|
13
|
+
*.xcodeproj
|
|
14
|
+
DerivedData/
|
|
15
|
+
|
|
16
|
+
# Python
|
|
17
|
+
__pycache__/
|
|
18
|
+
*.pyc
|
|
19
|
+
*.pyo
|
|
20
|
+
.venv/
|
|
21
|
+
venv/
|
|
22
|
+
*.egg-info/
|
|
23
|
+
dist/
|
|
24
|
+
build/
|
|
25
|
+
|
|
26
|
+
# Go
|
|
27
|
+
vendor/
|
|
28
|
+
*.exe
|
|
29
|
+
*.test
|
|
30
|
+
*.out
|
|
31
|
+
|
|
32
|
+
# Kotlin / Gradle
|
|
33
|
+
.gradle/
|
|
34
|
+
build/
|
|
35
|
+
*.iml
|
|
36
|
+
local.properties
|
|
37
|
+
|
|
38
|
+
# PHP / Composer
|
|
39
|
+
vendor/
|
|
40
|
+
composer.lock
|
|
41
|
+
|
|
42
|
+
# IDEs
|
|
43
|
+
.idea/
|
|
44
|
+
.vscode/
|
|
45
|
+
|
|
46
|
+
# Logs
|
|
47
|
+
*.log
|
nitroping-0.1.3/PKG-INFO
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nitroping
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Zero-dependency Python SDK for nitroping push notifications. Send pushes, register devices, verify webhooks.
|
|
5
|
+
Project-URL: Homepage, https://nitroping.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/productdevbook/nitroping-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/productdevbook/nitroping-sdk/issues
|
|
8
|
+
Project-URL: Documentation, https://nitroping.dev/docs
|
|
9
|
+
Author: productdevbook
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: apns,fcm,nitroping,notifications,push,sdk,web-push
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
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.
|
|
29
|
+
|
|
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>
|
|
43
|
+
|
|
44
|
+
## Why nitroping?
|
|
45
|
+
|
|
46
|
+
[nitroping](https://nitroping.dev) is a hosted push notification service that
|
|
47
|
+
unifies APNs (iOS), FCM (Android), and Web Push behind one API. Send to a
|
|
48
|
+
single device, a user across all of their devices, or every device in your app
|
|
49
|
+
with one HTTP call. The service handles fanout, retries, idempotency, quota
|
|
50
|
+
and outbound webhooks for delivery state — you write the product, not the
|
|
51
|
+
plumbing.
|
|
52
|
+
|
|
53
|
+
`nitroping` (Python) is the official Python client. It has **zero runtime
|
|
54
|
+
dependencies**, ships type stubs (PEP 561), and runs anywhere CPython 3.10+
|
|
55
|
+
runs: Django, FastAPI, Flask, Celery workers, AWS Lambda, plain scripts. The
|
|
56
|
+
package weighs in under 30 kB.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
pip install nitroping
|
|
62
|
+
# or
|
|
63
|
+
uv pip install nitroping
|
|
64
|
+
# or
|
|
65
|
+
poetry add nitroping
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick Start
|
|
69
|
+
|
|
70
|
+
### Send a notification
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import os
|
|
74
|
+
from nitroping import Nitroping
|
|
75
|
+
|
|
76
|
+
np = Nitroping(api_key=os.environ["NITROPING_API_KEY"])
|
|
77
|
+
|
|
78
|
+
result = np.notifications.send(
|
|
79
|
+
title="Order #4129 shipped",
|
|
80
|
+
body="Your package is on its way.",
|
|
81
|
+
deep_link="https://example.com/orders/4129",
|
|
82
|
+
actions=[
|
|
83
|
+
{"id": "track", "title": "Track"},
|
|
84
|
+
{"id": "view", "title": "View order"},
|
|
85
|
+
],
|
|
86
|
+
target={"user_ids": ["user-42"]},
|
|
87
|
+
idempotency_key="order-shipped-4129",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
print(result["id"], result["status"]) # "abc-...", "queued"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Register a device (server side)
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
np.devices.register(
|
|
97
|
+
platform="ios",
|
|
98
|
+
token=device_token, # raw APNs hex token
|
|
99
|
+
user_id="user-42",
|
|
100
|
+
metadata={"app_version": "2.4.1"},
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Verify a webhook
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import os
|
|
108
|
+
from nitroping.webhooks import verify
|
|
109
|
+
from nitroping.errors import (
|
|
110
|
+
InvalidSignatureError,
|
|
111
|
+
TimestampOutOfRangeError,
|
|
112
|
+
MissingSignatureHeaderError,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def handle_webhook(raw_body: bytes, signature_header: str | None) -> None:
|
|
116
|
+
try:
|
|
117
|
+
event = verify(
|
|
118
|
+
body=raw_body,
|
|
119
|
+
signature=signature_header,
|
|
120
|
+
secret=os.environ["NITROPING_WEBHOOK_SECRET"],
|
|
121
|
+
)
|
|
122
|
+
except (InvalidSignatureError, TimestampOutOfRangeError, MissingSignatureHeaderError):
|
|
123
|
+
# Reject with HTTP 400 — do NOT leak which check failed.
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
if event["type"] == "notification.delivered":
|
|
127
|
+
print("delivered", event["data"]["notification_id"])
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Sync and async
|
|
131
|
+
|
|
132
|
+
`Nitroping` is synchronous and uses only the stdlib (`urllib.request`).
|
|
133
|
+
|
|
134
|
+
`AsyncNitroping` exposes the same API as coroutines. It wraps the sync
|
|
135
|
+
client and runs each call on the default executor via
|
|
136
|
+
`asyncio.get_running_loop().run_in_executor(None, ...)`. This is a
|
|
137
|
+
**best-effort wrapper** — it keeps the SDK zero-dependency and lets you
|
|
138
|
+
`await` from FastAPI / aiohttp / Starlette without surprises, but it is not
|
|
139
|
+
true non-blocking I/O. For high-fanout workloads (thousands of concurrent
|
|
140
|
+
sends) bring your own async HTTP client and call the API directly.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
import asyncio
|
|
144
|
+
from nitroping import AsyncNitroping
|
|
145
|
+
|
|
146
|
+
async def main() -> None:
|
|
147
|
+
np = AsyncNitroping(api_key="np_live_...")
|
|
148
|
+
result = await np.notifications.send(
|
|
149
|
+
title="Hello",
|
|
150
|
+
body="World",
|
|
151
|
+
target={"all": True},
|
|
152
|
+
)
|
|
153
|
+
print(result)
|
|
154
|
+
|
|
155
|
+
asyncio.run(main())
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## API reference
|
|
159
|
+
|
|
160
|
+
### `Nitroping(api_key=None, *, base_url=..., timeout=30.0, user_agent=None)`
|
|
161
|
+
|
|
162
|
+
Creates a synchronous server-side client. `api_key` falls back to the
|
|
163
|
+
`NITROPING_API_KEY` environment variable when omitted.
|
|
164
|
+
|
|
165
|
+
| Argument | Default | Notes |
|
|
166
|
+
| ------------ | -------------------------- | ------------------------------------- |
|
|
167
|
+
| `api_key` | `$NITROPING_API_KEY` | Secret key, format `np_...`. |
|
|
168
|
+
| `base_url` | `"https://nitroping.dev"` | Override for self-hosted / staging. |
|
|
169
|
+
| `timeout` | `30.0` seconds | Per-request socket timeout. |
|
|
170
|
+
| `user_agent` | `"nitroping-python/0.1.0"` | Sent on every request. |
|
|
171
|
+
|
|
172
|
+
### `np.notifications.send(*, target, title=None, body=None, ..., idempotency_key=None)`
|
|
173
|
+
|
|
174
|
+
Enqueues a notification. Returns `{"id": str, "status": str}` (`NotificationResult`).
|
|
175
|
+
Raises `ApiError` on non-2xx, carrying the server's `code`, `message`, and
|
|
176
|
+
per-field `details`.
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
np.notifications.send(
|
|
180
|
+
title="Welcome!",
|
|
181
|
+
body="Glad to have you on board.",
|
|
182
|
+
icon="https://example.com/icon.png",
|
|
183
|
+
image="https://example.com/hero.png",
|
|
184
|
+
deep_link="https://example.com/welcome",
|
|
185
|
+
data={"onboarding": True},
|
|
186
|
+
actions=[{"id": "tour", "title": "Take the tour"}],
|
|
187
|
+
target={"all": True},
|
|
188
|
+
idempotency_key="welcome-user-42",
|
|
189
|
+
)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`target` is one of three shapes (exactly one):
|
|
193
|
+
|
|
194
|
+
| Selector | Use when |
|
|
195
|
+
| ------------------------------ | -------------------------------- |
|
|
196
|
+
| `{"all": True}` | Broadcast to every active device |
|
|
197
|
+
| `{"device_ids": [...]}` | Hit specific device rows |
|
|
198
|
+
| `{"user_ids": [...]}` | Hit every device row a user owns |
|
|
199
|
+
|
|
200
|
+
### `np.notifications.get(notification_id)`
|
|
201
|
+
|
|
202
|
+
Fetches a previously enqueued notification by id. Returns the full row
|
|
203
|
+
(including counters: `total_sent`, `total_delivered`, `total_failed`, etc.).
|
|
204
|
+
|
|
205
|
+
### `np.devices.register(*, platform, token, user_id=None, ...)`
|
|
206
|
+
|
|
207
|
+
Registers (or updates) a device with the **secret** API key. Use this for
|
|
208
|
+
iOS / Android where you control the server. Returns `{"id": str,
|
|
209
|
+
"created": bool}` — `created` is `False` when an existing row matched on
|
|
210
|
+
`(app_id, token, user_id)`.
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
np.devices.register(
|
|
214
|
+
platform="ios",
|
|
215
|
+
token="apns-hex-token",
|
|
216
|
+
user_id="user-42",
|
|
217
|
+
metadata={"app_version": "2.4.1"},
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
For Web Push, also pass `web_push_p256dh` and `web_push_auth` from the
|
|
222
|
+
browser's `PushSubscription`.
|
|
223
|
+
|
|
224
|
+
### `np.devices.deactivate(device_id)`
|
|
225
|
+
|
|
226
|
+
Soft-deletes a device (`status = "inactive"`). Subsequent sends skip it.
|
|
227
|
+
|
|
228
|
+
### `verify(*, body, signature, secret, tolerance=300, now=None)` — `nitroping.webhooks`
|
|
229
|
+
|
|
230
|
+
Verifies the `X-Nitroping-Signature` header and returns the parsed event.
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from nitroping.webhooks import verify
|
|
234
|
+
|
|
235
|
+
event = verify(
|
|
236
|
+
body=raw_body_bytes,
|
|
237
|
+
signature=request.headers.get("x-nitroping-signature"),
|
|
238
|
+
secret=os.environ["NITROPING_WEBHOOK_SECRET"],
|
|
239
|
+
tolerance=300, # optional, seconds. Default 300.
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The signing scheme is HMAC-SHA256 over `"<unix>.<raw body>"`. The header
|
|
244
|
+
ships as `t=<unix>, v1=<hex>` — same as Polar / Stripe. Use the raw
|
|
245
|
+
request body bytes (not a re-serialized parsed dict) or the HMAC won't
|
|
246
|
+
match.
|
|
247
|
+
|
|
248
|
+
## Framework recipes
|
|
249
|
+
|
|
250
|
+
### FastAPI
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
from fastapi import FastAPI, Header, HTTPException, Request
|
|
254
|
+
from nitroping import Nitroping
|
|
255
|
+
from nitroping.errors import (
|
|
256
|
+
InvalidSignatureError,
|
|
257
|
+
MissingSignatureHeaderError,
|
|
258
|
+
TimestampOutOfRangeError,
|
|
259
|
+
)
|
|
260
|
+
from nitroping.webhooks import verify
|
|
261
|
+
import os
|
|
262
|
+
|
|
263
|
+
app = FastAPI()
|
|
264
|
+
np = Nitroping(api_key=os.environ["NITROPING_API_KEY"])
|
|
265
|
+
|
|
266
|
+
@app.post("/notify")
|
|
267
|
+
def notify(title: str, body: str) -> dict[str, str]:
|
|
268
|
+
return np.notifications.send(
|
|
269
|
+
title=title, body=body, target={"all": True}
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
@app.post("/webhooks/nitroping")
|
|
273
|
+
async def webhook(
|
|
274
|
+
request: Request,
|
|
275
|
+
x_nitroping_signature: str | None = Header(default=None),
|
|
276
|
+
) -> dict[str, str]:
|
|
277
|
+
raw = await request.body()
|
|
278
|
+
try:
|
|
279
|
+
event = verify(
|
|
280
|
+
body=raw,
|
|
281
|
+
signature=x_nitroping_signature,
|
|
282
|
+
secret=os.environ["NITROPING_WEBHOOK_SECRET"],
|
|
283
|
+
)
|
|
284
|
+
except (
|
|
285
|
+
InvalidSignatureError,
|
|
286
|
+
MissingSignatureHeaderError,
|
|
287
|
+
TimestampOutOfRangeError,
|
|
288
|
+
):
|
|
289
|
+
raise HTTPException(status_code=400, detail="bad signature")
|
|
290
|
+
return {"received": event["id"]}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Django
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
# settings.py
|
|
297
|
+
NITROPING_API_KEY = os.environ["NITROPING_API_KEY"]
|
|
298
|
+
NITROPING_WEBHOOK_SECRET = os.environ["NITROPING_WEBHOOK_SECRET"]
|
|
299
|
+
|
|
300
|
+
# views.py
|
|
301
|
+
from django.conf import settings
|
|
302
|
+
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, JsonResponse
|
|
303
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
304
|
+
from django.views.decorators.http import require_POST
|
|
305
|
+
from nitroping import Nitroping
|
|
306
|
+
from nitroping.errors import (
|
|
307
|
+
InvalidSignatureError,
|
|
308
|
+
MissingSignatureHeaderError,
|
|
309
|
+
TimestampOutOfRangeError,
|
|
310
|
+
)
|
|
311
|
+
from nitroping.webhooks import verify
|
|
312
|
+
|
|
313
|
+
np = Nitroping(api_key=settings.NITROPING_API_KEY)
|
|
314
|
+
|
|
315
|
+
@csrf_exempt
|
|
316
|
+
@require_POST
|
|
317
|
+
def nitroping_webhook(request: HttpRequest) -> HttpResponse:
|
|
318
|
+
try:
|
|
319
|
+
event = verify(
|
|
320
|
+
body=request.body,
|
|
321
|
+
signature=request.headers.get("X-Nitroping-Signature"),
|
|
322
|
+
secret=settings.NITROPING_WEBHOOK_SECRET,
|
|
323
|
+
)
|
|
324
|
+
except (
|
|
325
|
+
InvalidSignatureError,
|
|
326
|
+
MissingSignatureHeaderError,
|
|
327
|
+
TimestampOutOfRangeError,
|
|
328
|
+
):
|
|
329
|
+
return HttpResponseBadRequest("bad signature")
|
|
330
|
+
# ...handle event...
|
|
331
|
+
return JsonResponse({"received": event["id"]})
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Flask
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
import os
|
|
338
|
+
from flask import Flask, abort, jsonify, request
|
|
339
|
+
from nitroping.errors import (
|
|
340
|
+
InvalidSignatureError,
|
|
341
|
+
MissingSignatureHeaderError,
|
|
342
|
+
TimestampOutOfRangeError,
|
|
343
|
+
)
|
|
344
|
+
from nitroping.webhooks import verify
|
|
345
|
+
|
|
346
|
+
app = Flask(__name__)
|
|
347
|
+
|
|
348
|
+
@app.post("/webhooks/nitroping")
|
|
349
|
+
def nitroping_webhook():
|
|
350
|
+
try:
|
|
351
|
+
event = verify(
|
|
352
|
+
body=request.get_data(), # bytes — do NOT use request.json
|
|
353
|
+
signature=request.headers.get("X-Nitroping-Signature"),
|
|
354
|
+
secret=os.environ["NITROPING_WEBHOOK_SECRET"],
|
|
355
|
+
)
|
|
356
|
+
except (
|
|
357
|
+
InvalidSignatureError,
|
|
358
|
+
MissingSignatureHeaderError,
|
|
359
|
+
TimestampOutOfRangeError,
|
|
360
|
+
):
|
|
361
|
+
abort(400)
|
|
362
|
+
return jsonify(received=event["id"])
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## Errors
|
|
366
|
+
|
|
367
|
+
Every error raised by the SDK extends `NitropingError`. Narrow by
|
|
368
|
+
`isinstance` to handle specific cases:
|
|
369
|
+
|
|
370
|
+
| Class | When it fires |
|
|
371
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------ |
|
|
372
|
+
| `NitropingError` | Base class for every SDK error. Catch this to handle everything. |
|
|
373
|
+
| `ApiError` | The server returned a non-2xx response. Has `status`, `code`, `details`. |
|
|
374
|
+
| `NetworkError` | DNS / TLS / offline / timeout — the request never reached the server. Cause attached. |
|
|
375
|
+
| `InvalidSignatureError` | `verify()` HMAC mismatch or malformed header. |
|
|
376
|
+
| `TimestampOutOfRangeError` | `verify()` signature valid but `t=` outside the tolerance window. |
|
|
377
|
+
| `MissingSignatureHeaderError` | `verify()` called with `signature=None`. |
|
|
378
|
+
|
|
379
|
+
```python
|
|
380
|
+
from nitroping import Nitroping
|
|
381
|
+
from nitroping.errors import ApiError, NetworkError
|
|
382
|
+
|
|
383
|
+
np = Nitroping() # reads NITROPING_API_KEY
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
np.notifications.send(title="Hi", body="There", target={"all": True})
|
|
387
|
+
except NetworkError:
|
|
388
|
+
# transient — retry with backoff
|
|
389
|
+
...
|
|
390
|
+
except ApiError as err:
|
|
391
|
+
if err.code == "quota_exceeded":
|
|
392
|
+
print(err.details) # {"quota": ..., "used": ..., "resets_at": ...}
|
|
393
|
+
else:
|
|
394
|
+
raise
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
## Type hints
|
|
398
|
+
|
|
399
|
+
The package ships `py.typed` (PEP 561) — `mypy`, `pyright`, and `ruff` see
|
|
400
|
+
the public surface as fully typed. Every request shape is a `TypedDict`:
|
|
401
|
+
|
|
402
|
+
```python
|
|
403
|
+
from nitroping import NotificationResult, RegisterDeviceResult, WebhookEvent
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
## Runtime support
|
|
407
|
+
|
|
408
|
+
| Runtime | Status |
|
|
409
|
+
| -------------- | ------ |
|
|
410
|
+
| CPython 3.10 | Yes |
|
|
411
|
+
| CPython 3.11 | Yes |
|
|
412
|
+
| CPython 3.12 | Yes |
|
|
413
|
+
| CPython 3.13 | Yes |
|
|
414
|
+
| PyPy 3.10+ | Should work (untested in CI). No C extensions. |
|
|
415
|
+
|
|
416
|
+
## License
|
|
417
|
+
|
|
418
|
+
[MIT](../LICENSE) — Copyright (c) 2026 productdevbook.
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
<p align="center">
|
|
423
|
+
<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>
|
|
425
|
+
</sub>
|
|
426
|
+
</p>
|