quickbase-extract 0.4.3__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.
Files changed (30) hide show
  1. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/CHANGELOG.md +35 -0
  2. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/PKG-INFO +1 -1
  3. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/pyproject.toml +4 -1
  4. quickbase_extract-0.5.0/src/quickbase_extract/api_handlers.py +165 -0
  5. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/src/quickbase_extract/cache_manager.py +2 -0
  6. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/src/quickbase_extract/cache_orchestration.py +9 -3
  7. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/src/quickbase_extract/report_data.py +4 -3
  8. quickbase_extract-0.5.0/tests/test_api_handlers.py +137 -0
  9. quickbase_extract-0.4.3/src/quickbase_extract/api_handlers.py +0 -210
  10. quickbase_extract-0.4.3/tests/test_api_handlers.py +0 -244
  11. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/.editorconfig +0 -0
  12. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/.gitignore +0 -0
  13. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/.pre-commit-config.yaml +0 -0
  14. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/.python-version +0 -0
  15. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/LICENSE.txt +0 -0
  16. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/README.md +0 -0
  17. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/TODO.md +0 -0
  18. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/src/quickbase_extract/__init__.py +0 -0
  19. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/src/quickbase_extract/cache_sync.py +0 -0
  20. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/src/quickbase_extract/config.py +0 -0
  21. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/src/quickbase_extract/py.typed +0 -0
  22. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/src/quickbase_extract/report_metadata.py +0 -0
  23. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/src/quickbase_extract/utils.py +0 -0
  24. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/tests/conftest.py +0 -0
  25. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/tests/test_cache_manager.py +0 -0
  26. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/tests/test_cache_orchestration.py +0 -0
  27. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/tests/test_cache_sync.py +0 -0
  28. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/tests/test_report_data.py +0 -0
  29. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/tests/test_report_metadata.py +0 -0
  30. {quickbase_extract-0.4.3 → quickbase_extract-0.5.0}/tests/test_utils.py +0 -0
@@ -5,6 +5,41 @@ 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
+
35
+ ## [0.4.4] - 2026-05-13
36
+
37
+ ### Fixed
38
+
39
+ - Changed `ask_values` parameter type annotations from `dict` to `Mapping` in `_refresh_data_cache` and `ensure_cache_freshness` in `cache_orchestration` to match `report_data` changes and accept any mapping type
40
+ - Fixed `data_reasons` variable in `ensure_cache_freshness` being potentially unbound if accessed outside the `data_caching_enabled` block; initialised to `[]` alongside other data cache variables
41
+ - Added `None` guard in `CacheManager._sync_to_s3` to raise `RuntimeError` with a clear message if called when `s3_client` is not configured, resolving type checker error and preventing unclear `AttributeError` at runtime
42
+
8
43
  ## [0.4.3] - 2026-05-13
9
44
 
10
45
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quickbase-extract
3
- Version: 0.4.3
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.4.3"
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
@@ -173,6 +173,8 @@ class CacheManager:
173
173
  Raises:
174
174
  Exception: If upload fails. This is critical - Lambda /tmp is ephemeral.
175
175
  """
176
+ if self.s3_client is None:
177
+ raise RuntimeError("_sync_to_s3 called but s3_client is not configured")
176
178
  try:
177
179
  relative_path = file_path.relative_to(self.cache_root)
178
180
  s3_key = f"{self.s3_prefix}/{relative_path}" if self.s3_prefix else str(relative_path)
@@ -6,6 +6,7 @@ metadata and data caches. Ensures caches are up-to-date before processing.
6
6
 
7
7
  import logging
8
8
  import os
9
+ from collections.abc import Mapping
9
10
 
10
11
  from quickbase_extract.cache_manager import DEFAULT_DATA_STALE_HOURS, DEFAULT_METADATA_STALE_HOURS, CacheManager
11
12
  from quickbase_extract.config import ReportConfig
@@ -123,7 +124,7 @@ def _refresh_data_cache(
123
124
  cache_manager: CacheManager,
124
125
  reports_to_refresh: list[ReportConfig],
125
126
  reasons: list[str],
126
- ask_values: dict[ReportConfig, dict[str, str | list[str]]] | None = None,
127
+ ask_values: Mapping[ReportConfig, Mapping[str, str | list[str]]] | None = None,
127
128
  ) -> None:
128
129
  """Refresh data cache for specified reports.
129
130
 
@@ -132,8 +133,12 @@ def _refresh_data_cache(
132
133
  cache_manager: CacheManager instance.
133
134
  reports_to_refresh: Reports to refresh data for.
134
135
  reasons: List of reasons for refresh (for logging).
135
- ask_values: Optional dict mapping ReportConfig -> ask_values dict.
136
+ ask_values: Optional mapping of ReportConfig -> ask_values mapping.
136
137
  Per-report "ask the user" filter values.
138
+ data cache. Example: {
139
+ ReportConfig("bq8x", "Accounts", "Python"): {"ask1": "abc"},
140
+ ReportConfig("bq9y", "Contacts", "Active"): {"ask1": "def"}
141
+ }
137
142
 
138
143
  Raises:
139
144
  CacheRefreshError: If data refresh fails.
@@ -164,7 +169,7 @@ def ensure_cache_freshness(
164
169
  cache_manager: CacheManager,
165
170
  report_configs_all: list[ReportConfig],
166
171
  report_configs_to_cache: list[ReportConfig] | None = None,
167
- ask_values: dict[ReportConfig, dict[str, str | list[str]]] | None = None,
172
+ ask_values: Mapping[ReportConfig, Mapping[str, str | list[str]]] | None = None,
168
173
  metadata_stale_hours: float | None = None,
169
174
  data_stale_hours: float | None = None,
170
175
  cache_all_data: bool = False,
@@ -277,6 +282,7 @@ def ensure_cache_freshness(
277
282
  # Check data cache state and determine refresh needs (only if data caching enabled)
278
283
  data_needs_refresh = False
279
284
  reports_to_refresh_data = []
285
+ data_reasons: list[str] = []
280
286
  data_age = None
281
287
 
282
288
  if data_caching_enabled:
@@ -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, the exception is raised
312
- immediately and pending (not yet started) tasks will not be executed. Already
313
- running tasks will complete before the exception propagates.
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