telegrinder 0.1.dev170__py3-none-any.whl → 0.1.dev171__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.

Potentially problematic release.


This version of telegrinder might be problematic. Click here for more details.

Files changed (79) hide show
  1. telegrinder/api/abc.py +7 -1
  2. telegrinder/api/api.py +12 -3
  3. telegrinder/api/error.py +2 -1
  4. telegrinder/bot/bot.py +6 -1
  5. telegrinder/bot/cute_types/base.py +144 -17
  6. telegrinder/bot/cute_types/callback_query.py +6 -1
  7. telegrinder/bot/cute_types/chat_member_updated.py +1 -2
  8. telegrinder/bot/cute_types/message.py +23 -11
  9. telegrinder/bot/cute_types/update.py +48 -0
  10. telegrinder/bot/cute_types/utils.py +2 -465
  11. telegrinder/bot/dispatch/__init__.py +2 -3
  12. telegrinder/bot/dispatch/abc.py +6 -3
  13. telegrinder/bot/dispatch/context.py +6 -6
  14. telegrinder/bot/dispatch/dispatch.py +61 -23
  15. telegrinder/bot/dispatch/handler/abc.py +2 -2
  16. telegrinder/bot/dispatch/handler/func.py +36 -17
  17. telegrinder/bot/dispatch/handler/message_reply.py +2 -2
  18. telegrinder/bot/dispatch/middleware/abc.py +2 -2
  19. telegrinder/bot/dispatch/process.py +10 -10
  20. telegrinder/bot/dispatch/return_manager/abc.py +3 -3
  21. telegrinder/bot/dispatch/view/abc.py +12 -15
  22. telegrinder/bot/dispatch/view/box.py +73 -62
  23. telegrinder/bot/dispatch/view/message.py +11 -3
  24. telegrinder/bot/dispatch/view/raw.py +3 -0
  25. telegrinder/bot/dispatch/waiter_machine/machine.py +2 -2
  26. telegrinder/bot/dispatch/waiter_machine/middleware.py +1 -1
  27. telegrinder/bot/dispatch/waiter_machine/short_state.py +2 -1
  28. telegrinder/bot/polling/polling.py +3 -3
  29. telegrinder/bot/rules/abc.py +11 -7
  30. telegrinder/bot/rules/adapter/event.py +7 -4
  31. telegrinder/bot/rules/adapter/node.py +1 -1
  32. telegrinder/bot/rules/command.py +5 -7
  33. telegrinder/bot/rules/func.py +1 -1
  34. telegrinder/bot/rules/fuzzy.py +1 -1
  35. telegrinder/bot/rules/integer.py +1 -2
  36. telegrinder/bot/rules/markup.py +3 -3
  37. telegrinder/bot/rules/message_entities.py +1 -1
  38. telegrinder/bot/rules/node.py +2 -2
  39. telegrinder/bot/rules/regex.py +1 -1
  40. telegrinder/bot/rules/rule_enum.py +1 -1
  41. telegrinder/bot/scenario/checkbox.py +2 -2
  42. telegrinder/model.py +85 -46
  43. telegrinder/modules.py +3 -3
  44. telegrinder/msgspec_utils.py +33 -5
  45. telegrinder/node/__init__.py +20 -11
  46. telegrinder/node/attachment.py +19 -16
  47. telegrinder/node/base.py +120 -24
  48. telegrinder/node/callback_query.py +5 -9
  49. telegrinder/node/command.py +6 -2
  50. telegrinder/node/composer.py +82 -54
  51. telegrinder/node/container.py +4 -4
  52. telegrinder/node/event.py +59 -0
  53. telegrinder/node/me.py +3 -0
  54. telegrinder/node/message.py +6 -10
  55. telegrinder/node/polymorphic.py +11 -12
  56. telegrinder/node/rule.py +27 -5
  57. telegrinder/node/source.py +10 -11
  58. telegrinder/node/text.py +4 -4
  59. telegrinder/node/update.py +1 -2
  60. telegrinder/py.typed +0 -0
  61. telegrinder/tools/__init__.py +2 -2
  62. telegrinder/tools/buttons.py +5 -10
  63. telegrinder/tools/error_handler/error.py +2 -0
  64. telegrinder/tools/error_handler/error_handler.py +1 -1
  65. telegrinder/tools/formatting/spec_html_formats.py +10 -10
  66. telegrinder/tools/global_context/__init__.py +2 -2
  67. telegrinder/tools/global_context/global_context.py +2 -2
  68. telegrinder/tools/global_context/telegrinder_ctx.py +4 -4
  69. telegrinder/tools/keyboard.py +2 -2
  70. telegrinder/tools/loop_wrapper/loop_wrapper.py +39 -5
  71. telegrinder/tools/magic.py +48 -15
  72. telegrinder/types/enums.py +1 -0
  73. telegrinder/types/methods.py +14 -5
  74. telegrinder/types/objects.py +3 -0
  75. {telegrinder-0.1.dev170.dist-info → telegrinder-0.1.dev171.dist-info}/METADATA +2 -2
  76. telegrinder-0.1.dev171.dist-info/RECORD +145 -0
  77. telegrinder-0.1.dev170.dist-info/RECORD +0 -143
  78. {telegrinder-0.1.dev170.dist-info → telegrinder-0.1.dev171.dist-info}/LICENSE +0 -0
  79. {telegrinder-0.1.dev170.dist-info → telegrinder-0.1.dev171.dist-info}/WHEEL +0 -0
