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,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
|
+
)
|