schedule-bit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ """The schedule-bit API from Python.
2
+
3
+ from schedule_bit import Client, Slots
4
+
5
+ with Client() as api: # key from SCHEDULE_BIT_API_KEY
6
+ api.create_world(name="room-1", preset="temporal-hour")
7
+ api.declare_occupier("room-1", name="omar")
8
+ receipt = api.claim("room-1", who="omar", date="2026-10-15", slots=Slots.range(9, 11))
9
+
10
+ See https://api.schedule-bit.io/v1/docs for every operation; each one is a method
11
+ here, in ``snake_case`` (``explainClaim`` is ``explain_claim``).
12
+ """
13
+
14
+ from ._client import DEFAULT_BASE_URL, AsyncClient, Client
15
+ from ._generated import models
16
+ from ._options import RequestOptions
17
+ from ._redact import redact
18
+ from ._version import __version__
19
+ from .errors import (
20
+ ApiError,
21
+ Cancelled,
22
+ LocalTimeError,
23
+ NetworkError,
24
+ RateLimited,
25
+ Refusal,
26
+ ScheduleBitError,
27
+ TimeoutError,
28
+ )
29
+ from .local_time import local_day, local_hour, local_slot
30
+ from .masks import Slots
31
+ from .rate_limit import RateLimit, TokenBucket
32
+ from .retry import RetryPolicy
33
+
34
+ __all__ = [
35
+ "DEFAULT_BASE_URL",
36
+ "ApiError",
37
+ "AsyncClient",
38
+ "Cancelled",
39
+ "Client",
40
+ "LocalTimeError",
41
+ "NetworkError",
42
+ "RateLimit",
43
+ "RateLimited",
44
+ "Refusal",
45
+ "RequestOptions",
46
+ "RetryPolicy",
47
+ "ScheduleBitError",
48
+ "Slots",
49
+ "TimeoutError",
50
+ "TokenBucket",
51
+ "__version__",
52
+ "local_day",
53
+ "local_hour",
54
+ "local_slot",
55
+ "models",
56
+ "redact",
57
+ ]
@@ -0,0 +1,444 @@
1
+ """`Client` and `AsyncClient`: the generated operations over one careful transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import datetime
7
+ import json
8
+ import os
9
+ import random
10
+ import time
11
+ import uuid
12
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
13
+ from typing import Any, TypeVar
14
+ from urllib.parse import quote
15
+
16
+ import httpx
17
+ from pydantic import BaseModel
18
+
19
+ from ._generated import models
20
+ from ._generated.operations import OPS, AsyncOperations, Operation, SyncOperations
21
+ from ._options import RequestOptions
22
+ from ._redact import redact
23
+ from ._version import __version__
24
+ from .errors import ApiError, Cancelled, NetworkError, Refusal, TimeoutError, error_for
25
+ from .rate_limit import RateLimit, TokenBucket
26
+ from .retry import RetryPolicy, should_retry, wait_for
27
+
28
+ DEFAULT_BASE_URL = "https://api.schedule-bit.io"
29
+ DEFAULT_TIMEOUT = 30.0
30
+ USER_AGENT = f"schedule-bit-python/{__version__}"
31
+
32
+ T = TypeVar("T")
33
+
34
+
35
+ def _query_value(value: Any) -> str:
36
+ if isinstance(value, bool):
37
+ return "true" if value else "false"
38
+ if isinstance(value, (datetime.date, datetime.datetime)):
39
+ return value.isoformat()
40
+ if isinstance(value, (list, tuple)):
41
+ return ",".join(_query_value(v) for v in value) # form, explode: false
42
+ return str(value)
43
+
44
+
45
+ class _ServerSentEvents:
46
+ """``text/event-stream`` lines in, one `AssistEvent` per blank-line-ended event out."""
47
+
48
+ def __init__(self) -> None:
49
+ self._data: list[str] = []
50
+
51
+ def feed(self, line: str) -> list[models.AssistEvent]:
52
+ if line == "":
53
+ return self.end()
54
+ if line.startswith("data:"):
55
+ self._data.append(line[5:].lstrip(" "))
56
+ return []
57
+
58
+ def end(self) -> list[models.AssistEvent]:
59
+ if not self._data:
60
+ return []
61
+ event = models.AssistEvent.model_validate(json.loads("\n".join(self._data)))
62
+ self._data = []
63
+ return [event]
64
+
65
+
66
+ class _Base:
67
+ """Configuration and request building shared by both clients."""
68
+
69
+ def __init__(
70
+ self,
71
+ api_key: str | None = None,
72
+ *,
73
+ base_url: str | None = None,
74
+ timeout: float = DEFAULT_TIMEOUT,
75
+ retry: RetryPolicy | None = None,
76
+ max_retries: int | None = None,
77
+ rate_limit_per_minute: float | None = None,
78
+ on_rate_limit: Callable[[RateLimit], None] | None = None,
79
+ headers: Mapping[str, str] | None = None,
80
+ clock: Callable[[], float] = time.time,
81
+ rng: random.Random | None = None,
82
+ ) -> None:
83
+ key = api_key if api_key is not None else os.environ.get("SCHEDULE_BIT_API_KEY")
84
+ self._api_key = key or None
85
+ self.base_url = (base_url or os.environ.get("SCHEDULE_BIT_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
86
+ self.timeout = timeout
87
+ policy = retry or RetryPolicy()
88
+ if max_retries is not None:
89
+ policy = RetryPolicy(max_retries=max_retries, base=policy.base, cap=policy.cap)
90
+ self.retry = policy
91
+ self.bucket = TokenBucket(rate_limit_per_minute) if rate_limit_per_minute else None
92
+ self.on_rate_limit = on_rate_limit
93
+ self.rate_limit: RateLimit | None = None
94
+ self._extra_headers = dict(headers or {})
95
+ self._clock = clock
96
+ self._rng = rng or random.Random()
97
+
98
+ def __repr__(self) -> str:
99
+ key = "set" if self._api_key else "none"
100
+ return f"{type(self).__name__}(base_url={self.base_url!r}, api_key=<{key}>)"
101
+
102
+ # -- building -----------------------------------------------------------------
103
+
104
+ def _url(self, op: Operation, path: Mapping[str, str]) -> str:
105
+ filled = op.path
106
+ for name, value in path.items():
107
+ filled = filled.replace("{" + name + "}", quote(str(value), safe=""))
108
+ return self.base_url + filled
109
+
110
+ def _headers(self, op: Operation, opts: RequestOptions, has_body: bool, key: str | None) -> dict[str, str]:
111
+ headers = {"Accept": "application/json, application/problem+json", "User-Agent": USER_AGENT}
112
+ if self._api_key:
113
+ headers["Authorization"] = f"Bearer {self._api_key}"
114
+ if has_body:
115
+ headers["Content-Type"] = "application/json"
116
+ if key:
117
+ headers["Idempotency-Key"] = key
118
+ headers.update(self._extra_headers)
119
+ headers.update(opts.headers)
120
+ return headers
121
+
122
+ def _request(
123
+ self, op: Operation, path: Mapping[str, str], query: Mapping[str, Any], body: Any, opts: RequestOptions
124
+ ) -> httpx.Request:
125
+ # One key per call, reused across its retries.
126
+ key = opts.idempotency_key or (str(uuid.uuid4()) if op.keyed else None)
127
+ params = {k: _query_value(v) for k, v in query.items() if v is not None}
128
+ content = json.dumps(body).encode() if body is not None else None
129
+ timeout = opts.timeout if opts.timeout is not None else self.timeout
130
+ return httpx.Request(
131
+ op.method,
132
+ self._url(op, path),
133
+ params=params,
134
+ content=content,
135
+ headers=self._headers(op, opts, body is not None, key),
136
+ extensions={"timeout": httpx.Timeout(timeout).as_dict()},
137
+ )
138
+
139
+ def _observe(self, response: httpx.Response) -> None:
140
+ limit = RateLimit.from_headers(response.headers)
141
+ if limit is not None:
142
+ self.rate_limit = limit
143
+ if self.on_rate_limit:
144
+ self.on_rate_limit(limit)
145
+
146
+ def _retries(self, opts: RequestOptions) -> int:
147
+ return self.retry.max_retries if opts.max_retries is None else opts.max_retries
148
+
149
+ # -- answers ------------------------------------------------------------------
150
+
151
+ @staticmethod
152
+ def _decode(response: httpx.Response) -> Any:
153
+ if not response.content:
154
+ return None
155
+ ctype = response.headers.get("content-type", "")
156
+ if "json" in ctype:
157
+ try:
158
+ return response.json()
159
+ except ValueError:
160
+ return response.text
161
+ return response.text
162
+
163
+ def _error(self, response: httpx.Response, request: httpx.Request) -> ApiError:
164
+ return error_for(
165
+ response.status_code,
166
+ self._decode(response),
167
+ method=request.method,
168
+ url=str(request.url),
169
+ rate_limit=RateLimit.from_headers(response.headers),
170
+ )
171
+
172
+ @staticmethod
173
+ def _parse(op: Operation, response: httpx.Response, parse: type[BaseModel] | None) -> Any:
174
+ if op.answer == "empty" or not response.content:
175
+ return None
176
+ if op.answer == "text":
177
+ return response.text
178
+ data = response.json()
179
+ return parse.model_validate(data) if parse is not None else data
180
+
181
+ @staticmethod
182
+ def _network(exc: Exception, request: httpx.Request, attempts: int) -> NetworkError:
183
+ kind = TimeoutError if isinstance(exc, httpx.TimeoutException) else NetworkError
184
+ what = type(exc).__name__
185
+ return kind(
186
+ f"{request.method} {redact(str(request.url))}: no answer after {attempts} attempt(s) ({what}: {exc})"
187
+ )
188
+
189
+ @staticmethod
190
+ def _sse_events(lines: Iterator[str]) -> Iterator[models.AssistEvent]:
191
+ parser = _ServerSentEvents()
192
+ for line in lines:
193
+ yield from parser.feed(line)
194
+ yield from parser.end()
195
+
196
+
197
+ class Client(_Base, SyncOperations):
198
+ """The schedule-bit API, synchronously.
199
+
200
+ >>> from schedule_bit import Client
201
+ >>> api = Client("sbk_…") # or set SCHEDULE_BIT_API_KEY
202
+ >>> api.claim("room-1", who="omar", date="2026-10-15", slots=[9, 10])
203
+
204
+ Every call retries 429/503 (and, when it is safe, network failures and other
205
+ 5xx) with jittered back-off, honours ``Retry-After``, sends an
206
+ ``Idempotency-Key`` on keyed writes, and raises a typed error otherwise.
207
+ """
208
+
209
+ def __init__(
210
+ self,
211
+ api_key: str | None = None,
212
+ *,
213
+ http: httpx.Client | None = None,
214
+ sleep: Callable[[float], None] = time.sleep,
215
+ **kwargs: Any,
216
+ ) -> None:
217
+ super().__init__(api_key, **kwargs)
218
+ self._http = http or httpx.Client()
219
+ self._sleep = sleep
220
+
221
+ def __enter__(self) -> Client:
222
+ return self
223
+
224
+ def __exit__(self, *exc: object) -> None:
225
+ self.close()
226
+
227
+ def close(self) -> None:
228
+ self._http.close()
229
+
230
+ def _wait(self, seconds: float, opts: RequestOptions) -> None:
231
+ if opts.cancel is not None:
232
+ if opts.cancel.wait(seconds):
233
+ raise Cancelled("cancelled while waiting to retry")
234
+ else:
235
+ self._sleep(seconds)
236
+
237
+ def _send(
238
+ self, op: Operation, request: httpx.Request, opts: RequestOptions, *, stream: bool = False
239
+ ) -> httpx.Response:
240
+ retries = self._retries(opts)
241
+ attempt = 0
242
+ while True:
243
+ if opts.cancel is not None and opts.cancel.is_set():
244
+ raise Cancelled(f"{op.id} cancelled")
245
+ if self.bucket:
246
+ self.bucket.acquire()
247
+ try:
248
+ response = self._http.send(request, stream=stream)
249
+ except httpx.TransportError as exc:
250
+ if attempt >= retries or not should_retry(op, None):
251
+ raise self._network(exc, request, attempt + 1) from exc
252
+ self._wait(wait_for(self.retry, attempt, None, self._clock(), self._rng), opts)
253
+ attempt += 1
254
+ continue
255
+ self._observe(response)
256
+ if response.status_code < 400:
257
+ return response
258
+ if attempt >= retries or not should_retry(op, response.status_code):
259
+ if stream:
260
+ response.read()
261
+ raise self._error(response, request)
262
+ delay = wait_for(self.retry, attempt, response.headers, self._clock(), self._rng)
263
+ response.close()
264
+ self._wait(delay, opts)
265
+ attempt += 1
266
+
267
+ def _call(
268
+ self,
269
+ op: Operation,
270
+ path: dict[str, str],
271
+ query: dict[str, Any],
272
+ body: Any,
273
+ options: RequestOptions | None,
274
+ parse: Any,
275
+ ) -> Any:
276
+ opts = options or RequestOptions()
277
+ request = self._request(op, path, query, body, opts)
278
+ return self._parse(op, self._send(op, request, opts), parse)
279
+
280
+ def _stream(
281
+ self, op: Operation, path: dict[str, str], query: dict[str, Any], body: Any, options: RequestOptions | None
282
+ ) -> Iterator[models.AssistEvent]:
283
+ opts = options or RequestOptions()
284
+ request = self._request(op, path, query, body, opts)
285
+ request.headers["Accept"] = "text/event-stream"
286
+
287
+ def events() -> Iterator[models.AssistEvent]:
288
+ response = self._send(op, request, opts, stream=True)
289
+ try:
290
+ yield from self._sse_events(response.iter_lines())
291
+ finally:
292
+ response.close()
293
+
294
+ return events()
295
+
296
+ # -- the expected outcomes, as values -----------------------------------------
297
+
298
+ def try_claim(
299
+ self, w: str, body: Any = None, /, *, options: RequestOptions | None = None, **fields: Any
300
+ ) -> models.Receipt | Refusal:
301
+ """`claim`, but a refusal is returned rather than raised."""
302
+ try:
303
+ return self.claim(w, body, options=options, **fields)
304
+ except Refusal as refusal:
305
+ return refusal
306
+
307
+ def request(
308
+ self,
309
+ method: str,
310
+ path: str,
311
+ *,
312
+ query: Mapping[str, Any] | None = None,
313
+ body: Any = None,
314
+ options: RequestOptions | None = None,
315
+ idempotent: bool = False,
316
+ ) -> Any:
317
+ """Any path, with the same retries and errors: for an operation newer than this SDK."""
318
+ op = Operation(f"{method} {path}", method.upper(), path, "json", idempotent)
319
+ return self._call(op, {}, dict(query or {}), body, options, None)
320
+
321
+ def watch(self, w: str, **kwargs: Any) -> Any:
322
+ """A live, resuming iterator of this World's frames; needs ``schedule-bit[watch]``."""
323
+ from .watch import Watch
324
+
325
+ return Watch(self, w, **kwargs)
326
+
327
+
328
+ class AsyncClient(_Base, AsyncOperations):
329
+ """The schedule-bit API, with asyncio. Same behaviour as `Client`; cancel a call by cancelling its task."""
330
+
331
+ def __init__(
332
+ self,
333
+ api_key: str | None = None,
334
+ *,
335
+ http: httpx.AsyncClient | None = None,
336
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
337
+ **kwargs: Any,
338
+ ) -> None:
339
+ super().__init__(api_key, **kwargs)
340
+ self._http = http or httpx.AsyncClient()
341
+ self._sleep = sleep
342
+
343
+ async def __aenter__(self) -> AsyncClient:
344
+ return self
345
+
346
+ async def __aexit__(self, *exc: object) -> None:
347
+ await self.aclose()
348
+
349
+ async def aclose(self) -> None:
350
+ await self._http.aclose()
351
+
352
+ async def _send(
353
+ self, op: Operation, request: httpx.Request, opts: RequestOptions, *, stream: bool = False
354
+ ) -> httpx.Response:
355
+ retries = self._retries(opts)
356
+ attempt = 0
357
+ while True:
358
+ if self.bucket:
359
+ await self.bucket.acquire_async()
360
+ try:
361
+ response = await self._http.send(request, stream=stream)
362
+ except httpx.TransportError as exc:
363
+ if attempt >= retries or not should_retry(op, None):
364
+ raise self._network(exc, request, attempt + 1) from exc
365
+ await self._sleep(wait_for(self.retry, attempt, None, self._clock(), self._rng))
366
+ attempt += 1
367
+ continue
368
+ self._observe(response)
369
+ if response.status_code < 400:
370
+ return response
371
+ if attempt >= retries or not should_retry(op, response.status_code):
372
+ if stream:
373
+ await response.aread()
374
+ raise self._error(response, request)
375
+ delay = wait_for(self.retry, attempt, response.headers, self._clock(), self._rng)
376
+ await response.aclose()
377
+ await self._sleep(delay)
378
+ attempt += 1
379
+
380
+ async def _call(
381
+ self,
382
+ op: Operation,
383
+ path: dict[str, str],
384
+ query: dict[str, Any],
385
+ body: Any,
386
+ options: RequestOptions | None,
387
+ parse: Any,
388
+ ) -> Any:
389
+ opts = options or RequestOptions()
390
+ request = self._request(op, path, query, body, opts)
391
+ return self._parse(op, await self._send(op, request, opts), parse)
392
+
393
+ def _stream(
394
+ self, op: Operation, path: dict[str, str], query: dict[str, Any], body: Any, options: RequestOptions | None
395
+ ) -> AsyncIterator[models.AssistEvent]:
396
+ opts = options or RequestOptions()
397
+ request = self._request(op, path, query, body, opts)
398
+ request.headers["Accept"] = "text/event-stream"
399
+
400
+ async def events() -> AsyncIterator[models.AssistEvent]:
401
+ response = await self._send(op, request, opts, stream=True)
402
+ parser = _ServerSentEvents()
403
+ try:
404
+ async for line in response.aiter_lines():
405
+ for event in parser.feed(line):
406
+ yield event
407
+ for event in parser.end():
408
+ yield event
409
+ finally:
410
+ await response.aclose()
411
+
412
+ return events()
413
+
414
+ async def try_claim(
415
+ self, w: str, body: Any = None, /, *, options: RequestOptions | None = None, **fields: Any
416
+ ) -> models.Receipt | Refusal:
417
+ """`claim`, but a refusal is returned rather than raised."""
418
+ try:
419
+ return await self.claim(w, body, options=options, **fields)
420
+ except Refusal as refusal:
421
+ return refusal
422
+
423
+ async def request(
424
+ self,
425
+ method: str,
426
+ path: str,
427
+ *,
428
+ query: Mapping[str, Any] | None = None,
429
+ body: Any = None,
430
+ options: RequestOptions | None = None,
431
+ idempotent: bool = False,
432
+ ) -> Any:
433
+ """Any path, with the same retries and errors: for an operation newer than this SDK."""
434
+ op = Operation(f"{method} {path}", method.upper(), path, "json", idempotent)
435
+ return await self._call(op, {}, dict(query or {}), body, options, None)
436
+
437
+ def watch(self, w: str, **kwargs: Any) -> Any:
438
+ """A live, resuming async iterator of this World's frames; needs ``schedule-bit[watch]``."""
439
+ from .watch import AsyncWatch
440
+
441
+ return AsyncWatch(self, w, **kwargs)
442
+
443
+
444
+ __all__ = ["DEFAULT_BASE_URL", "OPS", "AsyncClient", "Client"]
@@ -0,0 +1,2 @@
1
+ # Generated by sdks/python/scripts/generate.py from sdks/spec/openapi.json. Do not edit.
2
+ """Code generated from the contract: models and one method per operation."""