trigv 1.0.0__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.
- trigv-1.0.0/.gitignore +16 -0
- trigv-1.0.0/LICENSE +21 -0
- trigv-1.0.0/PKG-INFO +256 -0
- trigv-1.0.0/README.md +230 -0
- trigv-1.0.0/pyproject.toml +61 -0
- trigv-1.0.0/src/trigv/__init__.py +57 -0
- trigv-1.0.0/src/trigv/client.py +298 -0
- trigv-1.0.0/src/trigv/constants.py +25 -0
- trigv-1.0.0/src/trigv/errors.py +87 -0
- trigv-1.0.0/src/trigv/py.typed +0 -0
- trigv-1.0.0/src/trigv/retry.py +33 -0
- trigv-1.0.0/src/trigv/types.py +96 -0
- trigv-1.0.0/src/trigv/validation.py +99 -0
trigv-1.0.0/.gitignore
ADDED
trigv-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Trigv
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
trigv-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trigv
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Trigv SDK for Python — send notification events to your workspace
|
|
5
|
+
Project-URL: Homepage, https://trigv.com
|
|
6
|
+
Project-URL: Repository, https://github.com/Trigv/trigv-python
|
|
7
|
+
Project-URL: Documentation, https://github.com/Trigv/trigv-python#readme
|
|
8
|
+
Author: Trigv
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: notifications,push,sdk,trigv
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: black>=24.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Trigv Python SDK
|
|
28
|
+
|
|
29
|
+
Official Python client for the [Trigv](https://trigv.com) workspace ingest API. Send push notification events from scripts, cron jobs, CI pipelines, and backend services.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install trigv
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Requires Python 3.10 or newer. No runtime dependencies beyond the standard library.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from trigv import Trigv
|
|
43
|
+
|
|
44
|
+
trigv = Trigv()
|
|
45
|
+
|
|
46
|
+
result = trigv.send_event(
|
|
47
|
+
{
|
|
48
|
+
"channel": "general",
|
|
49
|
+
"title": "Hello from Python",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
print(result.event.public_id)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Authentication
|
|
57
|
+
|
|
58
|
+
Create a workspace ingest API key in the Trigv dashboard. Keys look like `trgv_a1b2c3d4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`.
|
|
59
|
+
|
|
60
|
+
Pass the key when constructing the client, or set the `TRIGV_API_KEY` environment variable:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from trigv import Trigv
|
|
64
|
+
|
|
65
|
+
trigv = Trigv(api_key="trgv_your_api_key")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Never commit API keys to source control. The SDK does not include your key in error messages or logs.
|
|
69
|
+
|
|
70
|
+
## Sending an event
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
result = trigv.send_event(
|
|
74
|
+
{
|
|
75
|
+
"channel": "deploys",
|
|
76
|
+
"title": "Production deploy complete",
|
|
77
|
+
"description": "Build #42 succeeded in 38s (commit abc1234)",
|
|
78
|
+
"level": "success",
|
|
79
|
+
"event_type": "deploy.completed",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
print(result.event.public_id, result.event.status)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`send_event()` returns a `SendEventResult` with:
|
|
87
|
+
|
|
88
|
+
- `event` — server metadata (`public_id`, `status`, `target_device_count`, etc.)
|
|
89
|
+
- `duplicate` — `True` when the same `idempotency_key` was already accepted (HTTP 200)
|
|
90
|
+
|
|
91
|
+
Title, description, and image URL are delivered to devices but are **not** stored server-side.
|
|
92
|
+
|
|
93
|
+
## Event options
|
|
94
|
+
|
|
95
|
+
| Field | Required | Description |
|
|
96
|
+
|-------|:--------:|-------------|
|
|
97
|
+
| `channel` | Yes | Channel slug (e.g. `general`, `deploys`) |
|
|
98
|
+
| `title` | Yes | Notification title |
|
|
99
|
+
| `description` | No | Body text (max 1000 characters) |
|
|
100
|
+
| `image_url` | No | HTTPS image URL (max 2048 characters) |
|
|
101
|
+
| `url` | No | Destination URL for the notification (max 2048 characters; not stored server-side) |
|
|
102
|
+
| `level` | No | `info`, `success`, `warning`, or `error` (default: `info`) |
|
|
103
|
+
| `delivery_urgency` | No | `standard` or `time_sensitive` (default: `standard`) |
|
|
104
|
+
| `event_type` | No | Free-form label (e.g. `deploy.completed`) |
|
|
105
|
+
| `idempotency_key` | No | Dedup key per workspace (max 190 characters) |
|
|
106
|
+
|
|
107
|
+
Prohibited fields: `api_key` (use the `Authorization` header), `icon`.
|
|
108
|
+
|
|
109
|
+
## Levels
|
|
110
|
+
|
|
111
|
+
| Level | Use case |
|
|
112
|
+
|-------|----------|
|
|
113
|
+
| `info` | General updates (default) |
|
|
114
|
+
| `success` | Completed jobs, successful deploys |
|
|
115
|
+
| `warning` | Degraded service, retries |
|
|
116
|
+
| `error` | Failures requiring attention |
|
|
117
|
+
|
|
118
|
+
## Delivery urgency
|
|
119
|
+
|
|
120
|
+
| Value | Behaviour |
|
|
121
|
+
|-------|-----------|
|
|
122
|
+
| `standard` | Normal delivery (default) |
|
|
123
|
+
| `time_sensitive` | iOS time-sensitive interruption level |
|
|
124
|
+
|
|
125
|
+
## Idempotency
|
|
126
|
+
|
|
127
|
+
Set `idempotency_key` to safely retry delivery without creating duplicate events or billing twice:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
result = trigv.send_event(
|
|
131
|
+
{
|
|
132
|
+
"channel": "deploys",
|
|
133
|
+
"title": "Production deploy complete",
|
|
134
|
+
"idempotency_key": "deploy-prod-42",
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if result.duplicate:
|
|
139
|
+
print("Already sent:", result.event.public_id)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- First request with a key → HTTP 202, `duplicate=False`, usage billed
|
|
143
|
+
- Retry with the same key → HTTP 200, `duplicate=True`, no additional usage
|
|
144
|
+
|
|
145
|
+
Without an `idempotency_key`, the SDK does not auto-retry `send_event()` on ambiguous failures.
|
|
146
|
+
|
|
147
|
+
## Error handling
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from trigv import (
|
|
151
|
+
AuthenticationError,
|
|
152
|
+
AuthorizationError,
|
|
153
|
+
NotFoundError,
|
|
154
|
+
RateLimitError,
|
|
155
|
+
Trigv,
|
|
156
|
+
ValidationError,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
trigv = Trigv()
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
trigv.send_event({"channel": "general", "title": "Hello"})
|
|
163
|
+
except ValidationError as exc:
|
|
164
|
+
print(exc.errors)
|
|
165
|
+
except AuthenticationError:
|
|
166
|
+
print("Check your API key")
|
|
167
|
+
except NotFoundError:
|
|
168
|
+
print("Channel does not exist")
|
|
169
|
+
except RateLimitError as exc:
|
|
170
|
+
if exc.retryable:
|
|
171
|
+
print("Per-minute limit — retry shortly")
|
|
172
|
+
else:
|
|
173
|
+
print("Monthly workspace cap reached")
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
| Exception | When |
|
|
177
|
+
|-----------|------|
|
|
178
|
+
| `ValidationError` | Client-side validation or HTTP 422 |
|
|
179
|
+
| `AuthenticationError` | HTTP 401 |
|
|
180
|
+
| `AuthorizationError` | HTTP 403 |
|
|
181
|
+
| `NotFoundError` | HTTP 404 (e.g. unknown channel) |
|
|
182
|
+
| `RateLimitError` | HTTP 429 (`retryable` distinguishes burst vs monthly cap) |
|
|
183
|
+
| `ApiError` | Other HTTP errors |
|
|
184
|
+
| `NetworkError` | Connection failure |
|
|
185
|
+
| `TimeoutError` | Request timeout |
|
|
186
|
+
|
|
187
|
+
## Configuration
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
trigv = Trigv(
|
|
191
|
+
api_key="trgv_your_api_key",
|
|
192
|
+
base_url="https://api.trigv.com/api", # default
|
|
193
|
+
timeout=30.0, # seconds
|
|
194
|
+
max_retries=2, # retryable errors only
|
|
195
|
+
)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
For local development with [Laravel Herd](https://herd.laravel.com):
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
trigv = Trigv(base_url="http://trigv-platform.test/api")
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Verify connection
|
|
205
|
+
|
|
206
|
+
Check that your API key is valid without sending an event or consuming usage:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
connection = trigv.verify_connection()
|
|
210
|
+
print(connection.workspace.name)
|
|
211
|
+
print(connection.api_key.prefix)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Examples
|
|
215
|
+
|
|
216
|
+
See the [`examples/`](examples/) directory:
|
|
217
|
+
|
|
218
|
+
- [`basic.py`](examples/basic.py) — minimal event
|
|
219
|
+
- [`deploy-completed.py`](examples/deploy-completed.py) — canonical deploy notification
|
|
220
|
+
- [`cron-failed.py`](examples/cron-failed.py) — scheduled job failure
|
|
221
|
+
- [`idempotency.py`](examples/idempotency.py) — safe retries with idempotency keys
|
|
222
|
+
|
|
223
|
+
## Development
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
git clone https://github.com/Trigv/trigv-python.git
|
|
227
|
+
cd trigv-python
|
|
228
|
+
python -m venv .venv
|
|
229
|
+
source .venv/bin/activate
|
|
230
|
+
pip install -e ".[dev]"
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Or with PEP 735 dependency groups:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
pip install pytest ruff black hatchling
|
|
237
|
+
pip install -e .
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Testing
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
pytest
|
|
244
|
+
ruff check src tests examples
|
|
245
|
+
black --check src tests examples
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Tests use a mocked HTTP transport — no live API key required.
|
|
249
|
+
|
|
250
|
+
## Contributing
|
|
251
|
+
|
|
252
|
+
Contributions are welcome. Please open an issue or pull request on [GitHub](https://github.com/Trigv/trigv-python).
|
|
253
|
+
|
|
254
|
+
## Licence
|
|
255
|
+
|
|
256
|
+
MIT — see [LICENSE](LICENSE).
|
trigv-1.0.0/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Trigv Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Trigv](https://trigv.com) workspace ingest API. Send push notification events from scripts, cron jobs, CI pipelines, and backend services.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install trigv
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python 3.10 or newer. No runtime dependencies beyond the standard library.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from trigv import Trigv
|
|
17
|
+
|
|
18
|
+
trigv = Trigv()
|
|
19
|
+
|
|
20
|
+
result = trigv.send_event(
|
|
21
|
+
{
|
|
22
|
+
"channel": "general",
|
|
23
|
+
"title": "Hello from Python",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
print(result.event.public_id)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Authentication
|
|
31
|
+
|
|
32
|
+
Create a workspace ingest API key in the Trigv dashboard. Keys look like `trgv_a1b2c3d4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`.
|
|
33
|
+
|
|
34
|
+
Pass the key when constructing the client, or set the `TRIGV_API_KEY` environment variable:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from trigv import Trigv
|
|
38
|
+
|
|
39
|
+
trigv = Trigv(api_key="trgv_your_api_key")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Never commit API keys to source control. The SDK does not include your key in error messages or logs.
|
|
43
|
+
|
|
44
|
+
## Sending an event
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
result = trigv.send_event(
|
|
48
|
+
{
|
|
49
|
+
"channel": "deploys",
|
|
50
|
+
"title": "Production deploy complete",
|
|
51
|
+
"description": "Build #42 succeeded in 38s (commit abc1234)",
|
|
52
|
+
"level": "success",
|
|
53
|
+
"event_type": "deploy.completed",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
print(result.event.public_id, result.event.status)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`send_event()` returns a `SendEventResult` with:
|
|
61
|
+
|
|
62
|
+
- `event` — server metadata (`public_id`, `status`, `target_device_count`, etc.)
|
|
63
|
+
- `duplicate` — `True` when the same `idempotency_key` was already accepted (HTTP 200)
|
|
64
|
+
|
|
65
|
+
Title, description, and image URL are delivered to devices but are **not** stored server-side.
|
|
66
|
+
|
|
67
|
+
## Event options
|
|
68
|
+
|
|
69
|
+
| Field | Required | Description |
|
|
70
|
+
|-------|:--------:|-------------|
|
|
71
|
+
| `channel` | Yes | Channel slug (e.g. `general`, `deploys`) |
|
|
72
|
+
| `title` | Yes | Notification title |
|
|
73
|
+
| `description` | No | Body text (max 1000 characters) |
|
|
74
|
+
| `image_url` | No | HTTPS image URL (max 2048 characters) |
|
|
75
|
+
| `url` | No | Destination URL for the notification (max 2048 characters; not stored server-side) |
|
|
76
|
+
| `level` | No | `info`, `success`, `warning`, or `error` (default: `info`) |
|
|
77
|
+
| `delivery_urgency` | No | `standard` or `time_sensitive` (default: `standard`) |
|
|
78
|
+
| `event_type` | No | Free-form label (e.g. `deploy.completed`) |
|
|
79
|
+
| `idempotency_key` | No | Dedup key per workspace (max 190 characters) |
|
|
80
|
+
|
|
81
|
+
Prohibited fields: `api_key` (use the `Authorization` header), `icon`.
|
|
82
|
+
|
|
83
|
+
## Levels
|
|
84
|
+
|
|
85
|
+
| Level | Use case |
|
|
86
|
+
|-------|----------|
|
|
87
|
+
| `info` | General updates (default) |
|
|
88
|
+
| `success` | Completed jobs, successful deploys |
|
|
89
|
+
| `warning` | Degraded service, retries |
|
|
90
|
+
| `error` | Failures requiring attention |
|
|
91
|
+
|
|
92
|
+
## Delivery urgency
|
|
93
|
+
|
|
94
|
+
| Value | Behaviour |
|
|
95
|
+
|-------|-----------|
|
|
96
|
+
| `standard` | Normal delivery (default) |
|
|
97
|
+
| `time_sensitive` | iOS time-sensitive interruption level |
|
|
98
|
+
|
|
99
|
+
## Idempotency
|
|
100
|
+
|
|
101
|
+
Set `idempotency_key` to safely retry delivery without creating duplicate events or billing twice:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
result = trigv.send_event(
|
|
105
|
+
{
|
|
106
|
+
"channel": "deploys",
|
|
107
|
+
"title": "Production deploy complete",
|
|
108
|
+
"idempotency_key": "deploy-prod-42",
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if result.duplicate:
|
|
113
|
+
print("Already sent:", result.event.public_id)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- First request with a key → HTTP 202, `duplicate=False`, usage billed
|
|
117
|
+
- Retry with the same key → HTTP 200, `duplicate=True`, no additional usage
|
|
118
|
+
|
|
119
|
+
Without an `idempotency_key`, the SDK does not auto-retry `send_event()` on ambiguous failures.
|
|
120
|
+
|
|
121
|
+
## Error handling
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from trigv import (
|
|
125
|
+
AuthenticationError,
|
|
126
|
+
AuthorizationError,
|
|
127
|
+
NotFoundError,
|
|
128
|
+
RateLimitError,
|
|
129
|
+
Trigv,
|
|
130
|
+
ValidationError,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
trigv = Trigv()
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
trigv.send_event({"channel": "general", "title": "Hello"})
|
|
137
|
+
except ValidationError as exc:
|
|
138
|
+
print(exc.errors)
|
|
139
|
+
except AuthenticationError:
|
|
140
|
+
print("Check your API key")
|
|
141
|
+
except NotFoundError:
|
|
142
|
+
print("Channel does not exist")
|
|
143
|
+
except RateLimitError as exc:
|
|
144
|
+
if exc.retryable:
|
|
145
|
+
print("Per-minute limit — retry shortly")
|
|
146
|
+
else:
|
|
147
|
+
print("Monthly workspace cap reached")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
| Exception | When |
|
|
151
|
+
|-----------|------|
|
|
152
|
+
| `ValidationError` | Client-side validation or HTTP 422 |
|
|
153
|
+
| `AuthenticationError` | HTTP 401 |
|
|
154
|
+
| `AuthorizationError` | HTTP 403 |
|
|
155
|
+
| `NotFoundError` | HTTP 404 (e.g. unknown channel) |
|
|
156
|
+
| `RateLimitError` | HTTP 429 (`retryable` distinguishes burst vs monthly cap) |
|
|
157
|
+
| `ApiError` | Other HTTP errors |
|
|
158
|
+
| `NetworkError` | Connection failure |
|
|
159
|
+
| `TimeoutError` | Request timeout |
|
|
160
|
+
|
|
161
|
+
## Configuration
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
trigv = Trigv(
|
|
165
|
+
api_key="trgv_your_api_key",
|
|
166
|
+
base_url="https://api.trigv.com/api", # default
|
|
167
|
+
timeout=30.0, # seconds
|
|
168
|
+
max_retries=2, # retryable errors only
|
|
169
|
+
)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
For local development with [Laravel Herd](https://herd.laravel.com):
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
trigv = Trigv(base_url="http://trigv-platform.test/api")
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Verify connection
|
|
179
|
+
|
|
180
|
+
Check that your API key is valid without sending an event or consuming usage:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
connection = trigv.verify_connection()
|
|
184
|
+
print(connection.workspace.name)
|
|
185
|
+
print(connection.api_key.prefix)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Examples
|
|
189
|
+
|
|
190
|
+
See the [`examples/`](examples/) directory:
|
|
191
|
+
|
|
192
|
+
- [`basic.py`](examples/basic.py) — minimal event
|
|
193
|
+
- [`deploy-completed.py`](examples/deploy-completed.py) — canonical deploy notification
|
|
194
|
+
- [`cron-failed.py`](examples/cron-failed.py) — scheduled job failure
|
|
195
|
+
- [`idempotency.py`](examples/idempotency.py) — safe retries with idempotency keys
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
git clone https://github.com/Trigv/trigv-python.git
|
|
201
|
+
cd trigv-python
|
|
202
|
+
python -m venv .venv
|
|
203
|
+
source .venv/bin/activate
|
|
204
|
+
pip install -e ".[dev]"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Or with PEP 735 dependency groups:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
pip install pytest ruff black hatchling
|
|
211
|
+
pip install -e .
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Testing
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
pytest
|
|
218
|
+
ruff check src tests examples
|
|
219
|
+
black --check src tests examples
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Tests use a mocked HTTP transport — no live API key required.
|
|
223
|
+
|
|
224
|
+
## Contributing
|
|
225
|
+
|
|
226
|
+
Contributions are welcome. Please open an issue or pull request on [GitHub](https://github.com/Trigv/trigv-python).
|
|
227
|
+
|
|
228
|
+
## Licence
|
|
229
|
+
|
|
230
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trigv"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Trigv SDK for Python — send notification events to your workspace"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Trigv" }]
|
|
13
|
+
keywords = ["trigv", "notifications", "push", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://trigv.com"
|
|
28
|
+
Repository = "https://github.com/Trigv/trigv-python"
|
|
29
|
+
Documentation = "https://github.com/Trigv/trigv-python#readme"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/trigv"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
include = ["src/trigv", "README.md", "LICENSE"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
pythonpath = ["src"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
target-version = "py310"
|
|
43
|
+
line-length = 100
|
|
44
|
+
src = ["src", "tests", "examples"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
48
|
+
|
|
49
|
+
[tool.black]
|
|
50
|
+
line-length = 100
|
|
51
|
+
target-version = ["py310", "py311", "py312"]
|
|
52
|
+
|
|
53
|
+
[project.optional-dependencies]
|
|
54
|
+
dev = ["black>=24.0", "pytest>=8.0", "ruff>=0.8"]
|
|
55
|
+
|
|
56
|
+
[dependency-groups]
|
|
57
|
+
dev = [
|
|
58
|
+
"black>=24.0",
|
|
59
|
+
"pytest>=8.0",
|
|
60
|
+
"ruff>=0.8",
|
|
61
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Official Trigv SDK for Python."""
|
|
2
|
+
|
|
3
|
+
from trigv.client import Trigv
|
|
4
|
+
from trigv.constants import (
|
|
5
|
+
DEFAULT_BASE_URL,
|
|
6
|
+
DELIVERY_URGENCIES,
|
|
7
|
+
LEVELS,
|
|
8
|
+
VERSION,
|
|
9
|
+
)
|
|
10
|
+
from trigv.errors import (
|
|
11
|
+
ApiError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
AuthorizationError,
|
|
14
|
+
NetworkError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
TimeoutError,
|
|
18
|
+
TrigvError,
|
|
19
|
+
ValidationError,
|
|
20
|
+
)
|
|
21
|
+
from trigv.types import (
|
|
22
|
+
ConnectionApiKey,
|
|
23
|
+
ConnectionResult,
|
|
24
|
+
ConnectionWorkspace,
|
|
25
|
+
DeliveryUrgency,
|
|
26
|
+
Event,
|
|
27
|
+
NotificationLevel,
|
|
28
|
+
SendEventParams,
|
|
29
|
+
SendEventResult,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"ApiError",
|
|
34
|
+
"AuthenticationError",
|
|
35
|
+
"AuthorizationError",
|
|
36
|
+
"ConnectionApiKey",
|
|
37
|
+
"ConnectionResult",
|
|
38
|
+
"ConnectionWorkspace",
|
|
39
|
+
"DEFAULT_BASE_URL",
|
|
40
|
+
"DELIVERY_URGENCIES",
|
|
41
|
+
"DeliveryUrgency",
|
|
42
|
+
"Event",
|
|
43
|
+
"LEVELS",
|
|
44
|
+
"NetworkError",
|
|
45
|
+
"NotFoundError",
|
|
46
|
+
"NotificationLevel",
|
|
47
|
+
"RateLimitError",
|
|
48
|
+
"SendEventParams",
|
|
49
|
+
"SendEventResult",
|
|
50
|
+
"TimeoutError",
|
|
51
|
+
"Trigv",
|
|
52
|
+
"TrigvError",
|
|
53
|
+
"ValidationError",
|
|
54
|
+
"VERSION",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
__version__ = VERSION
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""HTTP client for the Trigv ingest API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
import socket
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
from typing import Any, TypeVar
|
|
15
|
+
|
|
16
|
+
from trigv.constants import DEFAULT_BASE_URL, DEFAULT_TIMEOUT_SECONDS, MAX_RETRIES, VERSION
|
|
17
|
+
from trigv.errors import (
|
|
18
|
+
ApiError,
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
AuthorizationError,
|
|
21
|
+
NetworkError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
TrigvError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
is_monthly_limit_message,
|
|
27
|
+
)
|
|
28
|
+
from trigv.errors import (
|
|
29
|
+
TimeoutError as TrigvTimeoutError,
|
|
30
|
+
)
|
|
31
|
+
from trigv.types import (
|
|
32
|
+
ConnectionApiKey,
|
|
33
|
+
ConnectionResult,
|
|
34
|
+
ConnectionWorkspace,
|
|
35
|
+
Event,
|
|
36
|
+
HttpResponse,
|
|
37
|
+
SendEventParams,
|
|
38
|
+
SendEventResult,
|
|
39
|
+
)
|
|
40
|
+
from trigv.validation import validate_send_event_params
|
|
41
|
+
|
|
42
|
+
T = TypeVar("T")
|
|
43
|
+
|
|
44
|
+
HttpTransport = Callable[[str, str, dict[str, str], bytes | None, float], HttpResponse]
|
|
45
|
+
|
|
46
|
+
_RETRYABLE_STATUSES = {429, 502, 503, 504}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_json(text: str) -> dict[str, Any] | None:
|
|
50
|
+
if not text:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
parsed = json.loads(text)
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
return None
|
|
56
|
+
return parsed if isinstance(parsed, dict) else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _error_from_response(
|
|
60
|
+
status: int,
|
|
61
|
+
body: dict[str, Any] | None,
|
|
62
|
+
headers: dict[str, str] | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
message = str(body.get("message")) if body and body.get("message") is not None else None
|
|
65
|
+
if not message:
|
|
66
|
+
message = f"Trigv API returned HTTP {status}"
|
|
67
|
+
|
|
68
|
+
if status == 401:
|
|
69
|
+
raise AuthenticationError(message)
|
|
70
|
+
if status == 403:
|
|
71
|
+
raise AuthorizationError(message)
|
|
72
|
+
if status == 404:
|
|
73
|
+
raise NotFoundError(message)
|
|
74
|
+
if status == 422:
|
|
75
|
+
errors = body.get("errors") if body else None
|
|
76
|
+
if not isinstance(errors, dict):
|
|
77
|
+
errors = {}
|
|
78
|
+
normalized: dict[str, list[str]] = {}
|
|
79
|
+
for field, messages in errors.items():
|
|
80
|
+
if isinstance(messages, list):
|
|
81
|
+
normalized[str(field)] = [str(item) for item in messages]
|
|
82
|
+
else:
|
|
83
|
+
normalized[str(field)] = [str(messages)]
|
|
84
|
+
raise ValidationError(message, normalized)
|
|
85
|
+
if status == 429:
|
|
86
|
+
from trigv.retry import parse_retry_after_header
|
|
87
|
+
|
|
88
|
+
retry_after = None
|
|
89
|
+
if headers is not None:
|
|
90
|
+
retry_after = parse_retry_after_header(headers.get("retry-after"))
|
|
91
|
+
raise RateLimitError(message, not is_monthly_limit_message(message), retry_after)
|
|
92
|
+
raise ApiError(message, status, body)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _is_retryable_status(status: int) -> bool:
|
|
96
|
+
return status in _RETRYABLE_STATUSES
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _jitter(max_ms: int) -> float:
|
|
100
|
+
return random.randint(0, max_ms) / 1000.0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _default_transport(
|
|
104
|
+
method: str,
|
|
105
|
+
url: str,
|
|
106
|
+
headers: dict[str, str],
|
|
107
|
+
body: bytes | None,
|
|
108
|
+
timeout: float,
|
|
109
|
+
) -> HttpResponse:
|
|
110
|
+
request = urllib.request.Request(url=url, data=body, headers=headers, method=method)
|
|
111
|
+
try:
|
|
112
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
113
|
+
response_body = response.read().decode("utf-8")
|
|
114
|
+
response_headers = {key.lower(): value for key, value in response.headers.items()}
|
|
115
|
+
return HttpResponse(
|
|
116
|
+
status=response.status,
|
|
117
|
+
body=response_body,
|
|
118
|
+
headers=response_headers,
|
|
119
|
+
)
|
|
120
|
+
except urllib.error.HTTPError as exc:
|
|
121
|
+
response_body = exc.read().decode("utf-8", errors="replace")
|
|
122
|
+
response_headers = {key.lower(): value for key, value in exc.headers.items()}
|
|
123
|
+
return HttpResponse(status=exc.code, body=response_body, headers=response_headers)
|
|
124
|
+
except (builtins.TimeoutError, socket.timeout) as exc: # noqa: UP041
|
|
125
|
+
raise TrigvTimeoutError() from exc
|
|
126
|
+
except urllib.error.URLError as exc:
|
|
127
|
+
if isinstance(exc.reason, (builtins.TimeoutError, socket.timeout)): # noqa: UP041
|
|
128
|
+
raise TrigvTimeoutError() from exc
|
|
129
|
+
message = str(exc.reason) if exc.reason else "Network request failed"
|
|
130
|
+
raise NetworkError(message, exc) from exc
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Trigv:
|
|
134
|
+
"""Trigv workspace ingest client."""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
api_key: str | None = None,
|
|
140
|
+
base_url: str | None = None,
|
|
141
|
+
timeout: float | None = None,
|
|
142
|
+
max_retries: int | None = None,
|
|
143
|
+
transport: HttpTransport | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
resolved_key = api_key if api_key is not None else os.environ.get("TRIGV_API_KEY")
|
|
146
|
+
if not resolved_key or not resolved_key.strip():
|
|
147
|
+
raise ValidationError("api_key is required (or set TRIGV_API_KEY)")
|
|
148
|
+
|
|
149
|
+
if max_retries is not None and max_retries < 0:
|
|
150
|
+
raise ValidationError(
|
|
151
|
+
"max_retries must be zero or greater",
|
|
152
|
+
{"max_retries": ["max_retries must be zero or greater"]},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if timeout is not None and timeout <= 0:
|
|
156
|
+
raise ValidationError(
|
|
157
|
+
"timeout must be greater than zero",
|
|
158
|
+
{"timeout": ["timeout must be greater than zero"]},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
self._api_key = resolved_key.strip()
|
|
162
|
+
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
163
|
+
self._timeout = timeout if timeout is not None else DEFAULT_TIMEOUT_SECONDS
|
|
164
|
+
self._max_retries = max_retries if max_retries is not None else MAX_RETRIES
|
|
165
|
+
self._transport = transport or _default_transport
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def user_agent(self) -> str:
|
|
169
|
+
return f"trigv-python/{VERSION}"
|
|
170
|
+
|
|
171
|
+
def send_event(self, params: SendEventParams | Mapping[str, object]) -> SendEventResult:
|
|
172
|
+
body = validate_send_event_params(params)
|
|
173
|
+
has_idempotency = bool(body.get("idempotency_key"))
|
|
174
|
+
|
|
175
|
+
def parse_success(status: int, parsed: dict[str, Any] | None) -> SendEventResult:
|
|
176
|
+
if status in (200, 202) and parsed and isinstance(parsed.get("event"), dict):
|
|
177
|
+
return SendEventResult(
|
|
178
|
+
event=Event.from_dict(parsed["event"]),
|
|
179
|
+
duplicate=status == 200,
|
|
180
|
+
)
|
|
181
|
+
_error_from_response(status, parsed)
|
|
182
|
+
raise AssertionError("unreachable")
|
|
183
|
+
|
|
184
|
+
return self._request_with_retry(
|
|
185
|
+
method="POST",
|
|
186
|
+
path="/v1/events",
|
|
187
|
+
body=body,
|
|
188
|
+
allow_retry=has_idempotency,
|
|
189
|
+
parse_success=parse_success,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def verify_connection(self) -> ConnectionResult:
|
|
193
|
+
def parse_success(status: int, parsed: dict[str, Any] | None) -> ConnectionResult:
|
|
194
|
+
if (
|
|
195
|
+
status == 200
|
|
196
|
+
and parsed
|
|
197
|
+
and isinstance(parsed.get("workspace"), dict)
|
|
198
|
+
and isinstance(parsed.get("api_key"), dict)
|
|
199
|
+
):
|
|
200
|
+
return ConnectionResult(
|
|
201
|
+
workspace=ConnectionWorkspace.from_dict(parsed["workspace"]),
|
|
202
|
+
api_key=ConnectionApiKey.from_dict(parsed["api_key"]),
|
|
203
|
+
)
|
|
204
|
+
_error_from_response(status, parsed)
|
|
205
|
+
raise AssertionError("unreachable")
|
|
206
|
+
|
|
207
|
+
return self._request_with_retry(
|
|
208
|
+
method="GET",
|
|
209
|
+
path="/v1/connection",
|
|
210
|
+
body=None,
|
|
211
|
+
allow_retry=True,
|
|
212
|
+
parse_success=parse_success,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _request_with_retry(
|
|
216
|
+
self,
|
|
217
|
+
*,
|
|
218
|
+
method: str,
|
|
219
|
+
path: str,
|
|
220
|
+
body: dict[str, str] | None,
|
|
221
|
+
allow_retry: bool,
|
|
222
|
+
parse_success: Callable[[int, dict[str, Any] | None], T],
|
|
223
|
+
) -> T:
|
|
224
|
+
last_error: BaseException | None = None
|
|
225
|
+
|
|
226
|
+
for attempt in range(self._max_retries + 1):
|
|
227
|
+
try:
|
|
228
|
+
return self._request_once(
|
|
229
|
+
method=method,
|
|
230
|
+
path=path,
|
|
231
|
+
body=body,
|
|
232
|
+
parse_success=parse_success,
|
|
233
|
+
)
|
|
234
|
+
except TrigvError as error:
|
|
235
|
+
last_error = error
|
|
236
|
+
|
|
237
|
+
if not allow_retry or attempt >= self._max_retries:
|
|
238
|
+
raise
|
|
239
|
+
|
|
240
|
+
if isinstance(
|
|
241
|
+
error,
|
|
242
|
+
(ValidationError, AuthenticationError, AuthorizationError, NotFoundError),
|
|
243
|
+
):
|
|
244
|
+
raise
|
|
245
|
+
|
|
246
|
+
if isinstance(error, RateLimitError) and not error.retryable:
|
|
247
|
+
raise
|
|
248
|
+
|
|
249
|
+
if isinstance(error, ApiError) and not _is_retryable_status(error.status_code):
|
|
250
|
+
raise
|
|
251
|
+
|
|
252
|
+
if not isinstance(
|
|
253
|
+
error,
|
|
254
|
+
(NetworkError, TrigvTimeoutError, RateLimitError, ApiError),
|
|
255
|
+
):
|
|
256
|
+
raise
|
|
257
|
+
|
|
258
|
+
backoff = 0.5 * (2**attempt) + _jitter(250)
|
|
259
|
+
if isinstance(error, RateLimitError) and error.retry_after_seconds is not None:
|
|
260
|
+
backoff = error.retry_after_seconds
|
|
261
|
+
time.sleep(min(backoff, self._timeout))
|
|
262
|
+
|
|
263
|
+
assert last_error is not None
|
|
264
|
+
raise last_error
|
|
265
|
+
|
|
266
|
+
def _request_once(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
method: str,
|
|
270
|
+
path: str,
|
|
271
|
+
body: dict[str, str] | None,
|
|
272
|
+
parse_success: Callable[[int, dict[str, Any] | None], T],
|
|
273
|
+
) -> T:
|
|
274
|
+
url = f"{self._base_url}{path}"
|
|
275
|
+
headers = {
|
|
276
|
+
"Accept": "application/json",
|
|
277
|
+
"Content-Type": "application/json",
|
|
278
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
279
|
+
"User-Agent": self.user_agent,
|
|
280
|
+
}
|
|
281
|
+
payload = json.dumps(body).encode("utf-8") if body is not None else None
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
response = self._transport(method, url, headers, payload, self._timeout)
|
|
285
|
+
except TrigvTimeoutError:
|
|
286
|
+
raise
|
|
287
|
+
except NetworkError:
|
|
288
|
+
raise
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
raise NetworkError(str(exc), exc) from exc
|
|
291
|
+
|
|
292
|
+
parsed = _parse_json(response.body)
|
|
293
|
+
|
|
294
|
+
if 200 <= response.status < 300:
|
|
295
|
+
return parse_success(response.status, parsed)
|
|
296
|
+
|
|
297
|
+
_error_from_response(response.status, parsed, response.headers)
|
|
298
|
+
raise AssertionError("unreachable")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared constants for the Trigv Python SDK."""
|
|
2
|
+
|
|
3
|
+
VERSION = "1.0.0"
|
|
4
|
+
|
|
5
|
+
DEFAULT_BASE_URL = "https://api.trigv.com/api"
|
|
6
|
+
|
|
7
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
8
|
+
|
|
9
|
+
MAX_RETRIES = 2
|
|
10
|
+
|
|
11
|
+
LEVELS = ("info", "success", "warning", "error")
|
|
12
|
+
|
|
13
|
+
DELIVERY_URGENCIES = ("standard", "time_sensitive")
|
|
14
|
+
|
|
15
|
+
FIELD_MAX_LENGTHS = {
|
|
16
|
+
"channel": 120,
|
|
17
|
+
"title": 255,
|
|
18
|
+
"description": 1000,
|
|
19
|
+
"image_url": 2048,
|
|
20
|
+
"url": 2048,
|
|
21
|
+
"event_type": 120,
|
|
22
|
+
"idempotency_key": 190,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
PROHIBITED_FIELDS = ("api_key", "icon")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Exception types for the Trigv Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TrigvError(Exception):
|
|
9
|
+
"""Base exception for all Trigv SDK errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.message = message
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
return self.message
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ValidationError(TrigvError):
|
|
20
|
+
"""Client-side or HTTP 422 validation failure."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str, errors: dict[str, list[str]] | None = None) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.errors: dict[str, list[str]] = errors or {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthenticationError(TrigvError):
|
|
28
|
+
"""HTTP 401 — invalid or missing API key."""
|
|
29
|
+
|
|
30
|
+
status_code = 401
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthorizationError(TrigvError):
|
|
34
|
+
"""HTTP 403 — revoked, expired, or insufficient scope."""
|
|
35
|
+
|
|
36
|
+
status_code = 403
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NotFoundError(TrigvError):
|
|
40
|
+
"""HTTP 404 — channel or resource not found."""
|
|
41
|
+
|
|
42
|
+
status_code = 404
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RateLimitError(TrigvError):
|
|
46
|
+
"""HTTP 429 — per-minute burst or monthly workspace cap."""
|
|
47
|
+
|
|
48
|
+
status_code = 429
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
retryable: bool = True,
|
|
54
|
+
retry_after_seconds: float | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
self.retryable = retryable
|
|
58
|
+
self.retry_after_seconds = retry_after_seconds
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ApiError(TrigvError):
|
|
62
|
+
"""Other HTTP 4xx/5xx responses."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, message: str, status_code: int, body: Any = None) -> None:
|
|
65
|
+
super().__init__(message)
|
|
66
|
+
self.status_code = status_code
|
|
67
|
+
self.body = body
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NetworkError(TrigvError):
|
|
71
|
+
"""DNS, connection reset, or other transport failure."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, message: str, cause: BaseException | None = None) -> None:
|
|
74
|
+
super().__init__(message)
|
|
75
|
+
self.cause = cause
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TimeoutError(TrigvError):
|
|
79
|
+
"""Request exceeded the configured timeout."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, message: str = "Request timed out") -> None:
|
|
82
|
+
super().__init__(message)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_monthly_limit_message(message: str) -> bool:
|
|
86
|
+
"""Return True when a 429 message indicates the monthly workspace cap."""
|
|
87
|
+
return "Monthly event limit" in message
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Retry helpers for Trigv HTTP clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from datetime import timezone
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_retry_after_header(value: str | None, now: float | None = None) -> float | None:
|
|
11
|
+
"""Parse Retry-After (integer seconds or HTTP-date). Returns wait seconds when valid."""
|
|
12
|
+
if not value or not value.strip():
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
trimmed = value.strip()
|
|
16
|
+
|
|
17
|
+
if trimmed.isdigit():
|
|
18
|
+
seconds = int(trimmed)
|
|
19
|
+
if seconds < 0:
|
|
20
|
+
return None
|
|
21
|
+
return float(seconds)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
retry_at = parsedate_to_datetime(trimmed)
|
|
25
|
+
if retry_at.tzinfo is None:
|
|
26
|
+
retry_at = retry_at.replace(tzinfo=timezone.utc)
|
|
27
|
+
current = time.time() if now is None else now
|
|
28
|
+
wait = retry_at.timestamp() - current
|
|
29
|
+
if wait < 0:
|
|
30
|
+
return None
|
|
31
|
+
return wait
|
|
32
|
+
except (TypeError, ValueError, OverflowError):
|
|
33
|
+
return None
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Type definitions for the Trigv Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal, TypedDict
|
|
7
|
+
|
|
8
|
+
NotificationLevel = Literal["info", "success", "warning", "error"]
|
|
9
|
+
DeliveryUrgency = Literal["standard", "time_sensitive"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SendEventParams(TypedDict, total=False):
|
|
13
|
+
"""Parameters for :meth:`Trigv.send_event`."""
|
|
14
|
+
|
|
15
|
+
channel: str
|
|
16
|
+
title: str
|
|
17
|
+
description: str
|
|
18
|
+
image_url: str
|
|
19
|
+
url: str
|
|
20
|
+
level: NotificationLevel
|
|
21
|
+
delivery_urgency: DeliveryUrgency
|
|
22
|
+
event_type: str
|
|
23
|
+
idempotency_key: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Event:
|
|
28
|
+
"""Event metadata returned by the ingest API."""
|
|
29
|
+
|
|
30
|
+
public_id: str
|
|
31
|
+
event_uuid: str
|
|
32
|
+
status: str
|
|
33
|
+
level: str
|
|
34
|
+
event_type: str | None
|
|
35
|
+
target_device_count: int
|
|
36
|
+
received_at: str
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_dict(cls, data: dict[str, object]) -> Event:
|
|
40
|
+
return cls(
|
|
41
|
+
public_id=str(data["public_id"]),
|
|
42
|
+
event_uuid=str(data["event_uuid"]),
|
|
43
|
+
status=str(data["status"]),
|
|
44
|
+
level=str(data["level"]),
|
|
45
|
+
event_type=data.get("event_type") if data.get("event_type") is not None else None,
|
|
46
|
+
target_device_count=int(data["target_device_count"]), # type: ignore[arg-type]
|
|
47
|
+
received_at=str(data["received_at"]),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class SendEventResult:
|
|
53
|
+
"""Result of a successful event ingest."""
|
|
54
|
+
|
|
55
|
+
event: Event
|
|
56
|
+
duplicate: bool
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class ConnectionWorkspace:
|
|
61
|
+
public_id: str
|
|
62
|
+
name: str
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_dict(cls, data: dict[str, object]) -> ConnectionWorkspace:
|
|
66
|
+
return cls(public_id=str(data["public_id"]), name=str(data["name"]))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class ConnectionApiKey:
|
|
71
|
+
public_id: str
|
|
72
|
+
name: str
|
|
73
|
+
prefix: str
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_dict(cls, data: dict[str, object]) -> ConnectionApiKey:
|
|
77
|
+
return cls(
|
|
78
|
+
public_id=str(data["public_id"]),
|
|
79
|
+
name=str(data["name"]),
|
|
80
|
+
prefix=str(data["prefix"]),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class ConnectionResult:
|
|
86
|
+
workspace: ConnectionWorkspace
|
|
87
|
+
api_key: ConnectionApiKey
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class HttpResponse:
|
|
92
|
+
"""HTTP response returned by the transport layer."""
|
|
93
|
+
|
|
94
|
+
status: int
|
|
95
|
+
body: str
|
|
96
|
+
headers: dict[str, str]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Client-side validation for event parameters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from trigv.constants import (
|
|
8
|
+
DELIVERY_URGENCIES,
|
|
9
|
+
FIELD_MAX_LENGTHS,
|
|
10
|
+
LEVELS,
|
|
11
|
+
PROHIBITED_FIELDS,
|
|
12
|
+
)
|
|
13
|
+
from trigv.errors import ValidationError
|
|
14
|
+
from trigv.types import SendEventParams
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _trim(value: str | None) -> str | None:
|
|
18
|
+
if value is None:
|
|
19
|
+
return None
|
|
20
|
+
trimmed = value.strip()
|
|
21
|
+
return trimmed if trimmed else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _assert_max_length(field: str, value: str, maximum: int) -> None:
|
|
25
|
+
if len(value) > maximum:
|
|
26
|
+
message = f"{field} must be at most {maximum} characters"
|
|
27
|
+
raise ValidationError(message, {field: [message]})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _assert_enum(field: str, value: str, allowed: tuple[str, ...]) -> None:
|
|
31
|
+
if value not in allowed:
|
|
32
|
+
message = f"{field} must be one of: {', '.join(allowed)}"
|
|
33
|
+
raise ValidationError(message, {field: [message]})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_send_event_params(params: SendEventParams | Mapping[str, object]) -> dict[str, str]:
|
|
37
|
+
"""Validate and normalize send-event parameters for the JSON request body."""
|
|
38
|
+
raw = dict(params)
|
|
39
|
+
|
|
40
|
+
for prohibited in PROHIBITED_FIELDS:
|
|
41
|
+
if prohibited in raw:
|
|
42
|
+
raise ValidationError(
|
|
43
|
+
f'Field "{prohibited}" is not allowed',
|
|
44
|
+
{prohibited: [f"The {prohibited} field is prohibited"]},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
channel = _trim(str(raw.get("channel", "")) if raw.get("channel") is not None else None)
|
|
48
|
+
title = _trim(str(raw.get("title", "")) if raw.get("title") is not None else None)
|
|
49
|
+
|
|
50
|
+
if not channel:
|
|
51
|
+
raise ValidationError("channel is required", {"channel": ["channel is required"]})
|
|
52
|
+
if not title:
|
|
53
|
+
raise ValidationError("title is required", {"title": ["title is required"]})
|
|
54
|
+
|
|
55
|
+
_assert_max_length("channel", channel, FIELD_MAX_LENGTHS["channel"])
|
|
56
|
+
_assert_max_length("title", title, FIELD_MAX_LENGTHS["title"])
|
|
57
|
+
|
|
58
|
+
body: dict[str, str] = {"channel": channel, "title": title}
|
|
59
|
+
|
|
60
|
+
description = _trim(str(raw["description"]) if raw.get("description") is not None else None)
|
|
61
|
+
if description is not None:
|
|
62
|
+
_assert_max_length("description", description, FIELD_MAX_LENGTHS["description"])
|
|
63
|
+
body["description"] = description
|
|
64
|
+
|
|
65
|
+
image_url = _trim(str(raw["image_url"]) if raw.get("image_url") is not None else None)
|
|
66
|
+
if image_url is not None:
|
|
67
|
+
_assert_max_length("image_url", image_url, FIELD_MAX_LENGTHS["image_url"])
|
|
68
|
+
body["image_url"] = image_url
|
|
69
|
+
|
|
70
|
+
url = _trim(str(raw["url"]) if raw.get("url") is not None else None)
|
|
71
|
+
if url is not None:
|
|
72
|
+
_assert_max_length("url", url, FIELD_MAX_LENGTHS["url"])
|
|
73
|
+
body["url"] = url
|
|
74
|
+
|
|
75
|
+
level = _trim(str(raw["level"]) if raw.get("level") is not None else None)
|
|
76
|
+
if level is not None:
|
|
77
|
+
_assert_enum("level", level, LEVELS)
|
|
78
|
+
body["level"] = level
|
|
79
|
+
|
|
80
|
+
delivery_urgency = _trim(
|
|
81
|
+
str(raw["delivery_urgency"]) if raw.get("delivery_urgency") is not None else None
|
|
82
|
+
)
|
|
83
|
+
if delivery_urgency is not None:
|
|
84
|
+
_assert_enum("delivery_urgency", delivery_urgency, DELIVERY_URGENCIES)
|
|
85
|
+
body["delivery_urgency"] = delivery_urgency
|
|
86
|
+
|
|
87
|
+
event_type = _trim(str(raw["event_type"]) if raw.get("event_type") is not None else None)
|
|
88
|
+
if event_type is not None:
|
|
89
|
+
_assert_max_length("event_type", event_type, FIELD_MAX_LENGTHS["event_type"])
|
|
90
|
+
body["event_type"] = event_type
|
|
91
|
+
|
|
92
|
+
idempotency_key = _trim(
|
|
93
|
+
str(raw["idempotency_key"]) if raw.get("idempotency_key") is not None else None
|
|
94
|
+
)
|
|
95
|
+
if idempotency_key is not None:
|
|
96
|
+
_assert_max_length("idempotency_key", idempotency_key, FIELD_MAX_LENGTHS["idempotency_key"])
|
|
97
|
+
body["idempotency_key"] = idempotency_key
|
|
98
|
+
|
|
99
|
+
return body
|