strapi-kit 0.0.5__py3-none-any.whl → 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.
- strapi_kit/_version.py +2 -2
- strapi_kit/cache/schema_cache.py +91 -3
- strapi_kit/client/async_client.py +83 -47
- strapi_kit/client/base.py +70 -3
- strapi_kit/client/sync_client.py +23 -12
- strapi_kit/export/__init__.py +3 -1
- strapi_kit/export/exporter.py +160 -4
- strapi_kit/export/importer.py +519 -66
- strapi_kit/export/jsonl_reader.py +195 -0
- strapi_kit/export/jsonl_writer.py +134 -0
- strapi_kit/export/relation_resolver.py +230 -1
- strapi_kit/models/__init__.py +8 -1
- strapi_kit/models/config.py +5 -1
- strapi_kit/models/enums.py +4 -4
- strapi_kit/models/export_format.py +13 -0
- strapi_kit/models/import_options.py +12 -2
- strapi_kit/models/schema.py +9 -4
- strapi_kit/utils/__init__.py +3 -0
- strapi_kit/utils/schema.py +35 -0
- {strapi_kit-0.0.5.dist-info → strapi_kit-0.1.0.dist-info}/METADATA +2 -3
- {strapi_kit-0.0.5.dist-info → strapi_kit-0.1.0.dist-info}/RECORD +23 -20
- {strapi_kit-0.0.5.dist-info → strapi_kit-0.1.0.dist-info}/WHEEL +0 -0
- {strapi_kit-0.0.5.dist-info → strapi_kit-0.1.0.dist-info}/licenses/LICENSE +0 -0
strapi_kit/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.0
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.1.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
strapi_kit/cache/schema_cache.py
CHANGED
|
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
5
5
|
|
|
6
6
|
from ..exceptions import StrapiError
|
|
7
7
|
from ..models.schema import ContentTypeSchema, FieldSchema, FieldType, RelationType
|
|
8
|
+
from ..utils.schema import extract_info_from_schema
|
|
8
9
|
|
|
9
10
|
if TYPE_CHECKING:
|
|
10
11
|
from ..client.sync_client import SyncClient
|
|
@@ -36,6 +37,7 @@ class InMemorySchemaCache:
|
|
|
36
37
|
"""
|
|
37
38
|
self.client = client
|
|
38
39
|
self._cache: dict[str, ContentTypeSchema] = {}
|
|
40
|
+
self._component_cache: dict[str, ContentTypeSchema] = {}
|
|
39
41
|
self._fetch_count = 0
|
|
40
42
|
|
|
41
43
|
def get_schema(self, content_type: str) -> ContentTypeSchema:
|
|
@@ -88,9 +90,80 @@ class InMemorySchemaCache:
|
|
|
88
90
|
def clear_cache(self) -> None:
|
|
89
91
|
"""Clear all cached schemas."""
|
|
90
92
|
self._cache.clear()
|
|
93
|
+
self._component_cache.clear()
|
|
91
94
|
self._fetch_count = 0
|
|
92
95
|
logger.debug("Schema cache cleared")
|
|
93
96
|
|
|
97
|
+
def get_component_schema(self, component_uid: str) -> ContentTypeSchema:
|
|
98
|
+
"""Get component schema (cached or fetch from API).
|
|
99
|
+
|
|
100
|
+
Lazy loading: only fetches on first access.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
component_uid: Component UID (e.g., "blog.author-bio")
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Component schema
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
StrapiError: If schema fetch fails
|
|
110
|
+
"""
|
|
111
|
+
# Check cache first
|
|
112
|
+
if component_uid in self._component_cache:
|
|
113
|
+
logger.debug(f"Component schema cache hit: {component_uid}")
|
|
114
|
+
return self._component_cache[component_uid]
|
|
115
|
+
|
|
116
|
+
# Cache miss - fetch from API
|
|
117
|
+
logger.debug(f"Component schema cache miss: {component_uid}")
|
|
118
|
+
schema = self._fetch_component_schema(component_uid)
|
|
119
|
+
self._component_cache[component_uid] = schema
|
|
120
|
+
self._fetch_count += 1
|
|
121
|
+
return schema
|
|
122
|
+
|
|
123
|
+
def has_component_schema(self, component_uid: str) -> bool:
|
|
124
|
+
"""Check if component schema is cached.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
component_uid: Component UID
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True if schema is cached, False otherwise
|
|
131
|
+
"""
|
|
132
|
+
return component_uid in self._component_cache
|
|
133
|
+
|
|
134
|
+
def _fetch_component_schema(self, component_uid: str) -> ContentTypeSchema:
|
|
135
|
+
"""Fetch component schema from Strapi API.
|
|
136
|
+
|
|
137
|
+
Endpoint: GET /api/content-type-builder/components/{uid}
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
component_uid: Component UID
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Parsed component schema
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
StrapiError: If fetch fails
|
|
147
|
+
"""
|
|
148
|
+
endpoint = f"content-type-builder/components/{component_uid}"
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
response = self.client.get(endpoint)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
raise StrapiError(
|
|
154
|
+
f"Failed to fetch component schema for {component_uid}",
|
|
155
|
+
details={"component_uid": component_uid, "error": str(e)},
|
|
156
|
+
) from e
|
|
157
|
+
|
|
158
|
+
schema_data = response.get("data")
|
|
159
|
+
if not schema_data:
|
|
160
|
+
raise StrapiError(
|
|
161
|
+
f"Invalid component schema response for {component_uid}",
|
|
162
|
+
details={"response": response},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return self._parse_schema_response(component_uid, schema_data)
|
|
166
|
+
|
|
94
167
|
def _fetch_schema(self, content_type: str) -> ContentTypeSchema:
|
|
95
168
|
"""Fetch schema from Strapi API.
|
|
96
169
|
|
|
@@ -134,8 +207,14 @@ class InMemorySchemaCache:
|
|
|
134
207
|
Returns:
|
|
135
208
|
Parsed content type schema
|
|
136
209
|
"""
|
|
137
|
-
|
|
138
|
-
|
|
210
|
+
# Handle v5 nested schema format (Issue #28)
|
|
211
|
+
if "schema" in schema_data and isinstance(schema_data["schema"], dict):
|
|
212
|
+
schema = schema_data["schema"]
|
|
213
|
+
else:
|
|
214
|
+
schema = schema_data
|
|
215
|
+
|
|
216
|
+
info = extract_info_from_schema(schema)
|
|
217
|
+
attributes = schema.get("attributes", {})
|
|
139
218
|
|
|
140
219
|
fields: dict[str, FieldSchema] = {}
|
|
141
220
|
for field_name, field_data in attributes.items():
|
|
@@ -147,7 +226,7 @@ class InMemorySchemaCache:
|
|
|
147
226
|
return ContentTypeSchema(
|
|
148
227
|
uid=uid,
|
|
149
228
|
display_name=info.get("displayName", uid),
|
|
150
|
-
kind=
|
|
229
|
+
kind=schema.get("kind", "collectionType"),
|
|
151
230
|
singular_name=info.get("singularName"),
|
|
152
231
|
plural_name=info.get("pluralName"),
|
|
153
232
|
fields=fields,
|
|
@@ -190,6 +269,15 @@ class InMemorySchemaCache:
|
|
|
190
269
|
schema.mapped_by = field_data.get("mappedBy")
|
|
191
270
|
schema.inversed_by = field_data.get("inversedBy")
|
|
192
271
|
|
|
272
|
+
# Component-specific
|
|
273
|
+
if field_type == FieldType.COMPONENT:
|
|
274
|
+
schema.component = field_data.get("component")
|
|
275
|
+
schema.repeatable = field_data.get("repeatable", False)
|
|
276
|
+
|
|
277
|
+
# Dynamic zone-specific
|
|
278
|
+
if field_type == FieldType.DYNAMIC_ZONE:
|
|
279
|
+
schema.components = field_data.get("components", [])
|
|
280
|
+
|
|
193
281
|
return schema
|
|
194
282
|
|
|
195
283
|
@property
|
|
@@ -585,7 +585,7 @@ class AsyncClient(BaseClient):
|
|
|
585
585
|
self,
|
|
586
586
|
media_url: str,
|
|
587
587
|
save_path: str | Path | None = None,
|
|
588
|
-
) -> bytes:
|
|
588
|
+
) -> bytes | Path:
|
|
589
589
|
"""Download a media file from Strapi.
|
|
590
590
|
|
|
591
591
|
Args:
|
|
@@ -593,7 +593,7 @@ class AsyncClient(BaseClient):
|
|
|
593
593
|
save_path: Optional path to save file (if None, returns bytes only)
|
|
594
594
|
|
|
595
595
|
Returns:
|
|
596
|
-
File content as bytes
|
|
596
|
+
File content as bytes when save_path is None, or Path when save_path is provided
|
|
597
597
|
|
|
598
598
|
Raises:
|
|
599
599
|
MediaError: On download failure
|
|
@@ -604,11 +604,13 @@ class AsyncClient(BaseClient):
|
|
|
604
604
|
>>> len(content)
|
|
605
605
|
102400
|
|
606
606
|
|
|
607
|
-
>>> # Download and save to file
|
|
608
|
-
>>>
|
|
607
|
+
>>> # Download and save to file (returns Path, not bytes)
|
|
608
|
+
>>> path = await client.download_file(
|
|
609
609
|
... "/uploads/image.jpg",
|
|
610
610
|
... save_path="downloaded_image.jpg"
|
|
611
611
|
... )
|
|
612
|
+
>>> path.exists()
|
|
613
|
+
True
|
|
612
614
|
"""
|
|
613
615
|
try:
|
|
614
616
|
# Build full URL
|
|
@@ -619,19 +621,25 @@ class AsyncClient(BaseClient):
|
|
|
619
621
|
if not response.is_success:
|
|
620
622
|
self._handle_error_response(response)
|
|
621
623
|
|
|
622
|
-
# Read content asynchronously
|
|
623
|
-
chunks = []
|
|
624
|
-
async for chunk in response.aiter_bytes():
|
|
625
|
-
chunks.append(chunk)
|
|
626
|
-
content = b"".join(chunks)
|
|
627
|
-
|
|
628
|
-
# Save to file if path provided
|
|
629
624
|
if save_path:
|
|
625
|
+
# Stream directly to disk for memory efficiency
|
|
626
|
+
# Note: Using sync file I/O here as aiofiles is not a dependency
|
|
627
|
+
# The streaming download itself is async which is the main benefit
|
|
630
628
|
path = Path(save_path)
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
629
|
+
total_bytes = 0
|
|
630
|
+
with open(path, "wb") as f:
|
|
631
|
+
async for chunk in response.aiter_bytes():
|
|
632
|
+
f.write(chunk)
|
|
633
|
+
total_bytes += len(chunk)
|
|
634
|
+
logger.info(f"Downloaded {total_bytes} bytes to {save_path}")
|
|
635
|
+
# Return path instead of reading back to avoid memory overhead
|
|
636
|
+
return path
|
|
637
|
+
else:
|
|
638
|
+
# Buffer in memory (original behavior for in-memory use)
|
|
639
|
+
chunks = []
|
|
640
|
+
async for chunk in response.aiter_bytes():
|
|
641
|
+
chunks.append(chunk)
|
|
642
|
+
return b"".join(chunks)
|
|
635
643
|
|
|
636
644
|
except StrapiError:
|
|
637
645
|
raise # Preserve specific error types (NotFoundError, etc.)
|
|
@@ -751,6 +759,11 @@ class AsyncClient(BaseClient):
|
|
|
751
759
|
import json as json_module
|
|
752
760
|
|
|
753
761
|
try:
|
|
762
|
+
# Ensure API version is detected before choosing endpoint
|
|
763
|
+
# When api_version="auto" and no prior API call, _api_version is None
|
|
764
|
+
if self._api_version is None:
|
|
765
|
+
await self.get_media(media_id) # Triggers version detection
|
|
766
|
+
|
|
754
767
|
# Build update payload
|
|
755
768
|
file_info: dict[str, Any] = {}
|
|
756
769
|
if alternative_text is not None:
|
|
@@ -814,8 +827,9 @@ class AsyncClient(BaseClient):
|
|
|
814
827
|
Args:
|
|
815
828
|
endpoint: API endpoint (e.g., "articles")
|
|
816
829
|
items: List of entity data dicts
|
|
817
|
-
batch_size: Items per batch (default: 10
|
|
818
|
-
|
|
830
|
+
batch_size: Items per batch wave (default: 10). Controls memory usage
|
|
831
|
+
by limiting how many tasks are active at once.
|
|
832
|
+
max_concurrency: Max parallel requests within each batch (default: 5)
|
|
819
833
|
query: Optional query
|
|
820
834
|
progress_callback: Optional callback(completed, total)
|
|
821
835
|
|
|
@@ -827,16 +841,15 @@ class AsyncClient(BaseClient):
|
|
|
827
841
|
... {"title": "Article 1", "content": "..."},
|
|
828
842
|
... {"title": "Article 2", "content": "..."},
|
|
829
843
|
... ]
|
|
830
|
-
>>> result = await client.bulk_create("articles", items, max_concurrency=10)
|
|
844
|
+
>>> result = await client.bulk_create("articles", items, batch_size=20, max_concurrency=10)
|
|
831
845
|
>>> print(f"Created {result.succeeded}/{result.total}")
|
|
832
846
|
"""
|
|
833
847
|
successes: list[NormalizedEntity] = []
|
|
834
848
|
failures: list[BulkOperationFailure] = []
|
|
835
|
-
semaphore = asyncio.Semaphore(max_concurrency)
|
|
836
|
-
lock = asyncio.Lock()
|
|
837
849
|
completed = 0
|
|
850
|
+
lock = asyncio.Lock()
|
|
838
851
|
|
|
839
|
-
async def create_one(idx: int, item: dict[str, Any]) -> None:
|
|
852
|
+
async def create_one(idx: int, item: dict[str, Any], semaphore: asyncio.Semaphore) -> None:
|
|
840
853
|
nonlocal completed
|
|
841
854
|
|
|
842
855
|
async with semaphore:
|
|
@@ -864,11 +877,16 @@ class AsyncClient(BaseClient):
|
|
|
864
877
|
if progress_callback:
|
|
865
878
|
progress_callback(completed, len(items))
|
|
866
879
|
|
|
867
|
-
#
|
|
868
|
-
|
|
880
|
+
# Process items in batches to control memory usage
|
|
881
|
+
for batch_start in range(0, len(items), batch_size):
|
|
882
|
+
batch = items[batch_start : batch_start + batch_size]
|
|
883
|
+
semaphore = asyncio.Semaphore(max_concurrency)
|
|
869
884
|
|
|
870
|
-
|
|
871
|
-
|
|
885
|
+
# Create tasks for this batch only
|
|
886
|
+
tasks = [create_one(batch_start + i, item, semaphore) for i, item in enumerate(batch)]
|
|
887
|
+
|
|
888
|
+
# Execute batch with gather
|
|
889
|
+
await asyncio.gather(*tasks, return_exceptions=False)
|
|
872
890
|
|
|
873
891
|
return BulkOperationResult(
|
|
874
892
|
successes=successes,
|
|
@@ -893,8 +911,9 @@ class AsyncClient(BaseClient):
|
|
|
893
911
|
Args:
|
|
894
912
|
endpoint: API endpoint (e.g., "articles")
|
|
895
913
|
updates: List of (id, data) tuples
|
|
896
|
-
batch_size: Items per batch (default: 10
|
|
897
|
-
|
|
914
|
+
batch_size: Items per batch wave (default: 10). Controls memory usage
|
|
915
|
+
by limiting how many tasks are active at once.
|
|
916
|
+
max_concurrency: Max parallel requests within each batch (default: 5)
|
|
898
917
|
query: Optional query
|
|
899
918
|
progress_callback: Optional callback(completed, total)
|
|
900
919
|
|
|
@@ -906,16 +925,17 @@ class AsyncClient(BaseClient):
|
|
|
906
925
|
... (1, {"title": "Updated Title 1"}),
|
|
907
926
|
... (2, {"title": "Updated Title 2"}),
|
|
908
927
|
... ]
|
|
909
|
-
>>> result = await client.bulk_update("articles", updates)
|
|
928
|
+
>>> result = await client.bulk_update("articles", updates, batch_size=20)
|
|
910
929
|
>>> print(f"Updated {result.succeeded}/{result.total}")
|
|
911
930
|
"""
|
|
912
931
|
successes: list[NormalizedEntity] = []
|
|
913
932
|
failures: list[BulkOperationFailure] = []
|
|
914
|
-
semaphore = asyncio.Semaphore(max_concurrency)
|
|
915
|
-
lock = asyncio.Lock()
|
|
916
933
|
completed = 0
|
|
934
|
+
lock = asyncio.Lock()
|
|
917
935
|
|
|
918
|
-
async def update_one(
|
|
936
|
+
async def update_one(
|
|
937
|
+
idx: int, entity_id: str | int, data: dict[str, Any], semaphore: asyncio.Semaphore
|
|
938
|
+
) -> None:
|
|
919
939
|
nonlocal completed
|
|
920
940
|
|
|
921
941
|
async with semaphore:
|
|
@@ -943,11 +963,19 @@ class AsyncClient(BaseClient):
|
|
|
943
963
|
if progress_callback:
|
|
944
964
|
progress_callback(completed, len(updates))
|
|
945
965
|
|
|
946
|
-
#
|
|
947
|
-
|
|
966
|
+
# Process updates in batches to control memory usage
|
|
967
|
+
for batch_start in range(0, len(updates), batch_size):
|
|
968
|
+
batch = updates[batch_start : batch_start + batch_size]
|
|
969
|
+
semaphore = asyncio.Semaphore(max_concurrency)
|
|
970
|
+
|
|
971
|
+
# Create tasks for this batch only
|
|
972
|
+
tasks = [
|
|
973
|
+
update_one(batch_start + i, entity_id, data, semaphore)
|
|
974
|
+
for i, (entity_id, data) in enumerate(batch)
|
|
975
|
+
]
|
|
948
976
|
|
|
949
|
-
|
|
950
|
-
|
|
977
|
+
# Execute batch with gather
|
|
978
|
+
await asyncio.gather(*tasks, return_exceptions=False)
|
|
951
979
|
|
|
952
980
|
return BulkOperationResult(
|
|
953
981
|
successes=successes,
|
|
@@ -971,8 +999,9 @@ class AsyncClient(BaseClient):
|
|
|
971
999
|
Args:
|
|
972
1000
|
endpoint: API endpoint (e.g., "articles")
|
|
973
1001
|
ids: List of entity IDs (numeric or documentId)
|
|
974
|
-
batch_size: Items per batch (default: 10
|
|
975
|
-
|
|
1002
|
+
batch_size: Items per batch wave (default: 10). Controls memory usage
|
|
1003
|
+
by limiting how many tasks are active at once.
|
|
1004
|
+
max_concurrency: Max parallel requests within each batch (default: 5)
|
|
976
1005
|
progress_callback: Optional callback(completed, total)
|
|
977
1006
|
|
|
978
1007
|
Returns:
|
|
@@ -980,26 +1009,25 @@ class AsyncClient(BaseClient):
|
|
|
980
1009
|
|
|
981
1010
|
Example:
|
|
982
1011
|
>>> ids = [1, 2, 3, 4, 5]
|
|
983
|
-
>>> result = await client.bulk_delete("articles", ids)
|
|
1012
|
+
>>> result = await client.bulk_delete("articles", ids, batch_size=20)
|
|
984
1013
|
>>> print(f"Deleted {result.succeeded} articles")
|
|
985
1014
|
"""
|
|
986
1015
|
successes: list[NormalizedEntity] = []
|
|
987
1016
|
failures: list[BulkOperationFailure] = []
|
|
988
|
-
semaphore = asyncio.Semaphore(max_concurrency)
|
|
989
|
-
lock = asyncio.Lock()
|
|
990
1017
|
completed = 0
|
|
991
1018
|
success_count = 0
|
|
1019
|
+
lock = asyncio.Lock()
|
|
992
1020
|
|
|
993
|
-
async def delete_one(idx: int, entity_id: str | int) -> None:
|
|
1021
|
+
async def delete_one(idx: int, entity_id: str | int, semaphore: asyncio.Semaphore) -> None:
|
|
994
1022
|
nonlocal completed, success_count
|
|
995
1023
|
|
|
996
1024
|
async with semaphore:
|
|
997
1025
|
try:
|
|
998
1026
|
response = await self.remove(f"{endpoint}/{entity_id}")
|
|
999
1027
|
|
|
1028
|
+
# DELETE may return 204 No Content with no data
|
|
1029
|
+
# Count as success when no exception is raised
|
|
1000
1030
|
async with lock:
|
|
1001
|
-
# DELETE may return 204 No Content with no data
|
|
1002
|
-
# Count as success when no exception is raised
|
|
1003
1031
|
success_count += 1
|
|
1004
1032
|
if response.data:
|
|
1005
1033
|
successes.append(response.data)
|
|
@@ -1021,11 +1049,19 @@ class AsyncClient(BaseClient):
|
|
|
1021
1049
|
if progress_callback:
|
|
1022
1050
|
progress_callback(completed, len(ids))
|
|
1023
1051
|
|
|
1024
|
-
#
|
|
1025
|
-
|
|
1052
|
+
# Process deletes in batches to control memory usage
|
|
1053
|
+
for batch_start in range(0, len(ids), batch_size):
|
|
1054
|
+
batch = ids[batch_start : batch_start + batch_size]
|
|
1055
|
+
semaphore = asyncio.Semaphore(max_concurrency)
|
|
1056
|
+
|
|
1057
|
+
# Create tasks for this batch only
|
|
1058
|
+
tasks = [
|
|
1059
|
+
delete_one(batch_start + i, entity_id, semaphore)
|
|
1060
|
+
for i, entity_id in enumerate(batch)
|
|
1061
|
+
]
|
|
1026
1062
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1063
|
+
# Execute batch with gather
|
|
1064
|
+
await asyncio.gather(*tasks, return_exceptions=False)
|
|
1029
1065
|
|
|
1030
1066
|
return BulkOperationResult(
|
|
1031
1067
|
successes=successes,
|
strapi_kit/client/base.py
CHANGED
|
@@ -44,6 +44,7 @@ from ..models.response.normalized import (
|
|
|
44
44
|
from ..operations.media import normalize_media_response
|
|
45
45
|
from ..parsers import VersionDetectingParser
|
|
46
46
|
from ..protocols import AuthProvider, ConfigProvider, ResponseParser
|
|
47
|
+
from ..utils.schema import extract_info_from_schema
|
|
47
48
|
|
|
48
49
|
logger = logging.getLogger(__name__)
|
|
49
50
|
|
|
@@ -465,6 +466,63 @@ class BaseClient:
|
|
|
465
466
|
# Media list follows standard collection format
|
|
466
467
|
return self._parse_collection_response(response_data)
|
|
467
468
|
|
|
469
|
+
def _normalize_content_type_item(self, item: dict[str, Any]) -> dict[str, Any]:
|
|
470
|
+
"""Normalize content type item - flatten v5 schema to v4 format.
|
|
471
|
+
|
|
472
|
+
Strapi v5 returns content types with a nested 'schema' structure:
|
|
473
|
+
{"uid": "...", "apiID": "...", "schema": {"kind": "...", "info": {...}, ...}}
|
|
474
|
+
|
|
475
|
+
Or with flat schema properties (actual v5 API - Issue #28):
|
|
476
|
+
{"uid": "...", "apiID": "...", "schema": {"kind": "...", "displayName": "...", ...}}
|
|
477
|
+
|
|
478
|
+
This method flattens it to v4 format:
|
|
479
|
+
{"uid": "...", "kind": "...", "info": {...}, "attributes": {...}}
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
item: Raw content type item from API response
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
Normalized content type item in v4-compatible format
|
|
486
|
+
"""
|
|
487
|
+
if "schema" in item and isinstance(item["schema"], dict):
|
|
488
|
+
schema = item["schema"]
|
|
489
|
+
return {
|
|
490
|
+
"uid": item.get("uid", ""),
|
|
491
|
+
"kind": schema.get("kind", "collectionType"),
|
|
492
|
+
"info": extract_info_from_schema(schema),
|
|
493
|
+
"attributes": schema.get("attributes", {}),
|
|
494
|
+
"pluginOptions": schema.get("pluginOptions"),
|
|
495
|
+
}
|
|
496
|
+
return item
|
|
497
|
+
|
|
498
|
+
def _normalize_component_item(self, item: dict[str, Any]) -> dict[str, Any]:
|
|
499
|
+
"""Normalize component item - flatten v5 schema to v4 format.
|
|
500
|
+
|
|
501
|
+
Strapi v5 returns components with a nested 'schema' structure:
|
|
502
|
+
{"uid": "...", "category": "...", "schema": {"info": {...}, "attributes": {...}}}
|
|
503
|
+
|
|
504
|
+
Or with flat schema properties (actual v5 API - Issue #28):
|
|
505
|
+
{"uid": "...", "category": "...", "schema": {"displayName": "...", "attributes": {...}}}
|
|
506
|
+
|
|
507
|
+
This method flattens it to v4 format:
|
|
508
|
+
{"uid": "...", "category": "...", "info": {...}, "attributes": {...}}
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
item: Raw component item from API response
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
Normalized component item in v4-compatible format
|
|
515
|
+
"""
|
|
516
|
+
if "schema" in item and isinstance(item["schema"], dict):
|
|
517
|
+
schema = item["schema"]
|
|
518
|
+
return {
|
|
519
|
+
"uid": item.get("uid", ""),
|
|
520
|
+
"category": item.get("category", schema.get("category", "")),
|
|
521
|
+
"info": extract_info_from_schema(schema),
|
|
522
|
+
"attributes": schema.get("attributes", {}),
|
|
523
|
+
}
|
|
524
|
+
return item
|
|
525
|
+
|
|
468
526
|
def _parse_content_types_response(
|
|
469
527
|
self,
|
|
470
528
|
response_data: dict[str, Any],
|
|
@@ -472,6 +530,8 @@ class BaseClient:
|
|
|
472
530
|
) -> list["ContentTypeListItem"]:
|
|
473
531
|
"""Parse content-type-builder content types response.
|
|
474
532
|
|
|
533
|
+
Automatically normalizes v5 nested schema format to v4 flat format.
|
|
534
|
+
|
|
475
535
|
Args:
|
|
476
536
|
response_data: Raw JSON response from content-type-builder
|
|
477
537
|
include_plugins: Whether to include plugin content types
|
|
@@ -491,7 +551,8 @@ class BaseClient:
|
|
|
491
551
|
continue
|
|
492
552
|
|
|
493
553
|
try:
|
|
494
|
-
|
|
554
|
+
normalized_item = self._normalize_content_type_item(item)
|
|
555
|
+
content_type = ContentTypeListItem.model_validate(normalized_item)
|
|
495
556
|
result.append(content_type)
|
|
496
557
|
except PydanticValidationError as e:
|
|
497
558
|
# Skip malformed items
|
|
@@ -506,6 +567,8 @@ class BaseClient:
|
|
|
506
567
|
) -> list["ComponentListItem"]:
|
|
507
568
|
"""Parse content-type-builder components response.
|
|
508
569
|
|
|
570
|
+
Automatically normalizes v5 nested schema format to v4 flat format.
|
|
571
|
+
|
|
509
572
|
Args:
|
|
510
573
|
response_data: Raw JSON response from content-type-builder
|
|
511
574
|
|
|
@@ -520,7 +583,8 @@ class BaseClient:
|
|
|
520
583
|
for item in data:
|
|
521
584
|
uid = item.get("uid", "")
|
|
522
585
|
try:
|
|
523
|
-
|
|
586
|
+
normalized_item = self._normalize_component_item(item)
|
|
587
|
+
component = ComponentListItem.model_validate(normalized_item)
|
|
524
588
|
result.append(component)
|
|
525
589
|
except PydanticValidationError as e:
|
|
526
590
|
# Skip malformed items
|
|
@@ -535,6 +599,8 @@ class BaseClient:
|
|
|
535
599
|
) -> "CTBContentTypeSchema":
|
|
536
600
|
"""Parse content-type-builder single content type schema response.
|
|
537
601
|
|
|
602
|
+
Automatically normalizes v5 nested schema format to v4 flat format.
|
|
603
|
+
|
|
538
604
|
Args:
|
|
539
605
|
response_data: Raw JSON response from content-type-builder
|
|
540
606
|
|
|
@@ -548,7 +614,8 @@ class BaseClient:
|
|
|
548
614
|
|
|
549
615
|
data = response_data.get("data", response_data)
|
|
550
616
|
try:
|
|
551
|
-
|
|
617
|
+
normalized_data = self._normalize_content_type_item(data)
|
|
618
|
+
return CTBContentTypeSchema.model_validate(normalized_data)
|
|
552
619
|
except PydanticValidationError as e:
|
|
553
620
|
raise ValidationError(
|
|
554
621
|
"Invalid content type schema response",
|
strapi_kit/client/sync_client.py
CHANGED
|
@@ -578,7 +578,7 @@ class SyncClient(BaseClient):
|
|
|
578
578
|
self,
|
|
579
579
|
media_url: str,
|
|
580
580
|
save_path: str | Path | None = None,
|
|
581
|
-
) -> bytes:
|
|
581
|
+
) -> bytes | Path:
|
|
582
582
|
"""Download a media file from Strapi.
|
|
583
583
|
|
|
584
584
|
Args:
|
|
@@ -586,7 +586,7 @@ class SyncClient(BaseClient):
|
|
|
586
586
|
save_path: Optional path to save file (if None, returns bytes only)
|
|
587
587
|
|
|
588
588
|
Returns:
|
|
589
|
-
File content as bytes
|
|
589
|
+
File content as bytes when save_path is None, or Path when save_path is provided
|
|
590
590
|
|
|
591
591
|
Raises:
|
|
592
592
|
MediaError: On download failure
|
|
@@ -597,11 +597,13 @@ class SyncClient(BaseClient):
|
|
|
597
597
|
>>> len(content)
|
|
598
598
|
102400
|
|
599
599
|
|
|
600
|
-
>>> # Download and save to file
|
|
601
|
-
>>>
|
|
600
|
+
>>> # Download and save to file (returns Path, not bytes)
|
|
601
|
+
>>> path = client.download_file(
|
|
602
602
|
... "/uploads/image.jpg",
|
|
603
603
|
... save_path="downloaded_image.jpg"
|
|
604
604
|
... )
|
|
605
|
+
>>> path.exists()
|
|
606
|
+
True
|
|
605
607
|
"""
|
|
606
608
|
try:
|
|
607
609
|
# Build full URL
|
|
@@ -612,16 +614,20 @@ class SyncClient(BaseClient):
|
|
|
612
614
|
if not response.is_success:
|
|
613
615
|
self._handle_error_response(response)
|
|
614
616
|
|
|
615
|
-
# Read content
|
|
616
|
-
content = b"".join(response.iter_bytes())
|
|
617
|
-
|
|
618
|
-
# Save to file if path provided
|
|
619
617
|
if save_path:
|
|
618
|
+
# Stream directly to disk for memory efficiency
|
|
620
619
|
path = Path(save_path)
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
620
|
+
total_bytes = 0
|
|
621
|
+
with open(path, "wb") as f:
|
|
622
|
+
for chunk in response.iter_bytes():
|
|
623
|
+
f.write(chunk)
|
|
624
|
+
total_bytes += len(chunk)
|
|
625
|
+
logger.info(f"Downloaded {total_bytes} bytes to {save_path}")
|
|
626
|
+
# Return path instead of reading back to avoid memory overhead
|
|
627
|
+
return path
|
|
628
|
+
else:
|
|
629
|
+
# Buffer in memory (original behavior for in-memory use)
|
|
630
|
+
return b"".join(response.iter_bytes())
|
|
625
631
|
|
|
626
632
|
except StrapiError:
|
|
627
633
|
raise # Preserve specific error types (NotFoundError, etc.)
|
|
@@ -741,6 +747,11 @@ class SyncClient(BaseClient):
|
|
|
741
747
|
import json as json_module
|
|
742
748
|
|
|
743
749
|
try:
|
|
750
|
+
# Ensure API version is detected before choosing endpoint
|
|
751
|
+
# When api_version="auto" and no prior API call, _api_version is None
|
|
752
|
+
if self._api_version is None:
|
|
753
|
+
self.get_media(media_id) # Triggers version detection
|
|
754
|
+
|
|
744
755
|
# Build update payload
|
|
745
756
|
file_info: dict[str, Any] = {}
|
|
746
757
|
if alternative_text is not None:
|
strapi_kit/export/__init__.py
CHANGED
|
@@ -6,5 +6,7 @@ entities, and media files in a portable format.
|
|
|
6
6
|
|
|
7
7
|
from strapi_kit.export.exporter import StrapiExporter
|
|
8
8
|
from strapi_kit.export.importer import StrapiImporter
|
|
9
|
+
from strapi_kit.export.jsonl_reader import JSONLImportReader
|
|
10
|
+
from strapi_kit.export.jsonl_writer import JSONLExportWriter
|
|
9
11
|
|
|
10
|
-
__all__ = ["StrapiExporter", "StrapiImporter"]
|
|
12
|
+
__all__ = ["JSONLExportWriter", "JSONLImportReader", "StrapiExporter", "StrapiImporter"]
|