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,619 @@
1
+ """Main import orchestration for Strapi data.
2
+
3
+ This module coordinates the import of content types, entities,
4
+ and media files into a Strapi instance.
5
+ """
6
+
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from strapi_kit.cache.schema_cache import InMemorySchemaCache
12
+ from strapi_kit.exceptions import (
13
+ ImportExportError,
14
+ NotFoundError,
15
+ StrapiError,
16
+ ValidationError,
17
+ )
18
+ from strapi_kit.export.media_handler import MediaHandler
19
+ from strapi_kit.export.relation_resolver import RelationResolver
20
+ from strapi_kit.models.export_format import ExportData
21
+ from strapi_kit.models.import_options import ConflictResolution, ImportOptions, ImportResult
22
+ from strapi_kit.models.schema import ContentTypeSchema
23
+
24
+ if TYPE_CHECKING:
25
+ from strapi_kit.client.sync_client import SyncClient
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class StrapiImporter:
31
+ """Import Strapi content and media from exported format.
32
+
33
+ This class handles the complete import process including:
34
+ - Validation of export data
35
+ - Relation resolution
36
+ - Media file upload
37
+ - Entity creation with proper ordering
38
+ - Progress tracking
39
+
40
+ Example:
41
+ >>> from strapi_kit import SyncClient
42
+ >>> from strapi_kit.export import StrapiImporter, StrapiExporter
43
+ >>>
44
+ >>> # Load export data
45
+ >>> export_data = StrapiExporter.load_from_file("export.json")
46
+ >>>
47
+ >>> # Import to new instance
48
+ >>> with SyncClient(target_config) as client:
49
+ ... importer = StrapiImporter(client)
50
+ ... result = importer.import_data(export_data)
51
+ ... print(f"Imported {result.entities_imported} entities")
52
+ """
53
+
54
+ def __init__(self, client: "SyncClient"):
55
+ """Initialize importer with Strapi client.
56
+
57
+ Args:
58
+ client: Synchronous Strapi client
59
+ """
60
+ self.client = client
61
+ self._schema_cache = InMemorySchemaCache(client)
62
+
63
+ def import_data(
64
+ self,
65
+ export_data: ExportData,
66
+ options: ImportOptions | None = None,
67
+ media_dir: Path | str | None = None,
68
+ ) -> ImportResult:
69
+ """Import export data into Strapi instance.
70
+
71
+ Args:
72
+ export_data: Export data to import
73
+ options: Import options (uses defaults if None)
74
+ media_dir: Directory containing media files from export
75
+
76
+ Returns:
77
+ ImportResult with statistics and any errors
78
+
79
+ Raises:
80
+ ImportExportError: If import fails critically
81
+
82
+ Example:
83
+ >>> options = ImportOptions(
84
+ ... dry_run=True,
85
+ ... conflict_resolution=ConflictResolution.SKIP
86
+ ... )
87
+ >>> result = importer.import_data(
88
+ ... export_data,
89
+ ... options,
90
+ ... media_dir="export/media"
91
+ ... )
92
+ >>> if result.success:
93
+ ... print("Import successful!")
94
+ """
95
+ if options is None:
96
+ options = ImportOptions()
97
+
98
+ result = ImportResult(success=False, dry_run=options.dry_run)
99
+
100
+ try:
101
+ # Step 1: Validate export data
102
+ if options.progress_callback:
103
+ options.progress_callback(0, 100, "Validating export data")
104
+
105
+ self._validate_export_data(export_data, result)
106
+
107
+ if result.errors and not options.dry_run:
108
+ result.success = False
109
+ return result
110
+
111
+ # Step 1.5: Load schemas from export metadata
112
+ self._load_schemas_from_export(export_data)
113
+
114
+ # Step 2: Filter content types if specified
115
+ content_types_to_import = self._get_content_types_to_import(export_data, options)
116
+
117
+ if not content_types_to_import:
118
+ result.add_warning("No content types to import")
119
+ result.success = True
120
+ return result
121
+
122
+ # Step 3: Import media first (if requested)
123
+ media_id_mapping: dict[int, int] = {}
124
+ if options.import_media and export_data.media:
125
+ if options.progress_callback:
126
+ options.progress_callback(20, 100, "Importing media files")
127
+
128
+ media_id_mapping = self._import_media(export_data, media_dir, options, result)
129
+
130
+ # Step 4: Import entities (with updated media references)
131
+ if options.progress_callback:
132
+ options.progress_callback(40, 100, "Importing entities")
133
+
134
+ self._import_entities(
135
+ export_data,
136
+ content_types_to_import,
137
+ media_id_mapping,
138
+ options,
139
+ result,
140
+ )
141
+
142
+ # Step 5: Import relations (if not skipped)
143
+ if not options.skip_relations:
144
+ if options.progress_callback:
145
+ options.progress_callback(60, 100, "Importing relations")
146
+
147
+ self._import_relations(
148
+ export_data,
149
+ content_types_to_import,
150
+ options,
151
+ result,
152
+ )
153
+
154
+ if options.progress_callback:
155
+ options.progress_callback(100, 100, "Import complete")
156
+
157
+ result.success = result.entities_failed == 0
158
+
159
+ return result
160
+
161
+ except Exception as e:
162
+ result.add_error(f"Import failed: {e}")
163
+ raise ImportExportError(f"Import failed: {e}") from e
164
+
165
+ def _validate_export_data(self, export_data: ExportData, result: ImportResult) -> None:
166
+ """Validate export data format and compatibility.
167
+
168
+ Args:
169
+ export_data: Export data to validate
170
+ result: Result object to add errors/warnings to
171
+ """
172
+ # Check format version
173
+ if not export_data.metadata.version.startswith("1."):
174
+ result.add_warning(
175
+ f"Export format version {export_data.metadata.version} may not be fully compatible"
176
+ )
177
+
178
+ # Check Strapi version compatibility
179
+ target_version = self.client.api_version
180
+ source_version = export_data.metadata.strapi_version
181
+
182
+ if target_version and source_version != target_version:
183
+ result.add_warning(
184
+ f"Source version ({source_version}) differs from target ({target_version}). "
185
+ "Some data may require transformation."
186
+ )
187
+
188
+ # Check if we have any data
189
+ if export_data.get_entity_count() == 0:
190
+ result.add_warning("No entities to import")
191
+
192
+ def _get_content_types_to_import(
193
+ self, export_data: ExportData, options: ImportOptions
194
+ ) -> list[str]:
195
+ """Determine which content types to import based on options.
196
+
197
+ Args:
198
+ export_data: Export data
199
+ options: Import options
200
+
201
+ Returns:
202
+ List of content type UIDs to import
203
+ """
204
+ available = list(export_data.entities.keys())
205
+
206
+ if options.content_types:
207
+ # Only import specified content types
208
+ return [ct for ct in options.content_types if ct in available]
209
+
210
+ return available
211
+
212
+ def _import_entities(
213
+ self,
214
+ export_data: ExportData,
215
+ content_types: list[str],
216
+ media_id_mapping: dict[int, int],
217
+ options: ImportOptions,
218
+ result: ImportResult,
219
+ ) -> None:
220
+ """Import entities for specified content types.
221
+
222
+ Handles conflict resolution based on options:
223
+ - SKIP: Skip entities that already exist
224
+ - UPDATE: Update existing entities with imported data
225
+ - FAIL: Fail import if conflicts are detected
226
+
227
+ Args:
228
+ export_data: Export data
229
+ content_types: Content types to import
230
+ media_id_mapping: Mapping of old media IDs to new IDs
231
+ options: Import options
232
+ result: Result object to update
233
+ """
234
+ for content_type in content_types:
235
+ entities = export_data.entities.get(content_type, [])
236
+
237
+ # Get endpoint from schema (prefers plural_name) or fallback to UID
238
+ endpoint = self._get_endpoint(content_type)
239
+
240
+ for entity in entities:
241
+ try:
242
+ # Update media references if we have mappings
243
+ entity_data = entity.data
244
+ if media_id_mapping:
245
+ entity_data = MediaHandler.update_media_references(
246
+ entity.data, media_id_mapping
247
+ )
248
+
249
+ if options.dry_run:
250
+ # Just validate, don't actually create
251
+ result.entities_imported += 1
252
+ continue
253
+
254
+ # Check for existing entity if document_id is available (for conflict handling)
255
+ existing_id: int | None = None
256
+ if entity.document_id:
257
+ existing_id = self._check_entity_exists(endpoint, entity.document_id)
258
+
259
+ if existing_id is not None:
260
+ # Entity already exists - handle according to conflict resolution
261
+ if options.conflict_resolution == ConflictResolution.SKIP:
262
+ result.entities_skipped += 1
263
+ # Still track the ID mapping for relations
264
+ if content_type not in result.id_mapping:
265
+ result.id_mapping[content_type] = {}
266
+ result.id_mapping[content_type][entity.id] = existing_id
267
+ continue
268
+
269
+ elif options.conflict_resolution == ConflictResolution.FAIL:
270
+ raise ImportExportError(
271
+ f"Entity already exists: {content_type} with documentId "
272
+ f"{entity.document_id}. Use conflict_resolution=SKIP or UPDATE."
273
+ )
274
+
275
+ elif options.conflict_resolution == ConflictResolution.UPDATE:
276
+ # Update existing entity
277
+ response = self.client.update(
278
+ f"{endpoint}/{entity.document_id}",
279
+ entity_data,
280
+ )
281
+ if response.data:
282
+ if content_type not in result.id_mapping:
283
+ result.id_mapping[content_type] = {}
284
+ result.id_mapping[content_type][entity.id] = response.data.id
285
+ result.entities_updated += 1
286
+ continue
287
+
288
+ # Create new entity
289
+ response = self.client.create(endpoint, entity_data)
290
+
291
+ if response.data:
292
+ # Track ID mapping for relation resolution
293
+ if content_type not in result.id_mapping:
294
+ result.id_mapping[content_type] = {}
295
+
296
+ result.id_mapping[content_type][entity.id] = response.data.id
297
+ result.entities_imported += 1
298
+
299
+ except ValidationError as e:
300
+ result.add_error(f"Validation error importing {content_type} #{entity.id}: {e}")
301
+ result.entities_failed += 1
302
+
303
+ except ImportExportError:
304
+ # Re-raise ImportExportError (e.g., from FAIL conflict resolution)
305
+ raise
306
+
307
+ except StrapiError as e:
308
+ # Catch Strapi-specific errors (API errors, network issues, etc.)
309
+ result.add_error(f"Failed to import {content_type} #{entity.id}: {e}")
310
+ result.entities_failed += 1
311
+
312
+ def _check_entity_exists(self, endpoint: str, document_id: str) -> int | None:
313
+ """Check if an entity exists by document ID.
314
+
315
+ Args:
316
+ endpoint: API endpoint
317
+ document_id: Document ID to check
318
+
319
+ Returns:
320
+ Entity's numeric ID if exists, None otherwise
321
+ """
322
+ try:
323
+ response = self.client.get_one(f"{endpoint}/{document_id}")
324
+ if response.data:
325
+ return response.data.id
326
+ except NotFoundError:
327
+ pass
328
+ except Exception as e:
329
+ logger.warning(f"Error checking entity existence: {e}")
330
+ return None
331
+
332
+ def _import_relations(
333
+ self,
334
+ export_data: ExportData,
335
+ content_types: list[str],
336
+ options: ImportOptions,
337
+ result: ImportResult,
338
+ ) -> None:
339
+ """Import relations for entities.
340
+
341
+ This is done as a second pass after entities are created,
342
+ so that all entities exist before relations are added.
343
+
344
+ Args:
345
+ export_data: Export data
346
+ content_types: Content types to import relations for
347
+ options: Import options
348
+ result: Result object to update
349
+ """
350
+ for content_type in content_types:
351
+ entities = export_data.entities.get(content_type, [])
352
+ endpoint = self._get_endpoint(content_type)
353
+
354
+ for entity in entities:
355
+ # Skip if no relations
356
+ if not entity.relations:
357
+ continue
358
+
359
+ # Get the new ID from mapping
360
+ if content_type not in result.id_mapping:
361
+ continue
362
+
363
+ old_id = entity.id
364
+ if old_id not in result.id_mapping[content_type]:
365
+ logger.warning(
366
+ f"Cannot import relations for {content_type} #{old_id}: "
367
+ "entity not in ID mapping"
368
+ )
369
+ continue
370
+
371
+ new_id = result.id_mapping[content_type][old_id]
372
+
373
+ # Get schema for this content type
374
+ try:
375
+ schema = self._schema_cache.get_schema(content_type)
376
+ except Exception as e:
377
+ logger.warning(f"Could not load schema for {content_type}: {e}")
378
+ continue
379
+
380
+ try:
381
+ if options.dry_run:
382
+ continue
383
+
384
+ # FIXED: Resolve relations using schema
385
+ resolved_relations = self._resolve_relations_with_schema(
386
+ entity.relations, schema, result.id_mapping
387
+ )
388
+
389
+ if not resolved_relations:
390
+ continue
391
+
392
+ # Build relation payload
393
+ relation_payload = RelationResolver.build_relation_payload(resolved_relations)
394
+
395
+ if relation_payload:
396
+ # Update entity with relations
397
+ # Note: update() already wraps data in {"data": ...}
398
+ self.client.update(
399
+ f"{endpoint}/{new_id}",
400
+ relation_payload,
401
+ )
402
+
403
+ except Exception as e:
404
+ result.add_warning(
405
+ f"Failed to import relations for {content_type} #{new_id}: {e}"
406
+ )
407
+
408
+ def _import_media(
409
+ self,
410
+ export_data: ExportData,
411
+ media_dir: Path | str | None,
412
+ options: ImportOptions,
413
+ result: ImportResult,
414
+ ) -> dict[int, int]:
415
+ """Import media files from export.
416
+
417
+ Args:
418
+ export_data: Export data containing media metadata
419
+ media_dir: Directory containing downloaded media files
420
+ options: Import options
421
+ result: Result object to update
422
+
423
+ Returns:
424
+ Mapping of old media IDs to new media IDs
425
+ """
426
+ media_id_mapping: dict[int, int] = {}
427
+
428
+ if not export_data.media:
429
+ return media_id_mapping
430
+
431
+ if media_dir is None:
432
+ logger.warning(
433
+ "Media directory not specified - skipping media import. "
434
+ "Media references in entities will not be updated."
435
+ )
436
+ return media_id_mapping
437
+
438
+ media_path = Path(media_dir)
439
+ if not media_path.exists():
440
+ result.add_error(f"Media directory not found: {media_dir}")
441
+ return media_id_mapping
442
+
443
+ for exported_media in export_data.media:
444
+ try:
445
+ if options.dry_run:
446
+ result.media_imported += 1
447
+ continue
448
+
449
+ # Find local file with path traversal protection
450
+ file_path = (media_path / exported_media.local_path).resolve()
451
+
452
+ # Security: Ensure resolved path stays within media_path
453
+ if not file_path.is_relative_to(media_path.resolve()):
454
+ result.add_error(
455
+ f"Security: Invalid media path {exported_media.local_path} - "
456
+ "path traversal detected"
457
+ )
458
+ result.media_skipped += 1
459
+ continue
460
+
461
+ if not file_path.exists():
462
+ result.add_warning(
463
+ f"Media file not found: {file_path.name} (ID: {exported_media.id})"
464
+ )
465
+ result.media_skipped += 1
466
+ continue
467
+
468
+ # Upload file
469
+ uploaded = MediaHandler.upload_media_file(self.client, file_path, exported_media)
470
+
471
+ # Track ID mapping
472
+ media_id_mapping[exported_media.id] = uploaded.id
473
+ result.media_imported += 1
474
+
475
+ except Exception as e:
476
+ result.add_warning(f"Failed to import media {exported_media.name}: {e}")
477
+ result.media_skipped += 1
478
+
479
+ logger.info(f"Imported {result.media_imported}/{len(export_data.media)} media files")
480
+ return media_id_mapping
481
+
482
+ def _load_schemas_from_export(self, export_data: ExportData) -> None:
483
+ """Load schemas from export metadata into cache.
484
+
485
+ Args:
486
+ export_data: Export data containing schemas
487
+ """
488
+ # Load all schemas into cache
489
+ for content_type, schema in export_data.metadata.schemas.items():
490
+ self._schema_cache.cache_schema(content_type, schema)
491
+
492
+ logger.info(f"Loaded {self._schema_cache.cache_size} schemas from export")
493
+
494
+ def _resolve_relations_with_schema(
495
+ self,
496
+ relations: dict[str, list[int | str]],
497
+ schema: ContentTypeSchema,
498
+ id_mapping: dict[str, dict[int, int]],
499
+ ) -> dict[str, list[int]]:
500
+ """Resolve relation IDs using schema information.
501
+
502
+ Uses content type schemas to determine relation targets, enabling
503
+ proper ID mapping during import.
504
+
505
+ Args:
506
+ relations: Raw relations from export (field -> [old_ids])
507
+ schema: Schema for the content type
508
+ id_mapping: Full ID mapping (content_type -> {old_id: new_id})
509
+
510
+ Returns:
511
+ Resolved relations with new IDs
512
+ """
513
+ resolved: dict[str, list[int]] = {}
514
+
515
+ for field_name, old_ids in relations.items():
516
+ # Get target content type from schema
517
+ target_content_type = schema.get_field_target(field_name)
518
+
519
+ if not target_content_type:
520
+ logger.warning(f"Field {field_name} is not a relation. Skipping.")
521
+ continue
522
+
523
+ # Get ID mapping for target content type
524
+ if target_content_type not in id_mapping:
525
+ logger.warning(
526
+ f"No ID mapping for {target_content_type}. "
527
+ f"Relations in {field_name} cannot be resolved."
528
+ )
529
+ continue
530
+
531
+ target_mapping = id_mapping[target_content_type]
532
+
533
+ # Resolve old IDs to new IDs
534
+ new_ids = []
535
+ for old_id in old_ids:
536
+ if isinstance(old_id, int) and old_id in target_mapping:
537
+ new_ids.append(target_mapping[old_id])
538
+ else:
539
+ logger.warning(
540
+ f"Could not resolve {target_content_type} ID {old_id} "
541
+ f"for field {field_name}"
542
+ )
543
+
544
+ # Preserve empty lists only when source relation was explicitly empty.
545
+ # If old_ids had values but none resolved, skip to avoid clearing relations.
546
+ if new_ids or len(old_ids) == 0:
547
+ resolved[field_name] = new_ids
548
+
549
+ return resolved
550
+
551
+ def _get_endpoint(self, uid: str) -> str:
552
+ """Get API endpoint for a content type.
553
+
554
+ Prefers schema.plural_name when available to handle custom plural
555
+ names correctly (e.g., "person" -> "people"). Falls back to
556
+ hardcoded pluralization rules for basic cases.
557
+
558
+ Args:
559
+ uid: Content type UID (e.g., "api::article.article")
560
+
561
+ Returns:
562
+ API endpoint (e.g., "articles")
563
+ """
564
+ # Try to get plural_name from cached schema
565
+ if self._schema_cache.has_schema(uid):
566
+ schema = self._schema_cache.get_schema(uid)
567
+ if schema.plural_name:
568
+ return schema.plural_name
569
+
570
+ # Fallback to hardcoded pluralization
571
+ return self._uid_to_endpoint_fallback(uid)
572
+
573
+ @staticmethod
574
+ def _uid_to_endpoint_fallback(uid: str) -> str:
575
+ """Fallback pluralization for content type UID.
576
+
577
+ Handles common English pluralization patterns. Used when schema
578
+ metadata is not available.
579
+
580
+ Args:
581
+ uid: Content type UID (e.g., "api::article.article", "api::blog.post")
582
+
583
+ Returns:
584
+ API endpoint (e.g., "articles", "posts")
585
+ """
586
+ # Extract the model name (after the dot) and pluralize it
587
+ # For "api::blog.post", we want "post" -> "posts", not "blog" -> "blogs"
588
+ parts = uid.split("::")
589
+ if len(parts) == 2:
590
+ api_model = parts[1]
591
+ # Get model name (after the dot if present)
592
+ if "." in api_model:
593
+ name = api_model.split(".")[1]
594
+ else:
595
+ name = api_model
596
+ # Handle common irregular plurals
597
+ if name.endswith("y") and not name.endswith(("ay", "ey", "oy", "uy")):
598
+ return name[:-1] + "ies" # category -> categories
599
+ if name.endswith(("s", "x", "z", "ch", "sh")):
600
+ return name + "es" # class -> classes
601
+ if not name.endswith("s"):
602
+ return name + "s"
603
+ return name
604
+ return uid
605
+
606
+ # Keep for backward compatibility
607
+ @staticmethod
608
+ def _uid_to_endpoint(uid: str) -> str:
609
+ """Convert content type UID to API endpoint.
610
+
611
+ Deprecated: Use _get_endpoint() instead which uses schema metadata.
612
+
613
+ Args:
614
+ uid: Content type UID (e.g., "api::article.article")
615
+
616
+ Returns:
617
+ API endpoint (e.g., "articles")
618
+ """
619
+ return StrapiImporter._uid_to_endpoint_fallback(uid)