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,226 @@
1
+ """Media operations utilities for strapi-kit.
2
+
3
+ This module provides shared utility functions for media upload, download,
4
+ and response normalization across sync and async clients.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import IO, Any, Literal
10
+ from urllib.parse import urljoin, urlparse
11
+
12
+ from strapi_kit.models.response.media import MediaFile
13
+
14
+
15
+ class UploadPayload:
16
+ """Container for upload payload with proper file handle management.
17
+
18
+ This class ensures file handles are properly closed after upload operations,
19
+ preventing resource leaks in batch operations or error scenarios.
20
+
21
+ Usage:
22
+ Use as a context manager to ensure proper cleanup:
23
+
24
+ >>> with build_upload_payload("image.jpg") as payload:
25
+ ... files = {"files": payload.files_tuple}
26
+ ... data = payload.data
27
+ ... # Make HTTP request
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ file_path: Path,
33
+ data: dict[str, Any] | None = None,
34
+ ) -> None:
35
+ """Initialize upload payload.
36
+
37
+ Args:
38
+ file_path: Path to the file to upload
39
+ data: Optional metadata dictionary
40
+ """
41
+ self._file_path = file_path
42
+ self._data = data
43
+ self._file_handle: IO[bytes] | None = None
44
+
45
+ @property
46
+ def files_tuple(self) -> tuple[str, IO[bytes], None]:
47
+ """Get the files tuple for httpx multipart upload.
48
+
49
+ Returns:
50
+ Tuple of (filename, file_handle, content_type)
51
+ Content type is None to let httpx auto-detect MIME type.
52
+
53
+ Raises:
54
+ RuntimeError: If accessed outside of context manager
55
+ """
56
+ if self._file_handle is None:
57
+ raise RuntimeError("UploadPayload must be used as a context manager")
58
+ return ("file", self._file_handle, None)
59
+
60
+ @property
61
+ def data(self) -> dict[str, Any] | None:
62
+ """Get the metadata dictionary."""
63
+ return self._data
64
+
65
+ def __enter__(self) -> "UploadPayload":
66
+ """Open file handle on context entry."""
67
+ self._file_handle = open(self._file_path, "rb")
68
+ return self
69
+
70
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
71
+ """Close file handle on context exit."""
72
+ if self._file_handle is not None:
73
+ self._file_handle.close()
74
+ self._file_handle = None
75
+
76
+
77
+ def build_upload_payload(
78
+ file_path: str | Path,
79
+ ref: str | None = None,
80
+ ref_id: str | int | None = None,
81
+ field: str | None = None,
82
+ folder: str | None = None,
83
+ alternative_text: str | None = None,
84
+ caption: str | None = None,
85
+ ) -> UploadPayload:
86
+ """Build multipart form data payload for file upload.
87
+
88
+ Returns an UploadPayload context manager that properly handles file
89
+ lifecycle to prevent resource leaks.
90
+
91
+ Args:
92
+ file_path: Path to file to upload
93
+ ref: Reference model name (e.g., "api::article.article")
94
+ ref_id: Reference document ID (numeric or string)
95
+ field: Field name in reference model
96
+ folder: Folder ID for organization
97
+ alternative_text: Alt text for images
98
+ caption: Caption text
99
+
100
+ Returns:
101
+ UploadPayload context manager with file handle management
102
+
103
+ Raises:
104
+ FileNotFoundError: If file doesn't exist
105
+
106
+ Example:
107
+ >>> with build_upload_payload(
108
+ ... "image.jpg",
109
+ ... ref="api::article.article",
110
+ ... ref_id="123",
111
+ ... alternative_text="Hero image"
112
+ ... ) as payload:
113
+ ... # Use payload.files_tuple and payload.data for upload
114
+ ... pass
115
+ """
116
+ path = Path(file_path)
117
+ if not path.exists():
118
+ raise FileNotFoundError(f"File not found: {file_path}")
119
+
120
+ # Build metadata dict (fileInfo in Strapi API)
121
+ file_info: dict[str, Any] = {}
122
+ if alternative_text is not None:
123
+ file_info["alternativeText"] = alternative_text
124
+ if caption is not None:
125
+ file_info["caption"] = caption
126
+
127
+ # Build form data metadata
128
+ data: dict[str, Any] = {}
129
+ if ref is not None:
130
+ data["ref"] = ref
131
+ if ref_id is not None:
132
+ data["refId"] = str(ref_id)
133
+ if field is not None:
134
+ data["field"] = field
135
+ if folder is not None:
136
+ data["folder"] = folder
137
+ if file_info:
138
+ # httpx multipart requires JSON string for nested objects
139
+ data["fileInfo"] = json.dumps(file_info)
140
+
141
+ return UploadPayload(path, data if data else None)
142
+
143
+
144
+ def normalize_media_response(
145
+ response_data: dict[str, Any],
146
+ api_version: Literal["v4", "v5"],
147
+ ) -> MediaFile:
148
+ """Normalize v4/v5 media response to MediaFile model.
149
+
150
+ Handles both nested attributes (v4) and flattened (v5) response structures.
151
+
152
+ Args:
153
+ response_data: Raw API response data
154
+ api_version: Detected API version ("v4" or "v5")
155
+
156
+ Returns:
157
+ Validated MediaFile instance
158
+
159
+ Example:
160
+ >>> # v5 response (flattened)
161
+ >>> v5_data = {
162
+ ... "id": 1,
163
+ ... "documentId": "abc123",
164
+ ... "name": "image.jpg",
165
+ ... "url": "/uploads/image.jpg"
166
+ ... }
167
+ >>> media = normalize_media_response(v5_data, "v5")
168
+
169
+ >>> # v4 response (nested attributes)
170
+ >>> v4_data = {
171
+ ... "id": 1,
172
+ ... "attributes": {
173
+ ... "name": "image.jpg",
174
+ ... "url": "/uploads/image.jpg"
175
+ ... }
176
+ ... }
177
+ >>> media = normalize_media_response(v4_data, "v4")
178
+ """
179
+ if api_version == "v4":
180
+ # v4: nested structure with id at top level, rest in attributes
181
+ if "attributes" in response_data:
182
+ # Flatten attributes to top level
183
+ flattened = {"id": response_data["id"], **response_data["attributes"]}
184
+ return MediaFile.model_validate(flattened)
185
+ else:
186
+ # Already flattened or invalid
187
+ return MediaFile.model_validate(response_data)
188
+ else:
189
+ # v5: already flattened with documentId
190
+ return MediaFile.model_validate(response_data)
191
+
192
+
193
+ def build_media_download_url(base_url: str, media_url: str) -> str:
194
+ """Construct full URL for media download.
195
+
196
+ Handles both relative paths (/uploads/...) and absolute URLs.
197
+
198
+ Args:
199
+ base_url: Strapi instance base URL (e.g., "http://localhost:1337")
200
+ media_url: Media URL from API response (relative or absolute)
201
+
202
+ Returns:
203
+ Full absolute URL for download
204
+
205
+ Example:
206
+ >>> build_media_download_url(
207
+ ... "http://localhost:1337",
208
+ ... "/uploads/image.jpg"
209
+ ... )
210
+ 'http://localhost:1337/uploads/image.jpg'
211
+
212
+ >>> build_media_download_url(
213
+ ... "http://localhost:1337",
214
+ ... "https://cdn.example.com/image.jpg"
215
+ ... )
216
+ 'https://cdn.example.com/image.jpg'
217
+ """
218
+ # Check if URL is already absolute
219
+ parsed = urlparse(media_url)
220
+ if parsed.scheme: # Has http:// or https://
221
+ return media_url
222
+
223
+ # Relative URL - join with base_url
224
+ # Ensure base_url doesn't have trailing slash for proper joining
225
+ base = base_url.rstrip("/")
226
+ return urljoin(base, media_url)
@@ -0,0 +1,144 @@
1
+ """Streaming pagination utilities for large result sets.
2
+
3
+ This module provides generators that automatically handle pagination,
4
+ allowing memory-efficient iteration over large datasets.
5
+ """
6
+
7
+ from collections.abc import AsyncGenerator, Generator
8
+ from typing import TYPE_CHECKING
9
+
10
+ from ..models import StrapiQuery
11
+ from ..models.response.normalized import NormalizedEntity
12
+
13
+ if TYPE_CHECKING:
14
+ from ..client.async_client import AsyncClient
15
+ from ..client.sync_client import SyncClient
16
+
17
+
18
+ def stream_entities(
19
+ client: "SyncClient",
20
+ endpoint: str,
21
+ query: StrapiQuery | None = None,
22
+ page_size: int = 100,
23
+ ) -> Generator[NormalizedEntity, None, None]:
24
+ """Stream entities from endpoint with automatic pagination.
25
+
26
+ This generator automatically fetches pages as needed, yielding
27
+ entities one at a time without loading the entire dataset into memory.
28
+
29
+ Args:
30
+ client: SyncClient instance
31
+ endpoint: API endpoint (e.g., "articles")
32
+ query: Optional query (filters, sorts, populate, etc.)
33
+ page_size: Items per page (default: 100)
34
+
35
+ Yields:
36
+ NormalizedEntity objects one at a time
37
+
38
+ Raises:
39
+ ValueError: If page_size < 1
40
+
41
+ Example:
42
+ >>> with SyncClient(config) as client:
43
+ ... for article in stream_entities(client, "articles", page_size=50):
44
+ ... print(article.attributes["title"])
45
+ ... # Process one at a time without loading all into memory
46
+ """
47
+ if page_size < 1:
48
+ raise ValueError("page_size must be >= 1")
49
+
50
+ current_page = 1
51
+
52
+ # Build base query - create copy to avoid mutating caller's query
53
+ base_query = query.copy() if query is not None else StrapiQuery()
54
+
55
+ while True:
56
+ # Update pagination for current page on a copy
57
+ page_query = base_query.copy().paginate(page=current_page, page_size=page_size)
58
+
59
+ # Fetch page
60
+ response = client.get_many(endpoint, query=page_query)
61
+
62
+ # Yield each entity
63
+ yield from response.data
64
+
65
+ # Safety check: if no data returned, stop to prevent infinite loop
66
+ if not response.data:
67
+ break
68
+
69
+ # Check if more pages exist
70
+ if response.meta and response.meta.pagination:
71
+ total_pages = response.meta.pagination.page_count
72
+ # Handle None or 0 page_count - stop to prevent infinite loop
73
+ if total_pages is None or total_pages == 0 or current_page >= total_pages:
74
+ break
75
+ else:
76
+ # No pagination metadata, assume single page
77
+ break
78
+
79
+ current_page += 1
80
+
81
+
82
+ async def stream_entities_async(
83
+ client: "AsyncClient",
84
+ endpoint: str,
85
+ query: StrapiQuery | None = None,
86
+ page_size: int = 100,
87
+ ) -> AsyncGenerator[NormalizedEntity, None]:
88
+ """Async version of stream_entities.
89
+
90
+ This async generator automatically fetches pages as needed, yielding
91
+ entities one at a time without loading the entire dataset into memory.
92
+
93
+ Args:
94
+ client: AsyncClient instance
95
+ endpoint: API endpoint (e.g., "articles")
96
+ query: Optional query (filters, sorts, populate, etc.)
97
+ page_size: Items per page (default: 100)
98
+
99
+ Yields:
100
+ NormalizedEntity objects one at a time
101
+
102
+ Raises:
103
+ ValueError: If page_size < 1
104
+
105
+ Example:
106
+ >>> async with AsyncClient(config) as client:
107
+ ... async for article in stream_entities_async(client, "articles"):
108
+ ... print(article.attributes["title"])
109
+ ... # Process asynchronously without loading all into memory
110
+ """
111
+ if page_size < 1:
112
+ raise ValueError("page_size must be >= 1")
113
+
114
+ current_page = 1
115
+
116
+ # Build base query - create copy to avoid mutating caller's query
117
+ base_query = query.copy() if query is not None else StrapiQuery()
118
+
119
+ while True:
120
+ # Update pagination for current page on a copy
121
+ page_query = base_query.copy().paginate(page=current_page, page_size=page_size)
122
+
123
+ # Fetch page
124
+ response = await client.get_many(endpoint, query=page_query)
125
+
126
+ # Yield each entity
127
+ for entity in response.data:
128
+ yield entity
129
+
130
+ # Safety check: if no data returned, stop to prevent infinite loop
131
+ if not response.data:
132
+ break
133
+
134
+ # Check if more pages exist
135
+ if response.meta and response.meta.pagination:
136
+ total_pages = response.meta.pagination.page_count
137
+ # Handle None or 0 page_count - stop to prevent infinite loop
138
+ if total_pages is None or total_pages == 0 or current_page >= total_pages:
139
+ break
140
+ else:
141
+ # No pagination metadata, assume single page
142
+ break
143
+
144
+ current_page += 1
@@ -0,0 +1,5 @@
1
+ """Response parsers for Strapi API responses."""
2
+
3
+ from .version_detecting import VersionDetectingParser
4
+
5
+ __all__ = ["VersionDetectingParser"]
@@ -0,0 +1,171 @@
1
+ """Response parser with automatic Strapi version detection.
2
+
3
+ This module provides automatic detection and normalization of Strapi v4 and v5
4
+ API responses into a consistent format.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any, Literal
9
+
10
+ from ..models.response.normalized import (
11
+ NormalizedCollectionResponse,
12
+ NormalizedEntity,
13
+ NormalizedSingleResponse,
14
+ )
15
+ from ..models.response.v4 import V4CollectionResponse, V4SingleResponse
16
+ from ..models.response.v5 import V5CollectionResponse, V5SingleResponse
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class VersionDetectingParser:
22
+ """Response parser with automatic v4/v5 version detection.
23
+
24
+ This parser automatically detects whether responses are from Strapi v4
25
+ or v5 based on their structure, then normalizes them to a consistent format.
26
+
27
+ Example:
28
+ ```python
29
+ parser = VersionDetectingParser()
30
+ normalized = parser.parse_single(raw_response)
31
+ print(normalized.data.id)
32
+ ```
33
+ """
34
+
35
+ def __init__(self, default_version: Literal["v4", "v5"] | None = None) -> None:
36
+ """Initialize the parser.
37
+
38
+ Args:
39
+ default_version: Optional version to use if detection fails
40
+ """
41
+ self._detected_version: Literal["v4", "v5"] | None = default_version
42
+
43
+ @property
44
+ def detected_version(self) -> Literal["v4", "v5"] | None:
45
+ """Get the detected API version.
46
+
47
+ Returns:
48
+ Detected version or None if not yet detected
49
+ """
50
+ return self._detected_version
51
+
52
+ def detect_version(self, response_data: dict[str, Any]) -> Literal["v4", "v5"]:
53
+ """Detect Strapi API version from response structure.
54
+
55
+ Args:
56
+ response_data: Raw JSON response from Strapi
57
+
58
+ Returns:
59
+ Detected API version ("v4" or "v5")
60
+
61
+ Note:
62
+ Only caches version when detection is definitive (found attributes
63
+ or documentId). Ambiguous responses return fallback without caching,
64
+ allowing re-detection on subsequent meaningful responses.
65
+ """
66
+ # If already detected, use cached version
67
+ if self._detected_version:
68
+ return self._detected_version
69
+
70
+ # V4: data.attributes structure
71
+ # V5: flattened data with documentId
72
+ if isinstance(response_data.get("data"), dict):
73
+ data = response_data["data"]
74
+ if "attributes" in data:
75
+ self._detected_version = "v4"
76
+ logger.info("Detected Strapi v4 API format")
77
+ elif "documentId" in data:
78
+ self._detected_version = "v5"
79
+ logger.info("Detected Strapi v5 API format")
80
+ else:
81
+ # Don't cache - return fallback without locking
82
+ logger.warning("Could not detect API version from object data, using v4 fallback")
83
+ return "v4"
84
+ elif isinstance(response_data.get("data"), list) and response_data["data"]:
85
+ # Check first item in list
86
+ first_item = response_data["data"][0]
87
+ if "attributes" in first_item:
88
+ self._detected_version = "v4"
89
+ logger.info("Detected Strapi v4 API format")
90
+ elif "documentId" in first_item:
91
+ self._detected_version = "v5"
92
+ logger.info("Detected Strapi v5 API format")
93
+ else:
94
+ # Don't cache - return fallback without locking
95
+ logger.warning("Could not detect API version from list data, using v4 fallback")
96
+ return "v4"
97
+ else:
98
+ # Empty/no data - don't cache, return fallback
99
+ return "v4"
100
+
101
+ return self._detected_version
102
+
103
+ def parse_single(self, response_data: dict[str, Any]) -> NormalizedSingleResponse:
104
+ """Parse a single entity response into normalized format.
105
+
106
+ Args:
107
+ response_data: Raw JSON response from Strapi
108
+
109
+ Returns:
110
+ Normalized single entity response
111
+
112
+ Examples:
113
+ >>> parser = VersionDetectingParser()
114
+ >>> response = {"data": {"id": 1, "documentId": "abc", ...}}
115
+ >>> normalized = parser.parse_single(response)
116
+ >>> normalized.data.id
117
+ 1
118
+ """
119
+ # Detect API version from response
120
+ api_version = self.detect_version(response_data)
121
+
122
+ if api_version == "v4":
123
+ # Parse as v4 and normalize
124
+ v4_response = V4SingleResponse(**response_data)
125
+ if v4_response.data:
126
+ normalized_entity = NormalizedEntity.from_v4(v4_response.data)
127
+ else:
128
+ normalized_entity = None
129
+
130
+ return NormalizedSingleResponse(data=normalized_entity, meta=v4_response.meta)
131
+ else:
132
+ # Parse as v5 and normalize
133
+ v5_response = V5SingleResponse(**response_data)
134
+ if v5_response.data:
135
+ normalized_entity = NormalizedEntity.from_v5(v5_response.data)
136
+ else:
137
+ normalized_entity = None
138
+
139
+ return NormalizedSingleResponse(data=normalized_entity, meta=v5_response.meta)
140
+
141
+ def parse_collection(self, response_data: dict[str, Any]) -> NormalizedCollectionResponse:
142
+ """Parse a collection response into normalized format.
143
+
144
+ Args:
145
+ response_data: Raw JSON response from Strapi
146
+
147
+ Returns:
148
+ Normalized collection response
149
+
150
+ Examples:
151
+ >>> parser = VersionDetectingParser()
152
+ >>> response = {"data": [{"id": 1, ...}, {"id": 2, ...}]}
153
+ >>> normalized = parser.parse_collection(response)
154
+ >>> len(normalized.data)
155
+ 2
156
+ """
157
+ # Detect API version from response
158
+ api_version = self.detect_version(response_data)
159
+
160
+ if api_version == "v4":
161
+ # Parse as v4 and normalize
162
+ v4_response = V4CollectionResponse(**response_data)
163
+ normalized_entities = [NormalizedEntity.from_v4(entity) for entity in v4_response.data]
164
+
165
+ return NormalizedCollectionResponse(data=normalized_entities, meta=v4_response.meta)
166
+ else:
167
+ # Parse as v5 and normalize
168
+ v5_response = V5CollectionResponse(**response_data)
169
+ normalized_entities = [NormalizedEntity.from_v5(entity) for entity in v5_response.data]
170
+
171
+ return NormalizedCollectionResponse(data=normalized_entities, meta=v5_response.meta)