gists/python/state-cli.md

195 lines
4.9 KiB
Markdown

Implementation of a command line interface that provides different commands
based on the state of a state machine.
```py
import prompt_toolkit
from prompt_toolkit.styles import Style
from prompt_toolkit.patch_stdout import StdoutProxy, patch_stdout
import time
import threading
import logging
#
# StateCli
#
class StateCommandBase:
"""Base class for state-specific commands."""
def __init__(self, cli, state_machine):
self.cli = cli
self.state_machine = state_machine
class StateCli:
"""
Implementation of a CLI working in conjuction with a state machine.
Depending on the state, the CLI provides different commands.
"""
def __init__(self, state_machine, command_map):
self.state_machine = state_machine
self.state_commands = {
key: value(self, state_machine) for key, value in command_map.items()
}
self.update_prompt()
self._prompt_style = Style.from_dict(
{
"prompt": "ansiblue bold",
"input": "",
}
)
self.session = prompt_toolkit.PromptSession()
def update_prompt(self):
self.prompt = f"{self.state_machine.current_state}> "
def _process_command(self, command, args):
current_state = self.state_machine.current_state
if current_state not in self.state_commands:
print(f"No commands defined for state {current_state}")
return
state_cmd_obj = self.state_commands[current_state]
if not hasattr(state_cmd_obj, command):
print(f"Unknown command: '{command}'")
self._do_help()
return
getattr(state_cmd_obj, command)(" ".join(args))
def cmdloop(self):
with patch_stdout():
while True:
try:
user_input = self.session.prompt(
self.prompt, style=self._prompt_style
)
parts = user_input.split()
if len(parts) == 0:
continue
command, args = parts[0], parts[1:]
if command == "help":
self._do_help()
continue
elif command == "exit":
break
self._process_command(command, args)
except KeyboardInterrupt: # ctrl+c
break
except EOFError: # ctrl+d
break
def _do_help(self):
current_state = self.state_machine.current_state
print("=" * 50)
print("Available commands in any state")
print(f" {'help':15} - Show this help")
print(f" {'exit':15} - Exit the application")
print("=" * 50)
print(f"Available commands in state {current_state}")
if current_state not in self.state_commands:
print(f" No commands defined for state {current_state}")
print("=" * 50)
return
state_cmd_obj = self.state_commands[current_state]
commands = [
name
for name in dir(state_cmd_obj)
if not name.startswith("_") and callable(getattr(state_cmd_obj, name))
]
if not commands:
print(" No commands available")
print("=" * 50)
return
for cmd in sorted(commands):
method = getattr(state_cmd_obj, cmd)
if method.__doc__:
doc = method.__doc__.strip()
first_line = doc.split("\n")[0]
print(f" {cmd:15} - {first_line}")
else:
print(f" {cmd:15} - No documentation available")
print("=" * 50)
#
# StateMachine
#
class StateMachine:
def __init__(self):
self.current_state = "idle"
def start(self):
self.current_state = "running"
def stop(self):
self.current_state = "idle"
#
# Usage
#
class IdleCommands(StateCommandBase):
def start(self, args):
"""Start the machine"""
print("Starting the machine")
self.state_machine.start()
self.cli.update_prompt()
class RunningCommands(StateCommandBase):
def _stop(self, args):
"""Stop the machine"""
print("Stopping the the machine")
self.state_machine.stop()
self.cli.update_prompt()
def background_task():
while True:
time.sleep(2)
logging.info("This is a log message from some background task")
def main():
sm = StateMachine()
cli = StateCli(sm, {"idle": IdleCommands, "running": RunningCommands})
logging.basicConfig(
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
level=logging.INFO,
stream=StdoutProxy(),
)
thread = threading.Thread(target=background_task, daemon=True)
thread.start()
cli.cmdloop()
if __name__ == "__main__":
main()
```