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,384 @@
1
+ """Main export orchestration for Strapi data.
2
+
3
+ This module coordinates the export of content types, entities,
4
+ and media files from a Strapi instance.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from strapi_kit.cache.schema_cache import InMemorySchemaCache
14
+ from strapi_kit.exceptions import ImportExportError
15
+ from strapi_kit.export.media_handler import MediaHandler
16
+ from strapi_kit.export.relation_resolver import RelationResolver
17
+ from strapi_kit.models.export_format import (
18
+ ExportData,
19
+ ExportedEntity,
20
+ ExportMetadata,
21
+ )
22
+ from strapi_kit.models.request.query import StrapiQuery
23
+ from strapi_kit.operations.streaming import stream_entities
24
+
25
+ if TYPE_CHECKING:
26
+ from strapi_kit.client.sync_client import SyncClient
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class StrapiExporter:
32
+ """Export Strapi content and media to portable format.
33
+
34
+ This class handles the complete export process including:
35
+ - Content type discovery
36
+ - Entity export with relations
37
+ - Media file download
38
+ - Progress tracking
39
+
40
+ Example:
41
+ >>> from strapi_kit import SyncClient
42
+ >>> from strapi_kit.export import StrapiExporter
43
+ >>>
44
+ >>> with SyncClient(config) as client:
45
+ ... exporter = StrapiExporter(client)
46
+ ... export_data = exporter.export_content_types(
47
+ ... ["api::article.article", "api::author.author"]
48
+ ... )
49
+ ... exporter.save_to_file(export_data, "export.json")
50
+ """
51
+
52
+ def __init__(self, client: "SyncClient"):
53
+ """Initialize exporter with Strapi client.
54
+
55
+ Args:
56
+ client: Synchronous Strapi client
57
+ """
58
+ self.client = client
59
+ self._schema_cache = InMemorySchemaCache(client)
60
+
61
+ def export_content_types(
62
+ self,
63
+ content_types: list[str],
64
+ *,
65
+ include_media: bool = True,
66
+ media_dir: Path | str | None = None,
67
+ progress_callback: Callable[[int, int, str], None] | None = None,
68
+ ) -> ExportData:
69
+ """Export specified content types with all their entities.
70
+
71
+ Args:
72
+ content_types: List of content type UIDs to export
73
+ include_media: Whether to include media file references
74
+ media_dir: Directory to download media files to (if include_media=True)
75
+ progress_callback: Optional callback(current, total, message)
76
+
77
+ Returns:
78
+ ExportData containing all exported content
79
+
80
+ Raises:
81
+ ValueError: If include_media=True but media_dir is not provided
82
+ ImportExportError: If export fails
83
+
84
+ Example:
85
+ >>> export_data = exporter.export_content_types([
86
+ ... "api::article.article",
87
+ ... "api::author.author"
88
+ ... ], media_dir="export/media")
89
+ >>> print(f"Exported {export_data.get_entity_count()} entities")
90
+ """
91
+ if include_media and media_dir is None:
92
+ raise ValueError("media_dir must be provided when include_media=True")
93
+
94
+ try:
95
+ # Create metadata
96
+ metadata = ExportMetadata(
97
+ strapi_version=self.client.api_version or "auto",
98
+ source_url=self.client.base_url,
99
+ content_types=content_types,
100
+ )
101
+
102
+ export_data = ExportData(metadata=metadata)
103
+
104
+ # Fetch schemas upfront (required for relation resolution)
105
+ self._fetch_schemas(content_types, export_data, progress_callback)
106
+
107
+ total_content_types = len(content_types)
108
+
109
+ # Collect media IDs during entity streaming (before relations are stripped)
110
+ all_media_ids: set[int] = set()
111
+
112
+ for idx, content_type in enumerate(content_types):
113
+ if progress_callback:
114
+ progress_callback(
115
+ idx,
116
+ total_content_types,
117
+ f"Exporting {content_type}",
118
+ )
119
+
120
+ # Extract endpoint from UID (e.g., "api::article.article" -> "articles")
121
+ endpoint = self._get_endpoint(content_type)
122
+
123
+ # Build query with populate_all to ensure relations/media are included
124
+ export_query = StrapiQuery().populate_all()
125
+
126
+ # Stream entities for memory efficiency
127
+ entities = []
128
+ for entity in stream_entities(self.client, endpoint, query=export_query):
129
+ # Extract media references BEFORE stripping relations
130
+ # (media can be embedded in relation-like fields with {"data": ...} structure)
131
+ if include_media:
132
+ media_ids = MediaHandler.extract_media_references(entity.attributes)
133
+ all_media_ids.update(media_ids)
134
+
135
+ # Extract relations from entity data
136
+ relations = RelationResolver.extract_relations(entity.attributes)
137
+
138
+ # Strip relations from data to store separately
139
+ clean_data = RelationResolver.strip_relations(entity.attributes)
140
+
141
+ exported_entity = ExportedEntity(
142
+ id=entity.id,
143
+ document_id=entity.document_id,
144
+ content_type=content_type,
145
+ data=clean_data,
146
+ relations=relations,
147
+ )
148
+ entities.append(exported_entity)
149
+
150
+ export_data.entities[content_type] = entities
151
+
152
+ # Update metadata with counts
153
+ export_data.metadata.total_entities = export_data.get_entity_count()
154
+
155
+ # Export media if requested
156
+ if include_media:
157
+ if progress_callback:
158
+ progress_callback(
159
+ total_content_types,
160
+ total_content_types + 1,
161
+ "Exporting media files",
162
+ )
163
+
164
+ # media_dir is guaranteed non-None here (validated at method start)
165
+ assert media_dir is not None
166
+ self._export_media(
167
+ export_data, media_dir, progress_callback, media_ids=all_media_ids
168
+ )
169
+
170
+ if progress_callback:
171
+ progress_callback(
172
+ total_content_types,
173
+ total_content_types,
174
+ "Export complete",
175
+ )
176
+
177
+ return export_data
178
+
179
+ except Exception as e:
180
+ raise ImportExportError(f"Export failed: {e}") from e
181
+
182
+ @staticmethod
183
+ def save_to_file(export_data: ExportData, file_path: str | Path) -> None:
184
+ """Save export data to JSON file.
185
+
186
+ Args:
187
+ export_data: Export data to save
188
+ file_path: Path to output file
189
+
190
+ Example:
191
+ >>> StrapiExporter.save_to_file(export_data, "backup.json")
192
+ """
193
+ path = Path(file_path)
194
+ path.parent.mkdir(parents=True, exist_ok=True)
195
+
196
+ with open(path, "w", encoding="utf-8") as f:
197
+ # Use model_dump with mode='json' for proper serialization
198
+ json.dump(export_data.model_dump(mode="json"), f, indent=2, ensure_ascii=False)
199
+
200
+ logger.info(f"Export saved to {path}")
201
+
202
+ @staticmethod
203
+ def load_from_file(file_path: str | Path) -> ExportData:
204
+ """Load export data from JSON file.
205
+
206
+ Args:
207
+ file_path: Path to export file
208
+
209
+ Returns:
210
+ Loaded export data
211
+
212
+ Raises:
213
+ ImportExportError: If file cannot be loaded
214
+
215
+ Example:
216
+ >>> export_data = StrapiExporter.load_from_file("backup.json")
217
+ """
218
+ try:
219
+ path = Path(file_path)
220
+ with open(path, encoding="utf-8") as f:
221
+ data = json.load(f)
222
+
223
+ return ExportData.model_validate(data)
224
+
225
+ except Exception as e:
226
+ raise ImportExportError(f"Failed to load export file: {e}") from e
227
+
228
+ def _export_media(
229
+ self,
230
+ export_data: ExportData,
231
+ media_dir: Path | str,
232
+ progress_callback: Callable[[int, int, str], None] | None = None,
233
+ *,
234
+ media_ids: set[int] | None = None,
235
+ ) -> None:
236
+ """Export media files referenced in entities.
237
+
238
+ Args:
239
+ export_data: Export data to add media to
240
+ media_dir: Directory to download media files to
241
+ progress_callback: Optional progress callback
242
+ media_ids: Pre-collected media IDs (extracted before relation stripping)
243
+ """
244
+ # Use pre-collected media IDs if provided, otherwise collect from entity.data
245
+ # Note: Pre-collecting is important because entity.data has relations stripped,
246
+ # so media embedded in relation-like fields would be lost otherwise.
247
+ if media_ids is None:
248
+ media_ids = set()
249
+ for entities in export_data.entities.values():
250
+ for entity in entities:
251
+ data_media = MediaHandler.extract_media_references(entity.data)
252
+ media_ids.update(data_media)
253
+
254
+ if not media_ids:
255
+ logger.info("No media files to export")
256
+ return
257
+
258
+ logger.info(f"Found {len(media_ids)} media files to export")
259
+
260
+ # Download media files
261
+ output_dir = Path(media_dir)
262
+ output_dir.mkdir(parents=True, exist_ok=True)
263
+
264
+ downloaded = 0
265
+ for idx, media_id in enumerate(sorted(media_ids)):
266
+ try:
267
+ # Get media metadata
268
+ media = self.client.get_media(media_id)
269
+
270
+ # Download file
271
+ local_path = MediaHandler.download_media_file(self.client, media, output_dir)
272
+
273
+ # Create export metadata
274
+ exported_media = MediaHandler.create_media_export(media, local_path)
275
+ export_data.media.append(exported_media)
276
+
277
+ downloaded += 1
278
+
279
+ if progress_callback:
280
+ progress_callback(idx + 1, len(media_ids), f"Downloaded {media.name}")
281
+
282
+ except Exception as e:
283
+ logger.warning(f"Failed to download media {media_id}: {e}")
284
+
285
+ export_data.metadata.total_media = downloaded
286
+ logger.info(f"Successfully downloaded {downloaded}/{len(media_ids)} media files")
287
+
288
+ def _fetch_schemas(
289
+ self,
290
+ content_types: list[str],
291
+ export_data: ExportData,
292
+ progress_callback: Callable[[int, int, str], None] | None = None,
293
+ ) -> None:
294
+ """Fetch and cache schemas for content types.
295
+
296
+ Args:
297
+ content_types: List of content type UIDs
298
+ export_data: Export data to add schemas to
299
+ progress_callback: Optional progress callback
300
+ """
301
+ logger.info(f"Fetching schemas for {len(content_types)} content types")
302
+
303
+ for idx, content_type in enumerate(content_types):
304
+ try:
305
+ schema = self._schema_cache.get_schema(content_type)
306
+ export_data.metadata.schemas[content_type] = schema
307
+
308
+ if progress_callback:
309
+ progress_callback(
310
+ idx + 1, len(content_types), f"Fetched schema: {content_type}"
311
+ )
312
+ except Exception as e:
313
+ logger.warning(f"Failed to fetch schema for {content_type}: {e}")
314
+
315
+ logger.info(f"Cached {self._schema_cache.cache_size} schemas")
316
+
317
+ def _get_endpoint(self, uid: str) -> str:
318
+ """Get API endpoint for a content type.
319
+
320
+ Prefers schema.plural_name when available to handle custom plural
321
+ names correctly (e.g., "person" -> "people"). Falls back to
322
+ hardcoded pluralization rules for basic cases.
323
+
324
+ Args:
325
+ uid: Content type UID (e.g., "api::article.article")
326
+
327
+ Returns:
328
+ API endpoint (e.g., "articles")
329
+ """
330
+ # Try to get plural_name from cached schema
331
+ if self._schema_cache.has_schema(uid):
332
+ schema = self._schema_cache.get_schema(uid)
333
+ if schema.plural_name:
334
+ return schema.plural_name
335
+
336
+ # Fallback to hardcoded pluralization
337
+ return self._uid_to_endpoint_fallback(uid)
338
+
339
+ @staticmethod
340
+ def _uid_to_endpoint_fallback(uid: str) -> str:
341
+ """Fallback pluralization for content type UID.
342
+
343
+ Handles common English pluralization patterns. Used when schema
344
+ metadata is not available.
345
+
346
+ Args:
347
+ uid: Content type UID (e.g., "api::article.article", "api::blog.post")
348
+
349
+ Returns:
350
+ API endpoint (e.g., "articles", "posts")
351
+ """
352
+ # Extract the model name (after the dot) and pluralize it
353
+ # For "api::blog.post", we want "post" -> "posts", not "blog" -> "blogs"
354
+ parts = uid.split("::")
355
+ if len(parts) == 2:
356
+ api_model = parts[1]
357
+ # Get model name (after the dot if present)
358
+ if "." in api_model:
359
+ name = api_model.split(".")[1]
360
+ else:
361
+ name = api_model
362
+ # Handle common irregular plurals
363
+ if name.endswith("y") and not name.endswith(("ay", "ey", "oy", "uy")):
364
+ return name[:-1] + "ies" # category -> categories
365
+ if name.endswith(("s", "x", "z", "ch", "sh")):
366
+ return name + "es" # class -> classes
367
+ if not name.endswith("s"):
368
+ return name + "s"
369
+ return name
370
+ return uid
371
+
372
+ @staticmethod
373
+ def _uid_to_endpoint(uid: str) -> str:
374
+ """Convert content type UID to API endpoint.
375
+
376
+ Deprecated: Use _get_endpoint() instead which uses schema metadata.
377
+
378
+ Args:
379
+ uid: Content type UID (e.g., "api::article.article")
380
+
381
+ Returns:
382
+ API endpoint (e.g., "articles")
383
+ """
384
+ return StrapiExporter._uid_to_endpoint_fallback(uid)