uptimer-python-sdk 1.6.0rc0__tar.gz → 1.7.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.
Files changed (68) hide show
  1. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/CHANGELOG.md +14 -0
  2. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/PKG-INFO +195 -2
  3. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/README.md +193 -0
  4. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/Taskfile.yml +13 -2
  5. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/pyproject.toml +10 -2
  6. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/__init__.py +1 -1
  7. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/client.py +11 -3
  8. uptimer_python_sdk-1.7.0/src/uptimer/endpoints/subjects.py +439 -0
  9. uptimer_python_sdk-1.7.0/src/uptimer/endpoints/v1.py +145 -0
  10. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/models/v2/__init__.py +26 -0
  11. uptimer_python_sdk-1.7.0/src/uptimer/models/v2/acknowledgement.py +99 -0
  12. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/models/v2/deserialize.py +47 -0
  13. uptimer_python_sdk-1.7.0/src/uptimer/models/v2/maintenance.py +41 -0
  14. uptimer_python_sdk-1.7.0/src/uptimer/models/v2/subject.py +75 -0
  15. uptimer_python_sdk-1.7.0/tests/integrations/test_acknowledgement.py +437 -0
  16. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/integrations/test_observations.py +22 -10
  17. uptimer_python_sdk-1.7.0/tests/integrations/test_subjects.py +126 -0
  18. uptimer_python_sdk-1.7.0/tests/test_acknowledgement.py +399 -0
  19. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/test_client.py +47 -5
  20. uptimer_python_sdk-1.7.0/tests/test_maintenance.py +271 -0
  21. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/test_model_namespace.py +17 -0
  22. uptimer_python_sdk-1.7.0/tests/test_subjects_endpoint.py +223 -0
  23. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/uv.lock +1 -1
  24. uptimer_python_sdk-1.6.0rc0/src/uptimer/endpoints/subjects.py +0 -154
  25. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/.gitignore +0 -0
  26. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/.pre-commit-config.yaml +0 -0
  27. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/.vscode/extensions.json +0 -0
  28. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/.vscode/launch.json +0 -0
  29. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/.vscode/settings.json +0 -0
  30. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/.vscode/tasks.json +0 -0
  31. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/LICENSE +0 -0
  32. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/NOTICE +0 -0
  33. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/examples/01_client_setup.py +0 -0
  34. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/examples/02_list_workspaces.py +0 -0
  35. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/examples/03_list_locations.py +0 -0
  36. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/examples/04_create_website_monitor.py +0 -0
  37. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/examples/05_open_incidents.py +0 -0
  38. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/examples/README.md +0 -0
  39. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/examples/__init__.py +0 -0
  40. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/compat.py +0 -0
  41. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/endpoints/__init__.py +0 -0
  42. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/endpoints/endpoint.py +0 -0
  43. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/endpoints/incidents.py +0 -0
  44. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/endpoints/locations.py +0 -0
  45. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/endpoints/v2.py +0 -0
  46. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/endpoints/websites.py +0 -0
  47. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/endpoints/workspaces.py +0 -0
  48. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/errors.py +0 -0
  49. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/http.py +0 -0
  50. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/models/__init__.py +0 -0
  51. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/models/errors.py +0 -0
  52. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/models/v2/incident.py +0 -0
  53. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/models/v2/location.py +0 -0
  54. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/models/v2/monitor.py +0 -0
  55. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/models/v2/observation.py +0 -0
  56. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/models/v2/workspace.py +0 -0
  57. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/src/uptimer/py.typed +0 -0
  58. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/__init__.py +0 -0
  59. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/conftest.py +0 -0
  60. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/integrations/__init__.py +0 -0
  61. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/integrations/conftest.py +0 -0
  62. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/integrations/test_workspaces.py +0 -0
  63. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/test_endpoint.py +0 -0
  64. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/test_integration_v2.py +0 -0
  65. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/test_observations_endpoint.py +0 -0
  66. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/test_v2_endpoints.py +0 -0
  67. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tests/test_workspaces_endpoint.py +0 -0
  68. {uptimer_python_sdk-1.6.0rc0 → uptimer_python_sdk-1.7.0}/tools/mypy-diff.sh +0 -0
