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.
Files changed (55) hide show
  1. {rapyer-1.1.5 → rapyer-1.1.7}/PKG-INFO +1 -1
  2. {rapyer-1.1.5 → rapyer-1.1.7}/pyproject.toml +6 -1
  3. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/__init__.py +2 -0
  4. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/base.py +150 -44
  5. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/config.py +2 -0
  6. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/errors/__init__.py +6 -0
  7. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/errors/base.py +8 -0
  8. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/init.py +17 -1
  9. rapyer-1.1.7/rapyer/scripts/__init__.py +41 -0
  10. rapyer-1.1.7/rapyer/scripts/constants.py +10 -0
  11. rapyer-1.1.7/rapyer/scripts/loader.py +34 -0
  12. rapyer-1.1.7/rapyer/scripts/lua/datetime/__init__.py +0 -0
  13. rapyer-1.1.7/rapyer/scripts/lua/datetime/add.lua +80 -0
  14. rapyer-1.1.7/rapyer/scripts/lua/list/__init__.py +0 -0
  15. rapyer-1.1.7/rapyer/scripts/lua/list/remove_range.lua +36 -0
  16. rapyer-1.1.7/rapyer/scripts/lua/numeric/__init__.py +0 -0
  17. rapyer-1.1.7/rapyer/scripts/lua/numeric/floordiv.lua +13 -0
  18. rapyer-1.1.7/rapyer/scripts/lua/numeric/mod.lua +13 -0
  19. rapyer-1.1.7/rapyer/scripts/lua/numeric/mul.lua +13 -0
  20. rapyer-1.1.7/rapyer/scripts/lua/numeric/pow.lua +13 -0
  21. rapyer-1.1.7/rapyer/scripts/lua/numeric/pow_float.lua +13 -0
  22. rapyer-1.1.7/rapyer/scripts/lua/numeric/truediv.lua +13 -0
  23. rapyer-1.1.7/rapyer/scripts/lua/string/__init__.py +0 -0
  24. rapyer-1.1.7/rapyer/scripts/lua/string/append.lua +13 -0
  25. rapyer-1.1.7/rapyer/scripts/lua/string/mul.lua +13 -0
  26. rapyer-1.1.7/rapyer/scripts/registry.py +65 -0
  27. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/base.py +5 -4
  28. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/datetime.py +56 -1
  29. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/dct.py +12 -20
  30. rapyer-1.1.7/rapyer/types/float.py +100 -0
  31. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/integer.py +29 -12
  32. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/lst.py +25 -20
  33. rapyer-1.1.7/rapyer/types/string.py +41 -0
  34. rapyer-1.1.7/rapyer/utils/__init__.py +0 -0
  35. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/utils/redis.py +0 -7
  36. rapyer-1.1.5/rapyer/scripts.py +0 -86
  37. rapyer-1.1.5/rapyer/types/float.py +0 -52
  38. rapyer-1.1.5/rapyer/types/string.py +0 -20
  39. {rapyer-1.1.5 → rapyer-1.1.7}/README.md +0 -0
  40. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/context.py +0 -0
  41. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/fields/__init__.py +0 -0
  42. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/fields/expression.py +0 -0
  43. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/fields/index.py +0 -0
  44. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/fields/key.py +0 -0
  45. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/fields/safe_load.py +0 -0
  46. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/links.py +0 -0
  47. {rapyer-1.1.5/rapyer/utils → rapyer-1.1.7/rapyer/scripts/lua}/__init__.py +0 -0
  48. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/__init__.py +0 -0
  49. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/byte.py +0 -0
  50. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/convert.py +0 -0
  51. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/types/init.py +0 -0
  52. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/typing_support.py +0 -0
  53. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/utils/annotation.py +0 -0
  54. {rapyer-1.1.5 → rapyer-1.1.7}/rapyer/utils/fields.py +0 -0
  55. {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.5
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.5"
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_*"]
@@ -3,6 +3,7 @@
3
3
  from rapyer.base import (
4
4
  AtomicRedisModel,
5
5
  aget,
6
+ afind,
6
7
  find_redis_models,
7
8
  ainsert,
8
9
  get,
@@ -15,6 +16,7 @@ __all__ = [
15
16
  "init_rapyer",
16
17
  "teardown_rapyer",
17
18
  "aget",
19
+ "afind",
18
20
  "get",
19
21
  "find_redis_models",
20
22
  "ainsert",
@@ -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
- self.Meta.redis, self.key, self.Meta.ttl, self.Meta.refresh_ttl
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
- await refresh_ttl_if_needed(
423
- cls.Meta.redis, key, cls.Meta.ttl, cls.Meta.refresh_ttl
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
- async def afind(cls, *expressions):
450
- # Original behavior when no expressions provided - return all
451
- if not expressions:
452
- keys = await cls.afind_keys()
453
- if not keys:
454
- return []
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
- models = await cls.Meta.redis.json().mget(keys=keys, path="$")
457
- else:
458
- # With expressions - use Redis Search
459
- # Combine all expressions with & operator
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
- # Get the keys from search results
474
- keys = [doc.id for doc in search_result.docs]
509
+ # Fetch the actual documents
510
+ models = await cls.Meta.redis.json().mget(keys=targeted_keys, path="$")
475
511
 
476
- # Fetch the actual documents
477
- models = await cls.Meta.redis.json().mget(keys=keys, path="$")
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.Meta.ttl is not None and cls.Meta.refresh_ttl:
525
+ if cls.should_refresh():
480
526
  async with cls.Meta.redis.pipeline() as pipe:
481
- for key in keys:
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