@@ -1,10 +1,13 @@
1
1
  from .attachment import Attachment, Audio, Photo, Video
2
- from .base import ComposeError, DataNode, Node, ScalarNode, is_node
2
+ from .base import BaseNode, ComposeError, DataNode, Node, ScalarNode, is_node, node_impl
3
+ from .callback_query import CallbackQueryNode
3
4
  from .command import CommandInfo
4
5
  from .composer import Composition, NodeCollection, NodeSession, compose_node, compose_nodes
5
6
  from .container import ContainerNode
7
+ from .event import EventNode
6
8
  from .me import Me
7
9
  from .message import MessageNode
10
+ from .polymorphic import Polymorphic, impl
8
11
  from .rule import RuleChain
9
12
  from .scope import GLOBAL, PER_CALL, PER_EVENT, NodeScope, global_node, per_call, per_event
10
13
  from .source import ChatSource, Source, UserSource
@@ -15,35 +18,41 @@ from .update import UpdateNode
15
18
  __all__ = (
16
19
  "Attachment",
17
20
  "Audio",
21
+ "BaseNode",
22
+ "CallbackQueryNode",
18
23
  "ChatSource",
24
+ "CommandInfo",
19
25
  "ComposeError",
26
+ "Composition",
20
27
  "ContainerNode",
21
28
  "DataNode",
29
+ "EventNode",
30
+ "GLOBAL",
31
+ "Me",
22
32
  "MessageNode",
23
33
  "Node",
24
34
  "NodeCollection",
35
+ "NodeScope",
25
36
  "NodeSession",
37
+ "PER_CALL",
38
+ "PER_EVENT",
26
39
  "Photo",
40
+ "Polymorphic",
27
41
  "RuleChain",
28
42
  "ScalarNode",
29
43
  "Source",
30
44
  "Text",
31
45
  "TextInteger",
32
- "UserSource",
33
46
  "UpdateNode",
47
+ "UserSource",
34
48
  "Video",
35
49
  "compose_node",
50
+ "compose_nodes",
36
51
  "generate_node",
37
- "Composition",
52
+ "global_node",
53
+ "impl",
38
54
  "is_node",
39
- "compose_nodes",
40
- "NodeScope",
41
- "PER_CALL",
42
- "PER_EVENT",
55
+ "node_impl",
43
56
  "per_call",
44
57
  "per_event",
45
- "CommandInfo",
46
- "GLOBAL",
47
- "global_node",
48
- "Me",
49
58
  )
@@ -1,30 +1,33 @@
1
1
  import dataclasses
2
2
  import typing
3
3
 
4
- from fntypes import Option, Some
4
+ from fntypes.co import Option, Some
5
5
  from fntypes.option import Nothing
6
6
 
7
7
  import telegrinder.types
8
+ from telegrinder.node.base import ComposeError, DataNode, ScalarNode
9
+ from telegrinder.node.message import MessageNode
8
10
 
9
- from .base import ComposeError, DataNode, ScalarNode
10
- from .message import MessageNode
11
11
 
