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 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__"]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any