ripple-down-rules 0.3.0__py3-none-any.whl → 0.4.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.
- ripple_down_rules/datasets.py +5 -0
- ripple_down_rules/datastructures/enums.py +14 -0
- ripple_down_rules/experts.py +14 -7
- ripple_down_rules/rdr.py +6 -2
- ripple_down_rules/user_interface/__init__.py +0 -0
- ripple_down_rules/user_interface/gui.py +630 -0
- ripple_down_rules/user_interface/ipython_custom_shell.py +146 -0
- ripple_down_rules/user_interface/object_diagram.py +109 -0
- ripple_down_rules/user_interface/prompt.py +159 -0
- ripple_down_rules/user_interface/template_file_creator.py +293 -0
- ripple_down_rules/utils.py +2 -2
- {ripple_down_rules-0.3.0.dist-info → ripple_down_rules-0.4.0.dist-info}/METADATA +8 -1
- ripple_down_rules-0.4.0.dist-info/RECORD +25 -0
- ripple_down_rules/prompt.py +0 -510
- ripple_down_rules-0.3.0.dist-info/RECORD +0 -20
- {ripple_down_rules-0.3.0.dist-info → ripple_down_rules-0.4.0.dist-info}/WHEEL +0 -0
- {ripple_down_rules-0.3.0.dist-info → ripple_down_rules-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {ripple_down_rules-0.3.0.dist-info → ripple_down_rules-0.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,146 @@
|
|
1
|
+
import logging
|
2
|
+
from typing import Optional, List
|
3
|
+
|
4
|
+
from IPython.core.magic import magics_class, Magics, line_magic
|
5
|
+
from IPython.terminal.embed import InteractiveShellEmbed
|
6
|
+
from colorama import Fore, Style
|
7
|
+
from traitlets.config import Config
|
8
|
+
|
9
|
+
from ..datastructures.dataclasses import CaseQuery
|
10
|
+
from ..datastructures.enums import PromptFor
|
11
|
+
from .gui import encapsulate_code_lines_into_a_function
|
12
|
+
from .template_file_creator import TemplateFileCreator
|
13
|
+
from ..utils import contains_return_statement, extract_dependencies
|
14
|
+
|
15
|
+
|
16
|
+
@magics_class
|
17
|
+
class MyMagics(Magics):
|
18
|
+
|
19
|
+
def __init__(self, shell,
|
20
|
+
code_to_modify: Optional[str] = None,
|
21
|
+
prompt_for: Optional[PromptFor] = None,
|
22
|
+
case_query: Optional[CaseQuery] = None):
|
23
|
+
super().__init__(shell)
|
24
|
+
self.rule_editor = TemplateFileCreator(shell, case_query, prompt_for=prompt_for, code_to_modify=code_to_modify)
|
25
|
+
self.all_code_lines: Optional[List[str]] = None
|
26
|
+
|
27
|
+
@line_magic
|
28
|
+
def edit(self, line):
|
29
|
+
self.rule_editor.edit()
|
30
|
+
|
31
|
+
@line_magic
|
32
|
+
def load(self, line):
|
33
|
+
self.all_code_lines = self.rule_editor.load()
|
34
|
+
|
35
|
+
@line_magic
|
36
|
+
def help(self, line):
|
37
|
+
"""
|
38
|
+
Display help information for the Ipython shell.
|
39
|
+
"""
|
40
|
+
help_text = f"""
|
41
|
+
Directly write python code in the shell, and then `{Fore.GREEN}return {Fore.RESET}output`. Or use
|
42
|
+
the magic commands to write the code in a temporary file and edit it in PyCharm:
|
43
|
+
{Fore.MAGENTA}Usage: %edit{Style.RESET_ALL}
|
44
|
+
Opens a temporary file in PyCharm for editing a function (conclusion or conditions for case)
|
45
|
+
that will be executed on the case object.
|
46
|
+
{Fore.MAGENTA}Usage: %load{Style.RESET_ALL}
|
47
|
+
Loads the function defined in the temporary file into the user namespace, that can then be used inside the
|
48
|
+
Ipython shell. You can then do `{Fore.GREEN}return {Fore.RESET}function_name(case)`.
|
49
|
+
"""
|
50
|
+
print(help_text)
|
51
|
+
|
52
|
+
|
53
|
+
class CustomInteractiveShell(InteractiveShellEmbed):
|
54
|
+
def __init__(self, code_to_modify: Optional[str] = None,
|
55
|
+
prompt_for: Optional[PromptFor] = None,
|
56
|
+
case_query: Optional[CaseQuery] = None,
|
57
|
+
**kwargs):
|
58
|
+
kwargs.update({'user_ns': case_query.scope})
|
59
|
+
super().__init__(**kwargs)
|
60
|
+
self.my_magics = MyMagics(self, code_to_modify=code_to_modify,
|
61
|
+
prompt_for=prompt_for, case_query=case_query)
|
62
|
+
self.register_magics(self.my_magics)
|
63
|
+
self.all_lines = []
|
64
|
+
|
65
|
+
def run_cell(self, raw_cell: str, **kwargs):
|
66
|
+
"""
|
67
|
+
Override the run_cell method to capture return statements.
|
68
|
+
"""
|
69
|
+
if contains_return_statement(raw_cell) and 'def ' not in raw_cell:
|
70
|
+
if self.my_magics.rule_editor.func_name in raw_cell:
|
71
|
+
self.all_lines = self.my_magics.all_code_lines
|
72
|
+
self.all_lines.append(raw_cell)
|
73
|
+
self.history_manager.store_inputs(line_num=self.execution_count, source=raw_cell)
|
74
|
+
self.ask_exit()
|
75
|
+
return None
|
76
|
+
result = super().run_cell(raw_cell, **kwargs)
|
77
|
+
if result.error_in_exec is None and result.error_before_exec is None:
|
78
|
+
self.all_lines.append(raw_cell)
|
79
|
+
return result
|
80
|
+
|
81
|
+
|
82
|
+
class IPythonShell:
|
83
|
+
"""
|
84
|
+
Create an embedded Ipython shell that can be used to prompt the user for input.
|
85
|
+
"""
|
86
|
+
|
87
|
+
def __init__(self, header: Optional[str] = None,
|
88
|
+
prompt_for: Optional[PromptFor] = None, case_query: Optional[CaseQuery] = None,
|
89
|
+
code_to_modify: Optional[str] = None):
|
90
|
+
"""
|
91
|
+
Initialize the Ipython shell with the given scope and header.
|
92
|
+
|
93
|
+
:param header: The header to display when the shell is started.
|
94
|
+
:param prompt_for: The type of information to ask the user about.
|
95
|
+
:param case_query: The case query which contains the case and the attribute to ask about.
|
96
|
+
:param code_to_modify: The code to modify. If given, will be used as a start for user to modify.
|
97
|
+
"""
|
98
|
+
self.header: str = header or ">>> Embedded Ipython Shell"
|
99
|
+
self.case_query: Optional[CaseQuery] = case_query
|
100
|
+
self.prompt_for: Optional[PromptFor] = prompt_for
|
101
|
+
self.code_to_modify: Optional[str] = code_to_modify
|
102
|
+
self.user_input: Optional[str] = None
|
103
|
+
self.shell: CustomInteractiveShell = self._init_shell()
|
104
|
+
self.all_code_lines: List[str] = []
|
105
|
+
|
106
|
+
def _init_shell(self):
|
107
|
+
"""
|
108
|
+
Initialize the Ipython shell with a custom configuration.
|
109
|
+
"""
|
110
|
+
cfg = Config()
|
111
|
+
shell = CustomInteractiveShell(config=cfg, banner1=self.header,
|
112
|
+
code_to_modify=self.code_to_modify,
|
113
|
+
prompt_for=self.prompt_for,
|
114
|
+
case_query=self.case_query,
|
115
|
+
)
|
116
|
+
return shell
|
117
|
+
|
118
|
+
def run(self):
|
119
|
+
"""
|
120
|
+
Run the embedded shell.
|
121
|
+
"""
|
122
|
+
while True:
|
123
|
+
try:
|
124
|
+
self.shell()
|
125
|
+
self.update_user_input_from_code_lines()
|
126
|
+
break
|
127
|
+
except Exception as e:
|
128
|
+
logging.error(e)
|
129
|
+
print(f"{Fore.RED}ERROR::{e}{Style.RESET_ALL}")
|
130
|
+
|
131
|
+
def update_user_input_from_code_lines(self):
|
132
|
+
"""
|
133
|
+
Update the user input from the code lines captured in the shell.
|
134
|
+
"""
|
135
|
+
if len(self.shell.all_lines) == 1 and self.shell.all_lines[0].replace('return', '').strip() == '':
|
136
|
+
self.user_input = None
|
137
|
+
else:
|
138
|
+
self.all_code_lines = extract_dependencies(self.shell.all_lines)
|
139
|
+
if len(self.all_code_lines) == 1 and self.all_code_lines[0].strip() == '':
|
140
|
+
self.user_input = None
|
141
|
+
else:
|
142
|
+
self.user_input = encapsulate_code_lines_into_a_function(self.all_code_lines,
|
143
|
+
function_name=self.shell.my_magics.rule_editor.func_name,
|
144
|
+
function_signature=self.shell.my_magics.rule_editor.function_signature,
|
145
|
+
func_doc=self.shell.my_magics.rule_editor.func_doc,
|
146
|
+
case_query=self.case_query)
|
@@ -0,0 +1,109 @@
|
|
1
|
+
import graphviz
|
2
|
+
|
3
|
+
|
4
|
+
def is_simple(obj):
|
5
|
+
return isinstance(obj, (int, float, str, bool, type(None)))
|
6
|
+
|
7
|
+
|
8
|
+
def get_colored_value(value):
|
9
|
+
if isinstance(value, str):
|
10
|
+
color = '#A31515' # red for strings
|
11
|
+
val = repr(value)
|
12
|
+
elif isinstance(value, (int, float)):
|
13
|
+
color = '#098658' # green for numbers
|
14
|
+
val = str(value)
|
15
|
+
elif isinstance(value, bool):
|
16
|
+
color = '#0000FF' # blue for booleans
|
17
|
+
val = str(value)
|
18
|
+
elif value is None:
|
19
|
+
color = '#808080' # gray for None
|
20
|
+
val = 'None'
|
21
|
+
else:
|
22
|
+
color = '#000000' # fallback
|
23
|
+
val = str(value)
|
24
|
+
val = (val.replace("class ", "").replace("'", "").replace('<', "")
|
25
|
+
.replace('>', "").replace('?', '').replace('\n', ' '))
|
26
|
+
val = val[:50] + '...' if len(val) > 50 else val
|
27
|
+
return f'<FONT COLOR="{color}">{val}</FONT>'
|
28
|
+
|
29
|
+
|
30
|
+
def generate_object_graph(obj, name='root', seen=None, graph=None, current_depth=0, max_depth=3, chain_name=None,
|
31
|
+
included_attrs=None):
|
32
|
+
if seen is None:
|
33
|
+
seen = set()
|
34
|
+
if graph is None:
|
35
|
+
graph = graphviz.Digraph(format='svg', graph_attr={'dpi': '300'})
|
36
|
+
graph.attr('node', shape='plaintext')
|
37
|
+
|
38
|
+
obj_id = id(obj)
|
39
|
+
if obj_id in seen or current_depth > max_depth:
|
40
|
+
return graph
|
41
|
+
seen.add(obj_id)
|
42
|
+
|
43
|
+
# Build HTML table label for this object node
|
44
|
+
type_name = type(obj).__name__ if not isinstance(obj, type) else obj.__name__
|
45
|
+
rows = [f'<TR><TD><B>{name}</B></TD><TD><I>{type_name}</I></TD></TR>']
|
46
|
+
|
47
|
+
# We'll keep track of non-simple attrs to add edges later
|
48
|
+
non_simple_attrs = []
|
49
|
+
|
50
|
+
if isinstance(obj, (list, tuple, set, dict)):
|
51
|
+
items = obj.items() if isinstance(obj, dict) else enumerate(obj)
|
52
|
+
for idx, item in items:
|
53
|
+
if idx == "scope":
|
54
|
+
continue
|
55
|
+
# Represent items as attr = index + type (for the label)
|
56
|
+
if is_simple(item):
|
57
|
+
val_colored = get_colored_value(item)
|
58
|
+
rows.append(f'<TR><TD ALIGN="LEFT" PORT="{idx}">[{idx}]</TD><TD ALIGN="LEFT">{val_colored}</TD></TR>')
|
59
|
+
else:
|
60
|
+
type_name = type(item).__name__ if not isinstance(item, type) else item.__name__
|
61
|
+
rows.append(
|
62
|
+
f'<TR><TD ALIGN="LEFT" PORT="{idx}">[{idx}]</TD><TD ALIGN="LEFT"><I>{type_name}</I></TD></TR>')
|
63
|
+
non_simple_attrs.append((str(idx), item))
|
64
|
+
|
65
|
+
else:
|
66
|
+
for attr in dir(obj):
|
67
|
+
if attr.startswith('_'):
|
68
|
+
continue
|
69
|
+
if attr == 'scope':
|
70
|
+
continue
|
71
|
+
value = getattr(obj, attr)
|
72
|
+
if callable(value):
|
73
|
+
continue
|
74
|
+
if is_simple(value):
|
75
|
+
val_colored = get_colored_value(value)
|
76
|
+
rows.append(f'<TR><TD ALIGN="LEFT" PORT="{attr}">{attr}</TD><TD ALIGN="LEFT">{val_colored}</TD></TR>')
|
77
|
+
else:
|
78
|
+
type_name = type(value).__name__ if not isinstance(value, type) else value.__name__
|
79
|
+
# Show just name and type inside the node
|
80
|
+
rows.append(
|
81
|
+
f'<TR><TD ALIGN="LEFT" PORT="{attr}">{attr}</TD><TD ALIGN="LEFT"><I>{type_name}</I></TD></TR>')
|
82
|
+
non_simple_attrs.append((attr, value))
|
83
|
+
|
84
|
+
label = f"""<
|
85
|
+
<TABLE BORDER="1" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4">
|
86
|
+
{''.join(rows)}
|
87
|
+
</TABLE>
|
88
|
+
>"""
|
89
|
+
|
90
|
+
graph.node(str(obj_id), label)
|
91
|
+
chain_name = chain_name if chain_name is not None else name
|
92
|
+
|
93
|
+
# Add edges from this node to non-simple attribute nodes
|
94
|
+
for attr, value in non_simple_attrs:
|
95
|
+
if attr.startswith('_'):
|
96
|
+
continue
|
97
|
+
if attr == 'scope':
|
98
|
+
continue
|
99
|
+
if callable(value):
|
100
|
+
continue
|
101
|
+
attr_chain_name = f"{chain_name}.{attr}"
|
102
|
+
if included_attrs is not None and attr_chain_name not in included_attrs:
|
103
|
+
continue
|
104
|
+
generate_object_graph(value, attr, seen, graph, current_depth + 1, max_depth, chain_name=f"{chain_name}.{attr}",
|
105
|
+
included_attrs=included_attrs)
|
106
|
+
# Edge from this object's attribute port to nested object's node
|
107
|
+
graph.edge(f"{obj_id}:{attr}", str(id(value)), label=attr)
|
108
|
+
|
109
|
+
return graph
|
@@ -0,0 +1,159 @@
|
|
1
|
+
import ast
|
2
|
+
import logging
|
3
|
+
from _ast import AST
|
4
|
+
|
5
|
+
from PyQt6.QtWidgets import QApplication
|
6
|
+
from colorama import Fore, Style
|
7
|
+
from pygments import highlight
|
8
|
+
from pygments.formatters.terminal import TerminalFormatter
|
9
|
+
from pygments.lexers.python import PythonLexer
|
10
|
+
from typing_extensions import Optional, Tuple
|
11
|
+
|
12
|
+
from ..datastructures.callable_expression import CallableExpression, parse_string_to_expression
|
13
|
+
from ..datastructures.dataclasses import CaseQuery
|
14
|
+
from ..datastructures.enums import PromptFor, InteractionMode
|
15
|
+
from .gui import RDRCaseViewer
|
16
|
+
from .ipython_custom_shell import IPythonShell
|
17
|
+
from ..utils import make_list
|
18
|
+
|
19
|
+
|
20
|
+
class UserPrompt:
|
21
|
+
"""
|
22
|
+
A class to handle user prompts for the RDR.
|
23
|
+
"""
|
24
|
+
def __init__(self, viewer: Optional[RDRCaseViewer] = None):
|
25
|
+
"""
|
26
|
+
Initialize the UserPrompt class.
|
27
|
+
|
28
|
+
:param viewer: The RDRCaseViewer instance to use for prompting the user.
|
29
|
+
"""
|
30
|
+
self.viewer = viewer
|
31
|
+
self.print_func = print if viewer is None else viewer.print
|
32
|
+
|
33
|
+
|
34
|
+
def prompt_user_for_expression(self, case_query: CaseQuery, prompt_for: PromptFor, prompt_str: Optional[str] = None) \
|
35
|
+
-> Tuple[Optional[str], Optional[CallableExpression]]:
|
36
|
+
"""
|
37
|
+
Prompt the user for an executable python expression to the given case query.
|
38
|
+
|
39
|
+
:param case_query: The case query to prompt the user for.
|
40
|
+
:param prompt_for: The type of information ask user about.
|
41
|
+
:param prompt_str: The prompt string to display to the user.
|
42
|
+
:return: A callable expression that takes a case and executes user expression on it.
|
43
|
+
"""
|
44
|
+
prev_user_input: Optional[str] = None
|
45
|
+
callable_expression: Optional[CallableExpression] = None
|
46
|
+
while True:
|
47
|
+
user_input, expression_tree = self.prompt_user_about_case(case_query, prompt_for, prompt_str,
|
48
|
+
code_to_modify=prev_user_input)
|
49
|
+
if user_input is None:
|
50
|
+
if prompt_for == PromptFor.Conclusion:
|
51
|
+
self.print_func(f"{Fore.YELLOW}No conclusion provided. Exiting.{Style.RESET_ALL}")
|
52
|
+
return None, None
|
53
|
+
else:
|
54
|
+
self.print_func(f"{Fore.RED}Conditions must be provided. Please try again.{Style.RESET_ALL}")
|
55
|
+
continue
|
56
|
+
prev_user_input = '\n'.join(user_input.split('\n')[2:-1])
|
57
|
+
conclusion_type = bool if prompt_for == PromptFor.Conditions else case_query.attribute_type
|
58
|
+
callable_expression = CallableExpression(user_input, conclusion_type, expression_tree=expression_tree,
|
59
|
+
scope=case_query.scope,
|
60
|
+
mutually_exclusive=case_query.mutually_exclusive)
|
61
|
+
try:
|
62
|
+
result = callable_expression(case_query.case)
|
63
|
+
if len(make_list(result)) == 0:
|
64
|
+
self.print_func(f"{Fore.YELLOW}The given expression gave an empty result for case {case_query.name}."
|
65
|
+
f" Please modify!{Style.RESET_ALL}")
|
66
|
+
continue
|
67
|
+
break
|
68
|
+
except Exception as e:
|
69
|
+
logging.error(e)
|
70
|
+
self.print_func(f"{Fore.RED}{e}{Style.RESET_ALL}")
|
71
|
+
return user_input, callable_expression
|
72
|
+
|
73
|
+
|
74
|
+
def prompt_user_about_case(self, case_query: CaseQuery, prompt_for: PromptFor,
|
75
|
+
prompt_str: Optional[str] = None,
|
76
|
+
code_to_modify: Optional[str] = None) -> Tuple[Optional[str], Optional[AST]]:
|
77
|
+
"""
|
78
|
+
Prompt the user for input.
|
79
|
+
|
80
|
+
:param case_query: The case query to prompt the user for.
|
81
|
+
:param prompt_for: The type of information the user should provide for the given case.
|
82
|
+
:param prompt_str: The prompt string to display to the user.
|
83
|
+
:param code_to_modify: The code to modify. If given will be used as a start for user to modify.
|
84
|
+
:return: The user input, and the executable expression that was parsed from the user input.
|
85
|
+
"""
|
86
|
+
if prompt_str is None:
|
87
|
+
if prompt_for == PromptFor.Conclusion:
|
88
|
+
prompt_str = f"Give possible value(s) for:"
|
89
|
+
else:
|
90
|
+
prompt_str = f"Give conditions on when can the rule be evaluated for:"
|
91
|
+
case_query.scope.update({'case': case_query.case})
|
92
|
+
shell = None
|
93
|
+
if QApplication.instance() is None:
|
94
|
+
prompt_str = self.construct_prompt_str_for_shell(case_query, prompt_for, prompt_str)
|
95
|
+
shell = IPythonShell(header=prompt_str, prompt_for=prompt_for, case_query=case_query,
|
96
|
+
code_to_modify=code_to_modify)
|
97
|
+
else:
|
98
|
+
|
99
|
+
self.viewer.update_for_case_query(case_query, prompt_str,
|
100
|
+
prompt_for=prompt_for, code_to_modify=code_to_modify)
|
101
|
+
return self.prompt_user_input_and_parse_to_expression(shell=shell)
|
102
|
+
|
103
|
+
|
104
|
+
def construct_prompt_str_for_shell(self, case_query: CaseQuery, prompt_for: PromptFor,
|
105
|
+
prompt_str: Optional[str] = None) -> str:
|
106
|
+
"""
|
107
|
+
Construct the prompt string for the shell.
|
108
|
+
|
109
|
+
:param case_query: The case query to prompt the user for.
|
110
|
+
:param prompt_for: The type of information the user should provide for the given case.
|
111
|
+
:param prompt_str: The prompt string to display to the user.
|
112
|
+
"""
|
113
|
+
prompt_str += (f"\n{Fore.CYAN}{case_query.name}{Fore.MAGENTA} of type(s) "
|
114
|
+
f"{Fore.CYAN}({', '.join(map(lambda x: x.__name__, case_query.core_attribute_type))}){Fore.MAGENTA}")
|
115
|
+
if prompt_for == PromptFor.Conditions:
|
116
|
+
prompt_str += (f"\ne.g. `{Fore.GREEN}return {Fore.BLUE}len{Fore.RESET}(case.attribute) > {Fore.BLUE}0` "
|
117
|
+
f"{Fore.MAGENTA}\nOR `{Fore.GREEN}return {Fore.YELLOW}True`{Fore.MAGENTA} (If you want the"
|
118
|
+
f" rule to be always evaluated) \n"
|
119
|
+
f"You can also do {Fore.YELLOW}%edit{Fore.MAGENTA} for more complex conditions.")
|
120
|
+
|
121
|
+
prompt_str = f"{Fore.MAGENTA}{prompt_str}{Fore.YELLOW}\n(Write %help for guide){Fore.RESET}"
|
122
|
+
return prompt_str
|
123
|
+
|
124
|
+
|
125
|
+
def prompt_user_input_and_parse_to_expression(self, shell: Optional[IPythonShell] = None,
|
126
|
+
user_input: Optional[str] = None) \
|
127
|
+
-> Tuple[Optional[str], Optional[ast.AST]]:
|
128
|
+
"""
|
129
|
+
Prompt the user for input.
|
130
|
+
|
131
|
+
:param shell: The Ipython shell to use for prompting the user.
|
132
|
+
:param user_input: The user input to use. If given, the user input will be used instead of prompting the user.
|
133
|
+
:return: The user input and the AST tree.
|
134
|
+
"""
|
135
|
+
while True:
|
136
|
+
if user_input is None:
|
137
|
+
if QApplication.instance() is None:
|
138
|
+
shell = IPythonShell() if shell is None else shell
|
139
|
+
shell.run()
|
140
|
+
user_input = shell.user_input
|
141
|
+
else:
|
142
|
+
app = QApplication.instance()
|
143
|
+
if app is None:
|
144
|
+
app = QApplication([])
|
145
|
+
self.viewer.show()
|
146
|
+
app.exec()
|
147
|
+
user_input = self.viewer.user_input
|
148
|
+
if user_input is None:
|
149
|
+
return None, None
|
150
|
+
self.print_func(f"{Fore.GREEN}Captured User input: {Style.RESET_ALL}")
|
151
|
+
highlighted_code = highlight(user_input, PythonLexer(), TerminalFormatter())
|
152
|
+
self.print_func(highlighted_code)
|
153
|
+
try:
|
154
|
+
return user_input, parse_string_to_expression(user_input)
|
155
|
+
except Exception as e:
|
156
|
+
msg = f"Error parsing expression: {e}"
|
157
|
+
logging.error(msg)
|
158
|
+
self.print_func(f"{Fore.RED}{msg}{Style.RESET_ALL}")
|
159
|
+
user_input = None
|