pingloom 0.1.0__py3-none-any.whl

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.
pingloom/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """PingLoom Python client."""
2
+
3
+ from ._version import __version__
4
+ from .client import PingLoom
5
+ from .errors import (
6
+ AuthenticationError,
7
+ ConfigurationError,
8
+ ConflictError,
9
+ InvalidRequestError,
10
+ NetworkError,
11
+ NotFoundError,
12
+ PermissionDeniedError,
13
+ PingLoomError,
14
+ QuotaExceededError,
15
+ RateLimitError,
16
+ ServerError,
17
+ )
18
+
19
+ __all__ = [
20
+ "__version__",
21
+ "PingLoom",
22
+ "PingLoomError",
23
+ "ConfigurationError",
24
+ "NetworkError",
25
+ "InvalidRequestError",
26
+ "AuthenticationError",
27
+ "PermissionDeniedError",
28
+ "NotFoundError",
29
+ "ConflictError",
30
+ "RateLimitError",
31
+ "QuotaExceededError",
32
+ "ServerError",
33
+ ]
pingloom/_mask.py ADDED
@@ -0,0 +1,25 @@
1
+ """Key masking. The API key must never appear in logs, errors or reprs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Optional
7
+
8
+ _KEY_RE = re.compile(r"pl_(?:live|test)_[A-Za-z0-9_\-]{4,}")
9
+
10
+
11
+ def mask_key(key: Optional[str]) -> str:
12
+ """Return `pl_live_****abcd` for a key, or `****` when it does not look like one."""
13
+ if not key:
14
+ return "(no key)"
15
+ m = re.match(r"^(pl_(?:live|test)_)", key)
16
+ if m and len(key) >= len(m.group(1)) + 8:
17
+ return f"{m.group(1)}****{key[-4:]}"
18
+ return "****"
19
+
20
+
21
+ def scrub(text: str, key: Optional[str] = None) -> str:
22
+ """Mask the known key and anything shaped like a PingLoom key inside free text."""
23
+ if key:
24
+ text = text.replace(key, mask_key(key))
25
+ return _KEY_RE.sub(lambda m: mask_key(m.group(0)), text)
pingloom/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
pingloom/client.py ADDED
@@ -0,0 +1,171 @@
1
+ """PingLoom client. Standard library only (urllib)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import random
8
+ import time
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ import uuid
13
+ from typing import Any, Callable, Dict, Mapping, Optional
14
+
15
+ from ._mask import mask_key, scrub
16
+ from ._version import __version__
17
+ from .errors import ConfigurationError, NetworkError, PingLoomError, error_for
18
+
19
+ # TODO(T07/T08): add heartbeat() and decision helpers here once those endpoints are merged.
20
+
21
+ _RETRY_CAP_SECONDS = 30.0
22
+
23
+
24
+ DEFAULT_BASE_URL = "https://api.pingloom.dev"
25
+
26
+ class PingLoom:
27
+ """Server-side client. Never use it in browser or mobile code: the key must stay on your server.
28
+
29
+ `api_key` defaults to $PINGLOOM_API_KEY and `base_url` to $PINGLOOM_BASE_URL, else the hosted API.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ api_key: Optional[str] = None,
35
+ base_url: Optional[str] = None,
36
+ *,
37
+ timeout: float = 10.0,
38
+ max_retries: int = 3,
39
+ backoff: float = 0.5,
40
+ _sleep: Callable[[float], None] = time.sleep,
41
+ ) -> None:
42
+ self._api_key = api_key or os.environ.get("PINGLOOM_API_KEY") or ""
43
+ url = base_url or os.environ.get("PINGLOOM_BASE_URL") or DEFAULT_BASE_URL
44
+ if not self._api_key:
45
+ raise ConfigurationError("No API key. Pass api_key or set PINGLOOM_API_KEY.")
46
+ self.base_url = url.rstrip("/")
47
+ self.timeout = timeout
48
+ self.max_retries = max(0, max_retries)
49
+ self.backoff = backoff
50
+ self._sleep = _sleep
51
+
52
+ def __repr__(self) -> str:
53
+ return f"PingLoom(api_key={mask_key(self._api_key)!r}, base_url={self.base_url!r})"
54
+
55
+ # -- public API ---------------------------------------------------------------------------
56
+
57
+ def send(
58
+ self,
59
+ title: str,
60
+ body: Optional[str] = None,
61
+ channel: Optional[str] = None,
62
+ priority: Optional[str] = None,
63
+ data: Optional[Mapping[str, Any]] = None,
64
+ action_url: Optional[str] = None,
65
+ idempotency_key: Optional[str] = None,
66
+ ) -> str:
67
+ """Send a notification and return the event ID.
68
+
69
+ Without `idempotency_key` a random one is generated per call so that automatic retries cannot create
70
+ duplicates. Derive your own key from the business event (for example "order-1042-paid") when you also
71
+ want repeated calls of your own to be safe.
72
+ """
73
+ payload: Dict[str, Any] = {"title": title}
74
+ for name, value in (
75
+ ("body", body),
76
+ ("channel", channel),
77
+ ("priority", priority),
78
+ ("data", dict(data) if data is not None else None),
79
+ ("action_url", action_url),
80
+ ):
81
+ if value is not None:
82
+ payload[name] = value
83
+ key = idempotency_key or uuid.uuid4().hex
84
+ result = self._request("POST", "/v1/notifications", payload, {"Idempotency-Key": key})
85
+ return str(result["event_id"])
86
+
87
+ def status(self, event_id: str) -> Dict[str, Any]:
88
+ """Read an event back. The key needs the events:read scope."""
89
+ if not event_id or "/" in event_id:
90
+ raise ValueError("event_id must be a non-empty event ID")
91
+ return self._request("GET", f"/v1/notifications/{urllib.parse.quote(event_id, safe='')}", None, {})
92
+
93
+ # -- internals ----------------------------------------------------------------------------
94
+
95
+ def _request(self, method: str, path: str, payload: Optional[dict], extra: Dict[str, str]) -> Dict[str, Any]:
96
+ headers = {
97
+ "Authorization": f"Bearer {self._api_key}",
98
+ "Accept": "application/json",
99
+ "User-Agent": f"pingloom-python/{__version__}",
100
+ **extra,
101
+ }
102
+ raw = None
103
+ if payload is not None:
104
+ raw = json.dumps(payload).encode("utf-8")
105
+ headers["Content-Type"] = "application/json"
106
+ attempt = 0
107
+ while True:
108
+ outcome = self._once(method, path, raw, headers)
109
+ if not isinstance(outcome, PingLoomError):
110
+ return outcome
111
+ if attempt >= self.max_retries or not _retryable(outcome):
112
+ raise outcome
113
+ self._sleep(self._delay(attempt, outcome))
114
+ attempt += 1
115
+
116
+ def _once(self, method: str, path: str, raw: Optional[bytes], headers: Dict[str, str]) -> Any:
117
+ req = urllib.request.Request(self.base_url + path, data=raw, method=method, headers=headers)
118
+ try:
119
+ with urllib.request.urlopen(req, timeout=self.timeout) as res: # noqa: S310 - the scheme is the caller's
120
+ return _parse(res.read())
121
+ except urllib.error.HTTPError as e:
122
+ return self._http_error(e)
123
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as e:
124
+ return NetworkError(scrub(f"Could not reach PingLoom: {getattr(e, 'reason', e)}", self._api_key))
125
+
126
+ def _http_error(self, e: urllib.error.HTTPError) -> PingLoomError:
127
+ body = _parse(e.read(), strict=False)
128
+ env = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), dict) else {}
129
+ code = str(env.get("code") or "UNKNOWN")
130
+ message = scrub(str(env.get("message") or e.reason or f"HTTP {e.code}"), self._api_key)
131
+ retry_after = _float(e.headers.get("Retry-After")) if e.headers else None
132
+ cls = error_for(e.code, code)
133
+ return cls(
134
+ message,
135
+ status=e.code,
136
+ code=code,
137
+ request_id=env.get("request_id"),
138
+ details=env.get("details") if isinstance(env.get("details"), list) else None,
139
+ retry_after=retry_after,
140
+ )
141
+
142
+ def _delay(self, attempt: int, error: PingLoomError) -> float:
143
+ if error.retry_after is not None:
144
+ return min(max(error.retry_after, 0.0), _RETRY_CAP_SECONDS)
145
+ return min(self.backoff * (2**attempt) * (0.5 + random.random() / 2), _RETRY_CAP_SECONDS)
146
+
147
+
148
+ def _retryable(error: PingLoomError) -> bool:
149
+ if error.status == 0:
150
+ return not isinstance(error, ConfigurationError) # network error
151
+ if error.status == 429:
152
+ return error.code != "QUOTA_EXCEEDED"
153
+ return error.status >= 500
154
+
155
+
156
+ def _parse(raw: bytes, strict: bool = True) -> Any:
157
+ if not raw:
158
+ return {}
159
+ try:
160
+ return json.loads(raw.decode("utf-8"))
161
+ except (ValueError, UnicodeDecodeError):
162
+ if strict:
163
+ raise PingLoomError("PingLoom returned a response that is not JSON.", code="BAD_RESPONSE") from None
164
+ return {}
165
+
166
+
167
+ def _float(v: Optional[str]) -> Optional[float]:
168
+ try:
169
+ return float(v) if v is not None else None
170
+ except ValueError:
171
+ return None
pingloom/errors.py ADDED
@@ -0,0 +1,93 @@
1
+ """Typed exceptions mapped from the PingLoom error envelope {"error": {"code", "message", "request_id"}}."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, List, Optional
6
+
7
+
8
+ class PingLoomError(Exception):
9
+ """Base class. `status` is 0 for errors that never reached the API."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ status: int = 0,
16
+ code: str = "UNKNOWN",
17
+ request_id: Optional[str] = None,
18
+ details: Optional[List[Any]] = None,
19
+ retry_after: Optional[float] = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.status = status
24
+ self.code = code
25
+ self.request_id = request_id
26
+ self.details = details or []
27
+ self.retry_after = retry_after
28
+
29
+ def __str__(self) -> str:
30
+ parts = [self.message]
31
+ if self.code != "UNKNOWN":
32
+ parts.append(f"[{self.code}]")
33
+ if self.request_id:
34
+ parts.append(f"(request {self.request_id})")
35
+ return " ".join(parts)
36
+
37
+
38
+ class ConfigurationError(PingLoomError):
39
+ """Missing API key or base URL. Raised before any request is made."""
40
+
41
+
42
+ class NetworkError(PingLoomError):
43
+ """The request could not reach the API (DNS, connection, timeout)."""
44
+
45
+
46
+ class InvalidRequestError(PingLoomError):
47
+ """400, 413, 415 or 422: fix the request. See `details`."""
48
+
49
+
50
+ class AuthenticationError(PingLoomError):
51
+ """401: the key is missing, unknown, revoked or expired."""
52
+
53
+
54
+ class PermissionDeniedError(PingLoomError):
55
+ """403: the key lacks the needed scope."""
56
+
57
+
58
+ class NotFoundError(PingLoomError):
59
+ """404."""
60
+
61
+
62
+ class ConflictError(PingLoomError):
63
+ """409: IDEMPOTENCY_CONFLICT (same key, different body) or PROJECT_ARCHIVED. Do not retry."""
64
+
65
+
66
+ class RateLimitError(PingLoomError):
67
+ """429 RATE_LIMITED. Retried automatically; raised after the retries run out."""
68
+
69
+
70
+ class QuotaExceededError(PingLoomError):
71
+ """429 QUOTA_EXCEEDED: the monthly quota is used. Never retried."""
72
+
73
+
74
+ class ServerError(PingLoomError):
75
+ """5xx. Retried with the same idempotency key; raised after the retries run out."""
76
+
77
+
78
+ def error_for(status: int, code: str) -> type:
79
+ if status == 429:
80
+ return QuotaExceededError if code == "QUOTA_EXCEEDED" else RateLimitError
81
+ if status == 401:
82
+ return AuthenticationError
83
+ if status == 403:
84
+ return PermissionDeniedError
85
+ if status == 404:
86
+ return NotFoundError
87
+ if status == 409:
88
+ return ConflictError
89
+ if status >= 500:
90
+ return ServerError
91
+ if 400 <= status < 500:
92
+ return InvalidRequestError
93
+ return PingLoomError
pingloom/py.typed ADDED
File without changes
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.5
2
+ Name: pingloom
3
+ Version: 0.1.0
4
+ Summary: Python client for the PingLoom notification API (server-side only).
5
+ Project-URL: Homepage, https://pingloom.dev
6
+ Author: Srish Rachamalla
7
+ License: MIT
8
+ Keywords: alerts,notifications,on-call,pingloom,push,webhook
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.9
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=8; extra == 'test'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # pingloom (Python)
18
+
19
+ Python client for the [PingLoom](https://pingloom.dev) notification API. Standard library only, Python 3.9+.
20
+
21
+ Server-side only: the API key (`pl_live_...` or `pl_test_...`) must never go into browser or mobile code.
22
+
23
+ ```bash
24
+ pip install pingloom
25
+ export PINGLOOM_API_KEY=pl_test_...
26
+ ```
27
+
28
+ ```python
29
+ from pingloom import PingLoom
30
+
31
+ pl = PingLoom() # reads PINGLOOM_API_KEY; talks to https://api.pingloom.dev (override with PINGLOOM_BASE_URL)
32
+ event_id = pl.send(
33
+ "Backup finished", body="42 GB in 3 min", channel="ops", priority="high", idempotency_key="backup-2026-10-30"
34
+ )
35
+ print(pl.status(event_id)["status"]) # provider_accepted means FCM accepted it, not that a phone got it
36
+ ```
37
+
38
+ `send(title, body=None, channel=None, priority=None, data=None, action_url=None, idempotency_key=None)` returns the event ID. `status(event_id)` needs the `events:read` scope.
39
+
40
+ ## Errors and retries
41
+
42
+ Every API error is a typed exception carrying `status`, `code`, `message`, `request_id` and `details`:
43
+
44
+ | Exception | When |
45
+ | --- | --- |
46
+ | `InvalidRequestError` | 400, 413, 415, 422 |
47
+ | `AuthenticationError` | 401 |
48
+ | `PermissionDeniedError` | 403 |
49
+ | `NotFoundError` | 404 |
50
+ | `ConflictError` | 409 (`IDEMPOTENCY_CONFLICT`, `PROJECT_ARCHIVED`) |
51
+ | `RateLimitError` | 429 `RATE_LIMITED`, after retries |
52
+ | `QuotaExceededError` | 429 `QUOTA_EXCEEDED`, never retried |
53
+ | `ServerError` | 5xx, after retries |
54
+ | `NetworkError` | no response, after retries |
55
+ | `ConfigurationError` | missing key or base URL |
56
+
57
+ `429 RATE_LIMITED`, `5xx` and network errors are retried (default 3 times) with exponential backoff, or `Retry-After` when sent. Retries reuse the same `Idempotency-Key`, so they cannot create duplicates. When you do not pass `idempotency_key`, a random one is generated per call. Pass your own, derived from the business event, to make repeated calls of your own safe too.
58
+
59
+ The key is never logged: `repr(client)` and error text show `pl_live_****abcd`.
60
+
61
+ ## Develop
62
+
63
+ ```bash
64
+ pip install pytest
65
+ python -m pytest
66
+ ```
@@ -0,0 +1,9 @@
1
+ pingloom/__init__.py,sha256=ap_AcBJ3KTFMGf6uCa2FG3Ug4QGbuT4Dyt5q-jl2YqE,664
2
+ pingloom/_mask.py,sha256=Vmge87ijCYrcU-TdLNNk25QMiSfJ_1frJ6mB7nqtFkM,808
3
+ pingloom/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
4
+ pingloom/client.py,sha256=PAV6lhDa8GYN_rFHV14wiHE_kouxK5N7urnQNkC0cbU,6897
5
+ pingloom/errors.py,sha256=lUWimEUBZlsZbkTpIzdm0Ljbxgw9C29TNzuTl2qdavk,2648
6
+ pingloom/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pingloom-0.1.0.dist-info/METADATA,sha256=qkC0u_uYUlI0Bnoheehk6eNkbutNfT8F_HVzTlpYByU,2552
8
+ pingloom-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ pingloom-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any