12
-
13
- @dataclasses.dataclass
12
+ @dataclasses.dataclass(slots=True)
14
13
  class Attachment(DataNode):
15
14
  attachment_type: typing.Literal["audio", "document", "photo", "poll", "video"]
16
15
  audio: Option[telegrinder.types.Audio] = dataclasses.field(
17
- default_factory=lambda: Nothing(), kw_only=True
16
+ default_factory=lambda: Nothing(),
17
+ kw_only=True,
18
18
  )
19
19
  document: Option[telegrinder.types.Document] = dataclasses.field(
20
- default_factory=lambda: Nothing(), kw_only=True
20
+ default_factory=lambda: Nothing(),
21
+ kw_only=True,
21
22
  )
22
23
  photo: Option[list[telegrinder.types.PhotoSize]] = dataclasses.field(
23
- default_factory=lambda: Nothing(), kw_only=True
24
+ default_factory=lambda: Nothing(),
25
+ kw_only=True,
24
26
  )
25
27
  poll: Option[telegrinder.types.Poll] = dataclasses.field(default_factory=lambda: Nothing(), kw_only=True)
26
28
  video: Option[telegrinder.types.Video] = dataclasses.field(
27
- default_factory=lambda: Nothing(), kw_only=True
29
+ default_factory=lambda: Nothing(),
30
+ kw_only=True,
28
31
  )
29
32
 
30
33
  @classmethod
@@ -33,17 +36,17 @@ class Attachment(DataNode):
33
36
  match getattr(message, attachment_type, Nothing()):
34
37
  case Some(attachment):
35
38
  return cls(attachment_type, **{attachment_type: Some(attachment)})
36
- return cls.compose_error("No attachment found in message")
39
+ return cls.compose_error("No attachment found in message.")
37
40
 
38
41
 
39
- @dataclasses.dataclass
42
+ @dataclasses.dataclass(slots=True)
40
43
  class Photo(DataNode):
41
44
  sizes: list[telegrinder.types.PhotoSize]
42
45
 
43
46
  @classmethod
44
47
  async def compose(cls, attachment: Attachment) -> typing.Self:
45
48
  if not attachment.photo:
46
- raise ComposeError("Attachment is not an photo")
49
+ raise ComposeError("Attachment is not a photo.")
47
50
  return cls(attachment.photo.unwrap())
48
51
 
49
52
 
@@ -51,7 +54,7 @@ class Video(ScalarNode, telegrinder.types.Video):
51
54
  @classmethod
52
55
  async def compose(cls, attachment: Attachment) -> telegrinder.types.Video:
53
56
  if not attachment.video:
54
- raise ComposeError("Attachment is not an video")
57
+ raise ComposeError("Attachment is not a video.")
55
58
  return attachment.video.unwrap()
56
59
 
57
60
 
@@ -59,7 +62,7 @@ class Audio(ScalarNode, telegrinder.types.Audio):
59
62
  @classmethod
60
63
  async def compose(cls, attachment: Attachment) -> telegrinder.types.Audio:
61
64
  if not attachment.audio:
62
- raise ComposeError("Attachment is not an audio")
65
+ raise ComposeError("Attachment is not an audio.")
63
66
  return attachment.audio.unwrap()
64
67
 
65
68
 
@@ -67,7 +70,7 @@ class Document(ScalarNode, telegrinder.types.Document):
67
70
  @classmethod
68
71
  async def compose(cls, attachment: Attachment) -> telegrinder.types.Document:
69
72
  if not attachment.document:
70
- raise ComposeError("Attachment is not an document")
73
+ raise ComposeError("Attachment is not a document.")
71
74
  return attachment.document.unwrap()
72
75
 
73
76
 
@@ -75,7 +78,7 @@ class Poll(ScalarNode, telegrinder.types.Poll):
75
78
  @classmethod
76
79
  async def compose(cls, attachment: Attachment) -> telegrinder.types.Poll:
77
80
  if not attachment.poll:
78
- raise ComposeError("Attachment is not an poll")
81
+ raise ComposeError("Attachment is not a poll.")
79
82
  return attachment.poll.unwrap()
80
83
 
81
84
 
telegrinder/node/base.py CHANGED
@@ -1,19 +1,70 @@
1
1
  import abc
