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/models.py ADDED
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass(frozen=True, slots=True)
5
+ class Token:
6
+ """Immutable Provider token whose secret value is omitted from representations."""
7
+
8
+ value: str = field(repr=False)
9
+ expires_at_ms: int
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Identity:
14
+ """Immutable identity associated with a validated Matelab Session."""
15
+
16
+ userid: int
17
+ username: str | None
18
+ email: str | None
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Session:
23
+ """Immutable snapshot of one client's current process-local Matelab authentication state."""
24
+
25
+ access: Token
26
+ refresh: Token
27
+ identity: Identity | None = None
matelab/notebooks.py ADDED
@@ -0,0 +1,467 @@
1
+ import json
2
+ from collections.abc import Mapping, Sequence
3
+ from dataclasses import dataclass
4
+ from typing import Literal, cast
5
+
6
+ from pydantic import ValidationError
7
+
8
+ from matelab._generated.models import AddNotebookShareRequest as WireAddNotebookShareRequest
9
+ from matelab._generated.models import BasicSuccessResponse as WireBasicSuccessResponse
10
+ from matelab._generated.models import DeleteNotebookShareRequest as WireDeleteNotebookShareRequest
11
+ from matelab._generated.models import ElnNotebook
12
+ from matelab._generated.models import ListNotebookSharesParametersQuery as WireNotebookSharesQuery
13
+ from matelab._generated.models import NotebookListResponse as WireNotebookListResponse
14
+ from matelab._generated.models import NotebookShareListResponse as WireNotebookShareListResponse
15
+ from matelab._generated.models import SaveNotebookRequest as WireSaveNotebookRequest
16
+ from matelab._generated.models import UpdateNotebookSharePermissionsRequest as WireUpdateNotebookSharePermissionsRequest
17
+ from matelab._transport import Encoding, Operation, SessionTransport, multipart_fields
18
+ from matelab.errors import MatelabError, MatelabProtocolError, MatelabUsageError, MatelabVerificationError
19
+ from matelab.groups import GroupRef
20
+ from matelab.users import UserRef
21
+
22
+ _LIST_NOTEBOOKS = Operation(
23
+ method="GET",
24
+ path="/eln_api/elns",
25
+ encoding=Encoding.NONE,
26
+ response_model=WireNotebookListResponse,
27
+ success_codes=frozenset({0}),
28
+ retry_on_access_expired=True,
29
+ )
30
+ _SAVE_NOTEBOOK = Operation(
31
+ method="POST",
32
+ path="/eln/edit",
33
+ encoding=Encoding.JSON,
34
+ response_model=WireBasicSuccessResponse,
35
+ success_codes=frozenset({0}),
36
+ )
37
+ _LIST_SHARES = Operation(
38
+ method="GET",
39
+ path="/eln/share_list",
40
+ encoding=Encoding.QUERY,
41
+ response_model=WireNotebookShareListResponse,
42
+ success_codes=frozenset({0}),
43
+ retry_on_access_expired=True,
44
+ )
45
+ _ADD_SHARE = Operation(
46
+ method="POST",
47
+ path="/eln/share_add",
48
+ encoding=Encoding.MULTIPART,
49
+ response_model=WireBasicSuccessResponse,
50
+ success_codes=frozenset({0}),
51
+ )
52
+ _UPDATE_SHARE = Operation(
53
+ method="POST",
54
+ path="/eln/share_edit",
55
+ encoding=Encoding.MULTIPART,
56
+ response_model=WireBasicSuccessResponse,
57
+ success_codes=frozenset({0}),
58
+ )
59
+ _DELETE_SHARE = Operation(
60
+ method="POST",
61
+ path="/eln/sharedel",
62
+ encoding=Encoding.MULTIPART,
63
+ response_model=WireBasicSuccessResponse,
64
+ success_codes=frozenset({0}),
65
+ )
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class NotebookRef:
70
+ """Identity and exact Provider selector for an owned or shared notebook."""
71
+
72
+ notebook_id: int
73
+ title: str | None
74
+ owner_userid: int
75
+ scope: Literal["owned", "shared"]
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class PublicNotebookRef:
80
+ """Identity and title selector for a public-catalog notebook."""
81
+
82
+ public_notebook_id: int
83
+ title: str
84
+ owner_userid: int
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class NotebookListField:
89
+ uid: object
90
+ path: tuple[object, ...]
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class NotebookMetadata:
95
+ """Complete metadata replacement accepted by the legacy save operation."""
96
+
97
+ title: str
98
+ summary: str | None = None
99
+ notebook_types: tuple[object, ...] = ()
100
+ display_fields: tuple[NotebookListField, ...] | None = None
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class NotebookSummary:
105
+ ref: NotebookRef
106
+ summary: str | None
107
+ record_count: int
108
+ created_at: str | None
109
+ owner_name: str | None
110
+ group_name: str | None
111
+ notebook_types: tuple[object, ...]
112
+ display_enabled: bool
113
+ display_fields: tuple[NotebookListField, ...] | None
114
+ display_configuration_valid: bool
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class PublicNotebookSummary:
119
+ ref: PublicNotebookRef
120
+ summary: str
121
+ record_count: int
122
+ created_at: str
123
+
124
+
125
+ @dataclass(frozen=True, slots=True)
126
+ class NotebookCollection:
127
+ owned: tuple[NotebookSummary, ...]
128
+ shared: tuple[NotebookSummary, ...]
129
+ public: tuple[PublicNotebookSummary, ...]
130
+
131
+ @property
132
+ def all(self) -> tuple[NotebookSummary | PublicNotebookSummary, ...]:
133
+ return (*self.owned, *self.shared, *self.public)
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class NotebookCreateResult:
138
+ requested: NotebookMetadata
139
+ provider_acknowledged: Literal[True] = True
140
+ notebook: None = None
141
+ identity_status: Literal["provider_did_not_return_identity"] = "provider_did_not_return_identity"
142
+
143
+
144
+ @dataclass(frozen=True, slots=True)
145
+ class NotebookUpdateResult:
146
+ notebook: NotebookRef
147
+ requested: NotebookMetadata
148
+ observed: NotebookSummary | None
149
+ confirmation: Literal["observed_matching", "observed_mismatch", "not_observed"]
150
+ provider_acknowledged: Literal[True] = True
151
+
152
+
153
+ @dataclass(frozen=True, slots=True)
154
+ class NotebookSharePermissionGrant:
155
+ """Replacement flags; read access remains implicit and cannot be revoked here."""
156
+
157
+ write: bool = False
158
+ create: bool = False
159
+ delete: bool = False
160
+ sign: bool = False
161
+ manage_subtypes: bool = False
162
+ read_audit_log: bool = False
163
+ manage_shares: bool = False
164
+
165
+
166
+ @dataclass(frozen=True, slots=True)
167
+ class NotebookShareRef:
168
+ notebook: NotebookRef
169
+ share_row_id: int
170
+ recipient: UserRef
171
+ belongs_to_caller: bool
172
+
173
+
174
+ @dataclass(frozen=True, slots=True)
175
+ class NotebookShare:
176
+ ref: NotebookShareRef
177
+ recipient_name: str | None
178
+ recipient_email: str | None
179
+ stored_permission_mask: int
180
+ effective_read_access: Literal[True] = True
181
+
182
+
183
+ @dataclass(frozen=True, slots=True)
184
+ class NotebookShares:
185
+ notebook: NotebookRef
186
+ shares: tuple[NotebookShare, ...]
187
+ caller_is_owner: bool
188
+ caller_effective_permission_mask: int
189
+ ordering: Literal["provider_unspecified"] = "provider_unspecified"
190
+
191
+
192
+ @dataclass(frozen=True, slots=True)
193
+ class NotebookShareResult:
194
+ notebook: NotebookRef
195
+ requested: tuple[UserRef, ...]
196
+ visible_after_write: tuple[NotebookShare, ...]
197
+ not_observed_after_write: tuple[UserRef, ...]
198
+ observation: Literal["all_visible", "partially_visible", "none_visible"]
199
+ provider_acknowledged: Literal[True] = True
200
+
201
+
202
+ @dataclass(frozen=True, slots=True)
203
+ class NotebookShareUpdateResult:
204
+ ref: NotebookShareRef
205
+ requested: NotebookSharePermissionGrant
206
+ observed: NotebookShare | None
207
+ observation: Literal["relation_observed", "relation_not_observed"]
208
+ provider_acknowledged: Literal[True] = True
209
+
210
+
211
+ @dataclass(frozen=True, slots=True)
212
+ class NotebookUnshareResult:
213
+ ref: NotebookShareRef
214
+ absence_confirmed: bool | None
215
+ verification: Literal["confirmed_absent", "still_visible", "not_attempted_after_leaving"]
216
+ provider_acknowledged: Literal[True] = True
217
+
218
+
219
+ class Notebooks:
220
+ """Notebook-oriented Matelab operations."""
221
+
222
+ def __init__(self, transport: SessionTransport) -> None:
223
+ self._transport: SessionTransport = transport
224
+
225
+ async def list(self) -> NotebookCollection:
226
+ response = await self._transport.request(_LIST_NOTEBOOKS)
227
+ return NotebookCollection(
228
+ owned=tuple(self._owned_or_shared(item, scope="owned") for item in response.my),
229
+ shared=tuple(self._owned_or_shared(item, scope="shared") for item in response.share),
230
+ public=tuple(
231
+ PublicNotebookSummary(
232
+ ref=PublicNotebookRef(public_notebook_id=item.id, title=item.showtext, owner_userid=item.userid),
233
+ summary=item.comm,
234
+ record_count=item.num,
235
+ created_at=item.datetime_create,
236
+ )
237
+ for item in response.public
238
+ ),
239
+ )
240
+
241
+ async def create(self, metadata: NotebookMetadata, *, group: GroupRef | None = None) -> NotebookCreateResult:
242
+ request = self._save_request(metadata, notebook_id=0, group_id=group.group_id if group is not None else 0)
243
+ _ = await self._transport.request(_SAVE_NOTEBOOK, payload=request.model_dump(mode="json", exclude_unset=True))
244
+ return NotebookCreateResult(requested=metadata)
245
+
246
+ async def update(self, notebook: NotebookRef, metadata: NotebookMetadata) -> NotebookUpdateResult:
247
+ if notebook.scope != "owned":
248
+ raise MatelabUsageError("Notebook metadata can only be updated through an owned NotebookRef.")
249
+ request = self._save_request(metadata, notebook_id=notebook.notebook_id)
250
+ _ = await self._transport.request(_SAVE_NOTEBOOK, payload=request.model_dump(mode="json", exclude_unset=True))
251
+ notebooks = await self._verified_notebook_list("notebook update")
252
+ observed = next((item for item in notebooks.owned if item.ref.notebook_id == notebook.notebook_id), None)
253
+ if observed is None:
254
+ confirmation: Literal["observed_matching", "observed_mismatch", "not_observed"] = "not_observed"
255
+ elif self._metadata_matches(observed, metadata):
256
+ confirmation = "observed_matching"
257
+ else:
258
+ confirmation = "observed_mismatch"
259
+ return NotebookUpdateResult(notebook=notebook, requested=metadata, observed=observed, confirmation=confirmation)
260
+
261
+ async def shares(self, notebook: NotebookRef) -> NotebookShares:
262
+ try:
263
+ request = WireNotebookSharesQuery(id=notebook.notebook_id)
264
+ except ValidationError:
265
+ raise MatelabUsageError("Notebook identity does not satisfy the Integration Contract.") from None
266
+ response = await self._transport.request(_LIST_SHARES, payload=request.model_dump(mode="json"))
267
+ return NotebookShares(
268
+ notebook=notebook,
269
+ shares=tuple(
270
+ NotebookShare(
271
+ ref=NotebookShareRef(
272
+ notebook=notebook,
273
+ share_row_id=item.id,
274
+ recipient=UserRef(userid=item.userid),
275
+ belongs_to_caller=item.self,
276
+ ),
277
+ recipient_name=item.username,
278
+ recipient_email=item.email,
279
+ stored_permission_mask=item.power,
280
+ )
281
+ for item in response.list_
282
+ ),
283
+ caller_is_owner=response.owner,
284
+ caller_effective_permission_mask=response.power,
285
+ )
286
+
287
+ async def share(self, notebook: NotebookRef, recipients: Sequence[UserRef]) -> NotebookShareResult:
288
+ requested = tuple(recipients)
289
+ userids = tuple(recipient.userid for recipient in requested)
290
+ if (
291
+ not requested
292
+ or any(type(userid) is not int or userid < 1 for userid in userids)
293
+ or len(userids) != len(set(userids))
294
+ ):
295
+ raise MatelabUsageError("Notebook share recipients must be a non-empty set of distinct users.")
296
+ try:
297
+ request = WireAddNotebookShareRequest(
298
+ id=notebook.notebook_id, users=json.dumps(userids, ensure_ascii=False, separators=(",", ":"))
299
+ )
300
+ except (ValidationError, TypeError, ValueError):
301
+ raise MatelabUsageError("Notebook share targets do not satisfy the Integration Contract.") from None
302
+ _ = await self._transport.request(_ADD_SHARE, files=multipart_fields(request.model_dump(mode="json")))
303
+ after = await self._verified_shares(notebook, "notebook share")
304
+ by_userid = {item.ref.recipient.userid: item for item in after.shares}
305
+ visible = tuple(by_userid[userid] for userid in userids if userid in by_userid)
306
+ not_observed = tuple(recipient for recipient in requested if recipient.userid not in by_userid)
307
+ observation: Literal["all_visible", "partially_visible", "none_visible"]
308
+ if len(visible) == len(requested):
309
+ observation = "all_visible"
310
+ elif visible:
311
+ observation = "partially_visible"
312
+ else:
313
+ observation = "none_visible"
314
+ return NotebookShareResult(
315
+ notebook=notebook,
316
+ requested=requested,
317
+ visible_after_write=visible,
318
+ not_observed_after_write=not_observed,
319
+ observation=observation,
320
+ )
321
+
322
+ async def update_share(
323
+ self, ref: NotebookShareRef, permissions: NotebookSharePermissionGrant
324
+ ) -> NotebookShareUpdateResult:
325
+ try:
326
+ request = WireUpdateNotebookSharePermissionsRequest.model_validate(
327
+ {
328
+ "id": ref.share_row_id,
329
+ "write": int(permissions.write),
330
+ "create": int(permissions.create),
331
+ "delete": int(permissions.delete),
332
+ "sign": int(permissions.sign),
333
+ "subtype": int(permissions.manage_subtypes),
334
+ "log": int(permissions.read_audit_log),
335
+ "user": int(permissions.manage_shares),
336
+ }
337
+ )
338
+ except ValidationError:
339
+ raise MatelabUsageError("Notebook share permissions do not satisfy the Integration Contract.") from None
340
+ _ = await self._transport.request(_UPDATE_SHARE, files=multipart_fields(request.model_dump(mode="json")))
341
+ after = await self._verified_shares(ref.notebook, "notebook share-permission update")
342
+ observed = next((item for item in after.shares if item.ref.share_row_id == ref.share_row_id), None)
343
+ if observed is not None and observed.ref.recipient != ref.recipient:
344
+ raise MatelabProtocolError("Matelab returned a different recipient for the notebook share row.")
345
+ return NotebookShareUpdateResult(
346
+ ref=ref,
347
+ requested=permissions,
348
+ observed=observed,
349
+ observation="relation_observed" if observed is not None else "relation_not_observed",
350
+ )
351
+
352
+ async def unshare(self, ref: NotebookShareRef) -> NotebookUnshareResult:
353
+ try:
354
+ request = WireDeleteNotebookShareRequest(id=ref.share_row_id)
355
+ except ValidationError:
356
+ raise MatelabUsageError("Notebook share identity does not satisfy the Integration Contract.") from None
357
+ _ = await self._transport.request(_DELETE_SHARE, files=multipart_fields(request.model_dump(mode="json")))
358
+ if ref.belongs_to_caller:
359
+ return NotebookUnshareResult(ref=ref, absence_confirmed=None, verification="not_attempted_after_leaving")
360
+ after = await self._verified_shares(ref.notebook, "notebook unshare")
361
+ still_visible = any(item.ref.share_row_id == ref.share_row_id for item in after.shares)
362
+ return NotebookUnshareResult(
363
+ ref=ref,
364
+ absence_confirmed=not still_visible,
365
+ verification="still_visible" if still_visible else "confirmed_absent",
366
+ )
367
+
368
+ @staticmethod
369
+ def _save_request(
370
+ metadata: NotebookMetadata, *, notebook_id: int, group_id: int | None = None
371
+ ) -> WireSaveNotebookRequest:
372
+ try:
373
+ if any(not 1 <= len(field.path) <= 4 for field in metadata.display_fields or ()):
374
+ raise ValueError
375
+ encoded_types = json.dumps(metadata.notebook_types, ensure_ascii=False, separators=(",", ":"))
376
+ encoded_fields = (
377
+ json.dumps(
378
+ [{"uid": field.uid, "path": list(field.path)} for field in metadata.display_fields],
379
+ ensure_ascii=False,
380
+ separators=(",", ":"),
381
+ )
382
+ if metadata.display_fields is not None
383
+ else None
384
+ )
385
+ payload: dict[str, object] = {
386
+ "id": notebook_id,
387
+ "showtext": metadata.title,
388
+ "comm": metadata.summary,
389
+ "display": int(metadata.display_fields is not None),
390
+ "types": encoded_types,
391
+ "requests": encoded_fields,
392
+ }
393
+ if group_id is not None:
394
+ payload["group_id"] = group_id
395
+ return WireSaveNotebookRequest.model_validate(payload)
396
+ except (ValidationError, TypeError, ValueError):
397
+ raise MatelabUsageError("Notebook metadata does not satisfy the Integration Contract.") from None
398
+
399
+ async def _verified_notebook_list(self, action: str) -> NotebookCollection:
400
+ try:
401
+ return await self.list()
402
+ except MatelabError as exc:
403
+ raise MatelabVerificationError(
404
+ f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
405
+ ) from exc
406
+
407
+ async def _verified_shares(self, notebook: NotebookRef, action: str) -> NotebookShares:
408
+ try:
409
+ return await self.shares(notebook)
410
+ except MatelabError as exc:
411
+ raise MatelabVerificationError(
412
+ f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
413
+ ) from exc
414
+
415
+ @staticmethod
416
+ def _metadata_matches(observed: NotebookSummary, requested: NotebookMetadata) -> bool:
417
+ summary_matches = (observed.summary or "") == (requested.summary or "")
418
+ fields_match = not observed.display_enabled or (
419
+ observed.display_configuration_valid and observed.display_fields == (requested.display_fields or ())
420
+ )
421
+ return (
422
+ observed.ref.title == requested.title
423
+ and summary_matches
424
+ and observed.notebook_types == requested.notebook_types
425
+ and observed.display_enabled == (requested.display_fields is not None)
426
+ and fields_match
427
+ )
428
+
429
+ @staticmethod
430
+ def _owned_or_shared(item: ElnNotebook, *, scope: Literal["owned", "shared"]) -> NotebookSummary:
431
+ display_fields, configuration_valid = Notebooks._parse_display_fields(item.requests)
432
+ return NotebookSummary(
433
+ ref=NotebookRef(notebook_id=item.id, title=item.showtext, owner_userid=item.userid, scope=scope),
434
+ summary=item.comm,
435
+ record_count=item.num_total,
436
+ created_at=item.datetime_create,
437
+ owner_name=item.username or None,
438
+ group_name=item.group_name,
439
+ notebook_types=tuple(cast(list[object], item.types)),
440
+ display_enabled=bool(item.display.value),
441
+ display_fields=display_fields,
442
+ display_configuration_valid=configuration_valid,
443
+ )
444
+
445
+ @staticmethod
446
+ def _parse_display_fields(raw: str | None) -> tuple[tuple[NotebookListField, ...] | None, bool]:
447
+ if raw is None or raw == "":
448
+ return (), True
449
+ try:
450
+ decoded = cast(object, json.loads(raw))
451
+ except (TypeError, ValueError):
452
+ return None, False
453
+ if not isinstance(decoded, list):
454
+ return None, False
455
+ fields: list[NotebookListField] = []
456
+ for value in cast(list[object], decoded):
457
+ if not isinstance(value, Mapping):
458
+ return None, False
459
+ mapping = cast(Mapping[object, object], value)
460
+ path = mapping.get("path")
461
+ if "uid" not in mapping or not isinstance(path, list):
462
+ return None, False
463
+ path_values = cast(list[object], path)
464
+ if not 1 <= len(path_values) <= 4:
465
+ return None, False
466
+ fields.append(NotebookListField(uid=mapping["uid"], path=tuple(path_values)))
467
+ return tuple(fields), True
matelab/py.typed ADDED
@@ -0,0 +1 @@
1
+