quickbase-extract 0.4.4__tar.gz → 0.5.0__tar.gz
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.
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/CHANGELOG.md +27 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/PKG-INFO +1 -1
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/pyproject.toml +4 -1
- quickbase_extract-0.5.0/src/quickbase_extract/api_handlers.py +165 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/src/quickbase_extract/report_data.py +4 -3
- quickbase_extract-0.5.0/tests/test_api_handlers.py +137 -0
- quickbase_extract-0.4.4/src/quickbase_extract/api_handlers.py +0 -210
- quickbase_extract-0.4.4/tests/test_api_handlers.py +0 -244
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/.editorconfig +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/.gitignore +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/.pre-commit-config.yaml +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/.python-version +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/LICENSE.txt +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/README.md +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/TODO.md +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/src/quickbase_extract/__init__.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/src/quickbase_extract/cache_manager.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/src/quickbase_extract/cache_orchestration.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/src/quickbase_extract/cache_sync.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/src/quickbase_extract/config.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/src/quickbase_extract/py.typed +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/src/quickbase_extract/report_metadata.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/src/quickbase_extract/utils.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/tests/conftest.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/tests/test_cache_manager.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/tests/test_cache_orchestration.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/tests/test_cache_sync.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/tests/test_report_data.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/tests/test_report_metadata.py +0 -0
- {quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/tests/test_utils.py +0 -0
|
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.0] - 2026-06-07
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- `handle_upsert`, `handle_delete`, and `handle_query` in `api_handlers.py` now delegate
|
|
13
|
+
rate limit retry logic to the session layer (`quickbase-api`), which correctly respects
|
|
14
|
+
the `retry-after` response header on all HTTP methods including POST and DELETE
|
|
15
|
+
- Return type of `handle_upsert` corrected from `dict | None` to `dict`
|
|
16
|
+
- Return type of `handle_delete` corrected from `int | None` to `int`
|
|
17
|
+
- Return type of `handle_query` corrected from `dict | None` to `dict`
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- `get_data_parallel` now correctly cancels pending futures on failure, matching the
|
|
22
|
+
behaviour of `get_report_metadata_parallel` and its own documented fail-fast behaviour
|
|
23
|
+
|
|
24
|
+
### Removed
|
|
25
|
+
|
|
26
|
+
- `max_retries` parameter removed from `handle_upsert`, `handle_delete`, and `handle_query`
|
|
27
|
+
— retry behaviour is now configured at the session level in `quickbase-api`
|
|
28
|
+
|
|
29
|
+
### Breaking Changes
|
|
30
|
+
|
|
31
|
+
- `max_retries` parameter has been removed from `handle_upsert`, `handle_delete`, and
|
|
32
|
+
`handle_query`. Any callers passing this argument will raise a `TypeError` and must be
|
|
33
|
+
updated.
|
|
34
|
+
|
|
8
35
|
## [0.4.4] - 2026-05-13
|
|
9
36
|
|
|
10
37
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quickbase-extract
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Extract and cache Quickbase report data with built-in error handling and S3 support
|
|
5
5
|
Project-URL: Homepage, https://github.com/tbrezler/quickbase-extract
|
|
6
6
|
Project-URL: Repository, https://github.com/tbrezler/quickbase-extract.git
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "quickbase-extract"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Extract and cache Quickbase report data with built-in error handling and S3 support"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -50,3 +50,6 @@ target-version = "py312"
|
|
|
50
50
|
[tool.ruff.lint]
|
|
51
51
|
select = ["F", "E", "W", "B", "BLE", "I", "UP", "A", "RUF", "T10"]
|
|
52
52
|
ignore = ["E501"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Error handling utilities for Quickbase operations.
|
|
2
|
+
|
|
3
|
+
Provides standardized error handling and logging for Quickbase API operations.
|
|
4
|
+
Rate limit retry logic with respect for the retry-after header is handled at
|
|
5
|
+
the session level in quickbase-api's session.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QuickbaseOperationError(Exception):
|
|
14
|
+
"""Raised when a Quickbase API operation fails."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, operation: str, details: str = ""):
|
|
17
|
+
self.operation = operation
|
|
18
|
+
self.details = details
|
|
19
|
+
super().__init__(f"Quickbase {operation} failed: {details}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def handle_upsert(
|
|
23
|
+
client,
|
|
24
|
+
table_id: str,
|
|
25
|
+
data: list[dict],
|
|
26
|
+
description: str = "",
|
|
27
|
+
) -> dict:
|
|
28
|
+
"""Execute a Quickbase upsert with error handling and logging.
|
|
29
|
+
|
|
30
|
+
Rate limit retries with respect for the retry-after header are handled
|
|
31
|
+
at the session level.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
client: Quickbase API client.
|
|
35
|
+
table_id: Target table ID.
|
|
36
|
+
data: List of record dicts to upsert.
|
|
37
|
+
description: Human-readable description for logging. Defaults to empty string.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
API response dict containing metadata about created/updated/unchanged records.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
QuickbaseOperationError: If the upsert fails.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
>>> records = [{"6": {"value": "John"}, "7": {"value": "Doe"}}]
|
|
47
|
+
>>> result = handle_upsert(client, "bq8xyx9z", records, "customer records")
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
result = client.upsert_records(table_id, data=data)
|
|
51
|
+
|
|
52
|
+
created = result.get("metadata", {}).get("createdRecordIds", [])
|
|
53
|
+
updated = result.get("metadata", {}).get("updatedRecordIds", [])
|
|
54
|
+
unchanged = result.get("metadata", {}).get("unchangedRecordIds", [])
|
|
55
|
+
|
|
56
|
+
logger.info(f"Upsert {description}: {len(created)} created, {len(updated)} updated, {len(unchanged)} unchanged")
|
|
57
|
+
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
except Exception as e:
|
|
61
|
+
error_str = str(e)
|
|
62
|
+
logger.error(f"Upsert {description} failed: {error_str}")
|
|
63
|
+
raise QuickbaseOperationError("upsert", error_str) from e
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def handle_delete(
|
|
67
|
+
client,
|
|
68
|
+
table_id: str,
|
|
69
|
+
where: str,
|
|
70
|
+
description: str = "",
|
|
71
|
+
) -> int:
|
|
72
|
+
"""Execute a Quickbase delete with error handling and logging.
|
|
73
|
+
|
|
74
|
+
Rate limit retries with respect for the retry-after header are handled
|
|
75
|
+
at the session level.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
client: Quickbase API client.
|
|
79
|
+
table_id: Target table ID.
|
|
80
|
+
where: Quickbase filter string specifying records to delete.
|
|
81
|
+
description: Human-readable description for logging. Defaults to empty string.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Number of records deleted.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
QuickbaseOperationError: If the delete fails.
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
>>> deleted = handle_delete(client, "bq8xyx9z", "{3.EX.'test'}", "test records")
|
|
91
|
+
|
|
92
|
+
Note:
|
|
93
|
+
DELETE is safe to retry on any error — deleting already-deleted records
|
|
94
|
+
is a no-op in Quickbase.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
deleted = client.delete_records(table_id, where=where)
|
|
98
|
+
logger.info(f"Delete {description}: {deleted} records deleted")
|
|
99
|
+
return deleted
|
|
100
|
+
|
|
101
|
+
except Exception as e:
|
|
102
|
+
error_str = str(e)
|
|
103
|
+
logger.error(f"Delete {description} failed: {error_str}")
|
|
104
|
+
raise QuickbaseOperationError("delete", error_str) from e
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def handle_query(
|
|
108
|
+
client,
|
|
109
|
+
table_id: str,
|
|
110
|
+
*,
|
|
111
|
+
select: list[int] | None = None,
|
|
112
|
+
where: str | None = None,
|
|
113
|
+
sort_by: list[dict] | None = None,
|
|
114
|
+
group_by: list[dict] | None = None,
|
|
115
|
+
options: dict | None = None,
|
|
116
|
+
description: str = "",
|
|
117
|
+
) -> dict:
|
|
118
|
+
"""Execute a Quickbase query with error handling and logging.
|
|
119
|
+
|
|
120
|
+
Rate limit retries with respect for the retry-after header are handled
|
|
121
|
+
at the session level.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
client: Quickbase API client.
|
|
125
|
+
table_id: Target table ID.
|
|
126
|
+
select: List of field IDs to return. If omitted, returns fields from
|
|
127
|
+
the default report.
|
|
128
|
+
where: A Quickbase query string (e.g., "{12.EX.'VPF'}").
|
|
129
|
+
sort_by: Sort order, e.g., [{"fieldId": 6, "order": "ASC"}].
|
|
130
|
+
group_by: Grouping, e.g., [{"fieldId": 6, "grouping": "equal-values"}].
|
|
131
|
+
options: Additional options, e.g.,
|
|
132
|
+
{"skip": 0, "top": 100, "compareWithAppLocalTime": False}.
|
|
133
|
+
description: Human-readable description for logging. Defaults to empty string.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
API response dict containing query results.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
QuickbaseOperationError: If the query fails.
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
>>> result = handle_query(
|
|
143
|
+
... client,
|
|
144
|
+
... "bq8xyx9z",
|
|
145
|
+
... select=[6, 7, 8],
|
|
146
|
+
... where="{12.EX.'Active'}",
|
|
147
|
+
... description="active customers"
|
|
148
|
+
... )
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
result = client.query_for_data(
|
|
152
|
+
table_id,
|
|
153
|
+
select=select,
|
|
154
|
+
where=where,
|
|
155
|
+
sort_by=sort_by,
|
|
156
|
+
group_by=group_by,
|
|
157
|
+
options=options,
|
|
158
|
+
)
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
except Exception as e:
|
|
162
|
+
error_str = str(e)
|
|
163
|
+
desc_str = f" {description}" if description else f" on table {table_id}"
|
|
164
|
+
logger.error(f"Query{desc_str} failed: {error_str}")
|
|
165
|
+
raise QuickbaseOperationError("query", error_str) from e
|
|
@@ -308,9 +308,9 @@ def get_data_parallel(
|
|
|
308
308
|
"""Fetch multiple reports in parallel using cached report metadata.
|
|
309
309
|
|
|
310
310
|
Executes data fetching for multiple reports concurrently to improve
|
|
311
|
-
performance. Uses a fail-fast approach: if any report fetch fails,
|
|
312
|
-
|
|
313
|
-
|
|
311
|
+
performance. Uses a fail-fast approach: if any report fetch fails, all pending tasks are
|
|
312
|
+
cancelled and the exception is raised. Already running tasks will complete
|
|
313
|
+
before the exception propagates.
|
|
314
314
|
|
|
315
315
|
Args:
|
|
316
316
|
client: Quickbase API client. Should be thread-safe for concurrent use.
|
|
@@ -387,6 +387,7 @@ def get_data_parallel(
|
|
|
387
387
|
data = future.result() # Individual fetches are logged in get_data
|
|
388
388
|
results[config] = data
|
|
389
389
|
except Exception as e:
|
|
390
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
390
391
|
logger.error(f"Failed to fetch {config.app_id}/{config.table_name}/{config.report_name}: {e}")
|
|
391
392
|
raise
|
|
392
393
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Unit tests for api_handlers module."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from quickbase_extract.api_handlers import (
|
|
6
|
+
QuickbaseOperationError,
|
|
7
|
+
handle_delete,
|
|
8
|
+
handle_query,
|
|
9
|
+
handle_upsert,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestQuickbaseOperationError:
|
|
14
|
+
"""Tests for QuickbaseOperationError exception."""
|
|
15
|
+
|
|
16
|
+
def test_error_message(self):
|
|
17
|
+
"""Test error message format."""
|
|
18
|
+
error = QuickbaseOperationError("upsert", "Rate limited")
|
|
19
|
+
assert "Quickbase upsert failed" in str(error)
|
|
20
|
+
assert "Rate limited" in str(error)
|
|
21
|
+
|
|
22
|
+
def test_error_attributes(self):
|
|
23
|
+
"""Test error attributes."""
|
|
24
|
+
error = QuickbaseOperationError("delete", "Record not found")
|
|
25
|
+
assert error.operation == "delete"
|
|
26
|
+
assert error.details == "Record not found"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestHandleUpsert:
|
|
30
|
+
"""Tests for handle_upsert function."""
|
|
31
|
+
|
|
32
|
+
def test_upsert_success(self, mock_qb_api):
|
|
33
|
+
"""Test successful upsert."""
|
|
34
|
+
data = [{"field1": "value1"}]
|
|
35
|
+
|
|
36
|
+
result = handle_upsert(mock_qb_api, "tblXYZ", data, description="Test upsert")
|
|
37
|
+
|
|
38
|
+
assert "metadata" in result
|
|
39
|
+
mock_qb_api.upsert_records.assert_called_once_with("tblXYZ", data=data)
|
|
40
|
+
|
|
41
|
+
def test_upsert_logs_result(self, mock_qb_api, caplog):
|
|
42
|
+
"""Test that upsert logs result counts."""
|
|
43
|
+
data = [{"field1": "value1"}]
|
|
44
|
+
|
|
45
|
+
handle_upsert(mock_qb_api, "tblXYZ", data, description="Test upsert")
|
|
46
|
+
|
|
47
|
+
assert "1 created" in caplog.text
|
|
48
|
+
assert "1 updated" in caplog.text
|
|
49
|
+
|
|
50
|
+
def test_upsert_failure(self, mock_qb_api):
|
|
51
|
+
"""Test that upsert failure raises QuickbaseOperationError."""
|
|
52
|
+
mock_qb_api.upsert_records.side_effect = Exception("Invalid field")
|
|
53
|
+
|
|
54
|
+
with pytest.raises(QuickbaseOperationError, match="upsert"):
|
|
55
|
+
handle_upsert(mock_qb_api, "tblXYZ", [], description="Test")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestHandleDelete:
|
|
59
|
+
"""Tests for handle_delete function."""
|
|
60
|
+
|
|
61
|
+
def test_delete_success(self, mock_qb_api):
|
|
62
|
+
"""Test successful delete."""
|
|
63
|
+
result = handle_delete(mock_qb_api, "tblXYZ", where="{8.EX.'Inactive'}", description="Test delete")
|
|
64
|
+
|
|
65
|
+
assert result == 5
|
|
66
|
+
mock_qb_api.delete_records.assert_called_once_with("tblXYZ", where="{8.EX.'Inactive'}")
|
|
67
|
+
|
|
68
|
+
def test_delete_logs_result(self, mock_qb_api, caplog):
|
|
69
|
+
"""Test that delete logs result."""
|
|
70
|
+
handle_delete(mock_qb_api, "tblXYZ", where="{8.EX.'Inactive'}")
|
|
71
|
+
|
|
72
|
+
assert "5 records deleted" in caplog.text
|
|
73
|
+
|
|
74
|
+
def test_delete_failure(self, mock_qb_api):
|
|
75
|
+
"""Test that delete failure raises QuickbaseOperationError."""
|
|
76
|
+
mock_qb_api.delete_records.side_effect = Exception("Invalid where clause")
|
|
77
|
+
|
|
78
|
+
with pytest.raises(QuickbaseOperationError, match="delete"):
|
|
79
|
+
handle_delete(mock_qb_api, "tblXYZ", where="invalid")
|
|
80
|
+
|
|
81
|
+
def test_delete_failure_single_attempt(self, mock_qb_api):
|
|
82
|
+
"""Test that delete fails immediately without retry."""
|
|
83
|
+
mock_qb_api.delete_records.side_effect = Exception("Permission denied")
|
|
84
|
+
|
|
85
|
+
with pytest.raises(QuickbaseOperationError):
|
|
86
|
+
handle_delete(mock_qb_api, "tblXYZ", where="{8.EX.'Test'}")
|
|
87
|
+
|
|
88
|
+
assert mock_qb_api.delete_records.call_count == 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestHandleQuery:
|
|
92
|
+
"""Tests for handle_query function."""
|
|
93
|
+
|
|
94
|
+
def test_query_success(self, mock_qb_api):
|
|
95
|
+
"""Test successful query."""
|
|
96
|
+
result = handle_query(
|
|
97
|
+
mock_qb_api,
|
|
98
|
+
"tblXYZ",
|
|
99
|
+
select=[3, 6, 7],
|
|
100
|
+
where="{8.EX.'Active'}",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert "data" in result
|
|
104
|
+
assert len(result["data"]) == 2
|
|
105
|
+
|
|
106
|
+
def test_query_with_all_parameters(self, mock_qb_api):
|
|
107
|
+
"""Test query with all optional parameters including description."""
|
|
108
|
+
result = handle_query(
|
|
109
|
+
mock_qb_api,
|
|
110
|
+
"tblXYZ",
|
|
111
|
+
select=[3, 6],
|
|
112
|
+
where="{8.EX.'Active'}",
|
|
113
|
+
sort_by=[{"fieldId": 6, "order": "ASC"}],
|
|
114
|
+
group_by=[{"fieldId": 8}],
|
|
115
|
+
options={"skip": 0, "top": 100},
|
|
116
|
+
description="active users",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
assert result is not None
|
|
120
|
+
mock_qb_api.query_for_data.assert_called_once()
|
|
121
|
+
|
|
122
|
+
def test_query_failure(self, mock_qb_api):
|
|
123
|
+
"""Test that query failure raises QuickbaseOperationError."""
|
|
124
|
+
mock_qb_api.query_for_data.side_effect = Exception("Invalid field ID")
|
|
125
|
+
|
|
126
|
+
with pytest.raises(QuickbaseOperationError, match="query"):
|
|
127
|
+
handle_query(mock_qb_api, "tblXYZ", select=[999])
|
|
128
|
+
|
|
129
|
+
def test_query_description_in_logs(self, mock_qb_api, caplog):
|
|
130
|
+
"""Test that description appears in error log message on failure."""
|
|
131
|
+
mock_qb_api.query_for_data.side_effect = Exception("Invalid field ID")
|
|
132
|
+
|
|
133
|
+
with pytest.raises(QuickbaseOperationError):
|
|
134
|
+
handle_query(mock_qb_api, "tblXYZ", description="customer records")
|
|
135
|
+
|
|
136
|
+
assert "customer records" in caplog.text
|
|
137
|
+
assert any(record.levelname == "ERROR" for record in caplog.records)
|
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
"""Error handling utilities for Quickbase operations.
|
|
2
|
-
|
|
3
|
-
Provides retry logic for rate-limited requests, standardized error handling,
|
|
4
|
-
and logging for Quickbase API operations.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import logging
|
|
8
|
-
import random
|
|
9
|
-
import time
|
|
10
|
-
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class QuickbaseOperationError(Exception):
|
|
15
|
-
"""Raised when a Quickbase API operation fails."""
|
|
16
|
-
|
|
17
|
-
def __init__(self, operation: str, details: str = ""):
|
|
18
|
-
self.operation = operation
|
|
19
|
-
self.details = details
|
|
20
|
-
super().__init__(f"Quickbase {operation} failed: {details}")
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def handle_upsert(
|
|
24
|
-
client,
|
|
25
|
-
table_id: str,
|
|
26
|
-
data: list[dict],
|
|
27
|
-
description: str = "",
|
|
28
|
-
max_retries: int = 3,
|
|
29
|
-
) -> dict | None:
|
|
30
|
-
"""Execute a Quickbase upsert with error handling, retry logic, and logging.
|
|
31
|
-
|
|
32
|
-
Retries on rate limiting (429 errors) with exponential backoff and jitter.
|
|
33
|
-
Wait time is capped at 60 seconds per retry.
|
|
34
|
-
|
|
35
|
-
Args:
|
|
36
|
-
client: Quickbase API client.
|
|
37
|
-
table_id: Target table ID.
|
|
38
|
-
data: List of record dicts to upsert.
|
|
39
|
-
description: Human-readable description for logging. Defaults to empty string.
|
|
40
|
-
max_retries: Maximum number of retry attempts. Defaults to 3.
|
|
41
|
-
|
|
42
|
-
Returns:
|
|
43
|
-
API response dict containing metadata about created/updated/unchanged records.
|
|
44
|
-
|
|
45
|
-
Raises:
|
|
46
|
-
QuickbaseOperationError: If the upsert fails after all retries.
|
|
47
|
-
|
|
48
|
-
Example:
|
|
49
|
-
>>> records = [{"6": {"value": "John"}, "7": {"value": "Doe"}}]
|
|
50
|
-
>>> result = handle_upsert(client, "bq8xyx9z", records, "customer records")
|
|
51
|
-
"""
|
|
52
|
-
for attempt in range(max_retries):
|
|
53
|
-
try:
|
|
54
|
-
result = client.upsert_records(table_id, data=data)
|
|
55
|
-
|
|
56
|
-
created = result.get("metadata", {}).get("createdRecordIds", [])
|
|
57
|
-
updated = result.get("metadata", {}).get("updatedRecordIds", [])
|
|
58
|
-
unchanged = result.get("metadata", {}).get("unchangedRecordIds", [])
|
|
59
|
-
|
|
60
|
-
logger.info(
|
|
61
|
-
f"Upsert {description}: {len(created)} created, {len(updated)} updated, {len(unchanged)} unchanged"
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
return result
|
|
65
|
-
|
|
66
|
-
except Exception as e: # Need to catch all exceptions for retry logic
|
|
67
|
-
error_str = str(e)
|
|
68
|
-
|
|
69
|
-
# Retry on 429 (rate limit)
|
|
70
|
-
if "429" in error_str and attempt < max_retries - 1:
|
|
71
|
-
wait_time = min(2**attempt, 60) + random.uniform(0, 1)
|
|
72
|
-
logger.warning(
|
|
73
|
-
f"Rate limited on upsert {description} (attempt {attempt + 1}/{max_retries}), "
|
|
74
|
-
f"retrying in {wait_time:.1f}s"
|
|
75
|
-
)
|
|
76
|
-
time.sleep(wait_time)
|
|
77
|
-
else:
|
|
78
|
-
logger.error(f"Upsert {description} failed: {error_str}")
|
|
79
|
-
raise QuickbaseOperationError("upsert", error_str) from e
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def handle_delete(
|
|
83
|
-
client,
|
|
84
|
-
table_id: str,
|
|
85
|
-
where: str,
|
|
86
|
-
description: str = "",
|
|
87
|
-
max_retries: int = 3,
|
|
88
|
-
) -> int | None:
|
|
89
|
-
"""Execute a Quickbase delete with error handling, logging, and rate limit retry.
|
|
90
|
-
|
|
91
|
-
Only retries on rate limiting (429 errors) with exponential backoff and jitter.
|
|
92
|
-
Other errors fail immediately for safety. Wait time is capped at 60 seconds per retry.
|
|
93
|
-
|
|
94
|
-
Args:
|
|
95
|
-
client: Quickbase API client.
|
|
96
|
-
table_id: Target table ID.
|
|
97
|
-
where: Quickbase filter string specifying records to delete.
|
|
98
|
-
description: Human-readable description for logging. Defaults to empty string.
|
|
99
|
-
max_retries: Maximum number of retry attempts for rate limits. Defaults to 3.
|
|
100
|
-
|
|
101
|
-
Returns:
|
|
102
|
-
Number of records deleted.
|
|
103
|
-
|
|
104
|
-
Raises:
|
|
105
|
-
QuickbaseOperationError: If the delete fails.
|
|
106
|
-
|
|
107
|
-
Example:
|
|
108
|
-
>>> deleted = handle_delete(client, "bq8xyx9z", "{3.EX.'test'}", "test records")
|
|
109
|
-
|
|
110
|
-
Note:
|
|
111
|
-
For safety, only 429 (rate limit) errors are retried. All other errors
|
|
112
|
-
fail immediately to prevent unintended deletions.
|
|
113
|
-
"""
|
|
114
|
-
for attempt in range(max_retries):
|
|
115
|
-
try:
|
|
116
|
-
deleted = client.delete_records(table_id, where=where)
|
|
117
|
-
logger.info(f"Delete {description}: {deleted} records deleted")
|
|
118
|
-
return deleted
|
|
119
|
-
|
|
120
|
-
except Exception as e: # Need to catch all exceptions for retry logic
|
|
121
|
-
error_str = str(e)
|
|
122
|
-
|
|
123
|
-
# Only retry on 429 (rate limit) - other errors are too risky to retry
|
|
124
|
-
if "429" in error_str and attempt < max_retries - 1:
|
|
125
|
-
wait_time = min(2**attempt, 60) + random.uniform(0, 1)
|
|
126
|
-
logger.warning(
|
|
127
|
-
f"Rate limited on delete {description} (attempt {attempt + 1}/{max_retries}), "
|
|
128
|
-
f"retrying in {wait_time:.1f}s"
|
|
129
|
-
)
|
|
130
|
-
time.sleep(wait_time)
|
|
131
|
-
else:
|
|
132
|
-
logger.error(f"Delete {description} failed: {error_str}")
|
|
133
|
-
raise QuickbaseOperationError("delete", error_str) from e
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def handle_query(
|
|
137
|
-
client,
|
|
138
|
-
table_id: str,
|
|
139
|
-
*,
|
|
140
|
-
select: list[int] | None = None,
|
|
141
|
-
where: str | None = None,
|
|
142
|
-
sort_by: list[dict] | None = None,
|
|
143
|
-
group_by: list[dict] | None = None,
|
|
144
|
-
options: dict | None = None,
|
|
145
|
-
description: str = "",
|
|
146
|
-
max_retries: int = 3,
|
|
147
|
-
) -> dict | None:
|
|
148
|
-
"""Execute a Quickbase query with error handling, retry logic, and logging.
|
|
149
|
-
|
|
150
|
-
Retries on rate limiting (429 errors) with exponential backoff and jitter.
|
|
151
|
-
Wait time is capped at 60 seconds per retry.
|
|
152
|
-
|
|
153
|
-
Args:
|
|
154
|
-
client: Quickbase API client.
|
|
155
|
-
table_id: Target table ID.
|
|
156
|
-
select: List of field IDs to return. If omitted, returns fields from
|
|
157
|
-
the default report.
|
|
158
|
-
where: A Quickbase query string (e.g., "{12.EX.'VPF'}").
|
|
159
|
-
sort_by: Sort order, e.g., [{"fieldId": 6, "order": "ASC"}].
|
|
160
|
-
group_by: Grouping, e.g., [{"fieldId": 6, "grouping": "equal-values"}].
|
|
161
|
-
options: Additional options, e.g.,
|
|
162
|
-
{"skip": 0, "top": 100, "compareWithAppLocalTime": False}.
|
|
163
|
-
description: Human-readable description for logging. Defaults to empty string.
|
|
164
|
-
max_retries: Maximum number of retry attempts. Defaults to 3.
|
|
165
|
-
|
|
166
|
-
Returns:
|
|
167
|
-
API response dict containing query results.
|
|
168
|
-
|
|
169
|
-
Raises:
|
|
170
|
-
QuickbaseOperationError: If the query fails after all retries.
|
|
171
|
-
|
|
172
|
-
Example:
|
|
173
|
-
>>> result = handle_query(
|
|
174
|
-
... client,
|
|
175
|
-
... "bq8xyx9z",
|
|
176
|
-
... select=[6, 7, 8],
|
|
177
|
-
... where="{12.EX.'Active'}",
|
|
178
|
-
... description="active customers"
|
|
179
|
-
... )
|
|
180
|
-
"""
|
|
181
|
-
for attempt in range(max_retries):
|
|
182
|
-
try:
|
|
183
|
-
result = client.query_for_data(
|
|
184
|
-
table_id,
|
|
185
|
-
select=select,
|
|
186
|
-
where=where,
|
|
187
|
-
sort_by=sort_by,
|
|
188
|
-
group_by=group_by,
|
|
189
|
-
options=options,
|
|
190
|
-
)
|
|
191
|
-
record_count = len(result.get("data", []))
|
|
192
|
-
desc_str = f" {description}" if description else ""
|
|
193
|
-
logger.info(f"Query{desc_str} returned {record_count} records")
|
|
194
|
-
return result
|
|
195
|
-
|
|
196
|
-
except Exception as e: # Need to catch all exceptions for retry logic
|
|
197
|
-
error_str = str(e)
|
|
198
|
-
|
|
199
|
-
if "429" in error_str and attempt < max_retries - 1:
|
|
200
|
-
wait_time = min(2**attempt, 60) + random.uniform(0, 1)
|
|
201
|
-
desc_str = f" {description}" if description else f" table {table_id}"
|
|
202
|
-
logger.warning(
|
|
203
|
-
f"Rate limited on query{desc_str} (attempt {attempt + 1}/{max_retries}), "
|
|
204
|
-
f"retrying in {wait_time:.1f}s"
|
|
205
|
-
)
|
|
206
|
-
time.sleep(wait_time)
|
|
207
|
-
else:
|
|
208
|
-
desc_str = f" {description}" if description else f" on table {table_id}"
|
|
209
|
-
logger.error(f"Query{desc_str} failed: {error_str}")
|
|
210
|
-
raise QuickbaseOperationError("query", error_str) from e
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
"""Unit tests for api_handlers module."""
|
|
2
|
-
|
|
3
|
-
import time
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from quickbase_extract.api_handlers import (
|
|
8
|
-
QuickbaseOperationError,
|
|
9
|
-
handle_delete,
|
|
10
|
-
handle_query,
|
|
11
|
-
handle_upsert,
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class TestQuickbaseOperationError:
|
|
16
|
-
"""Tests for QuickbaseOperationError exception."""
|
|
17
|
-
|
|
18
|
-
def test_error_message(self):
|
|
19
|
-
"""Test error message format."""
|
|
20
|
-
error = QuickbaseOperationError("upsert", "Rate limited")
|
|
21
|
-
assert "Quickbase upsert failed" in str(error)
|
|
22
|
-
assert "Rate limited" in str(error)
|
|
23
|
-
|
|
24
|
-
def test_error_attributes(self):
|
|
25
|
-
"""Test error attributes."""
|
|
26
|
-
error = QuickbaseOperationError("delete", "Record not found")
|
|
27
|
-
assert error.operation == "delete"
|
|
28
|
-
assert error.details == "Record not found"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class TestHandleUpsert:
|
|
32
|
-
"""Tests for handle_upsert function."""
|
|
33
|
-
|
|
34
|
-
def test_upsert_success(self, mock_qb_api):
|
|
35
|
-
"""Test successful upsert."""
|
|
36
|
-
data = [{"field1": "value1"}]
|
|
37
|
-
|
|
38
|
-
result = handle_upsert(mock_qb_api, "tblXYZ", data, description="Test upsert")
|
|
39
|
-
|
|
40
|
-
assert "metadata" in result
|
|
41
|
-
mock_qb_api.upsert_records.assert_called_once_with("tblXYZ", data=data)
|
|
42
|
-
|
|
43
|
-
def test_upsert_logs_result(self, mock_qb_api, caplog):
|
|
44
|
-
"""Test that upsert logs result counts."""
|
|
45
|
-
data = [{"field1": "value1"}]
|
|
46
|
-
|
|
47
|
-
handle_upsert(mock_qb_api, "tblXYZ", data, description="Test upsert")
|
|
48
|
-
|
|
49
|
-
assert "1 created" in caplog.text
|
|
50
|
-
assert "1 updated" in caplog.text
|
|
51
|
-
|
|
52
|
-
def test_upsert_failure_non_retriable(self, mock_qb_api):
|
|
53
|
-
"""Test upsert failure with non-retriable error."""
|
|
54
|
-
mock_qb_api.upsert_records.side_effect = Exception("Invalid field")
|
|
55
|
-
|
|
56
|
-
with pytest.raises(QuickbaseOperationError, match="upsert"):
|
|
57
|
-
handle_upsert(mock_qb_api, "tblXYZ", [], description="Test")
|
|
58
|
-
|
|
59
|
-
def test_upsert_retry_on_rate_limit(self, mock_qb_api):
|
|
60
|
-
"""Test upsert retry on 429 rate limit."""
|
|
61
|
-
# Fail twice, then succeed
|
|
62
|
-
mock_qb_api.upsert_records.side_effect = [
|
|
63
|
-
Exception("429 Rate Limit Exceeded"),
|
|
64
|
-
Exception("429 Rate Limit Exceeded"),
|
|
65
|
-
{
|
|
66
|
-
"metadata": {
|
|
67
|
-
"createdRecordIds": [],
|
|
68
|
-
"updatedRecordIds": [],
|
|
69
|
-
"unchangedRecordIds": [],
|
|
70
|
-
}
|
|
71
|
-
},
|
|
72
|
-
]
|
|
73
|
-
|
|
74
|
-
with pytest.raises(QuickbaseOperationError):
|
|
75
|
-
# Will still fail after max retries, but should have tried multiple times
|
|
76
|
-
handle_upsert(mock_qb_api, "tblXYZ", [], max_retries=2)
|
|
77
|
-
|
|
78
|
-
assert mock_qb_api.upsert_records.call_count >= 2
|
|
79
|
-
|
|
80
|
-
def test_upsert_max_retries_customizable(self, mock_qb_api):
|
|
81
|
-
"""Test that max_retries parameter is respected."""
|
|
82
|
-
mock_qb_api.upsert_records.side_effect = Exception("429 Rate Limit")
|
|
83
|
-
|
|
84
|
-
with pytest.raises(QuickbaseOperationError):
|
|
85
|
-
handle_upsert(mock_qb_api, "tblXYZ", [], max_retries=2)
|
|
86
|
-
|
|
87
|
-
assert mock_qb_api.upsert_records.call_count == 2
|
|
88
|
-
|
|
89
|
-
def test_upsert_wait_time_cap(self, mock_qb_api):
|
|
90
|
-
"""Test that wait time is capped at 60 seconds."""
|
|
91
|
-
mock_qb_api.upsert_records.side_effect = [
|
|
92
|
-
Exception("429 Rate Limit"),
|
|
93
|
-
{
|
|
94
|
-
"metadata": {
|
|
95
|
-
"createdRecordIds": [],
|
|
96
|
-
"updatedRecordIds": [],
|
|
97
|
-
"unchangedRecordIds": [],
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
]
|
|
101
|
-
|
|
102
|
-
start = time.time()
|
|
103
|
-
handle_upsert(mock_qb_api, "tblXYZ", [], max_retries=10) # Would be 2^9 = 512s without cap
|
|
104
|
-
elapsed = time.time() - start
|
|
105
|
-
|
|
106
|
-
# Should be capped at ~60 seconds, not 512
|
|
107
|
-
assert elapsed < 65 # Allow some margin
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
class TestHandleDelete:
|
|
111
|
-
"""Tests for handle_delete function."""
|
|
112
|
-
|
|
113
|
-
def test_delete_success(self, mock_qb_api):
|
|
114
|
-
"""Test successful delete."""
|
|
115
|
-
result = handle_delete(mock_qb_api, "tblXYZ", where="{8.EX.'Inactive'}", description="Test delete")
|
|
116
|
-
|
|
117
|
-
assert result == 5
|
|
118
|
-
mock_qb_api.delete_records.assert_called_once_with("tblXYZ", where="{8.EX.'Inactive'}")
|
|
119
|
-
|
|
120
|
-
def test_delete_logs_result(self, mock_qb_api, caplog):
|
|
121
|
-
"""Test that delete logs result."""
|
|
122
|
-
handle_delete(mock_qb_api, "tblXYZ", where="{8.EX.'Inactive'}")
|
|
123
|
-
|
|
124
|
-
assert "5 records deleted" in caplog.text
|
|
125
|
-
|
|
126
|
-
def test_delete_failure(self, mock_qb_api):
|
|
127
|
-
"""Test delete failure."""
|
|
128
|
-
mock_qb_api.delete_records.side_effect = Exception("Invalid where clause")
|
|
129
|
-
|
|
130
|
-
with pytest.raises(QuickbaseOperationError, match="delete"):
|
|
131
|
-
handle_delete(mock_qb_api, "tblXYZ", where="invalid")
|
|
132
|
-
|
|
133
|
-
def test_delete_retries_on_rate_limit(self, mock_qb_api):
|
|
134
|
-
"""Test that delete retries on 429 rate limit."""
|
|
135
|
-
mock_qb_api.delete_records.side_effect = [
|
|
136
|
-
Exception("429 Rate Limit"),
|
|
137
|
-
5,
|
|
138
|
-
]
|
|
139
|
-
|
|
140
|
-
result = handle_delete(mock_qb_api, "tblXYZ", where="{8.EX.'Test'}")
|
|
141
|
-
|
|
142
|
-
assert result == 5
|
|
143
|
-
assert mock_qb_api.delete_records.call_count == 2
|
|
144
|
-
|
|
145
|
-
def test_delete_no_retry_on_other_errors(self, mock_qb_api):
|
|
146
|
-
"""Test that delete does not retry non-rate-limit errors."""
|
|
147
|
-
mock_qb_api.delete_records.side_effect = Exception("Permission denied")
|
|
148
|
-
|
|
149
|
-
with pytest.raises(QuickbaseOperationError):
|
|
150
|
-
handle_delete(mock_qb_api, "tblXYZ", where="{8.EX.'Test'}")
|
|
151
|
-
|
|
152
|
-
# Should only try once for non-rate-limit errors
|
|
153
|
-
assert mock_qb_api.delete_records.call_count == 1
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
class TestHandleQuery:
|
|
157
|
-
"""Tests for handle_query function."""
|
|
158
|
-
|
|
159
|
-
def test_query_success(self, mock_qb_api):
|
|
160
|
-
"""Test successful query."""
|
|
161
|
-
result = handle_query(
|
|
162
|
-
mock_qb_api,
|
|
163
|
-
"tblXYZ",
|
|
164
|
-
select=[3, 6, 7],
|
|
165
|
-
where="{8.EX.'Active'}",
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
assert "data" in result
|
|
169
|
-
assert len(result["data"]) == 2
|
|
170
|
-
|
|
171
|
-
def test_query_with_all_parameters(self, mock_qb_api):
|
|
172
|
-
"""Test query with all optional parameters including description."""
|
|
173
|
-
result = handle_query(
|
|
174
|
-
mock_qb_api,
|
|
175
|
-
"tblXYZ",
|
|
176
|
-
select=[3, 6],
|
|
177
|
-
where="{8.EX.'Active'}",
|
|
178
|
-
sort_by=[{"fieldId": 6, "order": "ASC"}],
|
|
179
|
-
group_by=[{"fieldId": 8}],
|
|
180
|
-
options={"skip": 0, "top": 100},
|
|
181
|
-
description="active users",
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
assert result is not None
|
|
185
|
-
mock_qb_api.query_for_data.assert_called_once()
|
|
186
|
-
|
|
187
|
-
def test_query_failure_non_retriable(self, mock_qb_api):
|
|
188
|
-
"""Test query failure with non-retriable error."""
|
|
189
|
-
mock_qb_api.query_for_data.side_effect = Exception("Invalid field ID")
|
|
190
|
-
|
|
191
|
-
with pytest.raises(QuickbaseOperationError, match="query"):
|
|
192
|
-
handle_query(mock_qb_api, "tblXYZ", select=[999])
|
|
193
|
-
|
|
194
|
-
def test_query_retry_on_rate_limit(self, mock_qb_api):
|
|
195
|
-
"""Test query retry on 429 rate limit."""
|
|
196
|
-
mock_qb_api.query_for_data.side_effect = [
|
|
197
|
-
Exception("429 Rate Limit Exceeded"),
|
|
198
|
-
{"data": []},
|
|
199
|
-
]
|
|
200
|
-
|
|
201
|
-
result = handle_query(mock_qb_api, "tblXYZ")
|
|
202
|
-
|
|
203
|
-
assert mock_qb_api.query_for_data.call_count == 2
|
|
204
|
-
assert result == {"data": []}
|
|
205
|
-
|
|
206
|
-
def test_query_max_retries_customizable(self, mock_qb_api):
|
|
207
|
-
"""Test that max_retries parameter is respected."""
|
|
208
|
-
mock_qb_api.query_for_data.side_effect = Exception("429 Rate Limit")
|
|
209
|
-
|
|
210
|
-
with pytest.raises(QuickbaseOperationError):
|
|
211
|
-
handle_query(mock_qb_api, "tblXYZ", max_retries=2)
|
|
212
|
-
|
|
213
|
-
assert mock_qb_api.query_for_data.call_count == 2
|
|
214
|
-
|
|
215
|
-
def test_query_logs_record_count(self, mock_qb_api, caplog):
|
|
216
|
-
"""Test that query logs record count at info level."""
|
|
217
|
-
handle_query(mock_qb_api, "tblXYZ", description="test query")
|
|
218
|
-
|
|
219
|
-
assert "2 records" in caplog.text
|
|
220
|
-
# Check it's at info level, not debug
|
|
221
|
-
assert any(record.levelname == "INFO" for record in caplog.records)
|
|
222
|
-
|
|
223
|
-
def test_query_exponential_backoff(self, mock_qb_api):
|
|
224
|
-
"""Test that retries use exponential backoff with cap."""
|
|
225
|
-
mock_qb_api.query_for_data.side_effect = [
|
|
226
|
-
Exception("429 Rate Limit"),
|
|
227
|
-
Exception("429 Rate Limit"),
|
|
228
|
-
{"data": []},
|
|
229
|
-
]
|
|
230
|
-
|
|
231
|
-
start = time.time()
|
|
232
|
-
handle_query(mock_qb_api, "tblXYZ", max_retries=3)
|
|
233
|
-
elapsed = time.time() - start
|
|
234
|
-
|
|
235
|
-
# Should have some delay due to exponential backoff
|
|
236
|
-
# (2^0 + 2^1) = 3 seconds + random = at least ~3 seconds
|
|
237
|
-
assert elapsed >= 2 # Allow some margin
|
|
238
|
-
|
|
239
|
-
def test_query_description_in_logs(self, mock_qb_api, caplog):
|
|
240
|
-
"""Test that description appears in log messages."""
|
|
241
|
-
handle_query(mock_qb_api, "tblXYZ", description="customer records")
|
|
242
|
-
|
|
243
|
-
assert "customer records" in caplog.text
|
|
244
|
-
assert "customer records" in caplog.text
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/src/quickbase_extract/cache_orchestration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quickbase_extract-0.4.4 → quickbase_extract-0.5.0}/src/quickbase_extract/report_metadata.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|