codex-platform 0.1.0__py3-none-any.whl

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 (45) hide show
  1. codex_platform/__init__.py +1 -0
  2. codex_platform/notifications/__init__.py +48 -0
  3. codex_platform/notifications/channels.py +24 -0
  4. codex_platform/notifications/clients/__init__.py +3 -0
  5. codex_platform/notifications/clients/smtp.py +134 -0
  6. codex_platform/notifications/delivery/__init__.py +18 -0
  7. codex_platform/notifications/delivery/arq.py +69 -0
  8. codex_platform/notifications/delivery/base.py +39 -0
  9. codex_platform/notifications/delivery/direct.py +89 -0
  10. codex_platform/notifications/dto.py +63 -0
  11. codex_platform/notifications/interfaces.py +30 -0
  12. codex_platform/notifications/orchestrator.py +116 -0
  13. codex_platform/notifications/registry.py +71 -0
  14. codex_platform/notifications/renderer.py +111 -0
  15. codex_platform/redis_service/__init__.py +59 -0
  16. codex_platform/redis_service/base.py +69 -0
  17. codex_platform/redis_service/exceptions.py +20 -0
  18. codex_platform/redis_service/keys.py +110 -0
  19. codex_platform/redis_service/managers/__init__.py +18 -0
  20. codex_platform/redis_service/managers/base_manager.py +31 -0
  21. codex_platform/redis_service/operations/__init__.py +19 -0
  22. codex_platform/redis_service/operations/hash.py +302 -0
  23. codex_platform/redis_service/operations/json_module.py +129 -0
  24. codex_platform/redis_service/operations/json_string.py +113 -0
  25. codex_platform/redis_service/operations/list_.py +219 -0
  26. codex_platform/redis_service/operations/pipeline.py +197 -0
  27. codex_platform/redis_service/operations/set_.py +212 -0
  28. codex_platform/redis_service/operations/string.py +318 -0
  29. codex_platform/redis_service/operations/zset.py +277 -0
  30. codex_platform/redis_service/service.py +80 -0
  31. codex_platform/streams/__init__.py +59 -0
  32. codex_platform/streams/consumer.py +106 -0
  33. codex_platform/streams/dispatcher.py +175 -0
  34. codex_platform/streams/processor.py +217 -0
  35. codex_platform/streams/producer.py +60 -0
  36. codex_platform/streams/router.py +78 -0
  37. codex_platform/workers/__init__.py +1 -0
  38. codex_platform/workers/arq/__init__.py +27 -0
  39. codex_platform/workers/arq/base.py +170 -0
  40. codex_platform/workers/arq/config.py +67 -0
  41. codex_platform/workers/arq/task_utils.py +72 -0
  42. codex_platform/workers/arq/types.py +11 -0
  43. codex_platform-0.1.0.dist-info/METADATA +120 -0
  44. codex_platform-0.1.0.dist-info/RECORD +45 -0
  45. codex_platform-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,71 @@
