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/uploads.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import re
|
|
3
|
+
import secrets
|
|
4
|
+
from dataclasses import dataclass, field, replace
|
|
5
|
+
from typing import IO, Literal, Self, cast
|
|
6
|
+
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from matelab._generated.models import ErrorResponse as WireErrorResponse
|
|
10
|
+
from matelab._generated.models import StagedTemporaryFileFragmentResponse as WireStagedFragmentResponse
|
|
11
|
+
from matelab._generated.models import StagedTemporaryFileResponse as WireStagedFileResponse
|
|
12
|
+
from matelab._generated.models import StageTemporaryFileRequest as WireStageTemporaryFileRequest
|
|
13
|
+
from matelab._generated.models import StageTemporaryFileResponse as WireStageTemporaryFileOperationResponse
|
|
14
|
+
from matelab._transport import Encoding, Operation, SessionTransport, multipart_fields
|
|
15
|
+
from matelab.errors import MatelabProtocolError, MatelabUsageError
|
|
16
|
+
|
|
17
|
+
_STAGE_FILE = Operation(
|
|
18
|
+
method="POST",
|
|
19
|
+
path="/actions/bigupload",
|
|
20
|
+
encoding=Encoding.MULTIPART,
|
|
21
|
+
response_model=WireStageTemporaryFileOperationResponse,
|
|
22
|
+
success_codes=frozenset({0}),
|
|
23
|
+
)
|
|
24
|
+
_ABORT_STAGE = Operation(
|
|
25
|
+
method="POST",
|
|
26
|
+
path="/actions/bigupload",
|
|
27
|
+
encoding=Encoding.MULTIPART,
|
|
28
|
+
response_model=WireErrorResponse,
|
|
29
|
+
success_codes=frozenset({2}),
|
|
30
|
+
)
|
|
31
|
+
_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class UploadBindingRef:
|
|
36
|
+
"""Caller-generated correlation value for one temporary-upload binding."""
|
|
37
|
+
|
|
38
|
+
uid: str = field(repr=False)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def new(cls) -> Self:
|
|
42
|
+
return cls(uid=f"upl-{secrets.token_urlsafe(24)}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class StagedUploadSession:
|
|
47
|
+
"""A resumable upload session whose offset is tracked by this caller."""
|
|
48
|
+
|
|
49
|
+
binding: UploadBindingRef
|
|
50
|
+
upload_session_id: str = field(repr=False)
|
|
51
|
+
next_offset: int = 0
|
|
52
|
+
offset_basis: Literal["client_declared_fragment_sizes_not_provider_confirmed"] = (
|
|
53
|
+
"client_declared_fragment_sizes_not_provider_confirmed"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def new(cls, *, binding: UploadBindingRef | None = None) -> Self:
|
|
58
|
+
return cls(binding=binding or UploadBindingRef.new(), upload_session_id=f"tmp-{secrets.token_urlsafe(24)}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class StagedFileFragment:
|
|
63
|
+
"""Provider-acknowledged non-final fragment and the next client-side session state."""
|
|
64
|
+
|
|
65
|
+
session: StagedUploadSession
|
|
66
|
+
fragment_sha256: str = field(repr=False)
|
|
67
|
+
fragment_size: int
|
|
68
|
+
completion: Literal["incomplete"] = "incomplete"
|
|
69
|
+
provider_offset_confirmation: Literal[False] = False
|
|
70
|
+
automatic_retry: Literal[False] = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class StagedFile:
|
|
75
|
+
"""A completed temporary file, not proof of downstream binding or uploader checks."""
|
|
76
|
+
|
|
77
|
+
session: StagedUploadSession
|
|
78
|
+
temporary_file_id: int
|
|
79
|
+
filename: str
|
|
80
|
+
size: int
|
|
81
|
+
sha256: str = field(repr=False)
|
|
82
|
+
temporary_url: str = field(repr=False)
|
|
83
|
+
completion: Literal["provider_acknowledged_complete"] = "provider_acknowledged_complete"
|
|
84
|
+
binding_state: Literal["staged_not_bound"] = "staged_not_bound"
|
|
85
|
+
downstream_binding_authorization: Literal["provider_does_not_verify_uploader"] = "provider_does_not_verify_uploader"
|
|
86
|
+
automatic_retry: Literal[False] = False
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def binding(self) -> UploadBindingRef:
|
|
90
|
+
return self.session.binding
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class StagedUploadAbortResult:
|
|
95
|
+
session: StagedUploadSession
|
|
96
|
+
provider_message: str
|
|
97
|
+
cleanup_confirmation: Literal["provider_reported_cancellation_not_independently_verified"] = (
|
|
98
|
+
"provider_reported_cancellation_not_independently_verified"
|
|
99
|
+
)
|
|
100
|
+
automatic_retry: Literal[False] = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Uploads:
|
|
104
|
+
"""Cross-domain resumable staging without claiming downstream ownership checks."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, transport: SessionTransport) -> None:
|
|
107
|
+
self._transport: SessionTransport = transport
|
|
108
|
+
|
|
109
|
+
async def stage(
|
|
110
|
+
self,
|
|
111
|
+
content: IO[bytes] | bytes,
|
|
112
|
+
*,
|
|
113
|
+
filename: str,
|
|
114
|
+
fragment_size: int,
|
|
115
|
+
session: StagedUploadSession | None = None,
|
|
116
|
+
final: bool = True,
|
|
117
|
+
complete_sha256: str | None = None,
|
|
118
|
+
content_type: str = "application/octet-stream",
|
|
119
|
+
) -> StagedFileFragment | StagedFile:
|
|
120
|
+
selected = session or StagedUploadSession.new()
|
|
121
|
+
self._validate_session(selected)
|
|
122
|
+
self._validate_fragment(
|
|
123
|
+
content=content,
|
|
124
|
+
filename=filename,
|
|
125
|
+
fragment_size=fragment_size,
|
|
126
|
+
content_type=content_type,
|
|
127
|
+
complete_sha256=complete_sha256,
|
|
128
|
+
final=final,
|
|
129
|
+
offset=selected.next_offset,
|
|
130
|
+
)
|
|
131
|
+
expected_sha256 = complete_sha256
|
|
132
|
+
if final and expected_sha256 is None and isinstance(content, bytes) and selected.next_offset == 0:
|
|
133
|
+
expected_sha256 = hashlib.sha256(content).hexdigest()
|
|
134
|
+
try:
|
|
135
|
+
request = WireStageTemporaryFileRequest.model_validate(
|
|
136
|
+
{
|
|
137
|
+
"uid": selected.binding.uid,
|
|
138
|
+
"tempid": selected.upload_session_id,
|
|
139
|
+
"hash": expected_sha256 or "",
|
|
140
|
+
"last": int(final),
|
|
141
|
+
"abort": 0,
|
|
142
|
+
"file": b"contract-validation-placeholder",
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
except ValidationError:
|
|
146
|
+
raise MatelabUsageError("Temporary-file fragment does not satisfy the Integration Contract.") from None
|
|
147
|
+
response = await self._transport.request(
|
|
148
|
+
_STAGE_FILE,
|
|
149
|
+
payload=request.model_dump(mode="json", exclude={"file"}),
|
|
150
|
+
files={"file": (filename, content, content_type)},
|
|
151
|
+
)
|
|
152
|
+
wire = response.root
|
|
153
|
+
next_session = replace(selected, next_offset=selected.next_offset + fragment_size)
|
|
154
|
+
if not final:
|
|
155
|
+
if not isinstance(wire, WireStagedFragmentResponse):
|
|
156
|
+
raise MatelabProtocolError(
|
|
157
|
+
"Matelab returned final-file metadata for a non-final fragment; the staging state is ambiguous."
|
|
158
|
+
)
|
|
159
|
+
if wire.tempid != selected.upload_session_id:
|
|
160
|
+
raise MatelabProtocolError("Matelab returned a different resumable upload-session identity.")
|
|
161
|
+
if isinstance(content, bytes) and wire.hash.root != hashlib.sha256(content).hexdigest():
|
|
162
|
+
raise MatelabProtocolError(
|
|
163
|
+
"Matelab returned a fragment hash that does not match the submitted temporary-file bytes."
|
|
164
|
+
)
|
|
165
|
+
return StagedFileFragment(session=next_session, fragment_sha256=wire.hash.root, fragment_size=fragment_size)
|
|
166
|
+
if not isinstance(wire, WireStagedFileResponse):
|
|
167
|
+
raise MatelabProtocolError(
|
|
168
|
+
"Matelab returned fragment metadata for a final fragment; the temporary upload may remain incomplete."
|
|
169
|
+
)
|
|
170
|
+
if (
|
|
171
|
+
wire.name != selected.upload_session_id
|
|
172
|
+
or wire.filename != filename
|
|
173
|
+
or wire.size != next_session.next_offset
|
|
174
|
+
or (expected_sha256 is not None and wire.hash.root != expected_sha256)
|
|
175
|
+
):
|
|
176
|
+
message = (
|
|
177
|
+
"Matelab completed staging with different identity, filename, size, or hash metadata; "
|
|
178
|
+
+ "the temporary upload may require abort cleanup."
|
|
179
|
+
)
|
|
180
|
+
raise MatelabProtocolError(message)
|
|
181
|
+
return StagedFile(
|
|
182
|
+
session=next_session,
|
|
183
|
+
temporary_file_id=wire.id,
|
|
184
|
+
filename=wire.filename,
|
|
185
|
+
size=wire.size,
|
|
186
|
+
sha256=wire.hash.root,
|
|
187
|
+
temporary_url=self._transport.absolute_url(wire.path, wire.query),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def abort(self, session: StagedUploadSession) -> StagedUploadAbortResult:
|
|
191
|
+
"""Request compensating cleanup; the legacy Provider reports this as business code 2."""
|
|
192
|
+
self._validate_session(session)
|
|
193
|
+
try:
|
|
194
|
+
request = WireStageTemporaryFileRequest.model_validate(
|
|
195
|
+
{"uid": session.binding.uid, "tempid": session.upload_session_id, "hash": "", "last": 0, "abort": 1}
|
|
196
|
+
)
|
|
197
|
+
except ValidationError:
|
|
198
|
+
raise MatelabUsageError(
|
|
199
|
+
"Temporary-file abort identity does not satisfy the Integration Contract."
|
|
200
|
+
) from None
|
|
201
|
+
response = await self._transport.request(
|
|
202
|
+
_ABORT_STAGE, files=multipart_fields(request.model_dump(mode="json", exclude_none=True))
|
|
203
|
+
)
|
|
204
|
+
return StagedUploadAbortResult(session=session, provider_message=response.msg)
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _validate_session(session: StagedUploadSession) -> None:
|
|
208
|
+
if (
|
|
209
|
+
not session.binding.uid
|
|
210
|
+
or len(session.binding.uid) > 45
|
|
211
|
+
or not session.upload_session_id
|
|
212
|
+
or len(session.upload_session_id) > 45
|
|
213
|
+
or type(session.next_offset) is not int
|
|
214
|
+
or session.next_offset < 0
|
|
215
|
+
):
|
|
216
|
+
raise MatelabUsageError("Temporary-upload session identity or offset is invalid.")
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _validate_fragment(
|
|
220
|
+
*,
|
|
221
|
+
content: IO[bytes] | bytes,
|
|
222
|
+
filename: str,
|
|
223
|
+
fragment_size: int,
|
|
224
|
+
content_type: str,
|
|
225
|
+
complete_sha256: str | None,
|
|
226
|
+
final: bool,
|
|
227
|
+
offset: int,
|
|
228
|
+
) -> None:
|
|
229
|
+
if (
|
|
230
|
+
not filename
|
|
231
|
+
or "\r" in filename
|
|
232
|
+
or "\n" in filename
|
|
233
|
+
or type(fragment_size) is not int
|
|
234
|
+
or fragment_size < 0
|
|
235
|
+
or type(final) is not bool
|
|
236
|
+
or not content_type
|
|
237
|
+
or "\r" in content_type
|
|
238
|
+
or "\n" in content_type
|
|
239
|
+
or (complete_sha256 is not None and _SHA256_PATTERN.fullmatch(complete_sha256) is None)
|
|
240
|
+
or (complete_sha256 is not None and not final)
|
|
241
|
+
or (offset > 0 and final and complete_sha256 is None)
|
|
242
|
+
):
|
|
243
|
+
raise MatelabUsageError("Temporary-file fragment metadata is invalid.")
|
|
244
|
+
if isinstance(content, bytes) and len(content) != fragment_size:
|
|
245
|
+
raise MatelabUsageError("Temporary-file bytes do not match the declared fragment size.")
|
|
246
|
+
if not isinstance(content, bytes) and not hasattr(cast(object, content), "read"):
|
|
247
|
+
raise MatelabUsageError("Temporary-file content must be bytes or a readable binary stream.")
|
matelab/users.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
|
|
7
|
+
from matelab._generated.models import SearchUsersParametersQuery as WireSearchUsersQuery
|
|
8
|
+
from matelab._generated.models import UserDirectorySearchResponse as WireUserDirectorySearchResponse
|
|
9
|
+
from matelab._transport import Encoding, Operation, SessionTransport
|
|
10
|
+
from matelab.errors import MatelabUsageError
|
|
11
|
+
|
|
12
|
+
_SEARCH_USERS = Operation(
|
|
13
|
+
method="GET",
|
|
14
|
+
path="/actions/search_user",
|
|
15
|
+
encoding=Encoding.QUERY,
|
|
16
|
+
response_model=WireUserDirectorySearchResponse,
|
|
17
|
+
success_codes=frozenset({0}),
|
|
18
|
+
retry_on_access_expired=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UserSearchScope(StrEnum):
|
|
23
|
+
SAME_INSTITUTE = "same_institute"
|
|
24
|
+
GLOBAL = "global"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class UserRef:
|
|
29
|
+
userid: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class UserSummary:
|
|
34
|
+
ref: UserRef
|
|
35
|
+
display_name: str | None
|
|
36
|
+
email: str | None
|
|
37
|
+
institute: str | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class UserSearchResult:
|
|
42
|
+
"""Unpaged directory matches in Provider-unspecified order."""
|
|
43
|
+
|
|
44
|
+
users: tuple[UserSummary, ...]
|
|
45
|
+
scope: UserSearchScope
|
|
46
|
+
ordering: Literal["provider_unspecified"] = "provider_unspecified"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Users:
|
|
50
|
+
"""User discovery operations for selecting sharing targets."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, transport: SessionTransport) -> None:
|
|
53
|
+
self._transport: SessionTransport = transport
|
|
54
|
+
|
|
55
|
+
async def search(
|
|
56
|
+
self, keyword: str, *, scope: UserSearchScope = UserSearchScope.SAME_INSTITUTE
|
|
57
|
+
) -> UserSearchResult:
|
|
58
|
+
try:
|
|
59
|
+
normalized_scope = UserSearchScope(scope)
|
|
60
|
+
except ValueError:
|
|
61
|
+
raise MatelabUsageError("User search scope must be same_institute or global.") from None
|
|
62
|
+
try:
|
|
63
|
+
request = WireSearchUsersQuery.model_validate(
|
|
64
|
+
{"keyword": keyword, "all": 1 if normalized_scope is UserSearchScope.GLOBAL else 0}
|
|
65
|
+
)
|
|
66
|
+
except ValidationError:
|
|
67
|
+
raise MatelabUsageError("User search input does not satisfy the Integration Contract.") from None
|
|
68
|
+
response = await self._transport.request(_SEARCH_USERS, payload=request.model_dump(mode="json"))
|
|
69
|
+
return UserSearchResult(
|
|
70
|
+
users=tuple(
|
|
71
|
+
UserSummary(
|
|
72
|
+
ref=UserRef(userid=user.id), display_name=user.username, email=user.email, institute=user.institute
|
|
73
|
+
)
|
|
74
|
+
for user in response.users
|
|
75
|
+
),
|
|
76
|
+
scope=normalized_scope,
|
|
77
|
+
)
|