2
2
  import inspect
3
3
  import typing
4
+ from types import AsyncGeneratorType
5
+
6
+ from telegrinder.api.api import API
7
+ from telegrinder.bot.cute_types.update import UpdateCute
8
+ from telegrinder.bot.dispatch.context import Context
9
+ from telegrinder.node.scope import NodeScope
10
+ from telegrinder.tools.magic import (
11
+ NODE_IMPL_MARK,
12
+ cache_magic_value,
13
+ get_annotations,
14
+ get_impls_by_key,
15
+ magic_bundle,
16
+ node_impl,
17
+ )
4
18
 
5
- from telegrinder.tools.magic import get_annotations
19
+ ComposeResult: typing.TypeAlias = typing.Awaitable[typing.Any] | typing.AsyncGenerator[typing.Any, None]
6
20
 
7
- from .scope import NodeScope
8
21
 
9
- ComposeResult: typing.TypeAlias = (
10
- typing.Coroutine[typing.Any, typing.Any, typing.Any]
11
- | typing.AsyncGenerator[typing.Any, None]
12
- | typing.Any
13
- )
22
+ def is_node(maybe_node: type[typing.Any]) -> typing.TypeGuard[type["Node"]]:
23
+ maybe_node = typing.get_origin(maybe_node) or maybe_node
24
+ return (
25
+ isinstance(maybe_node, type)
26
+ and issubclass(maybe_node, Node)
27
+ or isinstance(maybe_node, Node)
28
+ or hasattr(maybe_node, "as_node")
29
+ )
30
+
31
+
32
+ @cache_magic_value("__compose_annotations__")
33
+ def get_compose_annotations(function: typing.Callable[..., typing.Any]) -> dict[str, typing.Any]:
34
+ return {k: v for k, v in get_annotations(function).items() if not is_node(v)}
35
+
14
36
 
37
+ @cache_magic_value("__nodes__")
38
+ def get_nodes(function: typing.Callable[..., typing.Any]) -> dict[str, type["Node"]]:
39
+ return {k: v for k, v in get_annotations(function).items() if is_node(v)}
15
40
 
16
- class ComposeError(BaseException): ...
41
+
42
+ @cache_magic_value("__is_generator__")
43
+ def is_generator(function: typing.Callable[..., typing.Any]) -> typing.TypeGuard[AsyncGeneratorType[typing.Any, None]]:
44
+ return inspect.isasyncgenfunction(function)
45
+
46
+
47
+ def get_node_impls(node_cls: type["Node"]) -> dict[str, typing.Any]:
48
+ if not hasattr(node_cls, "__node_impls__"):
49
+ impls = get_impls_by_key(node_cls, NODE_IMPL_MARK)
50
+ if issubclass(node_cls, BaseNode):
51
+ impls |= get_impls_by_key(BaseNode, NODE_IMPL_MARK)
52
+ setattr(node_cls, "__node_impls__", impls)
53
+ return getattr(node_cls, "__node_impls__")
54
+
55
+
56
+ def get_node_impl(
57
+ node: type[typing.Any],
58
+ node_impls: dict[str, typing.Callable[..., typing.Any]],
59
+ ) -> typing.Callable[..., typing.Any] | None:
60
+ for n_impl in node_impls.values():
61
+ if "return" in n_impl.__annotations__ and node is n_impl.__annotations__["return"]:
62
+ return n_impl
63
+ return None
64
+
65
+
66
+ class ComposeError(BaseException):
67
+ pass
17
68
 
18
69
 
19
70
  class Node(abc.ABC):
@@ -25,13 +76,46 @@ class Node(abc.ABC):
25
76
  def compose(cls, *args, **kwargs) -> ComposeResult:
26
77
  pass
27
78
 
79
+ @classmethod
80
+ async def compose_annotation(
81
+ cls,
82
+ annotation: typing.Any,
83
+ update: UpdateCute,
84
+ ctx: Context,
85
+ ) -> typing.Any:
86
+ orig_annotation: type[typing.Any] = typing.get_origin(annotation) or annotation
87
+ n_impl = get_node_impl(orig_annotation, cls.get_node_impls())
88
+ if n_impl is None:
89
+ raise ComposeError(f"Node implementation for {orig_annotation!r} not found.")
90
+
91
+ result = n_impl(
92
+ cls,
93
+ **magic_bundle(
94
+ n_impl,
95
+ {"update": update, "context": ctx},
96
+ start_idx=0,
97
+ bundle_ctx=False,
98
+ ),
99
+ )
100
+ if inspect.isawaitable(result):
101
+ return await result
102
+ return result
103
+
28
104
  @classmethod
