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.
@@ -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
@@ -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>