trigv 1.0.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.
trigv/__init__.py ADDED
@@ -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
trigv/client.py ADDED
@@ -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")
trigv/constants.py ADDED
@@ -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")
trigv/errors.py ADDED
@@ -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
trigv/py.typed ADDED
File without changes
trigv/retry.py ADDED
@@ -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
trigv/types.py ADDED
@@ -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]
trigv/validation.py ADDED
@@ -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
@@ -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).
@@ -0,0 +1,12 @@
1
+ trigv/__init__.py,sha256=LegMNi_kWZCMLgEzuTms-Kht3kFKx_E8YjQwXvud8_o,1075
2
+ trigv/client.py,sha256=D4SSUncgnJibhiB95uNxQrEQEGx8WoG5ZiSWRJgkIcU,10053
3
+ trigv/constants.py,sha256=V07H-FvM36seJMHzeSudK0wNQXSnS28iI2NQTuMEKDA,488
4
+ trigv/errors.py,sha256=aPPjjQKcnr4clxi3IfjlEiLObJJ3wncevbJRQZzfRWo,2243
5
+ trigv/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ trigv/retry.py,sha256=dOLNPt-DVVIAB3sbPEVpET8hpdhmPIDPqAHuutbRZGY,972
7
+ trigv/types.py,sha256=UvXH7vB2s-vsTQkyvObdr4BDQ-mkvHE1SZL1Q6V9Xhc,2409
8
+ trigv/validation.py,sha256=fw9ClnVhIpDKBAAinr8dgUbDdnYnFHhBpFeBOiM-7Io,3740
9
+ trigv-1.0.0.dist-info/METADATA,sha256=jp1L3xDGDhWw6UWSVND7nigtVQXU_42UXjLMwOIDUJA,7031
10
+ trigv-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ trigv-1.0.0.dist-info/licenses/LICENSE,sha256=6x6ahs8nAue9h8wCiLwot31oXDHHpYDYlomTpCZ2ha0,1062
12
+ trigv-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.