flares-client 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.
- flares_client/__init__.py +47 -0
- flares_client/_common.py +94 -0
- flares_client/client.py +269 -0
- flares_client/errors.py +29 -0
- flares_client/models.py +140 -0
- flares_client/py.typed +0 -0
- flares_client-0.1.0.dist-info/METADATA +42 -0
- flares_client-0.1.0.dist-info/RECORD +9 -0
- flares_client-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Flares client library. HTTP success and notification delivery are separate outcomes."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncClient, Client
|
|
4
|
+
from .errors import DecodeError, FlaresError, HTTPError, TransportError, ValidationError
|
|
5
|
+
from .models import (
|
|
6
|
+
Alert,
|
|
7
|
+
AlertResult,
|
|
8
|
+
Delivery,
|
|
9
|
+
DestinationOutcome,
|
|
10
|
+
Health,
|
|
11
|
+
Heartbeat,
|
|
12
|
+
HeartbeatInput,
|
|
13
|
+
Issue,
|
|
14
|
+
IssueList,
|
|
15
|
+
IssueStatus,
|
|
16
|
+
MutationResult,
|
|
17
|
+
Notification,
|
|
18
|
+
NotificationStatus,
|
|
19
|
+
OpenIssue,
|
|
20
|
+
Severity,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Client",
|
|
26
|
+
"AsyncClient",
|
|
27
|
+
"FlaresError",
|
|
28
|
+
"ValidationError",
|
|
29
|
+
"TransportError",
|
|
30
|
+
"HTTPError",
|
|
31
|
+
"DecodeError",
|
|
32
|
+
"Alert",
|
|
33
|
+
"AlertResult",
|
|
34
|
+
"Delivery",
|
|
35
|
+
"DestinationOutcome",
|
|
36
|
+
"Health",
|
|
37
|
+
"Heartbeat",
|
|
38
|
+
"HeartbeatInput",
|
|
39
|
+
"Issue",
|
|
40
|
+
"IssueList",
|
|
41
|
+
"IssueStatus",
|
|
42
|
+
"MutationResult",
|
|
43
|
+
"Notification",
|
|
44
|
+
"NotificationStatus",
|
|
45
|
+
"OpenIssue",
|
|
46
|
+
"Severity",
|
|
47
|
+
]
|
flares_client/_common.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from pydantic import BaseModel, TypeAdapter
|
|
6
|
+
from pydantic import ValidationError as ModelError
|
|
7
|
+
|
|
8
|
+
from .errors import DecodeError, HTTPError, ValidationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def settings(base_url: str, token: str, timeout: float) -> tuple[str, dict[str, str]]:
|
|
12
|
+
try:
|
|
13
|
+
url = httpx.URL(base_url)
|
|
14
|
+
except (httpx.InvalidURL, TypeError):
|
|
15
|
+
raise ValidationError("Invalid API base URL") from None
|
|
16
|
+
if (
|
|
17
|
+
url.scheme not in ("http", "https")
|
|
18
|
+
or not url.host
|
|
19
|
+
or url.username
|
|
20
|
+
or url.password
|
|
21
|
+
# HTTPX retains empty delimiters even when query/fragment values are empty.
|
|
22
|
+
or "?" in str(url)
|
|
23
|
+
or "#" in str(url)
|
|
24
|
+
):
|
|
25
|
+
raise ValidationError(
|
|
26
|
+
"API base URL must be HTTP(S) without credentials, query, or fragment"
|
|
27
|
+
)
|
|
28
|
+
if not isinstance(token, str) or not token or not all(33 <= ord(c) <= 126 for c in token):
|
|
29
|
+
raise ValidationError("API token must be nonempty printable ASCII without spaces")
|
|
30
|
+
if (
|
|
31
|
+
not isinstance(timeout, (float, int))
|
|
32
|
+
or isinstance(timeout, bool)
|
|
33
|
+
or not math.isfinite(timeout)
|
|
34
|
+
or not 0 < timeout <= 86400
|
|
35
|
+
):
|
|
36
|
+
raise ValidationError("Timeout must be greater than zero and at most 86400 seconds")
|
|
37
|
+
return str(url).rstrip("/"), {"Authorization": f"Bearer {token}"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def body(model: type[BaseModel], **values: Any) -> dict[str, Any]:
|
|
41
|
+
try:
|
|
42
|
+
return model.model_validate(values).model_dump(mode="json")
|
|
43
|
+
except ModelError as error:
|
|
44
|
+
fields = sorted({str(e["loc"][0]) for e in error.errors() if e["loc"]})
|
|
45
|
+
raise ValidationError(f"Invalid request fields: {', '.join(fields)}") from None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def key_header(key: str | None) -> dict[str, str]:
|
|
49
|
+
if key is None:
|
|
50
|
+
return {}
|
|
51
|
+
if (
|
|
52
|
+
not isinstance(key, str)
|
|
53
|
+
or not 1 <= len(key) <= 200
|
|
54
|
+
or not all(33 <= ord(c) <= 126 for c in key)
|
|
55
|
+
):
|
|
56
|
+
raise ValidationError("Invalid idempotency key")
|
|
57
|
+
return {"Idempotency-Key": key}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def listing(status: str | None, limit: int, offset: int) -> dict[str, Any]:
|
|
61
|
+
if (
|
|
62
|
+
status not in (None, "open", "closed")
|
|
63
|
+
or type(limit) is not int
|
|
64
|
+
or not 1 <= limit <= 1000
|
|
65
|
+
or type(offset) is not int
|
|
66
|
+
or not 0 <= offset <= 4_294_967_295
|
|
67
|
+
):
|
|
68
|
+
raise ValidationError("Invalid status, limit, or offset")
|
|
69
|
+
params = {"limit": limit, "offset": offset}
|
|
70
|
+
if status is not None:
|
|
71
|
+
params["status"] = status
|
|
72
|
+
return params
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def delivery_path(id: int) -> str:
|
|
76
|
+
if type(id) is not int or not 1 <= id <= 9_223_372_036_854_775_807:
|
|
77
|
+
raise ValidationError("delivery id must be a positive 64-bit integer")
|
|
78
|
+
return f"/v1/deliveries/{id}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def decode(response: httpx.Response, model: Any) -> Any:
|
|
82
|
+
if not response.is_success:
|
|
83
|
+
raise HTTPError(response.status_code)
|
|
84
|
+
if model is str:
|
|
85
|
+
return response.text
|
|
86
|
+
try:
|
|
87
|
+
value = response.json()
|
|
88
|
+
if model is None:
|
|
89
|
+
if not isinstance(value, dict) or value.get("deleted") is not True:
|
|
90
|
+
raise ValueError
|
|
91
|
+
return None
|
|
92
|
+
return TypeAdapter(model).validate_python(value)
|
|
93
|
+
except (ValueError, TypeError):
|
|
94
|
+
raise DecodeError() from None
|
flares_client/client.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""HTTP clients with identical synchronous and asynchronous operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Self
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._common import body, decode, delivery_path, key_header, listing, settings
|
|
8
|
+
from .errors import TransportError
|
|
9
|
+
from .models import (
|
|
10
|
+
Alert,
|
|
11
|
+
AlertResult,
|
|
12
|
+
CloseIssue,
|
|
13
|
+
Delivery,
|
|
14
|
+
Health,
|
|
15
|
+
Heartbeat,
|
|
16
|
+
HeartbeatInput,
|
|
17
|
+
Issue,
|
|
18
|
+
IssueList,
|
|
19
|
+
IssueStatus,
|
|
20
|
+
MutationResult,
|
|
21
|
+
OpenIssue,
|
|
22
|
+
Severity,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Client:
|
|
27
|
+
def __init__(self, base_url: str, token: str, *, timeout: float = 15.0):
|
|
28
|
+
self._base_url, headers = settings(base_url, token, timeout)
|
|
29
|
+
self._client = httpx.Client(headers=headers, timeout=timeout, follow_redirects=False)
|
|
30
|
+
|
|
31
|
+
def __enter__(self) -> Self:
|
|
32
|
+
self._client.__enter__()
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
def __exit__(self, *args: Any) -> None:
|
|
36
|
+
self._client.__exit__(*args)
|
|
37
|
+
|
|
38
|
+
def close(self) -> None:
|
|
39
|
+
self._client.close()
|
|
40
|
+
|
|
41
|
+
def _request(self, method: str, path: str, model: Any, **kwargs: Any) -> Any:
|
|
42
|
+
try:
|
|
43
|
+
response = self._client.request(method, self._base_url + path, **kwargs)
|
|
44
|
+
except httpx.HTTPError as error:
|
|
45
|
+
raise TransportError(timed_out=isinstance(error, httpx.TimeoutException)) from None
|
|
46
|
+
return decode(response, model)
|
|
47
|
+
|
|
48
|
+
def open_issue(
|
|
49
|
+
self,
|
|
50
|
+
id: str,
|
|
51
|
+
*,
|
|
52
|
+
title: str | None = None,
|
|
53
|
+
message: str | None = None,
|
|
54
|
+
severity: Severity | str = Severity.WARNING,
|
|
55
|
+
remind_every_seconds: int | None = None,
|
|
56
|
+
notify_on_resolution: bool = False,
|
|
57
|
+
) -> MutationResult:
|
|
58
|
+
data = body(
|
|
59
|
+
OpenIssue,
|
|
60
|
+
id=id,
|
|
61
|
+
title=title,
|
|
62
|
+
message=message,
|
|
63
|
+
severity=severity,
|
|
64
|
+
remind_every_seconds=remind_every_seconds,
|
|
65
|
+
notify_on_resolution=notify_on_resolution,
|
|
66
|
+
)
|
|
67
|
+
return self._request("POST", "/v1/issues/open", MutationResult, json=data)
|
|
68
|
+
|
|
69
|
+
def close_issue(self, id: str) -> MutationResult:
|
|
70
|
+
return self._request(
|
|
71
|
+
"POST", "/v1/issues/close", MutationResult, json=body(CloseIssue, id=id)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def get_issue(self, id: str) -> Issue:
|
|
75
|
+
return self._request("GET", "/v1/issue", Issue, params=body(CloseIssue, id=id))
|
|
76
|
+
|
|
77
|
+
def list_issues(
|
|
78
|
+
self, *, status: IssueStatus | str | None = None, limit: int = 100, offset: int = 0
|
|
79
|
+
) -> IssueList:
|
|
80
|
+
return self._request("GET", "/v1/issues", IssueList, params=listing(status, limit, offset))
|
|
81
|
+
|
|
82
|
+
def alert(
|
|
83
|
+
self,
|
|
84
|
+
title: str,
|
|
85
|
+
message: str,
|
|
86
|
+
*,
|
|
87
|
+
severity: Severity | str = Severity.WARNING,
|
|
88
|
+
group_key: str | None = None,
|
|
89
|
+
idempotency_key: str | None = None,
|
|
90
|
+
) -> AlertResult:
|
|
91
|
+
return self._request(
|
|
92
|
+
"POST",
|
|
93
|
+
"/v1/alerts",
|
|
94
|
+
AlertResult,
|
|
95
|
+
json=body(Alert, title=title, message=message, severity=severity, group_key=group_key),
|
|
96
|
+
headers=key_header(idempotency_key),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def get_delivery(self, id: int) -> Delivery:
|
|
100
|
+
return self._request("GET", delivery_path(id), Delivery)
|
|
101
|
+
|
|
102
|
+
def register_heartbeat(
|
|
103
|
+
self,
|
|
104
|
+
id: str,
|
|
105
|
+
*,
|
|
106
|
+
title: str,
|
|
107
|
+
interval_seconds: int,
|
|
108
|
+
grace_seconds: int = 0,
|
|
109
|
+
severity: Severity | str = Severity.WARNING,
|
|
110
|
+
notify_on_recovery: bool = False,
|
|
111
|
+
) -> Heartbeat:
|
|
112
|
+
return self._request(
|
|
113
|
+
"POST",
|
|
114
|
+
"/v1/heartbeats",
|
|
115
|
+
Heartbeat,
|
|
116
|
+
json=body(
|
|
117
|
+
HeartbeatInput,
|
|
118
|
+
id=id,
|
|
119
|
+
title=title,
|
|
120
|
+
interval_seconds=interval_seconds,
|
|
121
|
+
grace_seconds=grace_seconds,
|
|
122
|
+
severity=severity,
|
|
123
|
+
notify_on_recovery=notify_on_recovery,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def check_in(self, id: str) -> Heartbeat:
|
|
128
|
+
return self._request(
|
|
129
|
+
"POST", "/v1/heartbeats/check-in", Heartbeat, json=body(CloseIssue, id=id)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def list_heartbeats(self) -> list[Heartbeat]:
|
|
133
|
+
return self._request("GET", "/v1/heartbeats", list[Heartbeat])
|
|
134
|
+
|
|
135
|
+
def delete_heartbeat(self, id: str) -> None:
|
|
136
|
+
return self._request("DELETE", "/v1/heartbeat", None, params=body(CloseIssue, id=id))
|
|
137
|
+
|
|
138
|
+
def health(self) -> Health:
|
|
139
|
+
return self._request("GET", "/healthz", Health)
|
|
140
|
+
|
|
141
|
+
def readiness(self) -> Health:
|
|
142
|
+
return self._request("GET", "/readyz", Health)
|
|
143
|
+
|
|
144
|
+
def metrics(self) -> str:
|
|
145
|
+
return self._request("GET", "/metrics", str)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class AsyncClient:
|
|
149
|
+
def __init__(self, base_url: str, token: str, *, timeout: float = 15.0):
|
|
150
|
+
self._base_url, headers = settings(base_url, token, timeout)
|
|
151
|
+
self._client = httpx.AsyncClient(headers=headers, timeout=timeout, follow_redirects=False)
|
|
152
|
+
|
|
153
|
+
async def __aenter__(self) -> Self:
|
|
154
|
+
await self._client.__aenter__()
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
158
|
+
await self._client.__aexit__(*args)
|
|
159
|
+
|
|
160
|
+
async def aclose(self) -> None:
|
|
161
|
+
await self._client.aclose()
|
|
162
|
+
|
|
163
|
+
async def _request(self, method: str, path: str, model: Any, **kwargs: Any) -> Any:
|
|
164
|
+
try:
|
|
165
|
+
response = await self._client.request(method, self._base_url + path, **kwargs)
|
|
166
|
+
except httpx.HTTPError as error:
|
|
167
|
+
raise TransportError(timed_out=isinstance(error, httpx.TimeoutException)) from None
|
|
168
|
+
return decode(response, model)
|
|
169
|
+
|
|
170
|
+
async def open_issue(
|
|
171
|
+
self,
|
|
172
|
+
id: str,
|
|
173
|
+
*,
|
|
174
|
+
title: str | None = None,
|
|
175
|
+
message: str | None = None,
|
|
176
|
+
severity: Severity | str = Severity.WARNING,
|
|
177
|
+
remind_every_seconds: int | None = None,
|
|
178
|
+
notify_on_resolution: bool = False,
|
|
179
|
+
) -> MutationResult:
|
|
180
|
+
data = body(
|
|
181
|
+
OpenIssue,
|
|
182
|
+
id=id,
|
|
183
|
+
title=title,
|
|
184
|
+
message=message,
|
|
185
|
+
severity=severity,
|
|
186
|
+
remind_every_seconds=remind_every_seconds,
|
|
187
|
+
notify_on_resolution=notify_on_resolution,
|
|
188
|
+
)
|
|
189
|
+
return await self._request("POST", "/v1/issues/open", MutationResult, json=data)
|
|
190
|
+
|
|
191
|
+
async def close_issue(self, id: str) -> MutationResult:
|
|
192
|
+
return await self._request(
|
|
193
|
+
"POST", "/v1/issues/close", MutationResult, json=body(CloseIssue, id=id)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def get_issue(self, id: str) -> Issue:
|
|
197
|
+
return await self._request("GET", "/v1/issue", Issue, params=body(CloseIssue, id=id))
|
|
198
|
+
|
|
199
|
+
async def list_issues(
|
|
200
|
+
self, *, status: IssueStatus | str | None = None, limit: int = 100, offset: int = 0
|
|
201
|
+
) -> IssueList:
|
|
202
|
+
return await self._request(
|
|
203
|
+
"GET", "/v1/issues", IssueList, params=listing(status, limit, offset)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
async def alert(
|
|
207
|
+
self,
|
|
208
|
+
title: str,
|
|
209
|
+
message: str,
|
|
210
|
+
*,
|
|
211
|
+
severity: Severity | str = Severity.WARNING,
|
|
212
|
+
group_key: str | None = None,
|
|
213
|
+
idempotency_key: str | None = None,
|
|
214
|
+
) -> AlertResult:
|
|
215
|
+
return await self._request(
|
|
216
|
+
"POST",
|
|
217
|
+
"/v1/alerts",
|
|
218
|
+
AlertResult,
|
|
219
|
+
json=body(Alert, title=title, message=message, severity=severity, group_key=group_key),
|
|
220
|
+
headers=key_header(idempotency_key),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def get_delivery(self, id: int) -> Delivery:
|
|
224
|
+
return await self._request("GET", delivery_path(id), Delivery)
|
|
225
|
+
|
|
226
|
+
async def register_heartbeat(
|
|
227
|
+
self,
|
|
228
|
+
id: str,
|
|
229
|
+
*,
|
|
230
|
+
title: str,
|
|
231
|
+
interval_seconds: int,
|
|
232
|
+
grace_seconds: int = 0,
|
|
233
|
+
severity: Severity | str = Severity.WARNING,
|
|
234
|
+
notify_on_recovery: bool = False,
|
|
235
|
+
) -> Heartbeat:
|
|
236
|
+
return await self._request(
|
|
237
|
+
"POST",
|
|
238
|
+
"/v1/heartbeats",
|
|
239
|
+
Heartbeat,
|
|
240
|
+
json=body(
|
|
241
|
+
HeartbeatInput,
|
|
242
|
+
id=id,
|
|
243
|
+
title=title,
|
|
244
|
+
interval_seconds=interval_seconds,
|
|
245
|
+
grace_seconds=grace_seconds,
|
|
246
|
+
severity=severity,
|
|
247
|
+
notify_on_recovery=notify_on_recovery,
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
async def check_in(self, id: str) -> Heartbeat:
|
|
252
|
+
return await self._request(
|
|
253
|
+
"POST", "/v1/heartbeats/check-in", Heartbeat, json=body(CloseIssue, id=id)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
async def list_heartbeats(self) -> list[Heartbeat]:
|
|
257
|
+
return await self._request("GET", "/v1/heartbeats", list[Heartbeat])
|
|
258
|
+
|
|
259
|
+
async def delete_heartbeat(self, id: str) -> None:
|
|
260
|
+
return await self._request("DELETE", "/v1/heartbeat", None, params=body(CloseIssue, id=id))
|
|
261
|
+
|
|
262
|
+
async def health(self) -> Health:
|
|
263
|
+
return await self._request("GET", "/healthz", Health)
|
|
264
|
+
|
|
265
|
+
async def readiness(self) -> Health:
|
|
266
|
+
return await self._request("GET", "/readyz", Health)
|
|
267
|
+
|
|
268
|
+
async def metrics(self) -> str:
|
|
269
|
+
return await self._request("GET", "/metrics", str)
|
flares_client/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Sanitized, distinguishable client errors."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FlaresError(Exception):
|
|
5
|
+
"""Base class for Flares client errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ValidationError(FlaresError, ValueError):
|
|
9
|
+
"""Invalid client configuration or request arguments."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TransportError(FlaresError):
|
|
13
|
+
def __init__(self, *, timed_out: bool = False):
|
|
14
|
+
self.timed_out = timed_out
|
|
15
|
+
super().__init__(
|
|
16
|
+
"API request failed or timed out; delivery may have occurred. "
|
|
17
|
+
"Reuse the alert idempotency key or check issue state before retrying"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HTTPError(FlaresError):
|
|
22
|
+
def __init__(self, status_code: int):
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
super().__init__(f"API returned HTTP {status_code}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DecodeError(FlaresError):
|
|
28
|
+
def __init__(self):
|
|
29
|
+
super().__init__("API returned an invalid response")
|
flares_client/models.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Typed API models. Response objects accept additive server fields."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
IssueId = Annotated[str, Field(strict=True, min_length=1, max_length=200)]
|
|
10
|
+
Title = Annotated[str, Field(strict=True, min_length=1, max_length=250)]
|
|
11
|
+
Message = Annotated[str, Field(strict=True, min_length=1, max_length=1024)]
|
|
12
|
+
Interval = Annotated[int, Field(strict=True, ge=1, le=31_536_000)]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Severity(StrEnum):
|
|
16
|
+
INFO = "info"
|
|
17
|
+
WARNING = "warning"
|
|
18
|
+
CRITICAL = "critical"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IssueStatus(StrEnum):
|
|
22
|
+
OPEN = "open"
|
|
23
|
+
CLOSED = "closed"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotificationStatus(StrEnum):
|
|
27
|
+
PENDING = "pending"
|
|
28
|
+
SENT = "sent"
|
|
29
|
+
FAILED = "failed"
|
|
30
|
+
UNKNOWN = "unknown"
|
|
31
|
+
NOT_ATTEMPTED = "not_attempted"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Request(BaseModel):
|
|
35
|
+
model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OpenIssue(Request):
|
|
39
|
+
id: IssueId
|
|
40
|
+
title: Title | None = None
|
|
41
|
+
message: Message | None = None
|
|
42
|
+
severity: Severity = Severity.WARNING
|
|
43
|
+
remind_every_seconds: Interval | None = None
|
|
44
|
+
notify_on_resolution: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CloseIssue(Request):
|
|
48
|
+
id: IssueId
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Alert(Request):
|
|
52
|
+
title: Title
|
|
53
|
+
message: Message
|
|
54
|
+
severity: Severity = Severity.WARNING
|
|
55
|
+
group_key: IssueId | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class HeartbeatInput(Request):
|
|
59
|
+
id: IssueId
|
|
60
|
+
title: Title
|
|
61
|
+
interval_seconds: Interval
|
|
62
|
+
grace_seconds: Annotated[int, Field(strict=True, ge=0, le=31_536_000)] = 0
|
|
63
|
+
severity: Severity = Severity.WARNING
|
|
64
|
+
notify_on_recovery: bool = False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Notification(BaseModel):
|
|
68
|
+
status: NotificationStatus
|
|
69
|
+
error: str | None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Issue(BaseModel):
|
|
73
|
+
id: str
|
|
74
|
+
status: IssueStatus
|
|
75
|
+
title: str
|
|
76
|
+
message: str
|
|
77
|
+
severity: Severity
|
|
78
|
+
remind_every_seconds: int | None
|
|
79
|
+
notify_on_resolution: bool
|
|
80
|
+
delivery_id: int | None
|
|
81
|
+
created_at: datetime
|
|
82
|
+
updated_at: datetime
|
|
83
|
+
opened_at: datetime
|
|
84
|
+
closed_at: datetime | None
|
|
85
|
+
opening_count: int
|
|
86
|
+
notification: Notification
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class MutationResult(BaseModel):
|
|
90
|
+
delivery_id: int | None
|
|
91
|
+
issue: Issue
|
|
92
|
+
changed: bool
|
|
93
|
+
notification: Notification
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AlertResult(BaseModel):
|
|
97
|
+
delivery_id: int
|
|
98
|
+
notification: Notification
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class IssueList(BaseModel):
|
|
102
|
+
items: list[Issue]
|
|
103
|
+
total: int
|
|
104
|
+
limit: int
|
|
105
|
+
offset: int
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Heartbeat(BaseModel):
|
|
109
|
+
id: str
|
|
110
|
+
title: str
|
|
111
|
+
interval_seconds: int
|
|
112
|
+
grace_seconds: int
|
|
113
|
+
severity: Severity
|
|
114
|
+
notify_on_recovery: bool
|
|
115
|
+
last_seen: int
|
|
116
|
+
due_at: int
|
|
117
|
+
overdue: bool
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class DestinationOutcome(BaseModel):
|
|
121
|
+
destination: str
|
|
122
|
+
attempts: int
|
|
123
|
+
notification: Notification
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Delivery(BaseModel):
|
|
127
|
+
id: int
|
|
128
|
+
title: str
|
|
129
|
+
message: str
|
|
130
|
+
severity: Severity
|
|
131
|
+
kind: str
|
|
132
|
+
count: int
|
|
133
|
+
created_at: int
|
|
134
|
+
next_attempt_at: int
|
|
135
|
+
notification: Notification
|
|
136
|
+
destinations: list[DestinationOutcome]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Health(BaseModel):
|
|
140
|
+
status: str
|
flares_client/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: flares-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Synchronous and asynchronous HTTP clients for Flares
|
|
5
|
+
Project-URL: Repository, https://github.com/torpedro/flares
|
|
6
|
+
Project-URL: Issues, https://github.com/torpedro/flares/issues
|
|
7
|
+
Project-URL: Documentation, https://github.com/torpedro/flares/blob/main/clients/python/README.md
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: httpx<1,>=0.28
|
|
11
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Flares Python client
|
|
15
|
+
|
|
16
|
+
Requires Python 3.11+. Install the built wheel with `pip install flares_client-*.whl`, or install from this directory with `uv pip install .`.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from flares_client import Client
|
|
20
|
+
|
|
21
|
+
with Client("http://localhost:8000", "your-api-token") as client:
|
|
22
|
+
result = client.open_issue("backup", title="Backup failed", severity="critical")
|
|
23
|
+
print(result.issue.status, result.notification.status)
|
|
24
|
+
client.close_issue("backup")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from flares_client import AsyncClient
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def notify():
|
|
32
|
+
async with AsyncClient("http://localhost:8000", "your-api-token") as client:
|
|
33
|
+
return await client.alert("Backup", "Finished", idempotency_key="backup-run-123")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Both clients have the same operations: `open_issue`, `close_issue`, `get_issue`, `list_issues`, `alert`, `get_delivery`, `register_heartbeat`, `check_in`, `list_heartbeats`, `delete_heartbeat`, `health`, `readiness`, and `metrics`. Responses are typed Pydantic models, except `metrics` (text) and `delete_heartbeat` (`None`). Models and enums are exported from `flares_client`. Request options match the HTTP API; list options are `status`, `limit=100`, and `offset=0`.
|
|
37
|
+
|
|
38
|
+
Use `timeout=15.0` to customize HTTPX's network-operation timeouts. Keep a client open to reuse connections; context managers close it, or call `close()` / `await aclose()` explicitly. Library configuration is passed directly; the server and CLI retain their YAML configuration.
|
|
39
|
+
|
|
40
|
+
Catch `ValidationError`, `TransportError` (with `timed_out`), `HTTPError` (with `status_code`), or `DecodeError`; all inherit `FlaresError`. Error messages do not include secrets or provider response bodies. A successful HTTP response with notification status `failed` is a normal result, not an exception. `pending` means accepted, not delivered. Requests never retry or follow redirects automatically. Reuse an explicit alert idempotency key after an uncertain outcome.
|
|
41
|
+
|
|
42
|
+
Development (from this directory): `uv sync --locked`, `uv run pytest tests`, `uv build`. Run the repository contract suite after building the server and Rust contract driver; see the root README.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
flares_client/__init__.py,sha256=2Zlc4VISg-yTCrRvMjz2bG4UDUv_jld5M2ghYMxuips,939
|
|
2
|
+
flares_client/_common.py,sha256=dHWx2-dab6LTRA68hUpc1tnSzNOl-HiPVTG0dAJbICI,3252
|
|
3
|
+
flares_client/client.py,sha256=wErC4stKHp3oWMj_lvuCBdNrkw329eULXOsuuPXWXE4,8800
|
|
4
|
+
flares_client/errors.py,sha256=niF00bkP_bQDuffQFzjqXs5jCK6bTC8ydbJdD2DyMC8,850
|
|
5
|
+
flares_client/models.py,sha256=Ggj7X-bzIYdq8kfyjtUW7BEysVrcfsQqEp6H9GeI0SU,2985
|
|
6
|
+
flares_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
flares_client-0.1.0.dist-info/METADATA,sha256=I6781Bt_6-sYKVPjxSSXmQf0XncynmQeSpRf65hYT8g,2619
|
|
8
|
+
flares_client-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
9
|
+
flares_client-0.1.0.dist-info/RECORD,,
|