slotlock 0.3.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.
- slotlock/__init__.py +17 -0
- slotlock/_operations.py +829 -0
- slotlock/_transport.py +238 -0
- slotlock/py.typed +0 -0
- slotlock-0.3.0.dist-info/METADATA +48 -0
- slotlock-0.3.0.dist-info/RECORD +7 -0
- slotlock-0.3.0.dist-info/WHEEL +4 -0
slotlock/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""A client for Slotlock's REST API: the agent tools and the application API, from Python.
|
|
2
|
+
|
|
3
|
+
from slotlock import Client
|
|
4
|
+
|
|
5
|
+
slotlock = Client("https://calendar.example.com", api_key)
|
|
6
|
+
hold = slotlock.acquire_hold(resource_id=..., start=..., end=..., ttl_seconds=600)["hold"]
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._operations import OPERATIONS, Operations
|
|
10
|
+
from ._transport import UNSET, SlotlockError, Unset, __version__
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Client(Operations):
|
|
14
|
+
"""A Slotlock server, reached with one API key. `Transport` describes the arguments."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["OPERATIONS", "UNSET", "Client", "SlotlockError", "Unset", "__version__"]
|
slotlock/_operations.py
ADDED
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
# Generated from openapi.json by clients/generate.ts. Do not edit: change the server, then run
|
|
2
|
+
# SLOTLOCK_WRITE_GENERATED=1 npx vitest run src/__tests__/generated.test.ts
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._transport import UNSET, Operation, Transport, Unset
|
|
8
|
+
|
|
9
|
+
#: Every operation, by name: its method, path, path parameters and query parameters.
|
|
10
|
+
OPERATIONS: dict[str, Operation] = {
|
|
11
|
+
"slotlock_list_resources": Operation(
|
|
12
|
+
"slotlock_list_resources",
|
|
13
|
+
"GET",
|
|
14
|
+
"/v1/resources",
|
|
15
|
+
(),
|
|
16
|
+
("cursor", "limit"),
|
|
17
|
+
),
|
|
18
|
+
"slotlock_create_resource": Operation(
|
|
19
|
+
"slotlock_create_resource",
|
|
20
|
+
"POST",
|
|
21
|
+
"/v1/resources",
|
|
22
|
+
(),
|
|
23
|
+
(),
|
|
24
|
+
),
|
|
25
|
+
"slotlock_get_free_busy": Operation(
|
|
26
|
+
"slotlock_get_free_busy",
|
|
27
|
+
"GET",
|
|
28
|
+
"/v1/free-busy",
|
|
29
|
+
(),
|
|
30
|
+
("resource_ids", "start", "end"),
|
|
31
|
+
),
|
|
32
|
+
"slotlock_find_next_available": Operation(
|
|
33
|
+
"slotlock_find_next_available",
|
|
34
|
+
"GET",
|
|
35
|
+
"/v1/next-available",
|
|
36
|
+
(),
|
|
37
|
+
("resource_ids", "start", "end", "duration_minutes"),
|
|
38
|
+
),
|
|
39
|
+
"slotlock_list_events": Operation(
|
|
40
|
+
"slotlock_list_events",
|
|
41
|
+
"GET",
|
|
42
|
+
"/v1/events",
|
|
43
|
+
(),
|
|
44
|
+
("resource_ids", "start", "end", "cursor", "limit"),
|
|
45
|
+
),
|
|
46
|
+
"slotlock_create_event": Operation(
|
|
47
|
+
"slotlock_create_event",
|
|
48
|
+
"POST",
|
|
49
|
+
"/v1/events",
|
|
50
|
+
(),
|
|
51
|
+
(),
|
|
52
|
+
),
|
|
53
|
+
"slotlock_get_event": Operation(
|
|
54
|
+
"slotlock_get_event",
|
|
55
|
+
"GET",
|
|
56
|
+
"/v1/events/{event_id}",
|
|
57
|
+
("event_id",),
|
|
58
|
+
(),
|
|
59
|
+
),
|
|
60
|
+
"slotlock_update_event": Operation(
|
|
61
|
+
"slotlock_update_event",
|
|
62
|
+
"PATCH",
|
|
63
|
+
"/v1/events/{event_id}",
|
|
64
|
+
("event_id",),
|
|
65
|
+
(),
|
|
66
|
+
),
|
|
67
|
+
"slotlock_delete_event": Operation(
|
|
68
|
+
"slotlock_delete_event",
|
|
69
|
+
"DELETE",
|
|
70
|
+
"/v1/events/{event_id}",
|
|
71
|
+
("event_id",),
|
|
72
|
+
("expected_revision", "idempotency_key"),
|
|
73
|
+
),
|
|
74
|
+
"slotlock_get_resource": Operation(
|
|
75
|
+
"slotlock_get_resource",
|
|
76
|
+
"GET",
|
|
77
|
+
"/v1/resources/{resource_id}",
|
|
78
|
+
("resource_id",),
|
|
79
|
+
(),
|
|
80
|
+
),
|
|
81
|
+
"slotlock_delete_resource": Operation(
|
|
82
|
+
"slotlock_delete_resource",
|
|
83
|
+
"DELETE",
|
|
84
|
+
"/v1/resources/{resource_id}",
|
|
85
|
+
("resource_id",),
|
|
86
|
+
(),
|
|
87
|
+
),
|
|
88
|
+
"slotlock_archive_resource": Operation(
|
|
89
|
+
"slotlock_archive_resource",
|
|
90
|
+
"POST",
|
|
91
|
+
"/v1/resources/{resource_id}/archive",
|
|
92
|
+
("resource_id",),
|
|
93
|
+
(),
|
|
94
|
+
),
|
|
95
|
+
"slotlock_restore_resource": Operation(
|
|
96
|
+
"slotlock_restore_resource",
|
|
97
|
+
"POST",
|
|
98
|
+
"/v1/resources/{resource_id}/restore",
|
|
99
|
+
("resource_id",),
|
|
100
|
+
(),
|
|
101
|
+
),
|
|
102
|
+
"slotlock_list_busy": Operation(
|
|
103
|
+
"slotlock_list_busy",
|
|
104
|
+
"GET",
|
|
105
|
+
"/v1/resources/{resource_id}/busy",
|
|
106
|
+
("resource_id",),
|
|
107
|
+
("start", "end"),
|
|
108
|
+
),
|
|
109
|
+
"slotlock_set_resource_availability": Operation(
|
|
110
|
+
"slotlock_set_resource_availability",
|
|
111
|
+
"PUT",
|
|
112
|
+
"/v1/resources/{resource_id}/availability",
|
|
113
|
+
("resource_id",),
|
|
114
|
+
(),
|
|
115
|
+
),
|
|
116
|
+
"slotlock_set_tenant_availability": Operation(
|
|
117
|
+
"slotlock_set_tenant_availability",
|
|
118
|
+
"PUT",
|
|
119
|
+
"/v1/availability",
|
|
120
|
+
(),
|
|
121
|
+
(),
|
|
122
|
+
),
|
|
123
|
+
"slotlock_acquire_hold": Operation(
|
|
124
|
+
"slotlock_acquire_hold",
|
|
125
|
+
"POST",
|
|
126
|
+
"/v1/holds",
|
|
127
|
+
(),
|
|
128
|
+
(),
|
|
129
|
+
),
|
|
130
|
+
"slotlock_get_hold": Operation(
|
|
131
|
+
"slotlock_get_hold",
|
|
132
|
+
"GET",
|
|
133
|
+
"/v1/holds/{hold_id}",
|
|
134
|
+
("hold_id",),
|
|
135
|
+
(),
|
|
136
|
+
),
|
|
137
|
+
"slotlock_release_hold": Operation(
|
|
138
|
+
"slotlock_release_hold",
|
|
139
|
+
"DELETE",
|
|
140
|
+
"/v1/holds/{hold_id}",
|
|
141
|
+
("hold_id",),
|
|
142
|
+
(),
|
|
143
|
+
),
|
|
144
|
+
"slotlock_list_holds": Operation(
|
|
145
|
+
"slotlock_list_holds",
|
|
146
|
+
"GET",
|
|
147
|
+
"/v1/resources/{resource_id}/holds",
|
|
148
|
+
("resource_id",),
|
|
149
|
+
("limit", "cursor"),
|
|
150
|
+
),
|
|
151
|
+
"slotlock_confirm_hold": Operation(
|
|
152
|
+
"slotlock_confirm_hold",
|
|
153
|
+
"POST",
|
|
154
|
+
"/v1/holds/{hold_id}/confirm",
|
|
155
|
+
("hold_id",),
|
|
156
|
+
(),
|
|
157
|
+
),
|
|
158
|
+
"slotlock_get_reservation": Operation(
|
|
159
|
+
"slotlock_get_reservation",
|
|
160
|
+
"GET",
|
|
161
|
+
"/v1/reservations/{reservation_ref}",
|
|
162
|
+
("reservation_ref",),
|
|
163
|
+
(),
|
|
164
|
+
),
|
|
165
|
+
"slotlock_put_reservation": Operation(
|
|
166
|
+
"slotlock_put_reservation",
|
|
167
|
+
"PUT",
|
|
168
|
+
"/v1/reservations/{reservation_ref}",
|
|
169
|
+
("reservation_ref",),
|
|
170
|
+
(),
|
|
171
|
+
),
|
|
172
|
+
"slotlock_cancel_reservation": Operation(
|
|
173
|
+
"slotlock_cancel_reservation",
|
|
174
|
+
"DELETE",
|
|
175
|
+
"/v1/reservations/{reservation_ref}",
|
|
176
|
+
("reservation_ref",),
|
|
177
|
+
("revision",),
|
|
178
|
+
),
|
|
179
|
+
"slotlock_list_reservations": Operation(
|
|
180
|
+
"slotlock_list_reservations",
|
|
181
|
+
"GET",
|
|
182
|
+
"/v1/resources/{resource_id}/reservations",
|
|
183
|
+
("resource_id",),
|
|
184
|
+
("start", "end", "limit", "cursor"),
|
|
185
|
+
),
|
|
186
|
+
"slotlock_record_coverage": Operation(
|
|
187
|
+
"slotlock_record_coverage",
|
|
188
|
+
"PUT",
|
|
189
|
+
"/v1/resources/{resource_id}/coverage/{source}",
|
|
190
|
+
("resource_id", "source"),
|
|
191
|
+
(),
|
|
192
|
+
),
|
|
193
|
+
"slotlock_list_tenant_events": Operation(
|
|
194
|
+
"slotlock_list_tenant_events",
|
|
195
|
+
"GET",
|
|
196
|
+
"/v1/tenant/events",
|
|
197
|
+
(),
|
|
198
|
+
("resource_id", "start", "end", "limit", "cursor"),
|
|
199
|
+
),
|
|
200
|
+
"slotlock_get_tenant_event": Operation(
|
|
201
|
+
"slotlock_get_tenant_event",
|
|
202
|
+
"GET",
|
|
203
|
+
"/v1/tenant/events/{event_id}",
|
|
204
|
+
("event_id",),
|
|
205
|
+
(),
|
|
206
|
+
),
|
|
207
|
+
"slotlock_cancel_tenant_event": Operation(
|
|
208
|
+
"slotlock_cancel_tenant_event",
|
|
209
|
+
"DELETE",
|
|
210
|
+
"/v1/tenant/events/{event_id}",
|
|
211
|
+
("event_id",),
|
|
212
|
+
("expected_revision", "expected_resource_id", "idempotency_key"),
|
|
213
|
+
),
|
|
214
|
+
"slotlock_get_tenant": Operation(
|
|
215
|
+
"slotlock_get_tenant",
|
|
216
|
+
"GET",
|
|
217
|
+
"/v1/tenant",
|
|
218
|
+
(),
|
|
219
|
+
(),
|
|
220
|
+
),
|
|
221
|
+
"slotlock_erase_tenant": Operation(
|
|
222
|
+
"slotlock_erase_tenant",
|
|
223
|
+
"POST",
|
|
224
|
+
"/v1/tenant/erase",
|
|
225
|
+
(),
|
|
226
|
+
(),
|
|
227
|
+
),
|
|
228
|
+
"slotlock_run_maintenance": Operation(
|
|
229
|
+
"slotlock_run_maintenance",
|
|
230
|
+
"POST",
|
|
231
|
+
"/v1/maintenance",
|
|
232
|
+
(),
|
|
233
|
+
(),
|
|
234
|
+
),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class Operations(Transport):
|
|
239
|
+
"""Every Slotlock REST operation as a method; `call` runs one by name."""
|
|
240
|
+
|
|
241
|
+
def list_resources(
|
|
242
|
+
self,
|
|
243
|
+
*,
|
|
244
|
+
cursor: str | None = None,
|
|
245
|
+
limit: int | None = None,
|
|
246
|
+
) -> dict[str, Any]:
|
|
247
|
+
"""List calendar resources.
|
|
248
|
+
|
|
249
|
+
List resources visible to the authenticated tenant principal.
|
|
250
|
+
"""
|
|
251
|
+
values: dict[str, Any] = {}
|
|
252
|
+
if cursor is not None:
|
|
253
|
+
values["cursor"] = cursor
|
|
254
|
+
if limit is not None:
|
|
255
|
+
values["limit"] = limit
|
|
256
|
+
return self.call("slotlock_list_resources", values)
|
|
257
|
+
|
|
258
|
+
def create_resource(
|
|
259
|
+
self,
|
|
260
|
+
*,
|
|
261
|
+
external_ref: str,
|
|
262
|
+
timezone: str | None = None,
|
|
263
|
+
visibility: str | None = None,
|
|
264
|
+
) -> dict[str, Any]:
|
|
265
|
+
"""Create or update a resource.
|
|
266
|
+
|
|
267
|
+
Create the resource with this external reference, or update its time zone if the tenant
|
|
268
|
+
already has it. Repeating the call changes nothing more. visibility is shared (the
|
|
269
|
+
default: agents list and book it) or private (agents never see it), fixed at creation:
|
|
270
|
+
naming another for an existing resource answers 409 resource_visibility_conflict.
|
|
271
|
+
"""
|
|
272
|
+
values: dict[str, Any] = {"external_ref": external_ref}
|
|
273
|
+
if timezone is not None:
|
|
274
|
+
values["timezone"] = timezone
|
|
275
|
+
if visibility is not None:
|
|
276
|
+
values["visibility"] = visibility
|
|
277
|
+
return self.call("slotlock_create_resource", values)
|
|
278
|
+
|
|
279
|
+
def get_free_busy(self, *, resource_ids: list[str], start: str, end: str) -> dict[str, Any]:
|
|
280
|
+
"""Get free-busy.
|
|
281
|
+
|
|
282
|
+
Return privacy-minimized busy intervals and explicit coverage certainty.
|
|
283
|
+
"""
|
|
284
|
+
values: dict[str, Any] = {"resource_ids": resource_ids, "start": start, "end": end}
|
|
285
|
+
return self.call("slotlock_get_free_busy", values)
|
|
286
|
+
|
|
287
|
+
def find_next_available(
|
|
288
|
+
self,
|
|
289
|
+
*,
|
|
290
|
+
resource_ids: list[str],
|
|
291
|
+
start: str,
|
|
292
|
+
end: str,
|
|
293
|
+
duration_minutes: int,
|
|
294
|
+
) -> dict[str, Any]:
|
|
295
|
+
"""Find next available interval.
|
|
296
|
+
|
|
297
|
+
Find the earliest certain interval that satisfies the requested duration (up to the
|
|
298
|
+
367-day search window, so a multi-month rental slot can be found).
|
|
299
|
+
"""
|
|
300
|
+
values: dict[str, Any] = {
|
|
301
|
+
"resource_ids": resource_ids,
|
|
302
|
+
"start": start,
|
|
303
|
+
"end": end,
|
|
304
|
+
"duration_minutes": duration_minutes,
|
|
305
|
+
}
|
|
306
|
+
return self.call("slotlock_find_next_available", values)
|
|
307
|
+
|
|
308
|
+
def list_events(
|
|
309
|
+
self,
|
|
310
|
+
*,
|
|
311
|
+
resource_ids: list[str],
|
|
312
|
+
start: str,
|
|
313
|
+
end: str,
|
|
314
|
+
cursor: str | None = None,
|
|
315
|
+
limit: int | None = None,
|
|
316
|
+
) -> dict[str, Any]:
|
|
317
|
+
"""List resource events.
|
|
318
|
+
|
|
319
|
+
List bounded events intersecting a time window.
|
|
320
|
+
"""
|
|
321
|
+
values: dict[str, Any] = {"resource_ids": resource_ids, "start": start, "end": end}
|
|
322
|
+
if cursor is not None:
|
|
323
|
+
values["cursor"] = cursor
|
|
324
|
+
if limit is not None:
|
|
325
|
+
values["limit"] = limit
|
|
326
|
+
return self.call("slotlock_list_events", values)
|
|
327
|
+
|
|
328
|
+
def create_event(
|
|
329
|
+
self,
|
|
330
|
+
*,
|
|
331
|
+
resource_id: str,
|
|
332
|
+
starts_at: str,
|
|
333
|
+
ends_at: str,
|
|
334
|
+
timezone: str,
|
|
335
|
+
idempotency_key: str,
|
|
336
|
+
title: str | None = None,
|
|
337
|
+
description: str | None = None,
|
|
338
|
+
location: str | None = None,
|
|
339
|
+
organizer: dict[str, Any] | None = None,
|
|
340
|
+
transparency: str | None = None,
|
|
341
|
+
status: str | None = None,
|
|
342
|
+
recurrence_rule: str | None = None,
|
|
343
|
+
attendees: list[dict[str, Any]] | None = None,
|
|
344
|
+
reminders: list[dict[str, Any]] | None = None,
|
|
345
|
+
recurrence_exceptions: list[dict[str, Any]] | None = None,
|
|
346
|
+
) -> dict[str, Any]:
|
|
347
|
+
"""Create resource event.
|
|
348
|
+
|
|
349
|
+
Create an event of up to 3,660 days (a booking, lease or long rental); opaque occurrences
|
|
350
|
+
use the database overlap arbiter.
|
|
351
|
+
"""
|
|
352
|
+
values: dict[str, Any] = {
|
|
353
|
+
"resource_id": resource_id,
|
|
354
|
+
"starts_at": starts_at,
|
|
355
|
+
"ends_at": ends_at,
|
|
356
|
+
"timezone": timezone,
|
|
357
|
+
"idempotency_key": idempotency_key,
|
|
358
|
+
}
|
|
359
|
+
if title is not None:
|
|
360
|
+
values["title"] = title
|
|
361
|
+
if description is not None:
|
|
362
|
+
values["description"] = description
|
|
363
|
+
if location is not None:
|
|
364
|
+
values["location"] = location
|
|
365
|
+
if organizer is not None:
|
|
366
|
+
values["organizer"] = organizer
|
|
367
|
+
if transparency is not None:
|
|
368
|
+
values["transparency"] = transparency
|
|
369
|
+
if status is not None:
|
|
370
|
+
values["status"] = status
|
|
371
|
+
if recurrence_rule is not None:
|
|
372
|
+
values["recurrence_rule"] = recurrence_rule
|
|
373
|
+
if attendees is not None:
|
|
374
|
+
values["attendees"] = attendees
|
|
375
|
+
if reminders is not None:
|
|
376
|
+
values["reminders"] = reminders
|
|
377
|
+
if recurrence_exceptions is not None:
|
|
378
|
+
values["recurrence_exceptions"] = recurrence_exceptions
|
|
379
|
+
return self.call("slotlock_create_event", values)
|
|
380
|
+
|
|
381
|
+
def get_event(self, event_id: str) -> dict[str, Any]:
|
|
382
|
+
"""Get resource event.
|
|
383
|
+
|
|
384
|
+
Read one tenant-scoped event by stable identifier.
|
|
385
|
+
"""
|
|
386
|
+
values: dict[str, Any] = {"event_id": event_id}
|
|
387
|
+
return self.call("slotlock_get_event", values)
|
|
388
|
+
|
|
389
|
+
def update_event(
|
|
390
|
+
self,
|
|
391
|
+
event_id: str,
|
|
392
|
+
*,
|
|
393
|
+
expected_revision: int,
|
|
394
|
+
idempotency_key: str,
|
|
395
|
+
resource_id: str | None = None,
|
|
396
|
+
starts_at: str | None = None,
|
|
397
|
+
ends_at: str | None = None,
|
|
398
|
+
timezone: str | None = None,
|
|
399
|
+
title: str | None | Unset = UNSET,
|
|
400
|
+
description: str | None | Unset = UNSET,
|
|
401
|
+
location: str | None | Unset = UNSET,
|
|
402
|
+
organizer: dict[str, Any] | None | Unset = UNSET,
|
|
403
|
+
attendees: list[dict[str, Any]] | None = None,
|
|
404
|
+
reminders: list[dict[str, Any]] | None = None,
|
|
405
|
+
transparency: str | None = None,
|
|
406
|
+
status: str | None = None,
|
|
407
|
+
recurrence_rule: str | None | Unset = UNSET,
|
|
408
|
+
recurrence_exceptions: list[dict[str, Any]] | None = None,
|
|
409
|
+
expected_resource_id: str | None = None,
|
|
410
|
+
) -> dict[str, Any]:
|
|
411
|
+
"""Update resource event.
|
|
412
|
+
|
|
413
|
+
Apply an expected-revision event patch and rematerialize occupancy atomically. With
|
|
414
|
+
expected_resource_id, the resource you read the event on, an event since moved elsewhere
|
|
415
|
+
is not found.
|
|
416
|
+
"""
|
|
417
|
+
values: dict[str, Any] = {
|
|
418
|
+
"event_id": event_id,
|
|
419
|
+
"expected_revision": expected_revision,
|
|
420
|
+
"idempotency_key": idempotency_key,
|
|
421
|
+
}
|
|
422
|
+
if resource_id is not None:
|
|
423
|
+
values["resource_id"] = resource_id
|
|
424
|
+
if starts_at is not None:
|
|
425
|
+
values["starts_at"] = starts_at
|
|
426
|
+
if ends_at is not None:
|
|
427
|
+
values["ends_at"] = ends_at
|
|
428
|
+
if timezone is not None:
|
|
429
|
+
values["timezone"] = timezone
|
|
430
|
+
if not isinstance(title, Unset):
|
|
431
|
+
values["title"] = title
|
|
432
|
+
if not isinstance(description, Unset):
|
|
433
|
+
values["description"] = description
|
|
434
|
+
if not isinstance(location, Unset):
|
|
435
|
+
values["location"] = location
|
|
436
|
+
if not isinstance(organizer, Unset):
|
|
437
|
+
values["organizer"] = organizer
|
|
438
|
+
if attendees is not None:
|
|
439
|
+
values["attendees"] = attendees
|
|
440
|
+
if reminders is not None:
|
|
441
|
+
values["reminders"] = reminders
|
|
442
|
+
if transparency is not None:
|
|
443
|
+
values["transparency"] = transparency
|
|
444
|
+
if status is not None:
|
|
445
|
+
values["status"] = status
|
|
446
|
+
if not isinstance(recurrence_rule, Unset):
|
|
447
|
+
values["recurrence_rule"] = recurrence_rule
|
|
448
|
+
if recurrence_exceptions is not None:
|
|
449
|
+
values["recurrence_exceptions"] = recurrence_exceptions
|
|
450
|
+
if expected_resource_id is not None:
|
|
451
|
+
values["expected_resource_id"] = expected_resource_id
|
|
452
|
+
return self.call("slotlock_update_event", values)
|
|
453
|
+
|
|
454
|
+
def delete_event(
|
|
455
|
+
self,
|
|
456
|
+
event_id: str,
|
|
457
|
+
*,
|
|
458
|
+
expected_revision: int,
|
|
459
|
+
idempotency_key: str,
|
|
460
|
+
) -> dict[str, Any]:
|
|
461
|
+
"""Delete resource event.
|
|
462
|
+
|
|
463
|
+
Tombstone an event at an expected revision so stale sync cannot resurrect it.
|
|
464
|
+
"""
|
|
465
|
+
values: dict[str, Any] = {
|
|
466
|
+
"event_id": event_id,
|
|
467
|
+
"expected_revision": expected_revision,
|
|
468
|
+
"idempotency_key": idempotency_key,
|
|
469
|
+
}
|
|
470
|
+
return self.call("slotlock_delete_event", values)
|
|
471
|
+
|
|
472
|
+
def get_resource(self, resource_id: str) -> dict[str, Any]:
|
|
473
|
+
"""Get a resource.
|
|
474
|
+
|
|
475
|
+
One resource of the tenant, archived or not, with its own weekly bookable hours and its
|
|
476
|
+
visibility.
|
|
477
|
+
"""
|
|
478
|
+
values: dict[str, Any] = {"resource_id": resource_id}
|
|
479
|
+
return self.call("slotlock_get_resource", values)
|
|
480
|
+
|
|
481
|
+
def delete_resource(self, resource_id: str) -> dict[str, Any]:
|
|
482
|
+
"""Delete a resource.
|
|
483
|
+
|
|
484
|
+
Delete a resource with its history (past bookings, coverage, cancelled events), freeing
|
|
485
|
+
its place under the tenant's resource limit. Refused with 409 resource_in_use while a
|
|
486
|
+
booking on it has not ended, a hold on it is live, or a calendar event is on it: cancel or
|
|
487
|
+
move those first. deleted is false when the tenant has no such resource, deleted already,
|
|
488
|
+
say.
|
|
489
|
+
"""
|
|
490
|
+
values: dict[str, Any] = {"resource_id": resource_id}
|
|
491
|
+
return self.call("slotlock_delete_resource", values)
|
|
492
|
+
|
|
493
|
+
def archive_resource(self, resource_id: str) -> dict[str, Any]:
|
|
494
|
+
"""Archive a resource.
|
|
495
|
+
|
|
496
|
+
Take a resource out of use: no list shows it and it takes no new booking, hold or event
|
|
497
|
+
(409 resource_archived), while what is on it stays and can still be cancelled or released.
|
|
498
|
+
Archiving it again keeps the first time.
|
|
499
|
+
"""
|
|
500
|
+
values: dict[str, Any] = {"resource_id": resource_id}
|
|
501
|
+
return self.call("slotlock_archive_resource", values)
|
|
502
|
+
|
|
503
|
+
def restore_resource(self, resource_id: str) -> dict[str, Any]:
|
|
504
|
+
"""Restore a resource.
|
|
505
|
+
|
|
506
|
+
Put an archived resource back in use. Restoring one in use changes nothing.
|
|
507
|
+
"""
|
|
508
|
+
values: dict[str, Any] = {"resource_id": resource_id}
|
|
509
|
+
return self.call("slotlock_restore_resource", values)
|
|
510
|
+
|
|
511
|
+
def list_busy(self, resource_id: str, *, start: str, end: str) -> dict[str, Any]:
|
|
512
|
+
"""List a resource's busy time.
|
|
513
|
+
|
|
514
|
+
The time a resource of the tenant, private ones included, is occupied in a window of at
|
|
515
|
+
most 367 days, by start: bookings with their turnaround, live holds, and calendar events.
|
|
516
|
+
Adjacent intervals are not merged. More than 10,000 answers 400 calendar_work_limit:
|
|
517
|
+
narrow the window. Free/busy with coverage, for the resources agents see, is
|
|
518
|
+
slotlock_get_free_busy.
|
|
519
|
+
"""
|
|
520
|
+
values: dict[str, Any] = {"resource_id": resource_id, "start": start, "end": end}
|
|
521
|
+
return self.call("slotlock_list_busy", values)
|
|
522
|
+
|
|
523
|
+
def set_resource_availability(
|
|
524
|
+
self,
|
|
525
|
+
resource_id: str,
|
|
526
|
+
*,
|
|
527
|
+
rules: list[dict[str, Any]] | None,
|
|
528
|
+
) -> dict[str, Any]:
|
|
529
|
+
"""Set a resource's bookable hours.
|
|
530
|
+
|
|
531
|
+
Give one resource its own weekly bookable hours. `null` makes it use the server's default;
|
|
532
|
+
`[]` makes it never bookable. Each rule is weekly with BYDAY, in the resource's time zone:
|
|
533
|
+
start_minutes after midnight, lasting duration_minutes.
|
|
534
|
+
"""
|
|
535
|
+
values: dict[str, Any] = {"resource_id": resource_id, "rules": rules}
|
|
536
|
+
return self.call("slotlock_set_resource_availability", values)
|
|
537
|
+
|
|
538
|
+
def set_tenant_availability(self, *, rules: list[dict[str, Any]] | None) -> dict[str, Any]:
|
|
539
|
+
"""Set every resource's bookable hours.
|
|
540
|
+
|
|
541
|
+
Set the weekly bookable hours of every resource the tenant has, as
|
|
542
|
+
slotlock_set_resource_availability does for one. `null` makes them all use the server's
|
|
543
|
+
default. Returns how many resources changed.
|
|
544
|
+
"""
|
|
545
|
+
values: dict[str, Any] = {"rules": rules}
|
|
546
|
+
return self.call("slotlock_set_tenant_availability", values)
|
|
547
|
+
|
|
548
|
+
def acquire_hold(
|
|
549
|
+
self,
|
|
550
|
+
*,
|
|
551
|
+
resource_id: str,
|
|
552
|
+
start: str,
|
|
553
|
+
end: str,
|
|
554
|
+
ttl_seconds: int,
|
|
555
|
+
source: str | None = None,
|
|
556
|
+
idempotency_key: str | None = None,
|
|
557
|
+
buffer_after_minutes: int | None = None,
|
|
558
|
+
) -> dict[str, Any]:
|
|
559
|
+
"""Hold time.
|
|
560
|
+
|
|
561
|
+
Occupy a window without booking it, for example while a customer pays. The hold blocks
|
|
562
|
+
every other booking until it expires (the database clock, ttl_seconds from now), is
|
|
563
|
+
confirmed, or is released. buffer_after_minutes keeps the time after it busy as well, and
|
|
564
|
+
a confirmed hold keeps it. A taken window answers 409 overlap. With an idempotency key
|
|
565
|
+
(the Idempotency-Key header or idempotency_key), a retry answers the hold the first
|
|
566
|
+
request made, until that hold would have expired, is released or is confirmed; the same
|
|
567
|
+
key with another request is 409 idempotency_conflict.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
idempotency_key: A retry identity, also accepted as the Idempotency-Key header: the
|
|
571
|
+
same key with the same request answers what the first did; with another request, 409
|
|
572
|
+
idempotency_conflict.
|
|
573
|
+
"""
|
|
574
|
+
values: dict[str, Any] = {
|
|
575
|
+
"resource_id": resource_id,
|
|
576
|
+
"start": start,
|
|
577
|
+
"end": end,
|
|
578
|
+
"ttl_seconds": ttl_seconds,
|
|
579
|
+
}
|
|
580
|
+
if source is not None:
|
|
581
|
+
values["source"] = source
|
|
582
|
+
if idempotency_key is not None:
|
|
583
|
+
values["idempotency_key"] = idempotency_key
|
|
584
|
+
if buffer_after_minutes is not None:
|
|
585
|
+
values["buffer_after_minutes"] = buffer_after_minutes
|
|
586
|
+
return self.call("slotlock_acquire_hold", values)
|
|
587
|
+
|
|
588
|
+
def get_hold(self, hold_id: str) -> dict[str, Any]:
|
|
589
|
+
"""Get a hold.
|
|
590
|
+
|
|
591
|
+
One of the tenant's live holds. An expired, confirmed, released or unknown one answers 404
|
|
592
|
+
hold_not_found; a confirmed one is a reservation whose reference is the hold's id.
|
|
593
|
+
"""
|
|
594
|
+
values: dict[str, Any] = {"hold_id": hold_id}
|
|
595
|
+
return self.call("slotlock_get_hold", values)
|
|
596
|
+
|
|
597
|
+
def release_hold(self, hold_id: str) -> dict[str, Any]:
|
|
598
|
+
"""Release a hold.
|
|
599
|
+
|
|
600
|
+
Free a held window. released is false when the hold was already gone: expired, confirmed
|
|
601
|
+
or released.
|
|
602
|
+
"""
|
|
603
|
+
values: dict[str, Any] = {"hold_id": hold_id}
|
|
604
|
+
return self.call("slotlock_release_hold", values)
|
|
605
|
+
|
|
606
|
+
def list_holds(
|
|
607
|
+
self,
|
|
608
|
+
resource_id: str,
|
|
609
|
+
*,
|
|
610
|
+
limit: int | None = None,
|
|
611
|
+
cursor: str | None = None,
|
|
612
|
+
) -> dict[str, Any]:
|
|
613
|
+
"""List a resource's holds.
|
|
614
|
+
|
|
615
|
+
The live holds on one of the tenant's resources, by start then id, a page at a time: pass
|
|
616
|
+
next_cursor as cursor for the next page, until it is null.
|
|
617
|
+
"""
|
|
618
|
+
values: dict[str, Any] = {"resource_id": resource_id}
|
|
619
|
+
if limit is not None:
|
|
620
|
+
values["limit"] = limit
|
|
621
|
+
if cursor is not None:
|
|
622
|
+
values["cursor"] = cursor
|
|
623
|
+
return self.call("slotlock_list_holds", values)
|
|
624
|
+
|
|
625
|
+
def confirm_hold(self, hold_id: str) -> dict[str, Any]:
|
|
626
|
+
"""Confirm a hold.
|
|
627
|
+
|
|
628
|
+
Turn an unexpired hold into a confirmed reservation, whose reference is the hold's id at
|
|
629
|
+
revision 1: move or cancel it with the reservation routes by that reference (DELETE
|
|
630
|
+
/v1/reservations/{hold_id}?revision=2 cancels it). Confirming it again answers the same
|
|
631
|
+
reservation, so a retry is safe. An expired hold answers 409 hold_expired; a released or
|
|
632
|
+
unknown one, 404 hold_not_found.
|
|
633
|
+
"""
|
|
634
|
+
values: dict[str, Any] = {"hold_id": hold_id}
|
|
635
|
+
return self.call("slotlock_confirm_hold", values)
|
|
636
|
+
|
|
637
|
+
def get_reservation(self, reservation_ref: str) -> dict[str, Any]:
|
|
638
|
+
"""Get a reservation.
|
|
639
|
+
|
|
640
|
+
The tenant's booking with this reference as it now is, at its current revision: move it at
|
|
641
|
+
that revision plus 1. A cancelled or unknown reference, or a calendar event's time,
|
|
642
|
+
answers 404 reservation_not_found.
|
|
643
|
+
"""
|
|
644
|
+
values: dict[str, Any] = {"reservation_ref": reservation_ref}
|
|
645
|
+
return self.call("slotlock_get_reservation", values)
|
|
646
|
+
|
|
647
|
+
def put_reservation(
|
|
648
|
+
self,
|
|
649
|
+
reservation_ref: str,
|
|
650
|
+
*,
|
|
651
|
+
resource_id: str,
|
|
652
|
+
start: str,
|
|
653
|
+
end: str,
|
|
654
|
+
revision: int,
|
|
655
|
+
buffer_after_minutes: int | None = None,
|
|
656
|
+
source: str | None = None,
|
|
657
|
+
) -> dict[str, Any]:
|
|
658
|
+
"""Book or move a reservation.
|
|
659
|
+
|
|
660
|
+
Book the reservation with this reference (revision 1), or move it (the next revision: the
|
|
661
|
+
current one plus 1). Sending the same reservation at its current revision again changes
|
|
662
|
+
nothing (replayed: true). A taken window answers 409 overlap and leaves a moved
|
|
663
|
+
reservation where it was. buffer_after_minutes keeps the time after it busy as well.
|
|
664
|
+
"""
|
|
665
|
+
values: dict[str, Any] = {
|
|
666
|
+
"reservation_ref": reservation_ref,
|
|
667
|
+
"resource_id": resource_id,
|
|
668
|
+
"start": start,
|
|
669
|
+
"end": end,
|
|
670
|
+
"revision": revision,
|
|
671
|
+
}
|
|
672
|
+
if buffer_after_minutes is not None:
|
|
673
|
+
values["buffer_after_minutes"] = buffer_after_minutes
|
|
674
|
+
if source is not None:
|
|
675
|
+
values["source"] = source
|
|
676
|
+
return self.call("slotlock_put_reservation", values)
|
|
677
|
+
|
|
678
|
+
def cancel_reservation(self, reservation_ref: str, *, revision: int) -> dict[str, Any]:
|
|
679
|
+
"""Cancel a reservation.
|
|
680
|
+
|
|
681
|
+
Cancel the reservation with this reference at the next revision (the current one plus 1).
|
|
682
|
+
Cancelling leaves a tombstone, so a delayed booking cannot bring it back; repeating the
|
|
683
|
+
cancel answers cancelled: false. Use a new reference for a new reservation.
|
|
684
|
+
"""
|
|
685
|
+
values: dict[str, Any] = {"reservation_ref": reservation_ref, "revision": revision}
|
|
686
|
+
return self.call("slotlock_cancel_reservation", values)
|
|
687
|
+
|
|
688
|
+
def list_reservations(
|
|
689
|
+
self,
|
|
690
|
+
resource_id: str,
|
|
691
|
+
*,
|
|
692
|
+
start: str,
|
|
693
|
+
end: str,
|
|
694
|
+
limit: int | None = None,
|
|
695
|
+
cursor: str | None = None,
|
|
696
|
+
) -> dict[str, Any]:
|
|
697
|
+
"""List a resource's reservations.
|
|
698
|
+
|
|
699
|
+
The tenant's bookings on one resource whose time, turnaround included, meets a window of
|
|
700
|
+
at most 367 days, by start then id, a page at a time: pass next_cursor as cursor for the
|
|
701
|
+
next page, until it is null. Calendar events are not bookings; slotlock_list_tenant_events
|
|
702
|
+
lists them.
|
|
703
|
+
"""
|
|
704
|
+
values: dict[str, Any] = {"resource_id": resource_id, "start": start, "end": end}
|
|
705
|
+
if limit is not None:
|
|
706
|
+
values["limit"] = limit
|
|
707
|
+
if cursor is not None:
|
|
708
|
+
values["cursor"] = cursor
|
|
709
|
+
return self.call("slotlock_list_reservations", values)
|
|
710
|
+
|
|
711
|
+
def record_coverage(
|
|
712
|
+
self,
|
|
713
|
+
resource_id: str,
|
|
714
|
+
source: str,
|
|
715
|
+
*,
|
|
716
|
+
start: str,
|
|
717
|
+
end: str,
|
|
718
|
+
revision: int,
|
|
719
|
+
) -> dict[str, Any]:
|
|
720
|
+
"""Record calendar coverage.
|
|
721
|
+
|
|
722
|
+
Record that the events of an external calendar (source) are complete for a window of a
|
|
723
|
+
resource, at that source's revision. Free/busy calls time unproven, not free, until every
|
|
724
|
+
source a resource requires covers it. Revisions only go forward; repeating one changes
|
|
725
|
+
nothing.
|
|
726
|
+
"""
|
|
727
|
+
values: dict[str, Any] = {
|
|
728
|
+
"resource_id": resource_id,
|
|
729
|
+
"source": source,
|
|
730
|
+
"start": start,
|
|
731
|
+
"end": end,
|
|
732
|
+
"revision": revision,
|
|
733
|
+
}
|
|
734
|
+
return self.call("slotlock_record_coverage", values)
|
|
735
|
+
|
|
736
|
+
def list_tenant_events(
|
|
737
|
+
self,
|
|
738
|
+
*,
|
|
739
|
+
resource_id: str,
|
|
740
|
+
start: str,
|
|
741
|
+
end: str,
|
|
742
|
+
limit: int | None = None,
|
|
743
|
+
cursor: str | None = None,
|
|
744
|
+
) -> dict[str, Any]:
|
|
745
|
+
"""List a resource's events, every key's.
|
|
746
|
+
|
|
747
|
+
The calendar events on one of the tenant's resources with an occurrence in a window of at
|
|
748
|
+
most 367 days, whichever key wrote them (a revoked one's included), by start then id, a
|
|
749
|
+
page at a time: pass next_cursor as cursor for the next page, until it is null.
|
|
750
|
+
"""
|
|
751
|
+
values: dict[str, Any] = {"resource_id": resource_id, "start": start, "end": end}
|
|
752
|
+
if limit is not None:
|
|
753
|
+
values["limit"] = limit
|
|
754
|
+
if cursor is not None:
|
|
755
|
+
values["cursor"] = cursor
|
|
756
|
+
return self.call("slotlock_list_tenant_events", values)
|
|
757
|
+
|
|
758
|
+
def get_tenant_event(self, event_id: str) -> dict[str, Any]:
|
|
759
|
+
"""Get any event of the tenant.
|
|
760
|
+
|
|
761
|
+
One of the tenant's events by its id (the id slotlock_list_tenant_events gives), whichever
|
|
762
|
+
key wrote it. A cancelled or unknown one answers 404 event_not_found.
|
|
763
|
+
"""
|
|
764
|
+
values: dict[str, Any] = {"event_id": event_id}
|
|
765
|
+
return self.call("slotlock_get_tenant_event", values)
|
|
766
|
+
|
|
767
|
+
def cancel_tenant_event(
|
|
768
|
+
self,
|
|
769
|
+
event_id: str,
|
|
770
|
+
*,
|
|
771
|
+
expected_revision: int,
|
|
772
|
+
idempotency_key: str,
|
|
773
|
+
expected_resource_id: str | None = None,
|
|
774
|
+
) -> dict[str, Any]:
|
|
775
|
+
"""Cancel any event of the tenant.
|
|
776
|
+
|
|
777
|
+
Cancel one of the tenant's events by its id, whichever key wrote it (a revoked key's
|
|
778
|
+
included, without charging its quota), at the revision you last read (expected_revision).
|
|
779
|
+
Its owner cannot create it again. With expected_resource_id, an event since moved to
|
|
780
|
+
another resource answers 404 event_not_found. Needs an idempotency key (the
|
|
781
|
+
Idempotency-Key header or idempotency_key): a retry with it answers the first cancellation
|
|
782
|
+
(replayed: true).
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
idempotency_key: A retry identity, also accepted as the Idempotency-Key header: the
|
|
786
|
+
same key with the same request answers what the first did; with another request, 409
|
|
787
|
+
idempotency_conflict.
|
|
788
|
+
"""
|
|
789
|
+
values: dict[str, Any] = {
|
|
790
|
+
"event_id": event_id,
|
|
791
|
+
"expected_revision": expected_revision,
|
|
792
|
+
"idempotency_key": idempotency_key,
|
|
793
|
+
}
|
|
794
|
+
if expected_resource_id is not None:
|
|
795
|
+
values["expected_resource_id"] = expected_resource_id
|
|
796
|
+
return self.call("slotlock_cancel_tenant_event", values)
|
|
797
|
+
|
|
798
|
+
def get_tenant(self) -> dict[str, Any]:
|
|
799
|
+
"""Get the tenant.
|
|
800
|
+
|
|
801
|
+
The key's tenant: its reference, and whether it is suspended. A suspended tenant may read
|
|
802
|
+
and writes nothing (403 tenant_suspended); only the server's operator suspends a tenant or
|
|
803
|
+
lifts a suspension (slotlock tenant suspend).
|
|
804
|
+
"""
|
|
805
|
+
values: dict[str, Any] = {}
|
|
806
|
+
return self.call("slotlock_get_tenant", values)
|
|
807
|
+
|
|
808
|
+
def erase_tenant(self, *, confirm_tenant_ref: str) -> dict[str, Any]:
|
|
809
|
+
"""Erase the tenant.
|
|
810
|
+
|
|
811
|
+
Delete everything the key's tenant has, at once and for good: resources, holds, bookings,
|
|
812
|
+
events, coverage and every API key, this one included. confirm_tenant_ref must be the
|
|
813
|
+
tenant's reference (slotlock_get_tenant), or it answers 400 invalid_confirmation. Off
|
|
814
|
+
unless the server allows it (403 tenant_erasure_disabled; slotlock serve does not: its
|
|
815
|
+
operator runs slotlock tenant erase). A suspended tenant cannot erase itself (403
|
|
816
|
+
tenant_suspended). Erasing again deletes nothing.
|
|
817
|
+
"""
|
|
818
|
+
values: dict[str, Any] = {"confirm_tenant_ref": confirm_tenant_ref}
|
|
819
|
+
return self.call("slotlock_erase_tenant", values)
|
|
820
|
+
|
|
821
|
+
def run_maintenance(self) -> dict[str, Any]:
|
|
822
|
+
"""Run maintenance.
|
|
823
|
+
|
|
824
|
+
Extend recurring events to the rolling horizon and prune what the retention window no
|
|
825
|
+
longer needs, for the caller's tenant. Run it at least daily (slotlock serve does). capped
|
|
826
|
+
is true when there was more than one call does: call again.
|
|
827
|
+
"""
|
|
828
|
+
values: dict[str, Any] = {}
|
|
829
|
+
return self.call("slotlock_run_maintenance", values)
|
slotlock/_transport.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""The HTTP side of the Slotlock client: the key, JSON, and errors. Standard library only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, NamedTuple
|
|
14
|
+
|
|
15
|
+
__version__ = "0.3.0"
|
|
16
|
+
|
|
17
|
+
_LOOPBACK = {"localhost", "127.0.0.1", "::1"}
|
|
18
|
+
|
|
19
|
+
#: The longest wait a Retry-After is read as: one day.
|
|
20
|
+
_MAX_RETRY_AFTER = 86400.0
|
|
21
|
+
_SECONDS = re.compile(r"[0-9]+(?:\.[0-9]+)?")
|
|
22
|
+
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
23
|
+
_HTTP_DATE = re.compile(
|
|
24
|
+
r"(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), ([0-9]{2}) (" + "|".join(_MONTHS) + r") ([0-9]{4}) "
|
|
25
|
+
r"([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Unset:
|
|
30
|
+
"""The default of an optional argument that may be null: leave it out of the request."""
|
|
31
|
+
|
|
32
|
+
_instance: Unset | None = None
|
|
33
|
+
|
|
34
|
+
def __new__(cls) -> Unset:
|
|
35
|
+
if cls._instance is None:
|
|
36
|
+
cls._instance = super().__new__(cls)
|
|
37
|
+
return cls._instance
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
return "UNSET"
|
|
41
|
+
|
|
42
|
+
def __bool__(self) -> bool:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
UNSET = Unset()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Operation(NamedTuple):
|
|
50
|
+
name: str
|
|
51
|
+
method: str
|
|
52
|
+
path: str
|
|
53
|
+
path_params: tuple[str, ...]
|
|
54
|
+
query: tuple[str, ...]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SlotlockError(Exception):
|
|
58
|
+
"""The server refused a call: `status` is the HTTP status, `code` says why.
|
|
59
|
+
|
|
60
|
+
`code` is the server's error code (`overlap`, `forbidden`, `confirmation_required`, ...), or
|
|
61
|
+
`http_<status>` when the response carried none; a redirect is one too (`http_302`), never
|
|
62
|
+
followed. `retry_after` is the seconds a 429 or 503 asks for, when its Retry-After says, in
|
|
63
|
+
seconds or as a date (at most a day).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, status: int, code: str, retry_after: float | None = None) -> None:
|
|
67
|
+
super().__init__(f"Slotlock answered {status} {code}")
|
|
68
|
+
self.status = status
|
|
69
|
+
self.code = code
|
|
70
|
+
self.retry_after = retry_after
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
74
|
+
"""Follows no redirect: the 3xx is raised as it came, and the key goes nowhere else."""
|
|
75
|
+
|
|
76
|
+
def redirect_request(self, *args: Any) -> None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Transport:
|
|
81
|
+
"""Calls a Slotlock server's REST API with an API key.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
base_url: The server's public URL (SLOTLOCK_PUBLIC_URL), such as
|
|
85
|
+
`https://calendar.example.com`.
|
|
86
|
+
api_key: A Slotlock API key (`slk_...`) or the server token. The application API needs a
|
|
87
|
+
key with the admin scope.
|
|
88
|
+
timeout: Seconds a call may take, its response body included.
|
|
89
|
+
allow_insecure: Send the key over plain HTTP to a host other than this machine. Never
|
|
90
|
+
needed for `http://localhost`, which is reached without the environment's proxy.
|
|
91
|
+
user_agent: Sent as User-Agent; `slotlock-python/<version>` unless given.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
base_url: str,
|
|
97
|
+
api_key: str,
|
|
98
|
+
*,
|
|
99
|
+
timeout: float = 30.0,
|
|
100
|
+
allow_insecure: bool = False,
|
|
101
|
+
user_agent: str | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
parsed = urllib.parse.urlsplit(base_url)
|
|
104
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
105
|
+
raise ValueError("base_url must be an http:// or https:// URL")
|
|
106
|
+
if parsed.query or parsed.fragment:
|
|
107
|
+
raise ValueError("base_url must not have a query or a fragment")
|
|
108
|
+
if parsed.scheme == "http" and parsed.hostname not in _LOOPBACK and not allow_insecure:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
"base_url is plain HTTP to another host, which would send the key unencrypted; "
|
|
111
|
+
"use https://, or pass allow_insecure=True"
|
|
112
|
+
)
|
|
113
|
+
if not api_key or any(ord(c) < 0x21 or ord(c) > 0x7E for c in api_key):
|
|
114
|
+
raise ValueError("api_key must be a Slotlock API key or the server token")
|
|
115
|
+
self._base_url = base_url.rstrip("/")
|
|
116
|
+
self._api_key = api_key
|
|
117
|
+
self._timeout = timeout
|
|
118
|
+
self._user_agent = user_agent or f"slotlock-python/{__version__}"
|
|
119
|
+
# This machine is reached directly: a proxy from the environment would carry the key off it.
|
|
120
|
+
direct = [urllib.request.ProxyHandler({})] if parsed.hostname in _LOOPBACK else []
|
|
121
|
+
self._opener = urllib.request.build_opener(_NoRedirect(), *direct)
|
|
122
|
+
|
|
123
|
+
def __repr__(self) -> str:
|
|
124
|
+
# Never the key.
|
|
125
|
+
return f"{type(self).__name__}({self._base_url!r})"
|
|
126
|
+
|
|
127
|
+
def call(self, operation: str, values: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
128
|
+
"""Run an operation by name (`slotlock_...`) with its arguments, path parameters included.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
SlotlockError: The server refused the call.
|
|
132
|
+
ValueError: No such operation, or a path parameter is missing.
|
|
133
|
+
"""
|
|
134
|
+
from ._operations import OPERATIONS
|
|
135
|
+
|
|
136
|
+
spec = OPERATIONS.get(operation)
|
|
137
|
+
if spec is None:
|
|
138
|
+
raise ValueError(f"no Slotlock operation named {operation!r}")
|
|
139
|
+
arguments = dict(values or {})
|
|
140
|
+
path = spec.path
|
|
141
|
+
for name in spec.path_params:
|
|
142
|
+
value = arguments.pop(name, None)
|
|
143
|
+
# The server resolves `.` and `..` as path segments, which would name another route.
|
|
144
|
+
if not isinstance(value, str) or value in ("", ".", ".."):
|
|
145
|
+
raise ValueError(f"{operation} needs {name}, a non-empty string other than . and ..")
|
|
146
|
+
path = path.replace("{" + name + "}", urllib.parse.quote(value, safe=""))
|
|
147
|
+
body: bytes | None = None
|
|
148
|
+
if spec.method in ("GET", "DELETE"):
|
|
149
|
+
unknown = [name for name in arguments if name not in spec.query]
|
|
150
|
+
if unknown:
|
|
151
|
+
raise ValueError(f"{operation} has no argument {unknown[0]}")
|
|
152
|
+
pairs: list[tuple[str, str]] = []
|
|
153
|
+
for name, value in arguments.items():
|
|
154
|
+
for item in value if isinstance(value, (list, tuple)) else [value]:
|
|
155
|
+
pairs.append((name, _query_value(item)))
|
|
156
|
+
if pairs:
|
|
157
|
+
path = f"{path}?{urllib.parse.urlencode(pairs)}"
|
|
158
|
+
else:
|
|
159
|
+
body = json.dumps(arguments, ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
160
|
+
return self._send(spec.method, path, body)
|
|
161
|
+
|
|
162
|
+
def _send(self, method: str, path: str, body: bytes | None) -> dict[str, Any]:
|
|
163
|
+
headers = {
|
|
164
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
165
|
+
"Accept": "application/json",
|
|
166
|
+
"User-Agent": self._user_agent,
|
|
167
|
+
}
|
|
168
|
+
if body is not None:
|
|
169
|
+
headers["Content-Type"] = "application/json"
|
|
170
|
+
request = urllib.request.Request(
|
|
171
|
+
f"{self._base_url}{path}", data=body, method=method, headers=headers
|
|
172
|
+
)
|
|
173
|
+
deadline = time.monotonic() + self._timeout
|
|
174
|
+
try:
|
|
175
|
+
with self._opener.open(request, timeout=self._timeout) as response:
|
|
176
|
+
payload = _read(response, deadline)
|
|
177
|
+
except urllib.error.HTTPError as error:
|
|
178
|
+
with error:
|
|
179
|
+
body = _read(error, deadline)
|
|
180
|
+
raise _refusal(error.code, body, error.headers.get("Retry-After")) from None
|
|
181
|
+
decoded = json.loads(payload) if payload else {}
|
|
182
|
+
if not isinstance(decoded, dict):
|
|
183
|
+
raise SlotlockError(200, "invalid_response")
|
|
184
|
+
return decoded
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _read(response: Any, deadline: float) -> bytes:
|
|
188
|
+
"""The body, as long as it arrives before `deadline`."""
|
|
189
|
+
chunks: list[bytes] = []
|
|
190
|
+
while True:
|
|
191
|
+
if time.monotonic() > deadline:
|
|
192
|
+
raise TimeoutError("Slotlock's answer did not arrive in time")
|
|
193
|
+
chunk = response.read1(65536)
|
|
194
|
+
if not chunk:
|
|
195
|
+
return b"".join(chunks)
|
|
196
|
+
chunks.append(chunk)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _retry_after(value: str | None) -> float | None:
|
|
200
|
+
"""Retry-After in seconds or as an HTTP date, at most a day; anything else is no wait."""
|
|
201
|
+
if value is None:
|
|
202
|
+
return None
|
|
203
|
+
value = value.strip()
|
|
204
|
+
if _SECONDS.fullmatch(value):
|
|
205
|
+
return min(float(value), _MAX_RETRY_AFTER)
|
|
206
|
+
date = _HTTP_DATE.fullmatch(value)
|
|
207
|
+
if date is None:
|
|
208
|
+
return None
|
|
209
|
+
day, month, year, hour, minute, second = date.groups()
|
|
210
|
+
try:
|
|
211
|
+
when = datetime(
|
|
212
|
+
int(year), _MONTHS.index(month) + 1, int(day), int(hour), int(minute), int(second),
|
|
213
|
+
tzinfo=timezone.utc,
|
|
214
|
+
)
|
|
215
|
+
except ValueError:
|
|
216
|
+
return None
|
|
217
|
+
return max(0.0, min(when.timestamp() - time.time(), _MAX_RETRY_AFTER))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _query_value(value: Any) -> str:
|
|
221
|
+
if isinstance(value, bool):
|
|
222
|
+
return "true" if value else "false"
|
|
223
|
+
if isinstance(value, (int, float, str)):
|
|
224
|
+
return str(value)
|
|
225
|
+
raise TypeError(f"a query argument is a string, number or boolean, not {type(value).__name__}")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _refusal(status: int, payload: bytes, retry_after: str | None) -> SlotlockError:
|
|
229
|
+
code = f"http_{status}"
|
|
230
|
+
try:
|
|
231
|
+
decoded = json.loads(payload)
|
|
232
|
+
if isinstance(decoded, dict) and isinstance(decoded.get("error"), dict):
|
|
233
|
+
found = decoded["error"].get("code")
|
|
234
|
+
if isinstance(found, str) and found:
|
|
235
|
+
code = found
|
|
236
|
+
except ValueError:
|
|
237
|
+
pass
|
|
238
|
+
return SlotlockError(status, code, _retry_after(retry_after))
|
slotlock/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: slotlock
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A client for Slotlock's REST API: calendars AI agents cannot double-book.
|
|
5
|
+
Project-URL: Homepage, https://slotlock.pylota.io
|
|
6
|
+
Project-URL: Source, https://github.com/PILOTAAI/slotlock/tree/main/clients/python
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Typing :: Typed
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Slotlock for Python
|
|
14
|
+
|
|
15
|
+
A client for [Slotlock](https://slotlock.pylota.io)'s REST API: the agent tools and the application
|
|
16
|
+
API, one method per operation. Python 3.10+, standard library only.
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pip install "git+https://github.com/PILOTAAI/slotlock.git#subdirectory=clients/python"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from slotlock import Client, SlotlockError
|
|
24
|
+
|
|
25
|
+
slotlock = Client("https://calendar.example.com", api_key) # a key with the admin scope
|
|
26
|
+
van = slotlock.create_resource(external_ref="vehicle-42", timezone="Europe/London")["resource"]
|
|
27
|
+
|
|
28
|
+
hold = slotlock.acquire_hold(
|
|
29
|
+
resource_id=van["id"], start="2027-03-29T09:00:00Z", end="2027-03-29T10:00:00Z", ttl_seconds=600
|
|
30
|
+
)["hold"]
|
|
31
|
+
try:
|
|
32
|
+
slotlock.acquire_hold(
|
|
33
|
+
resource_id=van["id"], start="2027-03-29T09:30:00Z", end="2027-03-29T10:30:00Z", ttl_seconds=600
|
|
34
|
+
)
|
|
35
|
+
except SlotlockError as error:
|
|
36
|
+
assert (error.status, error.code) == (409, "overlap")
|
|
37
|
+
|
|
38
|
+
reservation = slotlock.confirm_hold(hold["id"])["reservation"]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- Every method returns the response body as a `dict`; `client.call("slotlock_...", {...})` runs an
|
|
42
|
+
operation by name.
|
|
43
|
+
- A refusal raises `SlotlockError` with `status`, `code` and `retry_after`.
|
|
44
|
+
- `update_event(..., description=None)` clears the description; leaving it out keeps it.
|
|
45
|
+
- `Client(url, key, timeout=30.0, allow_insecure=False, user_agent=None)`. Plain HTTP is refused
|
|
46
|
+
except to `localhost`.
|
|
47
|
+
|
|
48
|
+
The [clients overview](../README.md) covers keys, errors and testing.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
slotlock/__init__.py,sha256=UItAwMuulBPV_-ZIO6XzhVwW-3S0by_122gY-KjFPrU,608
|
|
2
|
+
slotlock/_operations.py,sha256=o_5o187YYaUTNS37tgjQcf5Lu4xoOtNOy3icB1e9r7c,30163
|
|
3
|
+
slotlock/_transport.py,sha256=wAHxkGloxcLBWA1ERCVRuAKTH9l6hOf1CzgQ9j7xhzI,9256
|
|
4
|
+
slotlock/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
slotlock-0.3.0.dist-info/METADATA,sha256=8YsZfvmokwgC8Vv5hdeSkRJ9kQ61w3-3wPgCohUlIeo,1890
|
|
6
|
+
slotlock-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
+
slotlock-0.3.0.dist-info/RECORD,,
|