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.
- strapi_kit/__init__.py +97 -0
- strapi_kit/__version__.py +15 -0
- strapi_kit/_version.py +34 -0
- strapi_kit/auth/__init__.py +7 -0
- strapi_kit/auth/api_token.py +48 -0
- strapi_kit/cache/__init__.py +5 -0
- strapi_kit/cache/schema_cache.py +211 -0
- strapi_kit/client/__init__.py +11 -0
- strapi_kit/client/async_client.py +1032 -0
- strapi_kit/client/base.py +460 -0
- strapi_kit/client/sync_client.py +980 -0
- strapi_kit/config_provider.py +368 -0
- strapi_kit/exceptions/__init__.py +37 -0
- strapi_kit/exceptions/errors.py +205 -0
- strapi_kit/export/__init__.py +10 -0
- strapi_kit/export/exporter.py +384 -0
- strapi_kit/export/importer.py +619 -0
- strapi_kit/export/media_handler.py +322 -0
- strapi_kit/export/relation_resolver.py +172 -0
- strapi_kit/models/__init__.py +104 -0
- strapi_kit/models/bulk.py +69 -0
- strapi_kit/models/config.py +174 -0
- strapi_kit/models/enums.py +97 -0
- strapi_kit/models/export_format.py +166 -0
- strapi_kit/models/import_options.py +142 -0
- strapi_kit/models/request/__init__.py +1 -0
- strapi_kit/models/request/fields.py +65 -0
- strapi_kit/models/request/filters.py +611 -0
- strapi_kit/models/request/pagination.py +168 -0
- strapi_kit/models/request/populate.py +281 -0
- strapi_kit/models/request/query.py +429 -0
- strapi_kit/models/request/sort.py +147 -0
- strapi_kit/models/response/__init__.py +1 -0
- strapi_kit/models/response/base.py +75 -0
- strapi_kit/models/response/component.py +67 -0
- strapi_kit/models/response/media.py +91 -0
- strapi_kit/models/response/meta.py +44 -0
- strapi_kit/models/response/normalized.py +168 -0
- strapi_kit/models/response/relation.py +48 -0
- strapi_kit/models/response/v4.py +70 -0
- strapi_kit/models/response/v5.py +57 -0
- strapi_kit/models/schema.py +93 -0
- strapi_kit/operations/__init__.py +16 -0
- strapi_kit/operations/media.py +226 -0
- strapi_kit/operations/streaming.py +144 -0
- strapi_kit/parsers/__init__.py +5 -0
- strapi_kit/parsers/version_detecting.py +171 -0
- strapi_kit/protocols.py +455 -0
- strapi_kit/utils/__init__.py +15 -0
- strapi_kit/utils/rate_limiter.py +201 -0
- strapi_kit/utils/uid.py +88 -0
- strapi_kit-0.0.1.dist-info/METADATA +1098 -0
- strapi_kit-0.0.1.dist-info/RECORD +55 -0
- strapi_kit-0.0.1.dist-info/WHEEL +4 -0
- 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
|
+
)
|