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/templates.py ADDED
@@ -0,0 +1,737 @@
1
+ import json
2
+ from collections.abc import Iterable, Mapping, Sequence
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from types import MappingProxyType
6
+ from typing import Literal, cast
7
+
8
+ from pydantic import ValidationError
9
+
10
+ from matelab._generated.models import AddGroupTemplateRequest as WireAddGroupTemplateRequest
11
+ from matelab._generated.models import BasicSuccessResponse as WireBasicSuccessResponse
12
+ from matelab._generated.models import CopyTemplateRequest as WireCopyTemplateRequest
13
+ from matelab._generated.models import DeleteGroupTemplateRequest as WireDeleteGroupTemplateRequest
14
+ from matelab._generated.models import ListTemplateStoreParametersQuery as WireListTemplateStoreQuery
15
+ from matelab._generated.models import OwnedTemplateListItem as WireOwnedTemplateListItem
16
+ from matelab._generated.models import ReadTemplateContentParametersQuery as WireReadTemplateContentQuery
17
+ from matelab._generated.models import RemoveUserTemplateRelationRequest as WireRemoveUserTemplateRelationRequest
18
+ from matelab._generated.models import SaveTemplateContentRequest as WireSaveTemplateContentRequest
19
+ from matelab._generated.models import SaveTemplateMetadataRequest as WireSaveTemplateMetadataRequest
20
+ from matelab._generated.models import SaveTemplateMetadataResponse as WireSaveTemplateMetadataResponse
21
+ from matelab._generated.models import ShareMultipleTemplatesRequest as WireShareMultipleTemplatesRequest
22
+ from matelab._generated.models import TemplateContentResponse as WireTemplateContentResponse
23
+ from matelab._generated.models import TemplateIdRequest as WireTemplateIdRequest
24
+ from matelab._generated.models import TemplateListResponse as WireTemplateListResponse
25
+ from matelab._generated.models import TemplateModule as WireTemplateModule
26
+ from matelab._generated.models import TemplateStoreListResponse as WireTemplateStoreListResponse
27
+ from matelab._generated.models import UpdateTemplateIntroRequest as WireUpdateTemplateIntroRequest
28
+ from matelab._generated.models import UpdateTemplateIntroResponse as WireUpdateTemplateIntroResponse
29
+ from matelab._transport import Encoding, Operation, SessionTransport, multipart_fields
30
+ from matelab.errors import MatelabError, MatelabProtocolError, MatelabUsageError, MatelabVerificationError
31
+ from matelab.groups import GroupRef
32
+ from matelab.uploads import UploadBindingRef
33
+ from matelab.users import UserRef
34
+
35
+ _LIST_TEMPLATES = Operation(
36
+ method="GET",
37
+ path="/eln_template/list",
38
+ encoding=Encoding.NONE,
39
+ response_model=WireTemplateListResponse,
40
+ success_codes=frozenset({0}),
41
+ retry_on_access_expired=True,
42
+ )
43
+ _LIST_TEMPLATE_MARKET = Operation(
44
+ method="GET",
45
+ path="/eln_template/store_list",
46
+ encoding=Encoding.QUERY,
47
+ response_model=WireTemplateStoreListResponse,
48
+ success_codes=frozenset({0}),
49
+ retry_on_access_expired=True,
50
+ )
51
+ _READ_TEMPLATE = Operation(
52
+ method="GET",
53
+ path="/eln_template/content",
54
+ encoding=Encoding.QUERY,
55
+ response_model=WireTemplateContentResponse,
56
+ success_codes=frozenset({0}),
57
+ retry_on_access_expired=True,
58
+ )
59
+ _SHARE_TEMPLATES = Operation(
60
+ method="POST",
61
+ path="/eln_template/share_multiple",
62
+ encoding=Encoding.MULTIPART,
63
+ response_model=WireBasicSuccessResponse,
64
+ success_codes=frozenset({0}),
65
+ )
66
+ _REMOVE_USER_LIBRARY_ENTRY = Operation(
67
+ method="POST",
68
+ path="/eln_template/refuse",
69
+ encoding=Encoding.MULTIPART,
70
+ response_model=WireBasicSuccessResponse,
71
+ success_codes=frozenset({0}),
72
+ )
73
+ _ADD_GROUP_TEMPLATE = Operation(
74
+ method="POST",
75
+ path="/eln_template/group_add",
76
+ encoding=Encoding.MULTIPART,
77
+ response_model=WireBasicSuccessResponse,
78
+ success_codes=frozenset({0}),
79
+ )
80
+ _REMOVE_GROUP_TEMPLATE = Operation(
81
+ method="POST",
82
+ path="/eln_template/group_del",
83
+ encoding=Encoding.MULTIPART,
84
+ response_model=WireBasicSuccessResponse,
85
+ success_codes=frozenset({0}),
86
+ )
87
+ _ACQUIRE_MARKET_TEMPLATE = Operation(
88
+ method="POST",
89
+ path="/eln_template/store_buy",
90
+ encoding=Encoding.JSON,
91
+ response_model=WireBasicSuccessResponse,
92
+ success_codes=frozenset({0}),
93
+ )
94
+ _SAVE_TEMPLATE_METADATA = Operation(
95
+ method="POST",
96
+ path="/eln_template/edit",
97
+ encoding=Encoding.JSON,
98
+ response_model=WireSaveTemplateMetadataResponse,
99
+ success_codes=frozenset({0}),
100
+ )
101
+ _SAVE_TEMPLATE_CONTENT = Operation(
102
+ method="POST",
103
+ path="/eln_items/templateSave",
104
+ encoding=Encoding.JSON,
105
+ response_model=WireBasicSuccessResponse,
106
+ success_codes=frozenset({0}),
107
+ )
108
+ _COPY_TEMPLATE = Operation(
109
+ method="POST",
110
+ path="/eln_template/saveas",
111
+ encoding=Encoding.JSON,
112
+ response_model=WireBasicSuccessResponse,
113
+ success_codes=frozenset({0}),
114
+ )
115
+ _UPDATE_TEMPLATE_INTRO = Operation(
116
+ method="POST",
117
+ path="/eln_template/edit_intro",
118
+ encoding=Encoding.JSON,
119
+ response_model=WireUpdateTemplateIntroResponse,
120
+ success_codes=frozenset({0}),
121
+ )
122
+ _SUBMIT_TEMPLATE = Operation(
123
+ method="POST",
124
+ path="/eln_template/pub",
125
+ encoding=Encoding.JSON,
126
+ response_model=WireBasicSuccessResponse,
127
+ success_codes=frozenset({0}),
128
+ )
129
+ _MODULE_RESERVED_FIELDS = frozenset({"name", "type", "data", "language", "style"})
130
+
131
+
132
+ class TemplateSource(StrEnum):
133
+ OWNED = "owned"
134
+ SHARED = "shared"
135
+ MARKET_ACQUIRED = "market_acquired"
136
+ GROUP = "group"
137
+ DEFAULT = "default"
138
+ PENDING_MARKET_REVIEW = "pending_market_review"
139
+
140
+
141
+ class TemplateLibraryRelationKind(StrEnum):
142
+ DIRECT_SHARE = "direct_share"
143
+ MARKET_ACQUISITION = "market_acquisition"
144
+ GROUP = "group"
145
+
146
+
147
+ @dataclass(frozen=True, slots=True)
148
+ class TemplateRef:
149
+ template_id: int
150
+
151
+
152
+ @dataclass(frozen=True, slots=True)
153
+ class TemplateLibraryEntryRef:
154
+ """A relation identity kept distinct from its template database identity."""
155
+
156
+ relation_id: int
157
+ template: TemplateRef
158
+ kind: TemplateLibraryRelationKind
159
+
160
+
161
+ @dataclass(frozen=True, slots=True)
162
+ class TemplateSummary:
163
+ ref: TemplateRef
164
+ source: TemplateSource
165
+ title: str | None
166
+ summary: str | None
167
+ library_entry: TemplateLibraryEntryRef | None = None
168
+ owner: UserRef | None = None
169
+ owner_name: str | None = None
170
+ group: GroupRef | None = None
171
+ group_name: str | None = None
172
+ group_admin: bool | None = None
173
+ intro: str | None = None
174
+ modified_at: str | None = None
175
+ keywords: tuple[object, ...] | None = None
176
+ version: int | None = None
177
+ market_version: int | None = None
178
+ publication_reason: str | None = None
179
+ render_metadata: str | None = None
180
+
181
+
182
+ @dataclass(frozen=True, slots=True)
183
+ class TemplateCollection:
184
+ owned: tuple[TemplateSummary, ...]
185
+ shared: tuple[TemplateSummary, ...]
186
+ market_acquired: tuple[TemplateSummary, ...]
187
+ group: tuple[TemplateSummary, ...]
188
+ default: tuple[TemplateSummary, ...]
189
+ pending_market_review: tuple[TemplateSummary, ...]
190
+ ordering: Literal["provider_unspecified"] = "provider_unspecified"
191
+
192
+ @property
193
+ def all(self) -> tuple[TemplateSummary, ...]:
194
+ return (
195
+ *self.owned,
196
+ *self.shared,
197
+ *self.market_acquired,
198
+ *self.group,
199
+ *self.default,
200
+ *self.pending_market_review,
201
+ )
202
+
203
+
204
+ @dataclass(frozen=True, slots=True)
205
+ class TemplateMarketEntry:
206
+ ref: TemplateRef
207
+ title: str
208
+ summary: str | None
209
+ intro: str | None
210
+ modified_at: str | None
211
+ published_at: str | None
212
+ publisher_name: str | None
213
+ acquisition_count: int | None
214
+ acquired: bool
215
+
216
+
217
+ @dataclass(frozen=True, slots=True)
218
+ class TemplateMarketPage:
219
+ items: tuple[TemplateMarketEntry, ...]
220
+ page: int
221
+ requested_page_size: int
222
+ effective_page_size: int
223
+ total_count: int
224
+ has_more: bool
225
+ has_more_basis: Literal["derived_from_total"] = "derived_from_total"
226
+ ordering: Literal["provider_unspecified"] = "provider_unspecified"
227
+
228
+
229
+ @dataclass(frozen=True, slots=True)
230
+ class TemplateModule:
231
+ name: str
232
+ module_type: str
233
+ data: object
234
+ data_present: bool
235
+ language: str | None
236
+ style: object
237
+ attributes: Mapping[str, object]
238
+
239
+
240
+ @dataclass(frozen=True, slots=True)
241
+ class TemplateContent:
242
+ ref: TemplateRef
243
+ modules: tuple[TemplateModule, ...]
244
+
245
+
246
+ @dataclass(frozen=True, slots=True)
247
+ class TemplateMetadata:
248
+ title: str
249
+ summary: str | None = None
250
+ keywords: tuple[str, ...] = ()
251
+
252
+
253
+ @dataclass(frozen=True, slots=True)
254
+ class TemplateShareResult:
255
+ templates: tuple[TemplateRef, ...]
256
+ recipients: tuple[UserRef, ...]
257
+ affected_relations: None = None
258
+ result_scope: Literal["provider_acknowledged_without_relation_results"] = (
259
+ "provider_acknowledged_without_relation_results"
260
+ )
261
+
262
+
263
+ @dataclass(frozen=True, slots=True)
264
+ class TemplateLibraryRemovalResult:
265
+ entry: TemplateLibraryEntryRef
266
+ absence_confirmed: bool
267
+ provider_semantics: Literal["idempotent_acknowledgement"] = "idempotent_acknowledgement"
268
+
269
+
270
+ @dataclass(frozen=True, slots=True)
271
+ class TemplateGroupAddResult:
272
+ template: TemplateRef
273
+ group: GroupRef
274
+ observed_entry: TemplateLibraryEntryRef | None
275
+ observation: Literal["relation_observed", "relation_not_observed"]
276
+
277
+
278
+ @dataclass(frozen=True, slots=True)
279
+ class TemplateGroupRemoveResult:
280
+ entry: TemplateLibraryEntryRef
281
+ absence_confirmed: bool
282
+
283
+
284
+ @dataclass(frozen=True, slots=True)
285
+ class TemplateMarketAcquireResult:
286
+ template: TemplateRef
287
+ observed_entry: TemplateLibraryEntryRef | None
288
+ observation: Literal["relation_observed", "relation_not_observed"]
289
+
290
+
291
+ @dataclass(frozen=True, slots=True)
292
+ class TemplateMetadataResult:
293
+ template: TemplateRef
294
+ requested: TemplateMetadata
295
+ action: Literal["created", "updated"]
296
+ observed: TemplateSummary | None
297
+ confirmation: Literal["observed_matching", "observed_mismatch", "not_observed"]
298
+ publication_revision_advanced: Literal[False] = False
299
+
300
+
301
+ @dataclass(frozen=True, slots=True)
302
+ class TemplateContentSaveResult:
303
+ template: TemplateRef
304
+ requested: tuple[TemplateModule, ...]
305
+ observed: TemplateContent
306
+ confirmation: Literal["observed_matching", "observed_mismatch"]
307
+
308
+
309
+ @dataclass(frozen=True, slots=True)
310
+ class TemplateCopyResult:
311
+ source: TemplateRef
312
+ requested_title: str
313
+ copied_template: None = None
314
+ identity_status: Literal["provider_did_not_return_identity"] = "provider_did_not_return_identity"
315
+
316
+
317
+ @dataclass(frozen=True, slots=True)
318
+ class TemplateIntroUpdateResult:
319
+ template: TemplateRef
320
+ intro: str
321
+ upload_binding: UploadBindingRef
322
+ publication_revision_advanced: Literal[False] = False
323
+ binding_authorization: Literal["provider_does_not_verify_uploader"] = "provider_does_not_verify_uploader"
324
+
325
+
326
+ @dataclass(frozen=True, slots=True)
327
+ class TemplateMarketSubmissionResult:
328
+ template: TemplateRef
329
+ observed_pending_review: bool
330
+ observation: Literal["pending_entry_observed", "pending_entry_not_observed"]
331
+
332
+
333
+ class Templates:
334
+ """Template discovery, market search, and canonical content reads."""
335
+
336
+ def __init__(self, transport: SessionTransport) -> None:
337
+ self._transport: SessionTransport = transport
338
+
339
+ async def list(self) -> TemplateCollection:
340
+ response = await self._transport.request(_LIST_TEMPLATES)
341
+ return TemplateCollection(
342
+ owned=tuple(
343
+ TemplateSummary(
344
+ ref=TemplateRef(template_id=item.template_id),
345
+ source=TemplateSource.OWNED,
346
+ title=item.template_name,
347
+ summary=item.comm,
348
+ owner=UserRef(userid=item.userid_from) if item.userid_from else None,
349
+ owner_name=item.username,
350
+ intro=item.intro,
351
+ modified_at=item.datetime,
352
+ keywords=tuple(cast(list[object], item.keyword)),
353
+ version=item.version,
354
+ market_version=item.store_version,
355
+ publication_reason=self._publication_reason(item),
356
+ render_metadata=item.template_show,
357
+ )
358
+ for item in response.my
359
+ ),
360
+ shared=tuple(
361
+ TemplateSummary(
362
+ ref=TemplateRef(template_id=item.template_id),
363
+ source=TemplateSource.SHARED,
364
+ title=item.template_name,
365
+ summary=item.comm,
366
+ library_entry=TemplateLibraryEntryRef(
367
+ relation_id=item.id,
368
+ template=TemplateRef(template_id=item.template_id),
369
+ kind=TemplateLibraryRelationKind.DIRECT_SHARE,
370
+ ),
371
+ owner=UserRef(userid=item.userid_from) if item.userid_from is not None else None,
372
+ owner_name=item.username,
373
+ )
374
+ for item in response.share
375
+ ),
376
+ market_acquired=tuple(
377
+ TemplateSummary(
378
+ ref=TemplateRef(template_id=item.template_id),
379
+ source=TemplateSource.MARKET_ACQUIRED,
380
+ title=item.template_name,
381
+ summary=item.comm,
382
+ library_entry=TemplateLibraryEntryRef(
383
+ relation_id=item.id,
384
+ template=TemplateRef(template_id=item.template_id),
385
+ kind=TemplateLibraryRelationKind.MARKET_ACQUISITION,
386
+ ),
387
+ owner_name=item.username,
388
+ )
389
+ for item in response.stores
390
+ ),
391
+ group=tuple(
392
+ TemplateSummary(
393
+ ref=TemplateRef(template_id=item.template_id),
394
+ source=TemplateSource.GROUP,
395
+ title=item.template_name,
396
+ summary=item.comm,
397
+ library_entry=TemplateLibraryEntryRef(
398
+ relation_id=item.id,
399
+ template=TemplateRef(template_id=item.template_id),
400
+ kind=TemplateLibraryRelationKind.GROUP,
401
+ ),
402
+ owner=UserRef(userid=item.userid) if item.userid is not None else None,
403
+ owner_name=item.username,
404
+ group=GroupRef(group_id=item.group_id),
405
+ group_name=item.group_name,
406
+ group_admin=item.admin,
407
+ modified_at=item.datetime,
408
+ )
409
+ for item in response.group
410
+ ),
411
+ default=tuple(
412
+ TemplateSummary(
413
+ ref=TemplateRef(template_id=item.template_id),
414
+ source=TemplateSource.DEFAULT,
415
+ title=item.template_name,
416
+ summary=item.comm,
417
+ )
418
+ for item in response.default
419
+ ),
420
+ pending_market_review=tuple(
421
+ TemplateSummary(
422
+ ref=TemplateRef(template_id=item.template_id),
423
+ source=TemplateSource.PENDING_MARKET_REVIEW,
424
+ title=item.template_name,
425
+ summary=item.comm,
426
+ owner_name=item.username,
427
+ )
428
+ for item in response.pending
429
+ ),
430
+ )
431
+
432
+ async def search_market(self, search: str = "", *, page: int = 1, page_size: int = 10) -> TemplateMarketPage:
433
+ try:
434
+ request = WireListTemplateStoreQuery(search=search, page=page, page_size=page_size)
435
+ except ValidationError:
436
+ raise MatelabUsageError("Template-market filters do not satisfy the Integration Contract.") from None
437
+ response = await self._transport.request(_LIST_TEMPLATE_MARKET, payload=request.model_dump(mode="json"))
438
+ effective_page_size = max(10, request.page_size)
439
+ return TemplateMarketPage(
440
+ items=tuple(
441
+ TemplateMarketEntry(
442
+ ref=TemplateRef(template_id=item.id),
443
+ title=item.showtext,
444
+ summary=item.comm,
445
+ intro=item.intro,
446
+ modified_at=item.datetime_modify,
447
+ published_at=item.dtime_pub,
448
+ publisher_name=item.username,
449
+ acquisition_count=item.buy_count,
450
+ acquired=bool(item.my),
451
+ )
452
+ for item in response.items
453
+ ),
454
+ page=request.page,
455
+ requested_page_size=request.page_size,
456
+ effective_page_size=effective_page_size,
457
+ total_count=response.count,
458
+ has_more=request.page * effective_page_size < response.count,
459
+ )
460
+
461
+ async def read(self, template: TemplateRef) -> TemplateContent:
462
+ try:
463
+ request = WireReadTemplateContentQuery(id=template.template_id)
464
+ except ValidationError:
465
+ raise MatelabUsageError("Template identity does not satisfy the Integration Contract.") from None
466
+ response = await self._transport.request(_READ_TEMPLATE, payload=request.model_dump(mode="json"))
467
+ return TemplateContent(ref=template, modules=tuple(self._module(module) for module in response.content))
468
+
469
+ async def share(self, templates: Sequence[TemplateRef], recipients: Sequence[UserRef]) -> TemplateShareResult:
470
+ selected = tuple(templates)
471
+ targets = tuple(recipients)
472
+ template_ids = self._bounded_unique_ids(
473
+ (template.template_id for template in selected), subject="Template share selection"
474
+ )
475
+ userids = self._bounded_unique_ids(
476
+ (recipient.userid for recipient in targets), subject="Template share recipients"
477
+ )
478
+ try:
479
+ request = WireShareMultipleTemplatesRequest(
480
+ templates=json.dumps(template_ids, separators=(",", ":")),
481
+ users=json.dumps(userids, separators=(",", ":")),
482
+ )
483
+ except ValidationError:
484
+ raise MatelabUsageError("Template share request does not satisfy the Integration Contract.") from None
485
+ _ = await self._transport.request(_SHARE_TEMPLATES, files=multipart_fields(request.model_dump(mode="json")))
486
+ return TemplateShareResult(templates=selected, recipients=targets)
487
+
488
+ async def unshare(self, entry: TemplateLibraryEntryRef) -> TemplateLibraryRemovalResult:
489
+ if entry.kind is not TemplateLibraryRelationKind.DIRECT_SHARE:
490
+ raise MatelabUsageError("Template unshare requires a direct-share library relation.")
491
+ return await self._remove_user_library_entry(entry)
492
+
493
+ async def remove_market_acquisition(self, entry: TemplateLibraryEntryRef) -> TemplateLibraryRemovalResult:
494
+ if entry.kind is not TemplateLibraryRelationKind.MARKET_ACQUISITION:
495
+ raise MatelabUsageError("Removing a market acquisition requires a market-acquisition relation.")
496
+ return await self._remove_user_library_entry(entry)
497
+
498
+ async def add_to_group(self, template: TemplateRef, group: GroupRef) -> TemplateGroupAddResult:
499
+ try:
500
+ request = WireAddGroupTemplateRequest(template_id=template.template_id, group_id=group.group_id)
501
+ except ValidationError:
502
+ raise MatelabUsageError("Template/group identity does not satisfy the Integration Contract.") from None
503
+ _ = await self._transport.request(_ADD_GROUP_TEMPLATE, files=multipart_fields(request.model_dump(mode="json")))
504
+ after = await self._verified_list("template group addition")
505
+ observed = next(
506
+ (
507
+ item.library_entry
508
+ for item in after.group
509
+ if item.ref == template and item.group == group and item.library_entry is not None
510
+ ),
511
+ None,
512
+ )
513
+ return TemplateGroupAddResult(
514
+ template=template,
515
+ group=group,
516
+ observed_entry=observed,
517
+ observation="relation_observed" if observed is not None else "relation_not_observed",
518
+ )
519
+
520
+ async def remove_from_group(self, entry: TemplateLibraryEntryRef) -> TemplateGroupRemoveResult:
521
+ if entry.kind is not TemplateLibraryRelationKind.GROUP:
522
+ raise MatelabUsageError("Removing a group template requires a group library relation.")
523
+ try:
524
+ request = WireDeleteGroupTemplateRequest(id=entry.relation_id)
525
+ except ValidationError:
526
+ raise MatelabUsageError("Group-template relation does not satisfy the Integration Contract.") from None
527
+ _ = await self._transport.request(
528
+ _REMOVE_GROUP_TEMPLATE, files=multipart_fields(request.model_dump(mode="json"))
529
+ )
530
+ after = await self._verified_list("template group removal")
531
+ return TemplateGroupRemoveResult(entry=entry, absence_confirmed=not self._relation_present(after.group, entry))
532
+
533
+ async def acquire_from_market(self, template: TemplateRef) -> TemplateMarketAcquireResult:
534
+ try:
535
+ request = WireTemplateIdRequest(id=template.template_id)
536
+ except ValidationError:
537
+ raise MatelabUsageError("Template identity does not satisfy the Integration Contract.") from None
538
+ _ = await self._transport.request(_ACQUIRE_MARKET_TEMPLATE, payload=request.model_dump(mode="json"))
539
+ after = await self._verified_list("template market acquisition")
540
+ observed = next(
541
+ (
542
+ item.library_entry
543
+ for item in after.market_acquired
544
+ if item.ref == template and item.library_entry is not None
545
+ ),
546
+ None,
547
+ )
548
+ return TemplateMarketAcquireResult(
549
+ template=template,
550
+ observed_entry=observed,
551
+ observation="relation_observed" if observed is not None else "relation_not_observed",
552
+ )
553
+
554
+ async def create(self, metadata: TemplateMetadata) -> TemplateMetadataResult:
555
+ return await self._save_metadata(template=None, metadata=metadata)
556
+
557
+ async def update(self, template: TemplateRef, metadata: TemplateMetadata) -> TemplateMetadataResult:
558
+ return await self._save_metadata(template=template, metadata=metadata)
559
+
560
+ async def save_content(self, template: TemplateRef, modules: Sequence[TemplateModule]) -> TemplateContentSaveResult:
561
+ requested = tuple(modules)
562
+ payloads = self._module_payloads(requested)
563
+ try:
564
+ request = WireSaveTemplateContentRequest(
565
+ id=template.template_id, data=json.dumps(payloads, ensure_ascii=False, separators=(",", ":"))
566
+ )
567
+ except (ValidationError, TypeError, ValueError):
568
+ raise MatelabUsageError("Template content does not satisfy the Integration Contract.") from None
569
+ _ = await self._transport.request(_SAVE_TEMPLATE_CONTENT, payload=request.model_dump(mode="json"))
570
+ observed = await self._verified_read(template, "template content replacement")
571
+ return TemplateContentSaveResult(
572
+ template=template,
573
+ requested=requested,
574
+ observed=observed,
575
+ confirmation="observed_matching" if observed.modules == requested else "observed_mismatch",
576
+ )
577
+
578
+ async def copy(self, template: TemplateRef, *, title: str) -> TemplateCopyResult:
579
+ try:
580
+ request = WireCopyTemplateRequest(id=template.template_id, template_name=title)
581
+ except ValidationError:
582
+ raise MatelabUsageError("Template copy input does not satisfy the Integration Contract.") from None
583
+ _ = await self._transport.request(_COPY_TEMPLATE, payload=request.model_dump(mode="json"))
584
+ return TemplateCopyResult(source=template, requested_title=title)
585
+
586
+ async def update_intro(
587
+ self, template: TemplateRef, *, intro: str | None, upload_binding: UploadBindingRef
588
+ ) -> TemplateIntroUpdateResult:
589
+ try:
590
+ request = WireUpdateTemplateIntroRequest(id=template.template_id, intro=intro, uid=upload_binding.uid)
591
+ except ValidationError:
592
+ raise MatelabUsageError("Template intro input does not satisfy the Integration Contract.") from None
593
+ response = await self._transport.request(_UPDATE_TEMPLATE_INTRO, payload=request.model_dump(mode="json"))
594
+ return TemplateIntroUpdateResult(template=template, intro=response.intro, upload_binding=upload_binding)
595
+
596
+ async def submit_to_market(self, template: TemplateRef) -> TemplateMarketSubmissionResult:
597
+ try:
598
+ request = WireTemplateIdRequest(id=template.template_id)
599
+ except ValidationError:
600
+ raise MatelabUsageError("Template identity does not satisfy the Integration Contract.") from None
601
+ _ = await self._transport.request(_SUBMIT_TEMPLATE, payload=request.model_dump(mode="json"))
602
+ after = await self._verified_list("template market submission")
603
+ observed = any(item.ref == template for item in after.pending_market_review)
604
+ return TemplateMarketSubmissionResult(
605
+ template=template,
606
+ observed_pending_review=observed,
607
+ observation="pending_entry_observed" if observed else "pending_entry_not_observed",
608
+ )
609
+
610
+ async def _remove_user_library_entry(self, entry: TemplateLibraryEntryRef) -> TemplateLibraryRemovalResult:
611
+ try:
612
+ request = WireRemoveUserTemplateRelationRequest(id=entry.relation_id)
613
+ except ValidationError:
614
+ raise MatelabUsageError("Template library relation does not satisfy the Integration Contract.") from None
615
+ _ = await self._transport.request(
616
+ _REMOVE_USER_LIBRARY_ENTRY, files=multipart_fields(request.model_dump(mode="json"))
617
+ )
618
+ after = await self._verified_list("template library removal")
619
+ scope = after.shared if entry.kind is TemplateLibraryRelationKind.DIRECT_SHARE else after.market_acquired
620
+ return TemplateLibraryRemovalResult(entry=entry, absence_confirmed=not self._relation_present(scope, entry))
621
+
622
+ async def _save_metadata(
623
+ self, *, template: TemplateRef | None, metadata: TemplateMetadata
624
+ ) -> TemplateMetadataResult:
625
+ if any(not isinstance(keyword, str) for keyword in cast(tuple[object, ...], metadata.keywords)):
626
+ raise MatelabUsageError("Template keywords must be strings.")
627
+ try:
628
+ request = WireSaveTemplateMetadataRequest(
629
+ id=template.template_id if template is not None else 0,
630
+ showtext=metadata.title,
631
+ comm=metadata.summary,
632
+ keyword=json.dumps(metadata.keywords, ensure_ascii=False, separators=(",", ":")),
633
+ )
634
+ except (ValidationError, TypeError, ValueError):
635
+ raise MatelabUsageError("Template metadata does not satisfy the Integration Contract.") from None
636
+ response = await self._transport.request(_SAVE_TEMPLATE_METADATA, payload=request.model_dump(mode="json"))
637
+ expected_action = "edit" if template is not None else "add"
638
+ if response.action.value != expected_action or (template is not None and response.id != template.template_id):
639
+ raise MatelabProtocolError("Matelab returned an inconsistent template metadata action or identity.")
640
+ result_ref = TemplateRef(template_id=response.id)
641
+ after = await self._verified_list("template metadata save")
642
+ observed = next((item for item in after.owned if item.ref == result_ref), None)
643
+ if observed is None:
644
+ confirmation: Literal["observed_matching", "observed_mismatch", "not_observed"] = "not_observed"
645
+ elif self._metadata_matches(observed, metadata):
646
+ confirmation = "observed_matching"
647
+ else:
648
+ confirmation = "observed_mismatch"
649
+ return TemplateMetadataResult(
650
+ template=result_ref,
651
+ requested=metadata,
652
+ action="updated" if template is not None else "created",
653
+ observed=observed,
654
+ confirmation=confirmation,
655
+ )
656
+
657
+ async def _verified_list(self, action: str) -> TemplateCollection:
658
+ try:
659
+ return await self.list()
660
+ except MatelabError as exc:
661
+ raise MatelabVerificationError(
662
+ f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
663
+ ) from exc
664
+
665
+ async def _verified_read(self, template: TemplateRef, action: str) -> TemplateContent:
666
+ try:
667
+ return await self.read(template)
668
+ except MatelabError as exc:
669
+ raise MatelabVerificationError(
670
+ f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
671
+ ) from exc
672
+
673
+ @staticmethod
674
+ def _bounded_unique_ids(values: Iterable[int], *, subject: str) -> tuple[int, ...]:
675
+ selected = tuple(values)
676
+ if (
677
+ not 1 <= len(selected) <= 10
678
+ or any(type(value) is not int or value < 1 for value in selected)
679
+ or len(selected) != len(set(selected))
680
+ ):
681
+ raise MatelabUsageError(f"{subject} must contain one through ten distinct positive ids.")
682
+ return selected
683
+
684
+ @staticmethod
685
+ def _relation_present(scope: Sequence[TemplateSummary], entry: TemplateLibraryEntryRef) -> bool:
686
+ return any(item.library_entry == entry for item in scope)
687
+
688
+ @staticmethod
689
+ def _metadata_matches(observed: TemplateSummary, requested: TemplateMetadata) -> bool:
690
+ return (
691
+ observed.title == requested.title
692
+ and (observed.summary or "") == (requested.summary or "")
693
+ and observed.keywords == requested.keywords
694
+ )
695
+
696
+ @staticmethod
697
+ def _module_payloads(modules: tuple[TemplateModule, ...]) -> tuple[dict[str, object], ...]:
698
+ if len({module.name for module in modules}) != len(modules):
699
+ raise MatelabUsageError("Template module names must be unique.")
700
+ payloads: list[dict[str, object]] = []
701
+ try:
702
+ for module in modules:
703
+ attributes = dict(module.attributes)
704
+ if _MODULE_RESERVED_FIELDS & attributes.keys():
705
+ raise MatelabUsageError("Template module attributes cannot replace canonical fields.")
706
+ payload: dict[str, object] = {**attributes, "name": module.name, "type": module.module_type}
707
+ if module.data_present:
708
+ payload["data"] = module.data
709
+ if module.language is not None:
710
+ payload["language"] = module.language
711
+ if module.style is not None:
712
+ payload["style"] = module.style
713
+ wire = WireTemplateModule.model_validate(payload)
714
+ payloads.append(wire.model_dump(mode="json", exclude_unset=True))
715
+ _ = json.dumps(payloads, ensure_ascii=False)
716
+ except (ValidationError, TypeError, ValueError):
717
+ raise MatelabUsageError("Template modules do not satisfy the Integration Contract.") from None
718
+ return tuple(payloads)
719
+
720
+ @staticmethod
721
+ def _publication_reason(item: WireOwnedTemplateListItem) -> str:
722
+ extra = cast(dict[str, object], item.model_extra or {})
723
+ stable_alias = extra.get("pub_reason")
724
+ return stable_alias if isinstance(stable_alias, str) else item.Ifnull_pub_reason_____
725
+
726
+ @staticmethod
727
+ def _module(module: WireTemplateModule) -> TemplateModule:
728
+ attributes = MappingProxyType(cast(dict[str, object], dict(module.model_extra or {})))
729
+ return TemplateModule(
730
+ name=module.name,
731
+ module_type=module.type,
732
+ data=cast(object, module.data),
733
+ data_present="data" in module.model_fields_set,
734
+ language=module.language,
735
+ style=cast(object, module.style),
736
+ attributes=attributes,
737
+ )