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
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
|
+
]
|
qbvisor/_attachments.py
ADDED
|
@@ -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))
|