@@ -1,3 +1,17 @@
1
+ ## 1.7.0 (2026-09-14)
2
+
3
+ ### Feat
4
+
5
+ - change maintenance end time on Custom subjects
6
+ - subject maintenance window client
7
+ - acknowledge Website and Custom incidents on separate clients
8
+
9
+ ## 1.6.0 (2026-09-08)
10
+
11
+ ### Feat
12
+
13
+ - **sdk**: list and create Website/Custom subjects
14
+
1
15
  ## 1.6.0-rc.0 (2026-08-30)
2
16
 
3
17
  ### Feat
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.5
1
+ Metadata-Version: 2.4
2
2
  Name: uptimer-python-sdk
3
- Version: 1.6.0rc0
3
+ Version: 1.7.0
4
4
  Summary: A Python SDK for hosted and self-hosted Uptimer
5
5
  Project-URL: Homepage, https://myuptime.info
6
6
  Project-URL: Documentation, https://uptimer.myuptime.info/latest/reference/python-sdk/
@@ -153,6 +153,65 @@ except UptimerError: # base error, if you need one
153
153
  raise
154
154
  ```
155
155
 
156
+ ### Subjects: your custom monitoring
157
+
158
+ A **subject** is one monitored thing. Every subject is one of two kinds, and the
159
+ kind says how it is configured:
160
+
161
+ - **website** — Uptimer's own probe watches a URL, and the website check form
162
+ owns its signal and its rule;
163
+ - **custom** — yours, reporting through the signals you add to it.
164
+
165
+ Uptimer 1.6.0 splits its API along that line, and so does this SDK: website
166
+ monitoring is `client.v2.monitoring.websites`, and `client.v2.subjects` is the
167
+ **custom** half. Neither serves the other's subjects — passing a website
168
+ subject's slug to a `subjects` call is refused.
169
+
170
+ Requires Uptimer 1.6.0 or later.
171
+
172
+ ```python
173
+ from uptimer.client import UptimerClient
174
+ from uptimer.models.v2 import SUBJECT_KIND_CUSTOM, CreateSubjectRequest
175
+
176
+ client = UptimerClient(
177
+ api_key="your-api-key-here",
178
+ base_url="http://127.0.0.1:2517/api",
179
+ )
180
+
181
+ # The workspace's custom subjects. Website checks are not here.
182
+ for subject in client.v2.subjects.all("your-workspace-id"):
183
+ print(subject.id, subject.subject_kind, subject.signal_count)
184
+
185
+ # Create an empty custom subject. It arrives with nothing under it: no signal,
186
+ # no rule, no HTTP probe — add a signal to it in the Uptimer UI, then report to
187
+ # that signal with the observations API below.
188
+ created = client.v2.subjects.create(
189
+ CreateSubjectRequest(name="Nightly export", workspace_id="your-workspace-id"),
190
+ )
191
+ assert created.subject_kind == SUBJECT_KIND_CUSTOM
192
+ assert created.signal_count == 0
193
+
194
+ # `id` is the subject's slug — the same name the observation route addresses it
195
+ # by, and it never moves when the subject is renamed.
196
+ fetched = client.v2.subjects.get(created.id, workspace_id="your-workspace-id")
197
+ ```
198
+
199
+ `kind` and `subject_kind` are different fields on purpose. `kind` is `"subject"`
200
+ on every one of these objects — it says what you are holding, the way every v2
201
+ object does. `subject_kind` says how the subject is configured, and against a
202
+ 1.6.0 server everything these calls return reads `"custom"`; `is_custom` is the
203
+ typed way to read it. `SUBJECT_KIND_WEBSITE` and `is_website` stay in the model
204
+ for a payload from an older server.
205
+
206
+ **Website monitoring is not created here.** It needs a URL, an interval and
207
+ locations, so it has its own call — `client.v2.monitoring.websites.create` —
208
+ and asking for `subject_kind="website"` on this route is refused with a message
209
+ saying so.
210
+
211
+ **Signals and rules are added in the Uptimer UI.** Uptimer 1.6.0 also serves
212
+ them over the API, under `/v2/subjects/{subject}/signals` and
213
+ `/v2/subjects/{subject}/rules`; this SDK does not wrap those routes yet.
214
+
156
215
  ### Reporting your own observations
157
216
 
158
217
  Uptimer probes websites itself. For anything else — a cron job, a queue worker,
@@ -210,6 +269,140 @@ Retries are safe. An observation is identified by its signal, its `observed_at`
210
269
  and its labels, so re-sending the same one replaces it rather than counting
211
270
  twice.
212
271
 
272
+ ### Acknowledging an incident
273
+
274
+ **New in 1.7.0.** Acknowledging says a **person has seen** an open incident. It
275
+ changes nothing the engine decided — the verdict, the evidence, the close hold
276
+ and the alerting all carry on — and it is recorded once, with who and when.
277
+
278
+ Each kind of monitoring acknowledges through **its own family**, the same split
279
+ subjects follow: a website incident under its monitor on v1, a custom incident
280
+ under its subject on v2. Neither method falls back to the other, and there is no
281
+ kind-agnostic one: acknowledging is a claim about a specific incident, and an
282
+ SDK that guessed which family it belonged to could claim the wrong one.
283
+
284
+ **Availability.** These methods are part of the SDK's **1.7.0** release: they are
285
+ not in the published 1.6.0 package, so until 1.7.0 is on PyPI use them from a
286
+ checkout of this repository. They need a matching **Uptimer 1.7.0+** server —
287
+ the routes do not exist before that — and on
288
+ [myuptime.info](https://myuptime.info) they arrive when the hosted service picks
289
+ up the 1.7.0 API.
290
+
291
+ **Custom monitoring** — list the subject's open incidents, pick one, acknowledge
292
+ it by id:
293
+
294
+ ```python
295
+ from uptimer.client import UptimerClient
296
+
297
+ client = UptimerClient(
298
+ api_key="your-api-key-here",
299
+ base_url="http://127.0.0.1:2517/api",
300
+ )
301
+
302
+ subject = client.v2.subjects("payments-worker", "your-workspace-id")
303
+
304
+ # Open incidents of this subject: all of them, newest trouble first. A subject
305
+ # can have one open per rule, so each names the rule that opened it.
306
+ open_incidents = subject.incidents.all()
307
+ for incident in open_incidents:
308
+ print(incident.id, incident.rule_name, incident.status, incident.acknowledged)
309
+
310
+ # Nothing open is an ordinary answer, not an error.
311
+ if open_incidents:
312
+ # Acknowledge the one you mean, by the id the listing gave you. No body and
313
+ # no actor argument: the person recorded is the owner of the API key, at the
314
+ # time of the call.
315
+ target = open_incidents[0]
316
+ record = subject.incidents(target.id).acknowledge()
317
+ print(record.acknowledged_by, record.acknowledged_at, record.recorded)
318
+ ```
319
+
320
+ **Website monitoring** — the ids come from the workspace incident list this SDK
321
+ has had since 1.5.0, which already names each incident's monitor:
322
+
323
+ ```python
324
+ # An empty list means nothing is wrong: the loop simply does not run.
325
+ for incident in client.v2.incidents.all("your-workspace-id"):
326
+ record = client.v1.rules(incident.monitor_id).incidents(incident.id).acknowledge()
327
+ print(record.incident_id, record.acknowledged_by, record.recorded)
328
+ ```
329
+
330
+ `client.v1` exists for this one route. This is still a v2 client — website
331
+ monitors are read and written through `client.v2.monitoring.websites` — but
332
+ Uptimer serves website acknowledgement under `/v1/rules/...`, because website
333
+ monitoring is v1's resource and custom monitoring is v2's.
334
+
335
+ What the answer says:
336
+
337
+ | field | meaning |
338
+ |---|---|
339
+ | `recorded` | whether **this call** wrote it. `False` means it was already acknowledged and nothing changed |
340
+ | `acknowledged_by` / `acknowledged_at` | the record — on a repeat, the **first** person's name and time, not yours |
341
+ | `status` | the incident's condition, unchanged by acknowledging it |
342
+ | `monitor_id` | set for a website incident; `None` for a custom one |
343
+ | `subject_id` / `rule_id` | set for a custom incident; `None` for a website one |
344
+ | `closed_at` | set if the incident had already closed |
345
+
346
+ **Repeating it is safe.** A second call adds no second history entry and returns
347
+ the original name and time with `recorded=False` — so a retry after a timeout is
348
+ not a second claim.
349
+
350
+ **Refusals are raised, never worked around.** A `DefaultUptimerApiError` means
351
+ the incident is not this parent's — another monitor's, another subject's,
352
+ another workspace's, or the other kind of monitoring. Nothing is retried through
353
+ the other family.
354
+
355
+ **Closing cuts both ways, and the two are different.** A **first**
356
+ acknowledgement of an incident that has already closed is refused
357
+ (`Incident is closed`): there is nothing left to be on, and anything open now is
358
+ a different incident. But an incident acknowledged **while it was open** keeps
359
+ that record after it closes, so asking again is not an error — it answers the
360
+ original name and time with `recorded=False` and `closed_at` set. The look did
361
+ happen.
362
+
363
+ ### Maintenance windows
364
+
365
+ **New in 1.7.0.** A maintenance window holds back one subject's **problem**
366
+ notifications until a time you choose — for a deploy, a migration, anything that
367
+ will make it look broken on purpose. Monitoring, incidents and the timeline are
368
+ untouched, and **recoveries are never held back**: "it is back" is the message
369
+ you most want afterwards.
370
+
371
+ Same availability as acknowledgement above: the SDK's 1.7.0 release against an
372
+ uptimer 1.7.0+ server, Custom subjects only (a website check is put into
373
+ maintenance from its page in the dashboard).
374
+
375
+ ```python
376
+ maintenance = client.v2.subjects("payments-worker", "your-workspace-id").maintenance
377
+
378
+ # Nothing scheduled is None — an answer, not an error.
379
+ if maintenance.get() is None:
380
+ window = maintenance.start("2026-09-13T18:00:00Z")
381
+ print(window.active, window.ends_at, window.muted)
382
+
383
+ # The work is taking longer: move the end of the SAME window.
384
+ maintenance.update_end("2026-09-13T20:00:00Z")
385
+
386
+ # When it is done. Notifications are normal again immediately.
387
+ maintenance.cancel()
388
+ ```
389
+
390
+ `ends_at` is RFC 3339 and carries its own zone, for both `start` and
391
+ `update_end`. The window starts **immediately**.
392
+
393
+ `update_end` is a real update, not a cancel and a new window: it keeps the
394
+ window's identity and its start, so "since when have we been silencing this?"
395
+ keeps one answer and nothing sees the subject briefly leave maintenance. Moving
396
+ the end into the past raises rather than stopping the window — to stop it now,
397
+ `cancel()`.
398
+
399
+ `MaintenanceWindow` tells its three states apart by its fields — `active` true
400
+ is running, `cancelled_at` set is ended early, and neither is a window that ran
401
+ out — and `muted` says what waits, in the server's own words.
402
+
403
+ A past end time, a window already running, a website subject, or a caller who is
404
+ not an editor raise `DefaultUptimerApiError`. Reading takes the viewer role.
405
+
213
406
  ### Incident status
214
407
 
215
408
  `client.v2.incidents.all()` returns only **open** incidents. `status` carries the
@@ -137,6 +137,65 @@ except UptimerError: # base error, if you need one
137
137
  raise
138
138
  ```