1
+ """
2
+ codex_platform.notifications.channels.registry
3
+ =============================================
4
+ Auto-discovers available delivery channels based on configuration.
5
+
6
+ Usage:
7
+ registry = ChannelRegistry()
8
+ registry.register("smtp", lambda cfg: SmtpChannel(cfg) if cfg.SMTP_HOST else None)
9
+ registry.register("sendgrid", lambda cfg: SendGridChannel(cfg) if cfg.SENDGRID_API_KEY else None)
10
+ channels = registry.build_channels(settings)
11
+ # → [SmtpChannel, SendGridChannel] (только те, чей конфиг заполнен)
12
+ """
13
+
14
+ import logging
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ from .orchestrator import DeliveryChannel
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ class ChannelRegistry:
24
+ """
25
+ Registry for delivery channels.
26
+ Channels register with a factory function that returns a channel or None.
27
+ build_channels() creates only the channels whose config is available.
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ self._factories: list[tuple[str, Callable[[Any], DeliveryChannel | None]]] = []
32
+
33
+ def register(
34
+ self,
35
+ name: str,
36
+ factory: Callable[[Any], DeliveryChannel | None],
37
+ ) -> None:
38
+ """
39
+ Register a channel factory.
40
+
41
+ Args:
42
+ name: Human-readable channel name (for logging).
43
+ factory: Callable that takes config and returns DeliveryChannel or None.
44
+ Return None if the channel's config is missing/incomplete.
45
+ """
46
+ self._factories.append((name, factory))
47
+
48
+ def build_channels(self, config: Any) -> list[DeliveryChannel]:
49
+ """Build the list of available channels from all registered factories.
50
+
51
+ Calls each factory with ``config``. A channel is included only when the
52
+ factory returns a non-``None`` instance that also reports ``is_available() == True``.
53
+
54
+ Args:
55
+ config: Application settings object passed verbatim to every factory.
56
+
57
+ Returns:
58
+ Ordered list of ready-to-use :class:`DeliveryChannel` instances.
59
+ """
60
+ channels: list[DeliveryChannel] = []
61
+ for name, factory in self._factories:
62
+ try:
63
+ channel = factory(config)
64
+ if channel is not None and channel.is_available():
65
+ channels.append(channel)
66
+ log.info("ChannelRegistry | %s enabled", name)
67
+ else:
68
+ log.debug("ChannelRegistry | %s skipped (not configured)", name)
69
+ except Exception:
70
+ log.exception("ChannelRegistry | %s factory failed", name)
71
+ return channels
@@ -0,0 +1,111 @@
1
+ """
2
+ codex_platform.notifications.renderer
3
+ ======================================
4
+ Jinja2-based HTML template renderer for notification workers.
5
+
6
+ OPTIONAL DEPENDENCY
7
+ -------------------
8
+ This module requires ``jinja2`` which is NOT included in the default
9
+ ``codex-platform`` installation.
10
+
11
+ There are two delivery modes:
12
+
13
+ Mode 1 — Worker renders templates itself (needs Jinja2):
14
+ Worker receives ``template_name`` + ``context_data``,
15
+ renders HTML internally, then sends via SMTP.
16
+
17
+ Mode 2 — Pre-rendered HTML (no Jinja2 needed):
18
+ Django (or any other layer) renders the template and passes
19
+ ready ``html_content`` directly to the workers for delivery only.
20
+
21
+ If you are using Mode 1, add the following to your project dependencies:
22
+
23
+ # pyproject.toml
24
+ dependencies = [
25
+ "jinja2>=3.1",
26
+ ]
27
+
28
+ # or via pip
29
+ pip install jinja2
30
+
31
+ Usage
32
+ -----
33
+ from codex_platform.notifications.renderer import TemplateRenderer
34
+
35
+ renderer = TemplateRenderer(templates_dir="/path/to/workers/templates")
36
+ html = renderer.render("booking/bk_confirmation.html", context={...})
37
+
38
+ Template directory structure (recommended)
39
+ ------------------------------------------
40
+ templates/
41
+ ├── base_email.html
42
+ ├── booking/
43
+ │ ├── bk_confirmation.html
44
+ │ └── bk_cancellation.html
45
+ ├── contacts/
46
+ │ └── ct_receipt.html
47
+ └── marketing/
48
+ └── mk_reengagement.html
49
+ """
50
+
51
+ import logging
52
+ import os
53
+
54
+ log = logging.getLogger(__name__)
55
+
56
+ try:
57
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
58
+
59
+ _JINJA2_AVAILABLE = True
60
+ except ImportError:
61
+ _JINJA2_AVAILABLE = False
62
+
63
+
64
+ class TemplateRenderer:
65
+ """
66
+ Jinja2 HTML template renderer.
67
+
68
+ Requires ``jinja2`` — install via ``pip install codex-platform[jinja2]``.
69
+
70
+ Raises:
71
+ RuntimeError: If jinja2 is not installed.
72
+ FileNotFoundError: If the templates directory does not exist.
73
+ """
74
+
75
+ def __init__(self, templates_dir: str) -> None:
76
+ if not _JINJA2_AVAILABLE:
77
+ raise RuntimeError(
78
+ "jinja2 is not installed. "
79
+ "Run: pip install codex-platform[jinja2]\n"
80
+ "If you are passing pre-rendered html_content to the workers, "
81
+ "you do not need TemplateRenderer at all."
82
+ )
83
+
84
+ if not os.path.exists(templates_dir):
85
+ raise FileNotFoundError(f"Templates directory not found: {templates_dir}")
86
+
87
+ self.env = Environment(
88
+ loader=FileSystemLoader(templates_dir),
89
+ autoescape=select_autoescape(["html", "xml"]),
90
+ )
91
+ self.templates_dir = templates_dir
92
+ log.info("TemplateRenderer | initialized with dir='%s'", templates_dir)
93
+
94
+ def render(self, template_name: str, context: dict[str, object]) -> str:
95
+ """
96
+ Render a template with the given context.
97
+
98
+ Args:
99
+ template_name: Relative path to template (e.g. 'booking/bk_confirmation.html').
100
+ context: Dictionary of variables passed to the template.
101
+
102
+ Returns:
103
+ Rendered HTML string.
104
+
105
+ Raises:
106
+ jinja2.TemplateNotFound: If the template file does not exist.
107
+ jinja2.TemplateError: On rendering errors.
108
+ """
109
+ log.debug("TemplateRenderer | rendering template='%s'", template_name)
110
+ template = self.env.get_template(template_name)
111
+ return template.render(context)
@@ -0,0 +1,59 @@
1
+ """
2
+ codex_platform.redis_service
3
+ ==========================
4
+ Async Redis service with mixin architecture.
5
+
6
+ Requires ``redis.asyncio.Redis`` client. All methods are async-only.
7
+
8
+ Quick start::
9
+
10
+ from codex_platform.redis_service import RedisService
11
+
12
+ service = RedisService(client=redis_client)
13
+ await service.set_hash_json("my_key", "field", {"a": 1})
14
+
15
+ Custom composition::
16
+
17
+ from codex_platform.redis_service import BaseRedisService, HashMixin, StreamMixin
18
+
19
+ class MyRedisService(BaseRedisService, HashMixin, StreamMixin):
20
+ pass
21
+
22
+ Key Registry::
23
+
24
+ from codex_platform.redis_service import UserKey
25
+
26
+ await service.get_hash_json(UserKey(), "profile", user_id=42)
27
+ """
28
+
29
+ from .base import BaseRedisService
30
+ from .keys import BaseRedisKey, SessionKey, UserKey
31
+ from .operations import (
32
+ HashOperations,
33
+ JsonModuleOperations,
34
+ JsonStringOperations,
35
+ ListOperations,
36
+ PipelineOperations,
37
+ SetOperations,
38
+ StringOperations,
39
+ ZSetOperations,
40
+ )
41
+ from .service import RedisService
42
+
43
+ __all__ = [
44
+ "BaseRedisService",
45
+ "RedisService",
46
+ # Operations
47
+ "HashOperations",
48
+ "SetOperations",
49
+ "ListOperations",
50
+ "StringOperations",
51
+ "ZSetOperations",
52
+ "JsonStringOperations",
53
+ "JsonModuleOperations",
54
+ "PipelineOperations",
55
+ # Key Registry
56
+ "BaseRedisKey",
57
+ "UserKey",
58
+ "SessionKey",
59
+ ]
@@ -0,0 +1,69 @@
1
+ """
2
+ codex_platform.redis_service.base
3
+ =================================
4
+ Core Redis infrastructure components.
5
+
6
+ Contains:
7
+ - catch_redis_errors — decorator that converts redis-py errors to domain exceptions
8
+ - BaseRedisService — base class holding a Redis connection
9
+ """
10
+
11
+ import logging
12
+ from collections.abc import Callable
13
+ from functools import wraps
14
+ from typing import Any
15
+
16
+ from redis.asyncio import Redis
17
+ from redis.exceptions import ConnectionError, RedisError, TimeoutError
18
+
19
+ from codex_platform.redis_service.exceptions import RedisConnectionError, RedisServiceError
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ def catch_redis_errors(func: Callable[..., Any]) -> Callable[..., Any]:
25
+ """Decorator that converts redis-py exceptions into typed domain exceptions.
26
+
27
+ Never suppresses errors — always re-raises as a domain exception.
28
+ Preserves the original traceback via ``raise ... from e``.
29
+
30
+ Catches:
31
+ - ``ConnectionError``, ``TimeoutError`` → :exc:`RedisConnectionError`
32
+ - ``RedisError`` → :exc:`RedisServiceError`
33
+
34
+ Does NOT catch:
35
+ ``JSONDecodeError``, ``TypeError`` — data-specific errors are caught
36
+ in individual operations and re-raised as :exc:`RedisDataError`.
37
+ """
38
+
39
+ @wraps(func)
40
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
41
+ try:
42
+ return await func(*args, **kwargs)
43
+ except (ConnectionError, TimeoutError) as e:
44
+ msg = f"Network failure in {func.__name__}: {e}"
45
+ log.exception("Redis | error=connection_failed fn='%s'", func.__name__)
46
+ raise RedisConnectionError(msg) from e
47
+ except RedisError as e:
48
+ msg = f"Operation failed in {func.__name__}: {e}"
49
+ log.exception("Redis | error=operation_failed fn='%s'", func.__name__)
50
+ raise RedisServiceError(msg) from e
51
+
52
+ return wrapper
53
+
54
+
55
+ class BaseRedisService:
56
+ """Base class that holds a Redis connection.
57
+
58
+ Inherit from this class when building a service that wraps a Redis client
59
+ but does not need the full :class:`~codex_platform.redis_service.service.RedisService`
60
+ composition.
61
+ """
62
+
63
+ def __init__(self, client: Redis) -> None:
64
+ """Initialize the service with an existing async Redis client.
65
+
66
+ Args:
67
+ client: An already-constructed ``redis.asyncio.Redis`` instance.
68
+ """
69
+ self.redis_client = client
@@ -0,0 +1,20 @@
1
+ """
2
+ codex_platform.redis_service.exceptions
3
+ =======================================
4
+ Кастомные исключения для работы с Redis.
5
+
6
+ Используй эти классы в бизнес-логике вместо redis.exceptions.*
7
+ чтобы не зависеть от внутренностей redis-py.
8
+ """
9
+
10
+
11
+ class RedisServiceError(Exception):
12
+ """Базовый класс для всех ошибок слоя Redis."""
13
+
14
+
15
+ class RedisConnectionError(RedisServiceError):
16
+ """Ошибки сети: таймауты, недоступность сервера, обрывы связи."""
17
+
18
+
19
+ class RedisDataError(RedisServiceError):
20
+ """Ошибки контента: битый JSON, несовпадение типов."""
@@ -0,0 +1,110 @@
1
+ """
2
+ codex_platform.redis_service.keys
3
+ ================================
4
+ Redis Key Registry — typed, centralized key definitions.
5
+
6
+ Eliminates "magic strings" and prevents key format mismatches
7
+ across multiple containers (bot, workers, API) that share the same Redis.
8
+
9
+ Usage::
10
+
11
+ class UserKey(BaseRedisKey):
12
+ template = "u:{user_id}"
13
+
14
+ key = UserKey().build(user_id=42) # → "u:42"
15
+
16
+ # Or via resolve_key:
17
+ from codex_platform.redis_service.keys import resolve_key
18
+ resolve_key(UserKey(), user_id=42) # → "u:42"
19
+ resolve_key("u:42") # → "u:42"
20
+
21
+ Extending for project-specific keys::
22
+
23
+ from codex_platform.redis_service.keys import BaseRedisKey
24
+
25
+ class JobStatusKey(BaseRedisKey):
26
+ template = "arq:job:{job_id}:status"
27
+ """
28
+
29
+ from abc import ABC, abstractmethod
30
+ from typing import Any
31
+
32
+
33
+ class BaseRedisKey(ABC):
34
+ """
35
+ Abstract base for all Redis key definitions.
36
+
37
+ Subclass and define `template` with ``{placeholder}`` syntax.
38
+ Call ``.build(**kwargs)`` to construct the final key string.
39
+ """
40
+
41
+ @property
42
+ @abstractmethod
43
+ def template(self) -> str:
44
+ """Key template string with ``{placeholder}`` syntax."""
45
+
46
+ def build(self, **kwargs: Any) -> str:
47
+ """Build the final key string by formatting the template with keyword arguments.
48
+
49
+ Args:
50
+ **kwargs: Values for each placeholder defined in ``template``.
51
+
52
+ Returns:
53
+ Fully resolved Redis key string.
54
+
55
+ Raises:
56
+ ValueError: If a required placeholder argument is missing.
57
+ """
58
+ try:
59
+ return self.template.format(**kwargs)
60
+ except KeyError as e:
61
+ raise ValueError(f"Missing key argument: {e} for template '{self.template}'") from e
62
+
63
+ def __repr__(self) -> str:
64
+ return f"{self.__class__.__name__}(template={self.template!r})"
65
+
66
+
67
+ def resolve_key(key: "str | BaseRedisKey", **kwargs: Any) -> str:
68
+ """Resolve a key that is either a plain string or a ``BaseRedisKey`` instance.
69
+
70
+ If ``key`` is a :class:`BaseRedisKey`, calls ``key.build(**kwargs)``.
71
+ If ``key`` is a plain string, returns it unchanged.
72
+
73
+ Args:
74
+ key: Redis key string or a ``BaseRedisKey`` instance.
75
+ **kwargs: Placeholder values forwarded to ``BaseRedisKey.build``.
76
+
77
+ Returns:
78
+ Resolved Redis key string.
79
+
80
+ Example::
81
+
82
+ resolve_key(UserKey(), user_id=42) # → "u:42"
83
+ resolve_key("u:42") # → "u:42"
84
+ """
85
+ return key.build(**kwargs) if isinstance(key, BaseRedisKey) else key
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Shared keys — common across all services
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ class UserKey(BaseRedisKey):
94
+ """User data hash. Args: user_id."""
95
+
96
+ template = "u:{user_id}"
97
+
98
+
99
+ class SessionKey(BaseRedisKey):
100
+ """Session data. Args: token."""
101
+
102
+ template = "sess:{token}"
103
+
104
+
105
+ __all__ = [
106
+ "BaseRedisKey",
107
+ "resolve_key",
108
+ "UserKey",
109
+ "SessionKey",
110
+ ]
@@ -0,0 +1,18 @@
1
+ """
2
+ codex_platform.redis_service.managers
3
+ =======================================
4
+ Business-logic managers built on top of Operations.
5
+
6
+ Create project-specific managers here::
7
+
8
+ from codex_platform.redis_service.managers import BaseRedisManager
9
+ from codex_platform.redis_service.operations import HashOperations
10
+
11
+ class SiteSettingsManager(BaseRedisManager):
12
+ async def get_settings(self) -> dict:
13
+ return await self.hash.get_all("site:settings") or {}
14
+ """
15
+
16
+ from .base_manager import BaseRedisManager
17
+
18
+ __all__ = ["BaseRedisManager"]
@@ -0,0 +1,31 @@
1
+ """
2
+ codex_platform.redis_service.managers.base_manager
3
+ ====================================================
4
+ Base class for Redis business-logic managers.
5
+ """
6
+
7
+ from redis.asyncio import Redis
8
+
9
+ from codex_platform.redis_service.operations import HashOperations, StringOperations
10
+
11
+
12
+ class BaseRedisManager:
13
+ """Base manager that initialises common Redis operation helpers.
14
+
15
+ Subclass and add only the operation attributes your manager actually needs.
16
+
17
+ Example::
18
+
19
+ class SiteSettingsManager(BaseRedisManager):
20
+ async def get_settings(self) -> dict:
21
+ return await self.hash.get_all("site:settings") or {}
22
+ """
23
+
24
+ def __init__(self, redis_client: Redis) -> None:
25
+ """Initialize the manager with an existing async Redis client.
26
+
27
+ Args:
28
+ redis_client: An already-constructed ``redis.asyncio.Redis`` instance.
29
+ """
30
+ self.hash = HashOperations(redis_client)
31
+ self.string = StringOperations(redis_client)
@@ -0,0 +1,19 @@
1
+ from .hash import HashOperations
2
+ from .json_module import JsonModuleOperations
3
+ from .json_string import JsonStringOperations
4
+ from .list_ import ListOperations
5
+ from .pipeline import PipelineOperations
6
+ from .set_ import SetOperations
7
+ from .string import StringOperations
8
+ from .zset import ZSetOperations
9
+
10
+ __all__ = [
11
+ "HashOperations",
12
+ "StringOperations",
13
+ "ListOperations",
14
+ "SetOperations",
15
+ "ZSetOperations",
16
+ "JsonStringOperations",
17
+ "JsonModuleOperations",
18
+ "PipelineOperations",
19
+ ]