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