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,1032 @@
1
+ """Asynchronous HTTP client for Strapi API.
2
+
3
+ This module provides non-blocking I/O operations for high-concurrency
4
+ applications and batch operations.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from ..exceptions import (
16
+ ConnectionError as StrapiConnectionError,
17
+ )
18
+ from ..exceptions import (
19
+ FormatError,
20
+ MediaError,
21
+ StrapiError,
22
+ )
23
+ from ..exceptions import (
24
+ TimeoutError as StrapiTimeoutError,
25
+ )
26
+ from ..models.bulk import BulkOperationFailure, BulkOperationResult
27
+ from ..models.request.query import StrapiQuery
28
+ from ..models.response.media import MediaFile
29
+ from ..models.response.normalized import (
30
+ NormalizedCollectionResponse,
31
+ NormalizedEntity,
32
+ NormalizedSingleResponse,
33
+ )
34
+ from ..operations.media import build_media_download_url, build_upload_payload
35
+ from ..protocols import AsyncHTTPClient, AuthProvider, ConfigProvider, ResponseParser
36
+ from ..utils.rate_limiter import AsyncTokenBucketRateLimiter
37
+ from .base import BaseClient
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class AsyncClient(BaseClient):
43
+ """Asynchronous HTTP client for Strapi API.
44
+
45
+ This client uses non-blocking I/O and is suitable for:
46
+ - High-concurrency applications
47
+ - Batch operations on many documents
48
+ - Applications using async/await patterns
49
+
50
+ Example:
51
+ ```python
52
+ import asyncio
53
+ from strapi_kit import AsyncClient, StrapiConfig
54
+
55
+ async def main():
56
+ config = StrapiConfig(
57
+ base_url="http://localhost:1337",
58
+ api_token="your-token"
59
+ )
60
+
61
+ async with AsyncClient(config) as client:
62
+ response = await client.get("articles")
63
+ print(response)
64
+
65
+ asyncio.run(main())
66
+ ```
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ config: ConfigProvider,
72
+ http_client: AsyncHTTPClient | None = None,
73
+ auth: AuthProvider | None = None,
74
+ parser: ResponseParser | None = None,
75
+ ) -> None:
76
+ """Initialize the asynchronous client with dependency injection.
77
+
78
+ Args:
79
+ config: Configuration provider (typically StrapiConfig)
80
+ http_client: Async HTTP client (defaults to httpx.AsyncClient with pooling)
81
+ auth: Authentication provider (passed to BaseClient)
82
+ parser: Response parser (passed to BaseClient)
83
+ """
84
+ super().__init__(config, auth=auth, parser=parser)
85
+
86
+ # Dependency injection with default factory
87
+ self._client: AsyncHTTPClient | httpx.AsyncClient = (
88
+ http_client or self._create_default_http_client()
89
+ )
90
+ self._owns_client = http_client is None
91
+
92
+ # Initialize rate limiter if configured
93
+ self._rate_limiter: AsyncTokenBucketRateLimiter | None = None
94
+ if hasattr(config, "rate_limit_per_second") and config.rate_limit_per_second:
95
+ self._rate_limiter = AsyncTokenBucketRateLimiter(rate=config.rate_limit_per_second)
96
+
97
+ def _create_default_http_client(self) -> httpx.AsyncClient:
98
+ """Create default async HTTP client with connection pooling.
99
+
100
+ Returns:
101
+ Configured httpx.AsyncClient instance
102
+ """
103
+ return httpx.AsyncClient(
104
+ timeout=self.config.timeout,
105
+ verify=self.config.verify_ssl,
106
+ limits=httpx.Limits(
107
+ max_connections=self.config.max_connections,
108
+ max_keepalive_connections=self.config.max_connections,
109
+ ),
110
+ )
111
+
112
+ async def __aenter__(self) -> "AsyncClient":
113
+ """Async context manager entry."""
114
+ return self
115
+
116
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
117
+ """Async context manager exit - closes the client."""
118
+ await self.close()
119
+
120
+ async def close(self) -> None:
121
+ """Close the HTTP client and release connections.
122
+
123
+ Only closes the client if it was created by this instance
124
+ (not injected from outside).
125
+ """
126
+ if self._owns_client:
127
+ await self._client.aclose()
128
+ logger.info("Closed asynchronous Strapi client")
129
+
130
+ async def request(
131
+ self,
132
+ method: str,
133
+ endpoint: str,
134
+ params: dict[str, Any] | None = None,
135
+ json: dict[str, Any] | None = None,
136
+ headers: dict[str, str] | None = None,
137
+ ) -> dict[str, Any]:
138
+ """Make an HTTP request to the Strapi API with automatic retry.
139
+
140
+ Retries are automatically applied based on the retry configuration:
141
+ - Server errors (5xx)
142
+ - Connection failures
143
+ - Rate limit errors (429) with retry_after support
144
+ - Configured status codes from retry_on_status
145
+
146
+ Args:
147
+ method: HTTP method (GET, POST, PUT, DELETE)
148
+ endpoint: API endpoint path
149
+ params: URL query parameters
150
+ json: JSON request body
151
+ headers: Additional headers
152
+
153
+ Returns:
154
+ Response JSON data
155
+
156
+ Raises:
157
+ StrapiError: On API errors (after retries exhausted)
158
+ ConnectionError: On connection failures (after retries exhausted)
159
+ TimeoutError: On request timeout (after retries exhausted)
160
+ """
161
+ # Create retry-wrapped version of internal request
162
+ retry_decorator = self._create_retry_decorator()
163
+
164
+ @retry_decorator # type: ignore[untyped-decorator]
165
+ async def _do_request() -> dict[str, Any]:
166
+ """Internal async request implementation with retry support."""
167
+ # Apply rate limiting if configured
168
+ if self._rate_limiter:
169
+ await self._rate_limiter.acquire()
170
+
171
+ url = self._build_url(endpoint)
172
+ request_headers = self._get_headers(headers)
173
+
174
+ logger.debug(f"{method} {url} params={params}")
175
+
176
+ try:
177
+ response = await self._client.request(
178
+ method=method,
179
+ url=url,
180
+ params=params,
181
+ json=json,
182
+ headers=request_headers,
183
+ )
184
+
185
+ # Handle error responses
186
+ if not response.is_success:
187
+ self._handle_error_response(response)
188
+
189
+ # Handle 204 No Content (common for DELETE operations)
190
+ if response.status_code == 204 or not response.content:
191
+ logger.debug(f"Response: {response.status_code} (no content)")
192
+ return {}
193
+
194
+ # Parse and return JSON with proper error handling for non-JSON responses
195
+ try:
196
+ data: dict[str, Any] = response.json()
197
+ except Exception as json_error:
198
+ content_type = response.headers.get("content-type", "unknown")
199
+ body_preview = response.text[:500] if response.text else ""
200
+ raise FormatError(
201
+ f"Received non-JSON response (content-type: {content_type})",
202
+ details={"body_preview": body_preview},
203
+ ) from json_error
204
+
205
+ # Detect API version from response
206
+ if data and isinstance(data, dict):
207
+ self._detect_api_version(data)
208
+
209
+ logger.debug(f"Response: {response.status_code}")
210
+ return data
211
+
212
+ except httpx.ConnectError as e:
213
+ raise StrapiConnectionError(f"Failed to connect to {self.base_url}: {e}") from e
214
+ except httpx.TimeoutException as e:
215
+ raise StrapiTimeoutError(
216
+ f"Request timed out after {self.config.timeout}s: {e}"
217
+ ) from e
218
+
219
+ return await _do_request() # type: ignore[no-any-return]
220
+
221
+ async def get(
222
+ self,
223
+ endpoint: str,
224
+ params: dict[str, Any] | None = None,
225
+ headers: dict[str, str] | None = None,
226
+ ) -> dict[str, Any]:
227
+ """Make a GET request.
228
+
229
+ Args:
230
+ endpoint: API endpoint path
231
+ params: URL query parameters
232
+ headers: Additional headers
233
+
234
+ Returns:
235
+ Response JSON data
236
+ """
237
+ return await self.request("GET", endpoint, params=params, headers=headers)
238
+
239
+ async def post(
240
+ self,
241
+ endpoint: str,
242
+ json: dict[str, Any],
243
+ params: dict[str, Any] | None = None,
244
+ headers: dict[str, str] | None = None,
245
+ ) -> dict[str, Any]:
246
+ """Make a POST request.
247
+
248
+ Args:
249
+ endpoint: API endpoint path
250
+ json: JSON request body
251
+ params: URL query parameters
252
+ headers: Additional headers
253
+
254
+ Returns:
255
+ Response JSON data
256
+ """
257
+ return await self.request("POST", endpoint, params=params, json=json, headers=headers)
258
+
259
+ async def put(
260
+ self,
261
+ endpoint: str,
262
+ json: dict[str, Any],
263
+ params: dict[str, Any] | None = None,
264
+ headers: dict[str, str] | None = None,
265
+ ) -> dict[str, Any]:
266
+ """Make a PUT request.
267
+
268
+ Args:
269
+ endpoint: API endpoint path
270
+ json: JSON request body
271
+ params: URL query parameters
272
+ headers: Additional headers
273
+
274
+ Returns:
275
+ Response JSON data
276
+ """
277
+ return await self.request("PUT", endpoint, params=params, json=json, headers=headers)
278
+
279
+ async def delete(
280
+ self,
281
+ endpoint: str,
282
+ params: dict[str, Any] | None = None,
283
+ headers: dict[str, str] | None = None,
284
+ ) -> dict[str, Any]:
285
+ """Make a DELETE request.
286
+
287
+ Args:
288
+ endpoint: API endpoint path
289
+ params: URL query parameters
290
+ headers: Additional headers
291
+
292
+ Returns:
293
+ Response JSON data
294
+ """
295
+ return await self.request("DELETE", endpoint, params=params, headers=headers)
296
+
297
+ # Typed methods for normalized responses
298
+
299
+ async def get_one(
300
+ self,
301
+ endpoint: str,
302
+ query: StrapiQuery | None = None,
303
+ headers: dict[str, str] | None = None,
304
+ ) -> NormalizedSingleResponse:
305
+ """Get a single entity with typed, normalized response.
306
+
307
+ Args:
308
+ endpoint: API endpoint path (e.g., "articles/1" or "articles/abc123")
309
+ query: Optional query configuration (populate, fields, locale, etc.)
310
+ headers: Additional headers
311
+
312
+ Returns:
313
+ Normalized single entity response
314
+
315
+ Examples:
316
+ >>> from strapi_kit.models import StrapiQuery, Populate
317
+ >>> query = (StrapiQuery()
318
+ ... .populate_fields(["author", "category"])
319
+ ... .select(["title", "content"]))
320
+ >>> response = await client.get_one("articles/1", query=query)
321
+ >>> article = response.data
322
+ >>> article.attributes["title"]
323
+ 'My Article'
324
+ """
325
+ params = query.to_query_params() if query else None
326
+ raw_response = await self.get(endpoint, params=params, headers=headers)
327
+ return self._parse_single_response(raw_response)
328
+
329
+ async def get_many(
330
+ self,
331
+ endpoint: str,
332
+ query: StrapiQuery | None = None,
333
+ headers: dict[str, str] | None = None,
334
+ ) -> NormalizedCollectionResponse:
335
+ """Get multiple entities with typed, normalized response.
336
+
337
+ Args:
338
+ endpoint: API endpoint path (e.g., "articles")
339
+ query: Optional query configuration (filters, sort, pagination, etc.)
340
+ headers: Additional headers
341
+
342
+ Returns:
343
+ Normalized collection response
344
+
345
+ Examples:
346
+ >>> from strapi_kit.models import StrapiQuery, FilterBuilder, SortDirection
347
+ >>> query = (StrapiQuery()
348
+ ... .filter(FilterBuilder().eq("status", "published"))
349
+ ... .sort_by("publishedAt", SortDirection.DESC)
350
+ ... .paginate(page=1, page_size=25)
351
+ ... .populate_fields(["author"]))
352
+ >>> response = await client.get_many("articles", query=query)
353
+ >>> for article in response.data:
354
+ ... print(article.attributes["title"])
355
+ """
356
+ params = query.to_query_params() if query else None
357
+ raw_response = await self.get(endpoint, params=params, headers=headers)
358
+ return self._parse_collection_response(raw_response)
359
+
360
+ async def create(
361
+ self,
362
+ endpoint: str,
363
+ data: dict[str, Any],
364
+ query: StrapiQuery | None = None,
365
+ headers: dict[str, str] | None = None,
366
+ ) -> NormalizedSingleResponse:
367
+ """Create a new entity with typed, normalized response.
368
+
369
+ Args:
370
+ endpoint: API endpoint path (e.g., "articles")
371
+ data: Entity data to create (wrapped in {"data": {...}} automatically)
372
+ query: Optional query configuration (populate, fields, etc.)
373
+ headers: Additional headers
374
+
375
+ Returns:
376
+ Normalized single entity response
377
+
378
+ Examples:
379
+ >>> data = {"title": "New Article", "content": "Article body"}
380
+ >>> response = await client.create("articles", data)
381
+ >>> created = response.data
382
+ >>> created.id
383
+ 42
384
+ """
385
+ params = query.to_query_params() if query else None
386
+ # Wrap data in Strapi format
387
+ payload = {"data": data}
388
+ raw_response = await self.post(endpoint, json=payload, params=params, headers=headers)
389
+ return self._parse_single_response(raw_response)
390
+
391
+ async def update(
392
+ self,
393
+ endpoint: str,
394
+ data: dict[str, Any],
395
+ query: StrapiQuery | None = None,
396
+ headers: dict[str, str] | None = None,
397
+ ) -> NormalizedSingleResponse:
398
+ """Update an existing entity with typed, normalized response.
399
+
400
+ Args:
401
+ endpoint: API endpoint path (e.g., "articles/1" or "articles/abc123")
402
+ data: Entity data to update (wrapped in {"data": {...}} automatically)
403
+ query: Optional query configuration (populate, fields, etc.)
404
+ headers: Additional headers
405
+
406
+ Returns:
407
+ Normalized single entity response
408
+
409
+ Examples:
410
+ >>> data = {"title": "Updated Title"}
411
+ >>> response = await client.update("articles/1", data)
412
+ >>> updated = response.data
413
+ >>> updated.attributes["title"]
414
+ 'Updated Title'
415
+ """
416
+ params = query.to_query_params() if query else None
417
+ # Wrap data in Strapi format
418
+ payload = {"data": data}
419
+ raw_response = await self.put(endpoint, json=payload, params=params, headers=headers)
420
+ return self._parse_single_response(raw_response)
421
+
422
+ async def remove(
423
+ self,
424
+ endpoint: str,
425
+ headers: dict[str, str] | None = None,
426
+ ) -> NormalizedSingleResponse:
427
+ """Delete an entity with typed, normalized response.
428
+
429
+ Args:
430
+ endpoint: API endpoint path (e.g., "articles/1" or "articles/abc123")
431
+ headers: Additional headers
432
+
433
+ Returns:
434
+ Normalized single entity response (deleted entity)
435
+
436
+ Examples:
437
+ >>> response = await client.remove("articles/1")
438
+ >>> deleted = response.data
439
+ >>> deleted.id
440
+ 1
441
+ """
442
+ raw_response = await self.delete(endpoint, headers=headers)
443
+ return self._parse_single_response(raw_response)
444
+
445
+ # Media Operations
446
+
447
+ async def upload_file(
448
+ self,
449
+ file_path: str | Path,
450
+ *,
451
+ ref: str | None = None,
452
+ ref_id: str | int | None = None,
453
+ field: str | None = None,
454
+ folder: str | None = None,
455
+ alternative_text: str | None = None,
456
+ caption: str | None = None,
457
+ ) -> MediaFile:
458
+ """Upload a single file to Strapi media library.
459
+
460
+ Args:
461
+ file_path: Path to file to upload
462
+ ref: Reference model name (e.g., "api::article.article")
463
+ ref_id: Reference document ID (numeric or string)
464
+ field: Field name in reference model
465
+ folder: Folder ID for organization
466
+ alternative_text: Alt text for images
467
+ caption: Caption text
468
+
469
+ Returns:
470
+ MediaFile with upload details
471
+
472
+ Raises:
473
+ MediaError: On upload failure
474
+ FileNotFoundError: If file doesn't exist
475
+
476
+ Examples:
477
+ >>> # Simple upload
478
+ >>> media = await client.upload_file("image.jpg")
479
+ >>> media.name
480
+ 'image.jpg'
481
+
482
+ >>> # Upload with metadata
483
+ >>> media = await client.upload_file(
484
+ ... "hero.jpg",
485
+ ... alternative_text="Hero image",
486
+ ... caption="Main article image"
487
+ ... )
488
+
489
+ >>> # Upload and attach to entity
490
+ >>> media = await client.upload_file(
491
+ ... "cover.jpg",
492
+ ... ref="api::article.article",
493
+ ... ref_id="abc123",
494
+ ... field="cover"
495
+ ... )
496
+ """
497
+ try:
498
+ # Build multipart payload with context manager to ensure file handle cleanup
499
+ with build_upload_payload(
500
+ file_path,
501
+ ref=ref,
502
+ ref_id=ref_id,
503
+ field=field,
504
+ folder=folder,
505
+ alternative_text=alternative_text,
506
+ caption=caption,
507
+ ) as payload:
508
+ # Build URL and headers
509
+ url = self._build_url("upload")
510
+ headers = self._build_upload_headers()
511
+
512
+ # Make async request with multipart data
513
+ response = await self._client.post(
514
+ url,
515
+ files={"files": payload.files_tuple},
516
+ data=payload.data,
517
+ headers=headers,
518
+ )
519
+
520
+ # Handle errors
521
+ if not response.is_success:
522
+ self._handle_error_response(response)
523
+
524
+ # Parse response (upload returns single file object, not wrapped in data)
525
+ response_json = response.json()
526
+ # Upload endpoint returns array with single file
527
+ if isinstance(response_json, list) and response_json:
528
+ return self._parse_media_response(response_json[0])
529
+ else:
530
+ return self._parse_media_response(response_json)
531
+
532
+ except FileNotFoundError:
533
+ raise
534
+ except Exception as e:
535
+ raise MediaError(f"File upload failed: {e}") from e
536
+
537
+ async def upload_files(
538
+ self,
539
+ file_paths: list[str | Path],
540
+ **kwargs: Any,
541
+ ) -> list[MediaFile]:
542
+ """Upload multiple files sequentially.
543
+
544
+ Args:
545
+ file_paths: List of file paths to upload
546
+ **kwargs: Same metadata options as upload_file
547
+
548
+ Returns:
549
+ List of MediaFile objects
550
+
551
+ Raises:
552
+ MediaError: On any upload failure (partial uploads NOT rolled back)
553
+
554
+ Examples:
555
+ >>> files = ["image1.jpg", "image2.jpg", "image3.jpg"]
556
+ >>> media_list = await client.upload_files(files)
557
+ >>> len(media_list)
558
+ 3
559
+
560
+ >>> # Upload with shared metadata
561
+ >>> media_list = await client.upload_files(
562
+ ... ["thumb1.jpg", "thumb2.jpg"],
563
+ ... folder="thumbnails"
564
+ ... )
565
+ """
566
+ uploaded: list[MediaFile] = []
567
+
568
+ for idx, file_path in enumerate(file_paths):
569
+ try:
570
+ media = await self.upload_file(file_path, **kwargs)
571
+ uploaded.append(media)
572
+ except Exception as e:
573
+ raise MediaError(
574
+ f"Batch upload failed at file {idx} ({file_path}): {e}. "
575
+ f"{len(uploaded)} files were uploaded successfully before failure."
576
+ ) from e
577
+
578
+ return uploaded
579
+
580
+ async def download_file(
581
+ self,
582
+ media_url: str,
583
+ save_path: str | Path | None = None,
584
+ ) -> bytes:
585
+ """Download a media file from Strapi.
586
+
587
+ Args:
588
+ media_url: Media URL (relative /uploads/... or absolute)
589
+ save_path: Optional path to save file (if None, returns bytes only)
590
+
591
+ Returns:
592
+ File content as bytes
593
+
594
+ Raises:
595
+ MediaError: On download failure
596
+
597
+ Examples:
598
+ >>> # Download to bytes
599
+ >>> content = await client.download_file("/uploads/image.jpg")
600
+ >>> len(content)
601
+ 102400
602
+
603
+ >>> # Download and save to file
604
+ >>> content = await client.download_file(
605
+ ... "/uploads/image.jpg",
606
+ ... save_path="downloaded_image.jpg"
607
+ ... )
608
+ """
609
+ try:
610
+ # Build full URL
611
+ url = build_media_download_url(self.base_url, media_url)
612
+
613
+ # Download with async streaming for large files
614
+ async with self._client.stream("GET", url) as response:
615
+ if not response.is_success:
616
+ self._handle_error_response(response)
617
+
618
+ # Read content asynchronously
619
+ chunks = []
620
+ async for chunk in response.aiter_bytes():
621
+ chunks.append(chunk)
622
+ content = b"".join(chunks)
623
+
624
+ # Save to file if path provided
625
+ if save_path:
626
+ path = Path(save_path)
627
+ path.write_bytes(content)
628
+ logger.info(f"Downloaded {len(content)} bytes to {save_path}")
629
+
630
+ return content
631
+
632
+ except StrapiError:
633
+ raise # Preserve specific error types (NotFoundError, etc.)
634
+ except Exception as e:
635
+ raise MediaError(f"File download failed: {e}") from e
636
+
637
+ async def list_media(
638
+ self,
639
+ query: StrapiQuery | None = None,
640
+ ) -> NormalizedCollectionResponse:
641
+ """List media files from media library.
642
+
643
+ Args:
644
+ query: Optional query for filtering, sorting, pagination
645
+
646
+ Returns:
647
+ NormalizedCollectionResponse with MediaFile entities
648
+
649
+ Examples:
650
+ >>> # List all media
651
+ >>> response = await client.list_media()
652
+ >>> for media in response.data:
653
+ ... print(media.attributes["name"])
654
+
655
+ >>> # List with filters
656
+ >>> from strapi_kit.models import StrapiQuery, FilterBuilder
657
+ >>> query = (StrapiQuery()
658
+ ... .filter(FilterBuilder().eq("mime", "image/jpeg"))
659
+ ... .paginate(page=1, page_size=10))
660
+ >>> response = await client.list_media(query)
661
+ """
662
+ params = query.to_query_params() if query else None
663
+ raw_response = await self.get("upload/files", params=params)
664
+ return self._parse_media_list_response(raw_response)
665
+
666
+ async def get_media(
667
+ self,
668
+ media_id: str | int,
669
+ ) -> MediaFile:
670
+ """Get specific media file details.
671
+
672
+ Args:
673
+ media_id: Media file ID (numeric or documentId)
674
+
675
+ Returns:
676
+ MediaFile details
677
+
678
+ Raises:
679
+ NotFoundError: If media doesn't exist
680
+
681
+ Examples:
682
+ >>> media = await client.get_media(42)
683
+ >>> media.name
684
+ 'image.jpg'
685
+ >>> media.url
686
+ '/uploads/image.jpg'
687
+ """
688
+ raw_response = await self.get(f"upload/files/{media_id}")
689
+ return self._parse_media_response(raw_response)
690
+
691
+ async def delete_media(
692
+ self,
693
+ media_id: str | int,
694
+ ) -> None:
695
+ """Delete a media file.
696
+
697
+ Args:
698
+ media_id: Media file ID (numeric or documentId)
699
+
700
+ Raises:
701
+ NotFoundError: If media doesn't exist
702
+ MediaError: On deletion failure
703
+
704
+ Examples:
705
+ >>> await client.delete_media(42)
706
+ >>> # File deleted successfully
707
+ """
708
+ try:
709
+ await self.delete(f"upload/files/{media_id}")
710
+ except StrapiError:
711
+ raise # Preserve specific error types (NotFoundError, etc.)
712
+ except Exception as e:
713
+ raise MediaError(f"Media deletion failed: {e}") from e
714
+
715
+ async def update_media(
716
+ self,
717
+ media_id: str | int,
718
+ *,
719
+ alternative_text: str | None = None,
720
+ caption: str | None = None,
721
+ name: str | None = None,
722
+ ) -> MediaFile:
723
+ """Update media file metadata.
724
+
725
+ Args:
726
+ media_id: Media file ID (numeric or documentId)
727
+ alternative_text: New alt text
728
+ caption: New caption
729
+ name: New file name
730
+
731
+ Returns:
732
+ Updated MediaFile
733
+
734
+ Raises:
735
+ NotFoundError: If media doesn't exist
736
+ MediaError: On update failure
737
+
738
+ Examples:
739
+ >>> media = await client.update_media(
740
+ ... 42,
741
+ ... alternative_text="Updated alt text",
742
+ ... caption="Updated caption"
743
+ ... )
744
+ >>> media.alternative_text
745
+ 'Updated alt text'
746
+ """
747
+ import json as json_module
748
+
749
+ try:
750
+ # Build update payload
751
+ file_info: dict[str, Any] = {}
752
+ if alternative_text is not None:
753
+ file_info["alternativeText"] = alternative_text
754
+ if caption is not None:
755
+ file_info["caption"] = caption
756
+ if name is not None:
757
+ file_info["name"] = name
758
+
759
+ headers = self._build_upload_headers()
760
+
761
+ # v4 uses PUT /api/upload/files/:id
762
+ # v5 uses POST /api/upload?id=x with form-data
763
+ if self._api_version == "v4":
764
+ url = self._build_url(f"upload/files/{media_id}")
765
+ response = await self._client.request(
766
+ method="PUT",
767
+ url=url,
768
+ json={"fileInfo": file_info} if file_info else {},
769
+ headers=self._get_headers(),
770
+ )
771
+ else:
772
+ # v5 or auto (default to v5 behavior)
773
+ url = f"{self._build_url('upload')}?id={media_id}"
774
+ response = await self._client.post(
775
+ url,
776
+ data={"fileInfo": json_module.dumps(file_info)} if file_info else {},
777
+ headers=headers,
778
+ )
779
+
780
+ # Handle errors
781
+ if not response.is_success:
782
+ self._handle_error_response(response)
783
+
784
+ # Parse response
785
+ response_json = response.json()
786
+ if isinstance(response_json, list) and response_json:
787
+ return self._parse_media_response(response_json[0])
788
+ else:
789
+ return self._parse_media_response(response_json)
790
+
791
+ except StrapiError:
792
+ raise # Preserve specific error types (NotFoundError, etc.)
793
+ except Exception as e:
794
+ raise MediaError(f"Media update failed: {e}") from e
795
+
796
+ # Bulk Operations
797
+
798
+ async def bulk_create(
799
+ self,
800
+ endpoint: str,
801
+ items: list[dict[str, Any]],
802
+ *,
803
+ batch_size: int = 10,
804
+ max_concurrency: int = 5,
805
+ query: StrapiQuery | None = None,
806
+ progress_callback: Callable[[int, int], None] | None = None,
807
+ ) -> BulkOperationResult:
808
+ """Create multiple entities in concurrent batches.
809
+
810
+ Args:
811
+ endpoint: API endpoint (e.g., "articles")
812
+ items: List of entity data dicts
813
+ batch_size: Items per batch (default: 10, currently unused - for API compatibility)
814
+ max_concurrency: Max concurrent requests (default: 5)
815
+ query: Optional query
816
+ progress_callback: Optional callback(completed, total)
817
+
818
+ Returns:
819
+ BulkOperationResult with successes and failures
820
+
821
+ Example:
822
+ >>> items = [
823
+ ... {"title": "Article 1", "content": "..."},
824
+ ... {"title": "Article 2", "content": "..."},
825
+ ... ]
826
+ >>> result = await client.bulk_create("articles", items, max_concurrency=10)
827
+ >>> print(f"Created {result.succeeded}/{result.total}")
828
+ """
829
+ successes: list[NormalizedEntity] = []
830
+ failures: list[BulkOperationFailure] = []
831
+ semaphore = asyncio.Semaphore(max_concurrency)
832
+ lock = asyncio.Lock()
833
+ completed = 0
834
+
835
+ async def create_one(idx: int, item: dict[str, Any]) -> None:
836
+ nonlocal completed
837
+
838
+ async with semaphore:
839
+ try:
840
+ response = await self.create(endpoint, item, query=query)
841
+
842
+ async with lock:
843
+ if response.data:
844
+ successes.append(response.data)
845
+ completed += 1
846
+ if progress_callback:
847
+ progress_callback(completed, len(items))
848
+
849
+ except StrapiError as e:
850
+ async with lock:
851
+ failures.append(
852
+ BulkOperationFailure(
853
+ index=idx,
854
+ item=item,
855
+ error=str(e),
856
+ exception=e,
857
+ )
858
+ )
859
+ completed += 1
860
+ if progress_callback:
861
+ progress_callback(completed, len(items))
862
+
863
+ # Create all tasks
864
+ tasks = [create_one(i, item) for i, item in enumerate(items)]
865
+
866
+ # Execute with gather (doesn't stop on first error)
867
+ await asyncio.gather(*tasks, return_exceptions=False)
868
+
869
+ return BulkOperationResult(
870
+ successes=successes,
871
+ failures=failures,
872
+ total=len(items),
873
+ succeeded=len(successes),
874
+ failed=len(failures),
875
+ )
876
+
877
+ async def bulk_update(
878
+ self,
879
+ endpoint: str,
880
+ updates: list[tuple[str | int, dict[str, Any]]],
881
+ *,
882
+ batch_size: int = 10,
883
+ max_concurrency: int = 5,
884
+ query: StrapiQuery | None = None,
885
+ progress_callback: Callable[[int, int], None] | None = None,
886
+ ) -> BulkOperationResult:
887
+ """Update multiple entities in concurrent batches.
888
+
889
+ Args:
890
+ endpoint: API endpoint (e.g., "articles")
891
+ updates: List of (id, data) tuples
892
+ batch_size: Items per batch (default: 10, currently unused - for API compatibility)
893
+ max_concurrency: Max concurrent requests (default: 5)
894
+ query: Optional query
895
+ progress_callback: Optional callback(completed, total)
896
+
897
+ Returns:
898
+ BulkOperationResult
899
+
900
+ Example:
901
+ >>> updates = [
902
+ ... (1, {"title": "Updated Title 1"}),
903
+ ... (2, {"title": "Updated Title 2"}),
904
+ ... ]
905
+ >>> result = await client.bulk_update("articles", updates)
906
+ >>> print(f"Updated {result.succeeded}/{result.total}")
907
+ """
908
+ successes: list[NormalizedEntity] = []
909
+ failures: list[BulkOperationFailure] = []
910
+ semaphore = asyncio.Semaphore(max_concurrency)
911
+ lock = asyncio.Lock()
912
+ completed = 0
913
+
914
+ async def update_one(idx: int, entity_id: str | int, data: dict[str, Any]) -> None:
915
+ nonlocal completed
916
+
917
+ async with semaphore:
918
+ try:
919
+ response = await self.update(f"{endpoint}/{entity_id}", data, query=query)
920
+
921
+ async with lock:
922
+ if response.data:
923
+ successes.append(response.data)
924
+ completed += 1
925
+ if progress_callback:
926
+ progress_callback(completed, len(updates))
927
+
928
+ except StrapiError as e:
929
+ async with lock:
930
+ failures.append(
931
+ BulkOperationFailure(
932
+ index=idx,
933
+ item={"id": entity_id, "data": data},
934
+ error=str(e),
935
+ exception=e,
936
+ )
937
+ )
938
+ completed += 1
939
+ if progress_callback:
940
+ progress_callback(completed, len(updates))
941
+
942
+ # Create all tasks
943
+ tasks = [update_one(i, entity_id, data) for i, (entity_id, data) in enumerate(updates)]
944
+
945
+ # Execute with gather
946
+ await asyncio.gather(*tasks, return_exceptions=False)
947
+
948
+ return BulkOperationResult(
949
+ successes=successes,
950
+ failures=failures,
951
+ total=len(updates),
952
+ succeeded=len(successes),
953
+ failed=len(failures),
954
+ )
955
+
956
+ async def bulk_delete(
957
+ self,
958
+ endpoint: str,
959
+ ids: list[str | int],
960
+ *,
961
+ batch_size: int = 10,
962
+ max_concurrency: int = 5,
963
+ progress_callback: Callable[[int, int], None] | None = None,
964
+ ) -> BulkOperationResult:
965
+ """Delete multiple entities in concurrent batches.
966
+
967
+ Args:
968
+ endpoint: API endpoint (e.g., "articles")
969
+ ids: List of entity IDs (numeric or documentId)
970
+ batch_size: Items per batch (default: 10, currently unused - for API compatibility)
971
+ max_concurrency: Max concurrent requests (default: 5)
972
+ progress_callback: Optional callback(completed, total)
973
+
974
+ Returns:
975
+ BulkOperationResult
976
+
977
+ Example:
978
+ >>> ids = [1, 2, 3, 4, 5]
979
+ >>> result = await client.bulk_delete("articles", ids)
980
+ >>> print(f"Deleted {result.succeeded} articles")
981
+ """
982
+ successes: list[NormalizedEntity] = []
983
+ failures: list[BulkOperationFailure] = []
984
+ semaphore = asyncio.Semaphore(max_concurrency)
985
+ lock = asyncio.Lock()
986
+ completed = 0
987
+ success_count = 0
988
+
989
+ async def delete_one(idx: int, entity_id: str | int) -> None:
990
+ nonlocal completed, success_count
991
+
992
+ async with semaphore:
993
+ try:
994
+ response = await self.remove(f"{endpoint}/{entity_id}")
995
+
996
+ async with lock:
997
+ # DELETE may return 204 No Content with no data
998
+ # Count as success when no exception is raised
999
+ success_count += 1
1000
+ if response.data:
1001
+ successes.append(response.data)
1002
+ completed += 1
1003
+ if progress_callback:
1004
+ progress_callback(completed, len(ids))
1005
+
1006
+ except StrapiError as e:
1007
+ async with lock:
1008
+ failures.append(
1009
+ BulkOperationFailure(
1010
+ index=idx,
1011
+ item={"id": entity_id},
1012
+ error=str(e),
1013
+ exception=e,
1014
+ )
1015
+ )
1016
+ completed += 1
1017
+ if progress_callback:
1018
+ progress_callback(completed, len(ids))
1019
+
1020
+ # Create all tasks
1021
+ tasks = [delete_one(i, entity_id) for i, entity_id in enumerate(ids)]
1022
+
1023
+ # Execute with gather
1024
+ await asyncio.gather(*tasks, return_exceptions=False)
1025
+
1026
+ return BulkOperationResult(
1027
+ successes=successes,
1028
+ failures=failures,
1029
+ total=len(ids),
1030
+ succeeded=success_count,
1031
+ failed=len(failures),
1032
+ )