megaplan-sdk 0.1.0__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.
@@ -0,0 +1,794 @@
1
+ """Base resource class for Megaplan API resources."""
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterator
5
+ from typing import TYPE_CHECKING, Any, TypeVar
6
+
7
+ from megaplan_sdk.constants import ContentType
8
+ from megaplan_sdk.http_client import HTTPClient
9
+ from megaplan_sdk.logging_config import logger
10
+
11
+ if TYPE_CHECKING:
12
+ from megaplan_sdk.cache import EntityCache
13
+ from megaplan_sdk.models.comment import Comment
14
+ from megaplan_sdk.models.contractor import Contractor
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ class BaseResource:
20
+ """Base class for all API resources.
21
+
22
+ Provides common functionality for making API requests.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ http_client: HTTPClient,
28
+ cache: "EntityCache | None" = None,
29
+ default_comments_limit: int | None = None,
30
+ default_history_limit: int | None = None,
31
+ ) -> None:
32
+ """Initialize base resource.
33
+
34
+ Args:
35
+ http_client: HTTP client for making requests.
36
+ cache: Optional entity cache for caching related entities.
37
+ default_comments_limit: Default limit for comments in get_full_details().
38
+ None = use API default (no explicit limit).
39
+ default_history_limit: Default limit for history in get_full_details().
40
+ None = use API default (no explicit limit).
41
+ """
42
+ self._http = http_client
43
+ self._cache = cache
44
+ self._default_comments_limit = default_comments_limit
45
+ self._default_history_limit = default_history_limit
46
+
47
+ def _build_path(self, *parts: str) -> str:
48
+ """Build API path from parts.
49
+
50
+ Args:
51
+ *parts: Path parts.
52
+
53
+ Returns:
54
+ Combined path.
55
+ """
56
+ return "/" + "/".join(str(part).strip("/") for part in parts if part)
57
+
58
+ def _prepare_params(self, **kwargs: Any) -> dict[str, Any] | None:
59
+ """Prepare query parameters, removing None values.
60
+
61
+ Args:
62
+ **kwargs: Parameters to include.
63
+
64
+ Returns:
65
+ Dictionary with non-None parameters.
66
+ """
67
+ params = {k: v for k, v in kwargs.items() if v is not None}
68
+ return params if params else None
69
+
70
+ def _build_list_params(
71
+ self,
72
+ filter: Any | None = None,
73
+ limit: int | None = None,
74
+ page_after: dict[str, Any] | None = None,
75
+ page_before: dict[str, Any] | None = None,
76
+ page_with: dict[str, Any] | None = None,
77
+ fields: Any | None = None,
78
+ sort_by: list[dict[str, str]] | None = None,
79
+ only_requested_fields: bool | None = None,
80
+ **extra_params: Any,
81
+ ) -> dict[str, Any]:
82
+ """Build standard list parameters for pagination and filtering.
83
+
84
+ Args:
85
+ filter: Filter ID or configuration.
86
+ limit: Number of items per page.
87
+ page_after: Load page starting from this entity.
88
+ page_before: Load page strictly before this entity.
89
+ page_with: Load page containing this entity.
90
+ fields: Additional fields to include.
91
+ sort_by: Sort fields.
92
+ only_requested_fields: Return only requested fields.
93
+ **extra_params: Additional parameters (e.g., statuses, status, q).
94
+
95
+ Returns:
96
+ Dictionary with non-None parameters.
97
+ """
98
+ params: dict[str, Any] = {}
99
+
100
+ if filter is not None:
101
+ params["filter"] = filter
102
+ if limit is not None:
103
+ params["limit"] = limit
104
+ if page_after:
105
+ params["pageAfter"] = page_after
106
+ if page_before:
107
+ params["pageBefore"] = page_before
108
+ if page_with:
109
+ params["pageWith"] = page_with
110
+ if fields is not None:
111
+ params["fields"] = fields
112
+ if sort_by:
113
+ params["sortBy"] = sort_by
114
+ if only_requested_fields is not None:
115
+ params["onlyRequestedFields"] = only_requested_fields
116
+
117
+ # Add extra params (like statuses, status, q, baseOn, etc.)
118
+ params.update(extra_params)
119
+
120
+ return params if params else {}
121
+
122
+ async def _iterate_generic(
123
+ self,
124
+ content_type: str,
125
+ list_method: Any, # Method bound to instance, not a standalone Callable
126
+ limit: int = 100,
127
+ **kwargs: Any,
128
+ ) -> AsyncIterator[T]:
129
+ """Generic iterator for paginating through resources.
130
+
131
+ Args:
132
+ content_type: Entity type for pageAfter (e.g. "Task", "Deal").
133
+ list_method: The list() method to call for pagination.
134
+ limit: Items per page.
135
+ **kwargs: Additional parameters to pass to list_method.
136
+
137
+ Yields:
138
+ Individual items from the paginated results.
139
+ """
140
+ page_after = None
141
+
142
+ while True:
143
+ items: list[T] = await list_method(limit=limit, page_after=page_after, **kwargs)
144
+ if not items:
145
+ break
146
+
147
+ for item in items:
148
+ yield item
149
+
150
+ if len(items) < limit:
151
+ break
152
+
153
+ last_item = items[-1]
154
+ # TypeVar T doesn't guarantee .id attribute, but all our entities have it
155
+ item_id: int = getattr(last_item, "id", 0)
156
+ page_after = {"contentType": content_type, "id": item_id}
157
+
158
+ async def _get_entity_comments(
159
+ self,
160
+ entity_type: str,
161
+ entity_id: int,
162
+ limit: int | None = None,
163
+ page_after: dict[str, Any] | None = None,
164
+ page_before: dict[str, Any] | None = None,
165
+ page_with: dict[str, Any] | None = None,
166
+ ) -> list["Comment"]:
167
+ """Generic method to get comments for any entity.
168
+
169
+ Args:
170
+ entity_type: API path segment (e.g. "todo" for tasks, "project", "deal", "contractor").
171
+ entity_id: Entity identifier.
172
+ limit: Number of items per page.
173
+ page_after: Load page starting from this entity.
174
+ page_before: Load page strictly before this entity.
175
+ page_with: Load page containing this entity.
176
+
177
+ Returns:
178
+ List of comments.
179
+ """
180
+ from megaplan_sdk.models.comment import Comment
181
+
182
+ path = self._build_path("api", "v3", entity_type, str(entity_id), "comments")
183
+
184
+ params = self._build_list_params(
185
+ limit=limit,
186
+ page_after=page_after,
187
+ page_before=page_before,
188
+ page_with=page_with,
189
+ )
190
+
191
+ response = await self._http.get(path, params=params if params else None)
192
+ data = self._parse_list_response(response)
193
+
194
+ return [Comment(**item) if isinstance(item, dict) else item for item in data]
195
+
196
+ async def _create_entity_comment(
197
+ self,
198
+ entity_type: str,
199
+ entity_id: int,
200
+ text: str,
201
+ attaches: "list[dict[str, Any]] | None" = None,
202
+ **extra_fields: Any,
203
+ ) -> "Comment":
204
+ """Generic method to create comment for any entity.
205
+
206
+ Args:
207
+ entity_type: API path segment.
208
+ entity_id: Entity identifier.
209
+ text: Comment text.
210
+ attaches: File attachments.
211
+ **extra_fields: Additional fields (e.g. work for tasks).
212
+
213
+ Returns:
214
+ Created comment.
215
+ """
216
+ from megaplan_sdk.models.comment import Comment
217
+
218
+ path = self._build_path("api", "v3", entity_type, str(entity_id), "comments")
219
+
220
+ comment_data: dict[str, Any] = {"content": text}
221
+ if attaches:
222
+ comment_data["attaches"] = attaches
223
+ comment_data.update(extra_fields)
224
+
225
+ response = await self._http.post(path, json_data=comment_data)
226
+ return Comment(**response["data"])
227
+
228
+ async def _get_list(
229
+ self,
230
+ path: str,
231
+ model_class: type[T],
232
+ params: dict[str, Any] | None = None,
233
+ ) -> list[T]:
234
+ """Generic method to fetch and parse list response.
235
+
236
+ Args:
237
+ path: API endpoint path.
238
+ model_class: Pydantic model class for items.
239
+ params: Query parameters.
240
+
241
+ Returns:
242
+ List of model instances.
243
+ """
244
+ response = await self._http.get(path, params=params)
245
+ data = self._parse_list_response(response)
246
+
247
+ return [model_class(**item) if isinstance(item, dict) else item for item in data]
248
+
249
+ async def _create_entity(
250
+ self,
251
+ entity_type: str,
252
+ data: dict[str, Any],
253
+ model_class: type[T],
254
+ ) -> T:
255
+ """Generic create method.
256
+
257
+ Args:
258
+ entity_type: API resource type (e.g. "task", "project").
259
+ data: Entity data.
260
+ model_class: Pydantic model class.
261
+
262
+ Returns:
263
+ Created entity instance.
264
+ """
265
+ path = self._build_path("api", "v3", entity_type)
266
+ response = await self._http.post(path, json_data=data)
267
+ return model_class(**response["data"])
268
+
269
+ async def _get_entity(
270
+ self,
271
+ entity_type: str,
272
+ entity_id: int,
273
+ model_class: type[T],
274
+ ) -> T:
275
+ """Generic get method.
276
+
277
+ Args:
278
+ entity_type: API resource type.
279
+ entity_id: Entity identifier.
280
+ model_class: Pydantic model class.
281
+
282
+ Returns:
283
+ Entity instance.
284
+ """
285
+ path = self._build_path("api", "v3", entity_type, str(entity_id))
286
+ response = await self._http.get(path)
287
+ return model_class(**response["data"])
288
+
289
+ async def _update_entity(
290
+ self,
291
+ entity_type: str,
292
+ entity_id: int,
293
+ data: dict[str, Any],
294
+ model_class: type[T],
295
+ ) -> T:
296
+ """Generic update method.
297
+
298
+ Args:
299
+ entity_type: API resource type.
300
+ entity_id: Entity identifier.
301
+ data: Updated entity data.
302
+ model_class: Pydantic model class.
303
+
304
+ Returns:
305
+ Updated entity instance.
306
+ """
307
+ path = self._build_path("api", "v3", entity_type, str(entity_id))
308
+ response = await self._http.post(path, json_data=data)
309
+ return model_class(**response["data"])
310
+
311
+ async def _delete_entity(
312
+ self,
313
+ entity_type: str,
314
+ entity_id: int,
315
+ ) -> None:
316
+ """Generic delete method.
317
+
318
+ Args:
319
+ entity_type: API resource type.
320
+ entity_id: Entity identifier.
321
+ """
322
+ path = self._build_path("api", "v3", entity_type, str(entity_id))
323
+ await self._http.delete(path)
324
+
325
+ async def _get_entity_cached(
326
+ self,
327
+ entity_type: str,
328
+ entity_id: int,
329
+ model_class: type[T],
330
+ use_cache: bool = True,
331
+ ) -> T:
332
+ """Get entity with optional caching.
333
+
334
+ Fetches entity from cache if available and not expired,
335
+ otherwise fetches from API and caches result.
336
+
337
+ Args:
338
+ entity_type: API resource type (e.g., "employee", "task").
339
+ entity_id: Entity identifier.
340
+ model_class: Pydantic model class for parsing.
341
+ use_cache: Whether to use cache (default: True).
342
+
343
+ Returns:
344
+ Entity instance.
345
+
346
+ Examples:
347
+ >>> employee = await resource._get_entity_cached(
348
+ ... "employee", 123, Employee
349
+ ... )
350
+ """
351
+ # Determine contentType from entity_type
352
+ content_type = self._entity_type_to_content_type(entity_type)
353
+
354
+ # Try cache first
355
+ if use_cache and self._cache:
356
+ cached = self._cache.get(content_type, entity_id)
357
+ if cached is not None:
358
+ # Cache stores dict, convert to model
359
+ return model_class(**cached) if isinstance(cached, dict) else cached
360
+
361
+ # Fetch from API
362
+ entity = await self._get_entity(entity_type, entity_id, model_class)
363
+
364
+ # Store in cache
365
+ if use_cache and self._cache:
366
+ # Store as dict for consistency
367
+ # TypeVar T doesn't guarantee .model_dump, but all Pydantic models have it
368
+ entity_dict = entity.model_dump(by_alias=True) # type: ignore[attr-defined]
369
+ self._cache.set(content_type, entity_id, entity_dict)
370
+
371
+ return entity
372
+
373
+ async def _load_related_entities(
374
+ self,
375
+ entities: list[Any],
376
+ entity_type: str,
377
+ model_class: type[T],
378
+ ) -> dict[int, T]:
379
+ """Batch load related entities with caching.
380
+
381
+ Collects unique entity IDs, checks cache for each,
382
+ then fetches missing ones in parallel.
383
+
384
+ Args:
385
+ entities: List of BaseEntity references to load (can contain None).
386
+ entity_type: API resource type (e.g., "employee", "contractor").
387
+ model_class: Pydantic model class.
388
+
389
+ Returns:
390
+ Dict mapping entity ID to loaded entity.
391
+
392
+ Examples:
393
+ >>> # Load all unique responsible employees from tasks
394
+ >>> responsible_refs = [task.responsible for task in tasks]
395
+ >>> employees = await resource._load_related_entities(
396
+ ... responsible_refs, "employee", Employee
397
+ ... )
398
+ >>> # employees = {123: Employee(...), 456: Employee(...)}
399
+ """
400
+ # Collect unique IDs (filter out None and extract id attribute)
401
+ unique_ids: set[int] = set()
402
+ for entity in entities:
403
+ if entity is not None and hasattr(entity, "id"):
404
+ unique_ids.add(entity.id)
405
+
406
+ if not unique_ids:
407
+ return {}
408
+
409
+ content_type = self._entity_type_to_content_type(entity_type)
410
+ result: dict[int, T] = {}
411
+ ids_to_fetch: set[int] = set()
412
+
413
+ # Check cache for each ID
414
+ if self._cache:
415
+ for entity_id in unique_ids:
416
+ cached = self._cache.get(content_type, entity_id)
417
+ if cached is not None:
418
+ result[entity_id] = (
419
+ model_class(**cached) if isinstance(cached, dict) else cached
420
+ )
421
+ else:
422
+ ids_to_fetch.add(entity_id)
423
+ else:
424
+ ids_to_fetch = unique_ids
425
+
426
+ # Fetch missing entities in parallel
427
+ if ids_to_fetch:
428
+ fetch_tasks = [
429
+ self._get_entity_cached(entity_type, entity_id, model_class, use_cache=True)
430
+ for entity_id in ids_to_fetch
431
+ ]
432
+ fetched = await asyncio.gather(*fetch_tasks, return_exceptions=True)
433
+
434
+ for entity_id, entity in zip(ids_to_fetch, fetched, strict=True):
435
+ if not isinstance(entity, Exception):
436
+ result[entity_id] = entity # type: ignore[assignment]
437
+ # Ignore exceptions during batch loading
438
+
439
+ return result
440
+
441
+ @staticmethod
442
+ def _entity_type_to_content_type(entity_type: str) -> str:
443
+ """Convert API entity type to contentType.
444
+
445
+ Uses explicit mapping to avoid issues with capitalize() on CamelCase names.
446
+ For example, "contractorCompany" would become "Contractorcompany" with capitalize(),
447
+ but API expects "ContractorCompany".
448
+
449
+ Args:
450
+ entity_type: API resource type (e.g., "employee", "task", "todo", "contractorCompany").
451
+
452
+ Returns:
453
+ ContentType string (e.g., "Employee", "Task", "ContractorCompany").
454
+
455
+ Examples:
456
+ >>> BaseResource._entity_type_to_content_type("employee")
457
+ 'Employee'
458
+ >>> BaseResource._entity_type_to_content_type("task")
459
+ 'Task'
460
+ >>> BaseResource._entity_type_to_content_type("todo")
461
+ 'Task'
462
+ >>> BaseResource._entity_type_to_content_type("contractorCompany")
463
+ 'ContractorCompany'
464
+ """
465
+ # Map API path segments to ContentTypes
466
+ # This avoids issues with capitalize() on CamelCase names
467
+ mapping = {
468
+ "todo": ContentType.TASK,
469
+ "task": ContentType.TASK,
470
+ "project": ContentType.PROJECT,
471
+ "deal": ContentType.DEAL,
472
+ "employee": ContentType.EMPLOYEE,
473
+ "contractor": ContentType.CONTRACTOR,
474
+ "department": ContentType.DEPARTMENT,
475
+ "contractorCompany": ContentType.CONTRACTOR_COMPANY,
476
+ "contractorHuman": ContentType.CONTRACTOR_HUMAN,
477
+ "comment": ContentType.COMMENT,
478
+ }
479
+
480
+ result = mapping.get(entity_type)
481
+ if result:
482
+ return result
483
+
484
+ # Fallback: capitalize first letter for unknown types
485
+ # Log warning in production if this path is hit frequently
486
+ return entity_type.capitalize()
487
+
488
+ @staticmethod
489
+ def _parse_contractor_response(data: dict[str, Any]) -> "Contractor":
490
+ """Parse contractor response and return appropriate type.
491
+
492
+ Args:
493
+ data: Contractor data dictionary.
494
+
495
+ Returns:
496
+ Contractor, ContractorCompany, or ContractorHuman instance.
497
+ """
498
+ from megaplan_sdk.models.contractor import Contractor, ContractorCompany, ContractorHuman
499
+
500
+ content_type = data.get("contentType", ContentType.CONTRACTOR)
501
+ if content_type == ContentType.CONTRACTOR_COMPANY:
502
+ return ContractorCompany(**data)
503
+ elif content_type == ContentType.CONTRACTOR_HUMAN:
504
+ return ContractorHuman(**data)
505
+ return Contractor(**data)
506
+
507
+ async def _get_entity_related_list(
508
+ self,
509
+ entity_type: str,
510
+ entity_id: int,
511
+ related_type: str,
512
+ limit: int | None = None,
513
+ page_after: dict[str, Any] | None = None,
514
+ page_before: dict[str, Any] | None = None,
515
+ page_with: dict[str, Any] | None = None,
516
+ ) -> list[Any]:
517
+ """Generic method to get related list (auditors, executors, milestones).
518
+
519
+ Args:
520
+ entity_type: API resource type (e.g., "task", "project").
521
+ entity_id: Entity identifier.
522
+ related_type: Related resource type (e.g., "auditors", "executors", "milestones").
523
+ limit: Number of items per page.
524
+ page_after: Load page starting from this entity.
525
+ page_before: Load page strictly before this entity.
526
+ page_with: Load page containing this entity.
527
+
528
+ Returns:
529
+ List of related entities.
530
+ """
531
+ path = self._build_path("api", "v3", entity_type, str(entity_id), related_type)
532
+
533
+ params = self._build_list_params(
534
+ limit=limit,
535
+ page_after=page_after,
536
+ page_before=page_before,
537
+ page_with=page_with,
538
+ )
539
+
540
+ response = await self._http.get(path, params=params if params else None)
541
+ return self._parse_list_response(response)
542
+
543
+ async def _add_entity_related(
544
+ self,
545
+ entity_type: str,
546
+ entity_id: int,
547
+ related_type: str,
548
+ related_id: int,
549
+ related_content_type: str = ContentType.EMPLOYEE,
550
+ data_override: dict[str, Any] | None = None,
551
+ ) -> Any:
552
+ """Generic method to add related entity (auditor, executor, milestone).
553
+
554
+ Args:
555
+ entity_type: API resource type (e.g., "task", "project").
556
+ entity_id: Entity identifier.
557
+ related_type: Related resource type (e.g., "auditors", "executors", "milestones").
558
+ related_id: Related entity ID.
559
+ related_content_type: Content type of related entity (usually "Employee").
560
+ data_override: Optional data override (for milestones with custom data).
561
+
562
+ Returns:
563
+ Added related entity.
564
+ """
565
+ path = self._build_path("api", "v3", entity_type, str(entity_id), related_type)
566
+
567
+ if data_override:
568
+ related_data = data_override
569
+ else:
570
+ related_data = {"id": related_id, "contentType": related_content_type}
571
+
572
+ response = await self._http.post(path, json_data=related_data)
573
+ return self._parse_single_response(response)
574
+
575
+ async def _remove_entity_related(
576
+ self,
577
+ entity_type: str,
578
+ entity_id: int,
579
+ related_type: str,
580
+ related_id: int,
581
+ related_content_type: str = ContentType.EMPLOYEE,
582
+ ) -> None:
583
+ """Generic method to remove related entity (auditor, executor, milestone).
584
+
585
+ Args:
586
+ entity_type: API resource type (e.g., "task", "project").
587
+ entity_id: Entity identifier.
588
+ related_type: Related resource type (e.g., "auditors", "executors", "milestones").
589
+ related_id: Related entity ID.
590
+ related_content_type: Content type of related entity (usually "Employee").
591
+ """
592
+ path = self._build_path(
593
+ "api",
594
+ "v3",
595
+ entity_type,
596
+ str(entity_id),
597
+ related_type,
598
+ related_content_type,
599
+ str(related_id),
600
+ )
601
+ await self._http.delete(path)
602
+
603
+ async def _get_entity_history(
604
+ self,
605
+ entity_type: str,
606
+ entity_id: int,
607
+ limit: int | None = None,
608
+ page_after: dict[str, Any] | None = None,
609
+ page_before: dict[str, Any] | None = None,
610
+ page_with: dict[str, Any] | None = None,
611
+ ) -> list[dict[str, Any]]:
612
+ """Generic method to get history log for any entity.
613
+
614
+ Args:
615
+ entity_type: API resource type (e.g., "task", "project", "deal").
616
+ entity_id: Entity identifier.
617
+ limit: Number of items per page.
618
+ page_after: Load page starting from this entity.
619
+ page_before: Load page strictly before this entity.
620
+ page_with: Load page containing this entity.
621
+
622
+ Returns:
623
+ List of history entries.
624
+ """
625
+ path = self._build_path("api", "v3", entity_type, str(entity_id), "history")
626
+
627
+ params = self._build_list_params(
628
+ limit=limit,
629
+ page_after=page_after,
630
+ page_before=page_before,
631
+ page_with=page_with,
632
+ )
633
+
634
+ response = await self._http.get(path, params=params if params else None)
635
+ return self._parse_list_response(response)
636
+
637
+ async def _search_entity_history(
638
+ self,
639
+ entity_type: str,
640
+ entity_id: int,
641
+ query: str,
642
+ limit: int | None = None,
643
+ page_after: dict[str, Any] | None = None,
644
+ page_before: dict[str, Any] | None = None,
645
+ page_with: dict[str, Any] | None = None,
646
+ ) -> list[dict[str, Any]]:
647
+ """Generic method to search in entity history log.
648
+
649
+ Args:
650
+ entity_type: API resource type (e.g., "task", "project", "deal").
651
+ entity_id: Entity identifier.
652
+ query: Search query.
653
+ limit: Number of items per page.
654
+ page_after: Load page starting from this entity.
655
+ page_before: Load page strictly before this entity.
656
+ page_with: Load page containing this entity.
657
+
658
+ Returns:
659
+ List of matching history entries.
660
+ """
661
+ path = self._build_path("api", "v3", entity_type, str(entity_id), "history", "search")
662
+
663
+ params = self._build_list_params(
664
+ limit=limit,
665
+ page_after=page_after,
666
+ page_before=page_before,
667
+ page_with=page_with,
668
+ )
669
+ params_dict: dict[str, Any] = params if params is not None else {}
670
+ params_dict["q"] = query
671
+
672
+ response = await self._http.get(path, params=params_dict)
673
+ return self._parse_list_response(response)
674
+
675
+ async def _fetch_details_parallel(
676
+ self,
677
+ fetch_map: dict[str, Any],
678
+ ) -> dict[str, Any]:
679
+ """Execute fetch tasks in parallel safely.
680
+
681
+ Uses return_exceptions=True to prevent one failed fetch
682
+ from breaking the entire request. Errors are logged but
683
+ don't stop execution.
684
+
685
+ Args:
686
+ fetch_map: Dictionary of task_name -> coroutine.
687
+
688
+ Returns:
689
+ Dictionary of task_name -> result (None on error).
690
+ """
691
+ if not fetch_map:
692
+ return {}
693
+
694
+ results = await asyncio.gather(*fetch_map.values(), return_exceptions=True)
695
+
696
+ final_data: dict[str, Any] = {}
697
+ for key, result in zip(fetch_map.keys(), results, strict=True):
698
+ if isinstance(result, Exception):
699
+ logger.warning(f"Failed to fetch {key}: {result}")
700
+ final_data[key] = None
701
+ else:
702
+ final_data[key] = result
703
+
704
+ return final_data
705
+
706
+ def _parse_list_response(self, response: dict[str, Any]) -> list[Any]:
707
+ """Parse list response from API.
708
+
709
+ Args:
710
+ response: API response dictionary.
711
+
712
+ Returns:
713
+ List of items from response data.
714
+ """
715
+ data = response.get("data", [])
716
+ return data if isinstance(data, list) else []
717
+
718
+ def _parse_single_response(self, response: dict[str, Any]) -> dict[str, Any]:
719
+ """Parse single entity response from API.
720
+
721
+ Args:
722
+ response: API response dictionary.
723
+
724
+ Returns:
725
+ Entity data dictionary.
726
+ """
727
+ data = response.get("data", {})
728
+ return data if isinstance(data, dict) else {}
729
+
730
+ async def _expand_list_entities(
731
+ self,
732
+ entities: list[T],
733
+ expand: list[str] | None,
734
+ expand_config: dict[str, tuple[str, type, str]],
735
+ ) -> dict[str, dict[int, Any]]:
736
+ """Expand related entities for list of entities.
737
+
738
+ Unified method to handle expand logic in list() methods.
739
+ Returns empty dict if expand is None or empty, or if entities list is empty.
740
+
741
+ Args:
742
+ entities: List of entities to expand.
743
+ expand: List of field names to expand (None or empty means no expansion).
744
+ expand_config: Configuration mapping field_name -> (entity_type, model_class, content_type).
745
+
746
+ Returns:
747
+ Dictionary mapping field_name -> {entity_id -> loaded_entity}.
748
+ """
749
+ if not expand or not entities:
750
+ return {}
751
+
752
+ return await self._expand_entities(entities, expand, expand_config)
753
+
754
+ async def _expand_entities(
755
+ self,
756
+ entities: list[T],
757
+ expand: list[str],
758
+ expand_config: dict[str, tuple[str, type, str]],
759
+ ) -> dict[str, dict[int, Any]]:
760
+ """Generic method to expand related entities.
761
+
762
+ Args:
763
+ entities: List of entities to expand.
764
+ expand: List of field names to expand.
765
+ expand_config: Configuration mapping field_name -> (entity_type, model_class, content_type).
766
+
767
+ Returns:
768
+ Dictionary mapping field_name -> {entity_id -> loaded_entity}.
769
+ """
770
+ if not expand or not entities:
771
+ return {}
772
+
773
+ result: dict[str, dict[int, Any]] = {}
774
+
775
+ for field_name in expand:
776
+ if field_name not in expand_config:
777
+ continue
778
+
779
+ entity_type, model_class, _ = expand_config[field_name]
780
+
781
+ # Collect references for this field
782
+ refs = []
783
+ for entity in entities:
784
+ field_value = getattr(entity, field_name, None)
785
+ if field_value is not None and hasattr(field_value, "id"):
786
+ refs.append(field_value)
787
+
788
+ if refs:
789
+ loaded: dict[int, T] = await self._load_related_entities(
790
+ refs, entity_type, model_class
791
+ )
792
+ result[field_name] = loaded
793
+
794
+ return result