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/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
+ )