qbvisor 0.3.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.
qbvisor/__init__.py ADDED
@@ -0,0 +1,141 @@
1
+ """Public package interface for the qbvisor Quickbase SDK."""
2
+
3
+ from pathlib import Path
4
+
5
+ from dotenv import load_dotenv
6
+
7
+ from ._version import __version__
8
+
9
+ # Try to load .env from common locations
10
+ possible_paths = [
11
+ Path(__file__).resolve().parents[2] / ".env", # repo root if src/qbvisor/
12
+ Path(__file__).resolve().parents[1] / ".env", # one level up
13
+ Path.cwd() / ".env", # where the script is run from
14
+ ]
15
+
16
+ for dotenv_path in possible_paths:
17
+ if dotenv_path.exists():
18
+ load_dotenv(dotenv_path=dotenv_path)
19
+ break
20
+
21
+ from .backup import (
22
+ BACKUP_FORMAT,
23
+ BACKUP_FORMAT_VERSION,
24
+ ApplicationBackup,
25
+ AttachmentVersionMode,
26
+ BackupArtifact,
27
+ BackupArtifactKind,
28
+ BackupManifest,
29
+ BackupOptions,
30
+ BackupTable,
31
+ BackupVerification,
32
+ )
33
+ from .client import QuickBaseClient
34
+ from .exceptions import (
35
+ BackupConsistencyError,
36
+ BackupIntegrityError,
37
+ QuickbaseBatchError,
38
+ QuickbaseConfigurationError,
39
+ QuickbaseConnectionError,
40
+ QuickbaseError,
41
+ QuickbaseHTTPError,
42
+ QuickbaseRateLimitError,
43
+ QuickbaseResponseError,
44
+ QuickbaseSchemaApplyError,
45
+ QuickbaseSchemaConflictError,
46
+ QuickbaseSchemaLockError,
47
+ QuickbaseSchemaStalePlanError,
48
+ QuickbaseSchemaStateError,
49
+ QuickbaseTimeoutError,
50
+ )
51
+ from .helpers import (
52
+ ensure_temp_dir,
53
+ generate_timestamped_folder,
54
+ sanitize_filenames,
55
+ summarize_file_sizes,
56
+ )
57
+ from .log_runner import LoggingConfigurator, get_logger
58
+ from .models import RelationshipAccumulation, RelationshipSummary
59
+ from .query_helper import QueryHelper
60
+ from .schema import (
61
+ SCHEMA_STATE_FORMAT,
62
+ SCHEMA_STATE_FORMAT_VERSION,
63
+ AppSpec,
64
+ FieldSpec,
65
+ FormulaFieldType,
66
+ FormulaSpec,
67
+ RelationshipSpec,
68
+ SchemaAction,
69
+ SchemaApplyResult,
70
+ SchemaAttributeChange,
71
+ SchemaChange,
72
+ SchemaPlan,
73
+ SchemaResourceKind,
74
+ SchemaState,
75
+ SchemaStateAction,
76
+ StateResource,
77
+ SummaryFieldSpec,
78
+ TableSpec,
79
+ )
80
+ from .transport import QuickBaseTransport, RetryPolicy
81
+
82
+ # Expose file download utilities directly on the client
83
+
84
+ __all__ = [
85
+ "__version__",
86
+ "QuickBaseClient",
87
+ "BACKUP_FORMAT",
88
+ "BACKUP_FORMAT_VERSION",
89
+ "ApplicationBackup",
90
+ "AttachmentVersionMode",
91
+ "BackupArtifact",
92
+ "BackupArtifactKind",
93
+ "BackupManifest",
94
+ "BackupOptions",
95
+ "BackupTable",
96
+ "BackupVerification",
97
+ "BackupConsistencyError",
98
+ "BackupIntegrityError",
99
+ "QuickBaseTransport",
100
+ "RetryPolicy",
101
+ "QuickbaseError",
102
+ "QuickbaseBatchError",
103
+ "QuickbaseConfigurationError",
104
+ "QuickbaseConnectionError",
105
+ "QuickbaseTimeoutError",
106
+ "QuickbaseHTTPError",
107
+ "QuickbaseRateLimitError",
108
+ "QuickbaseResponseError",
109
+ "QuickbaseSchemaApplyError",
110
+ "QuickbaseSchemaConflictError",
111
+ "QuickbaseSchemaLockError",
112
+ "QuickbaseSchemaStateError",
113
+ "QuickbaseSchemaStalePlanError",
114
+ "QueryHelper",
115
+ "SCHEMA_STATE_FORMAT",
116
+ "SCHEMA_STATE_FORMAT_VERSION",
117
+ "AppSpec",
118
+ "FieldSpec",
119
+ "FormulaFieldType",
120
+ "FormulaSpec",
121
+ "RelationshipSpec",
122
+ "SchemaAction",
123
+ "SchemaApplyResult",
124
+ "SchemaAttributeChange",
125
+ "SchemaChange",
126
+ "SchemaPlan",
127
+ "SchemaResourceKind",
128
+ "SchemaState",
129
+ "SchemaStateAction",
130
+ "StateResource",
131
+ "SummaryFieldSpec",
132
+ "TableSpec",
133
+ "RelationshipAccumulation",
134
+ "RelationshipSummary",
135
+ "sanitize_filenames",
136
+ "ensure_temp_dir",
137
+ "generate_timestamped_folder",
138
+ "summarize_file_sizes",
139
+ "LoggingConfigurator",
140
+ "get_logger",
141
+ ]
@@ -0,0 +1,83 @@
1
+ """Validation helpers for attachment metadata returned by record queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from .exceptions import QuickbaseResponseError
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class LatestAttachment:
13
+ """The latest downloadable version of a file attachment."""
14
+
15
+ version_number: int
16
+ file_name: str
17
+
18
+
19
+ def latest_attachment(
20
+ value: Any,
21
+ *,
22
+ table_id: str,
23
+ record_id: int,
24
+ field_id: int,
25
+ ) -> LatestAttachment | None:
26
+ """Return validated latest-version metadata, or ``None`` for an empty file cell."""
27
+ location = f"{table_id}/{record_id}/{field_id}"
28
+ if value in (None, ""):
29
+ return None
30
+ if not isinstance(value, dict):
31
+ raise QuickbaseResponseError(
32
+ "POST",
33
+ "records/query",
34
+ expected="file attachment value object",
35
+ actual=f"{type(value).__name__} at {location}",
36
+ )
37
+
38
+ versions = value.get("versions")
39
+ if versions is None:
40
+ return None
41
+ if not isinstance(versions, list) or not all(isinstance(version, dict) for version in versions):
42
+ raise QuickbaseResponseError(
43
+ "POST",
44
+ "records/query",
45
+ expected="file attachment versions array",
46
+ actual=f"{type(versions).__name__} at {location}",
47
+ )
48
+ if not versions:
49
+ return None
50
+
51
+ numbered: list[tuple[int, dict[str, Any]]] = []
52
+ for version in versions:
53
+ number = version.get("versionNumber")
54
+ if not isinstance(number, int) or isinstance(number, bool) or number < 1:
55
+ raise QuickbaseResponseError(
56
+ "POST",
57
+ "records/query",
58
+ expected="positive integer attachment versionNumber",
59
+ actual=f"{number!r} at {location}",
60
+ )
61
+ numbered.append((number, version))
62
+
63
+ if len({number for number, _ in numbered}) != len(numbered):
64
+ raise QuickbaseResponseError(
65
+ "POST",
66
+ "records/query",
67
+ expected="unique attachment versionNumber values",
68
+ actual=f"duplicates at {location}",
69
+ )
70
+
71
+ version_number, latest = max(numbered, key=lambda item: item[0])
72
+ file_name = latest.get("fileName") or value.get("fileName")
73
+ if file_name is None or file_name == "":
74
+ file_name = f"fid{field_id}_v{version_number}.bin"
75
+ elif not isinstance(file_name, str):
76
+ raise QuickbaseResponseError(
77
+ "POST",
78
+ "records/query",
79
+ expected="string attachment fileName",
80
+ actual=f"{type(file_name).__name__} at {location}",
81
+ )
82
+
83
+ return LatestAttachment(version_number=version_number, file_name=file_name)
@@ -0,0 +1,23 @@
1
+ """Private implementation for application backup workflows."""
2
+
3
+ from .attachments import (
4
+ CapturedAttachments,
5
+ CapturedAttachmentTable,
6
+ capture_attachments,
7
+ )
8
+ from .records import CapturedRecords, CapturedRecordTable, capture_records
9
+ from .schema import CapturedSchema, CapturedTable, capture_schema
10
+ from .workspace import BackupWorkspace
11
+
12
+ __all__ = [
13
+ "BackupWorkspace",
14
+ "CapturedAttachments",
15
+ "CapturedAttachmentTable",
16
+ "CapturedRecords",
17
+ "CapturedRecordTable",
18
+ "CapturedSchema",
19
+ "CapturedTable",
20
+ "capture_attachments",
21
+ "capture_records",
22
+ "capture_schema",
23
+ ]
@@ -0,0 +1,303 @@
1
+ """Archive file attachment versions referenced by captured table records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from collections.abc import Callable, Iterator
8
+ from dataclasses import dataclass
9
+ from typing import Any, Protocol
10
+
11
+ from ..async_transport import AsyncQuickBaseTransport
12
+ from ..backup import AttachmentVersionMode, BackupArtifact
13
+ from ..exceptions import QuickbaseResponseError
14
+ from ..helpers import sanitize_filenames
15
+ from ..transport import QuickBaseTransport
16
+ from .schema import CapturedSchema, CapturedTable
17
+ from .workspace import BackupWorkspace, JsonLinesArtifactWriter
18
+
19
+
20
+ class AttachmentClient(Protocol):
21
+ transport: QuickBaseTransport
22
+
23
+
24
+ class AsyncFileTransport(Protocol):
25
+ async def __aenter__(self) -> AsyncFileTransport: ...
26
+
27
+ async def __aexit__(self, *_: object) -> None: ...
28
+
29
+ async def get_file(self, path: str) -> bytes: ...
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class AttachmentJob:
34
+ table_id: str
35
+ record_id: int
36
+ field_id: int
37
+ version_number: int
38
+ file_name: str
39
+ path: str
40
+ metadata: dict[str, Any]
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class CapturedAttachmentTable:
45
+ id: str
46
+ attachment_count: int
47
+ index_artifact: str
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class CapturedAttachments:
52
+ tables: tuple[CapturedAttachmentTable, ...]
53
+
54
+
55
+ def _safe_filename(value: str, field_id: int, version_number: int) -> str:
56
+ clean = sanitize_filenames(value)
57
+ clean = "".join(character for character in clean if " " <= character != "\x7f")
58
+ clean = clean.strip().strip(".")
59
+ if not clean:
60
+ clean = f"fid{field_id}_v{version_number}.bin"
61
+ while len(clean.encode("utf-8")) > 200:
62
+ clean = clean[:-1]
63
+ return clean
64
+
65
+
66
+ def _file_field_ids(table: CapturedTable) -> tuple[int, ...]:
67
+ field_ids: list[int] = []
68
+ for field in table.fields:
69
+ if field.get("fieldType") != "file":
70
+ continue
71
+ field_id = field.get("id")
72
+ if not isinstance(field_id, int) or isinstance(field_id, bool):
73
+ raise ValueError(f"Quickbase file field in table {table.id} is missing a valid id")
74
+ field_ids.append(field_id)
75
+ return tuple(field_ids)
76
+
77
+
78
+ def _record_id(record: dict[str, Any], table_id: str, line_number: int) -> int:
79
+ cell = record.get("3")
80
+ value = cell.get("value") if isinstance(cell, dict) else None
81
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
82
+ raise ValueError(
83
+ f"records.jsonl for table {table_id} has an invalid Record ID# at line {line_number}"
84
+ )
85
+ return value
86
+
87
+
88
+ def _selected_versions(
89
+ versions: Any,
90
+ mode: AttachmentVersionMode,
91
+ *,
92
+ table_id: str,
93
+ record_id: int,
94
+ field_id: int,
95
+ ) -> tuple[dict[str, Any], ...]:
96
+ if not isinstance(versions, list) or not all(isinstance(version, dict) for version in versions):
97
+ raise QuickbaseResponseError(
98
+ "POST",
99
+ "records/query",
100
+ expected="file attachment versions array",
101
+ actual=type(versions).__name__,
102
+ )
103
+ numbered: list[tuple[int, dict[str, Any]]] = []
104
+ for version in versions:
105
+ number = version.get("versionNumber")
106
+ if not isinstance(number, int) or isinstance(number, bool) or number < 1:
107
+ raise ValueError(
108
+ f"Attachment version for {table_id}/{record_id}/{field_id} has an invalid number"
109
+ )
110
+ numbered.append((number, version))
111
+ numbered.sort(key=lambda item: item[0])
112
+ if len({number for number, _ in numbered}) != len(numbered):
113
+ raise ValueError(
114
+ f"Attachment versions for {table_id}/{record_id}/{field_id} contain duplicates"
115
+ )
116
+ if mode == "latest" and numbered:
117
+ numbered = [numbered[-1]]
118
+ return tuple(version for _, version in numbered)
119
+
120
+
121
+ def _attachment_jobs(
122
+ table: CapturedTable,
123
+ workspace: BackupWorkspace,
124
+ mode: AttachmentVersionMode,
125
+ ) -> Iterator[AttachmentJob]:
126
+ if mode == "none":
127
+ return
128
+ field_ids = _file_field_ids(table)
129
+ if not field_ids:
130
+ return
131
+ records_path = workspace.root / "tables" / table.id / "records.jsonl"
132
+ with records_path.open(encoding="utf-8") as records:
133
+ for line_number, line in enumerate(records, start=1):
134
+ try:
135
+ record = json.loads(line)
136
+ except json.JSONDecodeError as error:
137
+ raise ValueError(
138
+ f"records.jsonl for table {table.id} is invalid at line {line_number}"
139
+ ) from error
140
+ if not isinstance(record, dict):
141
+ raise ValueError(f"records.jsonl for table {table.id} must contain objects")
142
+ record_id = _record_id(record, table.id, line_number)
143
+ for field_id in field_ids:
144
+ cell = record.get(str(field_id))
145
+ value = cell.get("value") if isinstance(cell, dict) else None
146
+ if value in (None, ""):
147
+ continue
148
+ if not isinstance(value, dict):
149
+ raise QuickbaseResponseError(
150
+ "POST",
151
+ "records/query",
152
+ expected="file attachment value object",
153
+ actual=type(value).__name__,
154
+ )
155
+ versions = _selected_versions(
156
+ value.get("versions", []),
157
+ mode,
158
+ table_id=table.id,
159
+ record_id=record_id,
160
+ field_id=field_id,
161
+ )
162
+ for version in versions:
163
+ version_number = int(version["versionNumber"])
164
+ raw_name = version.get("fileName") or value.get("fileName")
165
+ file_name = (
166
+ raw_name
167
+ if isinstance(raw_name, str) and raw_name
168
+ else f"fid{field_id}_v{version_number}.bin"
169
+ )
170
+ safe_name = _safe_filename(file_name, field_id, version_number)
171
+ path = (
172
+ f"tables/{table.id}/attachments/{record_id}/"
173
+ f"{field_id}/{version_number}/{safe_name}"
174
+ )
175
+ yield AttachmentJob(
176
+ table_id=table.id,
177
+ record_id=record_id,
178
+ field_id=field_id,
179
+ version_number=version_number,
180
+ file_name=file_name,
181
+ path=path,
182
+ metadata=version,
183
+ )
184
+
185
+
186
+ def _index_entry(job: AttachmentJob, artifact: BackupArtifact) -> dict[str, Any]:
187
+ return {
188
+ "record_id": job.record_id,
189
+ "field_id": job.field_id,
190
+ "version_number": job.version_number,
191
+ "file_name": job.file_name,
192
+ "path": artifact.path,
193
+ "sha256": artifact.sha256,
194
+ "bytes": artifact.bytes,
195
+ "metadata": job.metadata,
196
+ }
197
+
198
+
199
+ async def _download_job(
200
+ transport: AsyncFileTransport,
201
+ workspace: BackupWorkspace,
202
+ job: AttachmentJob,
203
+ ) -> tuple[AttachmentJob, BackupArtifact]:
204
+ endpoint = f"files/{job.table_id}/{job.record_id}/{job.field_id}/{job.version_number}"
205
+ content = await transport.get_file(endpoint)
206
+ return job, workspace.write_bytes(job.path, "attachment", content)
207
+
208
+
209
+ async def _capture_table_attachments(
210
+ transport: AsyncFileTransport,
211
+ table: CapturedTable,
212
+ workspace: BackupWorkspace,
213
+ mode: AttachmentVersionMode,
214
+ max_concurrency: int,
215
+ index_writer: JsonLinesArtifactWriter,
216
+ ) -> int:
217
+ jobs = iter(_attachment_jobs(table, workspace, mode))
218
+ count = 0
219
+ while True:
220
+ batch: list[AttachmentJob] = []
221
+ for _ in range(max_concurrency):
222
+ try:
223
+ batch.append(next(jobs))
224
+ except StopIteration:
225
+ break
226
+ if not batch:
227
+ return count
228
+ results = await asyncio.gather(*(_download_job(transport, workspace, job) for job in batch))
229
+ for job, artifact in results:
230
+ index_writer.write(_index_entry(job, artifact))
231
+ count += 1
232
+
233
+
234
+ async def _capture_all_attachments(
235
+ client: AttachmentClient,
236
+ schema: CapturedSchema,
237
+ workspace: BackupWorkspace,
238
+ mode: AttachmentVersionMode,
239
+ max_concurrency: int,
240
+ transport_factory: Callable[[QuickBaseTransport], AsyncFileTransport],
241
+ ) -> CapturedAttachments:
242
+ captured: list[CapturedAttachmentTable] = []
243
+ async with transport_factory(client.transport) as transport:
244
+ for table in schema.tables:
245
+ index_path = f"tables/{table.id}/attachments.jsonl"
246
+ with workspace.json_lines_writer(index_path, "attachment-index") as writer:
247
+ count = await _capture_table_attachments(
248
+ transport,
249
+ table,
250
+ workspace,
251
+ mode,
252
+ max_concurrency,
253
+ writer,
254
+ )
255
+ captured.append(
256
+ CapturedAttachmentTable(
257
+ id=table.id,
258
+ attachment_count=count,
259
+ index_artifact=writer.artifact.path,
260
+ )
261
+ )
262
+ return CapturedAttachments(tables=tuple(captured))
263
+
264
+
265
+ def capture_attachments(
266
+ client: AttachmentClient,
267
+ schema: CapturedSchema,
268
+ workspace: BackupWorkspace,
269
+ *,
270
+ mode: AttachmentVersionMode,
271
+ max_concurrency: int,
272
+ transport_factory: Callable[[QuickBaseTransport], AsyncFileTransport] = AsyncQuickBaseTransport,
273
+ ) -> CapturedAttachments:
274
+ """Archive selected attachment versions with bounded concurrent downloads."""
275
+ if mode not in {"all", "latest", "none"}:
276
+ raise ValueError(f"Unsupported attachment version mode: {mode}")
277
+ if max_concurrency < 1:
278
+ raise ValueError("max_concurrency must be at least 1")
279
+ if mode != "none":
280
+ return asyncio.run(
281
+ _capture_all_attachments(
282
+ client,
283
+ schema,
284
+ workspace,
285
+ mode,
286
+ max_concurrency,
287
+ transport_factory,
288
+ )
289
+ )
290
+
291
+ captured: list[CapturedAttachmentTable] = []
292
+ for table in schema.tables:
293
+ index_path = f"tables/{table.id}/attachments.jsonl"
294
+ with workspace.json_lines_writer(index_path, "attachment-index") as writer:
295
+ pass
296
+ captured.append(
297
+ CapturedAttachmentTable(
298
+ id=table.id,
299
+ attachment_count=0,
300
+ index_artifact=writer.artifact.path,
301
+ )
302
+ )
303
+ return CapturedAttachments(tables=tuple(captured))