strapi-kit 0.0.1__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.
Files changed (55) hide show
  1. strapi_kit/__init__.py +97 -0
  2. strapi_kit/__version__.py +15 -0
  3. strapi_kit/_version.py +34 -0
  4. strapi_kit/auth/__init__.py +7 -0
  5. strapi_kit/auth/api_token.py +48 -0
  6. strapi_kit/cache/__init__.py +5 -0
  7. strapi_kit/cache/schema_cache.py +211 -0
  8. strapi_kit/client/__init__.py +11 -0
  9. strapi_kit/client/async_client.py +1032 -0
  10. strapi_kit/client/base.py +460 -0
  11. strapi_kit/client/sync_client.py +980 -0
  12. strapi_kit/config_provider.py +368 -0
  13. strapi_kit/exceptions/__init__.py +37 -0
  14. strapi_kit/exceptions/errors.py +205 -0
  15. strapi_kit/export/__init__.py +10 -0
  16. strapi_kit/export/exporter.py +384 -0
  17. strapi_kit/export/importer.py +619 -0
  18. strapi_kit/export/media_handler.py +322 -0
  19. strapi_kit/export/relation_resolver.py +172 -0
  20. strapi_kit/models/__init__.py +104 -0
  21. strapi_kit/models/bulk.py +69 -0
  22. strapi_kit/models/config.py +174 -0
  23. strapi_kit/models/enums.py +97 -0
  24. strapi_kit/models/export_format.py +166 -0
  25. strapi_kit/models/import_options.py +142 -0
  26. strapi_kit/models/request/__init__.py +1 -0
  27. strapi_kit/models/request/fields.py +65 -0
  28. strapi_kit/models/request/filters.py +611 -0
  29. strapi_kit/models/request/pagination.py +168 -0
  30. strapi_kit/models/request/populate.py +281 -0
  31. strapi_kit/models/request/query.py +429 -0
  32. strapi_kit/models/request/sort.py +147 -0
  33. strapi_kit/models/response/__init__.py +1 -0
  34. strapi_kit/models/response/base.py +75 -0
  35. strapi_kit/models/response/component.py +67 -0
  36. strapi_kit/models/response/media.py +91 -0
  37. strapi_kit/models/response/meta.py +44 -0
  38. strapi_kit/models/response/normalized.py +168 -0
  39. strapi_kit/models/response/relation.py +48 -0
  40. strapi_kit/models/response/v4.py +70 -0
  41. strapi_kit/models/response/v5.py +57 -0
  42. strapi_kit/models/schema.py +93 -0
  43. strapi_kit/operations/__init__.py +16 -0
  44. strapi_kit/operations/media.py +226 -0
  45. strapi_kit/operations/streaming.py +144 -0
  46. strapi_kit/parsers/__init__.py +5 -0
  47. strapi_kit/parsers/version_detecting.py +171 -0
  48. strapi_kit/protocols.py +455 -0
  49. strapi_kit/utils/__init__.py +15 -0
  50. strapi_kit/utils/rate_limiter.py +201 -0
  51. strapi_kit/utils/uid.py +88 -0
  52. strapi_kit-0.0.1.dist-info/METADATA +1098 -0
  53. strapi_kit-0.0.1.dist-info/RECORD +55 -0
  54. strapi_kit-0.0.1.dist-info/WHEEL +4 -0
  55. strapi_kit-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,322 @@
