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.
- strapi_kit/__init__.py +97 -0
- strapi_kit/__version__.py +15 -0
- strapi_kit/_version.py +34 -0
- strapi_kit/auth/__init__.py +7 -0
- strapi_kit/auth/api_token.py +48 -0
- strapi_kit/cache/__init__.py +5 -0
- strapi_kit/cache/schema_cache.py +211 -0
- strapi_kit/client/__init__.py +11 -0
- strapi_kit/client/async_client.py +1032 -0
- strapi_kit/client/base.py +460 -0
- strapi_kit/client/sync_client.py +980 -0
- strapi_kit/config_provider.py +368 -0
- strapi_kit/exceptions/__init__.py +37 -0
- strapi_kit/exceptions/errors.py +205 -0
- strapi_kit/export/__init__.py +10 -0
- strapi_kit/export/exporter.py +384 -0
- strapi_kit/export/importer.py +619 -0
- strapi_kit/export/media_handler.py +322 -0
- strapi_kit/export/relation_resolver.py +172 -0
- strapi_kit/models/__init__.py +104 -0
- strapi_kit/models/bulk.py +69 -0
- strapi_kit/models/config.py +174 -0
- strapi_kit/models/enums.py +97 -0
- strapi_kit/models/export_format.py +166 -0
- strapi_kit/models/import_options.py +142 -0
- strapi_kit/models/request/__init__.py +1 -0
- strapi_kit/models/request/fields.py +65 -0
- strapi_kit/models/request/filters.py +611 -0
- strapi_kit/models/request/pagination.py +168 -0
- strapi_kit/models/request/populate.py +281 -0
- strapi_kit/models/request/query.py +429 -0
- strapi_kit/models/request/sort.py +147 -0
- strapi_kit/models/response/__init__.py +1 -0
- strapi_kit/models/response/base.py +75 -0
- strapi_kit/models/response/component.py +67 -0
- strapi_kit/models/response/media.py +91 -0
- strapi_kit/models/response/meta.py +44 -0
- strapi_kit/models/response/normalized.py +168 -0
- strapi_kit/models/response/relation.py +48 -0
- strapi_kit/models/response/v4.py +70 -0
- strapi_kit/models/response/v5.py +57 -0
- strapi_kit/models/schema.py +93 -0
- strapi_kit/operations/__init__.py +16 -0
- strapi_kit/operations/media.py +226 -0
- strapi_kit/operations/streaming.py +144 -0
- strapi_kit/parsers/__init__.py +5 -0
- strapi_kit/parsers/version_detecting.py +171 -0
- strapi_kit/protocols.py +455 -0
- strapi_kit/utils/__init__.py +15 -0
- strapi_kit/utils/rate_limiter.py +201 -0
- strapi_kit/utils/uid.py +88 -0
- strapi_kit-0.0.1.dist-info/METADATA +1098 -0
- strapi_kit-0.0.1.dist-info/RECORD +55 -0
- strapi_kit-0.0.1.dist-info/WHEEL +4 -0
- 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)
|