turnstack 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.
@@ -0,0 +1,273 @@
1
+ """
2
+ turnstack.handlers.base
3
+ =======================
4
+ Abstract base for all node handlers.
5
+
6
+ Key responsibilities:
7
+ - ``handle()`` — implemented by each subclass
8
+ - ``_enter_node()`` — shared logic for entering any node after a transition
9
+ (handles chained actions, routers, etc.)
10
+ - ``_transition_to()`` — moves session forward, pushes nav stack
11
+ - ``_go_back()`` — pops nav stack, moves session backward
12
+ - ``_go_home()`` — clears nav stack, returns to entry node
13
+ - ``_render()`` — builds a Reply for any renderable node type
14
+ """
15
+
16
+ from __future__ import annotations
17
+ from abc import ABC, abstractmethod
18
+ import inspect
19
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
20
+
21
+ from ..message import IncomingMessage
22
+ from ..reply import Reply, ReplyOption
23
+ from ..session import Session
24
+
25
+ if TYPE_CHECKING:
26
+ from ..tree import FlowTree
27
+
28
+
29
+ class NodeHandler(ABC):
30
+ """Abstract handler for a single node type."""
31
+
32
+ @abstractmethod
33
+ async def handle(
34
+ self,
35
+ node: Dict[str, Any],
36
+ session: Session,
37
+ message: IncomingMessage,
38
+ tree: "FlowTree",
39
+ ) -> Reply:
40
+ """Process the incoming message for this node and return a Reply."""
41
+ ...
42
+
43
+ # ── transition helpers ────────────────────────────────────────────────
44
+
45
+ def _transition_to(self, session: Session, next_node: str) -> None:
46
+ """
47
+ Move session to ``next_node``, pushing the current node onto nav_stack.
48
+
49
+ Does NOT push if going to "__end__" or if the destination is the same node.
50
+ """
51
+ if not next_node or next_node == "__end__":
52
+ return
53
+ if next_node != session.current_node:
54
+ session.nav_stack.append(session.current_node)
55
+ session.current_node = next_node
56
+
57
+ def _go_back(self, session: Session, entry_node: str) -> None:
58
+ """Pop the nav stack and go to the previous node (or entry if at root)."""
59
+ prev = session.go_back()
60
+ session.current_node = prev if prev else entry_node
61
+
62
+ def _go_home(self, session: Session, entry_node: str) -> None:
63
+ """Jump to entry node, clearing the nav stack and collected data."""
64
+ session.go_home(entry_node)
65
+
66
+ # ── shared enter-node logic ───────────────────────────────────────────
67
+
68
+ async def _enter_node(
69
+ self,
70
+ session: Session,
71
+ tree: "FlowTree",
72
+ _depth: int = 0,
73
+ ) -> Reply:
74
+ """
75
+ Render the node ``session.current_node`` is pointing at.
76
+
77
+ Handles transparent chaining:
78
+ - router → evaluates silently, enters the target node
79
+ - action → runs fn, then enters next node
80
+ - media → generates and returns media reply
81
+ - anything else → renders and returns
82
+
83
+ ``_depth`` guards against infinite loops (max 10 silent hops).
84
+ """
85
+ if _depth > 10:
86
+ return self._error(session, "Infinite routing loop detected.")
87
+
88
+ node = tree.get(session.current_node)
89
+ if not node:
90
+ return self._error(session, f"Node '{session.current_node}' not found.")
91
+
92
+ t = node.get("type")
93
+
94
+ if t == "router":
95
+ from .router import RouterHandler
96
+ return await RouterHandler()._run_router(node, session, tree, _depth)
97
+
98
+ if t == "action":
99
+ from .action import ActionHandler
100
+ return await ActionHandler()._run_action(node, session, tree, _depth)
101
+
102
+ if t == "media":
103
+ from .media_handler import MediaHandler
104
+ dummy = IncomingMessage(user_id=session.user_id, type="text", text="")
105
+ return await MediaHandler().handle(node, session, dummy, tree)
106
+
107
+ # Everything else (menu, confirm, input, list): render
108
+ return self._render(node, session)
109
+
110
+ # ── reply builders ────────────────────────────────────────────────────
111
+
112
+ def _render(self, node: Dict[str, Any], session: Session) -> Reply:
113
+ """Build a Reply for any renderable node type."""
114
+ t = node.get("type")
115
+
116
+ if t == "menu":
117
+ return self._render_menu(node, session)
118
+
119
+ if t == "confirm":
120
+ return self._render_confirm(node, session)
121
+
122
+ if t == "input":
123
+ return self._render_input(node, session)
124
+
125
+ if t == "list":
126
+ return self._render_list(node, session)
127
+
128
+ # Media is a transparent/auto-executing node (like router/action).
129
+ # It must be dispatched by the engine, never rendered statically.
130
+ # If we reach here it means _render_current had a gap — surface a
131
+ # clear error instead of going silent.
132
+ if t == "media":
133
+ return self._error(
134
+ session,
135
+ "MediaReply node reached _render() — this is a bug. "
136
+ "Ensure engine._render_current dispatches 'media' directly.",
137
+ )
138
+
139
+ return self._error(session, f"Cannot render node type '{t}'.")
140
+
141
+ def _render_menu(self, node: Dict[str, Any], session: Session) -> Reply:
142
+ # Delegate to MenuHandler so pagination is applied consistently
143
+ # whether we arrive via a cold entry (router/action chain) or
144
+ # directly through MenuHandler.handle().
145
+ from .menu import MenuHandler
146
+ all_options = node.get("options", [])
147
+ pkey = f"menu_{session.current_node}_page"
148
+ page = session.pagination.get(pkey, 0)
149
+
150
+ from .menu import MAX_MENU_ROWS
151
+ items_per_page = MAX_MENU_ROWS - 2 if len(all_options) > MAX_MENU_ROWS else MAX_MENU_ROWS
152
+ total_pages = max(1, (len(all_options) + items_per_page - 1) // items_per_page)
153
+ if page >= total_pages:
154
+ page = max(0, total_pages - 1)
155
+ session.pagination[pkey] = page
156
+
157
+ return MenuHandler()._render_menu_page(node, session, all_options, page, total_pages)
158
+
159
+ def _render_confirm(self, node: Dict[str, Any], session: Session) -> Reply:
160
+ text = node.get("text", "")
161
+ body = text(session.collected) if callable(text) else text
162
+
163
+ options = node.get("options", [])
164
+ reply_options: List[ReplyOption] = []
165
+
166
+ for opt in options:
167
+ label = opt.get("label", "") if isinstance(opt, dict) else opt[0]
168
+ value = opt.get("value", opt.get("next", "")) if isinstance(opt, dict) else opt[1]
169
+ reply_options.append(ReplyOption(label=label, value=value))
170
+
171
+ return Reply(
172
+ type="text",
173
+ body=body,
174
+ phone=session.user_id,
175
+ options=reply_options,
176
+ node_type="confirm",
177
+ suggested_replies=[o.value for o in reply_options],
178
+ current_node=session.current_node,
179
+ session_state=session.lifecycle_state,
180
+ )
181
+
182
+ def _render_input(self, node: Dict[str, Any], session: Session) -> Reply:
183
+ """
184
+ Render the current field inside an input node.
185
+
186
+ Two cases:
187
+ - Cold entry (no idx in pagination): reset the node and render field 0.
188
+ - Back-nav within the node (idx already set by engine): skip reset,
189
+ render whichever field the engine stepped back to.
190
+ """
191
+ from .input import InputHandler, _IDX_KEY_TMPL, _flatten_fields
192
+ fields = node.get("fields", [])
193
+
194
+ if not fields:
195
+ return self._error(session, "Input: no fields defined.")
196
+
197
+ handler = InputHandler()
198
+ idx_key = _IDX_KEY_TMPL.format(node=session.current_node)
199
+
200
+ if idx_key in session.pagination:
201
+ # Back-nav has already positioned the index -- just re-render that field.
202
+ # Flatten so BranchFields that are now active are included and the
203
+ # step counter reflects the real remaining work.
204
+ idx = session.pagination[idx_key]
205
+ fields = _flatten_fields(fields, session)
206
+ else:
207
+ # Genuine cold entry -- reset first (wipes collected + pagination),
208
+ # then flatten against the now-clean session so branch conditions
209
+ # that depended on stale answers from a previous run evaluate to
210
+ # False. Without the re-flatten the BranchField object itself is
211
+ # counted as a field, giving "Step 1 of 8" instead of "Step 1 of 7".
212
+ handler._reset_input(session, node, fields)
213
+ fields = _flatten_fields(fields, session)
214
+ idx = 0
215
+
216
+ return handler._render_field(node, session, fields, idx)
217
+
218
+ def _render_list(self, node: Dict[str, Any], session: Session) -> Reply:
219
+ from .list_handler import ListHandler, MAX_ROWS
220
+
221
+ fetch = node.get("fetch")
222
+ if not fetch:
223
+ return self._error(session, "ListNode has no 'fetch' function.")
224
+
225
+ page_size = min(MAX_ROWS, max(1, node.get("page_size", 8)))
226
+ interactive = node.get("interactive", False)
227
+
228
+ sig = inspect.signature(fetch)
229
+ paginated_mode = len(sig.parameters) >= 3
230
+
231
+ pkey = f"list_{session.current_node}_page"
232
+ page = session.pagination.get(pkey, 0)
233
+
234
+ if paginated_mode:
235
+ try:
236
+ result = fetch(session, page, page_size)
237
+ except Exception as exc:
238
+ return self._error(session, f"Paginated fetch raised: {exc}")
239
+ if isinstance(result, tuple) and len(result) == 2:
240
+ items_page, total_items = result
241
+ elif isinstance(result, dict):
242
+ items_page = result.get("items", [])
243
+ total_items = result.get("total", 0)
244
+ else:
245
+ return self._error(session, "Paginated fetch must return (items, total) or dict.")
246
+ items_page = list(items_page or [])
247
+ else:
248
+ try:
249
+ items = fetch(session)
250
+ except Exception as exc:
251
+ return self._error(session, f"Fetch raised: {exc}")
252
+ items = list(items or [])
253
+ total_items = len(items)
254
+ start = page * page_size
255
+ items_page = items[start: start + page_size]
256
+
257
+ total_pages = max(1, (total_items + page_size - 1) // page_size)
258
+ if page >= total_pages:
259
+ page = max(0, total_pages - 1)
260
+ session.pagination[pkey] = page
261
+
262
+ return ListHandler()._render_list_page(
263
+ node, session, items_page, page, total_pages, interactive, page_size, paginated_mode
264
+ )
265
+
266
+ def _error(self, session: Session, msg: str) -> Reply:
267
+ return Reply(
268
+ type="error",
269
+ body=msg,
270
+ phone=session.user_id,
271
+ current_node=session.current_node,
272
+ session_state=session.lifecycle_state,
273
+ )
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, Optional, TYPE_CHECKING
3
+
4
+ from ..message import IncomingMessage
5
+ from ..reply import Reply
6
+ from ..session import Session
7
+ from .base import NodeHandler
8
+
9
+ if TYPE_CHECKING:
10
+ from ..tree import FlowTree
11
+
12
+
13
+ class ConfirmHandler(NodeHandler):
14
+ """Handles ``confirm`` nodes — review screen before a write action."""
15
+
16
+ async def handle(
17
+ self,
18
+ node: Dict[str, Any],
19
+ session: Session,
20
+ message: IncomingMessage,
21
+ tree: "FlowTree",
22
+ ) -> Reply:
23
+ raw = (message.interactive_id or message.text or "").strip()
24
+
25
+ if not raw:
26
+ return self._render_confirm(node, session)
27
+
28
+ matched_next = self._match_option(node, message, raw)
29
+
30
+ if not matched_next:
31
+ rendered = self._render_confirm(node, session)
32
+ return Reply(
33
+ type="text",
34
+ body="Please choose one of the options.\n\n" + rendered.body,
35
+ phone=session.user_id,
36
+ options=rendered.options,
37
+ node_type="confirm",
38
+ current_node=session.current_node,
39
+ )
40
+
41
+ if matched_next == tree.entry:
42
+ session.collected = {}
43
+
44
+ self._transition_to(session, matched_next)
45
+ return await self._enter_node(session, tree)
46
+
47
+ def _match_option(
48
+ self,
49
+ node: Dict[str, Any],
50
+ message: IncomingMessage,
51
+ raw: str,
52
+ ) -> Optional[str]:
53
+ options = node.get("options", [])
54
+ allow_numeric = node.get("allow_numeric", False)
55
+
56
+ for i, opt in enumerate(options, 1):
57
+ label = opt.get("label", "") if isinstance(opt, dict) else opt[0]
58
+ value = opt.get("value", opt.get("next", "")) if isinstance(opt, dict) else opt[1]
59
+ next_key = opt.get("next", "") if isinstance(opt, dict) else opt[1]
60
+
61
+ if message.interactive_id and (
62
+ message.interactive_id == value or message.interactive_id == next_key
63
+ ):
64
+ return next_key
65
+ if allow_numeric and raw == str(i):
66
+ return next_key
67
+ if raw.lower() == label.lower():
68
+ return next_key
69
+
70
+ return None