1
+ """Media file handling for export and import operations.
2
+
3
+ This module handles downloading media files during export and
4
+ uploading them during import.
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ import unicodedata
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from strapi_kit.models.export_format import ExportedMediaFile
14
+ from strapi_kit.models.response.media import MediaFile
15
+
16
+ if TYPE_CHECKING:
17
+ from strapi_kit.client.sync_client import SyncClient
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class MediaHandler:
23
+ """Handles media file operations for export/import.
24
+
25
+ This class provides utilities for:
26
+ - Extracting media references from entity data
27
+ - Downloading media files during export
28
+ - Uploading media files during import
29
+ - Updating entity references with new media IDs
30
+ """
31
+
32
+ @staticmethod
33
+ def _is_media(item: dict[str, Any]) -> bool:
34
+ """Check if item is media (v4 or v5 format).
35
+
36
+ v4 format: {"id": 1, "attributes": {"mime": "image/jpeg", ...}}
37
+ v5 format: {"id": 1, "mime": "image/jpeg", ...}
38
+
39
+ Args:
40
+ item: Dictionary to check
41
+
42
+ Returns:
43
+ True if item is a media object
44
+ """
45
+ # v5 format: mime at top level
46
+ if "mime" in item:
47
+ return True
48
+ # v4 format: mime nested in attributes
49
+ if "attributes" in item and isinstance(item["attributes"], dict):
50
+ return "mime" in item["attributes"]
51
+ return False
52
+
53
+ @staticmethod
54
+ def _get_media_id(item: dict[str, Any]) -> int | None:
55
+ """Extract ID from media item (v4 or v5 format).
56
+
57
+ Args:
58
+ item: Media dictionary
59
+
60
+ Returns:
61
+ Media ID or None if not found
62
+ """
63
+ return item.get("id")
64
+
65
+ @staticmethod
66
+ def _sanitize_filename(name: str, max_length: int = 200) -> str:
67
+ """Sanitize filename to prevent path traversal and other issues.
68
+
69
+ Removes or replaces dangerous characters and path components that
70
+ could be used for path traversal attacks.
71
+
72
+ Args:
73
+ name: Original filename from media
74
+ max_length: Maximum length for the filename
75
+
76
+ Returns:
77
+ Sanitized filename safe for filesystem use
78
+
79
+ Examples:
80
+ >>> MediaHandler._sanitize_filename("../../../etc/passwd")
81
+ '______etc_passwd'
82
+ >>> MediaHandler._sanitize_filename("image<script>.jpg")
83
+ 'image_script_.jpg'
84
+ >>> MediaHandler._sanitize_filename("")
85
+ 'unnamed'
86
+ """
87
+ if not name or not name.strip():
88
+ return "unnamed"
89
+
90
+ # Normalize unicode characters
91
+ name = unicodedata.normalize("NFKC", name)
92
+
93
+ # Remove null bytes
94
+ name = name.replace("\x00", "")
95
+
96
+ # Replace path traversal sequences first
97
+ name = name.replace("..", "_")
98
+
99
+ # Replace dangerous characters: / \ : * ? " < > |
100
+ name = re.sub(r'[/\\:*?"<>|]', "_", name)
101
+
102
+ # Remove leading/trailing dots and spaces (problematic on Windows)
103
+ name = name.strip(". ")
104
+
105
+ # Handle empty result after stripping
106
+ if not name:
107
+ return "unnamed"
108
+
109
+ # Truncate while preserving extension
110
+ if len(name) > max_length:
111
+ parts = name.rsplit(".", 1)
112
+ if len(parts) == 2 and len(parts[1]) <= 10:
113
+ # Has reasonable extension, preserve it
114
+ ext_with_dot = "." + parts[1]
115
+ base_max = max_length - len(ext_with_dot)
116
+ name = parts[0][:base_max] + ext_with_dot
117
+ else:
118
+ name = name[:max_length]
119
+
120
+ return name or "unnamed"
121
+
122
+ @staticmethod
123
+ def extract_media_references(data: dict[str, Any]) -> list[int]:
124
+ """Extract media file IDs from entity data.
125
+
126
+ Searches for media references in various Strapi formats:
127
+ - Single media: {"data": {"id": 1}}
128
+ - Multiple media: {"data": [{"id": 1}, {"id": 2}]}
129
+
130
+ Args:
131
+ data: Entity attributes dictionary
132
+
133
+ Returns:
134
+ List of media file IDs found in the data
135
+
136
+ Example:
137
+ >>> data = {
138
+ ... "title": "Article",
139
+ ... "cover": {"data": {"id": 5}},
140
+ ... "gallery": {"data": [{"id": 10}, {"id": 11}]}
141
+ ... }
142
+ >>> MediaHandler.extract_media_references(data)
143
+ [5, 10, 11]
144
+ """
145
+ media_ids: list[int] = []
146
+
147
+ for field_value in data.values():
148
+ if isinstance(field_value, dict) and "data" in field_value:
149
+ media_data = field_value["data"]
150
+
151
+ if media_data is None:
152
+ continue
153
+ elif isinstance(media_data, dict) and MediaHandler._is_media(media_data):
154
+ # Single media file (v4 or v5 format)
155
+ media_id = MediaHandler._get_media_id(media_data)
156
+ if media_id is not None:
157
+ media_ids.append(media_id)
158
+ elif isinstance(media_data, list):
159
+ # Multiple media files
160
+ for item in media_data:
161
+ if isinstance(item, dict) and MediaHandler._is_media(item):
162
+ media_id = MediaHandler._get_media_id(item)
163
+ if media_id is not None:
164
+ media_ids.append(media_id)
165
+
166
+ return media_ids
167
+
168
+ @staticmethod
169
+ def download_media_file(
170
+ client: "SyncClient",
171
+ media: MediaFile,
172
+ output_dir: Path,
173
+ ) -> Path:
174
+ """Download a media file to local directory.
175
+
176
+ Args:
177
+ client: Strapi client
178
+ media: Media file metadata
179
+ output_dir: Directory to save file to
180
+
181
+ Returns:
182
+ Path where file was saved
183
+
184
+ Example:
185
+ >>> output_dir = Path("export/media")
186
+ >>> local_path = MediaHandler.download_media_file(
187
+ ... client, media, output_dir
188
+ ... )
189
+ """
190
+ # Create output directory if needed
191
+ output_dir.mkdir(parents=True, exist_ok=True)
192
+
193
+ # Generate safe filename with sanitization
194
+ safe_name = MediaHandler._sanitize_filename(media.name)
195
+ filename = f"{media.id}_{safe_name}"
196
+ output_path = output_dir / filename
197
+
198
+ # Download file
199
+ client.download_file(media.url, save_path=str(output_path))
200
+
201
+ logger.info(f"Downloaded media file: {filename}")
202
+ return output_path
203
+
204
+ @staticmethod
205
+ def create_media_export(media: MediaFile, local_path: Path) -> ExportedMediaFile:
206
+ """Create export metadata for a media file.
207
+
208
+ Args:
209
+ media: Media file metadata from Strapi
210
+ local_path: Local path where file is saved
211
+
212
+ Returns:
213
+ ExportedMediaFile with metadata
214
+ """
215
+ # MediaFile.size is in KB, ExportedMediaFile.size expects bytes
216
+ size_in_bytes = int(media.size * 1024) if media.size else 0
217
+ return ExportedMediaFile(
218
+ id=media.id,
219
+ url=media.url,
220
+ name=media.name,
221
+ mime=media.mime,
222
+ size=size_in_bytes,
223
+ hash=media.hash or "",
224
+ local_path=str(local_path.name),
225
+ )
226
+
227
+ @staticmethod
228
+ def upload_media_file(
229
+ client: "SyncClient",
230
+ file_path: Path,
231
+ original_metadata: ExportedMediaFile,
232
+ ) -> MediaFile:
233
+ """Upload a media file to Strapi.
234
+
235
+ Args:
236
+ client: Strapi client
237
+ file_path: Path to local file
238
+ original_metadata: Original media metadata from export
239
+
240
+ Returns:
241
+ Uploaded media file metadata with new ID
242
+
243
+ Example:
244
+ >>> file_path = Path("export/media/5_image.jpg")
245
+ >>> uploaded = MediaHandler.upload_media_file(
246
+ ... client, file_path, exported_media
247
+ ... )
248
+ >>> print(f"Old ID: {exported_media.id}, New ID: {uploaded.id}")
249
+ """
250
+ # Upload file with original metadata
251
+ uploaded = client.upload_file(
252
+ str(file_path),
253
+ alternative_text=original_metadata.name,
254
+ caption=original_metadata.name,
255
+ )
256
+
257
+ logger.info(
258
+ f"Uploaded media file: {original_metadata.name} "
259
+ f"(old ID: {original_metadata.id}, new ID: {uploaded.id})"
260
+ )
261
+ return uploaded
262
+
263
+ @staticmethod
264
+ def update_media_references(
265
+ data: dict[str, Any],
266
+ media_id_mapping: dict[int, int],
267
+ ) -> dict[str, Any]:
268
+ """Update media IDs in entity data using mapping.
269
+
270
+ Args:
271
+ data: Entity attributes dictionary
272
+ media_id_mapping: Mapping of old media IDs to new IDs
273
+
274
+ Returns:
275
+ Updated data with new media IDs
276
+
277
+ Example:
278
+ >>> data = {"cover": {"data": {"id": 5}}}
279
+ >>> mapping = {5: 50}
280
+ >>> updated = MediaHandler.update_media_references(data, mapping)
281
+ >>> updated["cover"]["data"]["id"]
282
+ 50
283
+ """
284
+ updated_data = {}
285
+
286
+ for field_name, field_value in data.items():
287
+ if isinstance(field_value, dict) and "data" in field_value:
288
+ media_data = field_value["data"]
289
+
290
+ if media_data is None:
291
+ updated_data[field_name] = field_value
292
+ elif isinstance(media_data, dict) and MediaHandler._is_media(media_data):
293
+ # Single media file (v4 or v5 format)
294
+ old_id = MediaHandler._get_media_id(media_data)
295
+ if old_id and old_id in media_id_mapping:
296
+ # Update with new ID
297
+ updated_media = media_data.copy()
298
+ updated_media["id"] = media_id_mapping[old_id]
299
+ updated_data[field_name] = {"data": updated_media}
300
+ else:
301
+ updated_data[field_name] = field_value
302
+ elif isinstance(media_data, list):
303
+ # Multiple media files
304
+ updated_list = []
305
+ for item in media_data:
306
+ if isinstance(item, dict) and MediaHandler._is_media(item):
307
+ old_id = MediaHandler._get_media_id(item)
308
+ if old_id and old_id in media_id_mapping:
309
+ updated_item = item.copy()
310
+ updated_item["id"] = media_id_mapping[old_id]
311
+ updated_list.append(updated_item)
312
+ else:
313
+ updated_list.append(item)
314
+ else:
315
+ updated_list.append(item)
316
+ updated_data[field_name] = {"data": updated_list}
317
+ else:
318
+ updated_data[field_name] = field_value
319
+ else:
320
+ updated_data[field_name] = field_value
321
+
322
+ return updated_data
@@ -0,0 +1,172 @@
1
+ """Relation resolution for import operations.
2
+
3
+ This module handles extracting relations from entities during export
4
+ and resolving them during import using ID mappings.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class RelationResolver:
14
+ """Handles relation extraction and resolution for export/import.
15
+
16
+ During export: Extracts relation IDs from entity attributes
17
+ During import: Resolves old IDs to new IDs using mapping
18
+ """
19
+
20
+ @staticmethod
21
+ def extract_relations(data: dict[str, Any]) -> dict[str, list[int | str]]:
22
+ """Extract relation field IDs from entity data.
23
+
24
+ Args:
25
+ data: Entity attributes dictionary
26
+
27
+ Returns:
28
+ Dictionary mapping relation field names to lists of IDs
29
+
30
+ Example:
31
+ >>> data = {
32
+ ... "title": "Article",
33
+ ... "author": {"data": {"id": 5}},
34
+ ... "categories": {"data": [{"id": 1}, {"id": 2}]}
35
+ ... }
36
+ >>> RelationResolver.extract_relations(data)
37
+ {'author': [5], 'categories': [1, 2]}
38
+ """
39
+ relations: dict[str, list[int | str]] = {}
40
+
41
+ for field_name, field_value in data.items():
42
+ if isinstance(field_value, dict) and "data" in field_value:
43
+ # This looks like a relation field
44
+ relation_data = field_value["data"]
45
+
46
+ if relation_data is None:
47
+ # Null relation
48
+ relations[field_name] = []
49
+ elif isinstance(relation_data, dict):
50
+ # Single relation
51
+ if "id" in relation_data:
52
+ relations[field_name] = [relation_data["id"]]
53
+ elif isinstance(relation_data, list):
54
+ # Multiple relations
55
+ ids = [item["id"] for item in relation_data if "id" in item]
56
+ if ids:
57
+ relations[field_name] = ids
58
+
59
+ return relations
60
+
61
+ @staticmethod
62
+ def strip_relations(data: dict[str, Any]) -> dict[str, Any]:
63
+ """Remove relation fields from entity data.
64
+
65
+ Useful for importing entities without relations first,
66
+ then adding relations in a second pass.
67
+
68
+ Args:
69
+ data: Entity attributes dictionary
70
+
71
+ Returns:
72
+ Copy of data with relation fields removed
73
+
74
+ Example:
75
+ >>> data = {"title": "Article", "author": {"data": {"id": 5}}}
76
+ >>> RelationResolver.strip_relations(data)
77
+ {'title': 'Article'}
78
+ """
79
+ cleaned_data = {}
80
+
81
+ for field_name, field_value in data.items():
82
+ # Skip fields that look like relations
83
+ if isinstance(field_value, dict) and "data" in field_value:
84
+ continue
85
+
86
+ cleaned_data[field_name] = field_value
87
+
88
+ return cleaned_data
89
+
90
+ @staticmethod
91
+ def resolve_relations(
92
+ relations: dict[str, list[int | str]],
93
+ id_mapping: dict[str, dict[int, int]],
94
+ content_type: str,
95
+ ) -> dict[str, list[int]]:
96
+ """Resolve old relation IDs to new IDs using mapping.
97
+
98
+ Args:
99
+ relations: Relation field mapping (field -> [old_ids])
100
+ id_mapping: ID mapping (content_type -> {old_id: new_id})
101
+ content_type: Content type of the related entities
102
+
103
+ Returns:
104
+ Resolved relations with new IDs
105
+
106
+ Example:
107
+ >>> relations = {"categories": [1, 2]}
108
+ >>> id_mapping = {
109
+ ... "api::category.category": {1: 10, 2: 11}
110
+ ... }
111
+ >>> RelationResolver.resolve_relations(
112
+ ... relations,
113
+ ... id_mapping,
114
+ ... "api::category.category"
115
+ ... )
116
+ {'categories': [10, 11]}
117
+ """
118
+ resolved: dict[str, list[int]] = {}
119
+
120
+ type_mapping = id_mapping.get(content_type, {})
121
+
122
+ for field_name, old_ids in relations.items():
123
+ new_ids = []
124
+ for old_id in old_ids:
125
+ if isinstance(old_id, int) and old_id in type_mapping:
126
+ new_ids.append(type_mapping[old_id])
127
+ else:
128
+ logger.warning(
129
+ f"Could not resolve {content_type} ID {old_id} for field {field_name}"
130
+ )
131
+
132
+ if new_ids:
133
+ resolved[field_name] = new_ids
134
+
135
+ return resolved
136
+
137
+ @staticmethod
138
+ def build_relation_payload(
139
+ relations: dict[str, list[int]],
140
+ ) -> dict[str, Any]:
141
+ """Build Strapi relation payload format.
142
+
143
+ Args:
144
+ relations: Resolved relations (field -> [new_ids])
145
+
146
+ Returns:
147
+ Payload in Strapi format for updating relations
148
+
149
+ Example:
150
+ >>> relations = {"author": [10], "categories": [11, 12]}
151
+ >>> RelationResolver.build_relation_payload(relations)
152
+ {'author': 10, 'categories': [11, 12]}
153
+
154
+ >>> # Empty list clears the relation
155
+ >>> relations = {"author": []}
156
+ >>> RelationResolver.build_relation_payload(relations)
157
+ {'author': []}
158
+ """
159
+ payload: dict[str, Any] = {}
160
+
161
+ for field_name, ids in relations.items():
162
+ if len(ids) == 0:
163
+ # Empty list - explicit clear of relation
164
+ payload[field_name] = []
165
+ elif len(ids) == 1:
166
+ # Single relation - use single ID
167
+ payload[field_name] = ids[0]
168
+ else:
169
+ # Multiple relations - use array
170
+ payload[field_name] = ids
171
+
172
+ return payload
@@ -0,0 +1,104 @@
1
+ """Data models for strapi-kit.
2
+
3
+ Includes configuration models and request/response models for Strapi API interactions.
4
+ """
5
+
6
+ from .bulk import BulkOperationFailure, BulkOperationResult
7
+ from .config import RetryConfig, StrapiConfig
8
+ from .enums import FilterOperator, PublicationState, SortDirection
9
+ from .export_format import ExportData, ExportedEntity, ExportedMediaFile, ExportMetadata
10
+ from .import_options import ConflictResolution, ImportOptions, ImportResult
11
+ from .request.fields import FieldSelection
12
+ from .request.filters import FilterBuilder, FilterCondition, FilterGroup
13
+ from .request.pagination import OffsetPagination, PagePagination, Pagination
14
+ from .request.populate import Populate, PopulateField
15
+ from .request.query import StrapiQuery
16
+ from .request.sort import Sort, SortField
17
+ from .response.base import (
18
+ BaseStrapiResponse,
19
+ StrapiCollectionResponse,
20
+ StrapiSingleResponse,
21
+ )
22
+ from .response.component import Component, DynamicZoneBlock
23
+ from .response.media import MediaFile, MediaFormat
24
+ from .response.meta import PaginationMeta, ResponseMeta
25
+ from .response.normalized import (
26
+ NormalizedCollectionResponse,
27
+ NormalizedEntity,
28
+ NormalizedSingleResponse,
29
+ )
30
+ from .response.relation import RelationData
31
+ from .response.v4 import V4Attributes, V4CollectionResponse, V4Entity, V4SingleResponse
32
+ from .response.v5 import V5CollectionResponse, V5Entity, V5SingleResponse
33
+ from .schema import ContentTypeSchema, FieldSchema, FieldType, RelationType
34
+
35
+ __all__ = [
36
+ # Configuration
37
+ "StrapiConfig",
38
+ "RetryConfig",
39
+ # Bulk Operations
40
+ "BulkOperationResult",
41
+ "BulkOperationFailure",
42
+ # Export/Import
43
+ "ExportData",
44
+ "ExportMetadata",
45
+ "ExportedEntity",
46
+ "ExportedMediaFile",
47
+ "ImportOptions",
48
+ "ImportResult",
49
+ "ConflictResolution",
50
+ # Enums
51
+ "FilterOperator",
52
+ "SortDirection",
53
+ "PublicationState",
54
+ # Request models - Filters
55
+ "FilterBuilder",
56
+ "FilterCondition",
57
+ "FilterGroup",
58
+ # Request models - Sort
59
+ "Sort",
60
+ "SortField",
61
+ # Request models - Pagination
62
+ "PagePagination",
63
+ "OffsetPagination",
64
+ "Pagination",
65
+ # Request models - Fields
66
+ "FieldSelection",
67
+ # Request models - Populate
68
+ "Populate",
69
+ "PopulateField",
70
+ # Request models - Query (Main API)
71
+ "StrapiQuery",
72
+ # Response models - Base
73
+ "BaseStrapiResponse",
74
+ "StrapiSingleResponse",
75
+ "StrapiCollectionResponse",
76
+ # Response models - Meta
77
+ "PaginationMeta",
78
+ "ResponseMeta",
79
+ # Response models - V4
80
+ "V4Attributes",
81
+ "V4Entity",
82
+ "V4SingleResponse",
83
+ "V4CollectionResponse",
84
+ # Response models - V5
85
+ "V5Entity",
86
+ "V5SingleResponse",
87
+ "V5CollectionResponse",
88
+ # Response models - Normalized
89
+ "NormalizedEntity",
90
+ "NormalizedSingleResponse",
91
+ "NormalizedCollectionResponse",
92
+ # Response models - Media
93
+ "MediaFile",
94
+ "MediaFormat",
95
+ # Response models - Relations & Components
96
+ "RelationData",
97
+ "Component",
98
+ "DynamicZoneBlock",
99
+ # Schema models
100
+ "ContentTypeSchema",
101
+ "FieldSchema",
102
+ "FieldType",
103
+ "RelationType",
104
+ ]
@@ -0,0 +1,69 @@
1
+ """Models for bulk operation results.
2
+
3
+ This module provides models for tracking results of bulk operations
4
+ like bulk_create, bulk_update, and bulk_delete.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from .response.normalized import NormalizedEntity
12
+
13
+
14
+ class BulkOperationFailure(BaseModel):
15
+ """Represents a failed item in a bulk operation.
16
+
17
+ Attributes:
18
+ index: Position in original list
19
+ item: Original item data
20
+ error: Error message
21
+ exception: Original exception (if available)
22
+ """
23
+
24
+ index: int = Field(..., description="Index in original list")
25
+ item: dict[str, Any] = Field(..., description="Original item data")
26
+ error: str = Field(..., description="Error message")
27
+ exception: Exception | None = Field(None, description="Original exception")
28
+
29
+ model_config = {"arbitrary_types_allowed": True}
30
+
31
+
32
+ class BulkOperationResult(BaseModel):
33
+ """Result of a bulk operation.
34
+
35
+ Attributes:
36
+ successes: Successfully processed entities
37
+ failures: Failed items with error details
38
+ total: Total items attempted
39
+ succeeded: Count of successful items
40
+ failed: Count of failed items
41
+ """
42
+
43
+ successes: list[NormalizedEntity] = Field(
44
+ default_factory=list, description="Successfully processed entities"
45
+ )
46
+ failures: list[BulkOperationFailure] = Field(
47
+ default_factory=list, description="Failed items with errors"
48
+ )
49
+ total: int = Field(..., description="Total items")
50
+ succeeded: int = Field(..., description="Successful count")
51
+ failed: int = Field(..., description="Failed count")
52
+
53
+ def is_complete_success(self) -> bool:
54
+ """Check if all items succeeded.
55
+
56
+ Returns:
57
+ True if all items succeeded, False otherwise
58
+ """
59
+ return self.failed == 0
60
+
61
+ def success_rate(self) -> float:
62
+ """Calculate success rate (0.0 to 1.0).
63
+
64
+ Returns:
65
+ Success rate as a float between 0.0 and 1.0
66
+ """
67
+ if self.total == 0:
68
+ return 0.0
69
+ return self.succeeded / self.total