29
105
  def compose_error(cls, error: str | None = None) -> typing.NoReturn:
30
106
  raise ComposeError(error)
31
107
 
32
108
  @classmethod
33
- def get_sub_nodes(cls) -> dict[str, type[typing.Self]]:
34
- return get_annotations(cls.compose)
109
+ def get_sub_nodes(cls) -> dict[str, type["Node"]]:
110
+ return get_nodes(cls.compose)
111
+
112
+ @classmethod
113
+ def get_compose_annotations(cls) -> dict[str, typing.Any]:
114
+ return get_compose_annotations(cls.compose)
115
+
116
+ @classmethod
117
+ def get_node_impls(cls) -> dict[str, typing.Callable[..., typing.Any]]:
118
+ return get_node_impls(cls)
35
119
 
36
120
  @classmethod
37
121
  def as_node(cls) -> type[typing.Self]:
@@ -39,10 +123,29 @@ class Node(abc.ABC):
39
123
 
40
124
  @classmethod
41
125
  def is_generator(cls) -> bool:
42
- return inspect.isasyncgenfunction(cls.compose)
126
+ return is_generator(cls.compose)
127
+
128
+
129
+ class BaseNode(Node, abc.ABC):
130
+ @classmethod
131
+ @abc.abstractmethod
132
+ def compose(cls, *args, **kwargs) -> ComposeResult:
133
+ pass
134
+
135
+ @node_impl
136
+ def compose_api(cls, update: UpdateCute) -> API:
137
+ return update.ctx_api
138
+
139
+ @node_impl
140
+ def compose_context(cls, context: Context) -> Context:
141
+ return context
43
142
 
143
+ @node_impl
144
+ def compose_update(cls, update: UpdateCute) -> UpdateCute:
145
+ return update
44
146
 
45
- class DataNode(Node, abc.ABC):
147
+
148
+ class DataNode(BaseNode, abc.ABC):
46
149
  node = "data"
47
150
 
48
151
  @typing.dataclass_transform()
@@ -52,7 +155,7 @@ class DataNode(Node, abc.ABC):
52
155
  pass
53
156
 
54
157
 
55
- class ScalarNodeProto(Node, abc.ABC):
158
+ class ScalarNodeProto(BaseNode, abc.ABC):
56
159
  @classmethod
57
160
  @abc.abstractmethod
58
161
  async def compose(cls, *args, **kwargs) -> ComposeResult:
@@ -68,7 +171,6 @@ if typing.TYPE_CHECKING:
68
171
  pass
69
172
 
70
173
  else:
71
-
72
174
  def create_node(cls, bases, dct):
73
175
  dct.update(cls.__dict__)
74
176
  return type(cls.__name__, bases, dct)
@@ -87,21 +189,15 @@ else:
87
189
  pass
88
190
 
89
191
 
90
- def is_node(maybe_node: type[typing.Any]) -> typing.TypeGuard[type[Node]]:
91
- maybe_node = typing.get_origin(maybe_node) or maybe_node
92
- return (
93
- isinstance(maybe_node, type)
94
- and issubclass(maybe_node, Node)
95
- or isinstance(maybe_node, Node)
96
- or hasattr(maybe_node, "as_node")
97
- )
98
-
99
-
100
192
  __all__ = (
193
+ "BaseNode",
101
194
  "ComposeError",
102
195
  "DataNode",
103
196
  "Node",
104
197
  "SCALAR_NODE",
105
198
  "ScalarNode",
199
+ "ScalarNodeProto",
200
+ "get_compose_annotations",
201
+ "get_nodes",
106
202
  "is_node",
107
203
  )
