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,611 @@
1
+ """Filter builder for Strapi API queries.
2
+
3
+ Provides a fluent API for constructing complex filters with:
4
+ - 24 filter operators (eq, gt, contains, etc.)
5
+ - Logical operators (AND, OR, NOT)
6
+ - Deep filtering on relations
7
+ - Nested filter groups
8
+
9
+ Examples:
10
+ Simple filter:
11
+ >>> filter_builder = FilterBuilder().eq("status", "published")
12
+ >>> filter_builder.to_query_dict()
13
+ {'status': {'$eq': 'published'}}
14
+
15
+ Complex filter with logical operators:
16
+ >>> filter_builder = (FilterBuilder()
17
+ ... .eq("status", "published")
18
+ ... .gt("views", 100)
19
+ ... .or_group(
20
+ ... FilterBuilder().contains("title", "Python"),
21
+ ... FilterBuilder().contains("title", "Django")
22
+ ... ))
23
+
24
+ Deep filtering on relations:
25
+ >>> filter_builder = FilterBuilder().eq("author.name", "John Doe")
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import TYPE_CHECKING, Any
31
+
32
+ from pydantic import BaseModel, Field
33
+
34
+ from strapi_kit.models.enums import FilterOperator
35
+
36
+ if TYPE_CHECKING:
37
+ pass
38
+
39
+
40
+ class FilterCondition(BaseModel):
41
+ """A single filter condition.
42
+
43
+ Represents a field-operator-value triple like "status = published".
44
+
45
+ Attributes:
46
+ field: Field name (supports dot notation for relations, e.g., "author.name")
47
+ operator: Filter operator from FilterOperator enum
48
+ value: Value to filter against (type depends on operator)
49
+ """
50
+
51
+ field: str = Field(..., min_length=1, description="Field name to filter on")
52
+ operator: FilterOperator = Field(..., description="Filter operator")
53
+ value: Any = Field(..., description="Value to compare against")
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ """Convert to dictionary format for query parameters.
57
+
58
+ Returns:
59
+ Dictionary with nested structure for field path and operator.
60
+
61
+ Examples:
62
+ >>> FilterCondition(field="status", operator=FilterOperator.EQ, value="published").to_dict()
63
+ {'status': {'$eq': 'published'}}
64
+
65
+ >>> FilterCondition(field="author.name", operator=FilterOperator.EQ, value="John").to_dict()
66
+ {'author': {'name': {'$eq': 'John'}}}
67
+ """
68
+ # Handle dot notation for nested fields (e.g., "author.name")
69
+ parts = self.field.split(".")
70
+ result: dict[str, Any] = {self.operator.value: self.value}
71
+
72
+ # Build nested dictionary from right to left
73
+ for part in reversed(parts):
74
+ result = {part: result}
75
+
76
+ return result
77
+
78
+
79
+ class FilterGroup(BaseModel):
80
+ """A group of filter conditions combined with logical operators.
81
+
82
+ Supports AND, OR, and NOT logical operators for combining conditions.
83
+
84
+ Attributes:
85
+ conditions: List of filter conditions
86
+ logical_operator: Logical operator combining conditions (default: AND)
87
+ """
88
+
89
+ conditions: list[FilterCondition | FilterGroup] = Field(
90
+ default_factory=list, description="Filter conditions or nested groups"
91
+ )
92
+ logical_operator: FilterOperator | None = Field(
93
+ None, description="Logical operator (AND, OR, NOT)"
94
+ )
95
+
96
+ def to_dict(self) -> dict[str, Any]:
97
+ """Convert to dictionary format for query parameters.
98
+
99
+ Returns:
100
+ Dictionary with conditions merged or wrapped in logical operator.
101
+
102
+ Examples:
103
+ >>> # Simple AND (default)
104
+ >>> group = FilterGroup(conditions=[
105
+ ... FilterCondition(field="status", operator=FilterOperator.EQ, value="published")
106
+ ... ])
107
+ >>> group.to_dict()
108
+ {'status': {'$eq': 'published'}}
109
+
110
+ >>> # Explicit OR
111
+ >>> group = FilterGroup(
112
+ ... conditions=[
113
+ ... FilterCondition(field="views", operator=FilterOperator.GT, value=100),
114
+ ... FilterCondition(field="likes", operator=FilterOperator.GT, value=50)
115
+ ... ],
116
+ ... logical_operator=FilterOperator.OR
117
+ ... )
118
+ >>> group.to_dict()
119
+ {'$or': [{'views': {'$gt': 100}}, {'likes': {'$gt': 50}}]}
120
+ """
121
+ if not self.conditions:
122
+ return {}
123
+
124
+ # Convert all conditions to dictionaries
125
+ condition_dicts = [
126
+ cond.to_dict() if isinstance(cond, FilterCondition) else cond.to_dict()
127
+ for cond in self.conditions
128
+ ]
129
+
130
+ # If no logical operator, merge dictionaries (implicit AND)
131
+ if self.logical_operator is None:
132
+ result: dict[str, Any] = {}
133
+ for cond_dict in condition_dicts:
134
+ # Deep merge dictionaries
135
+ self._deep_merge(result, cond_dict)
136
+ return result
137
+
138
+ # Wrap in logical operator (OR, AND, NOT)
139
+ return {self.logical_operator.value: condition_dicts}
140
+
141
+ @staticmethod
142
+ def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
143
+ """Deep merge source dictionary into target dictionary.
144
+
145
+ Args:
146
+ target: Target dictionary to merge into (modified in place)
147
+ source: Source dictionary to merge from
148
+ """
149
+ for key, value in source.items():
150
+ if key in target and isinstance(target[key], dict) and isinstance(value, dict):
151
+ FilterGroup._deep_merge(target[key], value)
152
+ else:
153
+ target[key] = value
154
+
155
+
156
+ class FilterBuilder:
157
+ """Fluent API for building Strapi filters.
158
+
159
+ Provides chainable methods for all 24 Strapi filter operators plus
160
+ logical grouping with AND/OR/NOT.
161
+
162
+ Examples:
163
+ >>> # Simple filter
164
+ >>> builder = FilterBuilder().eq("status", "published")
165
+
166
+ >>> # Chained filters (implicit AND)
167
+ >>> builder = (FilterBuilder()
168
+ ... .eq("status", "published")
169
+ ... .gt("views", 100)
170
+ ... .contains("title", "Python"))
171
+
172
+ >>> # OR group
173
+ >>> builder = FilterBuilder().or_group(
174
+ ... FilterBuilder().eq("category", "tech"),
175
+ ... FilterBuilder().eq("category", "science")
176
+ ... )
177
+
178
+ >>> # Complex nested filters
179
+ >>> builder = (FilterBuilder()
180
+ ... .eq("status", "published")
181
+ ... .or_group(
182
+ ... FilterBuilder().gt("views", 1000),
183
+ ... FilterBuilder().gt("likes", 500)
184
+ ... ))
185
+ """
186
+
187
+ def __init__(self) -> None:
188
+ """Initialize an empty filter builder."""
189
+ self._conditions: list[FilterCondition | FilterGroup] = []
190
+
191
+ def _add_condition(self, field: str, operator: FilterOperator, value: Any) -> FilterBuilder:
192
+ """Add a filter condition to the builder.
193
+
194
+ Args:
195
+ field: Field name to filter on
196
+ operator: Filter operator
197
+ value: Value to compare against
198
+
199
+ Returns:
200
+ Self for method chaining
201
+ """
202
+ self._conditions.append(FilterCondition(field=field, operator=operator, value=value))
203
+ return self
204
+
205
+ # Equality operators
206
+ def eq(self, field: str, value: Any) -> FilterBuilder:
207
+ """Equal to (case-sensitive).
208
+
209
+ Args:
210
+ field: Field name
211
+ value: Value to match
212
+
213
+ Returns:
214
+ Self for chaining
215
+
216
+ Examples:
217
+ >>> FilterBuilder().eq("status", "published")
218
+ """
219
+ return self._add_condition(field, FilterOperator.EQ, value)
220
+
221
+ def eqi(self, field: str, value: str) -> FilterBuilder:
222
+ """Equal to (case-insensitive).
223
+
224
+ Args:
225
+ field: Field name
226
+ value: String value to match
227
+
228
+ Returns:
229
+ Self for chaining
230
+
231
+ Examples:
232
+ >>> FilterBuilder().eqi("status", "PUBLISHED")
233
+ """
234
+ return self._add_condition(field, FilterOperator.EQI, value)
235
+
236
+ def ne(self, field: str, value: Any) -> FilterBuilder:
237
+ """Not equal to (case-sensitive).
238
+
239
+ Args:
240
+ field: Field name
241
+ value: Value to exclude
242
+
243
+ Returns:
244
+ Self for chaining
245
+ """
246
+ return self._add_condition(field, FilterOperator.NE, value)
247
+
248
+ def nei(self, field: str, value: str) -> FilterBuilder:
249
+ """Not equal to (case-insensitive).
250
+
251
+ Args:
252
+ field: Field name
253
+ value: String value to exclude
254
+
255
+ Returns:
256
+ Self for chaining
257
+ """
258
+ return self._add_condition(field, FilterOperator.NEI, value)
259
+
260
+ # Comparison operators
261
+ def lt(self, field: str, value: Any) -> FilterBuilder:
262
+ """Less than.
263
+
264
+ Args:
265
+ field: Field name
266
+ value: Upper bound (exclusive)
267
+
268
+ Returns:
269
+ Self for chaining
270
+
271
+ Examples:
272
+ >>> FilterBuilder().lt("price", 100)
273
+ """
274
+ return self._add_condition(field, FilterOperator.LT, value)
275
+
276
+ def lte(self, field: str, value: Any) -> FilterBuilder:
277
+ """Less than or equal to.
278
+
279
+ Args:
280
+ field: Field name
281
+ value: Upper bound (inclusive)
282
+
283
+ Returns:
284
+ Self for chaining
285
+ """
286
+ return self._add_condition(field, FilterOperator.LTE, value)
287
+
288
+ def gt(self, field: str, value: Any) -> FilterBuilder:
289
+ """Greater than.
290
+
291
+ Args:
292
+ field: Field name
293
+ value: Lower bound (exclusive)
294
+
295
+ Returns:
296
+ Self for chaining
297
+
298
+ Examples:
299
+ >>> FilterBuilder().gt("views", 1000)
300
+ """
301
+ return self._add_condition(field, FilterOperator.GT, value)
302
+
303
+ def gte(self, field: str, value: Any) -> FilterBuilder:
304
+ """Greater than or equal to.
305
+
306
+ Args:
307
+ field: Field name
308
+ value: Lower bound (inclusive)
309
+
310
+ Returns:
311
+ Self for chaining
312
+ """
313
+ return self._add_condition(field, FilterOperator.GTE, value)
314
+
315
+ # String matching operators
316
+ def contains(self, field: str, value: str) -> FilterBuilder:
317
+ """Contains substring (case-sensitive).
318
+
319
+ Args:
320
+ field: Field name
321
+ value: Substring to search for
322
+
323
+ Returns:
324
+ Self for chaining
325
+
326
+ Examples:
327
+ >>> FilterBuilder().contains("title", "Python")
328
+ """
329
+ return self._add_condition(field, FilterOperator.CONTAINS, value)
330
+
331
+ def not_contains(self, field: str, value: str) -> FilterBuilder:
332
+ """Does not contain substring (case-sensitive).
333
+
334
+ Args:
335
+ field: Field name
336
+ value: Substring to exclude
337
+
338
+ Returns:
339
+ Self for chaining
340
+ """
341
+ return self._add_condition(field, FilterOperator.NOT_CONTAINS, value)
342
+
343
+ def containsi(self, field: str, value: str) -> FilterBuilder:
344
+ """Contains substring (case-insensitive).
345
+
346
+ Args:
347
+ field: Field name
348
+ value: Substring to search for
349
+
350
+ Returns:
351
+ Self for chaining
352
+ """
353
+ return self._add_condition(field, FilterOperator.CONTAINSI, value)
354
+
355
+ def not_containsi(self, field: str, value: str) -> FilterBuilder:
356
+ """Does not contain substring (case-insensitive).
357
+
358
+ Args:
359
+ field: Field name
360
+ value: Substring to exclude
361
+
362
+ Returns:
363
+ Self for chaining
364
+ """
365
+ return self._add_condition(field, FilterOperator.NOT_CONTAINSI, value)
366
+
367
+ def starts_with(self, field: str, value: str) -> FilterBuilder:
368
+ """Starts with string (case-sensitive).
369
+
370
+ Args:
371
+ field: Field name
372
+ value: Prefix to match
373
+
374
+ Returns:
375
+ Self for chaining
376
+ """
377
+ return self._add_condition(field, FilterOperator.STARTS_WITH, value)
378
+
379
+ def starts_withi(self, field: str, value: str) -> FilterBuilder:
380
+ """Starts with string (case-insensitive).
381
+
382
+ Args:
383
+ field: Field name
384
+ value: Prefix to match
385
+
386
+ Returns:
387
+ Self for chaining
388
+ """
389
+ return self._add_condition(field, FilterOperator.STARTS_WITHI, value)
390
+
391
+ def ends_with(self, field: str, value: str) -> FilterBuilder:
392
+ """Ends with string (case-sensitive).
393
+
394
+ Args:
395
+ field: Field name
396
+ value: Suffix to match
397
+
398
+ Returns:
399
+ Self for chaining
400
+ """
401
+ return self._add_condition(field, FilterOperator.ENDS_WITH, value)
402
+
403
+ def ends_withi(self, field: str, value: str) -> FilterBuilder:
404
+ """Ends with string (case-insensitive).
405
+
406
+ Args:
407
+ field: Field name
408
+ value: Suffix to match
409
+
410
+ Returns:
411
+ Self for chaining
412
+ """
413
+ return self._add_condition(field, FilterOperator.ENDS_WITHI, value)
414
+
415
+ # Array operators
416
+ def in_(self, field: str, values: list[Any]) -> FilterBuilder:
417
+ """Value is in array.
418
+
419
+ Args:
420
+ field: Field name
421
+ values: List of acceptable values
422
+
423
+ Returns:
424
+ Self for chaining
425
+
426
+ Examples:
427
+ >>> FilterBuilder().in_("status", ["published", "draft"])
428
+ """
429
+ return self._add_condition(field, FilterOperator.IN, values)
430
+
431
+ def not_in(self, field: str, values: list[Any]) -> FilterBuilder:
432
+ """Value is not in array.
433
+
434
+ Args:
435
+ field: Field name
436
+ values: List of values to exclude
437
+
438
+ Returns:
439
+ Self for chaining
440
+ """
441
+ return self._add_condition(field, FilterOperator.NOT_IN, values)
442
+
443
+ # Null operators
444
+ def null(self, field: str, is_null: bool = True) -> FilterBuilder:
445
+ """Value is null.
446
+
447
+ Args:
448
+ field: Field name
449
+ is_null: True to match null, False to match not null
450
+
451
+ Returns:
452
+ Self for chaining
453
+
454
+ Examples:
455
+ >>> FilterBuilder().null("deletedAt") # Match null values
456
+ >>> FilterBuilder().null("deletedAt", False) # Match non-null values
457
+ """
458
+ return self._add_condition(field, FilterOperator.NULL, is_null)
459
+
460
+ def not_null(self, field: str) -> FilterBuilder:
461
+ """Value is not null.
462
+
463
+ Args:
464
+ field: Field name
465
+
466
+ Returns:
467
+ Self for chaining
468
+ """
469
+ return self._add_condition(field, FilterOperator.NOT_NULL, True)
470
+
471
+ # Range operators
472
+ def between(self, field: str, start: Any, end: Any) -> FilterBuilder:
473
+ """Value is between start and end (inclusive).
474
+
475
+ Args:
476
+ field: Field name
477
+ start: Lower bound
478
+ end: Upper bound
479
+
480
+ Returns:
481
+ Self for chaining
482
+
483
+ Examples:
484
+ >>> FilterBuilder().between("price", 10, 100)
485
+ >>> FilterBuilder().between("publishedAt", "2024-01-01", "2024-12-31")
486
+ """
487
+ return self._add_condition(field, FilterOperator.BETWEEN, [start, end])
488
+
489
+ # Logical operators
490
+ def and_group(self, *builders: FilterBuilder) -> FilterBuilder:
491
+ """Create an AND group of filters.
492
+
493
+ Each builder is wrapped as a sub-group to preserve logical structure.
494
+ For example, (a AND b) AND (c AND d) is preserved correctly.
495
+
496
+ Args:
497
+ *builders: FilterBuilder instances to combine with AND
498
+
499
+ Returns:
500
+ Self for chaining
501
+
502
+ Examples:
503
+ >>> FilterBuilder().and_group(
504
+ ... FilterBuilder().eq("status", "published"),
505
+ ... FilterBuilder().gt("views", 100)
506
+ ... )
507
+ """
508
+ # Wrap each builder as a sub-group to preserve grouping
509
+ conditions: list[FilterCondition | FilterGroup] = []
510
+ for builder in builders:
511
+ if len(builder._conditions) == 1:
512
+ # Single condition - add directly
513
+ conditions.append(builder._conditions[0])
514
+ else:
515
+ # Multiple conditions - wrap as implicit AND group
516
+ conditions.append(
517
+ FilterGroup(conditions=builder._conditions, logical_operator=None)
518
+ )
519
+
520
+ group = FilterGroup(conditions=conditions, logical_operator=FilterOperator.AND)
521
+ self._conditions.append(group)
522
+ return self
523
+
524
+ def or_group(self, *builders: FilterBuilder) -> FilterBuilder:
525
+ """Create an OR group of filters.
526
+
527
+ Each builder is wrapped as a sub-group to preserve logical structure.
528
+ For example, (a AND b) OR c preserves the grouping correctly.
529
+
530
+ Args:
531
+ *builders: FilterBuilder instances to combine with OR
532
+
533
+ Returns:
534
+ Self for chaining
535
+
536
+ Examples:
537
+ >>> FilterBuilder().or_group(
538
+ ... FilterBuilder().eq("category", "tech"),
539
+ ... FilterBuilder().eq("category", "science")
540
+ ... )
541
+ """
542
+ # Wrap each builder as a sub-group to preserve grouping
543
+ conditions: list[FilterCondition | FilterGroup] = []
544
+ for builder in builders:
545
+ if len(builder._conditions) == 1:
546
+ # Single condition - add directly
547
+ conditions.append(builder._conditions[0])
548
+ else:
549
+ # Multiple conditions - wrap as implicit AND group
550
+ conditions.append(
551
+ FilterGroup(conditions=builder._conditions, logical_operator=None)
552
+ )
553
+
554
+ group = FilterGroup(conditions=conditions, logical_operator=FilterOperator.OR)
555
+ self._conditions.append(group)
556
+ return self
557
+
558
+ def not_group(self, builder: FilterBuilder) -> FilterBuilder:
559
+ """Create a NOT group (negation).
560
+
561
+ The builder's conditions are wrapped as a group to preserve logical structure.
562
+
563
+ Args:
564
+ builder: FilterBuilder instance to negate
565
+
566
+ Returns:
567
+ Self for chaining
568
+
569
+ Examples:
570
+ >>> FilterBuilder().not_group(
571
+ ... FilterBuilder().eq("status", "draft")
572
+ ... )
573
+ """
574
+ # Wrap builder conditions appropriately
575
+ if len(builder._conditions) == 1:
576
+ # Single condition - wrap directly
577
+ conditions: list[FilterCondition | FilterGroup] = list(builder._conditions)
578
+ else:
579
+ # Multiple conditions - wrap as implicit AND group first
580
+ conditions = [FilterGroup(conditions=builder._conditions, logical_operator=None)]
581
+
582
+ group = FilterGroup(conditions=conditions, logical_operator=FilterOperator.NOT)
583
+ self._conditions.append(group)
584
+ return self
585
+
586
+ def to_query_dict(self) -> dict[str, Any]:
587
+ """Convert filter builder to dictionary format for query parameters.
588
+
589
+ Returns:
590
+ Dictionary with nested filter structure
591
+
592
+ Examples:
593
+ >>> builder = FilterBuilder().eq("status", "published").gt("views", 100)
594
+ >>> builder.to_query_dict()
595
+ {'status': {'$eq': 'published'}, 'views': {'$gt': 100}}
596
+ """
597
+ if not self._conditions:
598
+ return {}
599
+
600
+ # If single condition, return it directly
601
+ if len(self._conditions) == 1:
602
+ cond = self._conditions[0]
603
+ return cond.to_dict() if isinstance(cond, FilterCondition) else cond.to_dict()
604
+
605
+ # Multiple conditions - wrap in implicit AND
606
+ group = FilterGroup(conditions=self._conditions, logical_operator=None)
607
+ return group.to_dict()
608
+
609
+
610
+ # Rebuild models to resolve forward references
611
+ FilterGroup.model_rebuild()