quickbase-api 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,68 @@
1
+ """A simple Python wrapper for the Quickbase API.
2
+
3
+ Example::
4
+
5
+ import quickbase_api
6
+
7
+ # Option 1: Using the factory function (reads from environment variables)
8
+ client = quickbase_api.client()
9
+
10
+ # Option 2: Passing credentials directly
11
+ client = quickbase_api.client(
12
+ realm="mycompany.quickbase.com",
13
+ user_token="my-token",
14
+ )
15
+
16
+ # Option 3: Using the class directly
17
+ from quickbase_api import QuickbaseClient
18
+ client = QuickbaseClient("mycompany.quickbase.com", "my-token")
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+
25
+ from quickbase_api.api import QuickbaseClient
26
+ from quickbase_api.exceptions import (
27
+ QuickbaseAPIError,
28
+ QuickbaseError,
29
+ QuickbaseNotFoundError,
30
+ )
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "QuickbaseClient",
36
+ "QuickbaseAPIError",
37
+ "QuickbaseError",
38
+ "QuickbaseNotFoundError",
39
+ "client",
40
+ ]
41
+
42
+
43
+ def client(realm: str = None, user_token: str = None) -> QuickbaseClient:
44
+ """Create a QuickbaseClient, falling back to environment variables.
45
+
46
+ Args:
47
+ realm: The Quickbase realm hostname. If not provided, reads from
48
+ the ``QUICKBASE_REALM`` environment variable.
49
+ user_token: The Quickbase user token. If not provided, reads from
50
+ the ``QUICKBASE_USER_TOKEN`` environment variable.
51
+
52
+ Returns:
53
+ A configured QuickbaseClient instance.
54
+
55
+ Raises:
56
+ KeyError: If a credential is not provided and the corresponding
57
+ environment variable is not set.
58
+
59
+ Example::
60
+
61
+ # Set environment variables, then:
62
+ import quickbase_api
63
+ client = quickbase_api.client()
64
+ """
65
+ return QuickbaseClient(
66
+ realm=realm or os.environ["QUICKBASE_REALM"],
67
+ user_token=user_token or os.environ["QUICKBASE_USER_TOKEN"],
68
+ )
quickbase_api/api.py ADDED
@@ -0,0 +1,564 @@
1
+ # FILE: api.py
2
+
3
+ """Quickbase API client providing high-level methods for apps, tables, fields, and records."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import Optional
9
+
10
+ from quickbase_api.exceptions import QuickbaseAPIError, QuickbaseNotFoundError
11
+ from quickbase_api.legacy_method import LegacyMethod
12
+ from quickbase_api.rest_method import RestMethod
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class QuickbaseClient:
18
+ """High-level client for interacting with the Quickbase API.
19
+
20
+ Args:
21
+ realm: The Quickbase realm hostname (e.g., 'mycompany.quickbase.com').
22
+ user_token: The Quickbase user token for authentication.
23
+
24
+ Example::
25
+
26
+ from quickbase_api import QuickbaseClient
27
+
28
+ client = QuickbaseClient("mycompany.quickbase.com", "my-token")
29
+ app = client.get_app("bqrg4xyza")
30
+ """
31
+
32
+ def __init__(self, realm: str, user_token: str):
33
+ self.realm = realm
34
+ self.user_token = user_token
35
+ self._rm = RestMethod(realm, user_token)
36
+ self._lm = LegacyMethod(realm, user_token)
37
+
38
+ def _require_success(self, resp_json: dict, resp_code: int, context: str) -> dict:
39
+ """Raise QuickbaseAPIError if the response code indicates failure.
40
+
41
+ Args:
42
+ resp_json: The parsed response body.
43
+ resp_code: The HTTP status code.
44
+ context: A human-readable description of the operation for error messages.
45
+
46
+ Returns:
47
+ The response body if successful.
48
+
49
+ Raises:
50
+ QuickbaseAPIError: If the status code is 400 or above.
51
+ """
52
+ if resp_code >= 400:
53
+ raise QuickbaseAPIError(resp_code, resp_json)
54
+ return resp_json
55
+
56
+ # ── Apps ──────────────────────────────────────────────────────────────
57
+
58
+ def create_app(
59
+ self,
60
+ name: str,
61
+ description: str = None,
62
+ assign_token: bool = None,
63
+ ) -> str:
64
+ """Create a new Quickbase app.
65
+
66
+ Args:
67
+ name: The name for the new app.
68
+ description: Optional description for the app.
69
+ assign_token: If True, assigns the user token to the new app.
70
+
71
+ Returns:
72
+ The app ID of the newly created app.
73
+
74
+ Raises:
75
+ QuickbaseAPIError: If the API request fails.
76
+ """
77
+ body = {"name": name}
78
+ if description is not None:
79
+ body["description"] = description
80
+ if assign_token is not None:
81
+ body["assignToken"] = assign_token
82
+
83
+ resp_json, resp_code = self._rm.post("apps", data=body)
84
+ self._require_success(resp_json, resp_code, f"create app '{name}'")
85
+ logger.info("Created app '%s' with ID: %s", resp_json["name"], resp_json["id"])
86
+ return resp_json["id"]
87
+
88
+ def delete_app(self, app_name: str, app_id: str) -> dict:
89
+ """Delete a Quickbase app.
90
+
91
+ Args:
92
+ app_name: The name of the app (required for confirmation).
93
+ app_id: The ID of the app to delete.
94
+
95
+ Returns:
96
+ The API response body.
97
+
98
+ Raises:
99
+ QuickbaseAPIError: If the API request fails.
100
+ """
101
+ resp_json, resp_code = self._rm.delete(
102
+ f"apps/{app_id}",
103
+ data={"name": app_name},
104
+ )
105
+ self._require_success(resp_json, resp_code, f"delete app '{app_name}'")
106
+ logger.info("Deleted app '%s' (%s)", app_name, app_id)
107
+ return resp_json
108
+
109
+ def get_app(self, app_id: str) -> dict:
110
+ """Get metadata for a Quickbase app.
111
+
112
+ Args:
113
+ app_id: The ID of the app.
114
+
115
+ Returns:
116
+ A dictionary of app metadata.
117
+
118
+ Raises:
119
+ QuickbaseAPIError: If the API request fails.
120
+ """
121
+ resp_json, resp_code = self._rm.get(f"apps/{app_id}")
122
+ self._require_success(resp_json, resp_code, f"get app '{app_id}'")
123
+ return resp_json
124
+
125
+ # ── Tables ────────────────────────────────────────────────────────────
126
+
127
+ def create_table(self, app_id: str, properties: dict) -> str:
128
+ """Create a new table in a Quickbase app.
129
+
130
+ Args:
131
+ app_id: The ID of the app to create the table in.
132
+ properties: A dictionary of table properties (must include 'name').
133
+
134
+ Returns:
135
+ The table ID of the newly created table.
136
+
137
+ Raises:
138
+ QuickbaseAPIError: If the API request fails.
139
+ """
140
+ resp_json, resp_code = self._rm.post(
141
+ "tables",
142
+ params={"appId": app_id},
143
+ data=properties,
144
+ )
145
+ self._require_success(resp_json, resp_code, f"create table in app '{app_id}'")
146
+ logger.info("Created table '%s' with ID: %s", resp_json["name"], resp_json["id"])
147
+ return resp_json["id"]
148
+
149
+ def get_tables(self, app_id: str) -> list[dict]:
150
+ """Get all tables for a Quickbase app.
151
+
152
+ Args:
153
+ app_id: The ID of the app.
154
+
155
+ Returns:
156
+ A list of table metadata dictionaries.
157
+
158
+ Raises:
159
+ QuickbaseAPIError: If the API request fails.
160
+ """
161
+ resp_json, resp_code = self._rm.get("tables", params={"appId": app_id})
162
+ self._require_success(resp_json, resp_code, f"get tables for app '{app_id}'")
163
+ return resp_json
164
+
165
+ def get_table_id(self, app_id: str, table_name: str) -> str:
166
+ """Find a table ID by its name.
167
+
168
+ Args:
169
+ app_id: The ID of the app containing the table.
170
+ table_name: The exact name of the table to find.
171
+
172
+ Returns:
173
+ The table ID.
174
+
175
+ Raises:
176
+ QuickbaseNotFoundError: If no table with the given name is found.
177
+ """
178
+ tables = self.get_tables(app_id)
179
+ for table in tables:
180
+ if table["name"] == table_name:
181
+ return table["id"]
182
+ raise QuickbaseNotFoundError(f"No table named '{table_name}' in app '{app_id}'")
183
+
184
+ def delete_table(self, app_id: str, table_id: str) -> dict:
185
+ """Delete a table from a Quickbase app.
186
+
187
+ Args:
188
+ app_id: The ID of the app containing the table.
189
+ table_id: The ID of the table to delete.
190
+
191
+ Returns:
192
+ The API response body.
193
+
194
+ Raises:
195
+ QuickbaseAPIError: If the API request fails.
196
+ """
197
+ resp_json, resp_code = self._rm.delete(
198
+ f"tables/{table_id}",
199
+ params={"appId": app_id},
200
+ )
201
+ self._require_success(resp_json, resp_code, f"delete table '{table_id}'")
202
+ logger.info("Deleted table %s from app %s", table_id, app_id)
203
+ return resp_json
204
+
205
+ # ── Reports ───────────────────────────────────────────────────────────
206
+
207
+ def get_reports(self, table_id: str) -> list[dict]:
208
+ """Get all reports for a table.
209
+
210
+ Args:
211
+ table_id: The ID of the table.
212
+
213
+ Returns:
214
+ A list of report metadata dictionaries.
215
+
216
+ Raises:
217
+ QuickbaseAPIError: If the API request fails.
218
+ """
219
+ resp_json, resp_code = self._rm.get("reports", params={"tableId": table_id})
220
+ self._require_success(resp_json, resp_code, f"get reports for table '{table_id}'")
221
+ return resp_json
222
+
223
+ def get_report(self, table_id: str, report_id: str) -> dict:
224
+ """Get metadata for a specific report.
225
+
226
+ Args:
227
+ table_id: The ID of the table containing the report.
228
+ report_id: The ID of the report.
229
+
230
+ Returns:
231
+ A dictionary of report metadata.
232
+
233
+ Raises:
234
+ QuickbaseAPIError: If the API request fails.
235
+ """
236
+ resp_json, resp_code = self._rm.get(
237
+ f"reports/{report_id}",
238
+ params={"tableId": table_id},
239
+ )
240
+ self._require_success(resp_json, resp_code, f"get report '{report_id}'")
241
+ return resp_json
242
+
243
+ def run_report(
244
+ self,
245
+ table_id: str,
246
+ report_id: str,
247
+ skip: int = None,
248
+ top: int = None,
249
+ ) -> dict:
250
+ """Run a report and return its results.
251
+
252
+ Args:
253
+ table_id: The ID of the table containing the report.
254
+ report_id: The ID of the report to run.
255
+ skip: Number of records to skip (for pagination).
256
+ top: Maximum number of records to return.
257
+
258
+ Returns:
259
+ The report results.
260
+
261
+ Raises:
262
+ QuickbaseAPIError: If the API request fails.
263
+ """
264
+ params = {"tableId": table_id}
265
+ if skip is not None:
266
+ params["skip"] = skip
267
+ if top is not None:
268
+ params["top"] = top
269
+
270
+ resp_json, resp_code = self._rm.post(
271
+ f"reports/{report_id}/run",
272
+ params=params,
273
+ )
274
+ self._require_success(resp_json, resp_code, f"run report '{report_id}'")
275
+ return resp_json
276
+
277
+ # ── Fields ────────────────────────────────────────────────────────────
278
+
279
+ def create_field(self, table_id: str, properties: dict) -> dict:
280
+ """Create a new field in a table.
281
+
282
+ Args:
283
+ table_id: The ID of the table.
284
+ properties: Field properties (must include 'label' and 'fieldType').
285
+
286
+ Returns:
287
+ The API response body with the created field's metadata.
288
+
289
+ Raises:
290
+ QuickbaseAPIError: If the API request fails.
291
+ """
292
+ resp_json, resp_code = self._rm.post(
293
+ "fields",
294
+ params={"tableId": table_id},
295
+ data=properties,
296
+ )
297
+ self._require_success(resp_json, resp_code, f"create field in table '{table_id}'")
298
+ logger.info(
299
+ "Created field '%s' (ID: %s) in table %s",
300
+ resp_json["label"],
301
+ resp_json["id"],
302
+ table_id,
303
+ )
304
+ return resp_json
305
+
306
+ def create_fields(self, table_id: str, fields: list[dict]) -> dict:
307
+ """Create multiple fields in a table.
308
+
309
+ Args:
310
+ table_id: The ID of the table.
311
+ fields: A list of field property dictionaries.
312
+
313
+ Returns:
314
+ A dict with 'succeeded' and 'failed' lists.
315
+ """
316
+ results = {"succeeded": [], "failed": []}
317
+ for field in fields:
318
+ try:
319
+ created = self.create_field(table_id, field)
320
+ results["succeeded"].append(created)
321
+ except QuickbaseAPIError as e:
322
+ logger.warning("Failed to create field %s: %s", field, e)
323
+ results["failed"].append({"field": field, "error": str(e)})
324
+
325
+ logger.info(
326
+ "%d of %d fields created for table %s",
327
+ len(results["succeeded"]),
328
+ len(fields),
329
+ table_id,
330
+ )
331
+ return results
332
+
333
+ def get_table_fields(self, table_id: str) -> list[dict]:
334
+ """Get all fields for a table.
335
+
336
+ Args:
337
+ table_id: The ID of the table.
338
+
339
+ Returns:
340
+ A list of field metadata dictionaries.
341
+
342
+ Raises:
343
+ QuickbaseAPIError: If the API request fails.
344
+ """
345
+ resp_json, resp_code = self._rm.get("fields", params={"tableId": table_id})
346
+ self._require_success(resp_json, resp_code, f"get fields for table '{table_id}'")
347
+ return resp_json
348
+
349
+ def get_field_id(self, table_id: str, field_label: str) -> str:
350
+ """Find a field ID by its label.
351
+
352
+ Args:
353
+ table_id: The ID of the table.
354
+ field_label: The exact label of the field to find.
355
+
356
+ Returns:
357
+ The field ID as a string.
358
+
359
+ Raises:
360
+ QuickbaseNotFoundError: If no field with the given label is found.
361
+ """
362
+ fields = self.get_table_fields(table_id)
363
+ for field in fields:
364
+ if field["label"] == field_label:
365
+ return str(field["id"])
366
+ raise QuickbaseNotFoundError(f"No field labeled '{field_label}' in table '{table_id}'")
367
+
368
+ def get_field_label_id_map(self, table_id: str) -> dict[str, str]:
369
+ """Build a mapping of field labels to field IDs.
370
+
371
+ Args:
372
+ table_id: The ID of the table.
373
+
374
+ Returns:
375
+ A dict mapping field label strings to field ID strings.
376
+ """
377
+ fields = self.get_table_fields(table_id)
378
+ return {f["label"]: str(f["id"]) for f in fields}
379
+
380
+ def get_key_field_id(self, app_id: str, table_id: str) -> str:
381
+ """Get the key field ID for a table.
382
+
383
+ Args:
384
+ app_id: The ID of the app.
385
+ table_id: The ID of the table.
386
+
387
+ Returns:
388
+ The key field ID as a string.
389
+
390
+ Raises:
391
+ QuickbaseAPIError: If the API request fails.
392
+ """
393
+ resp_json, resp_code = self._rm.get(
394
+ f"tables/{table_id}",
395
+ params={"appId": app_id},
396
+ )
397
+ self._require_success(resp_json, resp_code, f"get key field for table '{table_id}'")
398
+ return str(resp_json["keyFieldId"])
399
+
400
+ def set_key_field(self, table_id: str, field_id: str) -> bool:
401
+ """Set the key field for a table using the legacy API.
402
+
403
+ Args:
404
+ table_id: The ID of the table.
405
+ field_id: The ID of the field to set as the key field.
406
+
407
+ Returns:
408
+ True if the key field was set successfully.
409
+
410
+ Raises:
411
+ QuickbaseAPIError: If the API request fails.
412
+ """
413
+ url = f"https://{self.realm}/db/{table_id}"
414
+ params = {"a": "API_SetKeyField", "fid": field_id}
415
+
416
+ resp_json, resp_code = self._lm.post(url, params=params)
417
+ if resp_code != 200:
418
+ raise QuickbaseAPIError(resp_code, resp_json)
419
+
420
+ logger.info(
421
+ "Set key field to %s for table %s",
422
+ field_id,
423
+ table_id,
424
+ )
425
+ return True
426
+
427
+ def set_required_field(self, table_id: str, field_id: str) -> bool:
428
+ """Mark a field as required.
429
+
430
+ Args:
431
+ table_id: The ID of the table.
432
+ field_id: The ID of the field to mark as required.
433
+
434
+ Returns:
435
+ True if the field was successfully marked as required.
436
+
437
+ Raises:
438
+ QuickbaseAPIError: If the API request fails.
439
+ """
440
+ resp_json, resp_code = self._rm.post(
441
+ f"fields/{field_id}",
442
+ params={"tableId": table_id},
443
+ data={"required": True},
444
+ )
445
+ self._require_success(resp_json, resp_code, f"set field '{field_id}' as required")
446
+ return True
447
+
448
+ # ── Records ───────────────────────────────────────────────────────────
449
+
450
+ def upsert_records(self, table_id: str, data: list[dict]) -> dict:
451
+ """Insert or update records in a table.
452
+
453
+ Args:
454
+ table_id: The ID of the table.
455
+ data: A list of record dictionaries, keyed by field ID::
456
+
457
+ [
458
+ {
459
+ "6": {"value": "Some text"},
460
+ "7": {"value": 42},
461
+ "8": {"value": "2024-01-15T08:00:00Z"},
462
+ }
463
+ ]
464
+
465
+ Returns:
466
+ A dict with keys: processed, created, updated, unchanged,
467
+ errored, errored_details.
468
+
469
+ Raises:
470
+ QuickbaseAPIError: If the API request fails entirely (4xx/5xx).
471
+ """
472
+ resp_json, resp_code = self._rm.post(
473
+ "records",
474
+ data={"to": table_id, "data": data},
475
+ )
476
+ if resp_code not in (200, 207):
477
+ raise QuickbaseAPIError(resp_code, resp_json)
478
+
479
+ metadata = resp_json["metadata"]
480
+ line_errors = metadata.get("lineErrors", {})
481
+
482
+ result = {
483
+ "processed": metadata["totalNumberOfRecordsProcessed"] - len(line_errors),
484
+ "created": len(metadata["createdRecordIds"]),
485
+ "updated": len(metadata["updatedRecordIds"]),
486
+ "unchanged": len(metadata["unchangedRecordIds"]),
487
+ "errored": len(line_errors),
488
+ "errored_details": line_errors,
489
+ }
490
+
491
+ if line_errors:
492
+ logger.warning(
493
+ "Upsert to table %s had %d line errors: %s",
494
+ table_id,
495
+ len(line_errors),
496
+ line_errors,
497
+ )
498
+
499
+ return result
500
+
501
+ def delete_records(self, table_id: str, where: str) -> int:
502
+ """Delete records from a table.
503
+
504
+ Args:
505
+ table_id: The ID of the table.
506
+ where: A Quickbase query string (e.g., "{6.EX.'123'}").
507
+
508
+ Returns:
509
+ The number of records deleted.
510
+
511
+ Raises:
512
+ QuickbaseAPIError: If the API request fails.
513
+ """
514
+ resp_json, resp_code = self._rm.delete(
515
+ "records",
516
+ data={"from": table_id, "where": where},
517
+ )
518
+ self._require_success(resp_json, resp_code, f"delete records from table '{table_id}'")
519
+ deleted = resp_json["numberDeleted"]
520
+ logger.info("Deleted %d records from table %s", deleted, table_id)
521
+ return deleted
522
+
523
+ def query_for_data(
524
+ self,
525
+ table_id: str,
526
+ select: list[int] = None,
527
+ where: str = None,
528
+ sort_by: list[dict] = None,
529
+ group_by: list[dict] = None,
530
+ options: dict = None,
531
+ ) -> dict:
532
+ """Query for record data from a table.
533
+
534
+ Args:
535
+ table_id: The ID of the table to query.
536
+ select: List of field IDs to return. If omitted, returns
537
+ fields from the default report.
538
+ where: A Quickbase query string (e.g., "{12.EX.'VPF'}").
539
+ sort_by: Sort order, e.g., [{"fieldId": 6, "order": "ASC"}].
540
+ group_by: Grouping, e.g., [{"fieldId": 6, "grouping": "equal-values"}].
541
+ options: Additional options, e.g.,
542
+ {"skip": 0, "top": 100, "compareWithAppLocalTime": False}.
543
+
544
+ Returns:
545
+ The query response including data and metadata.
546
+
547
+ Raises:
548
+ QuickbaseAPIError: If the API request fails.
549
+ """
550
+ body = {"from": table_id}
551
+ if select is not None:
552
+ body["select"] = select
553
+ if where is not None:
554
+ body["where"] = where
555
+ if sort_by is not None:
556
+ body["sortBy"] = sort_by
557
+ if group_by is not None:
558
+ body["groupBy"] = group_by
559
+ if options is not None:
560
+ body["options"] = options
561
+
562
+ resp_json, resp_code = self._rm.post("records/query", data=body)
563
+ self._require_success(resp_json, resp_code, f"query table '{table_id}'")
564
+ return resp_json
@@ -0,0 +1,19 @@
1
+ """Custom exceptions for the quickbase-api package."""
2
+
3
+
4
+ class QuickbaseError(Exception):
5
+ """Base exception for all Quickbase errors."""
6
+
7
+
8
+ class QuickbaseAPIError(QuickbaseError):
9
+ """Raised when the Quickbase API returns an unexpected status code."""
10
+
11
+ def __init__(self, status_code: int, response_body: dict):
12
+ self.status_code = status_code
13
+ self.response_body = response_body
14
+ message = response_body.get("message", response_body)
15
+ super().__init__(f"Quickbase API error {status_code}: {message}")
16
+
17
+
18
+ class QuickbaseNotFoundError(QuickbaseError):
19
+ """Raised when a requested resource (table, field, etc.) is not found."""
@@ -0,0 +1,66 @@
1
+ """Client for Quickbase's legacy XML/URL-based API endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from quickbase_api.session import create_session
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class LegacyMethod:
13
+ """Low-level HTTP client for legacy Quickbase API endpoints.
14
+
15
+ Used for older API actions (e.g., API_SetKeyField) that are not
16
+ available through the modern REST API.
17
+ """
18
+
19
+ def __init__(self, realm: str, user_token: str):
20
+ self.realm = realm
21
+ self.session = create_session(realm, user_token)
22
+
23
+ def make_request(
24
+ self,
25
+ method: str,
26
+ url: str,
27
+ params: dict = None,
28
+ ) -> tuple[dict, int]:
29
+ """Execute an HTTP request against a legacy Quickbase endpoint.
30
+
31
+ Args:
32
+ method: HTTP method (GET, POST, DELETE).
33
+ url: Full URL for the legacy endpoint.
34
+ params: Optional query string parameters.
35
+
36
+ Returns:
37
+ A tuple of (response_body, status_code).
38
+ """
39
+ response = self.session.request(method=method, url=url, params=params)
40
+ try:
41
+ body = response.json()
42
+ except ValueError:
43
+ body = {"raw_response": response.text}
44
+
45
+ if response.status_code >= 400:
46
+ logger.error(
47
+ "Legacy API error %d on %s %s: %s",
48
+ response.status_code,
49
+ method,
50
+ url,
51
+ body,
52
+ )
53
+
54
+ return body, response.status_code
55
+
56
+ def get(self, url: str, params: dict = None) -> tuple[dict, int]:
57
+ """Send a GET request."""
58
+ return self.make_request("GET", url, params=params)
59
+
60
+ def post(self, url: str, params: dict = None) -> tuple[dict, int]:
61
+ """Send a POST request."""
62
+ return self.make_request("POST", url, params=params)
63
+
64
+ def delete(self, url: str, params: dict = None) -> tuple[dict, int]:
65
+ """Send a DELETE request."""
66
+ return self.make_request("DELETE", url, params=params)
@@ -0,0 +1,75 @@
1
+ """Client for Quickbase's RESTful JSON API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from quickbase_api.session import create_session
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ BASE_URL = "https://api.quickbase.com/v1"
12
+
13
+
14
+ class RestMethod:
15
+ """Low-level HTTP client for the Quickbase REST API.
16
+
17
+ Handles URL construction, request execution, and response parsing
18
+ for all REST API endpoints.
19
+ """
20
+
21
+ def __init__(self, realm: str, user_token: str):
22
+ self.session = create_session(realm, user_token)
23
+
24
+ def make_request(
25
+ self,
26
+ method: str,
27
+ endpoint: str,
28
+ params: dict = None,
29
+ data: dict = None,
30
+ ) -> tuple[dict, int]:
31
+ """Execute an HTTP request against the Quickbase REST API.
32
+
33
+ Args:
34
+ method: HTTP method (GET, POST, DELETE).
35
+ endpoint: API endpoint path (appended to base URL).
36
+ params: Optional query string parameters.
37
+ data: Optional JSON request body.
38
+
39
+ Returns:
40
+ A tuple of (response_body, status_code).
41
+ """
42
+ url = f"{BASE_URL}/{endpoint}"
43
+ response = self.session.request(
44
+ method=method,
45
+ url=url,
46
+ params=params,
47
+ json=data,
48
+ )
49
+ try:
50
+ body = response.json()
51
+ except ValueError:
52
+ body = {"raw_response": response.text}
53
+
54
+ if response.status_code >= 400:
55
+ logger.error(
56
+ "REST API error %d on %s %s: %s",
57
+ response.status_code,
58
+ method,
59
+ endpoint,
60
+ body,
61
+ )
62
+
63
+ return body, response.status_code
64
+
65
+ def get(self, endpoint: str, params: dict = None) -> tuple[dict, int]:
66
+ """Send a GET request."""
67
+ return self.make_request("GET", endpoint, params=params)
68
+
69
+ def post(self, endpoint: str, params: dict = None, data: dict = None) -> tuple[dict, int]:
70
+ """Send a POST request."""
71
+ return self.make_request("POST", endpoint, params=params, data=data)
72
+
73
+ def delete(self, endpoint: str, params: dict = None, data: dict = None) -> tuple[dict, int]:
74
+ """Send a DELETE request."""
75
+ return self.make_request("DELETE", endpoint, params=params, data=data)
@@ -0,0 +1,32 @@
1
+ """Shared HTTP session factory for Quickbase API clients."""
2
+
3
+ import requests
4
+ from requests.adapters import HTTPAdapter, Retry
5
+
6
+
7
+ def create_session(realm: str, user_token: str) -> requests.Session:
8
+ """Create a configured requests session with retry logic.
9
+
10
+ Args:
11
+ realm: The Quickbase realm hostname (e.g., 'mycompany.quickbase.com').
12
+ user_token: The Quickbase user token for authentication.
13
+
14
+ Returns:
15
+ A configured requests.Session with retries and auth headers.
16
+ """
17
+ session = requests.Session()
18
+ retries = Retry(
19
+ total=5,
20
+ backoff_factor=1,
21
+ status_forcelist=[429, 502, 503, 504],
22
+ respect_retry_after_header=True,
23
+ )
24
+ session.mount("https://", HTTPAdapter(max_retries=retries))
25
+ session.headers.update(
26
+ {
27
+ "QB-Realm-Hostname": realm,
28
+ "Authorization": f"QB-USER-TOKEN {user_token}",
29
+ "Content-Type": "application/json",
30
+ }
31
+ )
32
+ return session
@@ -0,0 +1,265 @@
1
+ Metadata-Version: 2.4
2
+ Name: quickbase-api
3
+ Version: 0.1.0
4
+ Summary: A simple Python wrapper for the Quickbase API
5
+ Project-URL: Homepage, https://github.com/tbrezler/quickbase-api
6
+ Project-URL: Issues, https://github.com/tbrezler/quickbase-api/issues
7
+ Project-URL: Documentation, https://github.com/tbrezler/quickbase-api#readme
8
+ Author-email: Tyler Brezler <tbrezler@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE.txt
11
+ Keywords: api,database,quickbase,rest,wrapper
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: requests>=2.25.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Requires-Dist: responses>=0.20; extra == 'dev'
28
+ Requires-Dist: ruff; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # quickbase-api
32
+
33
+ A simple Python wrapper for the [Quickbase API](https://developer.quickbase.com/).
34
+
35
+ [![PyPI version](https://badge.fury.io/py/quickbase-api.svg)](https://pypi.org/project/quickbase-api/)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/quickbase-api.svg)](https://pypi.org/project/quickbase-api/)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install quickbase-api
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ```python
48
+ import quickbase_api
49
+
50
+ # Option 1: Pass credentials directly
51
+ client = quickbase_api.client(
52
+ realm="mycompany.quickbase.com",
53
+ user_token="your-user-token",
54
+ )
55
+
56
+ # Option 2: Use environment variables
57
+ # export QUICKBASE_REALM=mycompany.quickbase.com
58
+ # export QUICKBASE_USER_TOKEN=your-user-token
59
+ client = quickbase_api.client()
60
+
61
+ # Query records from a table
62
+ records = client.query_for_data(
63
+ table_id="bqrg4xyza",
64
+ select=[3, 6, 7],
65
+ where="{6.EX.'Active'}",
66
+ )
67
+ ```
68
+
69
+ ## Authentication
70
+
71
+ This library uses [Quickbase user tokens](https://developer.quickbase.com/auth) for
72
+ authentication. You can pass your credentials directly or set environment variables:
73
+
74
+ | Environment Variable | Description |
75
+ |--------------------------|--------------------------------------------------------|
76
+ | `QUICKBASE_REALM` | Your Quickbase realm (e.g., `mycompany.quickbase.com`) |
77
+ | `QUICKBASE_USER_TOKEN` | Your Quickbase user token |
78
+
79
+ > **Security note:** Never commit tokens to source control. Use environment variables
80
+ > or a secrets manager.
81
+
82
+ ## Usage
83
+
84
+ ### Apps
85
+
86
+ ```python
87
+ # Create an app
88
+ app_id = client.create_app(
89
+ name="My App",
90
+ description="An example app",
91
+ assign_token=True,
92
+ )
93
+
94
+ # Get app metadata
95
+ app = client.get_app(app_id)
96
+
97
+ # Delete an app
98
+ client.delete_app("My App", app_id)
99
+ ```
100
+
101
+ ### Tables
102
+
103
+ ```python
104
+ # Create a table
105
+ table_id = client.create_table(app_id, {
106
+ "name": "Customers",
107
+ "description": "Customer records",
108
+ })
109
+
110
+ # List all tables in an app
111
+ tables = client.get_tables(app_id)
112
+
113
+ # Find a table ID by name
114
+ table_id = client.get_table_id(app_id, "Customers")
115
+
116
+ # Delete a table
117
+ client.delete_table(app_id, table_id)
118
+ ```
119
+
120
+ ### Fields
121
+
122
+ ```python
123
+ # Create a field
124
+ client.create_field(table_id, {
125
+ "label": "Full Name",
126
+ "fieldType": "text",
127
+ })
128
+
129
+ # Create multiple fields at once
130
+ results = client.create_fields(table_id, [
131
+ {"label": "Email", "fieldType": "email"},
132
+ {"label": "Age", "fieldType": "numeric"},
133
+ {"label": "Active", "fieldType": "checkbox"},
134
+ ])
135
+ print(f"Created: {len(results['succeeded'])}, Failed: {len(results['failed'])}")
136
+
137
+ # List all fields in a table
138
+ fields = client.get_table_fields(table_id)
139
+
140
+ # Find a field ID by label
141
+ field_id = client.get_field_id(table_id, "Full Name")
142
+
143
+ # Get a mapping of all field labels to IDs
144
+ field_map = client.get_field_label_id_map(table_id)
145
+ # {"Full Name": "6", "Email": "7", "Age": "8", "Active": "9"}
146
+
147
+ # Set a key field (uses legacy API)
148
+ client.set_key_field(table_id, field_id)
149
+
150
+ # Mark a field as required
151
+ client.set_required_field(table_id, field_id)
152
+ ```
153
+
154
+ ### Records
155
+
156
+ ```python
157
+ # Upsert (insert or update) records
158
+ result = client.upsert_records(table_id, [
159
+ {
160
+ "6": {"value": "Jane Smith"},
161
+ "7": {"value": "jane@example.com"},
162
+ "8": {"value": 32},
163
+ },
164
+ {
165
+ "6": {"value": "Bob Jones"},
166
+ "7": {"value": "bob@example.com"},
167
+ "8": {"value": 45},
168
+ },
169
+ ])
170
+ print(f"Created: {result['created']}, Updated: {result['updated']}")
171
+
172
+ # Check for partial failures
173
+ if result["errored"] > 0:
174
+ print(f"Errors: {result['errored_details']}")
175
+
176
+ # Query for records
177
+ records = client.query_for_data(
178
+ table_id=table_id,
179
+ select=[6, 7, 8],
180
+ where="{8.GT.30}",
181
+ sort_by=[{"fieldId": 6, "order": "ASC"}],
182
+ options={"skip": 0, "top": 100},
183
+ )
184
+
185
+ # Delete records
186
+ deleted = client.delete_records(table_id, where="{8.LT.18}")
187
+ print(f"Deleted {deleted} records")
188
+ ```
189
+
190
+ ### Reports
191
+
192
+ ```python
193
+ # List all reports for a table
194
+ reports = client.get_reports(table_id)
195
+
196
+ # Get a specific report
197
+ report = client.get_report(table_id, report_id="1")
198
+
199
+ # Run a report
200
+ results = client.run_report(table_id, report_id="1", skip=0, top=100)
201
+ ```
202
+
203
+ ## Error Handling
204
+
205
+ The library raises specific exceptions that you can catch and handle:
206
+
207
+ ```python
208
+ from quickbase_api import QuickbaseAPIError, QuickbaseNotFoundError
209
+
210
+ # Handle API errors
211
+ try:
212
+ app = client.get_app("invalid-id")
213
+ except QuickbaseAPIError as e:
214
+ print(f"API error {e.status_code}: {e.response_body}")
215
+
216
+ # Handle not-found lookups
217
+ try:
218
+ table_id = client.get_table_id(app_id, "Nonexistent Table")
219
+ except QuickbaseNotFoundError as e:
220
+ print(f"Not found: {e}")
221
+ ```
222
+
223
+ Exception hierarchy:
224
+
225
+ ```
226
+ QuickbaseError # Base exception
227
+ ├── QuickbaseAPIError # HTTP errors from the API
228
+ └── QuickbaseNotFoundError # Resource not found in lookup methods
229
+ ```
230
+
231
+ ## Logging
232
+
233
+ This library uses Python's built-in `logging` module. To see log output,
234
+ configure logging in your application:
235
+
236
+ ```python
237
+ import logging
238
+
239
+ # See all quickbase-api log messages
240
+ logging.basicConfig(level=logging.INFO)
241
+
242
+ # Or configure just the quickbase_api logger
243
+ logging.getLogger("quickbase_api").setLevel(logging.DEBUG)
244
+ ```
245
+
246
+ ## Configuration
247
+
248
+ ### Retry Behavior
249
+
250
+ The library automatically retries failed requests up to 5 times with
251
+ exponential backoff for the following HTTP status codes:
252
+
253
+ - `429` — Rate limited
254
+ - `502` — Bad gateway
255
+ - `503` — Service unavailable
256
+ - `504` — Gateway timeout
257
+
258
+ ## Requirements
259
+
260
+ - Python 3.9+
261
+ - [requests](https://requests.readthedocs.io/) >= 2.25.0
262
+
263
+ ## License
264
+
265
+ This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,10 @@
1
+ quickbase_api/__init__.py,sha256=zkWcjGbkVh3_MJbfTY7_hCGsA9bTObXF2ktFx0XTcY0,1790
2
+ quickbase_api/api.py,sha256=t-1DFUpufgsGLjm62TFodLHd6_Ch8yS9Bpotq2pN2pE,18922
3
+ quickbase_api/exceptions.py,sha256=DZcoNLoRVDadwZW_wZuqRn8lOfvGV3pIBqS3Z8bTQj8,667
4
+ quickbase_api/legacy_method.py,sha256=4_tAD1gToTzBnbhHRc8WwgLCW8teVzXOzHekEaCdsNk,2003
5
+ quickbase_api/rest_method.py,sha256=H4WhGG6KVxDdGFhipWvOa1sJ-E0i47cxJnX9NWf1rXg,2270
6
+ quickbase_api/session.py,sha256=R2kaUebgYhgZDXQx_xF-vS2VNHkejE6xkK-EtNhZJh0,982
7
+ quickbase_api-0.1.0.dist-info/METADATA,sha256=u48PTsFwYTJnwDBB8HdJRcIrAjrK-xgGiB8TKxl2hGI,7019
8
+ quickbase_api-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ quickbase_api-0.1.0.dist-info/licenses/LICENSE.txt,sha256=gMsqXSha8Ek0_Q99VdMgsO-Vr1mmWSqSIHmM9WxJodA,1070
10
+ quickbase_api-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tyler Brezler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.