pyquickbase 0.1.0a1__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,511 @@
1
+ """Apps and tables: labels instead of IDs, cached schema, and automatic paging."""
2
+
3
+ import difflib
4
+ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
5
+ from functools import partial
6
+ from typing import Any, Self, TypeVar
7
+
8
+ from .._models import (
9
+ RECORD_ID_FIELD,
10
+ AppInfo,
11
+ Field,
12
+ FieldIndex,
13
+ Page,
14
+ Record,
15
+ Relationship,
16
+ TableInfo,
17
+ parse,
18
+ )
19
+ from .._query import Expression, FieldRef, build_query_body, compile_where
20
+ from .._schema import SchemaCache
21
+ from .._writes import RowInput, WriteResult, build_rows, refuse_field, require_field
22
+ from ..errors import (
23
+ InvalidResponseError,
24
+ InvalidValueError,
25
+ QueryError,
26
+ QuickbaseError,
27
+ UnknownFieldError,
28
+ UnknownTableError,
29
+ WriteError,
30
+ )
31
+ from ._transport import AsyncTransport
32
+
33
+ T = TypeVar("T")
34
+ DEFAULT_BATCH_SIZE = 1000
35
+
36
+
37
+ def _require_positive(name: str, value: int) -> None:
38
+ if value < 1:
39
+ raise QueryError(f"{name} must be at least 1.")
40
+
41
+
42
+ class AsyncApp:
43
+ """A Quickbase app. Create one with `qb.app(app_id)`. This makes no network calls."""
44
+
45
+ def __init__(self, transport: AsyncTransport, cache: SchemaCache, app_id: str) -> None:
46
+ self._transport = transport
47
+ self._cache = cache
48
+ self._id = app_id
49
+
50
+ @property
51
+ def id(self) -> str:
52
+ return self._id
53
+
54
+ async def info(self) -> AppInfo:
55
+ """Get the app's metadata."""
56
+ key = f"app:{self._id}"
57
+ cached = self._cache.get(key)
58
+ if cached is not None:
59
+ return cached
60
+ payload = await self._transport.request("GET", f"/apps/{self._id}")
61
+ info = parse(AppInfo.from_api, payload, "app")
62
+ self._cache.set(key, info)
63
+ return info
64
+
65
+ async def tables(self, *, refresh: bool = False) -> list[TableInfo]:
66
+ """List the app's tables, from the cache unless `refresh` is set."""
67
+ key = f"tables:{self._id}"
68
+ cached = None if refresh else self._cache.get(key)
69
+ if cached is not None:
70
+ return list(cached)
71
+ payload = await self._transport.request("GET", "/tables", params={"appId": self._id})
72
+ tables = parse(
73
+ lambda rows: tuple(TableInfo.from_api(row) for row in rows), payload, "tables"
74
+ )
75
+ self._cache.set(key, tables)
76
+ return list(tables)
77
+
78
+ async def create_table(
79
+ self,
80
+ name: str,
81
+ *,
82
+ description: str | None = None,
83
+ single_record_name: str | None = None,
84
+ plural_record_name: str | None = None,
85
+ ) -> "AsyncTable":
86
+ """Create a table and return it."""
87
+ optional = {
88
+ "description": description,
89
+ "singleRecordName": single_record_name,
90
+ "pluralRecordName": plural_record_name,
91
+ }
92
+ body = {"name": name, **{k: v for k, v in optional.items() if v is not None}}
93
+ payload = await self._transport.request(
94
+ "POST", "/tables", params={"appId": self._id}, json=body
95
+ )
96
+ self._cache.delete(f"tables:{self._id}")
97
+ return self.table(parse(lambda data: TableInfo.from_api(data).id, payload, "table"))
98
+
99
+ def table(self, name_or_id: str) -> "AsyncTable":
100
+ """Get a table in this app by name or ID. It's checked when first used."""
101
+ return AsyncTable(self._transport, self._cache, self, name_or_id)
102
+
103
+ def __repr__(self) -> str:
104
+ return f"{type(self).__name__}({self._id!r})"
105
+
106
+
107
+ def _find_table(tables: Sequence[TableInfo], ref: str) -> TableInfo | None:
108
+ return next((item for item in tables if item.id == ref), None) or next(
109
+ (item for item in tables if item.name == ref), None
110
+ )
111
+
112
+
113
+ class AsyncRecordIterator:
114
+ """Yields records from a query, fetching further pages as needed.
115
+
116
+ A class rather than an async generator, so stopping early with `break` needs no cleanup.
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ load_body: Callable[[], Any],
122
+ fetch_page: Callable[[dict[str, Any], int, int], Any],
123
+ page_size: int,
124
+ limit: int | None,
125
+ ) -> None:
126
+ self._load_body = load_body
127
+ self._fetch_page = fetch_page
128
+ self._page_size = page_size
129
+ self._limit = limit
130
+ self._body: dict[str, Any] | None = None
131
+ self._buffer: Iterator[Record] = iter(())
132
+ self._skip: int = 0
133
+ self._yielded: int = 0
134
+ self._done = False
135
+
136
+ def __aiter__(self) -> Self:
137
+ return self
138
+
139
+ async def __anext__(self) -> Record:
140
+ while True:
141
+ record = next(self._buffer, None)
142
+ if record is not None:
143
+ self._yielded += 1
144
+ return record
145
+ if self._done or self._yielded == self._limit:
146
+ raise StopAsyncIteration
147
+ await self._load_next_page()
148
+
149
+ async def _load_next_page(self) -> None:
150
+ body = self._body
151
+ if body is None:
152
+ body = self._body = await self._load_body()
153
+ remaining = None if self._limit is None else self._limit - self._yielded
154
+ top = self._page_size if remaining is None else min(self._page_size, remaining)
155
+ page: Page = await self._fetch_page(body, top, self._skip)
156
+ if page.has_more and not page.records:
157
+ raise InvalidResponseError(
158
+ f"Quickbase returned an empty page at {self._skip} of {page.total} records."
159
+ )
160
+ self._buffer = iter(page.records)
161
+ self._skip = page.next_skip
162
+ self._done = not page.has_more
163
+
164
+
165
+ class AsyncTable:
166
+ """A table in an app. Create one with `app.table("Tasks")`."""
167
+
168
+ def __init__(
169
+ self, transport: AsyncTransport, cache: SchemaCache, app: AsyncApp, ref: str
170
+ ) -> None:
171
+ self._transport = transport
172
+ self._cache = cache
173
+ self._app = app
174
+ self._ref = ref
175
+
176
+ async def info(self) -> TableInfo:
177
+ """Get the table's metadata. Raises `UnknownTableError` if it isn't in this app."""
178
+ tables = await self._app.tables()
179
+ found = _find_table(tables, self._ref)
180
+ if found is None:
181
+ # The table may be newer than the cache, so look once more before giving up.
182
+ tables = await self._app.tables(refresh=True)
183
+ found = _find_table(tables, self._ref)
184
+ if found is None:
185
+ names = [item.name for item in tables]
186
+ close = difflib.get_close_matches(self._ref, names, n=3)
187
+ hint = f" Did you mean {' or '.join(repr(name) for name in close)}?" if close else ""
188
+ raise UnknownTableError(f"App {self._app.id} has no table {self._ref!r}.{hint}")
189
+ return found
190
+
191
+ async def fields(self, *, refresh: bool = False) -> list[Field]:
192
+ """List the table's fields, from the cache unless `refresh` is set."""
193
+ table_id = (await self.info()).id
194
+ key = f"fields:{table_id}"
195
+ cached = None if refresh else self._cache.get(key)
196
+ if cached is not None:
197
+ return list(cached)
198
+ payload = await self._transport.request("GET", "/fields", params={"tableId": table_id})
199
+ fields = parse(lambda rows: tuple(Field.from_api(row) for row in rows), payload, "fields")
200
+ self._cache.set(key, fields)
201
+ return list(fields)
202
+
203
+ async def relationships(self, *, refresh: bool = False) -> list[Relationship]:
204
+ """List the relationships this table is part of, as either parent or child."""
205
+ table_id = (await self.info()).id
206
+ key = f"relationships:{table_id}"
207
+ cached = None if refresh else self._cache.get(key)
208
+ if cached is not None:
209
+ return list(cached)
210
+ found: list[Relationship] = []
211
+ while True:
212
+ payload = await self._transport.request(
213
+ "GET", f"/tables/{table_id}/relationships", params={"skip": len(found)}
214
+ )
215
+ page, total = parse(self._parse_relationships, payload, "relationships")
216
+ found.extend(page)
217
+ if not page or len(found) >= total:
218
+ break
219
+ self._cache.set(key, tuple(found))
220
+ return found
221
+
222
+ @staticmethod
223
+ def _parse_relationships(payload: Any) -> tuple[list[Relationship], int]:
224
+ rows = [Relationship.from_api(row) for row in payload["relationships"]]
225
+ return rows, payload["metadata"]["totalRelationships"]
226
+
227
+ async def _with_fields(self, build: Callable[[list[Field]], T]) -> T:
228
+ try:
229
+ return build(await self.fields())
230
+ except UnknownFieldError:
231
+ # The field may be newer than the cache, so look once more before giving up.
232
+ return build(await self.fields(refresh=True))
233
+
234
+ async def _query_body(
235
+ self,
236
+ where: Expression | str | None,
237
+ select: FieldRef | Sequence[FieldRef] | None,
238
+ order_by: FieldRef | Sequence[FieldRef] | None,
239
+ ) -> dict[str, Any]:
240
+ table_id = (await self.info()).id
241
+ return await self._with_fields(
242
+ lambda fields: build_query_body(
243
+ table_id, fields, where=where, select=select, order_by=order_by
244
+ )
245
+ )
246
+
247
+ async def _fetch_page(self, body: dict[str, Any], top: int, skip: int) -> Page:
248
+ payload = await self._transport.request(
249
+ "POST", "/records/query", json={**body, "options": {"top": top, "skip": skip}}
250
+ )
251
+ return parse(Page.from_api, payload, "records query")
252
+
253
+ async def query_page(
254
+ self,
255
+ *,
256
+ where: Expression | str | None = None,
257
+ select: FieldRef | Sequence[FieldRef] | None = None,
258
+ order_by: FieldRef | Sequence[FieldRef] | None = None,
259
+ top: int = 100,
260
+ skip: int = 0,
261
+ ) -> Page:
262
+ """Fetch one page of records. See `query` for the arguments."""
263
+ _require_positive("top", top)
264
+ if skip < 0:
265
+ raise QueryError("skip can't be negative.")
266
+ body = await self._query_body(where, select, order_by)
267
+ return await self._fetch_page(body, top, skip)
268
+
269
+ def query(
270
+ self,
271
+ *,
272
+ where: Expression | str | None = None,
273
+ select: FieldRef | Sequence[FieldRef] | None = None,
274
+ order_by: FieldRef | Sequence[FieldRef] | None = None,
275
+ page_size: int = 1000,
276
+ limit: int | None = None,
277
+ ) -> AsyncRecordIterator:
278
+ """Iterate over matching records, fetching further pages as needed.
279
+
280
+ Args:
281
+ where: A condition built with `F`, or a hand-written string, which gets checked.
282
+ select: Field labels or IDs. Defaults to the table's default fields. Record ID# is
283
+ always included.
284
+ order_by: Field labels or IDs. Prefix a label with `-`, or negate an ID, to sort
285
+ descending. Without this, records come back in Record ID# order.
286
+ page_size: Records to request per call to Quickbase.
287
+ limit: Stop after this many records.
288
+ """
289
+ _require_positive("page_size", page_size)
290
+ if limit is not None:
291
+ _require_positive("limit", limit)
292
+ return AsyncRecordIterator(
293
+ lambda: self._query_body(where, select, order_by), self._fetch_page, page_size, limit
294
+ )
295
+
296
+ async def count(self, where: Expression | str | None = None) -> int:
297
+ """Count the matching records without fetching them."""
298
+ body = await self._query_body(where, None, None)
299
+ return (await self._fetch_page({**body, "select": [RECORD_ID_FIELD]}, 1, 0)).total
300
+
301
+ async def _write(
302
+ self, rows: list[dict[str, Any]], merge_field_id: int | None, batch_size: int
303
+ ) -> WriteResult:
304
+ table_id = (await self.info()).id
305
+ result = WriteResult()
306
+ for start in range(0, len(rows), batch_size):
307
+ body: dict[str, Any] = {"to": table_id, "data": rows[start : start + batch_size]}
308
+ if merge_field_id is not None:
309
+ body["mergeFieldId"] = merge_field_id
310
+ try:
311
+ payload = await self._transport.request("POST", "/records", json=body)
312
+ except QuickbaseError as exc:
313
+ if start == 0:
314
+ raise
315
+ raise WriteError(
316
+ f"Writing stopped at row {start}, after earlier rows were written: {exc}",
317
+ result=result,
318
+ ) from exc
319
+ result = parse(partial(result.merged, offset=start), payload, "write")
320
+ if result.errors:
321
+ raise WriteError(result.summary(), result=result)
322
+ return result
323
+
324
+ async def insert(
325
+ self, rows: Iterable[RowInput], *, batch_size: int = DEFAULT_BATCH_SIZE
326
+ ) -> WriteResult:
327
+ """Create records from `{label or ID: value}` rows. This never changes existing records.
328
+
329
+ Raises `WriteError` if any row fails. Other rows are still written, and are listed in
330
+ the error's `result`. A value of `None` leaves the field empty.
331
+ """
332
+ _require_positive("batch_size", batch_size)
333
+ given = list(rows)
334
+ built = await self._with_fields(lambda fields: build_rows(given, FieldIndex(fields)))
335
+ refuse_field(built, RECORD_ID_FIELD, "which insert() can't do. Use update() instead")
336
+ return await self._write(built, None, batch_size)
337
+
338
+ async def update(
339
+ self, rows: Iterable[RowInput], *, batch_size: int = DEFAULT_BATCH_SIZE
340
+ ) -> WriteResult:
341
+ """Change existing records. Each row must set `Record ID#`.
342
+
343
+ A record that doesn't exist is an error rather than being created.
344
+ Fields left out of a row keep their values, and a value of `None` clears the field.
345
+ Raises `WriteError` if any row fails. Other rows are still written.
346
+ """
347
+ _require_positive("batch_size", batch_size)
348
+ given = list(rows)
349
+ built = await self._with_fields(lambda fields: build_rows(given, FieldIndex(fields)))
350
+ require_field(built, RECORD_ID_FIELD, "so update() can't tell which record to change")
351
+ return await self._write(built, RECORD_ID_FIELD, batch_size)
352
+
353
+ async def upsert(
354
+ self,
355
+ rows: Iterable[RowInput],
356
+ *,
357
+ merge_on: FieldRef,
358
+ batch_size: int = DEFAULT_BATCH_SIZE,
359
+ ) -> WriteResult:
360
+ """Update the record whose unique `merge_on` field matches, or create one if none does.
361
+
362
+ Raises `WriteError` if any row fails. Other rows are still written.
363
+ """
364
+ _require_positive("batch_size", batch_size)
365
+ given = list(rows)
366
+
367
+ def build(fields: list[Field]) -> tuple[list[dict[str, Any]], int]:
368
+ index = FieldIndex(fields)
369
+ return build_rows(given, index), index.resolve(merge_on).id
370
+
371
+ built, merge_field_id = await self._with_fields(build)
372
+ require_field(built, merge_field_id, "the merge_on field")
373
+ return await self._write(built, merge_field_id, batch_size)
374
+
375
+ async def delete(self, where: Expression | str) -> int:
376
+ """Delete the matching records and return how many were deleted.
377
+
378
+ `where` is required and is checked first. Quickbase would otherwise treat a lowercase
379
+ operator as "contains" and delete far more than you meant.
380
+ """
381
+ table_id = (await self.info()).id
382
+ clause = await self._with_fields(lambda fields: compile_where(where, FieldIndex(fields)))
383
+ payload = await self._transport.request(
384
+ "DELETE", "/records", json={"from": table_id, "where": clause}
385
+ )
386
+ return parse(lambda data: int(data["numberDeleted"]), payload, "delete")
387
+
388
+ def _forget_fields(self, table_id: str) -> None:
389
+ self._cache.delete(f"fields:{table_id}", f"relationships:{table_id}")
390
+
391
+ async def create_field(
392
+ self,
393
+ label: str,
394
+ field_type: str,
395
+ *,
396
+ choices: Sequence[str] | None = None,
397
+ required: bool | None = None,
398
+ unique: bool | None = None,
399
+ properties: Mapping[str, Any] | None = None,
400
+ ) -> Field:
401
+ """Create a field and return it. `field_type` is Quickbase's, such as `text` or `date`.
402
+
403
+ Quickbase sets `required` and `unique` in a second request, so if that one fails the field
404
+ exists without them.
405
+ """
406
+ table_id = (await self.info()).id
407
+ merged = dict(properties or {})
408
+ if choices is not None:
409
+ merged["choices"] = list(choices)
410
+ body: dict[str, Any] = {"label": label, "fieldType": field_type}
411
+ if merged:
412
+ body["properties"] = merged
413
+ payload = await self._transport.request(
414
+ "POST", "/fields", params={"tableId": table_id}, json=body
415
+ )
416
+ self._forget_fields(table_id)
417
+ field = parse(Field.from_api, payload, "field")
418
+ if required is None and unique is None:
419
+ return field
420
+ return await self.update_field(field.id, required=required, unique=unique)
421
+
422
+ async def update_field(
423
+ self,
424
+ field: FieldRef,
425
+ *,
426
+ label: str | None = None,
427
+ required: bool | None = None,
428
+ unique: bool | None = None,
429
+ properties: Mapping[str, Any] | None = None,
430
+ ) -> Field:
431
+ """Change a field's settings and return it. Anything left as None stays as it is."""
432
+ table_id = (await self.info()).id
433
+ field_id = await self._with_fields(lambda fields: FieldIndex(fields).resolve(field).id)
434
+ optional = {
435
+ "label": label,
436
+ "required": required,
437
+ "unique": unique,
438
+ "properties": properties,
439
+ }
440
+ body = {k: v for k, v in optional.items() if v is not None}
441
+ if not body:
442
+ raise InvalidValueError("update_field() needs at least one setting to change.")
443
+ payload = await self._transport.request(
444
+ "POST", f"/fields/{field_id}", params={"tableId": table_id}, json=body
445
+ )
446
+ self._forget_fields(table_id)
447
+ return parse(Field.from_api, payload, "field")
448
+
449
+ async def delete_fields(self, fields: FieldRef | Sequence[FieldRef]) -> None:
450
+ """Delete fields and their data. Raises `WriteError` if any couldn't be deleted."""
451
+ table_id = (await self.info()).id
452
+ refs = [fields] if isinstance(fields, str | int) else list(fields)
453
+ if not refs:
454
+ raise InvalidValueError("delete_fields() needs at least one field.")
455
+ field_ids = await self.resolve_field_ids(refs)
456
+ payload = await self._transport.request(
457
+ "DELETE", "/fields", params={"tableId": table_id}, json={"fieldIds": field_ids}
458
+ )
459
+ self._forget_fields(table_id)
460
+ errors = parse(lambda data: list(data.get("errors") or ()), payload, "delete fields")
461
+ if errors:
462
+ raise WriteError(f"Some fields weren't deleted: {'; '.join(map(str, errors))}")
463
+
464
+ async def create_relationship(
465
+ self,
466
+ parent: "AsyncTable",
467
+ *,
468
+ foreign_key_label: str,
469
+ lookup_fields: Sequence[FieldRef] = (),
470
+ ) -> Relationship:
471
+ """Make this table a child of `parent`, with a new foreign key field on this table.
472
+
473
+ `lookup_fields` are fields of the parent to show on this table.
474
+ """
475
+ table_id = (await self.info()).id
476
+ parent_id = (await parent.info()).id
477
+ lookup_ids = await parent.resolve_field_ids(lookup_fields)
478
+ payload = await self._transport.request(
479
+ "POST",
480
+ f"/tables/{table_id}/relationship",
481
+ json={
482
+ "parentTableId": parent_id,
483
+ "foreignKeyField": {"label": foreign_key_label},
484
+ "lookupFieldIds": lookup_ids,
485
+ },
486
+ )
487
+ self._forget_fields(table_id)
488
+ self._forget_fields(parent_id)
489
+ return parse(Relationship.from_api, payload, "relationship")
490
+
491
+ async def resolve_field_ids(self, fields: Sequence[FieldRef]) -> list[int]:
492
+ """Turn field labels or IDs into IDs. Raises `UnknownFieldError` if one doesn't exist."""
493
+ return await self._with_fields(
494
+ lambda known: [FieldIndex(known).resolve(ref).id for ref in fields]
495
+ )
496
+
497
+ async def drop(self, *, confirm: str) -> None:
498
+ """Delete this table and all its records. `confirm` must be the table's exact name."""
499
+ info = await self.info()
500
+ if confirm != info.name:
501
+ raise InvalidValueError(
502
+ f"Not dropping table {info.name!r}: confirm must be its exact name."
503
+ )
504
+ await self._transport.request(
505
+ "DELETE", f"/tables/{info.id}", params={"appId": self._app.id}
506
+ )
507
+ self._forget_fields(info.id)
508
+ self._cache.delete(f"tables:{self._app.id}")
509
+
510
+ def __repr__(self) -> str:
511
+ return f"{type(self).__name__}({self._ref!r}, app={self._app.id!r})"
@@ -0,0 +1,74 @@
1
+ """Send one logical request: auth, headers, retries, and error mapping."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ import anyio
7
+ import httpx
8
+
9
+ from .._config import build_url, check_allowed
10
+ from .._response import error_from_response, error_from_transport, parse_response
11
+ from .._retry import RetryPolicy, parse_retry_after
12
+
13
+
14
+ class AsyncTransport:
15
+ def __init__(
16
+ self,
17
+ *,
18
+ realm: str,
19
+ auth: httpx.Auth,
20
+ base_url: str,
21
+ retry: RetryPolicy,
22
+ timeout: float,
23
+ user_agent: str,
24
+ http_client: httpx.AsyncClient | None,
25
+ allowed_writes: frozenset[tuple[str, str]] | None = None,
26
+ ) -> None:
27
+ self._allowed_writes = allowed_writes
28
+ self._auth = auth
29
+ self._base_url = base_url
30
+ self._retry = retry
31
+ self._headers = {"QB-Realm-Hostname": realm, "User-Agent": user_agent}
32
+ self._owns_http = http_client is None
33
+ self._http = httpx.AsyncClient(timeout=timeout) if http_client is None else http_client
34
+
35
+ async def request(
36
+ self,
37
+ method: str,
38
+ path: str,
39
+ *,
40
+ params: Mapping[str, Any] | None = None,
41
+ json: Any = None,
42
+ ) -> Any:
43
+ method = method.upper()
44
+ url = build_url(self._base_url, path)
45
+ if self._allowed_writes is not None:
46
+ check_allowed(method, path, self._allowed_writes)
47
+ attempt = 0
48
+ while True:
49
+ try:
50
+ response = await self._http.request(
51
+ method, url, params=params, json=json, headers=self._headers, auth=self._auth
52
+ )
53
+ except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
54
+ # Nothing reached the server, so this is safe to retry even for writes.
55
+ if attempt >= self._retry.max_retries:
56
+ raise error_from_transport(exc, method, path) from exc
57
+ delay = self._retry.backoff(attempt)
58
+ except httpx.TransportError as exc:
59
+ raise error_from_transport(exc, method, path) from exc
60
+ else:
61
+ if response.status_code != 429 or attempt >= self._retry.max_retries:
62
+ return parse_response(response, method, path)
63
+ retry_after = parse_retry_after(response.headers.get("retry-after"))
64
+ wait = self._retry.rate_limit_delay(attempt, retry_after)
65
+ if wait is None:
66
+ raise error_from_response(response, method, path)
67
+ delay = wait
68
+ await anyio.sleep(delay)
69
+ attempt += 1
70
+
71
+ async def aclose(self) -> None:
72
+ """Close the HTTP client, unless the caller supplied it and so still owns it."""
73
+ if self._owns_http:
74
+ await self._http.aclose()
pyquickbase/_auth.py ADDED
@@ -0,0 +1,19 @@
1
+ """Authentication. Keeping the token here means no other object ever holds it."""
2
+
3
+ from collections.abc import Generator
4
+
5
+ import httpx
6
+
7
+
8
+ class UserTokenAuth(httpx.Auth):
9
+ """Add a Quickbase user token to each request."""
10
+
11
+ def __init__(self, token: str) -> None:
12
+ self._header = f"QB-USER-TOKEN {token}"
13
+
14
+ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
15
+ request.headers["Authorization"] = self._header
16
+ yield request
17
+
18
+ def __repr__(self) -> str:
19
+ return "UserTokenAuth(token=***)"