uptimer-python-sdk 1.7.0__py3-none-any.whl → 1.8.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.
uptimer/__init__.py CHANGED
@@ -4,9 +4,9 @@ Uptimer Python SDK.
4
4
  Targets Uptimer API v2 only. Code written against 0.4.x keeps working against
5
5
  the server — API v1 is unchanged and supported — but must stay on the 0.4.x SDK.
6
6
 
7
- The version tracks the uptimer release this SDK targets: 1.6.x speaks to
8
- uptimer 1.6.0 and later. Patch numbers are independent, so an SDK fix can ship
7
+ The version tracks the uptimer release this SDK targets: 1.8.x speaks to
8
+ uptimer 1.8.0 and later. Patch numbers are independent, so an SDK fix can ship
9
9
  without a server release. See product Decision 0013.
10
10
  """
11
11
 
12
- __version__ = "1.6.0"
12
+ __version__ = "1.8.0"
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from uptimer.endpoints.endpoint import BaseEndpoint
6
+ from uptimer.models.v2 import from_api_subject_delivery
7
+
8
+ if TYPE_CHECKING:
9
+ from uptimer.http import UptimerHttpLib
10
+ from uptimer.models.v2 import DeliverySelection, SubjectAlertDelivery
11
+
12
+
13
+ class AlertDeliveryEndpoint(BaseEndpoint):
14
+ """
15
+ Which destinations ONE subject tells, and about what (uptimer 1.8.0).
16
+
17
+ The choice lives on the subject rather than on the workspace, and that is
18
+ the whole point of it: the marketing site telling nobody must not stop the
19
+ payments API paging the on-call.
20
+
21
+ Three operations: read the table, replace it, clear it. There is no "add one
22
+ row" — the resource IS the table, so a save says what the subject will have.
23
+ A merge would make a removed row reappear, which is the bug an operator
24
+ reports as "it keeps notifying the channel I deleted".
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ http: UptimerHttpLib,
30
+ parent_segments: str | list[str] | None = None,
31
+ workspace_id: str | None = None,
32
+ ):
33
+ super().__init__(http, "delivery", parent_segments)
34
+ self._workspace_id = workspace_id
35
+
36
+ def _params(self) -> dict | None:
37
+ return {"workspace_id": self._workspace_id} if self._workspace_id else None
38
+
39
+ def get(self) -> SubjectAlertDelivery:
40
+ """
41
+ Return the whole table, and what an empty one means right now.
42
+
43
+ `fallback` reads "workspace_default" or "silence", so a caller knows
44
+ what happens next without modelling the fallback itself.
45
+ """
46
+ response = self.http.client.get(self.url, params=self._params())
47
+ result = self.http.parse_response(response=response)
48
+ return from_api_subject_delivery(result)
49
+
50
+ def replace(self, selections: list[DeliverySelection]) -> SubjectAlertDelivery:
51
+ """
52
+ Replace the table with these rows.
53
+
54
+ Each row needs at least one alert kind — problem, no_data, recovery — a
55
+ destination of this workspace, and no duplicate destination. An empty
56
+ list is a real instruction ("use the workspace default, or send nothing
57
+ if there is none") and does the same as `clear()`.
58
+
59
+ Saving changes delivery ONLY: signals, rules, incidents, acknowledgement
60
+ and maintenance are untouched, and nothing is sent by saving.
61
+ """
62
+ body = {
63
+ "selections": [
64
+ {
65
+ "destination_id": row.destination_id,
66
+ "alert_kinds": list(row.alert_kinds),
67
+ }
68
+ for row in selections
69
+ ],
70
+ }
71
+ response = self.http.client.post(self.url, params=self._params(), json=body)
72
+ result = self.http.parse_response(response=response)
73
+ return from_api_subject_delivery(result)
74
+
75
+ def clear(self) -> SubjectAlertDelivery:
76
+ """
77
+ Empty the table.
78
+
79
+ The workspace default then speaks for this subject — or, with no
80
+ default, nothing does.
81
+ """
82
+ response = self.http.client.delete(self.url, params=self._params())
83
+ result = self.http.parse_response(response=response)
84
+ return from_api_subject_delivery(result)
@@ -0,0 +1,414 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+ from typing import TYPE_CHECKING
5
+
6
+ from uptimer.endpoints.endpoint import BaseEndpoint
7
+ from uptimer.models.v2 import (
8
+ DeleteDestinationResponse,
9
+ DeleteTransformationResponse,
10
+ TestDeliveryResponse,
11
+ from_api_delivery_record,
12
+ from_api_destination,
13
+ from_api_transformation,
14
+ from_api_transformation_preview,
15
+ samples_from_api,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ from uptimer.http import UptimerHttpLib
20
+ from uptimer.models.v2 import (
21
+ CreateDestinationRequest,
22
+ CreateTransformationRequest,
23
+ DeliveryRecord,
24
+ Destination,
25
+ Transformation,
26
+ TransformationPreview,
27
+ TransformationSample,
28
+ UpdateDestinationRequest,
29
+ UpdateTransformationRequest,
30
+ )
31
+
32
+
33
+ def _params(workspace_id: str | None, **extra: str | int | None) -> dict | None:
34
+ """
35
+ Build the query for a notifications call.
36
+
37
+ `workspace_id` is optional and settles an ambiguity rather than being
38
+ required: these resources have no slug of their own, so the server searches
39
+ your memberships when it is absent and says so if the answer is more than
40
+ one.
41
+ """
42
+ params: dict[str, str | int] = {"workspace_id": workspace_id} if workspace_id else {}
43
+ params.update({key: value for key, value in extra.items() if value is not None})
44
+ return params or None
45
+
46
+
47
+ class DestinationsEndpoint(BaseEndpoint):
48
+ """
49
+ The workspace's alert destinations (uptimer 1.8.0).
50
+
51
+ A destination is one place alerts can go: a Slack incoming webhook or any
52
+ HTTP endpoint. Which SUBJECT sends to which of them is decided elsewhere —
53
+ `client.v2.subjects(slug).delivery` — because that choice belongs to the
54
+ subject, not to the workspace.
55
+
56
+ Reading these needs the EDITOR role, not just membership: a destination
57
+ holds a webhook URL, and a URL is enough for anyone holding it to post into
58
+ your channel.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ http: UptimerHttpLib,
64
+ parent_segments: str | list[str] | None = None,
65
+ ):
66
+ super().__init__(http, "destinations", parent_segments)
67
+
68
+ def all(self, workspace_id: str | None = None) -> list[Destination]:
69
+ """Every destination in the workspace, by name."""
70
+ response = self.http.client.get(self.url, params=_params(workspace_id))
71
+ result = self.http.parse_response(response=response)
72
+ return [from_api_destination(item) for item in result]
73
+
74
+ def get(self, destination_id: int, workspace_id: str | None = None) -> Destination:
75
+ """One destination by id."""
76
+ response = self.http.client.get(
77
+ f"{self.url}/{destination_id}",
78
+ params=_params(workspace_id),
79
+ )
80
+ result = self.http.parse_response(response=response)
81
+ return from_api_destination(result)
82
+
83
+ def create(
84
+ self,
85
+ destination: CreateDestinationRequest,
86
+ workspace_id: str | None = None,
87
+ ) -> Destination:
88
+ """
89
+ Create one, enabled.
90
+
91
+ The FIRST destination in a workspace becomes its default whether or not
92
+ it asks: a workspace whose only destination is not the default notifies
93
+ nobody.
94
+
95
+ Raises DefaultUptimerApiError on a name that is empty or already used, an
96
+ address that is not http(s), a type that is neither slack nor webhook,
97
+ and a transformation that is not in this workspace.
98
+ """
99
+ response = self.http.client.post(
100
+ self.url,
101
+ params=_params(workspace_id),
102
+ json=asdict(destination),
103
+ )
104
+ result = self.http.parse_response(response=response)
105
+ return from_api_destination(result)
106
+
107
+ def update(
108
+ self,
109
+ destination_id: int,
110
+ destination: UpdateDestinationRequest,
111
+ workspace_id: str | None = None,
112
+ ) -> Destination:
113
+ """
114
+ Replace name, channel, URL, default and transformation.
115
+
116
+ The TYPE cannot be changed: a Slack hook and a plain webhook carry
117
+ different payloads, and converting one silently is how a channel goes
118
+ quiet. Delete and recreate instead.
119
+ """
120
+ response = self.http.client.post(
121
+ f"{self.url}/{destination_id}",
122
+ params=_params(workspace_id),
123
+ json=asdict(destination),
124
+ )
125
+ result = self.http.parse_response(response=response)
126
+ return from_api_destination(result)
127
+
128
+ def set_enabled(
129
+ self,
130
+ destination_id: int,
131
+ enabled: bool, # noqa: FBT001
132
+ workspace_id: str | None = None,
133
+ ) -> Destination:
134
+ """
135
+ Switch one on or off.
136
+
137
+ A disabled destination keeps its name, its address and every subject
138
+ selection pointing at it, and sends nothing until it is switched back
139
+ on.
140
+ """
141
+ response = self.http.client.post(
142
+ f"{self.url}/{destination_id}/enabled",
143
+ params=_params(workspace_id),
144
+ json={"enabled": enabled},
145
+ )
146
+ result = self.http.parse_response(response=response)
147
+ return from_api_destination(result)
148
+
149
+ def make_default(
150
+ self,
151
+ destination_id: int,
152
+ workspace_id: str | None = None,
153
+ ) -> Destination:
154
+ """
155
+ Make this the workspace fallback.
156
+
157
+ It is what a subject sends to when it has chosen nothing of its own.
158
+ Moving the default clears it from whichever destination held it. A
159
+ DISABLED destination is refused: a fallback that cannot receive anything
160
+ is silence wearing a label.
161
+ """
162
+ response = self.http.client.post(
163
+ f"{self.url}/{destination_id}/default",
164
+ params=_params(workspace_id),
165
+ )
166
+ result = self.http.parse_response(response=response)
167
+ return from_api_destination(result)
168
+
169
+ def send_test(
170
+ self,
171
+ destination_id: int,
172
+ workspace_id: str | None = None,
173
+ ) -> TestDeliveryResponse:
174
+ """
175
+ Send one real test message.
176
+
177
+ It is a REAL send: the same render, the same transport and the same
178
+ delivery record an alert produces, so a template that works here works
179
+ during an outage.
180
+
181
+ A destination that refuses the message raises DefaultUptimerApiError
182
+ carrying the far end's own words — the request was fine, the destination
183
+ was not — and the attempt is recorded either way.
184
+ """
185
+ response = self.http.client.post(
186
+ f"{self.url}/{destination_id}/test",
187
+ params=_params(workspace_id),
188
+ )
189
+ result = self.http.parse_response(response=response)
190
+ return TestDeliveryResponse(
191
+ message=result["message"],
192
+ destination_id=result["destination_id"],
193
+ workspace_id=result["workspace_id"],
194
+ )
195
+
196
+ def delete(
197
+ self,
198
+ destination_id: int,
199
+ workspace_id: str | None = None,
200
+ ) -> DeleteDestinationResponse:
201
+ """
202
+ Delete one, and every subject selection naming it.
203
+
204
+ The records it already wrote stay.
205
+
206
+ Deleting the default PROMOTES NOBODY: a workspace is allowed to have
207
+ none, and handing the role to whichever destination is next would start
208
+ sending a channel alerts it never asked for. Check `was_default` on the
209
+ answer. Delivery records stay — they record what was sent, and that
210
+ remains true after the destination is gone.
211
+ """
212
+ response = self.http.client.delete(
213
+ f"{self.url}/{destination_id}",
214
+ params=_params(workspace_id),
215
+ )
216
+ result = self.http.parse_response(response=response)
217
+ return DeleteDestinationResponse(
218
+ message=result["message"],
219
+ destination_id=result["destination_id"],
220
+ workspace_id=result["workspace_id"],
221
+ was_default=result.get("was_default", False),
222
+ )
223
+
224
+
225
+ class TransformationsEndpoint(BaseEndpoint):
226
+ """
227
+ The workspace's outbound payload templates (uptimer 1.8.0).
228
+
229
+ A transformation is a named template for what a destination receives — a
230
+ PagerDuty event, your own JSON, a line of text. A destination with none gets
231
+ Uptimer's built-in Slack-shaped message.
232
+ """
233
+
234
+ def __init__(
235
+ self,
236
+ http: UptimerHttpLib,
237
+ parent_segments: str | list[str] | None = None,
238
+ ):
239
+ super().__init__(http, "transformations", parent_segments)
240
+
241
+ def all(self, workspace_id: str | None = None) -> list[Transformation]:
242
+ """Every transformation in the workspace, by name."""
243
+ response = self.http.client.get(self.url, params=_params(workspace_id))
244
+ result = self.http.parse_response(response=response)
245
+ return [from_api_transformation(item) for item in result]
246
+
247
+ def samples(self) -> list[TransformationSample]:
248
+ """
249
+ Return the three messages every template is judged against.
250
+
251
+ Each carries every field it may read. No workspace: the samples are the product's, not a tenant's. They are
252
+ the same fixtures the editor shows and the same ones a save is judged
253
+ against, so you can render locally and get the answer this API would
254
+ give.
255
+ """
256
+ response = self.http.client.get(f"{self.url}/samples")
257
+ result = self.http.parse_response(response=response)
258
+ return samples_from_api(result)
259
+
260
+ def preview(
261
+ self,
262
+ template: str,
263
+ workspace_id: str | None = None,
264
+ ) -> TransformationPreview:
265
+ """
266
+ Render a template against every sample, storing nothing.
267
+
268
+ `passed` is exactly the condition a create or an update enforces, so
269
+ this answers "will a write be accepted" before attempting one — and
270
+ says, per sample, what is wrong when it will not.
271
+ """
272
+ response = self.http.client.post(
273
+ f"{self.url}/preview",
274
+ params=_params(workspace_id),
275
+ json={"template": template},
276
+ )
277
+ result = self.http.parse_response(response=response)
278
+ return from_api_transformation_preview(result)
279
+
280
+ def get(
281
+ self,
282
+ transformation_id: int,
283
+ workspace_id: str | None = None,
284
+ ) -> Transformation:
285
+ """One transformation by id."""
286
+ response = self.http.client.get(
287
+ f"{self.url}/{transformation_id}",
288
+ params=_params(workspace_id),
289
+ )
290
+ result = self.http.parse_response(response=response)
291
+ return from_api_transformation(result)
292
+
293
+ def create(
294
+ self,
295
+ transformation: CreateTransformationRequest,
296
+ workspace_id: str | None = None,
297
+ ) -> Transformation:
298
+ """
299
+ Store one, if it renders every sample.
300
+
301
+ There is no force flag. A template that breaks one message raises
302
+ DefaultUptimerApiError naming the sample that broke, because a template
303
+ that cannot render a recovery would fail at the moment it mattered.
304
+ """
305
+ response = self.http.client.post(
306
+ self.url,
307
+ params=_params(workspace_id),
308
+ json=asdict(transformation),
309
+ )
310
+ result = self.http.parse_response(response=response)
311
+ return from_api_transformation(result)
312
+
313
+ def update(
314
+ self,
315
+ transformation_id: int,
316
+ transformation: UpdateTransformationRequest,
317
+ workspace_id: str | None = None,
318
+ ) -> Transformation:
319
+ """
320
+ Replace its name and template, judged by the same rule.
321
+
322
+ A refused edit leaves the STORED template exactly as it was.
323
+ """
324
+ response = self.http.client.post(
325
+ f"{self.url}/{transformation_id}",
326
+ params=_params(workspace_id),
327
+ json=asdict(transformation),
328
+ )
329
+ result = self.http.parse_response(response=response)
330
+ return from_api_transformation(result)
331
+
332
+ def delete(
333
+ self,
334
+ transformation_id: int,
335
+ workspace_id: str | None = None,
336
+ ) -> DeleteTransformationResponse:
337
+ """Delete one. Destinations using it fall back to the built-in message."""
338
+ response = self.http.client.delete(
339
+ f"{self.url}/{transformation_id}",
340
+ params=_params(workspace_id),
341
+ )
342
+ result = self.http.parse_response(response=response)
343
+ return DeleteTransformationResponse(
344
+ message=result["message"],
345
+ transformation_id=result["transformation_id"],
346
+ workspace_id=result["workspace_id"],
347
+ )
348
+
349
+
350
+ class DeliveriesEndpoint(BaseEndpoint):
351
+ """
352
+ The delivery log (uptimer 1.8.0).
353
+
354
+ What was actually sent, and what the far end said. It is a READ. Nothing here sends, resends or changes a destination — Uptimer
355
+ sends once, in the background, and this is the record. Attempts are kept 30
356
+ days.
357
+ """
358
+
359
+ def __init__(
360
+ self,
361
+ http: UptimerHttpLib,
362
+ parent_segments: str | list[str] | None = None,
363
+ ):
364
+ super().__init__(http, "deliveries", parent_segments)
365
+
366
+ def all(
367
+ self,
368
+ workspace_id: str | None = None,
369
+ destination_id: int | None = None,
370
+ undelivered: bool = False, # noqa: FBT001, FBT002
371
+ ) -> list[DeliveryRecord]:
372
+ """
373
+ Return recorded attempts, newest first.
374
+
375
+ `destination_id` narrows the log to one destination, and `undelivered`
376
+ to the attempts that were not accepted — the two filters the Delivery
377
+ page has.
378
+ """
379
+ params = _params(
380
+ workspace_id,
381
+ destination_id=destination_id,
382
+ undelivered="true" if undelivered else None,
383
+ )
384
+ response = self.http.client.get(self.url, params=params)
385
+ result = self.http.parse_response(response=response)
386
+ return [from_api_delivery_record(item) for item in result]
387
+
388
+
389
+ class NotificationsEndpoint(BaseEndpoint):
390
+ """
391
+ Where a workspace's alerts can go, and what they look like (uptimer 1.8.0).
392
+
393
+ Three collections, and one thing that is NOT here: which destinations a
394
+ subject tells. That belongs to the subject —
395
+ `client.v2.subjects(slug).delivery` and
396
+ `client.v2.monitoring.websites(id).delivery` — because a workspace-wide
397
+ route says one thing for everything a team watches, and the point of this
398
+ release is that it no longer has to.
399
+ """
400
+
401
+ destinations: DestinationsEndpoint
402
+ transformations: TransformationsEndpoint
403
+ deliveries: DeliveriesEndpoint
404
+
405
+ def __init__(
406
+ self,
407
+ http: UptimerHttpLib,
408
+ parent_segments: str | list[str] | None = None,
409
+ ):
410
+ super().__init__(http, "notifications", parent_segments)
411
+ mine = [*self._parent_segments, self.segment]
412
+ self.destinations = DestinationsEndpoint(http, mine)
413
+ self.transformations = TransformationsEndpoint(http, mine)
414
+ self.deliveries = DeliveriesEndpoint(http, mine)