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.
- {rapyer-1.1.4 → rapyer-1.1.6}/PKG-INFO +6 -1
- {rapyer-1.1.4 → rapyer-1.1.6}/pyproject.toml +12 -5
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/base.py +199 -51
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/config.py +6 -1
- rapyer-1.1.6/rapyer/errors/__init__.py +17 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/errors/base.py +12 -0
- rapyer-1.1.6/rapyer/fields/__init__.py +5 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/fields/expression.py +0 -1
- rapyer-1.1.6/rapyer/fields/safe_load.py +27 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/init.py +21 -3
- rapyer-1.1.6/rapyer/scripts.py +86 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/base.py +22 -3
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/byte.py +0 -1
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/convert.py +10 -3
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/datetime.py +1 -2
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/dct.py +13 -3
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/float.py +1 -2
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/integer.py +2 -3
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/lst.py +35 -3
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/utils/fields.py +25 -2
- rapyer-1.1.4/rapyer/errors/__init__.py +0 -8
- rapyer-1.1.4/rapyer/fields/__init__.py +0 -4
- {rapyer-1.1.4 → rapyer-1.1.6}/README.md +0 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/__init__.py +0 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/context.py +0 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/fields/index.py +0 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/fields/key.py +0 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/links.py +0 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/__init__.py +0 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/init.py +0 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/types/string.py +0 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/typing_support.py +0 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/utils/__init__.py +0 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/utils/annotation.py +0 -0
- {rapyer-1.1.4 → rapyer-1.1.6}/rapyer/utils/pythonic.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, *
|
|
368
|
-
#
|
|
369
|
-
if
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
392
|
-
|
|
487
|
+
if not targeted_keys:
|
|
488
|
+
return []
|
|
393
489
|
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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,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,
|
|
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
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
from typing import
|
|
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__(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|