139
139
 
140
+ ### Subjects: your custom monitoring
141
+
142
+ A **subject** is one monitored thing. Every subject is one of two kinds, and the
143
+ kind says how it is configured:
144
+
145
+ - **website** — Uptimer's own probe watches a URL, and the website check form
146
+ owns its signal and its rule;
147
+ - **custom** — yours, reporting through the signals you add to it.
148
+
149
+ Uptimer 1.6.0 splits its API along that line, and so does this SDK: website
150
+ monitoring is `client.v2.monitoring.websites`, and `client.v2.subjects` is the
151
+ **custom** half. Neither serves the other's subjects — passing a website
152
+ subject's slug to a `subjects` call is refused.
153
+
154
+ Requires Uptimer 1.6.0 or later.
155
+
156
+ ```python
157
+ from uptimer.client import UptimerClient
158
+ from uptimer.models.v2 import SUBJECT_KIND_CUSTOM, CreateSubjectRequest
159
+
160
+ client = UptimerClient(
161
+ api_key="your-api-key-here",
162
+ base_url="http://127.0.0.1:2517/api",
163
+ )
164
+
165
+ # The workspace's custom subjects. Website checks are not here.
166
+ for subject in client.v2.subjects.all("your-workspace-id"):
167
+ print(subject.id, subject.subject_kind, subject.signal_count)
168
+
169
+ # Create an empty custom subject. It arrives with nothing under it: no signal,
170
+ # no rule, no HTTP probe — add a signal to it in the Uptimer UI, then report to
171
+ # that signal with the observations API below.
172
+ created = client.v2.subjects.create(
173
+ CreateSubjectRequest(name="Nightly export", workspace_id="your-workspace-id"),
174
+ )
175
+ assert created.subject_kind == SUBJECT_KIND_CUSTOM
176
+ assert created.signal_count == 0
177
+
178
+ # `id` is the subject's slug — the same name the observation route addresses it
179
+ # by, and it never moves when the subject is renamed.
180
+ fetched = client.v2.subjects.get(created.id, workspace_id="your-workspace-id")
181
+ ```
182
+
183
+ `kind` and `subject_kind` are different fields on purpose. `kind` is `"subject"`
184
+ on every one of these objects — it says what you are holding, the way every v2
185
+ object does. `subject_kind` says how the subject is configured, and against a
186
+ 1.6.0 server everything these calls return reads `"custom"`; `is_custom` is the
187
+ typed way to read it. `SUBJECT_KIND_WEBSITE` and `is_website` stay in the model
188
+ for a payload from an older server.
189
+
190
+ **Website monitoring is not created here.** It needs a URL, an interval and
191
+ locations, so it has its own call — `client.v2.monitoring.websites.create` —
192
+ and asking for `subject_kind="website"` on this route is refused with a message
193
+ saying so.
194
+
195
+ **Signals and rules are added in the Uptimer UI.** Uptimer 1.6.0 also serves
196
+ them over the API, under `/v2/subjects/{subject}/signals` and
197
+ `/v2/subjects/{subject}/rules`; this SDK does not wrap those routes yet.
198
+
140
199
  ### Reporting your own observations
141
200
 
142
201
  Uptimer probes websites itself. For anything else — a cron job, a queue worker,
@@ -194,6 +253,140 @@ Retries are safe. An observation is identified by its signal, its `observed_at`
194
253
  and its labels, so re-sending the same one replaces it rather than counting
195
254
  twice.
196
255
 
256
+ ### Acknowledging an incident
257
+
258
+ **New in 1.7.0.** Acknowledging says a **person has seen** an open incident. It
259
+ changes nothing the engine decided — the verdict, the evidence, the close hold
260
+ and the alerting all carry on — and it is recorded once, with who and when.
261
+
262
+ Each kind of monitoring acknowledges through **its own family**, the same split
263
+ subjects follow: a website incident under its monitor on v1, a custom incident
264
+ under its subject on v2. Neither method falls back to the other, and there is no
265
+ kind-agnostic one: acknowledging is a claim about a specific incident, and an
266
+ SDK that guessed which family it belonged to could claim the wrong one.
267
+
268
+ **Availability.** These methods are part of the SDK's **1.7.0** release: they are
269
+ not in the published 1.6.0 package, so until 1.7.0 is on PyPI use them from a
270
+ checkout of this repository. They need a matching **Uptimer 1.7.0+** server —
271
+ the routes do not exist before that — and on
272
+ [myuptime.info](https://myuptime.info) they arrive when the hosted service picks
273
+ up the 1.7.0 API.
274
+
275
+ **Custom monitoring** — list the subject's open incidents, pick one, acknowledge
276
+ it by id:
277
+
278
+ ```python
279
+ from uptimer.client import UptimerClient
280
+
281
+ client = UptimerClient(
282
+ api_key="your-api-key-here",
283
+ base_url="http://127.0.0.1:2517/api",
284
+ )
285
+
286
+ subject = client.v2.subjects("payments-worker", "your-workspace-id")
287
+
288
+ # Open incidents of this subject: all of them, newest trouble first. A subject
289
+ # can have one open per rule, so each names the rule that opened it.
290
+ open_incidents = subject.incidents.all()
291
+ for incident in open_incidents:
292
+ print(incident.id, incident.rule_name, incident.status, incident.acknowledged)
293
+
294
+ # Nothing open is an ordinary answer, not an error.
295
+ if open_incidents:
296
+ # Acknowledge the one you mean, by the id the listing gave you. No body and
297
+ # no actor argument: the person recorded is the owner of the API key, at the
298
+ # time of the call.
299
+ target = open_incidents[0]
300
+ record = subject.incidents(target.id).acknowledge()
301
+ print(record.acknowledged_by, record.acknowledged_at, record.recorded)
302
+ ```
303
+
304
+ **Website monitoring** — the ids come from the workspace incident list this SDK
305
+ has had since 1.5.0, which already names each incident's monitor:
306
+
307
+ ```python
308
+ # An empty list means nothing is wrong: the loop simply does not run.
309
+ for incident in client.v2.incidents.all("your-workspace-id"):
310
+ record = client.v1.rules(incident.monitor_id).incidents(incident.id).acknowledge()
311
+ print(record.incident_id, record.acknowledged_by, record.recorded)
312
+ ```
313
+
314
+ `client.v1` exists for this one route. This is still a v2 client — website
315
+ monitors are read and written through `client.v2.monitoring.websites` — but
316
+ Uptimer serves website acknowledgement under `/v1/rules/...`, because website
317
+ monitoring is v1's resource and custom monitoring is v2's.
318
+
319
+ What the answer says:
320
+
321
+ | field | meaning |
322
+ |---|---|
323
+ | `recorded` | whether **this call** wrote it. `False` means it was already acknowledged and nothing changed |
324
+ | `acknowledged_by` / `acknowledged_at` | the record — on a repeat, the **first** person's name and time, not yours |
325
+ | `status` | the incident's condition, unchanged by acknowledging it |
326
+ | `monitor_id` | set for a website incident; `None` for a custom one |
327
+ | `subject_id` / `rule_id` | set for a custom incident; `None` for a website one |
328
+ | `closed_at` | set if the incident had already closed |
329
+
330
+ **Repeating it is safe.** A second call adds no second history entry and returns
331
+ the original name and time with `recorded=False` — so a retry after a timeout is
332
+ not a second claim.
333
+
334
+ **Refusals are raised, never worked around.** A `DefaultUptimerApiError` means
335
+ the incident is not this parent's — another monitor's, another subject's,
336
+ another workspace's, or the other kind of monitoring. Nothing is retried through
337
+ the other family.
338
+
339
+ **Closing cuts both ways, and the two are different.** A **first**
340
+ acknowledgement of an incident that has already closed is refused
341
+ (`Incident is closed`): there is nothing left to be on, and anything open now is
342
+ a different incident. But an incident acknowledged **while it was open** keeps
343
+ that record after it closes, so asking again is not an error — it answers the
344
+ original name and time with `recorded=False` and `closed_at` set. The look did
345
+ happen.
346
+
347
+ ### Maintenance windows
348
+
349
+ **New in 1.7.0.** A maintenance window holds back one subject's **problem**
350
+ notifications until a time you choose — for a deploy, a migration, anything that
351
+ will make it look broken on purpose. Monitoring, incidents and the timeline are
352
+ untouched, and **recoveries are never held back**: "it is back" is the message
353
+ you most want afterwards.
354
+
355
+ Same availability as acknowledgement above: the SDK's 1.7.0 release against an
356
+ uptimer 1.7.0+ server, Custom subjects only (a website check is put into
357
+ maintenance from its page in the dashboard).
358
+
359
+ ```python
360
+ maintenance = client.v2.subjects("payments-worker", "your-workspace-id").maintenance
361
+
362
+ # Nothing scheduled is None — an answer, not an error.
363
+ if maintenance.get() is None:
364
+ window = maintenance.start("2026-09-13T18:00:00Z")
365
+ print(window.active, window.ends_at, window.muted)
366
+
367
+ # The work is taking longer: move the end of the SAME window.
368
+ maintenance.update_end("2026-09-13T20:00:00Z")
369
+
370
+ # When it is done. Notifications are normal again immediately.
371
+ maintenance.cancel()
372
+ ```
373
+
374
+ `ends_at` is RFC 3339 and carries its own zone, for both `start` and
375
+ `update_end`. The window starts **immediately**.
376
+
377
+ `update_end` is a real update, not a cancel and a new window: it keeps the
378
+ window's identity and its start, so "since when have we been silencing this?"
379
+ keeps one answer and nothing sees the subject briefly leave maintenance. Moving
380
+ the end into the past raises rather than stopping the window — to stop it now,
381
+ `cancel()`.
382
+
383
+ `MaintenanceWindow` tells its three states apart by its fields — `active` true
384
+ is running, `cancelled_at` set is ended early, and neither is a window that ran
385
+ out — and `muted` says what waits, in the server's own words.
386
+
387
+ A past end time, a window already running, a website subject, or a caller who is
388
+ not an editor raise `DefaultUptimerApiError`. Reading takes the viewer role.
389
+
197
390
  ### Incident status
198
391
 
199
392
  `client.v2.incidents.all()` returns only **open** incidents. `status` carries the
@@ -1,5 +1,8 @@
1
1
  # Dev + git release tasks for the uptimer Python SDK. All tooling via `uv` (uv.lock).
2
2
  # PyPI publish lives in the private product repo: /work → task sdk:publish*
3
+ #
4
+ # HARD RULE: never publish an SDK prerelease (rc/a/b/dev) to TestPyPI or PyPI.
5
+ # OSS/SaaS may cut RCs; this package ships final X.Y.Z only. Verify locally.
3
6
  version: '3'
4
7
 
5
8
  tasks:
@@ -27,9 +30,17 @@ tasks:
27
30
  - task: test
28
31
 
29
32
  release:
30
- desc: 'Cut a release: cq, then `cz bump` (version + CHANGELOG + tag) + push. Commit first. Prefer manual tag when version is already set.'
33
+ desc: 'Cut a FINAL release only: cq, then `cz bump` (no --prerelease) + push. Never cut an SDK RC for PyPI. Commit first.'
31
34
  cmds:
32
35
  - task: cq
33
36
  - cz bump --yes
37
+ - |
38
+ ver=$(cz version --project)
39
+ case "$ver" in
40
+ *rc*|*RC*|*alpha*|*beta*|*dev*|*pre*)
41
+ echo "REFUSED: cz produced prerelease $ver — do not push or publish. SDK ships final X.Y.Z only." >&2
42
+ exit 1
43
+ ;;
44
+ esac
34
45
  - git push --follow-tags
