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.
@@ -0,0 +1,263 @@
1
+ """Open, verify, and analyze completed application backups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import pandas as pd
11
+
12
+ from ..backup import (
13
+ ApplicationBackup,
14
+ BackupArtifact,
15
+ BackupManifest,
16
+ BackupTable,
17
+ BackupVerification,
18
+ )
19
+ from ..exceptions import BackupIntegrityError
20
+
21
+
22
+ def open_backup(path: str | Path) -> ApplicationBackup:
23
+ root = Path(path).expanduser().resolve()
24
+ manifest_path = root / "manifest.json"
25
+ try:
26
+ payload = json.loads(manifest_path.read_text(encoding="utf-8"))
27
+ except (OSError, json.JSONDecodeError) as error:
28
+ raise ValueError(f"Could not read backup manifest at {manifest_path}") from error
29
+ if not isinstance(payload, dict):
30
+ raise ValueError("backup manifest must contain a JSON object")
31
+ return ApplicationBackup(path=root, manifest=BackupManifest.from_dict(payload))
32
+
33
+
34
+ def _hash_file(path: Path) -> tuple[str, int]:
35
+ digest = hashlib.sha256()
36
+ size = 0
37
+ with path.open("rb") as stream:
38
+ while chunk := stream.read(1024 * 1024):
39
+ digest.update(chunk)
40
+ size += len(chunk)
41
+ return digest.hexdigest(), size
42
+
43
+
44
+ def _json_line_count(path: Path, issues: list[str]) -> int:
45
+ count = 0
46
+ with path.open(encoding="utf-8") as stream:
47
+ for line_number, line in enumerate(stream, start=1):
48
+ try:
49
+ payload = json.loads(line)
50
+ except json.JSONDecodeError:
51
+ issues.append(f"{path.name} contains invalid JSON at line {line_number}")
52
+ continue
53
+ if not isinstance(payload, dict):
54
+ issues.append(f"{path.name} contains a non-object at line {line_number}")
55
+ count += 1
56
+ return count
57
+
58
+
59
+ def _json_item_count(path: Path, artifact: BackupArtifact, issues: list[str]) -> int | None:
60
+ if artifact.kind in {"records", "attachment-index"}:
61
+ return _json_line_count(path, issues)
62
+ if artifact.item_count is None:
63
+ return None
64
+ try:
65
+ payload = json.loads(path.read_text(encoding="utf-8"))
66
+ except (OSError, json.JSONDecodeError):
67
+ issues.append(f"{artifact.path} is not valid JSON")
68
+ return None
69
+ if artifact.kind == "reports" and isinstance(payload, dict):
70
+ payload = payload.get("details")
71
+ if not isinstance(payload, list):
72
+ issues.append(f"{artifact.path} does not contain the expected array")
73
+ return None
74
+ return len(payload)
75
+
76
+
77
+ def _verify_table_counts(backup: ApplicationBackup, issues: list[str]) -> None:
78
+ artifacts = {artifact.path: artifact for artifact in backup.manifest.artifacts}
79
+ for table in backup.manifest.tables:
80
+ table_artifacts = [artifacts[path] for path in table.artifacts]
81
+ record_artifacts = [artifact for artifact in table_artifacts if artifact.kind == "records"]
82
+ index_artifacts = [
83
+ artifact for artifact in table_artifacts if artifact.kind == "attachment-index"
84
+ ]
85
+ if len(record_artifacts) != 1 or record_artifacts[0].item_count != table.record_count:
86
+ issues.append(f"table {table.id} record count does not match its records artifact")
87
+ if len(index_artifacts) != 1 or index_artifacts[0].item_count != table.attachment_count:
88
+ issues.append(f"table {table.id} attachment count does not match its index")
89
+
90
+
91
+ def _verify_attachment_indexes(backup: ApplicationBackup, issues: list[str]) -> None:
92
+ attachments = {
93
+ artifact.path: artifact
94
+ for artifact in backup.manifest.artifacts
95
+ if artifact.kind == "attachment"
96
+ }
97
+ indexed_paths: set[str] = set()
98
+ for artifact in backup.manifest.artifacts:
99
+ if artifact.kind != "attachment-index":
100
+ continue
101
+ path = backup.path / artifact.path
102
+ if not path.is_file():
103
+ continue
104
+ table_prefix = artifact.path.removesuffix("attachments.jsonl") + "attachments/"
105
+ try:
106
+ with path.open(encoding="utf-8") as stream:
107
+ for line_number, line in enumerate(stream, start=1):
108
+ try:
109
+ entry = json.loads(line)
110
+ except json.JSONDecodeError:
111
+ continue
112
+ if not isinstance(entry, dict):
113
+ continue
114
+ referenced_path = entry.get("path")
115
+ if not isinstance(referenced_path, str):
116
+ issues.append(
117
+ f"{artifact.path} line {line_number} has an invalid attachment path"
118
+ )
119
+ continue
120
+ if not referenced_path.startswith(table_prefix):
121
+ issues.append(
122
+ f"{artifact.path} line {line_number} references another table"
123
+ )
124
+ continue
125
+ referenced = attachments.get(referenced_path)
126
+ if referenced is None:
127
+ issues.append(
128
+ f"{artifact.path} line {line_number} references a missing attachment"
129
+ )
130
+ continue
131
+ if referenced_path in indexed_paths:
132
+ issues.append(f"attachment is indexed more than once: {referenced_path}")
133
+ indexed_paths.add(referenced_path)
134
+ if (
135
+ entry.get("sha256") != referenced.sha256
136
+ or entry.get("bytes") != referenced.bytes
137
+ ):
138
+ issues.append(
139
+ f"{artifact.path} line {line_number} has incorrect attachment integrity"
140
+ )
141
+ except (OSError, UnicodeDecodeError):
142
+ # The general artifact pass already reports unreadable index content.
143
+ continue
144
+ missing = set(attachments) - indexed_paths
145
+ if missing:
146
+ issues.append(f"attachment artifacts are not indexed: {', '.join(sorted(missing))}")
147
+
148
+
149
+ def verify_backup(backup: ApplicationBackup) -> BackupVerification:
150
+ root = backup.path
151
+ issues: list[str] = []
152
+ if not root.is_dir() or root.is_symlink():
153
+ raise BackupIntegrityError((f"backup root is not a regular directory: {root}",))
154
+ try:
155
+ disk_manifest = open_backup(root).manifest
156
+ except ValueError as error:
157
+ issues.append(str(error))
158
+ else:
159
+ if disk_manifest != backup.manifest:
160
+ issues.append("manifest.json changed after this backup was opened")
161
+
162
+ expected_paths = {artifact.path for artifact in backup.manifest.artifacts} | {"manifest.json"}
163
+ actual_paths: set[str] = set()
164
+ for path in root.rglob("*"):
165
+ relative = path.relative_to(root).as_posix()
166
+ if path.is_symlink():
167
+ issues.append(f"backup contains a symbolic link: {relative}")
168
+ elif path.is_file():
169
+ actual_paths.add(relative)
170
+ missing = expected_paths - actual_paths
171
+ unexpected = actual_paths - expected_paths
172
+ if missing:
173
+ issues.append(f"backup files are missing: {', '.join(sorted(missing))}")
174
+ if unexpected:
175
+ issues.append(f"backup contains untracked files: {', '.join(sorted(unexpected))}")
176
+
177
+ total_bytes = 0
178
+ for artifact in backup.manifest.artifacts:
179
+ path = root / artifact.path
180
+ if not path.is_file() or path.is_symlink():
181
+ continue
182
+ try:
183
+ digest, size = _hash_file(path)
184
+ except OSError as error:
185
+ issues.append(f"artifact could not be read: {artifact.path} ({error})")
186
+ continue
187
+ total_bytes += size
188
+ if digest != artifact.sha256:
189
+ issues.append(f"artifact hash does not match: {artifact.path}")
190
+ if size != artifact.bytes:
191
+ issues.append(f"artifact byte count does not match: {artifact.path}")
192
+ try:
193
+ item_count = _json_item_count(path, artifact, issues)
194
+ except (OSError, UnicodeDecodeError) as error:
195
+ issues.append(f"artifact content could not be read: {artifact.path} ({error})")
196
+ continue
197
+ if item_count is not None and item_count != artifact.item_count:
198
+ issues.append(f"artifact item count does not match: {artifact.path}")
199
+
200
+ _verify_table_counts(backup, issues)
201
+ _verify_attachment_indexes(backup, issues)
202
+ if issues:
203
+ raise BackupIntegrityError(tuple(issues))
204
+ return BackupVerification(
205
+ artifact_count=len(backup.manifest.artifacts),
206
+ total_bytes=total_bytes,
207
+ )
208
+
209
+
210
+ def _resolve_table(backup: ApplicationBackup, value: str) -> BackupTable:
211
+ matches = [
212
+ table
213
+ for table in backup.manifest.tables
214
+ if table.id == value or table.name.casefold() == value.casefold()
215
+ ]
216
+ if not matches:
217
+ available = ", ".join(table.name for table in backup.manifest.tables)
218
+ raise KeyError(f"Table {value!r} is not in this backup. Available: {available}")
219
+ if len(matches) > 1:
220
+ raise KeyError(f"Table name {value!r} is ambiguous; use a table ID")
221
+ return matches[0]
222
+
223
+
224
+ def table_dataframe(backup: ApplicationBackup, table: str) -> pd.DataFrame:
225
+ selected = _resolve_table(backup, table)
226
+ fields_path = backup.path / f"tables/{selected.id}/fields.json"
227
+ records_path = backup.path / f"tables/{selected.id}/records.jsonl"
228
+ fields = json.loads(fields_path.read_text(encoding="utf-8"))
229
+ if not isinstance(fields, list) or not all(isinstance(field, dict) for field in fields):
230
+ raise ValueError(f"Captured fields for table {selected.id} are invalid")
231
+ columns: list[tuple[str, str]] = []
232
+ for field in fields:
233
+ field_id = field.get("id")
234
+ label = field.get("label")
235
+ if (
236
+ not isinstance(field_id, int)
237
+ or isinstance(field_id, bool)
238
+ or not isinstance(label, str)
239
+ ):
240
+ raise ValueError(f"Captured field metadata for table {selected.id} is invalid")
241
+ columns.append((str(field_id), label))
242
+ labels = [label for _, label in columns]
243
+ if len(set(labels)) != len(labels):
244
+ raise ValueError(f"Captured field labels for table {selected.id} are not unique")
245
+
246
+ rows: list[dict[str, Any]] = []
247
+ known_ids = {field_id for field_id, _ in columns}
248
+ with records_path.open(encoding="utf-8") as stream:
249
+ for line_number, line in enumerate(stream, start=1):
250
+ record = json.loads(line)
251
+ if not isinstance(record, dict):
252
+ raise ValueError(f"Captured record at line {line_number} is not an object")
253
+ unknown = set(record) - known_ids
254
+ if unknown:
255
+ raise ValueError(
256
+ f"Captured record at line {line_number} contains unknown fields: {unknown}"
257
+ )
258
+ row: dict[str, Any] = {}
259
+ for field_id, label in columns:
260
+ cell = record.get(field_id)
261
+ row[label] = cell.get("value") if isinstance(cell, dict) else None
262
+ rows.append(row)
263
+ return pd.DataFrame.from_records(rows, columns=labels)
@@ -0,0 +1,83 @@
1
+ """Stream complete table records into deterministic JSON Lines artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from .._records.pagination import (
10
+ RECORD_ID_FIELD_ID,
11
+ RecordQueryClient,
12
+ iter_record_pages_by_id,
13
+ )
14
+ from .schema import CapturedSchema, CapturedTable
15
+ from .workspace import BackupWorkspace
16
+
17
+ RecordClient = RecordQueryClient
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class CapturedRecordTable:
22
+ id: str
23
+ record_count: int
24
+ artifact: str
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class CapturedRecords:
29
+ tables: tuple[CapturedRecordTable, ...]
30
+
31
+
32
+ def _field_ids(table: CapturedTable) -> tuple[int, ...]:
33
+ field_ids: list[int] = []
34
+ for field in table.fields:
35
+ field_id = field.get("id")
36
+ if not isinstance(field_id, int) or isinstance(field_id, bool):
37
+ raise ValueError(f"Quickbase field in table {table.id} is missing a valid id")
38
+ field_ids.append(field_id)
39
+ if len(set(field_ids)) != len(field_ids):
40
+ raise ValueError(f"Quickbase returned duplicate field IDs for table {table.id}")
41
+ if RECORD_ID_FIELD_ID not in field_ids:
42
+ raise ValueError(f"Quickbase table {table.id} does not include Record ID# field 3")
43
+ return tuple(field_ids)
44
+
45
+
46
+ def _iter_table_records(
47
+ client: RecordClient,
48
+ table: CapturedTable,
49
+ page_size: int,
50
+ ) -> Iterator[dict[str, Any]]:
51
+ select_fields = _field_ids(table)
52
+ for page in iter_record_pages_by_id(
53
+ client,
54
+ table.id,
55
+ select_fields=select_fields,
56
+ page_size=page_size,
57
+ ):
58
+ yield from page
59
+
60
+
61
+ def capture_records(
62
+ client: RecordClient,
63
+ schema: CapturedSchema,
64
+ workspace: BackupWorkspace,
65
+ *,
66
+ page_size: int,
67
+ ) -> CapturedRecords:
68
+ """Stream all fields and records for every captured table."""
69
+ captured: list[CapturedRecordTable] = []
70
+ for table in schema.tables:
71
+ artifact = workspace.write_json_lines(
72
+ f"tables/{table.id}/records.jsonl",
73
+ "records",
74
+ _iter_table_records(client, table, page_size),
75
+ )
76
+ captured.append(
77
+ CapturedRecordTable(
78
+ id=table.id,
79
+ record_count=artifact.item_count or 0,
80
+ artifact=artifact.path,
81
+ )
82
+ )
83
+ return CapturedRecords(tables=tuple(captured))
@@ -0,0 +1,143 @@
1
+ """Capture application and table schema without reshaping Quickbase metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Protocol
7
+
8
+ from .workspace import BackupWorkspace
9
+
10
+
11
+ class SchemaClient(Protocol):
12
+ def _ids(self, app_name: str, table_name: None = None) -> tuple[str, None]: ...
13
+
14
+ def get_app(self, app_name: str) -> dict[str, Any]: ...
15
+
16
+ def get_app_events(self, app_name: str) -> list[dict[str, Any]]: ...
17
+
18
+ def get_app_roles(self, app_name: str) -> list[dict[str, Any]]: ...
19
+
20
+ def get_tables_for_app(self, app_name: str) -> list[dict[str, Any]]: ...
21
+
22
+ def get_table(self, app_name: str, table_name: str) -> dict[str, Any]: ...
23
+
24
+ def get_fields_for_table(self, app_name: str, table_name: str) -> list[dict[str, Any]]: ...
25
+
26
+ def get_all_relationships(self, app_name: str, table_name: str) -> list[dict[str, Any]]: ...
27
+
28
+ def get_reports_for_table(self, app_name: str, table_name: str) -> list[dict[str, Any]]: ...
29
+
30
+ def get_report(self, app_name: str, table_name: str, report_id: int) -> dict[str, Any]: ...
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class CapturedTable:
35
+ id: str
36
+ name: str
37
+ fields: tuple[dict[str, Any], ...]
38
+ artifacts: tuple[str, ...]
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class CapturedSchema:
43
+ app_id: str
44
+ app_name: str
45
+ tables: tuple[CapturedTable, ...]
46
+ artifacts: tuple[str, ...]
47
+
48
+
49
+ def _required_identifier(payload: dict[str, Any], key: str, resource: str) -> str:
50
+ value = payload.get(key)
51
+ if not isinstance(value, str) or not value:
52
+ raise ValueError(f"Quickbase {resource} is missing a valid {key}")
53
+ return value
54
+
55
+
56
+ def _report_id(summary: dict[str, Any], table_id: str) -> int:
57
+ value = summary.get("id")
58
+ if isinstance(value, int) and not isinstance(value, bool):
59
+ return value
60
+ if isinstance(value, str) and value.isdigit():
61
+ return int(value)
62
+ raise ValueError(f"Quickbase report in table {table_id} is missing a valid id")
63
+
64
+
65
+ def capture_schema(
66
+ client: SchemaClient,
67
+ app_name: str,
68
+ workspace: BackupWorkspace,
69
+ ) -> CapturedSchema:
70
+ """Capture raw application metadata and every table's schema artifacts."""
71
+ app_id, _ = client._ids(app_name)
72
+ app = client.get_app(app_name)
73
+ response_app_id = app.get("id")
74
+ if response_app_id is not None and response_app_id != app_id:
75
+ raise ValueError(f"Quickbase returned app {response_app_id!r} while backing up {app_id!r}")
76
+ resolved_app_name = _required_identifier(app, "name", "application")
77
+ events = client.get_app_events(app_name)
78
+ roles = client.get_app_roles(app_name)
79
+ table_summaries = client.get_tables_for_app(app_name)
80
+
81
+ artifact_paths = [
82
+ workspace.write_json("app.json", "application", app).path,
83
+ workspace.write_json("events.json", "events", events, item_count=len(events)).path,
84
+ workspace.write_json("roles.json", "roles", roles, item_count=len(roles)).path,
85
+ ]
86
+ captured_tables: list[CapturedTable] = []
87
+
88
+ for summary in table_summaries:
89
+ table_id = _required_identifier(summary, "id", "table summary")
90
+ table_name = _required_identifier(summary, "name", f"table {table_id}")
91
+ details = client.get_table(app_name, table_id)
92
+ response_table_id = details.get("id")
93
+ if response_table_id is not None and response_table_id != table_id:
94
+ raise ValueError(
95
+ f"Quickbase returned table {response_table_id!r} while backing up {table_id!r}"
96
+ )
97
+ fields = client.get_fields_for_table(app_name, table_id)
98
+ relationships = client.get_all_relationships(app_name, table_id)
99
+ report_summaries = client.get_reports_for_table(app_name, table_id)
100
+ reports = [
101
+ client.get_report(app_name, table_id, _report_id(report, table_id))
102
+ for report in report_summaries
103
+ ]
104
+
105
+ prefix = f"tables/{table_id}"
106
+ table_artifacts = (
107
+ workspace.write_json(
108
+ f"{prefix}/table.json",
109
+ "table",
110
+ {"summary": summary, "details": details},
111
+ ).path,
112
+ workspace.write_json(
113
+ f"{prefix}/fields.json", "fields", fields, item_count=len(fields)
114
+ ).path,
115
+ workspace.write_json(
116
+ f"{prefix}/relationships.json",
117
+ "relationships",
118
+ relationships,
119
+ item_count=len(relationships),
120
+ ).path,
121
+ workspace.write_json(
122
+ f"{prefix}/reports.json",
123
+ "reports",
124
+ {"summaries": report_summaries, "details": reports},
125
+ item_count=len(reports),
126
+ ).path,
127
+ )
128
+ artifact_paths.extend(table_artifacts)
129
+ captured_tables.append(
130
+ CapturedTable(
131
+ id=table_id,
132
+ name=table_name,
133
+ fields=tuple(fields),
134
+ artifacts=table_artifacts,
135
+ )
136
+ )
137
+
138
+ return CapturedSchema(
139
+ app_id=app_id,
140
+ app_name=resolved_app_name,
141
+ tables=tuple(captured_tables),
142
+ artifacts=tuple(artifact_paths),
143
+ )
@@ -0,0 +1,184 @@
1
+ """Atomic orchestration for complete application backups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ from datetime import UTC, datetime
9
+ from importlib.metadata import PackageNotFoundError, version
10
+ from pathlib import Path
11
+ from typing import Any, Protocol
12
+ from uuid import uuid4
13
+
14
+ from ..backup import ApplicationBackup, BackupManifest, BackupOptions, BackupTable
15
+ from ..exceptions import BackupConsistencyError, QuickbaseResponseError
16
+ from ..transport import QuickBaseTransport
17
+ from .attachments import AttachmentClient, capture_attachments
18
+ from .records import RecordClient, capture_records
19
+ from .schema import SchemaClient, capture_schema
20
+ from .workspace import BackupWorkspace
21
+
22
+
23
+ class BackupClient(SchemaClient, RecordClient, AttachmentClient, Protocol):
24
+ transport: QuickBaseTransport
25
+
26
+ def records_modified_since(
27
+ self,
28
+ app_name: str,
29
+ table_name: str,
30
+ after: datetime | str,
31
+ *,
32
+ field_list: Any = None,
33
+ include_details: bool = False,
34
+ ) -> dict[str, Any]: ...
35
+
36
+
37
+ def _timestamp(value: datetime) -> str:
38
+ return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
39
+
40
+
41
+ def _package_version() -> str:
42
+ try:
43
+ return version("qbvisor")
44
+ except PackageNotFoundError:
45
+ return "0+unknown"
46
+
47
+
48
+ def _changed_tables(
49
+ client: BackupClient,
50
+ app_name: str,
51
+ table_ids: tuple[str, ...],
52
+ started_at: str,
53
+ ) -> tuple[str, ...]:
54
+ changed: list[str] = []
55
+ for table_id in table_ids:
56
+ response = client.records_modified_since(app_name, table_id, started_at)
57
+ count = response.get("count")
58
+ if not isinstance(count, int) or isinstance(count, bool) or count < 0:
59
+ raise QuickbaseResponseError(
60
+ "POST",
61
+ "records/modifiedSince",
62
+ expected="non-negative integer count",
63
+ actual=type(count).__name__,
64
+ )
65
+ if count:
66
+ changed.append(table_id)
67
+ return tuple(changed)
68
+
69
+
70
+ def _write_manifest(root: Path, manifest: BackupManifest) -> None:
71
+ destination = root / "manifest.json"
72
+ content = (
73
+ json.dumps(
74
+ manifest.to_dict(),
75
+ ensure_ascii=False,
76
+ indent=2,
77
+ sort_keys=True,
78
+ allow_nan=False,
79
+ )
80
+ + "\n"
81
+ ).encode("utf-8")
82
+ temporary = destination.with_name(f".{destination.name}.{uuid4().hex}.tmp")
83
+ try:
84
+ temporary.write_bytes(content)
85
+ os.replace(temporary, destination)
86
+ finally:
87
+ temporary.unlink(missing_ok=True)
88
+
89
+
90
+ def create_application_backup(
91
+ client: BackupClient,
92
+ app_name: str,
93
+ output_dir: str | Path,
94
+ *,
95
+ options: BackupOptions,
96
+ ) -> ApplicationBackup:
97
+ """Capture, inventory, and atomically publish one complete application backup."""
98
+ if not isinstance(options, BackupOptions):
99
+ raise ValueError("options must be BackupOptions")
100
+ app_id, _ = client._ids(app_name)
101
+ if not app_id.isalnum():
102
+ raise ValueError(f"Quickbase returned an unsafe application ID: {app_id!r}")
103
+
104
+ started = datetime.now(UTC)
105
+ started_at = _timestamp(started)
106
+ snapshot_id = str(uuid4())
107
+ output_root = Path(output_dir).expanduser().resolve()
108
+ output_root.mkdir(parents=True, exist_ok=True)
109
+ staging = output_root / f".qbvisor-{snapshot_id}.tmp"
110
+ final = output_root / (f"{app_id}-{started.strftime('%Y%m%dT%H%M%SZ')}-{snapshot_id}")
111
+ staging.mkdir(exist_ok=False)
112
+ workspace = BackupWorkspace(staging)
113
+
114
+ try:
115
+ schema = capture_schema(client, app_name, workspace)
116
+ records = capture_records(
117
+ client,
118
+ schema,
119
+ workspace,
120
+ page_size=options.page_size,
121
+ )
122
+ attachments = capture_attachments(
123
+ client,
124
+ schema,
125
+ workspace,
126
+ mode=options.attachment_versions,
127
+ max_concurrency=options.max_attachment_concurrency,
128
+ )
129
+ table_ids = tuple(table.id for table in schema.tables)
130
+ changed_tables = _changed_tables(client, app_name, table_ids, started_at)
131
+ if changed_tables and options.fail_on_changes:
132
+ raise BackupConsistencyError(changed_tables)
133
+
134
+ records_by_table = {table.id: table for table in records.tables}
135
+ attachments_by_table = {table.id: table for table in attachments.tables}
136
+ artifacts = tuple(sorted(workspace.artifacts, key=lambda artifact: artifact.path))
137
+ backup_tables: list[BackupTable] = []
138
+ for table in schema.tables:
139
+ table_records = records_by_table[table.id]
140
+ table_attachments = attachments_by_table[table.id]
141
+ prefix = f"tables/{table.id}/"
142
+ table_artifacts = tuple(
143
+ artifact.path for artifact in artifacts if artifact.path.startswith(prefix)
144
+ )
145
+ backup_tables.append(
146
+ BackupTable(
147
+ id=table.id,
148
+ name=table.name,
149
+ record_count=table_records.record_count,
150
+ attachment_count=table_attachments.attachment_count,
151
+ artifacts=table_artifacts,
152
+ )
153
+ )
154
+
155
+ realm = client.transport.realm_hostname
156
+ if not isinstance(realm, str) or not realm:
157
+ raise ValueError("Quickbase transport does not have a realm hostname")
158
+ manifest = BackupManifest(
159
+ snapshot_id=snapshot_id,
160
+ source_realm=realm,
161
+ source_app_id=schema.app_id,
162
+ source_app_name=schema.app_name,
163
+ qbvisor_version=_package_version(),
164
+ started_at=started_at,
165
+ completed_at=_timestamp(datetime.now(UTC)),
166
+ options=options,
167
+ consistent=not changed_tables,
168
+ changed_tables=changed_tables,
169
+ tables=tuple(backup_tables),
170
+ artifacts=artifacts,
171
+ )
172
+ _write_manifest(staging, manifest)
173
+ if final.exists():
174
+ raise FileExistsError(f"Backup destination already exists: {final}")
175
+ os.replace(staging, final)
176
+ return ApplicationBackup(path=final, manifest=manifest)
177
+ except Exception as error:
178
+ try:
179
+ shutil.rmtree(staging)
180
+ except FileNotFoundError:
181
+ pass
182
+ except OSError as cleanup_error:
183
+ error.add_note(f"Could not remove incomplete backup {staging}: {cleanup_error}")
184
+ raise