├── phonetree ├── __init__.py └── phonetree.py ├── pyproject.toml ├── LICENSE ├── README.md └── .gitignore /phonetree/__init__.py: -------------------------------------------------------------------------------- 1 | from . import phonetree 2 | from .phonetree import * 3 | 4 | __all__ = phonetree.__all__ 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "phonetree" 7 | version = "1.3.0" 8 | authors = [ 9 | { name="Leandro Lima", email="leandro@lls-software.com" }, 10 | ] 11 | description = "A phone-tree like menu system for text-based interfaces" 12 | readme = "README.md" 13 | requires-python = ">=3.10" 14 | classifiers = [ 15 | "Development Status :: 4 - Beta", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python :: 3 :: Only", 19 | "Topic :: Communications :: Chat", 20 | "Topic :: Software Development :: Libraries :: Application Frameworks", 21 | "Operating System :: OS Independent", 22 | ] 23 | dependencies = [ 24 | "rapidfuzz>=2.13.7" 25 | ] 26 | 27 | [project.urls] 28 | "Homepage" = "https://github.com/leandropls/phonetree" 29 | "Bug Tracker" = "https://github.com/leandropls/phonetree/issues" 30 | 31 | [tool.isort] 32 | profile = "black" 33 | multi_line_output = 3 34 | 35 | [tool.black] 36 | line-length = 100 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2023 Leandro Pereira de Lima e Silva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | “Software”), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PhoneTree 2 | 3 | PhoneTree is a Python framework for creating text-based menu systems, resembling phone tree systems or rudimentary chatbots. It allows you to easily create menus and actions, manage user input and output, and maintain state between interactions. 4 | 5 | ## Features 6 | 7 | - Simple decorator-based syntax for defining menus and actions 8 | - Optional "ask" and "tell" callbacks for handling user input and output 9 | - State management for passing data between menus and actions 10 | 11 | ## Installation 12 | 13 | To install PhoneTree, simply use pip: 14 | 15 | ``` 16 | pip install phonetree 17 | ``` 18 | 19 | ## Usage 20 | 21 | Here's an example of how to use PhoneTree to create a simple menu system: 22 | 23 | ```python 24 | import phonetree 25 | from phonetree import Ask, Tell 26 | 27 | @phonetree.menu() 28 | def main_menu(state: dict) -> dict: 29 | """Main menu.""" 30 | return {"interactions": state.get("interactions", 0) + 1} 31 | 32 | @main_menu.menu("First Submenu") 33 | def first_submenu(state: dict) -> dict: 34 | """First Submenu menu.""" 35 | # here goes the code that runs when you enter the submenu 36 | ... 37 | 38 | return {"interactions": state.get("interactions", 0) + 1} 39 | 40 | @first_submenu.action("Do something") 41 | def do_something(state: dict, ask: Ask, tell: Tell) -> dict: 42 | """Some action""" 43 | anything = ask("Is there anything you want to say?") 44 | print("user answered: " + anything) 45 | tell("Alright! Thank you!") 46 | return {"interactions": state.get("interactions", 0) + 1} 47 | 48 | @first_submenu.action("Do something else") 49 | def do_something_else(state: dict, ask: Ask, tell: Tell) -> dict: 50 | """Some action""" 51 | color = ask("What's your favorite color?") 52 | print("User said " + color + " is their favorite color.") 53 | tell("Alright! Nice to know!") 54 | return {"interactions": state.get("interactions", 0) + 1, "favorite_color": color} 55 | 56 | @main_menu.menu("Second submenu") 57 | def second_submenu(state: dict, tell: Tell) -> dict: 58 | """Second submenu.""" 59 | tell("Welcome to second submenu!") 60 | return {"interactions": state.get("interactions", 0) + 1} 61 | ``` 62 | 63 | ### Defining Menus and Actions 64 | 65 | To define a menu, simply use the `@phonetree.menu()` decorator on a function. The function should return a dictionary representing the new state of the menu. This state will be passed on to the next menu or action function call. 66 | 67 | To define an action within a menu, use the `@menu.action("Action Name")` decorator on a function. The function should also return a dictionary representing the new state of the menu. 68 | 69 | ### Handling User Input and Output 70 | 71 | Menu and action functions can take optional "ask" and "tell" callbacks. 72 | 73 | The "ask" function sends some text to the user and expects an answer, returning the answer as the response of the function call. If the function returns `None`, the program execution is ended. 74 | 75 | The "tell" function just sends some text to the user and doesn't return anything back. Both functions are recognized by their names in the menu/action functions argument list. 76 | 77 | ### State Management 78 | 79 | Menu and action functions can also take a state variable, which can be called anything (except for "ask" and "tell"). This argument is optional, but if passed, should be the first argument of the function. This argument can receive a state of any type. 80 | 81 | The function should return the new state of the menu, which will determine what will be passed on as the state for the next menu/action function call. This state can be any object, including `None`, if the user doesn't need to keep any state. 82 | 83 | ### Running the Application 84 | 85 | To run the application defined by the menu, call the `communicate` method for the menu, passing the state, ask, and tell callbacks: 86 | 87 | ```python 88 | # communicate(state, ask, tell) 89 | main_menu.communicate({"interactions": 0}, input, print) 90 | ``` 91 | 92 | This will start the menu system and handle user interactions according to the defined menu and action functions. 93 | 94 | 95 | ## License 96 | 97 | PhoneTree is released under the MIT License. 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### JetBrains template 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | 5 | # User-specific stuff 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/**/usage.statistics.xml 9 | .idea/**/dictionaries 10 | .idea/**/shelf 11 | 12 | # AWS User-specific 13 | .idea/**/aws.xml 14 | 15 | # Generated files 16 | .idea/**/contentModel.xml 17 | 18 | # Sensitive or high-churn files 19 | .idea/**/dataSources/ 20 | .idea/**/dataSources.ids 21 | .idea/**/dataSources.local.xml 22 | .idea/**/sqlDataSources.xml 23 | .idea/**/dynamic.xml 24 | .idea/**/uiDesigner.xml 25 | .idea/**/dbnavigator.xml 26 | 27 | # Gradle 28 | .idea/**/gradle.xml 29 | .idea/**/libraries 30 | 31 | # Gradle and Maven with auto-import 32 | # When using Gradle or Maven with auto-import, you should exclude module files, 33 | # since they will be recreated, and may cause churn. Uncomment if using 34 | # auto-import. 35 | # .idea/artifacts 36 | # .idea/compiler.xml 37 | # .idea/jarRepositories.xml 38 | # .idea/modules.xml 39 | # .idea/*.iml 40 | # .idea/modules 41 | # *.iml 42 | # *.ipr 43 | 44 | # CMake 45 | cmake-build-*/ 46 | 47 | # Mongo Explorer plugin 48 | .idea/**/mongoSettings.xml 49 | 50 | # File-based project format 51 | *.iws 52 | 53 | # IntelliJ 54 | out/ 55 | 56 | # mpeltonen/sbt-idea plugin 57 | .idea_modules/ 58 | 59 | # JIRA plugin 60 | atlassian-ide-plugin.xml 61 | 62 | # Cursive Clojure plugin 63 | .idea/replstate.xml 64 | 65 | # SonarLint plugin 66 | .idea/sonarlint/ 67 | 68 | # Crashlytics plugin (for Android Studio and IntelliJ) 69 | com_crashlytics_export_strings.xml 70 | crashlytics.properties 71 | crashlytics-build.properties 72 | fabric.properties 73 | 74 | # Editor-based Rest Client 75 | .idea/httpRequests 76 | 77 | # Android studio 3.1+ serialized cache file 78 | .idea/caches/build_file_checksums.ser 79 | 80 | ### PyCharm template 81 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 82 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 83 | 84 | # User-specific stuff 85 | 86 | # AWS User-specific 87 | 88 | # Generated files 89 | 90 | # Sensitive or high-churn files 91 | 92 | # Gradle 93 | 94 | # Gradle and Maven with auto-import 95 | # When using Gradle or Maven with auto-import, you should exclude module files, 96 | # since they will be recreated, and may cause churn. Uncomment if using 97 | # auto-import. 98 | # .idea/artifacts 99 | # .idea/compiler.xml 100 | # .idea/jarRepositories.xml 101 | # .idea/modules.xml 102 | # .idea/*.iml 103 | # .idea/modules 104 | # *.iml 105 | # *.ipr 106 | 107 | # CMake 108 | 109 | # Mongo Explorer plugin 110 | 111 | # File-based project format 112 | 113 | # IntelliJ 114 | 115 | # mpeltonen/sbt-idea plugin 116 | 117 | # JIRA plugin 118 | 119 | # Cursive Clojure plugin 120 | 121 | # SonarLint plugin 122 | 123 | # Crashlytics plugin (for Android Studio and IntelliJ) 124 | 125 | # Editor-based Rest Client 126 | 127 | # Android studio 3.1+ serialized cache file 128 | 129 | ### VirtualEnv template 130 | # Virtualenv 131 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 132 | .Python 133 | [Bb]in 134 | [Ii]nclude 135 | [Ll]ib 136 | [Ll]ib64 137 | [Ll]ocal 138 | [Ss]cripts 139 | pyvenv.cfg 140 | .venv 141 | pip-selfcheck.json 142 | 143 | ### Python template 144 | # Byte-compiled / optimized / DLL files 145 | __pycache__/ 146 | *.py[cod] 147 | *$py.class 148 | 149 | # C extensions 150 | *.so 151 | 152 | # Distribution / packaging 153 | build/ 154 | develop-eggs/ 155 | dist/ 156 | downloads/ 157 | eggs/ 158 | .eggs/ 159 | lib/ 160 | lib64/ 161 | parts/ 162 | sdist/ 163 | var/ 164 | wheels/ 165 | share/python-wheels/ 166 | *.egg-info/ 167 | .installed.cfg 168 | *.egg 169 | MANIFEST 170 | 171 | # PyInstaller 172 | # Usually these files are written by a python script from a template 173 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 174 | *.manifest 175 | *.spec 176 | 177 | # Installer logs 178 | pip-log.txt 179 | pip-delete-this-directory.txt 180 | 181 | # Unit test / coverage reports 182 | htmlcov/ 183 | .tox/ 184 | .nox/ 185 | .coverage 186 | .coverage.* 187 | .cache 188 | nosetests.xml 189 | coverage.xml 190 | *.cover 191 | *.py,cover 192 | .hypothesis/ 193 | .pytest_cache/ 194 | cover/ 195 | 196 | # Translations 197 | *.mo 198 | *.pot 199 | 200 | # Django stuff: 201 | *.log 202 | local_settings.py 203 | db.sqlite3 204 | db.sqlite3-journal 205 | 206 | # Flask stuff: 207 | instance/ 208 | .webassets-cache 209 | 210 | # Scrapy stuff: 211 | .scrapy 212 | 213 | # Sphinx documentation 214 | docs/_build/ 215 | 216 | # PyBuilder 217 | .pybuilder/ 218 | target/ 219 | 220 | # Jupyter Notebook 221 | .ipynb_checkpoints 222 | 223 | # IPython 224 | profile_default/ 225 | ipython_config.py 226 | 227 | # pyenv 228 | # For a library or package, you might want to ignore these files since the code is 229 | # intended to run in multiple environments; otherwise, check them in: 230 | # .python-version 231 | 232 | # pipenv 233 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 234 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 235 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 236 | # install all needed dependencies. 237 | #Pipfile.lock 238 | 239 | # poetry 240 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 241 | # This is especially recommended for binary packages to ensure reproducibility, and is more 242 | # commonly ignored for libraries. 243 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 244 | #poetry.lock 245 | 246 | # pdm 247 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 248 | #pdm.lock 249 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 250 | # in version control. 251 | # https://pdm.fming.dev/#use-with-ide 252 | .pdm.toml 253 | 254 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 255 | __pypackages__/ 256 | 257 | # Celery stuff 258 | celerybeat-schedule 259 | celerybeat.pid 260 | 261 | # SageMath parsed files 262 | *.sage.py 263 | 264 | # Environments 265 | .env 266 | env/ 267 | venv/ 268 | ENV/ 269 | env.bak/ 270 | venv.bak/ 271 | 272 | # Spyder project settings 273 | .spyderproject 274 | .spyproject 275 | 276 | # Rope project settings 277 | .ropeproject 278 | 279 | # mkdocs documentation 280 | /site 281 | 282 | # mypy 283 | .mypy_cache/ 284 | .dmypy.json 285 | dmypy.json 286 | 287 | # Pyre type checker 288 | .pyre/ 289 | 290 | # pytype static type analyzer 291 | .pytype/ 292 | 293 | # Cython debug symbols 294 | cython_debug/ 295 | 296 | # PyCharm 297 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 298 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 299 | # and can be added to the global gitignore or merged into this file. For a more nuclear 300 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 301 | .idea/ 302 | 303 | -------------------------------------------------------------------------------- /phonetree/phonetree.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from typing import Any, Callable, Iterator, Protocol, Sequence 5 | 6 | from rapidfuzz.distance import Indel 7 | 8 | __all__ = ["menu", "Ask", "Tell", "Flow"] 9 | 10 | Ask = Callable[[str], str | None] 11 | 12 | Tell = Callable[[str], None] 13 | 14 | ActionCallback = Callable[..., Any] 15 | 16 | 17 | class NormalizedActionCallback(Protocol): 18 | def __call__(self, state: Any, ask: Ask, tell: Tell, flow: Flow) -> Any: 19 | """ 20 | Represents a callback function with a normalized action. 21 | 22 | The method should implement a specific action to be executed given the input state and 23 | the methods ask and tell. The method should return any object representing the resulting 24 | state after the action has been taken. 25 | 26 | :param state: The current state of the application or system. 27 | :param ask: The method to ask questions or make requests to the user. 28 | :param tell: The method to send messages or information to the user. 29 | :param flow: The flow object controlling the flow of the application. 30 | :return: The resulting state after the specific action has been taken. 31 | """ 32 | ... 33 | 34 | 35 | def similarity(s1: str, s2: str) -> float: 36 | """ 37 | Find the normalized Indel similarity between two strings. 38 | 39 | :param s1: The first string to be compared 40 | :param s2: The second string to be compared 41 | :return: The normalized Indel similarity value between the two strings, 42 | ranging from 0.0 (no similarity) to 1.0 (identical strings) 43 | """ 44 | # Calculate the normalized similarity using the Indel class method 45 | return Indel.normalized_similarity(s1, s2) 46 | 47 | 48 | class NextProtocol(Protocol): 49 | def next(self, state: Any, ask: Ask, tell: Tell) -> tuple[Menu | Action | None, Any]: 50 | ... 51 | 52 | 53 | class Flow: 54 | """Controls the flow of the menu system.""" 55 | 56 | __slots__ = ("next",) 57 | 58 | # noinspection PyShadowingBuiltins 59 | def __init__(self, next: Menu | Action) -> None: 60 | self.next = next 61 | 62 | 63 | class Menu(NextProtocol): 64 | def __init__( 65 | self, 66 | parent: Menu | None = None, 67 | include_exit: bool = False, 68 | include_exit_on_submenus: bool = False, 69 | ) -> None: 70 | """ 71 | Initialize method for the Menu class. 72 | 73 | :param parent: The parent menu object if any, defaults to None 74 | :param include_exit: Whether to include an Exit option in the menu, defaults to False 75 | :param include_exit_on_submenus: Whether to include an Exit option in submenus, defaults to False 76 | """ 77 | self._items: list[tuple[str, Menu | Action]] = [] 78 | self.parent: Menu | None = parent 79 | self.include_exit: bool = include_exit 80 | self.include_exit_on_submenus: bool = include_exit_on_submenus 81 | self.callback: NormalizedActionCallback | None = None 82 | 83 | @property 84 | def _items_list(self) -> Sequence[tuple[str, Menu | Action | None]]: 85 | """ 86 | Get the list of menu items including the parent menu and / or Exit option. 87 | 88 | :return: A list containing tuples with menu item names and their corresponding Menu or Action objects 89 | """ 90 | items: list[tuple[str, Menu | Action | None]] = list(self._items) 91 | if (parent := self.parent) is not None: 92 | items.append(("Return to previous menu", parent)) 93 | if parent is None or self.include_exit: 94 | items.append(("Exit", None)) 95 | return items 96 | 97 | @property 98 | def _menu(self) -> Iterator[str]: 99 | """ 100 | Generator function for iterating over the menu item names with their numerical indices. 101 | 102 | :return: An iterator yielding formatted menu item strings 103 | """ 104 | for i, item in enumerate(self._items_list): 105 | yield f"{i + 1}. {item[0]}" 106 | 107 | def _get_item(self, name: str) -> Menu | Action | None: 108 | """ 109 | Get the Menu or Action object corresponding to a user's input. 110 | 111 | :param name: The user's input 112 | :return: The Menu or Action object associated with the user's input, or raise KeyError if not found 113 | """ 114 | # Find the object with the closest text similarity to the input name 115 | max_ratio, item = max((similarity(x[0].lower(), name.lower()), x) for x in self._items_list) 116 | if max_ratio >= 0.5: 117 | return item[1] 118 | 119 | # Find the object by its index in the menu 120 | max_ratio, index = max( 121 | (similarity(str(i), name), i) for i, _ in enumerate(self._items_list, 1) 122 | ) 123 | if max_ratio >= 0.5: 124 | return self._items_list[index - 1][1] 125 | 126 | raise KeyError(name) 127 | 128 | def __call__(self, callback: ActionCallback) -> Menu: 129 | """ 130 | Set the callback function for the menu. 131 | 132 | :param callback: The callback function to be called when the menu is opened 133 | :return: The menu object itself for method chaining 134 | """ 135 | self.callback = normalize_callback(callback) if callback is not None else None 136 | return self 137 | 138 | def menu( 139 | self, 140 | name: str, 141 | include_exit: bool | None = None, 142 | include_exit_on_submenus: bool | None = None, 143 | ) -> Menu: 144 | """ 145 | Add a submenu to the current menu. 146 | 147 | :param name: The name of the submenu 148 | :param include_exit: Whether to include an Exit option in the submenu, 149 | defaults to self.include_exit_on_submenus 150 | :param include_exit_on_submenus: Whether to include an Exit option in submenus, 151 | defaults to self.include_exit_on_submenus 152 | :return: The submenu object 153 | """ 154 | submenu = Menu( 155 | parent=self, 156 | include_exit=include_exit 157 | if include_exit is not None 158 | else self.include_exit_on_submenus, 159 | include_exit_on_submenus=include_exit_on_submenus 160 | if include_exit_on_submenus is not None 161 | else self.include_exit_on_submenus, 162 | ) 163 | self._items.append((name, submenu)) 164 | return submenu 165 | 166 | def action( 167 | self, 168 | name: str, 169 | ) -> Action: 170 | """ 171 | Add an action to the current menu. 172 | 173 | :param name: The name of the action 174 | :return: The action object 175 | """ 176 | action = Action(parent=self) 177 | self._items.append((name, action)) 178 | return action 179 | 180 | def next( 181 | self, 182 | state: Any, 183 | ask: Ask, 184 | tell: Tell, 185 | ) -> tuple[Menu | Action | None, Any]: 186 | """ 187 | Get the next menu action or menu object based on the user's input. 188 | 189 | :param state: The state object from previous interaction 190 | :param ask: The `ask` function for getting user input 191 | :param tell: The `tell` function for providing information to the user 192 | :return: A tuple with the next Menu or Action object and the updated state 193 | """ 194 | # Trigger the callback if it's set 195 | if (callback := self.callback) is not None: 196 | state = callback(state, ask, tell, Flow(self)) 197 | 198 | # Display the menu options to the user 199 | question = "Please select an option:\n" + "\n".join(self._menu) 200 | 201 | while True: 202 | question_answer = ask(question) 203 | 204 | # If answer is None, the user has exited the program 205 | if question_answer is None: 206 | return None, state 207 | 208 | try: 209 | return self._get_item(question_answer), state 210 | except KeyError: 211 | question = "Invalid option, please try again." 212 | 213 | def communicate(self, state: Any, ask: Ask, tell: Tell) -> None: 214 | """ 215 | Primary method for communication with the user in the menu-based system. 216 | 217 | :param state: An object representing the state of the conversation 218 | :param ask: The `ask` function for getting user input 219 | :param tell: The `tell` function for providing information to the user 220 | """ 221 | current: Menu | Action | None = self 222 | while current is not None: 223 | current, state = current.next(state, ask, tell) 224 | 225 | 226 | def menu( 227 | include_exit: bool = False, 228 | include_exit_on_submenus: bool = False, 229 | ) -> Menu: 230 | """ 231 | Create a new menu. 232 | 233 | :param include_exit: Whether to include an Exit option in the menu, defaults to False 234 | :param include_exit_on_submenus: Whether to include an Exit option in submenus, defaults to False 235 | """ 236 | return Menu(include_exit=include_exit, include_exit_on_submenus=include_exit_on_submenus) 237 | 238 | 239 | class Action(NextProtocol): 240 | def __init__( 241 | self, 242 | parent: Menu, 243 | ) -> None: 244 | """ 245 | Initializes the Action object. 246 | 247 | :param parent: The parent menu of this action. 248 | """ 249 | self.parent = parent 250 | self.callback: NormalizedActionCallback | None = None 251 | 252 | def next( 253 | self, 254 | state: Any, 255 | ask: Ask, 256 | tell: Tell, 257 | ) -> tuple[Menu | Action, Any]: 258 | """ 259 | Executes the action callback and provides a next menu and updated state. 260 | 261 | :param state: The current state of the menu system. 262 | :param ask: An instance of the Ask object used for questions to the user. 263 | :param tell: An instance of the Tell object used to communicate information to 264 | the user. 265 | :return: A tuple containing the next menu object and the updated state. 266 | """ 267 | flow = Flow(self.parent) 268 | if (callback := self.callback) is not None: 269 | state = callback(state, ask=ask, tell=tell, flow=flow) 270 | return flow.next, state 271 | 272 | def __call__(self, callback: ActionCallback) -> Action: 273 | """ 274 | Sets the action callback for this action. 275 | 276 | :param callback: The function to be called during action execution. 277 | :return: The updated action with the new callback. 278 | """ 279 | # Normalize the callback if it is not None, otherwise set it to None 280 | self.callback = normalize_callback(callback) if callback is not None else None 281 | return self 282 | 283 | 284 | def normalize_callback(callback: ActionCallback) -> NormalizedActionCallback: 285 | """ 286 | Normalize a given callback function to have `state`, `ask`, `tell`, and `flow` as arguments. The 287 | given callback may have only `state` as positional argument and `ask`, `tell` and `action` as keyword arguments. 288 | 289 | :param callback: The callback function to normalize. 290 | :return: The normalized callback function, accepting `state`, `ask`, `tell`, and 'action' as arguments. 291 | :raises ValueError: If the given callback function has unsupported number of arguments or 292 | an invalid signature. 293 | """ 294 | signature = inspect.signature(callback) 295 | parameters = signature.parameters 296 | kwargs = [] 297 | 298 | params = list(parameters.keys()) 299 | 300 | if len(params) > 0 and params[0] not in ("ask", "tell", "flow"): 301 | params.pop(0) 302 | hasState = True 303 | else: 304 | hasState = False 305 | 306 | while params: 307 | param = params.pop(0) 308 | if param not in ("ask", "tell", "flow"): 309 | raise ValueError("Unsupported argument in callback function: {}".format(param)) 310 | kwargs.append(param) 311 | 312 | def normalized_callback(state: Any, ask: Ask, tell: Tell, flow: Flow) -> Any: 313 | callbackLocals = locals() 314 | return callback( 315 | *((state,) if hasState else ()), 316 | **{k: callbackLocals[k] for k in kwargs}, 317 | ) 318 | 319 | normalized_callback.__name__ = callback.__name__ 320 | normalized_callback.__doc__ = callback.__doc__ 321 | 322 | return normalized_callback 323 | --------------------------------------------------------------------------------