35
- - 'echo ">> released v$(cz version --project) — publish from /work: task sdk:publish:test, then task sdk:publish"'
46
+ - 'echo ">> released v$(cz version --project) — publish from /work only if FINAL: task sdk:publish:test, then task sdk:publish"'
@@ -1,10 +1,14 @@
1
1
  [build-system]
2
- requires = ["hatchling"]
2
+ # Pinned below 1.28: from that version hatchling writes Metadata-Version 2.5,
3
+ # which twine 6.x (packaging 25) refuses to validate, so `twine check` fails on
4
+ # an artifact that is otherwise fine. 2.4 is what the release path can verify.
5
+ # Lift the pin once twine and packaging accept 2.5.
6
+ requires = ["hatchling<1.28"]
3
7
  build-backend = "hatchling.build"
4
8
 
5
9
  [project]
6
10
  name = "uptimer-python-sdk"
7
- version = "1.6.0-rc.0"
11
+ version = "1.7.0"
8
12
  description = "A Python SDK for hosted and self-hosted Uptimer"
9
13
  readme = "README.md"
10
14
  requires-python = ">=3.9"
@@ -26,6 +30,10 @@ include = ["LICENSE", "NOTICE", "README.md"]
26
30
  [tool.commitizen]
27
31
  name = "cz_conventional_commits"
