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