@@ -1,18 +1,14 @@
1
- from telegrinder.bot.cute_types import CallbackQueryCute
2
-
3
- from .base import ComposeError, ScalarNode
4
- from .update import UpdateNode
1
+ from telegrinder.bot.cute_types.callback_query import CallbackQueryCute
2
+ from telegrinder.node.base import ComposeError, ScalarNode
3
+ from telegrinder.node.update import UpdateNode
5
4
 
6
5
 
7
6
  class CallbackQueryNode(ScalarNode, CallbackQueryCute):
8
7
  @classmethod
9
8
  async def compose(cls, update: UpdateNode) -> CallbackQueryCute:
10
9
  if not update.callback_query:
11
- raise ComposeError
12
- return CallbackQueryCute(
13
- **update.callback_query.unwrap().to_dict(),
14
- api=update.api,
15
- )
10
+ raise ComposeError("Update is not a callback_query.")
11
+ return update.callback_query.unwrap()
16
12
 
17
13
 
18
14
  __all__ = ("CallbackQueryNode",)
@@ -1,3 +1,4 @@
1
+ import typing
1
2
  from dataclasses import dataclass, field
2
3
 
3
4
  from fntypes import Nothing, Option, Some
@@ -16,14 +17,17 @@ def cut_mention(text: str) -> tuple[str, Option[str]]:
16
17
  return left, Some(right) if right else Nothing()
17
18
 
18
19
 
19
- @dataclass
20
+ @dataclass(slots=True)
20
21
  class CommandInfo(DataNode):
21
22
  name: str
22
23
  arguments: str
23
24
  mention: Option[str] = field(default_factory=Nothing)
24
25
 
25
26
  @classmethod
26
- async def compose(cls, text: Text):
27
+ async def compose(cls, text: Text) -> typing.Self:
27
28
  name, arguments = single_split(text, separator=" ")
28
29
  name, mention = cut_mention(name)
29
30
  return cls(name, arguments, mention)
31
+
32
+
33
+ __all__ = ("CommandInfo", "cut_mention", "single_split")
@@ -1,11 +1,20 @@
1
+ import dataclasses
1
2
  import typing
2
3
 
3
- from telegrinder.api.api import API
4
- from telegrinder.bot.cute_types import UpdateCute
4
+ from fntypes.error import UnwrapError
5
+
6
+ from telegrinder.bot.cute_types.update import UpdateCute
5
7
  from telegrinder.bot.dispatch.context import Context
6
- from telegrinder.node.base import ComposeError, Node
7
- from telegrinder.node.scope import NodeScope
8
- from telegrinder.tools.magic import get_annotations, magic_bundle
8
+ from telegrinder.modules import logger
9
+ from telegrinder.node.base import (
10
+ BaseNode,
11
+ ComposeError,
12
+ Node,
13
+ NodeScope,
14
+ get_compose_annotations,
15
+ get_nodes,
16
+ )
17
+ from telegrinder.tools.magic import magic_bundle
9
18
 
10
19
  CONTEXT_STORE_NODES_KEY = "node_ctx"
11
20
 
@@ -22,80 +31,86 @@ async def compose_node(
22
31
  for name, subnode in node.get_sub_nodes().items():
23
32
  if subnode in node_ctx:
24
33
  context.sessions[name] = node_ctx[subnode]
25
- elif subnode is UpdateCute:
26
- context.sessions[name] = NodeSession(None, update, {})
27
- elif subnode is API:
28
- context.sessions[name] = NodeSession(None, update.ctx_api, {})
29
- elif subnode is Context:
30
- context.sessions[name] = NodeSession(None, ctx, {})
31
34
  else:
32
35
  context.sessions[name] = await compose_node(subnode, update, ctx)
33
36
 
34
37
  if getattr(subnode, "scope", None) is NodeScope.PER_EVENT:
35
38
  node_ctx[subnode] = context.sessions[name]
36
39
 
37
- generator: typing.AsyncGenerator | None
40
+ for name, annotation in node.get_compose_annotations().items():
41
+ context.sessions[name] = NodeSession(
42
+ None,
43
+ await node.compose_annotation(annotation, update, ctx),
44
+ {},
45
+ )
38
46
 
39
47
  if node.is_generator():
40
- generator = typing.cast(typing.AsyncGenerator, node.compose(**context.values()))
48
+ generator = typing.cast(typing.AsyncGenerator[typing.Any, None], node.compose(**context.values()))
41
49
  value = await generator.asend(None)
42
50
  else:
43
51
  generator = None
44
- value = await node.compose(**context.values()) # type: ignore
52
+ value = await typing.cast(typing.Awaitable[typing.Any], node.compose(**context.values()))
45
53
 
46
54
  return NodeSession(_node, value, context.sessions, generator)
47
55
 
48
56
 
49
57
  async def compose_nodes(
50
- node_types: dict[str, type[Node]],
51
58
  update: UpdateCute,
52
59
  ctx: Context,
53
- ) -> typing.Optional["NodeCollection"]:
54
- nodes: dict[str, NodeSession] = {}
60
+ nodes: dict[str, type[Node]],
61
+ node_class: type[Node] | None = None,
62
+ context_annotations: dict[str, typing.Any] | None = None,
63
+ ) -> "NodeCollection | None":
64
+ node_sessions: dict[str, NodeSession] = {}
55
65
  node_ctx: dict[type[Node], "NodeSession"] = ctx.get_or_set(CONTEXT_STORE_NODES_KEY, {})
