strapi-kit 0.0.6__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 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.6'
32
- __version_tuple__ = version_tuple = (0, 0, 6)
31
+ __version__ = version = '0.1.0'
32
+ __version_tuple__ = version_tuple = (0, 1, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -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
- info = schema_data.get("info", {})
138
- attributes = schema_data.get("attributes", {})
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=schema_data.get("kind", "collectionType"),
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
- >>> content = await client.download_file(
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
- path.write_bytes(content)
632
- logger.info(f"Downloaded {len(content)} bytes to {save_path}")
633
-
634
- return content
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, currently unused - for API compatibility)
818
- max_concurrency: Max concurrent requests (default: 5)
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
- # Create all tasks
868
- tasks = [create_one(i, item) for i, item in enumerate(items)]
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
- # Execute with gather (doesn't stop on first error)
871
- await asyncio.gather(*tasks, return_exceptions=False)
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, currently unused - for API compatibility)
897
- max_concurrency: Max concurrent requests (default: 5)
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(idx: int, entity_id: str | int, data: dict[str, Any]) -> None:
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
- # Create all tasks
947
- tasks = [update_one(i, entity_id, data) for i, (entity_id, data) in enumerate(updates)]
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
- # Execute with gather
950
- await asyncio.gather(*tasks, return_exceptions=False)
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, currently unused - for API compatibility)
975
- max_concurrency: Max concurrent requests (default: 5)
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
- # Create all tasks
1025
- tasks = [delete_one(i, entity_id) for i, entity_id in enumerate(ids)]
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
- # Execute with gather
1028
- await asyncio.gather(*tasks, return_exceptions=False)
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
 
@@ -471,6 +472,9 @@ class BaseClient:
471
472
  Strapi v5 returns content types with a nested 'schema' structure:
472
473
  {"uid": "...", "apiID": "...", "schema": {"kind": "...", "info": {...}, ...}}
473
474
 
475
+ Or with flat schema properties (actual v5 API - Issue #28):
476
+ {"uid": "...", "apiID": "...", "schema": {"kind": "...", "displayName": "...", ...}}
477
+
474
478
  This method flattens it to v4 format:
475
479
  {"uid": "...", "kind": "...", "info": {...}, "attributes": {...}}
476
480
 
@@ -485,7 +489,7 @@ class BaseClient:
485
489
  return {
486
490
  "uid": item.get("uid", ""),
487
491
  "kind": schema.get("kind", "collectionType"),
488
- "info": schema.get("info", {}),
492
+ "info": extract_info_from_schema(schema),
489
493
  "attributes": schema.get("attributes", {}),
490
494
  "pluginOptions": schema.get("pluginOptions"),
491
495
  }
@@ -497,6 +501,9 @@ class BaseClient:
497
501
  Strapi v5 returns components with a nested 'schema' structure:
498
502
  {"uid": "...", "category": "...", "schema": {"info": {...}, "attributes": {...}}}
499
503
 
504
+ Or with flat schema properties (actual v5 API - Issue #28):
505
+ {"uid": "...", "category": "...", "schema": {"displayName": "...", "attributes": {...}}}
506
+
500
507
  This method flattens it to v4 format:
501
508
  {"uid": "...", "category": "...", "info": {...}, "attributes": {...}}
502
509
 
@@ -511,7 +518,7 @@ class BaseClient:
511
518
  return {
512
519
  "uid": item.get("uid", ""),
513
520
  "category": item.get("category", schema.get("category", "")),
514
- "info": schema.get("info", {}),
521
+ "info": extract_info_from_schema(schema),
515
522
  "attributes": schema.get("attributes", {}),
516
523
  }
517
524
  return item
@@ -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
- >>> content = client.download_file(
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
- path.write_bytes(content)
622
- logger.info(f"Downloaded {len(content)} bytes to {save_path}")
623
-
624
- return content
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:
@@ -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"]