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 +141 -0
- qbvisor/_attachments.py +83 -0
- qbvisor/_backup/__init__.py +23 -0
- qbvisor/_backup/attachments.py +303 -0
- qbvisor/_backup/reader.py +263 -0
- qbvisor/_backup/records.py +83 -0
- qbvisor/_backup/schema.py +143 -0
- qbvisor/_backup/workflow.py +184 -0
- qbvisor/_backup/workspace.py +190 -0
- qbvisor/_pagination.py +165 -0
- qbvisor/_records/__init__.py +1 -0
- qbvisor/_records/pagination.py +186 -0
- qbvisor/_records/upsert.py +331 -0
- qbvisor/_resources/__init__.py +1 -0
- qbvisor/_resources/apps.py +87 -0
- qbvisor/_resources/base.py +84 -0
- qbvisor/_resources/fields.py +102 -0
- qbvisor/_resources/relationships.py +129 -0
- qbvisor/_resources/tables.py +93 -0
- qbvisor/_schema/__init__.py +1 -0
- qbvisor/_schema/apply.py +364 -0
- qbvisor/_schema/dependencies.py +122 -0
- qbvisor/_schema/planner.py +1191 -0
- qbvisor/_schema/relationship_apply.py +327 -0
- qbvisor/_schema/state.py +128 -0
- qbvisor/_version.py +8 -0
- qbvisor/async_transport.py +293 -0
- qbvisor/backup.py +436 -0
- qbvisor/client.py +1529 -0
- qbvisor/exceptions.py +144 -0
- qbvisor/helpers.py +51 -0
- qbvisor/log_runner.py +166 -0
- qbvisor/metadata.py +238 -0
- qbvisor/models.py +49 -0
- qbvisor/py.typed +1 -0
- qbvisor/query_helper.py +192 -0
- qbvisor/query_value.py +37 -0
- qbvisor/schema.py +845 -0
- qbvisor/transport.py +416 -0
- qbvisor-0.3.0.dist-info/METADATA +259 -0
- qbvisor-0.3.0.dist-info/RECORD +43 -0
- qbvisor-0.3.0.dist-info/WHEEL +4 -0
- qbvisor-0.3.0.dist-info/licenses/LICENSE.md +7 -0
|
@@ -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
|