56
66
 
57
- for name, node_t in node_types.items():
58
- scope = getattr(node_t, "scope", None)
59
- try:
67
+ try:
68
+ for name, node_t in nodes.items():
69
+ scope = getattr(node_t, "scope", None)
70
+
60
71
  if scope is NodeScope.PER_EVENT and node_t in node_ctx:
61
- nodes[name] = node_ctx[node_t]
72
+ node_sessions[name] = node_ctx[node_t]
62
73
  continue
63
74
  elif scope is NodeScope.GLOBAL and hasattr(node_t, "_value"):
64
- nodes[name] = getattr(node_t, "_value")
75
+ node_sessions[name] = getattr(node_t, "_value")
65
76
  continue
66
77
 
67
- nodes[name] = await compose_node(
68
- node_t,
69
- update,
70
- ctx,
71
- )
78
+ node_sessions[name] = await compose_node(node_t, update, ctx)
72
79
 
73
80
  if scope is NodeScope.PER_EVENT:
74
- node_ctx[node_t] = nodes[name]
81
+ node_ctx[node_t] = node_sessions[name]
75
82
  elif scope is NodeScope.GLOBAL:
76
- setattr(node_t, "_value", nodes[name])
83
+ setattr(node_t, "_value", node_sessions[name])
84
+ except (ComposeError, UnwrapError) as exc:
85
+ logger.debug(f"Composing node (name={name!r}, node_class={node_t!r}) failed with error: {str(exc)!r}")
86
+ await NodeCollection(node_sessions).close_all()
87
+ return None
88
+
89
+ if context_annotations:
90
+ node_class = node_class or BaseNode
77
91
 
78
- except ComposeError:
79
- await NodeCollection(nodes).close_all()
92
+ try:
93
+ for name, annotation in context_annotations.items():
94
+ node_sessions[name] = await node_class.compose_annotation(annotation, update, ctx)
95
+ except (ComposeError, UnwrapError) as exc:
96
+ logger.debug(
97
+ f"Composing context annotation (name={name!r}, annotation={annotation!r}) failed with error: {str(exc)!r}",
98
+ )
99
+ await NodeCollection(node_sessions).close_all()
80
100
  return None
81
- return NodeCollection(nodes)
101
+
102
+ return NodeCollection(node_sessions)
82
103
 
83
104
 
105
+ @dataclasses.dataclass(slots=True, repr=False)
84
106
  class NodeSession:
85
- def __init__(
86
- self,
87
- node_type: type[Node] | None,
88
- value: typing.Any,
89
- subnodes: dict[str, typing.Self],
90
- generator: typing.AsyncGenerator[typing.Any, None] | None = None,
91
- ):
92
- self.node_type = node_type
93
- self.value = value
94
- self.subnodes = subnodes
95
- self.generator = generator
107
+ node_type: type[Node] | None
108
+ value: typing.Any
109
+ subnodes: dict[str, typing.Self]
110
+ generator: typing.AsyncGenerator[typing.Any, None] | None = None
96
111
 
97
112
  def __repr__(self) -> str:
98
- return f"<{self.__class__.__name__}: {self.value}" + ("ACTIVE>" if self.generator else ">")
113
+ return f"<{self.__class__.__name__}: {self.value!r}" + (" ACTIVE>" if self.generator else ">")
99
114
 
100
115
  async def close(
101
116
  self,
@@ -117,11 +132,13 @@ class NodeSession:
117
132
 
118
133
 
119
134
  class NodeCollection:
135
+ __slots__ = ("sessions",)
136
+
120
137
  def __init__(self, sessions: dict[str, NodeSession]) -> None:
121
138
  self.sessions = sessions
122
139
 
123
140
  def __repr__(self) -> str:
124
- return "<{}: sessions={}>".format(self.__class__.__name__, self.sessions)
141
+ return "<{}: sessions={!r}>".format(self.__class__.__name__, self.sessions)
125
142
 
126
143
  def values(self) -> dict[str, typing.Any]:
127
144
  return {name: session.value for name, session in self.sessions.items()}
@@ -135,26 +152,37 @@ class NodeCollection:
135
152
  await session.close(with_value, scopes=scopes)
136
153
 
137
154
 
155
+ @dataclasses.dataclass(slots=True, repr=False)
138
156
  class Composition:
139
- nodes: dict[str, type[Node]]
157
+ func: typing.Callable[..., typing.Any]
158
+ is_blocking: bool
159
+ node_class: type[Node] = dataclasses.field(default_factory=lambda: BaseNode)
160
+ nodes: dict[str, type[Node]] = dataclasses.field(init=False)
161
+ context_annotations: dict[str, typing.Any] = dataclasses.field(init=False)
140
162
 
141
- def __init__(self, func: typing.Callable[..., typing.Any], is_blocking: bool) -> None:
142
- self.func = func
143
- self.nodes = get_annotations(func)
144
- self.is_blocking = is_blocking
163
+ def __post_init__(self) -> None:
164
+ self.nodes = get_nodes(self.func)
165
+ self.context_annotations = get_compose_annotations(self.func)
145
166
 
146
167
  def __repr__(self) -> str:
147
- return "<{}: for function={!r} with nodes={}>".format(
168
+ return "<{}: for function={!r} with nodes={!r}, context_annotations={!r}>".format(
148
169
  ("blocking " if self.is_blocking else "") + self.__class__.__name__,
149
- self.func.__name__,
170
+ self.func.__qualname__,
150
171
  self.nodes,
172
+ self.context_annotations,
151
173
  )
152
174
 
153
175
  async def compose_nodes(self, update: UpdateCute, context: Context) -> NodeCollection | None:
154
- return await compose_nodes(self.nodes, update, context)
176
+ return await compose_nodes(
177
+ update=update,
178
+ ctx=context,
179
+ nodes=self.nodes,
180
+ node_class=self.node_class,
181
+ context_annotations=self.context_annotations,
182
+ )
155
183
 
156
184
  async def __call__(self, **kwargs: typing.Any) -> typing.Any:
157
185
  return await self.func(**magic_bundle(self.func, kwargs, start_idx=0, bundle_ctx=False)) # type: ignore
158
186
 
159
187
 
160
- __all__ = ("NodeCollection", "NodeSession", "Composition", "compose_node", "compose_nodes")
188
+ __all__ = ("Composition", "NodeCollection", "NodeSession", "compose_node", "compose_nodes")
@@ -1,17 +1,17 @@
1
1
  import typing
2
2
 
3
- from .base import Node
3
+ from telegrinder.node.base import BaseNode, Node
4
4
 
5
5
 
6
- class ContainerNode(Node):
6
+ class ContainerNode(BaseNode):
7
7
  linked_nodes: typing.ClassVar[list[type[Node]]]
8
8
 
9
9
  @classmethod
10
- async def compose(cls, **kw) -> tuple["Node", ...]:
10
+ async def compose(cls, **kw) -> tuple[Node, ...]:
11
11
  return tuple(t[1] for t in sorted(kw.items(), key=lambda t: t[0]))
12
12
 
13
13
  @classmethod
14
- def get_sub_nodes(cls) -> dict[str, type["Node"]]:
14
+ def get_sub_nodes(cls) -> dict[str, type[Node]]:
15
15
  return {f"node_{i}": node_t for i, node_t in enumerate(cls.linked_nodes)}
16
16
 
17
17
  @classmethod