rapyer 1.1.4__tar.gz → 1.1.6__tar.gz

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 (36) hide show
  1. {rapyer-1.1.4 → rapyer-1.1.6}/PKG-INFO +6 -1
  2. {rapyer-1.1.4 → rapyer-1.1.6}/pyproject.toml +12 -5
  3. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/base.py +199 -51
  4. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/config.py +6 -1
  5. rapyer-1.1.6/rapyer/errors/__init__.py +17 -0
  6. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/errors/base.py +12 -0
  7. rapyer-1.1.6/rapyer/fields/__init__.py +5 -0
  8. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/fields/expression.py +0 -1
  9. rapyer-1.1.6/rapyer/fields/safe_load.py +27 -0
  10. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/init.py +21 -3
  11. rapyer-1.1.6/rapyer/scripts.py +86 -0
  12. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/base.py +22 -3
  13. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/byte.py +0 -1
  14. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/convert.py +10 -3
  15. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/datetime.py +1 -2
  16. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/dct.py +13 -3
  17. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/float.py +1 -2
  18. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/integer.py +2 -3
  19. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/lst.py +35 -3
  20. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/utils/fields.py +25 -2
  21. rapyer-1.1.4/rapyer/errors/__init__.py +0 -8
  22. rapyer-1.1.4/rapyer/fields/__init__.py +0 -4
  23. {rapyer-1.1.4 → rapyer-1.1.6}/README.md +0 -0
  24. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/__init__.py +0 -0
  25. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/context.py +0 -0
  26. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/fields/index.py +0 -0
  27. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/fields/key.py +0 -0
  28. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/links.py +0 -0
  29. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/__init__.py +0 -0
  30. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/init.py +0 -0
  31. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/string.py +0 -0
  32. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/typing_support.py +0 -0
  33. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/utils/__init__.py +0 -0
  34. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/utils/annotation.py +0 -0
  35. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/utils/pythonic.py +0 -0
  36. {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/utils/redis.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rapyer
3
- Version: 1.1.4
3
+ Version: 1.1.6
4
4
  Summary: Pydantic models with Redis as the backend
5
5
  License: MIT
6
6
  Keywords: redis,redis-json,pydantic,pydantic-v2,orm,database,async,nosql,cache,key-value,data-modeling,python,backend,storage,serialization,validation
@@ -23,7 +23,12 @@ Classifier: Topic :: Database :: Database Engines/Servers
23
23
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
24
  Classifier: Typing :: Typed
25
25
  Classifier: Operating System :: OS Independent
26
+ Provides-Extra: test
27
+ Requires-Dist: fakeredis[json,lua] (>=2.20.0) ; extra == "test"
26
28
  Requires-Dist: pydantic (>=2.11.0,<2.13.0)
29
+ Requires-Dist: pytest (>=8.4.2) ; extra == "test"
30
+ Requires-Dist: pytest-asyncio (>=0.25.0) ; extra == "test"
31
+ Requires-Dist: pytest-cov (>=6.0.0) ; extra == "test"
27
32
  Requires-Dist: redis[async] (>=6.0.0,<7.1.0)
28
33
  Project-URL: Bug Tracker, https://github.com/imaginary-cherry/rapyer/issues
29
34
  Project-URL: Changelog, https://github.com/imaginary-cherry/rapyer/releases
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "rapyer"
7
- version = "1.1.4"
7
+ version = "1.1.6"
8
8
  description = "Pydantic models with Redis as the backend"
9
9
  authors = [{name = "YedidyaHKfir", email = "yedidyakfir@gmail.com"}]
10
10
  readme = "README.md"
@@ -51,6 +51,14 @@ dependencies = [
51
51
  "pydantic>=2.11.0, <2.13.0",
52
52
  ]
53
53
 
54
+ [project.optional-dependencies]
55
+ test = [
56
+ "pytest>=8.4.2",
57
+ "pytest-asyncio>=0.25.0",
58
+ "pytest-cov>=6.0.0",
59
+ "fakeredis[lua,json]>=2.20.0",
60
+ ]
61
+
54
62
  [project.urls]
55
63
  Homepage = "https://imaginary-cherry.github.io/rapyer/"
56
64
  Documentation = "https://imaginary-cherry.github.io/rapyer/"
@@ -67,10 +75,9 @@ packages = [{include = "rapyer"}]
67
75
  black = "^25.9.0"
68
76
  mypy = "^1.0.0"
69
77
 
70
- [tool.poetry.group.tests.dependencies]
71
- pytest = "^8.4.2"
72
- pytest-asyncio = "^0.25.0"
73
- pytest-cov = "^6.0.0"
78
+ [tool.bandit]
79
+ exclude_dirs = ["tests"]
80
+ skips = ["B101"] # assert_used - valid in test files
74
81
 
75
82
  [tool.coverage.run]
76
83
  source = ["rapyer"]
@@ -2,6 +2,7 @@ import asyncio
2
2
  import base64
3
3
  import contextlib
4
4
  import functools
5
+ import logging
5
6
  import pickle
6
7
  import uuid
7
8
  from contextlib import AbstractAsyncContextManager
@@ -14,20 +15,24 @@ from pydantic import (
14
15
  model_validator,
15
16
  field_serializer,
16
17
  field_validator,
18
+ ValidationError,
17
19
  )
18
20
  from pydantic_core.core_schema import FieldSerializationInfo, ValidationInfo
19
- from redis.commands.search.index_definition import IndexDefinition, IndexType
20
- from redis.commands.search.query import Query
21
- from typing_extensions import deprecated
22
-
23
21
  from rapyer.config import RedisConfig
24
22
  from rapyer.context import _context_var, _context_xx_pipe
25
- from rapyer.errors.base import KeyNotFound, UnsupportedIndexedFieldError
23
+ from rapyer.errors.base import (
24
+ KeyNotFound,
25
+ PersistentNoScriptError,
26
+ UnsupportedIndexedFieldError,
27
+ CantSerializeRedisValueError,
28
+ )
26
29
  from rapyer.fields.expression import ExpressionField, AtomicField, Expression
27
30
  from rapyer.fields.index import IndexAnnotation
28
31
  from rapyer.fields.key import KeyAnnotation
32
+ from rapyer.fields.safe_load import SafeLoadAnnotation
29
33
  from rapyer.links import REDIS_SUPPORTED_LINK
30
- from rapyer.types.base import RedisType, REDIS_DUMP_FLAG_NAME
34
+ from rapyer.scripts import handle_noscript_error
35
+ from rapyer.types.base import RedisType, REDIS_DUMP_FLAG_NAME, FAILED_FIELDS_KEY
31
36
  from rapyer.types.convert import RedisConverter
32
37
  from rapyer.typing_support import Self, Unpack
33
38
  from rapyer.utils.annotation import (
@@ -36,34 +41,61 @@ from rapyer.utils.annotation import (
36
41
  field_with_flag,
37
42
  DYNAMIC_CLASS_DOC,
38
43
  )
39
- from rapyer.utils.fields import get_all_pydantic_annotation, is_redis_field
44
+ from rapyer.utils.fields import (
45
+ get_all_pydantic_annotation,
46
+ is_redis_field,
47
+ is_type_json_serializable,
48
+ )
40
49
  from rapyer.utils.pythonic import safe_issubclass
41
50
  from rapyer.utils.redis import (
42
51
  acquire_lock,
43
52
  update_keys_in_pipeline,
44
53
  refresh_ttl_if_needed,
45
54
  )
55
+ from redis.commands.search.index_definition import IndexDefinition, IndexType
56
+ from redis.commands.search.query import Query
57
+ from redis.exceptions import NoScriptError, ResponseError
58
+ from typing_extensions import deprecated
59
+
60
+ logger = logging.getLogger("rapyer")
46
61
 
47
62
 
48
- def make_pickle_field_serializer(field: str):
63
+ def make_pickle_field_serializer(
64
+ field: str, safe_load: bool = False, can_json: bool = False
65
+ ):
49
66
  @field_serializer(field, when_used="json-unless-none")
50
- def pickle_field_serializer(v, info: FieldSerializationInfo):
67
+ @classmethod
68
+ def pickle_field_serializer(cls, v, info: FieldSerializationInfo):
51
69
  ctx = info.context or {}
52
70
  should_serialize_redis = ctx.get(REDIS_DUMP_FLAG_NAME, False)
53
- if should_serialize_redis:
71
+ # Skip pickling if field CAN be JSON serialized AND user prefers JSON dump
72
+ field_can_be_json = can_json and cls.Meta.prefer_normal_json_dump
73
+ if should_serialize_redis and not field_can_be_json:
54
74
  return base64.b64encode(pickle.dumps(v)).decode("utf-8")
55
75
  return v
56
76
 
57
77
  pickle_field_serializer.__name__ = f"__serialize_{field}"
58
78
 
59
79
  @field_validator(field, mode="before")
60
- def pickle_field_validator(v, info: ValidationInfo):
80
+ @classmethod
81
+ def pickle_field_validator(cls, v, info: ValidationInfo):
61
82
  if v is None:
62
83
  return v
63
84
  ctx = info.context or {}
64
85
  should_serialize_redis = ctx.get(REDIS_DUMP_FLAG_NAME, False)
65
86
  if should_serialize_redis:
66
- return pickle.loads(base64.b64decode(v))
87
+ try:
88
+ field_can_be_json = can_json and cls.Meta.prefer_normal_json_dump
89
+ if should_serialize_redis and not field_can_be_json:
90
+ return pickle.loads(base64.b64decode(v))
91
+ return v
92
+ except Exception as e:
93
+ if safe_load:
94
+ failed_fields = ctx.setdefault(FAILED_FIELDS_KEY, set())
95
+ failed_fields.add(field)
96
+ logger.warning("SafeLoad: Failed to deserialize field '%s'", field)
97
+ return None
98
+ raise CantSerializeRedisValueError() from e
67
99
  return v
68
100
 
69
101
  pickle_field_validator.__name__ = f"__deserialize_{field}"
@@ -71,15 +103,40 @@ def make_pickle_field_serializer(field: str):
71
103
  return pickle_field_serializer, pickle_field_validator
72
104
 
73
105
 
106
+ # TODO: Remove in next major version (2.0) - backward compatibility for pickled data
107
+ # This validator handles loading old pickled data for fields that are now JSON-serializable.
108
+ # In 2.0, remove this function and the validator registration in __init_subclass__.
109
+ def make_backward_compat_validator(field: str):
110
+ @field_validator(field, mode="before")
111
+ def backward_compat_validator(v, info: ValidationInfo):
112
+ ctx = info.context or {}
113
+ should_deserialize_redis = ctx.get(REDIS_DUMP_FLAG_NAME, False)
114
+ if should_deserialize_redis and isinstance(v, str):
115
+ try:
116
+ return pickle.loads(base64.b64decode(v))
117
+ except Exception:
118
+ pass
119
+ return v
120
+
121
+ backward_compat_validator.__name__ = f"__backward_compat_{field}"
122
+ return backward_compat_validator
123
+
124
+
74
125
  class AtomicRedisModel(BaseModel):
75
126
  _pk: str = PrivateAttr(default_factory=lambda: str(uuid.uuid4()))
76
127
  _base_model_link: Self | RedisType = PrivateAttr(default=None)
128
+ _failed_fields: set[str] = PrivateAttr(default_factory=set)
77
129
 
78
130
  Meta: ClassVar[RedisConfig] = RedisConfig()
79
131
  _key_field_name: ClassVar[str | None] = None
132
+ _safe_load_fields: ClassVar[set[str]] = set()
80
133
  _field_name: str = PrivateAttr(default="")
81
134
  model_config = ConfigDict(validate_assignment=True, validate_default=True)
82
135
 
136
+ @property
137
+ def failed_fields(self) -> set[str]:
138
+ return self._failed_fields
139
+
83
140
  @property
84
141
  def pk(self):
85
142
  if self._key_field_name:
@@ -112,6 +169,9 @@ class AtomicRedisModel(BaseModel):
112
169
  field_path = self.field_path
113
170
  return f"${field_path}" if field_path else "$"
114
171
 
172
+ def should_refresh(self):
173
+ return self.Meta.refresh_ttl and self.Meta.ttl is not None
174
+
115
175
  @classmethod
116
176
  def redis_schema(cls, redis_name: str = ""):
117
177
  fields = []
@@ -184,11 +244,13 @@ class AtomicRedisModel(BaseModel):
184
244
  self._pk = value.split(":", maxsplit=1)[-1]
185
245
 
186
246
  def __init_subclass__(cls, **kwargs):
187
- # Find a field with KeyAnnotation and save its name
247
+ # Find fields with KeyAnnotation and SafeLoadAnnotation
248
+ cls._safe_load_fields = set()
188
249
  for field_name, annotation in cls.__annotations__.items():
189
250
  if has_annotation(annotation, KeyAnnotation):
190
251
  cls._key_field_name = field_name
191
- break
252
+ if has_annotation(annotation, SafeLoadAnnotation):
253
+ cls._safe_load_fields.add(field_name)
192
254
 
193
255
  # Redefine annotations to use redis types
194
256
  pydantic_annotation = get_all_pydantic_annotation(cls, AtomicRedisModel)
@@ -200,7 +262,13 @@ class AtomicRedisModel(BaseModel):
200
262
  original_annotations.update(new_annotation)
201
263
  new_annotations = {
202
264
  field_name: replace_to_redis_types_in_annotation(
203
- annotation, RedisConverter(cls.Meta.redis_type, f".{field_name}")
265
+ annotation,
266
+ RedisConverter(
267
+ cls.Meta.redis_type,
268
+ f".{field_name}",
269
+ safe_load=field_name in cls._safe_load_fields
270
+ or cls.Meta.safe_load_all,
271
+ ),
204
272
  )
205
273
  for field_name, annotation in original_annotations.items()
206
274
  if is_redis_field(field_name, annotation)
@@ -216,9 +284,22 @@ class AtomicRedisModel(BaseModel):
216
284
  if not is_redis_field(attr_name, attr_type):
217
285
  continue
218
286
  if original_annotations[attr_name] == attr_type:
219
- serializer, validator = make_pickle_field_serializer(attr_name)
220
- setattr(cls, serializer.__name__, serializer)
221
- setattr(cls, validator.__name__, validator)
287
+ default_value = cls.__dict__.get(attr_name, None)
288
+ can_json = is_type_json_serializable(attr_type, default_value)
289
+ should_json_serialize = can_json and cls.Meta.prefer_normal_json_dump
290
+
291
+ if not should_json_serialize:
292
+ is_field_marked_safe = attr_name in cls._safe_load_fields
293
+ is_safe_load = is_field_marked_safe or cls.Meta.safe_load_all
294
+ serializer, validator = make_pickle_field_serializer(
295
+ attr_name, safe_load=is_safe_load, can_json=can_json
296
+ )
297
+ setattr(cls, serializer.__name__, serializer)
298
+ setattr(cls, validator.__name__, validator)
299
+ else:
300
+ # TODO: Remove in 2.0 - backward compatibility for old pickled data
301
+ validator = make_backward_compat_validator(attr_name)
302
+ setattr(cls, validator.__name__, validator)
222
303
  continue
223
304
 
224
305
  # Update the redis model list for initialization
@@ -321,6 +402,11 @@ class AtomicRedisModel(BaseModel):
321
402
  self.Meta.redis, self.key, self.Meta.ttl, self.Meta.refresh_ttl
322
403
  )
323
404
 
405
+ async def aset_ttl(self, ttl: int) -> None:
406
+ if self.is_inner_model():
407
+ raise RuntimeError("Can only set TTL from top level model")
408
+ await self.Meta.redis.expire(self.key, ttl)
409
+
324
410
  @classmethod
325
411
  @deprecated(
326
412
  "get() classmethod is deprecated and will be removed in rapyer 1.2.0, use aget instead"
@@ -337,8 +423,10 @@ class AtomicRedisModel(BaseModel):
337
423
  raise KeyNotFound(f"{key} is missing in redis")
338
424
  model_dump = model_dump[0]
339
425
 
340
- instance = cls.model_validate(model_dump, context={REDIS_DUMP_FLAG_NAME: True})
426
+ context = {REDIS_DUMP_FLAG_NAME: True, FAILED_FIELDS_KEY: set()}
427
+ instance = cls.model_validate(model_dump, context=context)
341
428
  instance.key = key
429
+ instance._failed_fields = context.get(FAILED_FIELDS_KEY, set())
342
430
  await refresh_ttl_if_needed(
343
431
  cls.Meta.redis, key, cls.Meta.ttl, cls.Meta.refresh_ttl
344
432
  )
@@ -355,56 +443,77 @@ class AtomicRedisModel(BaseModel):
355
443
  if not model_dump:
356
444
  raise KeyNotFound(f"{self.key} is missing in redis")
357
445
  model_dump = model_dump[0]
358
- instance = self.__class__(**model_dump)
446
+ context = {REDIS_DUMP_FLAG_NAME: True, FAILED_FIELDS_KEY: set()}
447
+ instance = self.__class__.model_validate(model_dump, context=context)
359
448
  instance._pk = self._pk
360
449
  instance._base_model_link = self._base_model_link
450
+ instance._failed_fields = context.get(FAILED_FIELDS_KEY, set())
361
451
  await refresh_ttl_if_needed(
362
452
  self.Meta.redis, self.key, self.Meta.ttl, self.Meta.refresh_ttl
363
453
  )
364
454
  return instance
365
455
 
366
456
  @classmethod
367
- async def afind(cls, *expressions):
368
- # Original behavior when no expressions provided - return all
369
- if not expressions:
370
- keys = await cls.afind_keys()
371
- if not keys:
372
- return []
457
+ async def afind(cls, *args):
458
+ # Separate keys (str) from expressions (Expression)
459
+ provided_keys = [arg for arg in args if isinstance(arg, str)]
460
+ expressions = [arg for arg in args if isinstance(arg, Expression)]
461
+
462
+ if provided_keys and expressions:
463
+ logger.warning(
464
+ "afind called with both keys and expressions; expressions ignored"
465
+ )
373
466
 
374
- models = await cls.Meta.redis.json().mget(keys=keys, path="$")
375
- else:
376
- # With expressions - use Redis Search
377
- # Combine all expressions with & operator
467
+ if provided_keys:
468
+ # Case 1: Extract by keys
469
+ targeted_keys = [
470
+ k if ":" in k else f"{cls.class_key_initials()}:{k}"
471
+ for k in provided_keys
472
+ ]
473
+ elif expressions:
474
+ # Case 2: Extract by expressions
378
475
  combined_expression = functools.reduce(lambda a, b: a & b, expressions)
379
476
  query_string = combined_expression.create_filter()
380
-
381
- # Create a Query object
382
477
  query = Query(query_string).no_content()
383
-
384
- # Try to search using the index
385
478
  index_name = cls.index_name()
386
479
  search_result = await cls.Meta.redis.ft(index_name).search(query)
387
-
388
480
  if not search_result.docs:
389
481
  return []
482
+ targeted_keys = [doc.id for doc in search_result.docs]
483
+ else:
484
+ # Case 3: Extract all
485
+ targeted_keys = await cls.afind_keys()
390
486
 
391
- # Get the keys from search results
392
- keys = [doc.id for doc in search_result.docs]
487
+ if not targeted_keys:
488
+ return []
393
489
 
394
- # Fetch the actual documents
395
- models = await cls.Meta.redis.json().mget(keys=keys, path="$")
490
+ # Fetch the actual documents
491
+ models = await cls.Meta.redis.json().mget(keys=targeted_keys, path="$")
492
+
493
+ instances = []
494
+ for model, key in zip(models, targeted_keys):
495
+ if model is None:
496
+ continue
497
+ context = {REDIS_DUMP_FLAG_NAME: True, FAILED_FIELDS_KEY: set()}
498
+ try:
499
+ model = cls.model_validate(model[0], context=context)
500
+ except ValidationError as exc:
501
+ logger.debug(
502
+ "Skipping key %s due to validation error during afind: %s",
503
+ key,
504
+ exc,
505
+ )
506
+ continue
507
+ model.key = key
508
+ model._failed_fields = context.get(FAILED_FIELDS_KEY, set())
509
+ instances.append(model)
396
510
 
397
511
  if cls.Meta.ttl is not None and cls.Meta.refresh_ttl:
398
512
  async with cls.Meta.redis.pipeline() as pipe:
399
- for key in keys:
400
- pipe.expire(key, cls.Meta.ttl)
513
+ for model in instances:
514
+ pipe.expire(model.key, cls.Meta.ttl)
401
515
  await pipe.execute()
402
516
 
403
- instances = []
404
- for model, key in zip(models, keys):
405
- model = cls.model_validate(model[0], context={REDIS_DUMP_FLAG_NAME: True})
406
- model.key = key
407
- instances.append(model)
408
517
  return instances
409
518
 
410
519
  @classmethod
@@ -512,7 +621,7 @@ class AtomicRedisModel(BaseModel):
512
621
  async def apipeline(
513
622
  self, ignore_if_deleted: bool = False
514
623
  ) -> AbstractAsyncContextManager[Self]:
515
- async with self.Meta.redis.pipeline() as pipe:
624
+ async with self.Meta.redis.pipeline(transaction=True) as pipe:
516
625
  try:
517
626
  redis_model = await self.__class__.aget(self.key)
518
627
  unset_fields = {
@@ -527,10 +636,49 @@ class AtomicRedisModel(BaseModel):
527
636
  _context_var.set(pipe)
528
637
  _context_xx_pipe.set(ignore_if_deleted)
529
638
  yield redis_model
530
- await pipe.execute()
531
- await refresh_ttl_if_needed(
532
- self.Meta.redis, self.key, self.Meta.ttl, self.Meta.refresh_ttl
533
- )
639
+ commands_backup = list(pipe.command_stack)
640
+ noscript_on_first_attempt = False
641
+ noscript_on_retry = False
642
+
643
+ try:
644
+ if self.should_refresh():
645
+ pipe.expire(self.key, self.Meta.ttl)
646
+ await pipe.execute()
647
+ except NoScriptError:
648
+ noscript_on_first_attempt = True
649
+ except ResponseError as exc:
650
+ if ignore_if_deleted:
651
+ logger.warning(
652
+ "Swallowed ResponseError during pipeline.execute() with "
653
+ "ignore_if_deleted=True for key %r: %s",
654
+ getattr(self, "key", None),
655
+ exc,
656
+ )
657
+ else:
658
+ raise
659
+
660
+ if noscript_on_first_attempt:
661
+ await handle_noscript_error(self.Meta.redis)
662
+ evalsha_commands = [
663
+ (args, options)
664
+ for args, options in commands_backup
665
+ if args[0] == "EVALSHA"
666
+ ]
667
+ # Retry execute the pipeline actions
668
+ async with self.Meta.redis.pipeline(transaction=True) as retry_pipe:
669
+ for args, options in evalsha_commands:
670
+ retry_pipe.execute_command(*args, **options)
671
+ try:
672
+ await retry_pipe.execute()
673
+ except NoScriptError:
674
+ noscript_on_retry = True
675
+
676
+ if noscript_on_retry:
677
+ raise PersistentNoScriptError(
678
+ "NOSCRIPT error persisted after re-registering scripts. "
679
+ "This indicates a server-side problem with Redis."
680
+ )
681
+
534
682
  _context_var.set(None)
535
683
  _context_xx_pipe.set(False)
536
684
 
@@ -22,4 +22,9 @@ class RedisConfig:
22
22
  redis_type: dict[type, type] = dataclasses.field(default_factory=create_all_types)
23
23
  ttl: int | None = None
24
24
  init_with_rapyer: bool = True
25
- refresh_ttl: bool = True # Enable TTL refresh on read/write operations by default
25
+ # Enable TTL refresh on read/write operations by default
26
+ refresh_ttl: bool = True
27
+ # If True, all non-Redis-supported fields are treated as SafeLoad
28
+ safe_load_all: bool = False
29
+ # If True, use JSON serialization for fields that support it instead of pickle
30
+ prefer_normal_json_dump: bool = False
@@ -0,0 +1,17 @@
1
+ from rapyer.errors.base import (
2
+ BadFilterError,
3
+ FindError,
4
+ PersistentNoScriptError,
5
+ RapyerError,
6
+ ScriptsNotInitializedError,
7
+ UnsupportedIndexedFieldError,
8
+ )
9
+
10
+ __all__ = [
11
+ "BadFilterError",
12
+ "FindError",
13
+ "PersistentNoScriptError",
14
+ "RapyerError",
15
+ "ScriptsNotInitializedError",
16
+ "UnsupportedIndexedFieldError",
17
+ ]
@@ -24,3 +24,15 @@ class BadFilterError(FindError):
24
24
 
25
25
  class UnsupportedIndexedFieldError(FindError):
26
26
  pass
27
+
28
+
29
+ class CantSerializeRedisValueError(RapyerError):
30
+ pass
31
+
32
+
33
+ class ScriptsNotInitializedError(RapyerError):
34
+ pass
35
+
36
+
37
+ class PersistentNoScriptError(RapyerError):
38
+ pass
@@ -0,0 +1,5 @@
1
+ from rapyer.fields.index import Index
2
+ from rapyer.fields.key import Key
3
+ from rapyer.fields.safe_load import SafeLoad
4
+
5
+ __all__ = ["Key", "Index", "SafeLoad"]
@@ -1,7 +1,6 @@
1
1
  from typing import Any
2
2
 
3
3
  from pydantic import TypeAdapter
4
-
5
4
  from rapyer.errors import BadFilterError
6
5
  from rapyer.types.base import REDIS_DUMP_FLAG_NAME
7
6
  from rapyer.typing_support import Unpack
@@ -0,0 +1,27 @@
1
+ import dataclasses
2
+ from typing import TYPE_CHECKING, Annotated, Any, Generic, TypeAlias, TypeVar
3
+
4
+
5
+ @dataclasses.dataclass(frozen=True)
6
+ class SafeLoadAnnotation:
7
+ pass
8
+
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ class _SafeLoadType(Generic[T]):
14
+ def __new__(cls, typ: Any = None):
15
+ if typ is None:
16
+ return SafeLoadAnnotation()
17
+ return Annotated[typ, SafeLoadAnnotation()]
18
+
19
+ def __class_getitem__(cls, item):
20
+ return Annotated[item, SafeLoadAnnotation()]
21
+
22
+
23
+ SafeLoad = _SafeLoadType
24
+
25
+
26
+ if TYPE_CHECKING:
27
+ SafeLoad: TypeAlias = Annotated[T, SafeLoadAnnotation()] # pragma: no cover
@@ -1,21 +1,39 @@
1
+ import logging
2
+
1
3
  import redis.asyncio as redis_async
4
+ from rapyer.base import REDIS_MODELS
5
+ from rapyer.scripts import register_scripts
2
6
  from redis import ResponseError
3
7
  from redis.asyncio.client import Redis
4
8
 
5
- from rapyer.base import REDIS_MODELS
6
-
7
9
 
8
10
  async def init_rapyer(
9
- redis: str | Redis = None, ttl: int = None, override_old_idx: bool = True
11
+ redis: str | Redis = None,
12
+ ttl: int = None,
13
+ override_old_idx: bool = True,
14
+ prefer_normal_json_dump: bool = None,
15
+ logger: logging.Logger = None,
10
16
  ):
17
+ if logger is not None:
18
+ rapyer_logger = logging.getLogger("rapyer")
19
+ rapyer_logger.setLevel(logger.level)
20
+ rapyer_logger.handlers.clear()
21
+ for handler in logger.handlers:
22
+ rapyer_logger.addHandler(handler)
23
+
11
24
  if isinstance(redis, str):
12
25
  redis = redis_async.from_url(redis, decode_responses=True, max_connections=20)
13
26
 
27
+ if redis is not None:
28
+ await register_scripts(redis)
29
+
14
30
  for model in REDIS_MODELS:
15
31
  if redis is not None:
16
32
  model.Meta.redis = redis
17
33
  if ttl is not None:
18
34
  model.Meta.ttl = ttl
35
+ if prefer_normal_json_dump is not None:
36
+ model.Meta.prefer_normal_json_dump = prefer_normal_json_dump
19
37
 
20
38
  # Initialize model fields
21
39
  model.init_class()
@@ -0,0 +1,86 @@
1
+ from rapyer.errors import ScriptsNotInitializedError
2
+
3
+ REMOVE_RANGE_SCRIPT_NAME = "remove_range"
4
+
5
+ _REMOVE_RANGE_SCRIPT_TEMPLATE = """
6
+ local key = KEYS[1]
7
+ local path = ARGV[1]
8
+ local start_idx = tonumber(ARGV[2])
9
+ local end_idx = tonumber(ARGV[3])
10
+
11
+ local arr_json = redis.call('JSON.GET', key, path)
12
+ if not arr_json or arr_json == 'null' then
13
+ return nil
14
+ end
15
+
16
+ {extract_array}
17
+ local n = #arr
18
+
19
+ if start_idx < 0 then start_idx = n + start_idx end
20
+ if end_idx < 0 then end_idx = n + end_idx end
21
+ if start_idx < 0 then start_idx = 0 end
22
+ if end_idx < 0 then end_idx = 0 end
23
+ if end_idx > n then end_idx = n end
24
+ if start_idx >= n or start_idx >= end_idx then return true end
25
+
26
+ local new_arr = {{}}
27
+ local j = 1
28
+
29
+ for i = 1, start_idx do
30
+ new_arr[j] = arr[i]
31
+ j = j + 1
32
+ end
33
+
34
+ for i = end_idx + 1, n do
35
+ new_arr[j] = arr[i]
36
+ j = j + 1
37
+ end
38
+
39
+ local encoded = j == 1 and '[]' or cjson.encode(new_arr)
40
+ redis.call('JSON.SET', key, path, encoded)
41
+ return true
42
+ """
43
+
44
+ _EXTRACT_ARRAY_REDIS = "local arr = cjson.decode(arr_json)[1]"
45
+ _EXTRACT_ARRAY_FAKEREDIS = "local arr = cjson.decode(arr_json)"
46
+
47
+ REMOVE_RANGE_SCRIPT = _REMOVE_RANGE_SCRIPT_TEMPLATE.format(
48
+ extract_array=_EXTRACT_ARRAY_REDIS
49
+ )
50
+ REMOVE_RANGE_SCRIPT_FAKEREDIS = _REMOVE_RANGE_SCRIPT_TEMPLATE.format(
51
+ extract_array=_EXTRACT_ARRAY_FAKEREDIS
52
+ )
53
+
54
+ SCRIPTS: dict[str, str] = {
55
+ REMOVE_RANGE_SCRIPT_NAME: REMOVE_RANGE_SCRIPT,
56
+ }
57
+
58
+ SCRIPTS_FAKEREDIS: dict[str, str] = {
59
+ REMOVE_RANGE_SCRIPT_NAME: REMOVE_RANGE_SCRIPT_FAKEREDIS,
60
+ }
61
+
62
+ _REGISTERED_SCRIPT_SHAS: dict[str, str] = {}
63
+
64
+
65
+ def is_fakeredis(client) -> bool:
66
+ return "fakeredis" in type(client).__module__
67
+
68
+
69
+ async def register_scripts(redis_client):
70
+ scripts = SCRIPTS_FAKEREDIS if is_fakeredis(redis_client) else SCRIPTS
71
+ for name, script_text in scripts.items():
72
+ sha = await redis_client.script_load(script_text)
73
+ _REGISTERED_SCRIPT_SHAS[name] = sha
74
+
75
+
76
+ def run_sha(pipeline, script_name: str, keys: int, *args):
77
+ sha = _REGISTERED_SCRIPT_SHAS.get(script_name)
78
+ if sha is None:
79
+ raise ScriptsNotInitializedError(
80
+ f"Script '{script_name}' not loaded. Did you forget to call init_rapyer()?"
81
+ )
82
+ pipeline.evalsha(sha, keys, *args)
83
+
84
+
85
+ async def handle_noscript_error(redis_client):
86
+ await register_scripts(redis_client)
@@ -1,5 +1,6 @@
1
1
  import abc
2
2
  import base64
3
+ import logging
3
4
  import pickle
4
5
  from abc import ABC
5
6
  from typing import get_args, Any, TypeVar, Generic
@@ -7,14 +8,18 @@ from typing import get_args, Any, TypeVar, Generic
7
8
  from pydantic import GetCoreSchemaHandler, TypeAdapter
8
9
  from pydantic_core import core_schema
9
10
  from pydantic_core.core_schema import ValidationInfo, CoreSchema, SerializationInfo
10
- from redis.commands.search.field import TextField
11
- from typing_extensions import deprecated
12
-
13
11
  from rapyer.context import _context_var
12
+ from rapyer.errors.base import CantSerializeRedisValueError
14
13
  from rapyer.typing_support import Self
15
14
  from rapyer.utils.redis import refresh_ttl_if_needed
15
+ from redis.commands.search.field import TextField
16
+ from typing_extensions import deprecated
17
+
18
+ logger = logging.getLogger("rapyer")
16
19
 
17
20
  REDIS_DUMP_FLAG_NAME = "__rapyer_dumped__"
21
+ FAILED_FIELDS_KEY = "__rapyer_failed_fields__"
22
+ SKIP_SENTINEL = object()
18
23
 
19
24
 
20
25
  class RedisType(ABC):
@@ -129,6 +134,8 @@ T = TypeVar("T")
129
134
 
130
135
 
131
136
  class GenericRedisType(RedisType, Generic[T], ABC):
137
+ safe_load: bool = False
138
+
132
139
  def __init__(self, *args, **kwargs):
133
140
  super().__init__(*args, **kwargs)
134
141
  for key, val in self.iterate_items():
@@ -139,6 +146,18 @@ class GenericRedisType(RedisType, Generic[T], ABC):
139
146
  args = get_args(type_)
140
147
  return args[0] if args else Any
141
148
 
149
+ @classmethod
150
+ def try_deserialize_item(cls, item, identifier):
151
+ try:
152
+ return cls.deserialize_unknown(item)
153
+ except Exception as e:
154
+ if cls.safe_load:
155
+ logger.warning(
156
+ "SafeLoad: Failed to deserialize item at '%s'.", identifier
157
+ )
158
+ return SKIP_SENTINEL
159
+ raise CantSerializeRedisValueError() from e
160
+
142
161
  @abc.abstractmethod
143
162
  def iterate_items(self):
144
163
  pass # pragma: no cover
@@ -2,7 +2,6 @@ from typing import TypeAlias, TYPE_CHECKING
2
2
 
3
3
  from pydantic_core import core_schema
4
4
  from pydantic_core.core_schema import ValidationInfo, SerializationInfo
5
-
6
5
  from rapyer.types.base import RedisType, REDIS_DUMP_FLAG_NAME
7
6
 
8
7
 
@@ -1,16 +1,21 @@
1
- from typing import Any, get_origin
1
+ from typing import get_origin
2
2
 
3
3
  from pydantic import BaseModel, PrivateAttr, TypeAdapter
4
-
5
4
  from rapyer.types.base import RedisType
6
5
  from rapyer.utils.annotation import TypeConverter, DYNAMIC_CLASS_DOC
7
6
  from rapyer.utils.pythonic import safe_issubclass
8
7
 
9
8
 
10
9
  class RedisConverter(TypeConverter):
11
- def __init__(self, supported_types: dict[type, type], field_name: str):
10
+ def __init__(
11
+ self,
12
+ supported_types: dict[type, type],
13
+ field_name: str,
14
+ safe_load: bool = False,
15
+ ):
12
16
  self.supported_types = supported_types
13
17
  self.field_name = field_name
18
+ self.safe_load = safe_load
14
19
 
15
20
  def is_redis_type(self, type_to_check: type) -> bool:
16
21
  origin = get_origin(type_to_check) or type_to_check
@@ -62,6 +67,7 @@ class RedisConverter(TypeConverter):
62
67
  dict(
63
68
  field_name=self.field_name,
64
69
  original_type=original_type,
70
+ safe_load=self.safe_load,
65
71
  __doc__=DYNAMIC_CLASS_DOC,
66
72
  ),
67
73
  )
@@ -86,6 +92,7 @@ class RedisConverter(TypeConverter):
86
92
  dict(
87
93
  field_name=self.field_name,
88
94
  original_type=original_type,
95
+ safe_load=self.safe_load,
89
96
  __doc__=DYNAMIC_CLASS_DOC,
90
97
  ),
91
98
  )
@@ -3,9 +3,8 @@ from typing import TYPE_CHECKING
3
3
 
4
4
  from pydantic_core import core_schema
5
5
  from pydantic_core.core_schema import ValidationInfo, SerializationInfo
6
- from redis.commands.search.field import NumericField
7
-
8
6
  from rapyer.types.base import RedisType, REDIS_DUMP_FLAG_NAME
7
+ from redis.commands.search.field import NumericField
9
8
 
10
9
 
11
10
  class RedisDatetime(datetime, RedisType):
@@ -1,8 +1,12 @@
1
1
  from typing import TypeVar, Generic, get_args, Any, TypeAlias, TYPE_CHECKING
2
2
 
3
3
  from pydantic_core import core_schema
4
-
5
- from rapyer.types.base import GenericRedisType, RedisType, REDIS_DUMP_FLAG_NAME
4
+ from rapyer.types.base import (
5
+ GenericRedisType,
6
+ RedisType,
7
+ REDIS_DUMP_FLAG_NAME,
8
+ SKIP_SENTINEL,
9
+ )
6
10
  from rapyer.utils.redis import refresh_ttl_if_needed
7
11
  from rapyer.utils.redis import update_keys_in_pipeline
8
12
 
@@ -239,9 +243,15 @@ class RedisDict(dict[str, T], GenericRedisType, Generic[T]):
239
243
  def full_deserializer(cls, value: dict, info: core_schema.ValidationInfo):
240
244
  ctx = info.context or {}
241
245
  should_serialize_redis = ctx.get(REDIS_DUMP_FLAG_NAME)
246
+
247
+ if not should_serialize_redis:
248
+ return value
249
+
242
250
  return {
243
- key: cls.deserialize_unknown(item) if should_serialize_redis else item
251
+ key: deserialized
244
252
  for key, item in value.items()
253
+ if (deserialized := cls.try_deserialize_item(item, f"key '{key}'"))
254
+ is not SKIP_SENTINEL
245
255
  }
246
256
 
247
257
  @classmethod
@@ -1,9 +1,8 @@
1
1
  from typing import TypeAlias, TYPE_CHECKING
2
2
 
3
- from redis.commands.search.field import NumericField
4
-
5
3
  from rapyer.types.base import RedisType
6
4
  from rapyer.utils.redis import refresh_ttl_if_needed
5
+ from redis.commands.search.field import NumericField
7
6
 
8
7
 
9
8
  class RedisFloat(float, RedisType):
@@ -1,10 +1,9 @@
1
1
  from typing import TypeAlias, TYPE_CHECKING
2
2
 
3
- from redis.commands.search.field import NumericField
4
- from typing_extensions import deprecated
5
-
6
3
  from rapyer.types.base import RedisType
7
4
  from rapyer.utils.redis import refresh_ttl_if_needed
5
+ from redis.commands.search.field import NumericField
6
+ from typing_extensions import deprecated
8
7
 
9
8
 
10
9
  class RedisInt(int, RedisType):
@@ -1,12 +1,20 @@
1
1
  import json
2
+ import logging
2
3
  from typing import TypeVar, TYPE_CHECKING
3
4
 
4
5
  from pydantic_core import core_schema
5
6
  from pydantic_core.core_schema import ValidationInfo, SerializationInfo
7
+ from rapyer.scripts import run_sha, REMOVE_RANGE_SCRIPT_NAME
8
+ from rapyer.types.base import (
9
+ GenericRedisType,
10
+ RedisType,
11
+ REDIS_DUMP_FLAG_NAME,
12
+ SKIP_SENTINEL,
13
+ )
14
+ from rapyer.utils.redis import refresh_ttl_if_needed
6
15
  from typing_extensions import TypeAlias
7
16
 
8
- from rapyer.types.base import GenericRedisType, RedisType, REDIS_DUMP_FLAG_NAME
9
- from rapyer.utils.redis import refresh_ttl_if_needed
17
+ logger = logging.getLogger("rapyer")
10
18
 
11
19
  T = TypeVar("T")
12
20
 
@@ -66,6 +74,24 @@ class RedisList(list, GenericRedisType[T]):
66
74
  self.pipeline.json().set(self.key, self.json_path, [])
67
75
  return super().clear()
68
76
 
77
+ def remove_range(self, start: int, end: int):
78
+ if self.pipeline:
79
+ run_sha(
80
+ self.pipeline,
81
+ REMOVE_RANGE_SCRIPT_NAME,
82
+ 1,
83
+ self.key,
84
+ self.json_path,
85
+ start,
86
+ end,
87
+ )
88
+ del self[start:end]
89
+ else:
90
+ logger.warning(
91
+ "remove_range() called without a pipeline context. "
92
+ "No changes were made. Use 'async with model.apipeline():' to execute."
93
+ )
94
+
69
95
  async def aappend(self, __object):
70
96
  self.append(__object)
71
97
 
@@ -162,8 +188,14 @@ class RedisList(list, GenericRedisType[T]):
162
188
  ctx = info.context or {}
163
189
  is_redis_data = ctx.get(REDIS_DUMP_FLAG_NAME)
164
190
 
191
+ if not is_redis_data:
192
+ return value
193
+
165
194
  return [
166
- cls.deserialize_unknown(item) if is_redis_data else item for item in value
195
+ deserialized
196
+ for idx, item in enumerate(value)
197
+ if (deserialized := cls.try_deserialize_item(item, f"index {idx}"))
198
+ is not SKIP_SENTINEL
167
199
  ]
168
200
 
169
201
  @classmethod
@@ -1,7 +1,8 @@
1
- from typing import get_origin, ClassVar
1
+ from typing import get_origin, ClassVar, Any
2
2
 
3
- from pydantic import BaseModel
3
+ from pydantic import BaseModel, TypeAdapter
4
4
  from pydantic.fields import FieldInfo
5
+ from pydantic_core import PydanticUndefined
5
6
 
6
7
 
7
8
  def _collect_annotations_recursive(
@@ -62,3 +63,25 @@ def is_redis_field(field_name, field_annotation):
62
63
  or field_name.endswith("_")
63
64
  or get_origin(field_annotation) is ClassVar
64
65
  )
66
+
67
+
68
+ def is_field_default_has_value(field_default):
69
+ return field_default is not PydanticUndefined and field_default is not None
70
+
71
+
72
+ def is_type_json_serializable(typ: type, test_value: Any) -> bool:
73
+ try:
74
+ adapter = TypeAdapter(typ)
75
+ if isinstance(test_value, FieldInfo):
76
+ if is_field_default_has_value(test_value.default):
77
+ test_value = test_value.default
78
+ elif is_field_default_has_value(test_value.default_factory):
79
+ test_value = test_value.default_factory()
80
+ else:
81
+ return False
82
+ if test_value is None:
83
+ return False
84
+ adapter.dump_python(test_value, mode="json")
85
+ return True
86
+ except Exception:
87
+ return False
@@ -1,8 +0,0 @@
1
- from rapyer.errors.base import (
2
- BadFilterError,
3
- FindError,
4
- RapyerError,
5
- UnsupportedIndexedFieldError,
6
- )
7
-
8
- __all__ = ["BadFilterError", "FindError", "RapyerError", "UnsupportedIndexedFieldError"]
@@ -1,4 +0,0 @@
1
- from rapyer.fields.index import Index
2
- from rapyer.fields.key import Key
3
-
4
- __all__ = ["Key", "Index"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes