codex-platform 0.3.0__tar.gz → 0.4.0__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 (153) hide show
  1. {codex_platform-0.3.0 → codex_platform-0.4.0}/CHANGELOG.md +14 -0
  2. {codex_platform-0.3.0 → codex_platform-0.4.0}/PKG-INFO +1 -1
  3. codex_platform-0.4.0/redis-backends-recovery.md +376 -0
  4. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/registry.py +1 -1
  5. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/base.py +31 -0
  6. codex_platform-0.4.0/src/codex_platform/redis_service/exceptions.py +20 -0
  7. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/operations/__init__.py +4 -0
  8. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/operations/hash.py +12 -1
  9. codex_platform-0.4.0/src/codex_platform/redis_service/operations/sync_hash.py +263 -0
  10. codex_platform-0.4.0/src/codex_platform/redis_service/operations/sync_string.py +187 -0
  11. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/streams/__init__.py +2 -2
  12. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/workers/arq/config.py +1 -1
  13. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/integration/test_docs_build.py +2 -1
  14. codex_platform-0.4.0/tests/unit/test_sync_operations.py +112 -0
  15. codex_platform-0.4.0/tools/dev/__init__.py +0 -0
  16. {codex_platform-0.3.0 → codex_platform-0.4.0}/tools/dev/check.py +3 -0
  17. codex_platform-0.3.0/src/codex_platform/redis_service/exceptions.py +0 -20
  18. {codex_platform-0.3.0 → codex_platform-0.4.0}/.github/workflows/ci.yml +0 -0
  19. {codex_platform-0.3.0 → codex_platform-0.4.0}/.github/workflows/docs.yml +0 -0
  20. {codex_platform-0.3.0 → codex_platform-0.4.0}/.github/workflows/publish.yml +0 -0
  21. {codex_platform-0.3.0 → codex_platform-0.4.0}/.gitignore +0 -0
  22. {codex_platform-0.3.0 → codex_platform-0.4.0}/.pre-commit-config.yaml +0 -0
  23. {codex_platform-0.3.0 → codex_platform-0.4.0}/.python-version +0 -0
  24. {codex_platform-0.3.0 → codex_platform-0.4.0}/README.md +0 -0
  25. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/changelog.md +0 -0
  26. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/index.md +0 -0
  27. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/notifications/channels.md +0 -0
  28. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/notifications/delivery/arq.md +0 -0
  29. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/notifications/delivery/base.md +0 -0
  30. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/notifications/delivery/direct.md +0 -0
  31. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/notifications/dto.md +0 -0
  32. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/notifications/index.md +0 -0
  33. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/notifications/orchestrator.md +0 -0
  34. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/notifications/registry.md +0 -0
  35. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/notifications/renderer.md +0 -0
  36. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/redis_service/base.md +0 -0
  37. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/redis_service/index.md +0 -0
  38. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/redis_service/keys.md +0 -0
  39. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/redis_service/operations/hash.md +0 -0
  40. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/redis_service/operations/json_module.md +0 -0
  41. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/redis_service/operations/json_string.md +0 -0
  42. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/redis_service/operations/list_.md +0 -0
  43. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/redis_service/operations/pipeline.md +0 -0
  44. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/redis_service/operations/set_.md +0 -0
  45. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/redis_service/operations/string.md +0 -0
  46. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/redis_service/operations/zset.md +0 -0
  47. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/redis_service/service.md +0 -0
  48. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/streams/consumer.md +0 -0
  49. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/streams/dispatcher.md +0 -0
  50. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/streams/index.md +0 -0
  51. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/streams/processor.md +0 -0
  52. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/streams/producer.md +0 -0
  53. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/streams/router.md +0 -0
  54. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/workers/arq/base.md +0 -0
  55. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/workers/arq/config.md +0 -0
  56. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/workers/arq/index.md +0 -0
  57. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/workers/arq/task_utils.md +0 -0
  58. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/api/workers/arq/types.md +0 -0
  59. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/architecture/notifications/README.md +0 -0
  60. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/architecture/notifications/data_flow.md +0 -0
  61. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/architecture/redis_service/README.md +0 -0
  62. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/architecture/redis_service/data_flow.md +0 -0
  63. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/architecture/streams/README.md +0 -0
  64. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/architecture/streams/data_flow.md +0 -0
  65. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/architecture/workers/README.md +0 -0
  66. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/en/architecture/workers/data_flow.md +0 -0
  67. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/index.md +0 -0
  68. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/ru/architecture/notifications/README.md +0 -0
  69. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/ru/architecture/notifications/data_flow.md +0 -0
  70. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/ru/architecture/redis_service/README.md +0 -0
  71. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/ru/architecture/redis_service/data_flow.md +0 -0
  72. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/ru/architecture/streams/README.md +0 -0
  73. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/ru/architecture/streams/data_flow.md +0 -0
  74. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/ru/architecture/workers/README.md +0 -0
  75. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/ru/architecture/workers/data_flow.md +0 -0
  76. {codex_platform-0.3.0 → codex_platform-0.4.0}/docs/stylesheets/extra.css +0 -0
  77. {codex_platform-0.3.0 → codex_platform-0.4.0}/mkdocs.yml +0 -0
  78. {codex_platform-0.3.0 → codex_platform-0.4.0}/project_structure.txt +0 -0
  79. {codex_platform-0.3.0 → codex_platform-0.4.0}/pyproject.toml +0 -0
  80. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/__init__.py +0 -0
  81. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/__init__.py +0 -0
  82. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/channels.py +0 -0
  83. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/clients/__init__.py +0 -0
  84. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/clients/smtp.py +0 -0
  85. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/delivery/__init__.py +0 -0
  86. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/delivery/arq.py +0 -0
  87. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/delivery/base.py +0 -0
  88. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/delivery/direct.py +0 -0
  89. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/dto.py +0 -0
  90. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/interfaces.py +0 -0
  91. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/orchestrator.py +0 -0
  92. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/notifications/renderer.py +0 -0
  93. /codex_platform-0.3.0/tests/integration/__init__.py → /codex_platform-0.4.0/src/codex_platform/py.typed +0 -0
  94. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/__init__.py +0 -0
  95. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/keys.py +0 -0
  96. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/managers/__init__.py +0 -0
  97. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/managers/base_manager.py +0 -0
  98. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/managers/site_settings.py +0 -0
  99. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/operations/json_module.py +0 -0
  100. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/operations/json_string.py +0 -0
  101. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/operations/list_.py +0 -0
  102. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/operations/pipeline.py +0 -0
  103. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/operations/set_.py +0 -0
  104. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/operations/string.py +0 -0
  105. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/operations/zset.py +0 -0
  106. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/redis_service/service.py +0 -0
  107. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/streams/consumer.py +0 -0
  108. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/streams/dispatcher.py +0 -0
  109. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/streams/processor.py +0 -0
  110. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/streams/producer.py +0 -0
  111. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/streams/router.py +0 -0
  112. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/workers/__init__.py +0 -0
  113. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/workers/arq/__init__.py +0 -0
  114. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/workers/arq/base.py +0 -0
  115. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/workers/arq/task_utils.py +0 -0
  116. {codex_platform-0.3.0 → codex_platform-0.4.0}/src/codex_platform/workers/arq/types.py +0 -0
  117. {codex_platform-0.3.0/tests/unit → codex_platform-0.4.0/tests/integration}/__init__.py +0 -0
  118. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/integration/conftest.py +0 -0
  119. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/integration/test_redis_service.py +0 -0
  120. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/integration/test_streams.py +0 -0
  121. {codex_platform-0.3.0/tools → codex_platform-0.4.0/tests/unit}/__init__.py +0 -0
  122. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/conftest.py +0 -0
  123. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_arq_base.py +0 -0
  124. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_arq_config.py +0 -0
  125. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_arq_public_api.py +0 -0
  126. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_arq_task_utils.py +0 -0
  127. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_notification_adapters.py +0 -0
  128. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_notification_channels.py +0 -0
  129. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_notification_dto.py +0 -0
  130. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_notification_orchestrator.py +0 -0
  131. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_notification_registry.py +0 -0
  132. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_notification_renderer.py +0 -0
  133. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_notification_smtp.py +0 -0
  134. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_redis_base.py +0 -0
  135. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_redis_hash.py +0 -0
  136. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_redis_keys.py +0 -0
  137. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_redis_list.py +0 -0
  138. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_redis_manager.py +0 -0
  139. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_redis_pipeline.py +0 -0
  140. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_redis_service.py +0 -0
  141. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_redis_set.py +0 -0
  142. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_redis_string.py +0 -0
  143. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_redis_zset.py +0 -0
  144. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_site_settings_manager.py +0 -0
  145. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_stream_consumer.py +0 -0
  146. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_stream_dispatcher.py +0 -0
  147. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_stream_processor.py +0 -0
  148. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_stream_producer.py +0 -0
  149. {codex_platform-0.3.0 → codex_platform-0.4.0}/tests/unit/test_stream_router.py +0 -0
  150. {codex_platform-0.3.0/tools/dev → codex_platform-0.4.0/tools}/__init__.py +0 -0
  151. {codex_platform-0.3.0 → codex_platform-0.4.0}/tools/dev/README.md +0 -0
  152. {codex_platform-0.3.0 → codex_platform-0.4.0}/tools/dev/generate_project_tree.py +0 -0
  153. {codex_platform-0.3.0 → codex_platform-0.4.0}/uv.lock +0 -0
@@ -3,6 +3,20 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  Grouped by `Added` · `Changed` · `Deprecated` · `Removed` · `Fixed`.
5
5
 
6
+ ## [Unreleased]
7
+
8
+ ## [0.4.0] - 2026-04-22
9
+
10
+ ### Added
11
+ - Synchronous Redis operations: `SyncStringOperations` and `SyncHashOperations` using `redis.Redis`.
12
+ - Added `catch_redis_errors_sync` decorator for synchronous methods.
13
+ - Added `encoder` parameter to `HashOperations.set_fields` and `SyncHashOperations.set_fields`.
14
+ ### Added
15
+ - Added `py.typed` marker for PEP 561 compliance — downstream consumers now benefit from full type inference when using mypy or pyright.
16
+
17
+ ### Changed
18
+ - Translated all Russian comments and docstrings to English across `redis_service/exceptions.py`, `workers/arq/config.py`, `notifications/registry.py`, and `streams/__init__.py`.
19
+
6
20
  ## [0.3.0] - 2026-04-05
7
21
 
8
22
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-platform
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Modular async infrastructure (Redis, Streams, ARQ, Notifications) built for Codex, designed for independent use.
5
5
  Project-URL: Homepage, https://github.com/codexdlc/codex-platform
6
6
  Project-URL: Documentation, https://codexdlc.github.io/codex-platform/
@@ -0,0 +1,376 @@
1
+ # Plan: Fix codex_django Redis backends — client factory + sync/async split + feature flag rollout
2
+
3
+ > [!NOTE]
4
+ > **Project Focus:** This repository (**codex-platform**) is responsible for **Track A**.
5
+ > The full plan is included below to maintain context and avoid narrowing down data.
6
+
7
+ ## Context
8
+
9
+ Production `lily_website` ловил пять Redis-багов:
10
+
11
+ 1. **`AttributeError: 'DashboardRedisManager' object has no attribute '_client'`** на `GET /ru/cabinet/` — главный блокер.
12
+ 2. `RuntimeError: Event loop is closed` — admin login → `SessionStore.asave`.
13
+ 3. `RuntimeError: got Future attached to a different loop` — повторные `FixtureHashManager.get_hash/set_hash`.
14
+ 4. `redis.exceptions.DataError: Invalid input of type 'bool'` — `update_site_settings` / `update_all_content`.
15
+ 5. `Error fetching static page SEO for key team/contacts: Event loop is closed` — `codex_django.core.seo.selectors.get_static_page_seo`.
16
+
17
+ Плюс infra-инцидент: `The "ATTV6fkq" variable is not set` — `$` в Redis-пароле, docker-compose интерполирует. Надо проверить.
18
+
19
+ Prod hotfix'ом (`951a6c4`) переведён на `django_redis` + `sessions.backends.db`, entrypoint очищен. Цель — починить codex_django Redis engine и вернуть его через **feature flag** с возможностью отката.
20
+
21
+ ## Root causes
22
+
23
+ 1. **Несогласованный контракт менеджеров.** `BaseRedisManager` (codex-platform) и `BaseDjangoRedisManager` не присваивают `self._client`. `DashboardRedisManager` и SEO selector используют `self._client.*`, которого нет.
24
+ 2. **Живой async client переживает свой event loop.** `Redis.from_url(...)` создаётся один раз в `__init__`, `async_to_sync` создаёт новый loop на каждый sync-вызов — клиент держит Future'ы мёртвого loop.
25
+ 3. **Module-level instantiation** (`_manager = DashboardRedisManager()` в `cabinet/selector/dashboard.py:41`, аналогично в SEO).
26
+ 4. **CacheCoder не подключён в HSET-пути.** `DjangoSiteSettingsManager.asave_instance` (`core/redis/managers/settings.py:95-106`) пишет сырой `instance.to_dict()`. CacheCoder покрывает datetime/Decimal/UUID/bytes/set, но не bool/None/Enum/Path/Promise. В `lily_website/src/lily_backend/system/models/settings.py:89-98` дублирующий костыль.
27
+ 5. **Sync Django path завязан на async_to_sync** для cache/session/dashboard/SEO. Нужен отдельный sync-путь поверх `redis.Redis`.
28
+
29
+ ## Глобальные принципы (жёсткие)
30
+
31
+ Библиотеки в alpha, consumer один — делаем чистый breaking change, compat-слой не тащим.
32
+
33
+ - **Единственный публичный API менеджеров — 4 context manager'а**: `sync_string()`, `sync_hash()`, `async_string()`, `async_hash()`. Всё старое (`self._client`, `self.string`, `self.hash` как атрибуты) — **удаляется**, не заменяется alias'ом.
34
+ - **Sync code → sync_* методы → `redis.Redis`**. `async_to_sync` из sync Django hot paths запрещён.
35
+ - **Async code → async_* методы → `redis.asyncio.Redis`**.
36
+ - **Менеджер принимает `client_factory: Callable[[], Redis]`, а НЕ live client**. Default factory читает `settings.REDIS_URL`. Live client между вызовами не хранится.
37
+ - **New client per operation с `aclose()` / `close()` в `finally`** (Variant A). Pool / loop-local cache — follow-up при превышении p95-бюджета.
38
+ - **Grep-gate** (обязательная часть CI / ручной проверки перед мержем):
39
+ ```
40
+ rg "self\._client|\.string\.|\.hash\." src/codex_django src/codex_platform src/lily_backend
41
+ ```
42
+ Все находки либо удалены, либо явно внутри operation-классов codex-platform. Новый код с `self._client` / атрибутным доступом к `.string`/`.hash` не мержится.
43
+ - **Rollout через feature flag** `USE_CODEX_REDIS_BACKENDS`; `django-redis` оставляем в deps как fallback.
44
+ - **CacheCoder без маркеров**: `None→""`, `bool→"1"/"0"`, `Enum→CacheCoder.dump(value.value)`. Typed restore делает `DjangoSiteSettingsManager` на основе Django field types.
45
+
46
+ ## Треки (независимо разрабатываемые)
47
+
48
+ Ветка в каждом репо: `codex/fix-redis-backends`. Треки разделены так, чтобы A/B/C/D можно было вести параллельно разными исполнителями; E/F/G — ниже по зависимостям.
49
+
50
+ ---
51
+
52
+ ### Трек A — codex-platform: sync operations + encoder hook (без alias)
53
+
54
+ **Репо:** `C:\install\projects\codex_tools\codex-platform`
55
+
56
+ `BaseRedisManager` остаётся как есть для async service layer; он **не используется** как база `BaseDjangoRedisManager` (см. трек B) — таким образом в codex-platform нет необходимости в compat-alias.
57
+
58
+ A.1. `src/codex_platform/redis_service/operations/hash.py` — добавить `encoder: Callable | None = None` в `set_fields`. Default `None`, не ломает существующих consumers.
59
+
60
+ A.2. Новый модуль `src/codex_platform/redis_service/operations/sync_string.py`, `operations/sync_hash.py`:
61
+ - `SyncStringOperations(redis.Redis)` — `get/set/delete/mget/mset/incr/expire/ttl/exists`.
62
+ - `SyncHashOperations(redis.Redis)` — `hget/hset/hdel/hgetall/set_fields`.
63
+ - Минимальный набор, используемый codex-django.
64
+
65
+ A.3. Тесты `tests/unit/test_sync_operations.py` (round-trip, encoder hook).
66
+
67
+ **Зависимости:** нет. Релизится первым.
68
+
69
+ **Deliverable:** codex-platform version bump + changelog.
70
+
71
+ ---
72
+
73
+ ### Трек B — codex-django: client factory + sync/async context managers в `BaseDjangoRedisManager`
74
+
75
+ **Репо:** `C:\install\projects\codex_tools\codex-django`
76
+
77
+ **Depends on:** A (для sync operations) — либо делать только после A.3.
78
+
79
+ B.1. `src/codex_django/core/redis/managers/base.py` — переписать `BaseDjangoRedisManager` с нуля. **Не наследуется от `BaseRedisManager`** (codex-platform). Единственный публичный API — 4 context manager'а:
80
+
81
+ ```python
82
+ class BaseDjangoRedisManager:
83
+ def __init__(
84
+ self,
85
+ prefix: str = "",
86
+ *,
87
+ async_client_factory: "Callable[[], AsyncRedis] | None" = None,
88
+ sync_client_factory: "Callable[[], SyncRedis] | None" = None,
89
+ ):
90
+ self.prefix = prefix
91
+ self.project_name = get_project_name()
92
+ self._redis_url = get_redis_url_from_settings()
93
+ self._async_factory = async_client_factory or self._default_async_factory
94
+ self._sync_factory = sync_client_factory or self._default_sync_factory
95
+
96
+ def _default_async_factory(self) -> "AsyncRedis":
97
+ from redis.asyncio import Redis
98
+ return Redis.from_url(self._redis_url, decode_responses=True)
99
+
100
+ def _default_sync_factory(self) -> "SyncRedis":
101
+ import redis
102
+ return redis.Redis.from_url(self._redis_url, decode_responses=True)
103
+
104
+ @asynccontextmanager
105
+ async def async_string(self) -> "AsyncIterator[StringOperations]":
106
+ client = self._async_factory()
107
+ try:
108
+ yield StringOperations(client)
109
+ finally:
110
+ await client.aclose()
111
+
112
+ @asynccontextmanager
113
+ async def async_hash(self) -> "AsyncIterator[HashOperations]":
114
+ client = self._async_factory()
115
+ try:
116
+ yield HashOperations(client)
117
+ finally:
118
+ await client.aclose()
119
+
120
+ @contextmanager
121
+ def sync_string(self) -> "Iterator[SyncStringOperations]":
122
+ client = self._sync_factory()
123
+ try:
124
+ yield SyncStringOperations(client)
125
+ finally:
126
+ client.close()
127
+
128
+ @contextmanager
129
+ def sync_hash(self) -> "Iterator[SyncHashOperations]":
130
+ client = self._sync_factory()
131
+ try:
132
+ yield SyncHashOperations(client)
133
+ finally:
134
+ client.close()
135
+
136
+ def make_key(self, key: str) -> str: ...
137
+ ```
138
+
139
+ Правила (жёсткие):
140
+ - `self._client`, `self.string`, `self.hash` как **атрибуты** — удалены. Если найдутся в коде после миграции — не мержим.
141
+ - Все sync use-sites: `with mgr.sync_string() as s: s.get(...)`.
142
+ - Все async use-sites: `async with mgr.async_string() as s: await s.get(...)`.
143
+ - `async_to_sync` из sync Django hot paths удаляется — sync backend использует `sync_string/sync_hash` напрямую.
144
+
145
+ B.2. `src/codex_django/cache/backends/redis.py`:
146
+ - sync методы (`get/set/delete/has_key/clear/...`) — через `sync_string()`/`sync_hash()`.
147
+ - async методы (`aget/aset/...`) — через `async_string()`/`async_hash()`.
148
+ - `async_to_sync` удалить из sync path полностью.
149
+
150
+ B.3. `src/codex_django/sessions/backends/redis.py`:
151
+ - `save/load/exists/create/delete` — через `sync_string()`.
152
+ - `asave/aload/aexists/acreate/adelete` — через `async_string()`.
153
+
154
+ B.4. `src/codex_django/system/redis/managers/fixtures.py` — мигрировать на `sync_string()/async_string()`.
155
+
156
+ B.5. Тесты:
157
+ - `tests/unit/core/redis/test_base_manager_client_factory.py` — default async/sync factories, injection, `aclose/close` вызывается.
158
+ - `tests/unit/sessions/test_redis_backend_sync.py` — sync path.
159
+ - `tests/unit/cache/test_redis_backend_sync.py` — sync path.
160
+
161
+ **Deliverable:** codex-django version bump (minor, т.к. ломающее изменение в signature BaseDjangoRedisManager).
162
+
163
+ ---
164
+
165
+ ### Трек C — codex-django: CacheCoder расширение + `DjangoSiteSettingsManager` typed restore
166
+
167
+ **Репо:** codex-django. **Depends on:** B (использует новые `hash()`/`ahash()`).
168
+
169
+ C.1. `src/codex_django/cache/values.py` — расширить CacheCoder:
170
+ - `dump_bool(v) → "1"|"0"`; `load_bool(raw) → True|False`.
171
+ - `dump_none() → ""`; `load_none` — только явный вызов из typed restore (не автоматический).
172
+ - `dump_enum(v) → CacheCoder.dump(v.value)` (рекурсивно, т.к. value может быть dict/bool/None).
173
+ - `dump_path(v) → str(v)`.
174
+ - `dump_lazy(v) → force_str(v)` (`django.utils.functional.Promise`).
175
+ - В generic `dump()` — `isinstance(value, bool)` ДО `int`.
176
+
177
+ C.2. Тесты `tests/unit/cache/test_values.py` — round-trip всех типов. Для Promise — `override_settings(USE_I18N=True)` + `gettext_lazy`.
178
+
179
+ C.3. `src/codex_django/core/redis/managers/settings.py`:
180
+
181
+ **Запись** (sync):
182
+ ```python
183
+ data = instance.to_dict()
184
+ coded = {k: CacheCoder.dump(v) for k, v in data.items()}
185
+ with self.sync_hash() as h:
186
+ h.set_fields(self.make_key(...), coded)
187
+ ```
188
+
189
+ **Чтение** (sync):
190
+ ```python
191
+ with self.sync_hash() as h:
192
+ raw = h.hgetall(self.make_key(...))
193
+ return self._decode_fields(raw, instance_cls=SiteSettings)
194
+ ```
195
+
196
+ Async-пары (`asave_instance`, `aload_cached`) — через `async_hash()`.
197
+
198
+ Метод `_decode_fields(raw: dict[str,str], instance_cls)`:
199
+ - Идёт по `instance_cls._meta.get_fields()`.
200
+ - Для `BooleanField`:
201
+ - `raw[name] == "1"` → `True`;
202
+ - `"0"` → `False`;
203
+ - `""` + `field.null=True` → `None`;
204
+ - иначе fallback на дефолт поля.
205
+ - Для `CharField/TextField` — `raw[name]` as-is (пустая строка **не** превращается в None).
206
+ - Для nullable non-string полей (`IntegerField(null=True)`, `DateTimeField(null=True)` и т.п.) — `"" → None`.
207
+ - Для `DateTimeField/DateField/TimeField/DecimalField/UUIDField` — `CacheCoder.load_*`.
208
+ - Для полей, которые модель хранит как JSON (ForeignKey id / JSONField) — `json.loads` либо соответствующий loader.
209
+
210
+ C.4. Тесты:
211
+ - `tests/unit/core/redis/test_settings_manager_encoding.py` — write bool/None/datetime/Decimal/UUID → read с typed restore, включая `BooleanField(null=True)` edge cases.
212
+
213
+ ---
214
+
215
+ ### Трек D — codex-django: `DashboardRedisManager`, SEO selectors, остальные менеджеры
216
+
217
+ **Репо:** codex-django. **Depends on:** B.
218
+
219
+ D.1. `src/codex_django/cabinet/redis/managers/dashboard.py`:
220
+ - Заменить все `self._client.*` на `with self.sync_string() as s: s.get/set/delete(...)` (sync) и `async with self.async_string() as s: await s.get/set/delete(...)` (async).
221
+ - TTL — через `s.set(key, value, ex=ttl)`.
222
+
223
+ D.2. `src/codex_django/cabinet/selector/dashboard.py:41` — убрать `_manager = DashboardRedisManager()`; заменить на:
224
+ ```python
225
+ @lru_cache(maxsize=1)
226
+ def get_dashboard_redis_manager() -> DashboardRedisManager:
227
+ return DashboardRedisManager()
228
+ ```
229
+ Все использования `_manager` переписать на `get_dashboard_redis_manager()`.
230
+
231
+ D.3. `src/codex_django/core/seo/selectors.py` — аналогично:
232
+ - убрать module-level manager;
233
+ - `_client` удалить; мигрировать sync на `sync_string()`, async на `async_string()`.
234
+
235
+ D.4. Grep-gate (обязательно перед мержем в каждом треке B/C/D/E):
236
+ ```
237
+ rg "self\._client|\.string\.|\.hash\." src/codex_django src/codex_platform src/lily_backend
238
+ ```
239
+ Разрешённые находки — только внутри operation-классов codex-platform (`HashOperations`, `StringOperations`, `SyncHashOperations`, `SyncStringOperations`), где `self._client` — это injected redis client этого operation-класса (не менеджер). Всё остальное — мигрировать.
240
+
241
+ D.5. Проверка оставшихся менеджеров: `NotificationsCacheManager`, `DjangoSiteSettingsManager` (уже в треке C), `FixtureHashManager` (в треке B), локальные `ActionTokenRedisManager` (в lily_website — трек E).
242
+
243
+ D.6. Тесты:
244
+ - `tests/unit/cabinet/redis/test_dashboard_manager.py` — sync get/set/delete, async aget/aset/adelete.
245
+ - `tests/unit/core/seo/test_seo_selector_redis.py` — повторные вызовы в одном процессе.
246
+
247
+ ---
248
+
249
+ ### Трек E — lily_website: тесты, локальный workaround, local managers
250
+
251
+ **Репо:** `C:\install\projects\clients\lily_website`. **Depends on:** B, C, D через editable install из `C:\install\projects\codex_tools\`.
252
+
253
+ E.1. Удалить локальный bool/None workaround в `src/lily_backend/system/models/settings.py:89-98` — пусть `to_dict()` возвращает нативные Python-значения; CacheCoder применяется централизованно.
254
+
255
+ E.2. Локальные менеджеры в `src/lily_backend/system/redis.py` (`ActionTokenRedisManager`) — мигрировать на новый контракт (`sync_string()/async_string()`).
256
+
257
+ E.3. Regression tests в `tests/lily_backend/regression/`:
258
+ - `test_dashboard_manager_cache.py` — `Client.force_login(staff)`, `GET /ru/cabinet/` × 2 (первый → cache write, второй → cache read) с `cache_ttl=300`. Оба 200.
259
+ - `test_session_event_loop.py` — admin login + 3 GET.
260
+ - `test_fixture_hash_repeated.py` — `get_hash/set_hash` × 2.
261
+ - `test_site_settings_bool_hset.py` — `DjangoSiteSettingsManager.save_instance` с bool/None/datetime + typed restore.
262
+ - `test_seo_static_page.py` — `get_static_page_seo("team")` × 2.
263
+
264
+ E.4. Integration tests in `tests/lily_backend/integration/` (требуют Docker redis):
265
+ - `test_cache_get_set_repeat.py` — sync `cache.set/get/delete` × 10.
266
+ - `test_management_commands.py` — двойной `update_site_settings --force`, `update_all_content`, `load_catalog`.
267
+ - `test_admin_login_flow.py` — login через `Client` + несколько админ-страниц.
268
+
269
+ E.5. Unit tests:
270
+ - `tests/lily_backend/unit/test_cache_coder_extensions.py` — sanity check, что проектные модели сериализуются.
271
+
272
+ ---
273
+
274
+ ### Трек F — lily_website: feature flag rollout (staging → prod)
275
+
276
+ **Зависит от:** зелёных A/B/C/D/E и Docker smoke.
277
+
278
+ F.1. `src/lily_backend/core/settings/prod.py` — feature flag:
279
+ ```python
280
+ USE_CODEX_REDIS_BACKENDS = env.bool("USE_CODEX_REDIS_BACKENDS", default=False)
281
+
282
+ if USE_CODEX_REDIS_BACKENDS:
283
+ CACHES = {"default": {"BACKEND": "codex_django.cache.backends.redis.RedisCache", ...}}
284
+ SESSION_ENGINE = "codex_django.sessions.backends.redis"
285
+ else:
286
+ CACHES = {"default": {"BACKEND": "django_redis.cache.RedisCache", ...}}
287
+ SESSION_ENGINE = "django.contrib.sessions.backends.db"
288
+ ```
289
+
290
+ F.2. **`django-redis` оставить в deps** в этом релизе. Удалять — только после успешной работы с flag=True в prod ≥ 2 недели.
291
+
292
+ F.3. `deploy/lily_backend/entrypoint.sh` — **не** возвращать startup-команды. `update_all_content` — one-shot:
293
+ ```
294
+ docker compose -f docker-compose.prod.yml run --rm -T backend python manage.py update_all_content
295
+ ```
296
+
297
+ F.4. Rollout:
298
+ - staging: `USE_CODEX_REDIS_BACKENDS=true` → наблюдение логов ≥ 1 рабочий день.
299
+ - prod: включить flag; при ошибках — откат переменной окружения без редеплоя кода.
300
+
301
+ F.5. Runbook в `docs/redis-feature-flag.md`: включение/выключение, что мониторить, куда смотреть логи.
302
+
303
+ ---
304
+
305
+ ### Трек G — infra: Redis password escaping
306
+
307
+ G.1. Проверить `deploy/docker-compose.prod.yml` + `.env`:
308
+ ```
309
+ docker compose --env-file .env -f docker-compose.prod.yml config
310
+ ```
311
+ Если видим `The "ATTV6fkq" variable is not set` — `$` в пароле интерполируется. Фикс:
312
+ - экранировать как `$$` в `.env` / `docker-compose.yml` ИЛИ
313
+ - завернуть пароль в одинарные кавычки ИЛИ
314
+ - перегенерировать пароль без `$`.
315
+
316
+ G.2. Добавить guard в entrypoint: проверка, что `REDIS_URL` парсится и содержит непустой пароль; если нет — exit с понятным сообщением.
317
+
318
+ G.3. Не зависит от A-F. Можно делать параллельно.
319
+
320
+ ---
321
+
322
+ ## Verification (общая)
323
+
324
+ 1. `cd codex-platform && pytest` — трек A зелёный.
325
+ 2. `cd codex-django && pytest` — треки B/C/D зелёные.
326
+ 3. `cd lily_website && pytest tests/lily_backend/unit` — трек E unit зелёный.
327
+ 4. Поднять Docker redis, `pytest tests/lily_backend/integration tests/lily_backend/regression` — зелёные.
328
+ 5. Docker smoke:
329
+ ```
330
+ docker compose -f deploy/docker-compose.yml up -d redis backend
331
+ docker exec lily_website-backend python manage.py update_site_settings --force
332
+ docker exec lily_website-backend python manage.py update_site_settings --force # 2-й
333
+ docker exec lily_website-backend python manage.py update_all_content
334
+ docker exec lily_website-backend python manage.py load_catalog
335
+ docker exec lily_website-backend python manage.py shell -c \
336
+ "from django.core.cache import cache; cache.set('redis_check','ok',30); print(cache.get('redis_check'))"
337
+ # Browser: admin login + /ru/cabinet/ × 2 + /ru/team/ + /ru/contacts/
338
+ # Логи: без AttributeError, Event loop is closed, Future attached to different loop, DataError.
339
+ ```
340
+ 6. **Acceptance metric:** `/ru/cabinet/` p95 в staging с `USE_CODEX_REDIS_BACKENDS=true` не хуже чем с hotfix'ом на ≥50ms. При превышении — follow-up на connection pool / loop-local strong cache.
341
+ 7. Трек G: `docker compose config` без warnings.
342
+
343
+ ## Critical files (сводка)
344
+
345
+ **codex-platform** (`C:\install\projects\codex_tools\codex-platform\src\codex_platform\redis_service\`):
346
+ - `operations/hash.py` — `encoder` параметр.
347
+ - `operations/sync_string.py`, `operations/sync_hash.py` — новые sync-operations.
348
+ - `base.py` — без изменений; `BaseRedisManager` остаётся async-only service-layer базой и больше не используется codex-django.
349
+
350
+ **codex-django** (`C:\install\projects\codex_tools\codex-django\src\codex_django\`):
351
+ - `core/redis/managers/base.py` — `BaseDjangoRedisManager` переписан: client factory, 4 context manager'а (`sync_string`, `sync_hash`, `async_string`, `async_hash`), не наследуется от `BaseRedisManager`.
352
+ - `cache/backends/redis.py` — sync через `sync_string()/sync_hash()`.
353
+ - `sessions/backends/redis.py` — sync через `sync_string()`.
354
+ - `system/redis/managers/fixtures.py` — через `sync_string()/async_string()`.
355
+ - `cache/values.py` — CacheCoder extensions.
356
+ - `core/redis/managers/settings.py` — write с CacheCoder, read с typed restore.
357
+ - `cabinet/redis/managers/dashboard.py` — через `sync_string()/async_string()`.
358
+ - `cabinet/selector/dashboard.py:41` — lazy `get_dashboard_redis_manager()`.
359
+ - `core/seo/selectors.py` — то же.
360
+ - `core/redis/managers/notifications.py` — проверить, мигрировать.
361
+
362
+ **lily_website** (`C:\install\projects\clients\lily_website\src\lily_backend\`):
363
+ - `system/models/settings.py:89-98` — удалить workaround.
364
+ - `system/redis.py` — мигрировать `ActionTokenRedisManager`.
365
+ - `core/settings/prod.py` — feature flag `USE_CODEX_REDIS_BACKENDS`.
366
+
367
+ ## Risks / open questions
368
+
369
+ - **Performance:** new-client-per-operation. Acceptance metric p95 +50ms max — иначе follow-up. Оптимизация: connection pool на уровне factory (не client) — `redis.Redis(connection_pool=pool)` с общим pool; pool thread-safe, но async pool — нет. Для first-fix не делаем.
370
+ - **Breaking change BaseDjangoRedisManager:** публичный API полностью меняется (`self.string`/`self.hash`/`self._client` удалены). Т.к. библиотеки в alpha и единственный consumer — lily_website, compat-слой не тащим. Bump codex-django major (0.x → 0.(x+1)) + changelog с migration guide.
371
+ - **Grep-gate не проходит — не мержим.** Любое оставшееся `self._client` / `.string.` / `.hash.` вне operation-классов codex-platform = 500 в проде.
372
+ - **CharField "" vs None:** typed restore не превращает пустую строку в None для CharField/TextField.
373
+ - **Enum с non-primitive value:** `dump_enum` рекурсивно через `CacheCoder.dump(value.value)` — покрывает.
374
+ - **Windows vs Linux asyncio:** smoke только в Linux-контейнере.
375
+ - **Координация релизов:** codex-platform → codex-django → lily_website pin commit. Editable install in dev; pinned in prod.
376
+ - **django-redis как fallback:** оставляем в deps, не удаляем в этом PR. Удалять — отдельным cleanup после ≥ 2 недель успеха с flag=True.
@@ -8,7 +8,7 @@ Usage:
8
8
  registry.register("smtp", lambda cfg: SmtpChannel(cfg) if cfg.SMTP_HOST else None)
9
9
  registry.register("sendgrid", lambda cfg: SendGridChannel(cfg) if cfg.SENDGRID_API_KEY else None)
10
10
  channels = registry.build_channels(settings)
11
- # → [SmtpChannel, SendGridChannel] (только те, чей конфиг заполнен)
11
+ # → [SmtpChannel, SendGridChannel] (only those whose config is populated)
12
12
  """
13
13
 
14
14
  import logging
@@ -52,6 +52,37 @@ def catch_redis_errors(func: Callable[..., Any]) -> Callable[..., Any]:
52
52
  return wrapper
53
53
 
54
54
 
55
+ def catch_redis_errors_sync(func: Callable[..., Any]) -> Callable[..., Any]:
56
+ """Decorator that converts redis-py exceptions into typed domain exceptions for sync functions.
57
+
58
+ Never suppresses errors — always re-raises as a domain exception.
59
+ Preserves the original traceback via ``raise ... from e``.
60
+
61
+ Catches:
62
+ - ``ConnectionError``, ``TimeoutError`` → :exc:`RedisConnectionError`
63
+ - ``RedisError`` → :exc:`RedisServiceError`
64
+
65
+ Does NOT catch:
66
+ ``JSONDecodeError``, ``TypeError`` — data-specific errors are caught
67
+ in individual operations and re-raised as :exc:`RedisDataError`.
68
+ """
69
+
70
+ @wraps(func)
71
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
72
+ try:
73
+ return func(*args, **kwargs)
74
+ except (ConnectionError, TimeoutError) as e:
75
+ msg = f"Network failure in {func.__name__}: {e}"
76
+ log.exception("Redis | error=connection_failed fn='%s'", func.__name__)
77
+ raise RedisConnectionError(msg) from e
78
+ except RedisError as e:
79
+ msg = f"Operation failed in {func.__name__}: {e}"
80
+ log.exception("Redis | error=operation_failed fn='%s'", func.__name__)
81
+ raise RedisServiceError(msg) from e
82
+
83
+ return wrapper
84
+
85
+
55
86
  class BaseRedisService:
56
87
  """Base class that holds a Redis connection.
57
88
 
@@ -0,0 +1,20 @@
1
+ """
2
+ codex_platform.redis_service.exceptions
3
+ =======================================
4
+ Custom exceptions for the Redis service layer.
5
+
6
+ Use these classes in business logic instead of redis.exceptions.*
7
+ to avoid coupling to redis-py internals.
8
+ """
9
+
10
+
11
+ class RedisServiceError(Exception):
12
+ """Base class for all Redis layer errors."""
13
+
14
+
15
+ class RedisConnectionError(RedisServiceError):
16
+ """Network errors: timeouts, server unavailability, connection drops."""
17
+
18
+
19
+ class RedisDataError(RedisServiceError):
20
+ """Content errors: malformed JSON, type mismatches."""
@@ -5,6 +5,8 @@ from .list_ import ListOperations
5
5
  from .pipeline import PipelineOperations
6
6
  from .set_ import SetOperations
7
7
  from .string import StringOperations
8
+ from .sync_hash import SyncHashOperations
9
+ from .sync_string import SyncStringOperations
8
10
  from .zset import ZSetOperations
9
11
 
10
12
  __all__ = [
@@ -16,4 +18,6 @@ __all__ = [
16
18
  "JsonStringOperations",
17
19
  "JsonModuleOperations",
18
20
  "PipelineOperations",
21
+ "SyncStringOperations",
22
+ "SyncHashOperations",
19
23
  ]
@@ -6,6 +6,7 @@ Redis Hash operations.
6
6
 
7
7
  import json
8
8
  import logging
9
+ from collections.abc import Callable
9
10
  from typing import Any
10
11
 
11
12
  from redis.asyncio import Redis
@@ -99,12 +100,20 @@ class HashOperations:
99
100
  await self.client.hset(real_key, field, value)
100
101
 
101
102
  @catch_redis_errors
102
- async def set_fields(self, key: "str | BaseRedisKey", data: dict[str, Any], **kwargs: Any) -> None:
103
+ async def set_fields(
104
+ self,
105
+ key: "str | BaseRedisKey",
106
+ data: dict[str, Any],
107
+ *,
108
+ encoder: Callable[[Any], Any] | None = None,
109
+ **kwargs: Any,
110
+ ) -> None:
103
111
  """Set multiple hash fields in a single call (HSET mapping).
104
112
 
105
113
  Args:
106
114
  key: Redis key or a ``BaseRedisKey`` instance.
107
115
  data: Mapping of ``{field: value}`` pairs to write.
116
+ encoder: Optional callable to transform values before setting.
108
117
  **kwargs: Extra parameters forwarded to ``resolve_key``.
109
118
 
110
119
  Raises:
@@ -112,6 +121,8 @@ class HashOperations:
112
121
  RedisServiceError: Redis operation failure.
113
122
  """
114
123
  real_key = resolve_key(key, **kwargs)
124
+ if encoder:
125
+ data = {k: encoder(v) for k, v in data.items()}
115
126
  await self.client.hset(real_key, mapping=data)
116
127
 
117
128
  @catch_redis_errors