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,460 @@
1
+ """Base HTTP client for Strapi API communication.
2
+
3
+ This module provides the foundation for all HTTP operations with
4
+ automatic response format detection, error handling, and authentication.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any, Literal
9
+
10
+ import httpx
11
+ from tenacity import (
12
+ before_sleep_log,
13
+ retry,
14
+ retry_if_exception,
15
+ stop_after_attempt,
16
+ wait_exponential,
17
+ )
18
+
19
+ from ..auth.api_token import APITokenAuth
20
+ from ..exceptions import (
21
+ AuthenticationError,
22
+ AuthorizationError,
23
+ ConflictError,
24
+ NotFoundError,
25
+ RateLimitError,
26
+ ServerError,
27
+ StrapiError,
28
+ ValidationError,
29
+ )
30
+ from ..exceptions import (
31
+ ConnectionError as StrapiConnectionError,
32
+ )
33
+ from ..models.response.media import MediaFile
34
+ from ..models.response.normalized import (
35
+ NormalizedCollectionResponse,
36
+ NormalizedSingleResponse,
37
+ )
38
+ from ..operations.media import normalize_media_response
39
+ from ..parsers import VersionDetectingParser
40
+ from ..protocols import AuthProvider, ConfigProvider, ResponseParser
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ class BaseClient:
46
+ """Base HTTP client for Strapi API operations.
47
+
48
+ This class provides the foundation for both synchronous and asynchronous
49
+ clients with:
50
+ - Authentication via API tokens
51
+ - Automatic Strapi version detection (v4 vs v5)
52
+ - Error handling and exception mapping
53
+ - Request/response logging
54
+ - Connection pooling
55
+
56
+ Not intended to be used directly - use SyncClient or AsyncClient instead.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ config: ConfigProvider,
62
+ auth: AuthProvider | None = None,
63
+ parser: ResponseParser | None = None,
64
+ ) -> None:
65
+ """Initialize the base client with dependency injection.
66
+
67
+ Args:
68
+ config: Configuration provider (typically StrapiConfig)
69
+ auth: Authentication provider (defaults to APITokenAuth)
70
+ parser: Response parser (defaults to VersionDetectingParser)
71
+
72
+ Raises:
73
+ ValueError: If authentication token is invalid
74
+ """
75
+ self.config: ConfigProvider = config
76
+ self.base_url = config.get_base_url()
77
+
78
+ # Dependency injection with sensible defaults
79
+ self.auth: AuthProvider = auth or APITokenAuth(config.get_api_token())
80
+ self.parser: ResponseParser = parser or VersionDetectingParser(
81
+ default_version=None if config.api_version == "auto" else config.api_version
82
+ )
83
+
84
+ # Validate authentication
85
+ if not self.auth.validate_token():
86
+ raise ValueError("API token is required and cannot be empty")
87
+
88
+ # API version detection (for backward compatibility)
89
+ self._api_version: Literal["v4", "v5"] | None = (
90
+ None if config.api_version == "auto" else config.api_version
91
+ )
92
+
93
+ logger.info(
94
+ f"Initialized Strapi client for {self.base_url} (version: {config.api_version})"
95
+ )
96
+
97
+ def _get_headers(self, extra_headers: dict[str, str] | None = None) -> dict[str, str]:
98
+ """Build request headers with authentication.
99
+
100
+ Args:
101
+ extra_headers: Additional headers to include
102
+
103
+ Returns:
104
+ Complete headers dictionary
105
+ """
106
+ headers = {
107
+ "Content-Type": "application/json",
108
+ "Accept": "application/json",
109
+ **self.auth.get_headers(),
110
+ }
111
+
112
+ if extra_headers:
113
+ headers.update(extra_headers)
114
+
115
+ return headers
116
+
117
+ def _build_url(self, endpoint: str) -> str:
118
+ """Build full URL for an endpoint.
119
+
120
+ Args:
121
+ endpoint: API endpoint path (e.g., "articles" or "/api/articles")
122
+
123
+ Returns:
124
+ Complete URL
125
+ """
126
+ # Remove leading and trailing slashes from endpoint
127
+ endpoint = endpoint.strip("/")
128
+
129
+ # Ensure /api prefix for content endpoints
130
+ if not endpoint.startswith("api/"):
131
+ endpoint = f"api/{endpoint}"
132
+
133
+ return f"{self.base_url}/{endpoint}"
134
+
135
+ def _detect_api_version(self, response_data: dict[str, Any]) -> Literal["v4", "v5"]:
136
+ """Detect Strapi API version from response structure.
137
+
138
+ Only caches the version when detection is definitive (attributes or documentId found).
139
+ Ambiguous responses return v4 as fallback without caching.
140
+
141
+ Args:
142
+ response_data: Response JSON data
143
+
144
+ Returns:
145
+ Detected API version
146
+ """
147
+ # If already detected or configured, use that
148
+ if self._api_version:
149
+ return self._api_version
150
+
151
+ # V4: data.attributes structure
152
+ # V5: flattened data with documentId
153
+ if isinstance(response_data.get("data"), dict):
154
+ data = response_data["data"]
155
+ if "attributes" in data:
156
+ self._api_version = "v4"
157
+ logger.info("Detected Strapi v4 API format")
158
+ return self._api_version
159
+ elif "documentId" in data:
160
+ self._api_version = "v5"
161
+ logger.info("Detected Strapi v5 API format")
162
+ return self._api_version
163
+ else:
164
+ # Ambiguous - don't cache, return v4 as fallback
165
+ logger.warning("Could not detect API version, using v4 fallback (not cached)")
166
+ return "v4"
167
+ elif isinstance(response_data.get("data"), list) and response_data["data"]:
168
+ # Check first item in list
169
+ first_item = response_data["data"][0]
170
+ if "attributes" in first_item:
171
+ self._api_version = "v4"
172
+ logger.info("Detected Strapi v4 API format")
173
+ return self._api_version
174
+ elif "documentId" in first_item:
175
+ self._api_version = "v5"
176
+ logger.info("Detected Strapi v5 API format")
177
+ return self._api_version
178
+ else:
179
+ # Ambiguous - don't cache, return v4 as fallback
180
+ logger.warning("Could not detect API version, using v4 fallback (not cached)")
181
+ return "v4"
182
+ else:
183
+ # No data field or empty - don't cache, return v4 as fallback
184
+ return "v4"
185
+
186
+ def reset_version_detection(self) -> None:
187
+ """Reset the cached API version detection.
188
+
189
+ Call this if you need to re-detect the API version, for example
190
+ after changing the Strapi instance or during testing.
191
+ """
192
+ self._api_version = None
193
+ logger.info("Reset API version detection cache")
194
+
195
+ def _handle_error_response(self, response: httpx.Response) -> None:
196
+ """Handle HTTP error responses by raising appropriate exceptions.
197
+
198
+ Args:
199
+ response: HTTPX response object
200
+
201
+ Raises:
202
+ Appropriate StrapiError subclass based on status code
203
+ """
204
+ status_code = response.status_code
205
+
206
+ # Try to extract error details from response
207
+ try:
208
+ error_data = response.json()
209
+ error_message = error_data.get("error", {}).get("message", response.text)
210
+ error_details = error_data.get("error", {}).get("details", {})
211
+ except Exception:
212
+ error_message = response.text or f"HTTP {status_code}"
213
+ error_details = {}
214
+
215
+ # Map status codes to exceptions
216
+ if status_code == 401:
217
+ raise AuthenticationError(
218
+ f"Authentication failed: {error_message}", details=error_details
219
+ )
220
+ elif status_code == 403:
221
+ raise AuthorizationError(
222
+ f"Authorization failed: {error_message}", details=error_details
223
+ )
224
+ elif status_code == 404:
225
+ raise NotFoundError(f"Resource not found: {error_message}", details=error_details)
226
+ elif status_code == 400:
227
+ raise ValidationError(f"Validation error: {error_message}", details=error_details)
228
+ elif status_code == 409:
229
+ raise ConflictError(f"Conflict: {error_message}", details=error_details)
230
+ elif status_code == 429:
231
+ retry_after = response.headers.get("Retry-After")
232
+ # RFC 7231: Retry-After can be numeric seconds or HTTP-date string
233
+ retry_seconds: int | None = None
234
+ if retry_after:
235
+ try:
236
+ retry_seconds = int(retry_after)
237
+ except ValueError:
238
+ # HTTP-date format (e.g., "Wed, 21 Oct 2015 07:28:00 GMT")
239
+ # Fall back to default retry behavior
240
+ retry_seconds = None
241
+ raise RateLimitError(
242
+ f"Rate limit exceeded: {error_message}",
243
+ retry_after=retry_seconds,
244
+ details=error_details,
245
+ )
246
+ elif 500 <= status_code < 600:
247
+ raise ServerError(
248
+ f"Server error: {error_message}",
249
+ status_code=status_code,
250
+ details=error_details,
251
+ )
252
+ else:
253
+ raise StrapiError(
254
+ f"Unexpected error (HTTP {status_code}): {error_message}",
255
+ details=error_details,
256
+ )
257
+
258
+ def _create_retry_decorator(self) -> Any:
259
+ """Create a retry decorator based on configuration.
260
+
261
+ The decorator retries on:
262
+ - Server errors (5xx) and connection issues
263
+ - Rate limit errors (429) with retry_after support
264
+ - Configured status codes from retry_on_status
265
+
266
+ Returns:
267
+ Configured tenacity retry decorator
268
+ """
269
+ retry_config = self.config.retry
270
+
271
+ def should_retry_exception(exception: BaseException) -> bool:
272
+ """Determine if exception should trigger retry."""
273
+ # Always retry connection issues
274
+ if isinstance(exception, StrapiConnectionError):
275
+ return True
276
+
277
+ # Retry RateLimitError with exponential backoff
278
+ if isinstance(exception, RateLimitError):
279
+ return True
280
+
281
+ # Check if exception has status_code matching retry_on_status
282
+ # This includes ServerError if its status code is in retry_on_status
283
+ if hasattr(exception, "status_code"):
284
+ return exception.status_code in retry_config.retry_on_status
285
+
286
+ return False
287
+
288
+ def wait_strategy(retry_state): # type: ignore[no-untyped-def]
289
+ """Custom wait strategy that respects retry_after."""
290
+ exception = retry_state.outcome.exception()
291
+
292
+ # If RateLimitError with retry_after, use that value
293
+ if isinstance(exception, RateLimitError) and exception.retry_after:
294
+ return exception.retry_after
295
+
296
+ # Otherwise use exponential backoff
297
+ return wait_exponential(
298
+ multiplier=retry_config.exponential_base,
299
+ min=retry_config.initial_wait,
300
+ max=retry_config.max_wait,
301
+ )(retry_state)
302
+
303
+ return retry(
304
+ stop=stop_after_attempt(retry_config.max_attempts),
305
+ wait=wait_strategy,
306
+ retry=retry_if_exception(should_retry_exception),
307
+ before_sleep=before_sleep_log(logger, logging.WARNING),
308
+ reraise=True,
309
+ )
310
+
311
+ @property
312
+ def api_version(self) -> Literal["v4", "v5"] | None:
313
+ """Get the detected or configured API version.
314
+
315
+ Returns:
316
+ API version or None if not yet detected
317
+ """
318
+ return self._api_version
319
+
320
+ def _parse_single_response(self, response_data: dict[str, Any]) -> NormalizedSingleResponse:
321
+ """Parse a single entity response into normalized format.
322
+
323
+ Delegates to the injected parser for actual parsing logic.
324
+
325
+ Args:
326
+ response_data: Raw JSON response from Strapi
327
+
328
+ Returns:
329
+ Normalized single entity response
330
+
331
+ Examples:
332
+ >>> response_data = {"data": {"id": 1, "documentId": "abc", ...}}
333
+ >>> normalized = client._parse_single_response(response_data)
334
+ >>> normalized.data.id
335
+ 1
336
+ """
337
+ # Delegate to injected parser
338
+ return self.parser.parse_single(response_data)
339
+
340
+ def _parse_collection_response(
341
+ self, response_data: dict[str, Any]
342
+ ) -> NormalizedCollectionResponse:
343
+ """Parse a collection response into normalized format.
344
+
345
+ Delegates to the injected parser for actual parsing logic.
346
+
347
+ Args:
348
+ response_data: Raw JSON response from Strapi
349
+
350
+ Returns:
351
+ Normalized collection response
352
+
353
+ Examples:
354
+ >>> response_data = {"data": [{"id": 1, ...}, {"id": 2, ...}]}
355
+ >>> normalized = client._parse_collection_response(response_data)
356
+ >>> len(normalized.data)
357
+ 2
358
+ """
359
+ # Delegate to injected parser
360
+ return self.parser.parse_collection(response_data)
361
+
362
+ def _build_upload_headers(self) -> dict[str, str]:
363
+ """Build headers for multipart file upload.
364
+
365
+ Omits Content-Type header to let httpx set the multipart boundary automatically.
366
+
367
+ Returns:
368
+ Headers dictionary without Content-Type
369
+ """
370
+ headers = {
371
+ "Accept": "application/json",
372
+ **self.auth.get_headers(),
373
+ }
374
+ return headers
375
+
376
+ def _parse_media_response(self, response_data: dict[str, Any]) -> MediaFile:
377
+ """Parse media upload/download response into MediaFile model.
378
+
379
+ Automatically detects v4/v5 format and normalizes the response.
380
+
381
+ Args:
382
+ response_data: Raw JSON response from Strapi media endpoint
383
+
384
+ Returns:
385
+ Validated MediaFile instance
386
+
387
+ Examples:
388
+ >>> # v5 response
389
+ >>> response_data = {
390
+ ... "id": 1,
391
+ ... "documentId": "abc123",
392
+ ... "name": "image.jpg",
393
+ ... "url": "/uploads/image.jpg",
394
+ ... ...
395
+ ... }
396
+ >>> media = client._parse_media_response(response_data)
397
+ >>> media.name
398
+ 'image.jpg'
399
+ """
400
+ api_version = self._detect_api_version({"data": response_data})
401
+ return normalize_media_response(response_data, api_version)
402
+
403
+ def _parse_media_list_response(
404
+ self, response_data: dict[str, Any] | list[dict[str, Any]]
405
+ ) -> NormalizedCollectionResponse:
406
+ """Parse media library list response into normalized collection.
407
+
408
+ Media list responses may be in standard Strapi collection format
409
+ or a raw array (depending on Strapi version/plugin).
410
+
411
+ For v4 responses with nested attributes, this method flattens each
412
+ item before passing to the collection parser to ensure consistent
413
+ handling with single media responses.
414
+
415
+ Args:
416
+ response_data: Raw JSON response from media list endpoint
417
+ (may be dict with "data" key or raw array)
418
+
419
+ Returns:
420
+ Normalized collection response with MediaFile entities
421
+
422
+ Examples:
423
+ >>> # Standard format
424
+ >>> response_data = {
425
+ ... "data": [
426
+ ... {"id": 1, "name": "image1.jpg", ...},
427
+ ... {"id": 2, "name": "image2.jpg", ...}
428
+ ... ],
429
+ ... "meta": {"pagination": {...}}
430
+ ... }
431
+ >>> result = client._parse_media_list_response(response_data)
432
+ >>> len(result.data)
433
+ 2
434
+
435
+ >>> # Raw array format (Strapi Upload plugin)
436
+ >>> response_data = [{"id": 1, "name": "image.jpg", ...}]
437
+ >>> result = client._parse_media_list_response(response_data)
438
+ >>> len(result.data)
439
+ 1
440
+ """
441
+ # Handle raw array response (Strapi Upload plugin may return this)
442
+ if isinstance(response_data, list):
443
+ response_data = {"data": response_data, "meta": {}}
444
+
445
+ # For v4, flatten nested attributes to match v5 format before parsing
446
+ if isinstance(response_data.get("data"), list):
447
+ data_items = response_data["data"]
448
+ if data_items and isinstance(data_items[0], dict) and "attributes" in data_items[0]:
449
+ # v4 format - flatten each item
450
+ flattened_items = []
451
+ for item in data_items:
452
+ if "attributes" in item:
453
+ flattened = {"id": item["id"], **item["attributes"]}
454
+ flattened_items.append(flattened)
455
+ else:
456
+ flattened_items.append(item)
457
+ response_data = {"data": flattened_items, "meta": response_data.get("meta", {})}
458
+
459
+ # Media list follows standard collection format
460
+ return self._parse_collection_response(response_data)