28
32
  tag_format = "$version"
33
+ # Create ANNOTATED tags: `git push --follow-tags` in `task release` SKIPS
34
+ # lightweight ones, so the 1.7.0 bump commit went up without its tag and the
35
+ # tag had to be pushed by hand afterwards.
36
+ annotated_tag = true
29
37
  version_scheme = "semver2"
30
38
  version_provider = "uv"
31
39
  update_changelog_on_bump = true
@@ -9,4 +9,4 @@ uptimer 1.6.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-rc.0"
12
+ __version__ = "1.6.0"
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from typing import cast
4
4
 
5
5
  from uptimer.compat import ensure_v2_supported
6
+ from uptimer.endpoints.v1 import V1Endpoint
6
7
  from uptimer.endpoints.v2 import V2Endpoint
7
8
  from uptimer.http import UptimerHttpLib
8
9
 
@@ -14,14 +15,20 @@ class UptimerClient:
14
15
  Resources are reached through the API version that serves them:
15
16
  `client.v2.workspaces`, `client.v2.locations`, `client.v2.incidents`,
16
17
  `client.v2.monitoring.websites` and
17
- `client.v2.subjects(subject).signals(signal).observations`. This SDK does
18
- not speak API v1 — see the migration note in the README if you are coming
19
- from 0.4.x.
18
+ `client.v2.subjects(subject).signals(signal).observations`.
19
+
20
+ This is a v2 client. `client.v1` exists for one thing only: uptimer 1.7.0
21
+ serves WEBSITE incident acknowledgement under `/v1/rules/...`, because
22
+ website monitoring is v1's resource and custom monitoring is v2's. Reading
23
+ and writing website monitors themselves stays on
24
+ `client.v2.monitoring.websites` — see the migration note in the README if
25
+ you are coming from 0.4.x.
20
26
 
21
27
  `version()` and the compatibility helpers stay here rather than under a
22
28
  version namespace, because `/version` is shared and unversioned.
23
29
  """
24
30
 
31
+ v1: V1Endpoint
25
32
  v2: V2Endpoint
26
33
 
27
34
  def __init__(self, api_key: str, base_url: str):
@@ -30,6 +37,7 @@ class UptimerClient:
30
37
  self._wire()
31
38
 
32
39
  def _wire(self) -> None:
40
+ self.v1 = V1Endpoint(self._http_lib)
33
41
  self.v2 = V2Endpoint(self._http_lib)
34
42
 
35
43
  def version(self) -> str: