rapyer 1.1.5__tar.gz → 1.1.7__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.5 → rapyer-1.1.7}/PKG-INFO +1 -1
- {rapyer-1.1.5 → rapyer-1.1.7}/pyproject.toml +6 -1
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/__init__.py +2 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/base.py +150 -44
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/config.py +2 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/errors/__init__.py +6 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/errors/base.py +8 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/init.py +17 -1
- rapyer-1.1.7/rapyer/scripts/__init__.py +41 -0
- rapyer-1.1.7/rapyer/scripts/constants.py +10 -0
- rapyer-1.1.7/rapyer/scripts/loader.py +34 -0
- rapyer-1.1.7/rapyer/scripts/lua/datetime/__init__.py +0 -0
- rapyer-1.1.7/rapyer/scripts/lua/datetime/add.lua +80 -0
- rapyer-1.1.7/rapyer/scripts/lua/list/__init__.py +0 -0
- rapyer-1.1.7/rapyer/scripts/lua/list/remove_range.lua +36 -0
- rapyer-1.1.7/rapyer/scripts/lua/numeric/__init__.py +0 -0
- rapyer-1.1.7/rapyer/scripts/lua/numeric/floordiv.lua +13 -0
- rapyer-1.1.7/rapyer/scripts/lua/numeric/mod.lua +13 -0
- rapyer-1.1.7/rapyer/scripts/lua/numeric/mul.lua +13 -0
- rapyer-1.1.7/rapyer/scripts/lua/numeric/pow.lua +13 -0
- rapyer-1.1.7/rapyer/scripts/lua/numeric/pow_float.lua +13 -0
- rapyer-1.1.7/rapyer/scripts/lua/numeric/truediv.lua +13 -0
- rapyer-1.1.7/rapyer/scripts/lua/string/__init__.py +0 -0
- rapyer-1.1.7/rapyer/scripts/lua/string/append.lua +13 -0
- rapyer-1.1.7/rapyer/scripts/lua/string/mul.lua +13 -0
- rapyer-1.1.7/rapyer/scripts/registry.py +65 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/base.py +5 -4
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/datetime.py +56 -1
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/dct.py +12 -20
- rapyer-1.1.7/rapyer/types/float.py +100 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/integer.py +29 -12
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/lst.py +25 -20
- rapyer-1.1.7/rapyer/types/string.py +41 -0
- rapyer-1.1.7/rapyer/utils/__init__.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/utils/redis.py +0 -7
- rapyer-1.1.5/rapyer/scripts.py +0 -86
- rapyer-1.1.5/rapyer/types/float.py +0 -52
- rapyer-1.1.5/rapyer/types/string.py +0 -20
- {rapyer-1.1.5 → rapyer-1.1.7}/README.md +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/context.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/fields/__init__.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/fields/expression.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/fields/index.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/fields/key.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/fields/safe_load.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/links.py +0 -0
- {rapyer-1.1.5/rapyer/utils → rapyer-1.1.7/rapyer/scripts/lua}/__init__.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/__init__.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/byte.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/convert.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/init.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/typing_support.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/utils/annotation.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/utils/fields.py +0 -0
- {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/utils/pythonic.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.7
|
|
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
|
|
@@ -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.7"
|
|
8
8
|
description = "Pydantic models with Redis as the backend"
|
|
9
9
|
authors = [{name = "YedidyaHKfir", email = "yedidyakfir@gmail.com"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -69,12 +69,17 @@ Repository = "https://github.com/imaginary-cherry/rapyer"
|
|
|
69
69
|
|
|
70
70
|
[tool.poetry]
|
|
71
71
|
packages = [{include = "rapyer"}]
|
|
72
|
+
include = ["rapyer/scripts/lua/**/*.lua"]
|
|
72
73
|
|
|
73
74
|
|
|
74
75
|
[tool.poetry.group.dev.dependencies]
|
|
75
76
|
black = "^25.9.0"
|
|
76
77
|
mypy = "^1.0.0"
|
|
77
78
|
|
|
79
|
+
[tool.bandit]
|
|
80
|
+
exclude_dirs = ["tests"]
|
|
81
|
+
skips = ["B101"] # assert_used - valid in test files
|
|
82
|
+
|
|
78
83
|
[tool.coverage.run]
|
|
79
84
|
source = ["rapyer"]
|
|
80
85
|
omit = ["*/tests/*", "*/test_*"]
|
|
@@ -6,7 +6,7 @@ import logging
|
|
|
6
6
|
import pickle
|
|
7
7
|
import uuid
|
|
8
8
|
from contextlib import AbstractAsyncContextManager
|
|
9
|
-
from typing import ClassVar, Any, get_origin
|
|
9
|
+
from typing import ClassVar, Any, get_origin, Optional
|
|
10
10
|
|
|
11
11
|
from pydantic import (
|
|
12
12
|
BaseModel,
|
|
@@ -15,11 +15,12 @@ from pydantic import (
|
|
|
15
15
|
model_validator,
|
|
16
16
|
field_serializer,
|
|
17
17
|
field_validator,
|
|
18
|
+
ValidationError,
|
|
18
19
|
)
|
|
19
20
|
from pydantic_core.core_schema import FieldSerializationInfo, ValidationInfo
|
|
20
21
|
from redis.commands.search.index_definition import IndexDefinition, IndexType
|
|
21
22
|
from redis.commands.search.query import Query
|
|
22
|
-
from redis.exceptions import NoScriptError
|
|
23
|
+
from redis.exceptions import NoScriptError, ResponseError
|
|
23
24
|
from typing_extensions import deprecated
|
|
24
25
|
|
|
25
26
|
from rapyer.config import RedisConfig
|
|
@@ -29,6 +30,7 @@ from rapyer.errors.base import (
|
|
|
29
30
|
PersistentNoScriptError,
|
|
30
31
|
UnsupportedIndexedFieldError,
|
|
31
32
|
CantSerializeRedisValueError,
|
|
33
|
+
RapyerModelDoesntExistError,
|
|
32
34
|
)
|
|
33
35
|
from rapyer.fields.expression import ExpressionField, AtomicField, Expression
|
|
34
36
|
from rapyer.fields.index import IndexAnnotation
|
|
@@ -54,7 +56,6 @@ from rapyer.utils.pythonic import safe_issubclass
|
|
|
54
56
|
from rapyer.utils.redis import (
|
|
55
57
|
acquire_lock,
|
|
56
58
|
update_keys_in_pipeline,
|
|
57
|
-
refresh_ttl_if_needed,
|
|
58
59
|
)
|
|
59
60
|
|
|
60
61
|
logger = logging.getLogger("rapyer")
|
|
@@ -169,6 +170,14 @@ class AtomicRedisModel(BaseModel):
|
|
|
169
170
|
field_path = self.field_path
|
|
170
171
|
return f"${field_path}" if field_path else "$"
|
|
171
172
|
|
|
173
|
+
@classmethod
|
|
174
|
+
def should_refresh(cls):
|
|
175
|
+
return cls.Meta.refresh_ttl and cls.Meta.ttl is not None
|
|
176
|
+
|
|
177
|
+
async def refresh_ttl_if_needed(self):
|
|
178
|
+
if self.should_refresh():
|
|
179
|
+
await self.Meta.redis.expire(self.key, self.Meta.ttl)
|
|
180
|
+
|
|
172
181
|
@classmethod
|
|
173
182
|
def redis_schema(cls, redis_name: str = ""):
|
|
174
183
|
fields = []
|
|
@@ -395,9 +404,12 @@ class AtomicRedisModel(BaseModel):
|
|
|
395
404
|
async with self.Meta.redis.pipeline() as pipe:
|
|
396
405
|
update_keys_in_pipeline(pipe, self.key, **json_path_kwargs)
|
|
397
406
|
await pipe.execute()
|
|
398
|
-
await refresh_ttl_if_needed(
|
|
399
|
-
|
|
400
|
-
|
|
407
|
+
await self.refresh_ttl_if_needed()
|
|
408
|
+
|
|
409
|
+
async def aset_ttl(self, ttl: int) -> None:
|
|
410
|
+
if self.is_inner_model():
|
|
411
|
+
raise RuntimeError("Can only set TTL from top level model")
|
|
412
|
+
await self.Meta.redis.expire(self.key, ttl)
|
|
401
413
|
|
|
402
414
|
@classmethod
|
|
403
415
|
@deprecated(
|
|
@@ -419,9 +431,8 @@ class AtomicRedisModel(BaseModel):
|
|
|
419
431
|
instance = cls.model_validate(model_dump, context=context)
|
|
420
432
|
instance.key = key
|
|
421
433
|
instance._failed_fields = context.get(FAILED_FIELDS_KEY, set())
|
|
422
|
-
|
|
423
|
-
cls.Meta.redis
|
|
424
|
-
)
|
|
434
|
+
if cls.should_refresh():
|
|
435
|
+
await cls.Meta.redis.expire(key, cls.Meta.ttl)
|
|
425
436
|
return instance
|
|
426
437
|
|
|
427
438
|
@deprecated(
|
|
@@ -440,55 +451,83 @@ class AtomicRedisModel(BaseModel):
|
|
|
440
451
|
instance._pk = self._pk
|
|
441
452
|
instance._base_model_link = self._base_model_link
|
|
442
453
|
instance._failed_fields = context.get(FAILED_FIELDS_KEY, set())
|
|
443
|
-
await refresh_ttl_if_needed(
|
|
444
|
-
self.Meta.redis, self.key, self.Meta.ttl, self.Meta.refresh_ttl
|
|
445
|
-
)
|
|
454
|
+
await self.refresh_ttl_if_needed()
|
|
446
455
|
return instance
|
|
447
456
|
|
|
448
457
|
@classmethod
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
458
|
+
def create_redis_model(cls, model_dump: dict, key: str) -> Optional[Self]:
|
|
459
|
+
context = {REDIS_DUMP_FLAG_NAME: True, FAILED_FIELDS_KEY: set()}
|
|
460
|
+
try:
|
|
461
|
+
model = cls.model_validate(model_dump, context=context)
|
|
462
|
+
model.key = key
|
|
463
|
+
except ValidationError as exc:
|
|
464
|
+
logger.debug(
|
|
465
|
+
"Skipping key %s due to validation error during afind: %s",
|
|
466
|
+
key,
|
|
467
|
+
exc,
|
|
468
|
+
)
|
|
469
|
+
return None
|
|
470
|
+
model.key = key
|
|
471
|
+
model._failed_fields = context.get(FAILED_FIELDS_KEY, set())
|
|
472
|
+
return model
|
|
455
473
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
474
|
+
@classmethod
|
|
475
|
+
async def afind(cls, *args):
|
|
476
|
+
# Separate keys (str) from expressions (Expression)
|
|
477
|
+
provided_keys = [arg for arg in args if isinstance(arg, str)]
|
|
478
|
+
expressions = [arg for arg in args if isinstance(arg, Expression)]
|
|
479
|
+
raise_on_missing = bool(provided_keys)
|
|
480
|
+
|
|
481
|
+
if provided_keys and expressions:
|
|
482
|
+
logger.warning(
|
|
483
|
+
"afind called with both keys and expressions; expressions ignored"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if provided_keys:
|
|
487
|
+
# Case 1: Extract by keys
|
|
488
|
+
targeted_keys = [
|
|
489
|
+
k if ":" in k else f"{cls.class_key_initials()}:{k}"
|
|
490
|
+
for k in provided_keys
|
|
491
|
+
]
|
|
492
|
+
elif expressions:
|
|
493
|
+
# Case 2: Extract by expressions
|
|
460
494
|
combined_expression = functools.reduce(lambda a, b: a & b, expressions)
|
|
461
495
|
query_string = combined_expression.create_filter()
|
|
462
|
-
|
|
463
|
-
# Create a Query object
|
|
464
496
|
query = Query(query_string).no_content()
|
|
465
|
-
|
|
466
|
-
# Try to search using the index
|
|
467
497
|
index_name = cls.index_name()
|
|
468
498
|
search_result = await cls.Meta.redis.ft(index_name).search(query)
|
|
469
|
-
|
|
470
499
|
if not search_result.docs:
|
|
471
500
|
return []
|
|
501
|
+
targeted_keys = [doc.id for doc in search_result.docs]
|
|
502
|
+
else:
|
|
503
|
+
# Case 3: Extract all
|
|
504
|
+
targeted_keys = await cls.afind_keys()
|
|
505
|
+
|
|
506
|
+
if not targeted_keys:
|
|
507
|
+
return []
|
|
472
508
|
|
|
473
|
-
|
|
474
|
-
|
|
509
|
+
# Fetch the actual documents
|
|
510
|
+
models = await cls.Meta.redis.json().mget(keys=targeted_keys, path="$")
|
|
475
511
|
|
|
476
|
-
|
|
477
|
-
|
|
512
|
+
instances = []
|
|
513
|
+
for model, key in zip(models, targeted_keys):
|
|
514
|
+
if model is None:
|
|
515
|
+
if raise_on_missing:
|
|
516
|
+
raise KeyNotFound(f"{key} is missing in redis")
|
|
517
|
+
continue
|
|
518
|
+
if not cls.Meta.is_fake_redis:
|
|
519
|
+
model = model[0]
|
|
520
|
+
model = cls.create_redis_model(model, key)
|
|
521
|
+
if model is None:
|
|
522
|
+
continue
|
|
523
|
+
instances.append(model)
|
|
478
524
|
|
|
479
|
-
if cls.
|
|
525
|
+
if cls.should_refresh():
|
|
480
526
|
async with cls.Meta.redis.pipeline() as pipe:
|
|
481
|
-
for
|
|
482
|
-
pipe.expire(key, cls.Meta.ttl)
|
|
527
|
+
for model in instances:
|
|
528
|
+
pipe.expire(model.key, cls.Meta.ttl)
|
|
483
529
|
await pipe.execute()
|
|
484
530
|
|
|
485
|
-
instances = []
|
|
486
|
-
for model, key in zip(models, keys):
|
|
487
|
-
context = {REDIS_DUMP_FLAG_NAME: True, FAILED_FIELDS_KEY: set()}
|
|
488
|
-
model = cls.model_validate(model[0], context=context)
|
|
489
|
-
model.key = key
|
|
490
|
-
model._failed_fields = context.get(FAILED_FIELDS_KEY, set())
|
|
491
|
-
instances.append(model)
|
|
492
531
|
return instances
|
|
493
532
|
|
|
494
533
|
@classmethod
|
|
@@ -616,9 +655,21 @@ class AtomicRedisModel(BaseModel):
|
|
|
616
655
|
noscript_on_retry = False
|
|
617
656
|
|
|
618
657
|
try:
|
|
658
|
+
if self.should_refresh():
|
|
659
|
+
pipe.expire(self.key, self.Meta.ttl)
|
|
619
660
|
await pipe.execute()
|
|
620
661
|
except NoScriptError:
|
|
621
662
|
noscript_on_first_attempt = True
|
|
663
|
+
except ResponseError as exc:
|
|
664
|
+
if ignore_if_deleted:
|
|
665
|
+
logger.warning(
|
|
666
|
+
"Swallowed ResponseError during pipeline.execute() with "
|
|
667
|
+
"ignore_if_deleted=True for key %r: %s",
|
|
668
|
+
getattr(self, "key", None),
|
|
669
|
+
exc,
|
|
670
|
+
)
|
|
671
|
+
else:
|
|
672
|
+
raise
|
|
622
673
|
|
|
623
674
|
if noscript_on_first_attempt:
|
|
624
675
|
await handle_noscript_error(self.Meta.redis)
|
|
@@ -642,9 +693,6 @@ class AtomicRedisModel(BaseModel):
|
|
|
642
693
|
"This indicates a server-side problem with Redis."
|
|
643
694
|
)
|
|
644
695
|
|
|
645
|
-
await refresh_ttl_if_needed(
|
|
646
|
-
self.Meta.redis, self.key, self.Meta.ttl, self.Meta.refresh_ttl
|
|
647
|
-
)
|
|
648
696
|
_context_var.set(None)
|
|
649
697
|
_context_xx_pipe.set(False)
|
|
650
698
|
|
|
@@ -659,6 +707,16 @@ class AtomicRedisModel(BaseModel):
|
|
|
659
707
|
if isinstance(attr, RedisType):
|
|
660
708
|
attr._base_model_link = self
|
|
661
709
|
|
|
710
|
+
pipeline = _context_var.get()
|
|
711
|
+
if pipeline is not None:
|
|
712
|
+
serialized = self.model_dump(
|
|
713
|
+
mode="json",
|
|
714
|
+
context={REDIS_DUMP_FLAG_NAME: True},
|
|
715
|
+
include={name},
|
|
716
|
+
)
|
|
717
|
+
json_path = f"{self.json_path}.{name}"
|
|
718
|
+
pipeline.json().set(self.key, json_path, serialized[name])
|
|
719
|
+
|
|
662
720
|
def __eq__(self, other):
|
|
663
721
|
if not isinstance(other, BaseModel):
|
|
664
722
|
return False
|
|
@@ -702,6 +760,52 @@ async def aget(redis_key: str) -> AtomicRedisModel:
|
|
|
702
760
|
return await klass.aget(redis_key)
|
|
703
761
|
|
|
704
762
|
|
|
763
|
+
async def afind(*redis_keys: str, skip_missing: bool = False) -> list[AtomicRedisModel]:
|
|
764
|
+
if not redis_keys:
|
|
765
|
+
return []
|
|
766
|
+
|
|
767
|
+
redis_model_mapping = {klass.__name__: klass for klass in REDIS_MODELS}
|
|
768
|
+
|
|
769
|
+
key_to_class: dict[str, type[AtomicRedisModel]] = {}
|
|
770
|
+
for key in redis_keys:
|
|
771
|
+
class_name = key.split(":")[0]
|
|
772
|
+
if class_name not in redis_model_mapping:
|
|
773
|
+
raise RapyerModelDoesntExistError(
|
|
774
|
+
class_name, f"Unknown model class: {class_name}"
|
|
775
|
+
)
|
|
776
|
+
key_to_class[key] = redis_model_mapping[class_name]
|
|
777
|
+
|
|
778
|
+
models_data = await AtomicRedisModel.Meta.redis.json().mget(
|
|
779
|
+
keys=redis_keys, path="$"
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
instances = []
|
|
783
|
+
instances_by_class: dict[type[AtomicRedisModel], list[AtomicRedisModel]] = {}
|
|
784
|
+
|
|
785
|
+
for data, key in zip(models_data, redis_keys):
|
|
786
|
+
if data is None:
|
|
787
|
+
if not skip_missing:
|
|
788
|
+
raise KeyNotFound(f"{key} is missing in redis")
|
|
789
|
+
continue
|
|
790
|
+
klass = key_to_class[key]
|
|
791
|
+
if not klass.Meta.is_fake_redis:
|
|
792
|
+
data = data[0]
|
|
793
|
+
model = klass.create_redis_model(data, key)
|
|
794
|
+
if model is None:
|
|
795
|
+
continue
|
|
796
|
+
instances.append(model)
|
|
797
|
+
instances_by_class.setdefault(klass, []).append(model)
|
|
798
|
+
|
|
799
|
+
async with AtomicRedisModel.Meta.redis.pipeline() as pipe:
|
|
800
|
+
for klass, class_instances in instances_by_class.items():
|
|
801
|
+
if klass.should_refresh():
|
|
802
|
+
for model in class_instances:
|
|
803
|
+
pipe.expire(model.key, klass.Meta.ttl)
|
|
804
|
+
await pipe.execute()
|
|
805
|
+
|
|
806
|
+
return instances
|
|
807
|
+
|
|
808
|
+
|
|
705
809
|
def find_redis_models() -> list[type[AtomicRedisModel]]:
|
|
706
810
|
return REDIS_MODELS
|
|
707
811
|
|
|
@@ -710,6 +814,8 @@ async def ainsert(*models: Unpack[AtomicRedisModel]) -> list[AtomicRedisModel]:
|
|
|
710
814
|
async with AtomicRedisModel.Meta.redis.pipeline() as pipe:
|
|
711
815
|
for model in models:
|
|
712
816
|
pipe.json().set(model.key, model.json_path, model.redis_dump())
|
|
817
|
+
if model.Meta.ttl is not None:
|
|
818
|
+
pipe.expire(model.key, model.Meta.ttl)
|
|
713
819
|
await pipe.execute()
|
|
714
820
|
return models
|
|
715
821
|
|
|
@@ -28,3 +28,5 @@ class RedisConfig:
|
|
|
28
28
|
safe_load_all: bool = False
|
|
29
29
|
# If True, use JSON serialization for fields that support it instead of pickle
|
|
30
30
|
prefer_normal_json_dump: bool = False
|
|
31
|
+
# Set to True when using FakeRedis to normalize JSON responses
|
|
32
|
+
is_fake_redis: bool = False
|
|
@@ -5,6 +5,9 @@ from rapyer.errors.base import (
|
|
|
5
5
|
RapyerError,
|
|
6
6
|
ScriptsNotInitializedError,
|
|
7
7
|
UnsupportedIndexedFieldError,
|
|
8
|
+
RapyerModelDoesntExistError,
|
|
9
|
+
CantSerializeRedisValueError,
|
|
10
|
+
KeyNotFound,
|
|
8
11
|
)
|
|
9
12
|
|
|
10
13
|
__all__ = [
|
|
@@ -12,6 +15,9 @@ __all__ = [
|
|
|
12
15
|
"FindError",
|
|
13
16
|
"PersistentNoScriptError",
|
|
14
17
|
"RapyerError",
|
|
18
|
+
"KeyNotFound",
|
|
15
19
|
"ScriptsNotInitializedError",
|
|
16
20
|
"UnsupportedIndexedFieldError",
|
|
21
|
+
"RapyerModelDoesntExistError",
|
|
22
|
+
"CantSerializeRedisValueError",
|
|
17
23
|
]
|
|
@@ -10,6 +10,14 @@ class KeyNotFound(RapyerError):
|
|
|
10
10
|
pass
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
class RapyerModelDoesntExistError(RapyerError):
|
|
14
|
+
"""Raised when a model doesn't exist."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, model_name: str, *args):
|
|
17
|
+
super().__init__(*args)
|
|
18
|
+
self.model_name = model_name
|
|
19
|
+
|
|
20
|
+
|
|
13
21
|
class FindError(RapyerError):
|
|
14
22
|
"""Raised when a model cannot be found."""
|
|
15
23
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
1
3
|
import redis.asyncio as redis_async
|
|
2
4
|
from redis import ResponseError
|
|
3
5
|
from redis.asyncio.client import Redis
|
|
@@ -6,21 +8,35 @@ from rapyer.base import REDIS_MODELS
|
|
|
6
8
|
from rapyer.scripts import register_scripts
|
|
7
9
|
|
|
8
10
|
|
|
11
|
+
def is_fakeredis(client) -> bool:
|
|
12
|
+
return "fakeredis" in type(client).__module__
|
|
13
|
+
|
|
14
|
+
|
|
9
15
|
async def init_rapyer(
|
|
10
16
|
redis: str | Redis = None,
|
|
11
17
|
ttl: int = None,
|
|
12
18
|
override_old_idx: bool = True,
|
|
13
19
|
prefer_normal_json_dump: bool = None,
|
|
20
|
+
logger: logging.Logger = None,
|
|
14
21
|
):
|
|
22
|
+
if logger is not None:
|
|
23
|
+
rapyer_logger = logging.getLogger("rapyer")
|
|
24
|
+
rapyer_logger.setLevel(logger.level)
|
|
25
|
+
rapyer_logger.handlers.clear()
|
|
26
|
+
for handler in logger.handlers:
|
|
27
|
+
rapyer_logger.addHandler(handler)
|
|
28
|
+
|
|
15
29
|
if isinstance(redis, str):
|
|
16
30
|
redis = redis_async.from_url(redis, decode_responses=True, max_connections=20)
|
|
17
31
|
|
|
32
|
+
is_fake_redis = is_fakeredis(redis)
|
|
18
33
|
if redis is not None:
|
|
19
|
-
await register_scripts(redis)
|
|
34
|
+
await register_scripts(redis, is_fake_redis)
|
|
20
35
|
|
|
21
36
|
for model in REDIS_MODELS:
|
|
22
37
|
if redis is not None:
|
|
23
38
|
model.Meta.redis = redis
|
|
39
|
+
model.Meta.is_fake_redis = is_fake_redis
|
|
24
40
|
if ttl is not None:
|
|
25
41
|
model.Meta.ttl = ttl
|
|
26
42
|
if prefer_normal_json_dump is not None:
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from rapyer.scripts.constants import (
|
|
2
|
+
DATETIME_ADD_SCRIPT_NAME,
|
|
3
|
+
NUM_FLOORDIV_SCRIPT_NAME,
|
|
4
|
+
NUM_MOD_SCRIPT_NAME,
|
|
5
|
+
NUM_MUL_SCRIPT_NAME,
|
|
6
|
+
NUM_POW_FLOAT_SCRIPT_NAME,
|
|
7
|
+
NUM_POW_SCRIPT_NAME,
|
|
8
|
+
NUM_TRUEDIV_SCRIPT_NAME,
|
|
9
|
+
REMOVE_RANGE_SCRIPT_NAME,
|
|
10
|
+
STR_APPEND_SCRIPT_NAME,
|
|
11
|
+
STR_MUL_SCRIPT_NAME,
|
|
12
|
+
)
|
|
13
|
+
from rapyer.scripts.registry import (
|
|
14
|
+
_REGISTERED_SCRIPT_SHAS,
|
|
15
|
+
get_scripts,
|
|
16
|
+
get_scripts_fakeredis,
|
|
17
|
+
handle_noscript_error,
|
|
18
|
+
register_scripts,
|
|
19
|
+
run_sha,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
SCRIPTS = get_scripts()
|
|
23
|
+
SCRIPTS_FAKEREDIS = get_scripts_fakeredis()
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"DATETIME_ADD_SCRIPT_NAME",
|
|
27
|
+
"NUM_FLOORDIV_SCRIPT_NAME",
|
|
28
|
+
"NUM_MOD_SCRIPT_NAME",
|
|
29
|
+
"NUM_MUL_SCRIPT_NAME",
|
|
30
|
+
"NUM_POW_FLOAT_SCRIPT_NAME",
|
|
31
|
+
"NUM_POW_SCRIPT_NAME",
|
|
32
|
+
"NUM_TRUEDIV_SCRIPT_NAME",
|
|
33
|
+
"REMOVE_RANGE_SCRIPT_NAME",
|
|
34
|
+
"SCRIPTS",
|
|
35
|
+
"SCRIPTS_FAKEREDIS",
|
|
36
|
+
"STR_APPEND_SCRIPT_NAME",
|
|
37
|
+
"STR_MUL_SCRIPT_NAME",
|
|
38
|
+
"handle_noscript_error",
|
|
39
|
+
"register_scripts",
|
|
40
|
+
"run_sha",
|
|
41
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
REMOVE_RANGE_SCRIPT_NAME = "remove_range"
|
|
2
|
+
NUM_MUL_SCRIPT_NAME = "num_mul"
|
|
3
|
+
NUM_FLOORDIV_SCRIPT_NAME = "num_floordiv"
|
|
4
|
+
NUM_MOD_SCRIPT_NAME = "num_mod"
|
|
5
|
+
NUM_POW_SCRIPT_NAME = "num_pow"
|
|
6
|
+
NUM_POW_FLOAT_SCRIPT_NAME = "num_pow_float"
|
|
7
|
+
NUM_TRUEDIV_SCRIPT_NAME = "num_truediv"
|
|
8
|
+
STR_APPEND_SCRIPT_NAME = "str_append"
|
|
9
|
+
STR_MUL_SCRIPT_NAME = "str_mul"
|
|
10
|
+
DATETIME_ADD_SCRIPT_NAME = "datetime_add"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
from importlib import resources
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
VARIANTS = {
|
|
6
|
+
"redis": {
|
|
7
|
+
"EXTRACT_ARRAY": "local arr = cjson.decode(arr_json)[1]",
|
|
8
|
+
"EXTRACT_VALUE": "local value = tonumber(cjson.decode(current_json)[1])",
|
|
9
|
+
"EXTRACT_STR": "local value = cjson.decode(current_json)[1]",
|
|
10
|
+
"EXTRACT_DATETIME": "local value = cjson.decode(current_json)[1]",
|
|
11
|
+
},
|
|
12
|
+
"fakeredis": {
|
|
13
|
+
"EXTRACT_ARRAY": "local arr = cjson.decode(arr_json)",
|
|
14
|
+
"EXTRACT_VALUE": "local value = tonumber(cjson.decode(current_json)[1])",
|
|
15
|
+
"EXTRACT_STR": "local value = cjson.decode(current_json)[1]",
|
|
16
|
+
"EXTRACT_DATETIME": "local value = cjson.decode(current_json)[1]",
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@lru_cache(maxsize=None)
|
|
22
|
+
def _load_template(category: str, name: str) -> str:
|
|
23
|
+
package = f"rapyer.scripts.lua.{category}"
|
|
24
|
+
filename = f"{name}.lua"
|
|
25
|
+
return resources.files(package).joinpath(filename).read_text()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_script(category: str, name: str, variant: str = "redis") -> str:
|
|
29
|
+
template = _load_template(category, name)
|
|
30
|
+
replacements = VARIANTS[variant]
|
|
31
|
+
result = template
|
|
32
|
+
for placeholder, value in replacements.items():
|
|
33
|
+
result = result.replace(f"--[[{placeholder}]]", value)
|
|
34
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
local key = KEYS[1]
|
|
2
|
+
local path = ARGV[1]
|
|
3
|
+
local delta_seconds = tonumber(ARGV[2])
|
|
4
|
+
|
|
5
|
+
local current_json = redis.call('JSON.GET', key, path)
|
|
6
|
+
if not current_json or current_json == 'null' then
|
|
7
|
+
return nil
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
--[[EXTRACT_DATETIME]]
|
|
11
|
+
local pattern = "(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)"
|
|
12
|
+
local y, m, d, h, mi, s = string.match(value, pattern)
|
|
13
|
+
if not y then return nil end
|
|
14
|
+
|
|
15
|
+
y = tonumber(y)
|
|
16
|
+
m = tonumber(m)
|
|
17
|
+
d = tonumber(d)
|
|
18
|
+
h = tonumber(h)
|
|
19
|
+
mi = tonumber(mi)
|
|
20
|
+
s = tonumber(s)
|
|
21
|
+
|
|
22
|
+
s = s + delta_seconds
|
|
23
|
+
|
|
24
|
+
while s >= 60 do
|
|
25
|
+
s = s - 60
|
|
26
|
+
mi = mi + 1
|
|
27
|
+
end
|
|
28
|
+
while s < 0 do
|
|
29
|
+
s = s + 60
|
|
30
|
+
mi = mi - 1
|
|
31
|
+
end
|
|
32
|
+
while mi >= 60 do
|
|
33
|
+
mi = mi - 60
|
|
34
|
+
h = h + 1
|
|
35
|
+
end
|
|
36
|
+
while mi < 0 do
|
|
37
|
+
mi = mi + 60
|
|
38
|
+
h = h - 1
|
|
39
|
+
end
|
|
40
|
+
while h >= 24 do
|
|
41
|
+
h = h - 24
|
|
42
|
+
d = d + 1
|
|
43
|
+
end
|
|
44
|
+
while h < 0 do
|
|
45
|
+
h = h + 24
|
|
46
|
+
d = d - 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
local function is_leap_year(year)
|
|
50
|
+
return (year % 4 == 0 and year % 100 ~= 0) or (year % 400 == 0)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
local function days_in_month(year, month)
|
|
54
|
+
local days = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
|
|
55
|
+
if month == 2 and is_leap_year(year) then
|
|
56
|
+
return 29
|
|
57
|
+
end
|
|
58
|
+
return days[month]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
while d > days_in_month(y, m) do
|
|
62
|
+
d = d - days_in_month(y, m)
|
|
63
|
+
m = m + 1
|
|
64
|
+
if m > 12 then
|
|
65
|
+
m = 1
|
|
66
|
+
y = y + 1
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
while d < 1 do
|
|
70
|
+
m = m - 1
|
|
71
|
+
if m < 1 then
|
|
72
|
+
m = 12
|
|
73
|
+
y = y - 1
|
|
74
|
+
end
|
|
75
|
+
d = d + days_in_month(y, m)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
local new_date = string.format("%04d-%02d-%02dT%02d:%02d:%02d", y, m, d, h, mi, s)
|
|
79
|
+
redis.call('JSON.SET', key, path, cjson.encode(new_date))
|
|
80
|
+
return new_date
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
local key = KEYS[1]
|
|
2
|
+
local path = ARGV[1]
|
|
3
|
+
local start_idx = tonumber(ARGV[2])
|
|
4
|
+
local end_idx = tonumber(ARGV[3])
|
|
5
|
+
|
|
6
|
+
local arr_json = redis.call('JSON.GET', key, path)
|
|
7
|
+
if not arr_json or arr_json == 'null' then
|
|
8
|
+
return nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
--[[EXTRACT_ARRAY]]
|
|
12
|
+
local n = #arr
|
|
13
|
+
|
|
14
|
+
if start_idx < 0 then start_idx = n + start_idx end
|
|
15
|
+
if end_idx < 0 then end_idx = n + end_idx end
|
|
16
|
+
if start_idx < 0 then start_idx = 0 end
|
|
17
|
+
if end_idx < 0 then end_idx = 0 end
|
|
18
|
+
if end_idx > n then end_idx = n end
|
|
19
|
+
if start_idx >= n or start_idx >= end_idx then return true end
|
|
20
|
+
|
|
21
|
+
local new_arr = {}
|
|
22
|
+
local j = 1
|
|
23
|
+
|
|
24
|
+
for i = 1, start_idx do
|
|
25
|
+
new_arr[j] = arr[i]
|
|
26
|
+
j = j + 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
for i = end_idx + 1, n do
|
|
30
|
+
new_arr[j] = arr[i]
|
|
31
|
+
j = j + 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
local encoded = j == 1 and '[]' or cjson.encode(new_arr)
|
|
35
|
+
redis.call('JSON.SET', key, path, encoded)
|
|
36
|
+
return true
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
local key = KEYS[1]
|
|
2
|
+
local path = ARGV[1]
|
|
3
|
+
local operand = tonumber(ARGV[2])
|
|
4
|
+
|
|
5
|
+
local current_json = redis.call('JSON.GET', key, path)
|
|
6
|
+
if not current_json or current_json == 'null' then
|
|
7
|
+
return nil
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
--[[EXTRACT_VALUE]]
|
|
11
|
+
local result = math.floor(value / operand)
|
|
12
|
+
redis.call('JSON.SET', key, path, result)
|
|
13
|
+
return result
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
local key = KEYS[1]
|
|
2
|
+
local path = ARGV[1]
|
|
3
|
+
local operand = tonumber(ARGV[2])
|
|
4
|
+
|
|
5
|
+
local current_json = redis.call('JSON.GET', key, path)
|
|
6
|
+
if not current_json or current_json == 'null' then
|
|
7
|
+
return nil
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
--[[EXTRACT_VALUE]]
|
|
11
|
+
local result = value % operand
|
|
12
|
+
redis.call('JSON.SET', key, path, result)
|
|
13
|
+
return result
|