matelab-python-sdk 0.1.0a1__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.
- matelab/__init__.py +372 -0
- matelab/_generated/__init__.py +1 -0
- matelab/_generated/models.py +3553 -0
- matelab/_transport.py +481 -0
- matelab/client.py +191 -0
- matelab/cloud_drive.py +789 -0
- matelab/errors.py +38 -0
- matelab/groups.py +78 -0
- matelab/literature.py +1243 -0
- matelab/models.py +27 -0
- matelab/notebooks.py +467 -0
- matelab/py.typed +1 -0
- matelab/records.py +2511 -0
- matelab/streaming.py +82 -0
- matelab/templates.py +737 -0
- matelab/uploads.py +247 -0
- matelab/users.py +77 -0
- matelab_python_sdk-0.1.0a1.dist-info/METADATA +538 -0
- matelab_python_sdk-0.1.0a1.dist-info/RECORD +22 -0
- matelab_python_sdk-0.1.0a1.dist-info/WHEEL +4 -0
- matelab_python_sdk-0.1.0a1.dist-info/licenses/LICENSE +202 -0
- matelab_python_sdk-0.1.0a1.dist-info/licenses/NOTICE +2 -0
matelab/cloud_drive.py
ADDED
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Literal, TypeAlias, cast
|
|
9
|
+
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from matelab._generated.models import BasicSuccessResponse as WireBasicSuccessResponse
|
|
13
|
+
from matelab._generated.models import BindCloudDriveFileRequest as WireBindCloudDriveFileRequest
|
|
14
|
+
from matelab._generated.models import CloudDriveFile as WireCloudDriveFile
|
|
15
|
+
from matelab._generated.models import CloudDriveFileIdList as WireCloudDriveFileIdList
|
|
16
|
+
from matelab._generated.models import CloudDriveFileListResponse as WireCloudDriveFileListResponse
|
|
17
|
+
from matelab._generated.models import CloudDriveFolder as WireCloudDriveFolder
|
|
18
|
+
from matelab._generated.models import CloudDriveFolderIdRequest as WireCloudDriveFolderIdRequest
|
|
19
|
+
from matelab._generated.models import CloudDriveIdentityResponse as WireCloudDriveIdentityResponse
|
|
20
|
+
from matelab._generated.models import CloudDriveRootPermissions as WireCloudDriveRootPermissions
|
|
21
|
+
from matelab._generated.models import DeleteCloudDriveFilesRequest as WireDeleteCloudDriveFilesRequest
|
|
22
|
+
from matelab._generated.models import DownloadCloudDriveFileParametersQuery as WireDownloadCloudDriveFileQuery
|
|
23
|
+
from matelab._generated.models import EditCloudDriveFileRequest as WireEditCloudDriveFileRequest
|
|
24
|
+
from matelab._generated.models import ListCloudDriveFilesParametersQuery as WireListCloudDriveFilesQuery
|
|
25
|
+
from matelab._generated.models import MoveCloudDriveFolderRequest as WireMoveCloudDriveFolderRequest
|
|
26
|
+
from matelab._generated.models import SaveCloudDriveFolderRequest as WireSaveCloudDriveFolderRequest
|
|
27
|
+
from matelab._generated.models import TransferCloudDriveFilesRequest as WireTransferCloudDriveFilesRequest
|
|
28
|
+
from matelab._transport import Encoding, Operation, SessionTransport, StreamOperation, multipart_fields
|
|
29
|
+
from matelab.errors import MatelabError, MatelabProtocolError, MatelabUsageError, MatelabVerificationError
|
|
30
|
+
from matelab.streaming import ByteRange, DownloadStream
|
|
31
|
+
from matelab.uploads import StagedFile
|
|
32
|
+
from matelab.users import UserRef
|
|
33
|
+
|
|
34
|
+
_LIST_FILES = Operation(
|
|
35
|
+
method="GET",
|
|
36
|
+
path="/files/files",
|
|
37
|
+
encoding=Encoding.QUERY,
|
|
38
|
+
response_model=WireCloudDriveFileListResponse,
|
|
39
|
+
success_codes=frozenset({0}),
|
|
40
|
+
retry_on_access_expired=True,
|
|
41
|
+
)
|
|
42
|
+
_SAVE_FOLDER = Operation(
|
|
43
|
+
method="POST",
|
|
44
|
+
path="/files/filetype_edit",
|
|
45
|
+
encoding=Encoding.MULTIPART,
|
|
46
|
+
response_model=WireCloudDriveIdentityResponse,
|
|
47
|
+
success_codes=frozenset({0}),
|
|
48
|
+
)
|
|
49
|
+
_DELETE_FOLDER = Operation(
|
|
50
|
+
method="POST",
|
|
51
|
+
path="/files/filetype_del",
|
|
52
|
+
encoding=Encoding.MULTIPART,
|
|
53
|
+
response_model=WireBasicSuccessResponse,
|
|
54
|
+
success_codes=frozenset({0}),
|
|
55
|
+
)
|
|
56
|
+
_MOVE_FOLDER = Operation(
|
|
57
|
+
method="POST",
|
|
58
|
+
path="/files/filetype_move",
|
|
59
|
+
encoding=Encoding.MULTIPART,
|
|
60
|
+
response_model=WireBasicSuccessResponse,
|
|
61
|
+
success_codes=frozenset({0}),
|
|
62
|
+
)
|
|
63
|
+
_BIND_STAGED_FILE = Operation(
|
|
64
|
+
method="POST",
|
|
65
|
+
path="/files/file_add",
|
|
66
|
+
encoding=Encoding.MULTIPART,
|
|
67
|
+
response_model=WireBasicSuccessResponse,
|
|
68
|
+
success_codes=frozenset({0}),
|
|
69
|
+
)
|
|
70
|
+
_EDIT_FILE = Operation(
|
|
71
|
+
method="POST",
|
|
72
|
+
path="/files/file_edit",
|
|
73
|
+
encoding=Encoding.MULTIPART,
|
|
74
|
+
response_model=WireBasicSuccessResponse,
|
|
75
|
+
success_codes=frozenset({0}),
|
|
76
|
+
)
|
|
77
|
+
_DELETE_FILES = Operation(
|
|
78
|
+
method="POST",
|
|
79
|
+
path="/files/file_del",
|
|
80
|
+
encoding=Encoding.MULTIPART,
|
|
81
|
+
response_model=WireBasicSuccessResponse,
|
|
82
|
+
success_codes=frozenset({0}),
|
|
83
|
+
)
|
|
84
|
+
_MOVE_FILES = Operation(
|
|
85
|
+
method="POST",
|
|
86
|
+
path="/files/move",
|
|
87
|
+
encoding=Encoding.MULTIPART,
|
|
88
|
+
response_model=WireBasicSuccessResponse,
|
|
89
|
+
success_codes=frozenset({0}),
|
|
90
|
+
)
|
|
91
|
+
_DOWNLOAD_FILE = StreamOperation(method="GET", path="/files/url", retry_on_access_expired=True)
|
|
92
|
+
_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _empty_mapping() -> Mapping[str, object]:
|
|
96
|
+
return MappingProxyType({})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CloudDriveOrder(StrEnum):
|
|
100
|
+
MODIFIED_AT = "modified_at"
|
|
101
|
+
FILENAME = "filename"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class CloudDriveRootRef:
|
|
106
|
+
"""The caller's synthetic personal cloud-drive root."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class CloudFolderRef:
|
|
111
|
+
cloud_folder_id: int
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
CloudDriveLocation: TypeAlias = CloudDriveRootRef | CloudFolderRef
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class CloudFileRef:
|
|
119
|
+
cloud_file_id: int
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class CloudDriveRootPermissions:
|
|
124
|
+
permission_mask: int
|
|
125
|
+
can_create_folder: bool
|
|
126
|
+
can_delete_folder: bool
|
|
127
|
+
can_edit_folder: bool
|
|
128
|
+
can_create_file: bool
|
|
129
|
+
can_delete_file: bool
|
|
130
|
+
can_view_file: bool
|
|
131
|
+
can_edit_file: bool
|
|
132
|
+
attributes: Mapping[str, object] = field(default_factory=_empty_mapping)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class CloudDriveRoot:
|
|
137
|
+
ref: CloudDriveRootRef
|
|
138
|
+
name: str
|
|
139
|
+
description: str
|
|
140
|
+
permissions: CloudDriveRootPermissions
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True)
|
|
144
|
+
class CloudFolder:
|
|
145
|
+
ref: CloudFolderRef
|
|
146
|
+
name: str
|
|
147
|
+
parent: CloudDriveLocation
|
|
148
|
+
direct_file_count: int
|
|
149
|
+
description: str | None
|
|
150
|
+
attributes: Mapping[str, object] = field(default_factory=_empty_mapping)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True, slots=True)
|
|
154
|
+
class CloudFile:
|
|
155
|
+
ref: CloudFileRef
|
|
156
|
+
location: CloudDriveLocation | None
|
|
157
|
+
filename: str
|
|
158
|
+
sha256: str = field(repr=False)
|
|
159
|
+
size: int
|
|
160
|
+
owner: UserRef
|
|
161
|
+
owner_name: str | None
|
|
162
|
+
modified_at: str | None
|
|
163
|
+
description: str
|
|
164
|
+
display_date: str
|
|
165
|
+
display_time: str
|
|
166
|
+
attributes: Mapping[str, object] = field(default_factory=_empty_mapping)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True, slots=True)
|
|
170
|
+
class CloudDriveQuota:
|
|
171
|
+
quota_gib: int | None
|
|
172
|
+
used_bytes: int
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def unlimited(self) -> bool:
|
|
176
|
+
return self.quota_gib is None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True, slots=True)
|
|
180
|
+
class CloudFolderTree:
|
|
181
|
+
folders: tuple[CloudFolder, ...]
|
|
182
|
+
ordering: Literal["provider_unspecified"] = "provider_unspecified"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class CloudFilePage:
|
|
187
|
+
files: tuple[CloudFile, ...]
|
|
188
|
+
folder: CloudDriveLocation | None
|
|
189
|
+
search: str
|
|
190
|
+
scope: Literal["selected_folder", "search_across_personal_root"]
|
|
191
|
+
page: int
|
|
192
|
+
requested_page_size: int
|
|
193
|
+
effective_page_size: int
|
|
194
|
+
total_count: int
|
|
195
|
+
has_more: bool
|
|
196
|
+
order: CloudDriveOrder
|
|
197
|
+
descending: bool
|
|
198
|
+
ordering: Literal["provider_selected_without_stable_id_tiebreaker"] = (
|
|
199
|
+
"provider_selected_without_stable_id_tiebreaker"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True, slots=True)
|
|
204
|
+
class CloudDriveListing:
|
|
205
|
+
file_page: CloudFilePage
|
|
206
|
+
folder_tree: CloudFolderTree
|
|
207
|
+
quota: CloudDriveQuota
|
|
208
|
+
root: CloudDriveRoot
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True, slots=True)
|
|
212
|
+
class CloudFolderMetadata:
|
|
213
|
+
name: str
|
|
214
|
+
description: str = ""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True, slots=True)
|
|
218
|
+
class CloudFolderSaveResult:
|
|
219
|
+
folder: CloudFolderRef
|
|
220
|
+
requested: CloudFolderMetadata
|
|
221
|
+
action: Literal["created", "updated"]
|
|
222
|
+
observed: CloudFolder | None
|
|
223
|
+
confirmation: Literal["observed_matching", "observed_mismatch", "not_observed"]
|
|
224
|
+
automatic_retry: Literal[False] = False
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(frozen=True, slots=True)
|
|
228
|
+
class CloudFolderMoveResult:
|
|
229
|
+
folder: CloudFolderRef
|
|
230
|
+
target: CloudDriveLocation
|
|
231
|
+
observed: CloudFolder | None
|
|
232
|
+
confirmation: Literal["observed_in_target", "observed_elsewhere", "not_observed"]
|
|
233
|
+
automatic_retry: Literal[False] = False
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True, slots=True)
|
|
237
|
+
class CloudFolderDeleteResult:
|
|
238
|
+
folder: CloudFolderRef
|
|
239
|
+
absence_confirmed: bool
|
|
240
|
+
verification: Literal["personal_folder_tree_snapshot"] = "personal_folder_tree_snapshot"
|
|
241
|
+
recoverable: Literal[False] = False
|
|
242
|
+
automatic_retry: Literal[False] = False
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass(frozen=True, slots=True)
|
|
246
|
+
class CloudFileBindResult:
|
|
247
|
+
staged_file: StagedFile
|
|
248
|
+
target: CloudDriveLocation
|
|
249
|
+
observed: CloudFile | None
|
|
250
|
+
cloud_file: CloudFileRef | None
|
|
251
|
+
confirmation: Literal["uniquely_observed", "not_observed", "ambiguous_observation"]
|
|
252
|
+
staging_owner_verification: Literal["provider_does_not_verify_uploader"] = "provider_does_not_verify_uploader"
|
|
253
|
+
finalize_atomicity: Literal["not_contractually_guaranteed"] = "not_contractually_guaranteed"
|
|
254
|
+
automatic_retry: Literal[False] = False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass(frozen=True, slots=True)
|
|
258
|
+
class CloudFileUpdateResult:
|
|
259
|
+
file: CloudFileRef
|
|
260
|
+
requested_filename: str
|
|
261
|
+
requested_description: str
|
|
262
|
+
observed: CloudFile | None
|
|
263
|
+
confirmation: Literal["observed_matching", "observed_mismatch", "not_observed"]
|
|
264
|
+
automatic_retry: Literal[False] = False
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass(frozen=True, slots=True)
|
|
268
|
+
class CloudFileMoveResult:
|
|
269
|
+
files: tuple[CloudFileRef, ...]
|
|
270
|
+
target: CloudDriveLocation
|
|
271
|
+
observed_in_target: tuple[CloudFileRef, ...]
|
|
272
|
+
not_observed_in_target: tuple[CloudFileRef, ...]
|
|
273
|
+
provider_item_results: None = None
|
|
274
|
+
atomicity: Literal["provider_does_not_report_or_guarantee_batch_atomicity"] = (
|
|
275
|
+
"provider_does_not_report_or_guarantee_batch_atomicity"
|
|
276
|
+
)
|
|
277
|
+
automatic_retry: Literal[False] = False
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True, slots=True)
|
|
281
|
+
class CloudFilePermanentDeleteResult:
|
|
282
|
+
files: tuple[CloudFileRef, ...]
|
|
283
|
+
confirmed_absent: tuple[CloudFileRef, ...]
|
|
284
|
+
still_visible: tuple[CloudFileRef, ...]
|
|
285
|
+
provider_item_results: None = None
|
|
286
|
+
verification: Literal["post_delete_snapshots"] = "post_delete_snapshots"
|
|
287
|
+
atomicity: Literal["provider_does_not_report_or_guarantee_batch_atomicity"] = (
|
|
288
|
+
"provider_does_not_report_or_guarantee_batch_atomicity"
|
|
289
|
+
)
|
|
290
|
+
recoverable: Literal[False] = False
|
|
291
|
+
automatic_retry: Literal[False] = False
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class CloudDrive:
|
|
295
|
+
"""Personal cloud-drive browsing, lifecycle, staging finalization, and streaming."""
|
|
296
|
+
|
|
297
|
+
def __init__(self, transport: SessionTransport) -> None:
|
|
298
|
+
self._transport: SessionTransport = transport
|
|
299
|
+
|
|
300
|
+
async def list(
|
|
301
|
+
self,
|
|
302
|
+
folder: CloudDriveLocation | None = None,
|
|
303
|
+
*,
|
|
304
|
+
page: int = 1,
|
|
305
|
+
page_size: int = 20,
|
|
306
|
+
order: CloudDriveOrder = CloudDriveOrder.MODIFIED_AT,
|
|
307
|
+
descending: bool = True,
|
|
308
|
+
search: str = "",
|
|
309
|
+
) -> CloudDriveListing:
|
|
310
|
+
selected = folder or CloudDriveRootRef()
|
|
311
|
+
folder_id = self._location_id(selected)
|
|
312
|
+
try:
|
|
313
|
+
selected_order = CloudDriveOrder(order)
|
|
314
|
+
except (TypeError, ValueError):
|
|
315
|
+
raise MatelabUsageError("Cloud-drive order must be modified_at or filename.") from None
|
|
316
|
+
if type(cast(object, descending)) is not bool:
|
|
317
|
+
raise MatelabUsageError("Cloud-drive descending selection must be a boolean.")
|
|
318
|
+
try:
|
|
319
|
+
request = WireListCloudDriveFilesQuery.model_validate(
|
|
320
|
+
{
|
|
321
|
+
"type_id": folder_id,
|
|
322
|
+
"root_id": 0,
|
|
323
|
+
"page": page,
|
|
324
|
+
"page_size": page_size,
|
|
325
|
+
"order_by": 0 if selected_order is CloudDriveOrder.MODIFIED_AT else 1,
|
|
326
|
+
"order_desc": int(descending),
|
|
327
|
+
"search": search,
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
except ValidationError:
|
|
331
|
+
raise MatelabUsageError("Cloud-drive list input does not satisfy the Integration Contract.") from None
|
|
332
|
+
response = await self._transport.request(_LIST_FILES, payload=request.model_dump(mode="json"))
|
|
333
|
+
return self._listing(
|
|
334
|
+
response,
|
|
335
|
+
selected=selected,
|
|
336
|
+
page=request.page,
|
|
337
|
+
page_size=request.page_size,
|
|
338
|
+
order=selected_order,
|
|
339
|
+
descending=descending,
|
|
340
|
+
search=request.search,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
async def create_folder(
|
|
344
|
+
self, metadata: CloudFolderMetadata, *, parent: CloudDriveLocation | None = None
|
|
345
|
+
) -> CloudFolderSaveResult:
|
|
346
|
+
selected_parent = parent or CloudDriveRootRef()
|
|
347
|
+
request = self._folder_request(metadata, folder=None, parent=selected_parent)
|
|
348
|
+
response = await self._transport.request(_SAVE_FOLDER, files=multipart_fields(request.model_dump(mode="json")))
|
|
349
|
+
folder = CloudFolderRef(cloud_folder_id=response.id)
|
|
350
|
+
observed = self._find_folder((await self._verified_list("cloud-drive folder creation")).folder_tree, folder)
|
|
351
|
+
return CloudFolderSaveResult(
|
|
352
|
+
folder=folder,
|
|
353
|
+
requested=metadata,
|
|
354
|
+
action="created",
|
|
355
|
+
observed=observed,
|
|
356
|
+
confirmation=self._folder_save_confirmation(observed, metadata, selected_parent),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
async def update_folder(self, folder: CloudFolderRef, metadata: CloudFolderMetadata) -> CloudFolderSaveResult:
|
|
360
|
+
request = self._folder_request(metadata, folder=folder, parent=CloudDriveRootRef())
|
|
361
|
+
response = await self._transport.request(_SAVE_FOLDER, files=multipart_fields(request.model_dump(mode="json")))
|
|
362
|
+
if response.id != folder.cloud_folder_id:
|
|
363
|
+
raise MatelabProtocolError("Matelab returned a different folder identity for a cloud-drive update.")
|
|
364
|
+
observed = self._find_folder((await self._verified_list("cloud-drive folder update")).folder_tree, folder)
|
|
365
|
+
if observed is None:
|
|
366
|
+
confirmation: Literal["observed_matching", "observed_mismatch", "not_observed"] = "not_observed"
|
|
367
|
+
elif observed.name == metadata.name and (observed.description or "") == metadata.description:
|
|
368
|
+
confirmation = "observed_matching"
|
|
369
|
+
else:
|
|
370
|
+
confirmation = "observed_mismatch"
|
|
371
|
+
return CloudFolderSaveResult(
|
|
372
|
+
folder=folder, requested=metadata, action="updated", observed=observed, confirmation=confirmation
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
async def move_folder(
|
|
376
|
+
self, folder: CloudFolderRef, target: CloudDriveLocation | None = None
|
|
377
|
+
) -> CloudFolderMoveResult:
|
|
378
|
+
selected_target = target or CloudDriveRootRef()
|
|
379
|
+
try:
|
|
380
|
+
request = WireMoveCloudDriveFolderRequest.model_validate(
|
|
381
|
+
{"id": self._folder_id(folder), "target": self._location_id(selected_target)}
|
|
382
|
+
)
|
|
383
|
+
except ValidationError:
|
|
384
|
+
raise MatelabUsageError("Cloud-drive folder move does not satisfy the Integration Contract.") from None
|
|
385
|
+
_ = await self._transport.request(_MOVE_FOLDER, files=multipart_fields(request.model_dump(mode="json")))
|
|
386
|
+
observed = self._find_folder((await self._verified_list("cloud-drive folder move")).folder_tree, folder)
|
|
387
|
+
if observed is None:
|
|
388
|
+
confirmation: Literal["observed_in_target", "observed_elsewhere", "not_observed"] = "not_observed"
|
|
389
|
+
elif observed.parent == selected_target:
|
|
390
|
+
confirmation = "observed_in_target"
|
|
391
|
+
else:
|
|
392
|
+
confirmation = "observed_elsewhere"
|
|
393
|
+
return CloudFolderMoveResult(
|
|
394
|
+
folder=folder, target=selected_target, observed=observed, confirmation=confirmation
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
async def delete_folder(self, folder: CloudFolderRef) -> CloudFolderDeleteResult:
|
|
398
|
+
try:
|
|
399
|
+
request = WireCloudDriveFolderIdRequest(id=self._folder_id(folder))
|
|
400
|
+
except ValidationError:
|
|
401
|
+
raise MatelabUsageError("Cloud-drive folder identity does not satisfy the Integration Contract.") from None
|
|
402
|
+
_ = await self._transport.request(_DELETE_FOLDER, files=multipart_fields(request.model_dump(mode="json")))
|
|
403
|
+
observed = self._find_folder((await self._verified_list("cloud-drive folder deletion")).folder_tree, folder)
|
|
404
|
+
return CloudFolderDeleteResult(folder=folder, absence_confirmed=observed is None)
|
|
405
|
+
|
|
406
|
+
async def bind_staged_file(
|
|
407
|
+
self, staged_file: StagedFile, *, target: CloudDriveLocation | None = None
|
|
408
|
+
) -> CloudFileBindResult:
|
|
409
|
+
self._validate_staged_file(staged_file)
|
|
410
|
+
selected_target = target or CloudDriveRootRef()
|
|
411
|
+
try:
|
|
412
|
+
request = WireBindCloudDriveFileRequest.model_validate(
|
|
413
|
+
{"file_id": staged_file.temporary_file_id, "type_id": self._location_id(selected_target), "root_id": 0}
|
|
414
|
+
)
|
|
415
|
+
except ValidationError:
|
|
416
|
+
raise MatelabUsageError(
|
|
417
|
+
"Cloud-drive staged-file binding does not satisfy the Integration Contract."
|
|
418
|
+
) from None
|
|
419
|
+
_ = await self._transport.request(_BIND_STAGED_FILE, files=multipart_fields(request.model_dump(mode="json")))
|
|
420
|
+
files = await self._verified_folder_files(selected_target, "cloud-drive staged-file binding")
|
|
421
|
+
matches = tuple(
|
|
422
|
+
item
|
|
423
|
+
for item in files
|
|
424
|
+
if item.filename == staged_file.filename
|
|
425
|
+
and item.sha256 == staged_file.sha256
|
|
426
|
+
and item.size == staged_file.size
|
|
427
|
+
)
|
|
428
|
+
if len(matches) == 1:
|
|
429
|
+
confirmation: Literal["uniquely_observed", "not_observed", "ambiguous_observation"] = "uniquely_observed"
|
|
430
|
+
observed = matches[0]
|
|
431
|
+
elif matches:
|
|
432
|
+
confirmation = "ambiguous_observation"
|
|
433
|
+
observed = None
|
|
434
|
+
else:
|
|
435
|
+
confirmation = "not_observed"
|
|
436
|
+
observed = None
|
|
437
|
+
return CloudFileBindResult(
|
|
438
|
+
staged_file=staged_file,
|
|
439
|
+
target=selected_target,
|
|
440
|
+
observed=observed,
|
|
441
|
+
cloud_file=observed.ref if observed is not None else None,
|
|
442
|
+
confirmation=confirmation,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
async def update_file(self, file: CloudFile, *, filename: str, description: str = "") -> CloudFileUpdateResult:
|
|
446
|
+
self._validate_observed_file(file)
|
|
447
|
+
if self._extension(file.filename) != self._extension(filename):
|
|
448
|
+
raise MatelabUsageError("Cloud-drive file rename must preserve the existing filename extension.")
|
|
449
|
+
try:
|
|
450
|
+
request = WireEditCloudDriveFileRequest.model_validate(
|
|
451
|
+
{"id": file.ref.cloud_file_id, "filename": filename, "comm": description}
|
|
452
|
+
)
|
|
453
|
+
except ValidationError:
|
|
454
|
+
raise MatelabUsageError("Cloud-drive file metadata does not satisfy the Integration Contract.") from None
|
|
455
|
+
_ = await self._transport.request(_EDIT_FILE, files=multipart_fields(request.model_dump(mode="json")))
|
|
456
|
+
observed = await self._verified_locate_file(
|
|
457
|
+
file.ref, location=file.location, search=filename, action="cloud-drive file update"
|
|
458
|
+
)
|
|
459
|
+
if observed is None:
|
|
460
|
+
confirmation: Literal["observed_matching", "observed_mismatch", "not_observed"] = "not_observed"
|
|
461
|
+
elif (
|
|
462
|
+
observed.filename == filename
|
|
463
|
+
and observed.description == description
|
|
464
|
+
and observed.sha256 == file.sha256
|
|
465
|
+
and observed.size == file.size
|
|
466
|
+
):
|
|
467
|
+
confirmation = "observed_matching"
|
|
468
|
+
else:
|
|
469
|
+
confirmation = "observed_mismatch"
|
|
470
|
+
return CloudFileUpdateResult(
|
|
471
|
+
file=file.ref,
|
|
472
|
+
requested_filename=filename,
|
|
473
|
+
requested_description=description,
|
|
474
|
+
observed=observed,
|
|
475
|
+
confirmation=confirmation,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
async def move_files(
|
|
479
|
+
self, files: Sequence[CloudFileRef], *, target: CloudDriveLocation | None = None
|
|
480
|
+
) -> CloudFileMoveResult:
|
|
481
|
+
selected = self._unique_file_refs(files)
|
|
482
|
+
selected_target = target or CloudDriveRootRef()
|
|
483
|
+
encoded = self._wire_file_ids(selected)
|
|
484
|
+
try:
|
|
485
|
+
request = WireTransferCloudDriveFilesRequest(
|
|
486
|
+
files=encoded, target=self._location_id(selected_target), root_id=0
|
|
487
|
+
)
|
|
488
|
+
except ValidationError:
|
|
489
|
+
raise MatelabUsageError("Cloud-drive file move does not satisfy the Integration Contract.") from None
|
|
490
|
+
_ = await self._transport.request(_MOVE_FILES, files=multipart_fields(request.model_dump(mode="json")))
|
|
491
|
+
after = await self._verified_folder_files(selected_target, "cloud-drive file move")
|
|
492
|
+
observed_ids = {item.ref.cloud_file_id for item in after}
|
|
493
|
+
observed = tuple(item for item in selected if item.cloud_file_id in observed_ids)
|
|
494
|
+
missing = tuple(item for item in selected if item.cloud_file_id not in observed_ids)
|
|
495
|
+
return CloudFileMoveResult(
|
|
496
|
+
files=selected, target=selected_target, observed_in_target=observed, not_observed_in_target=missing
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
async def permanently_delete_files(self, files: Sequence[CloudFile]) -> CloudFilePermanentDeleteResult:
|
|
500
|
+
selected_files = tuple(files)
|
|
501
|
+
for item in selected_files:
|
|
502
|
+
self._validate_observed_file(item)
|
|
503
|
+
refs = self._unique_file_refs(tuple(item.ref for item in selected_files))
|
|
504
|
+
if len(selected_files) != len(refs):
|
|
505
|
+
raise MatelabUsageError("Cloud-drive deletion requires distinct observed files.")
|
|
506
|
+
try:
|
|
507
|
+
request = WireDeleteCloudDriveFilesRequest(ids=self._wire_file_ids(refs))
|
|
508
|
+
except ValidationError:
|
|
509
|
+
raise MatelabUsageError("Cloud-drive file deletion does not satisfy the Integration Contract.") from None
|
|
510
|
+
_ = await self._transport.request(_DELETE_FILES, files=multipart_fields(request.model_dump(mode="json")))
|
|
511
|
+
still_visible: list[CloudFileRef] = []
|
|
512
|
+
for item in selected_files:
|
|
513
|
+
observed = await self._verified_locate_file(
|
|
514
|
+
item.ref, location=item.location, search=item.filename, action="cloud-drive permanent file deletion"
|
|
515
|
+
)
|
|
516
|
+
if observed is not None:
|
|
517
|
+
still_visible.append(item.ref)
|
|
518
|
+
visible_ids = {item.cloud_file_id for item in still_visible}
|
|
519
|
+
return CloudFilePermanentDeleteResult(
|
|
520
|
+
files=refs,
|
|
521
|
+
confirmed_absent=tuple(item for item in refs if item.cloud_file_id not in visible_ids),
|
|
522
|
+
still_visible=tuple(still_visible),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
async def download(
|
|
526
|
+
self, file: CloudFileRef, *, thumbnail: bool = False, preview: bool = True, byte_range: ByteRange | None = None
|
|
527
|
+
) -> DownloadStream:
|
|
528
|
+
if type(cast(object, thumbnail)) is not bool or type(cast(object, preview)) is not bool:
|
|
529
|
+
raise MatelabUsageError("Cloud-drive thumbnail and preview selections must be booleans.")
|
|
530
|
+
try:
|
|
531
|
+
request = WireDownloadCloudDriveFileQuery.model_validate(
|
|
532
|
+
{"id": self._file_id(file), "thumb": int(thumbnail), "c": int(preview)}
|
|
533
|
+
)
|
|
534
|
+
except ValidationError:
|
|
535
|
+
raise MatelabUsageError("Cloud-drive file identity does not satisfy the Integration Contract.") from None
|
|
536
|
+
return await self._transport.stream(
|
|
537
|
+
_DOWNLOAD_FILE,
|
|
538
|
+
payload=request.model_dump(mode="json"),
|
|
539
|
+
range_header=byte_range.to_header() if byte_range is not None else None,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
async def _verified_list(self, action: str) -> CloudDriveListing:
|
|
543
|
+
try:
|
|
544
|
+
return await self.list()
|
|
545
|
+
except MatelabError as exc:
|
|
546
|
+
raise MatelabVerificationError(
|
|
547
|
+
f"Matelab acknowledged the {action}, but cloud-drive readback failed; the mutation may have persisted."
|
|
548
|
+
) from exc
|
|
549
|
+
|
|
550
|
+
async def _verified_folder_files(self, location: CloudDriveLocation, action: str) -> tuple[CloudFile, ...]:
|
|
551
|
+
try:
|
|
552
|
+
return await self._folder_files(location)
|
|
553
|
+
except MatelabError as exc:
|
|
554
|
+
raise MatelabVerificationError(
|
|
555
|
+
f"Matelab acknowledged the {action}, but target-folder readback failed; the mutation may have persisted."
|
|
556
|
+
) from exc
|
|
557
|
+
|
|
558
|
+
async def _verified_locate_file(
|
|
559
|
+
self, ref: CloudFileRef, *, location: CloudDriveLocation | None, search: str, action: str
|
|
560
|
+
) -> CloudFile | None:
|
|
561
|
+
try:
|
|
562
|
+
candidates = (
|
|
563
|
+
await self._folder_files(location) if location is not None else await self._search_files(search)
|
|
564
|
+
)
|
|
565
|
+
return next((item for item in candidates if item.ref == ref), None)
|
|
566
|
+
except MatelabError as exc:
|
|
567
|
+
raise MatelabVerificationError(
|
|
568
|
+
f"Matelab acknowledged the {action}, but file readback failed; the mutation may have persisted."
|
|
569
|
+
) from exc
|
|
570
|
+
|
|
571
|
+
async def _folder_files(self, location: CloudDriveLocation) -> tuple[CloudFile, ...]:
|
|
572
|
+
first = await self.list(location, page=1, page_size=256)
|
|
573
|
+
files = list(first.file_page.files)
|
|
574
|
+
for page_number in range(2, math.ceil(first.file_page.total_count / first.file_page.effective_page_size) + 1):
|
|
575
|
+
page = await self.list(location, page=page_number, page_size=first.file_page.effective_page_size)
|
|
576
|
+
files.extend(page.file_page.files)
|
|
577
|
+
return tuple(files)
|
|
578
|
+
|
|
579
|
+
async def _search_files(self, search: str) -> tuple[CloudFile, ...]:
|
|
580
|
+
first = await self.list(page=1, page_size=256, search=search)
|
|
581
|
+
files = list(first.file_page.files)
|
|
582
|
+
for page_number in range(2, math.ceil(first.file_page.total_count / first.file_page.effective_page_size) + 1):
|
|
583
|
+
page = await self.list(page=page_number, page_size=first.file_page.effective_page_size, search=search)
|
|
584
|
+
files.extend(page.file_page.files)
|
|
585
|
+
return tuple(files)
|
|
586
|
+
|
|
587
|
+
@staticmethod
|
|
588
|
+
def _listing(
|
|
589
|
+
response: WireCloudDriveFileListResponse,
|
|
590
|
+
*,
|
|
591
|
+
selected: CloudDriveLocation,
|
|
592
|
+
page: int,
|
|
593
|
+
page_size: int,
|
|
594
|
+
order: CloudDriveOrder,
|
|
595
|
+
descending: bool,
|
|
596
|
+
search: str,
|
|
597
|
+
) -> CloudDriveListing:
|
|
598
|
+
normalized_page = max(1, page)
|
|
599
|
+
effective_page_size = max(10, page_size)
|
|
600
|
+
file_location = None if search else selected
|
|
601
|
+
return CloudDriveListing(
|
|
602
|
+
file_page=CloudFilePage(
|
|
603
|
+
files=tuple(CloudDrive._file(item, file_location) for item in response.files),
|
|
604
|
+
folder=file_location,
|
|
605
|
+
search=search,
|
|
606
|
+
scope="search_across_personal_root" if search else "selected_folder",
|
|
607
|
+
page=normalized_page,
|
|
608
|
+
requested_page_size=page_size,
|
|
609
|
+
effective_page_size=effective_page_size,
|
|
610
|
+
total_count=response.count,
|
|
611
|
+
has_more=normalized_page * effective_page_size < response.count,
|
|
612
|
+
order=order,
|
|
613
|
+
descending=descending,
|
|
614
|
+
),
|
|
615
|
+
folder_tree=CloudFolderTree(folders=tuple(CloudDrive._folder(item) for item in response.types)),
|
|
616
|
+
quota=CloudDriveQuota(quota_gib=response.quota or None, used_bytes=response.used),
|
|
617
|
+
root=CloudDrive._root(response.root_type),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
@staticmethod
|
|
621
|
+
def _file(item: WireCloudDriveFile, location: CloudDriveLocation | None) -> CloudFile:
|
|
622
|
+
return CloudFile(
|
|
623
|
+
ref=CloudFileRef(cloud_file_id=item.id),
|
|
624
|
+
location=location,
|
|
625
|
+
filename=item.filename,
|
|
626
|
+
sha256=item.hash.root,
|
|
627
|
+
size=int(item.size),
|
|
628
|
+
owner=UserRef(userid=item.userid),
|
|
629
|
+
owner_name=item.username,
|
|
630
|
+
modified_at=item.date_time,
|
|
631
|
+
description=item.comm,
|
|
632
|
+
display_date=item.date,
|
|
633
|
+
display_time=item.time,
|
|
634
|
+
attributes=MappingProxyType(cast(dict[str, object], dict(item.model_extra or {}))),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
@staticmethod
|
|
638
|
+
def _folder(item: WireCloudDriveFolder) -> CloudFolder:
|
|
639
|
+
parent: CloudDriveLocation = (
|
|
640
|
+
CloudDriveRootRef() if item.parent == 0 else CloudFolderRef(cloud_folder_id=item.parent)
|
|
641
|
+
)
|
|
642
|
+
return CloudFolder(
|
|
643
|
+
ref=CloudFolderRef(cloud_folder_id=item.id),
|
|
644
|
+
name=item.showtext,
|
|
645
|
+
parent=parent,
|
|
646
|
+
direct_file_count=item.num,
|
|
647
|
+
description=item.comm,
|
|
648
|
+
attributes=MappingProxyType(cast(dict[str, object], dict(item.model_extra or {}))),
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
@staticmethod
|
|
652
|
+
def _root(item: WireCloudDriveRootPermissions) -> CloudDriveRoot:
|
|
653
|
+
return CloudDriveRoot(
|
|
654
|
+
ref=CloudDriveRootRef(),
|
|
655
|
+
name=item.showtext,
|
|
656
|
+
description=item.comm,
|
|
657
|
+
permissions=CloudDriveRootPermissions(
|
|
658
|
+
permission_mask=item.power,
|
|
659
|
+
can_create_folder=item.dir_create,
|
|
660
|
+
can_delete_folder=item.dir_delete,
|
|
661
|
+
can_edit_folder=item.dir_edit,
|
|
662
|
+
can_create_file=item.file_create,
|
|
663
|
+
can_delete_file=item.file_delete,
|
|
664
|
+
can_view_file=item.file_view,
|
|
665
|
+
can_edit_file=item.file_edit,
|
|
666
|
+
attributes=MappingProxyType(cast(dict[str, object], dict(item.model_extra or {}))),
|
|
667
|
+
),
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
@staticmethod
|
|
671
|
+
def _folder_request(
|
|
672
|
+
metadata: CloudFolderMetadata, *, folder: CloudFolderRef | None, parent: CloudDriveLocation
|
|
673
|
+
) -> WireSaveCloudDriveFolderRequest:
|
|
674
|
+
raw_metadata = cast(object, metadata)
|
|
675
|
+
if not isinstance(raw_metadata, CloudFolderMetadata):
|
|
676
|
+
raise MatelabUsageError("Cloud-drive folder metadata is invalid.")
|
|
677
|
+
try:
|
|
678
|
+
return WireSaveCloudDriveFolderRequest.model_validate(
|
|
679
|
+
{
|
|
680
|
+
"id": folder.cloud_folder_id if folder is not None else 0,
|
|
681
|
+
"parent": CloudDrive._location_id(parent),
|
|
682
|
+
"root_id": 0,
|
|
683
|
+
"showtext": raw_metadata.name,
|
|
684
|
+
"comm": raw_metadata.description,
|
|
685
|
+
}
|
|
686
|
+
)
|
|
687
|
+
except ValidationError:
|
|
688
|
+
raise MatelabUsageError("Cloud-drive folder metadata does not satisfy the Integration Contract.") from None
|
|
689
|
+
|
|
690
|
+
@staticmethod
|
|
691
|
+
def _folder_save_confirmation(
|
|
692
|
+
observed: CloudFolder | None, metadata: CloudFolderMetadata, parent: CloudDriveLocation
|
|
693
|
+
) -> Literal["observed_matching", "observed_mismatch", "not_observed"]:
|
|
694
|
+
if observed is None:
|
|
695
|
+
return "not_observed"
|
|
696
|
+
if (
|
|
697
|
+
observed.name == metadata.name
|
|
698
|
+
and (observed.description or "") == metadata.description
|
|
699
|
+
and observed.parent == parent
|
|
700
|
+
):
|
|
701
|
+
return "observed_matching"
|
|
702
|
+
return "observed_mismatch"
|
|
703
|
+
|
|
704
|
+
@staticmethod
|
|
705
|
+
def _find_folder(tree: CloudFolderTree, ref: CloudFolderRef) -> CloudFolder | None:
|
|
706
|
+
return next((item for item in tree.folders if item.ref == ref), None)
|
|
707
|
+
|
|
708
|
+
@staticmethod
|
|
709
|
+
def _validate_staged_file(staged: StagedFile) -> None:
|
|
710
|
+
raw_staged = cast(object, staged)
|
|
711
|
+
if (
|
|
712
|
+
not isinstance(raw_staged, StagedFile)
|
|
713
|
+
or raw_staged.temporary_file_id < 1
|
|
714
|
+
or not raw_staged.binding.uid
|
|
715
|
+
or len(raw_staged.binding.uid) > 45
|
|
716
|
+
or not raw_staged.session.upload_session_id
|
|
717
|
+
or len(raw_staged.session.upload_session_id) > 45
|
|
718
|
+
or raw_staged.session.next_offset != raw_staged.size
|
|
719
|
+
or not raw_staged.filename
|
|
720
|
+
or raw_staged.size < 0
|
|
721
|
+
or _SHA256_PATTERN.fullmatch(raw_staged.sha256) is None
|
|
722
|
+
or raw_staged.completion != "provider_acknowledged_complete"
|
|
723
|
+
or raw_staged.binding_state != "staged_not_bound"
|
|
724
|
+
):
|
|
725
|
+
raise MatelabUsageError("Completed staged-file metadata is invalid.")
|
|
726
|
+
|
|
727
|
+
@staticmethod
|
|
728
|
+
def _validate_observed_file(file: CloudFile) -> None:
|
|
729
|
+
raw_file = cast(object, file)
|
|
730
|
+
if (
|
|
731
|
+
not isinstance(raw_file, CloudFile)
|
|
732
|
+
or CloudDrive._file_id(raw_file.ref) < 1
|
|
733
|
+
or not raw_file.filename
|
|
734
|
+
or raw_file.size < 0
|
|
735
|
+
or _SHA256_PATTERN.fullmatch(raw_file.sha256) is None
|
|
736
|
+
):
|
|
737
|
+
raise MatelabUsageError("Observed cloud-drive file metadata is invalid.")
|
|
738
|
+
if raw_file.location is not None:
|
|
739
|
+
_ = CloudDrive._location_id(raw_file.location)
|
|
740
|
+
|
|
741
|
+
@staticmethod
|
|
742
|
+
def _wire_file_ids(refs: tuple[CloudFileRef, ...]) -> str:
|
|
743
|
+
ids = [item.cloud_file_id for item in refs]
|
|
744
|
+
try:
|
|
745
|
+
wire = WireCloudDriveFileIdList.model_validate(ids)
|
|
746
|
+
payload = cast(list[object], wire.model_dump(mode="json"))
|
|
747
|
+
return json.dumps(payload, separators=(",", ":"))
|
|
748
|
+
except (ValidationError, TypeError, ValueError):
|
|
749
|
+
raise MatelabUsageError("Cloud-drive file identities do not satisfy the Integration Contract.") from None
|
|
750
|
+
|
|
751
|
+
@staticmethod
|
|
752
|
+
def _unique_file_refs(files: Sequence[CloudFileRef]) -> tuple[CloudFileRef, ...]:
|
|
753
|
+
selected = tuple(files)
|
|
754
|
+
ids = tuple(CloudDrive._file_id(item) for item in selected)
|
|
755
|
+
if not selected or len(ids) != len(set(ids)):
|
|
756
|
+
raise MatelabUsageError("Cloud-drive file selection must contain distinct positive identities.")
|
|
757
|
+
return selected
|
|
758
|
+
|
|
759
|
+
@staticmethod
|
|
760
|
+
def _location_id(location: CloudDriveLocation) -> int:
|
|
761
|
+
raw_location = cast(object, location)
|
|
762
|
+
if isinstance(raw_location, CloudDriveRootRef):
|
|
763
|
+
return 0
|
|
764
|
+
if isinstance(raw_location, CloudFolderRef):
|
|
765
|
+
return CloudDrive._folder_id(raw_location)
|
|
766
|
+
raise MatelabUsageError("Cloud-drive location identity is invalid.")
|
|
767
|
+
|
|
768
|
+
@staticmethod
|
|
769
|
+
def _folder_id(folder: CloudFolderRef) -> int:
|
|
770
|
+
raw_folder = cast(object, folder)
|
|
771
|
+
if (
|
|
772
|
+
isinstance(raw_folder, CloudFolderRef)
|
|
773
|
+
and type(raw_folder.cloud_folder_id) is int
|
|
774
|
+
and raw_folder.cloud_folder_id >= 1
|
|
775
|
+
):
|
|
776
|
+
return raw_folder.cloud_folder_id
|
|
777
|
+
raise MatelabUsageError("Cloud-drive folder identity is invalid.")
|
|
778
|
+
|
|
779
|
+
@staticmethod
|
|
780
|
+
def _file_id(file: CloudFileRef) -> int:
|
|
781
|
+
raw_file = cast(object, file)
|
|
782
|
+
if isinstance(raw_file, CloudFileRef) and type(raw_file.cloud_file_id) is int and raw_file.cloud_file_id >= 1:
|
|
783
|
+
return raw_file.cloud_file_id
|
|
784
|
+
raise MatelabUsageError("Cloud-drive file identity is invalid.")
|
|
785
|
+
|
|
786
|
+
@staticmethod
|
|
787
|
+
def _extension(filename: str) -> str:
|
|
788
|
+
tail = filename.rpartition(".")
|
|
789
|
+
return f".{tail[2]}" if tail[0] and tail[2] else ""
|