How to Build a Menu Tree for a Python CLI App

How to Build a Menu Tree for a Python CLI App

A step-by-step guide to build a Python CLI application using a Tree with Nodes

In this article I will walk through the basic functionality for creating a menu tree in Python using Nodes, then populating the menu tree to create a basic CLI application.

The repo for this example can be found here:

menu_tree_cli_example Repository

For simplicity, I have omitted data validation procedures, including property methods.

For a more robust example of how I used menu trees to build a CLI app, check out my rental management tool on Github:

rental_management_tool Repository

Why Build a CLI Application?

User interfaces are great for appealing to a broad audience, but many tasks don’t need them—CLI tools provide faster, scriptable solutions for efficient workflows.

Some benefits of CLI applications include:

  • Automation and Scripting: Python CLI applications allow users to automate repetitive tasks and integrate them into scripts for streamlined workflows.

  • Ease of Development and Extensibility: Python's readability and extensive libraries make it easy to build and expand CLI tools for various use cases.

  • Cross-Platform Compatibility: Python is available on all major operating systems, making CLI tools portable and accessible to a wide audience.

  • Versatility and Integration: Python CLI tools can handle diverse tasks, from data processing to interacting with APIs and databases, fitting seamlessly into development, DevOps, or data workflows.

Building the MenuTree and Node Classes

The following dependencies will be required for this example:

from pick import pick
import os

First, create and initalize MenuTree and Node classes.

For this basic example, the MenuTree class will just need to store the root Node of the tree.

The Node class will have the following attributes:

  • option_label: label when node is displayed as a menu option

  • title_label: title after node is selected from menu options

  • parent: parent node of the current instance

  • children: list of child Node instances

  • procedure: function to run when instance is selected from menu (default None)

    • procedures can optionally include nodes as return values to tell the application where to go next if a procedure is run
  • data_ref: any object that the user would like to store in the instance

    • note: for those familiar with Javascript/HTML you can think of this like the dataset attribute
class MenuTree:
    def __init__(self, node):
        self.root = node

class Node:
    last_node = None

    def __init__(self, option_label, title_label=None):
        self.option_label = option_label
        self.title_label = option_label if title_label is None else title_label
        self.parent = None
        self.children = []
        self.procedure = None
        self.data_ref = None

Next, add methods to the Node class which are used to populate the tree:

    def add_procedure(self, procedure):
        '''
        validates and adds procedure to instance

        Parameters
        ---------
        procedure: callable object
            - function to be run when node is activated
        '''
        if callable(procedure):
            _procedure = procedure
        else:
            raise ValueError("Function parameter must be a function")

        self.procedure = _procedure       

    def add_child(self, node):
        '''
        adds child node to list of children

        Parameters
        ---------
        node: Node instance
            - node to be added as a child of instance
        '''
        node.parent = self
        self.children.append(node)

    def add_children(self, nodes):
        '''
        adds multiple children to instance

        Parameters
        ---------
        nodes: list
            - list of Node instances to be added as children of instance
        '''
        for node in nodes:
            self.add_child(node)

Then, add methods to the Node class which are used to run the CLI application:

  • show_menu: run from the cli to show menu items based on user selections

    • the return of this function is a node to tell the application where to go after a menu item is selected

  • run_procedure: run from the cli to run procedure stored in a Node if that Node is selected by the user

    • the return of this function is a node to tell the application where to go after a procedure is run

    • if a procedure includes a node as a return value, this will override the default next node


    def show_menu(self):
        '''
        navigates to next menu based on user selection

        Returns
        ---------
        self.children[index]: Node instance
            - Node to display in the application after user selection
        '''
        Node.last_node = self
        os.system('cls' if os.name == 'nt' else 'clear')
        user_selection, index = pick([child.option_label for child in self.children], self.title_label)

        return self.children[index]

    def run_procedure(self):
        '''
        runs procedure stored in instance

        Returns
        ---------
        Node instance
            - Node to display in the application after procedure runs
        '''
        default_next = self if len(self.children) > 0 else self.parent
        next_node = self.procedure()

        return next_node if next_node else default_next

Populating the Tree

Create generic menu tree nodes

Start by initializing the tree and adding reusable menu items as nodes

Note that procedures can be added as lambda functions or regular functions if a function does not include parameters.

The to_main and go_back node procedures return nodes to tell the CLI where to go next if these procedures are run.

from menu_tree import MenuTree, Node
import sys
from pick import pick

# //////////////////////////////////////
# INITIALIZE TREE

main = Node(option_label="Main Menu")
menu = MenuTree(main)

# //////////////////////////////////////
# BASIC REUSABLE FUNCTIONALITY

# go to main menu

to_main = Node(option_label="Main Menu")
to_main.add_procedure(lambda: menu.root)

# go to previous menu

go_back = Node(option_label="Previous Menu")
go_back.add_procedure(lambda: Node.last_node.parent)

# exit application

exit_app = Node(option_label="Exit App")
exit_app.add_procedure(lambda: sys.exit())

Create application-specific menu tree nodes

In the same py file, add in any additional nodes that should be used for your application.

For this example, I have added nodes to select a pet, then greet that selected pet.

A few things to note here:

  • Store pet selection utilizes the data_ref attribute to store user-selected data inside a Node instance

  • After a pet is selected, the say_hi_to_pet and say_bye_to_pet nodes utilize this attribute by referencing data_ref from the select_pet node

# //////////////////////////////////////
# ADD APPLICATION-SPECIFIC NODES

pet_names = ["Brooke", "Buster", "Baxter"]

pets = Node(option_label="Pets")

# select pet

def store_pet_selection(ref_node):
    user_input, index = pick(pet_names, f"Select pet from the following options")
    ref_node.data_ref = user_input

select_pet = Node(option_label="Select Pet")
select_pet.add_procedure(lambda: store_pet_selection(ref_node=select_pet))

# greet pet

def greet_pet(greeting, pet_name):
    print(f'{greeting}, {pet_name}!')
    print("press any key to continue")
    input("")

# say hi to pet

say_hi_to_pet = Node(option_label="Say Hi to Pet")
say_hi_to_pet.add_procedure(lambda: greet_pet("Hi", select_pet.data_ref))

# say bye to pet

say_bye_to_pet= Node(option_label="Say Bye to Pet")
say_bye_to_pet.add_procedure(lambda: greet_pet("Bye", select_pet.data_ref))

Attach nodes to parent elements to create hierarchy of menu items

💡
if you are familiar with Javascript, this is similar to the append method when working with a DOM tree
# attach nodes to parent elements

select_pet.add_children([say_hi_to_pet, say_bye_to_pet, go_back, to_main, exit_app])
pets.add_children([select_pet, to_main, exit_app])
main.add_children([pets, exit_app])

Building the Application

Finally, add the code to run the CLI

# //////////////////////////////////////
# RUN CLI

if __name__ == "__main__":

    node = menu.root # set initial node to root
    while True:
        if node.procedure:
            node = node.run_procedure() # invoke callback function if user is at the end of the menu tree

        if len(node.children) > 0:
            node = node.show_menu() # show next menu if the user is not at the end of the menu tree

Running the Application

In the terminal, run the application by navigating to the the codebase and running “python cli.py

note: cli.py is the name of the file which includes population of the tree and application code

(3.8.13) jtrapp@LAPTOP-9VMAHRSC:~/example_tree$ python cli.py

The Result

After following these steps you should now be able to create a fully functioning cli application with highly flexible and easy-to-update menus!

Appendix

Full code for example:

#menu_tree.py
from pick import pick
import os

class MenuTree:
    '''
    A class to create and manage a menu tree for a cli application

    Attributes
    ---------
    root: Node instance
        - Root node of the tree instance
    '''

    def __init__(self, node):
        '''
        Constructs the necessary attributes for the MenuTree object.

        Parameters
        ---------
        node: Node instance
            - Node to be used for the root of the tree instance
        '''
        self.root = node

    def __repr__(self):
        return (
            f"<Root Node: {self.root.option_label}>"
        )

class Node:
    '''
    A class to create and manage nodes on a menu tree for rental management application

    Attributes
    ---------
    option_label: str
        - label when node is displayed as a menu option
    title_label: str
        - title after node is selected from menu options
    menu_tree: MenuTree instance
        - menu tree that current instance is connected to
    parent: Node instance
        - parent of current instance
    children: list
        - list of child Node instances
    procedure: callable object
        - function to run when instance is selected from menu
    data_ref: class instance

    Methods
    ---------
    - add_procedure: validates and adds dictionary with procedure information to instance
    - add_child: adds child node to list of children
    - add_children: adds multiple children to instance
    - show_menu: navigates to next menu based on user selection
    - run_procedure: runs procedure stored in instance
    '''
    last_node = None

    def __init__(self, option_label, title_label=None):
        self.option_label = option_label
        self.title_label = option_label if title_label is None else title_label
        self.parent = None
        self.children = []
        self.procedure = None
        self.data_ref = None

    def __repr__(self):
        return (
            f"<Node: {self.option_label}>"
        )

    def add_procedure(self, procedure):
        '''
        validates and adds procedure to instance

        Parameters
        ---------
        procedure: callable object
            - function to be run when node is activated
        '''
        if callable(procedure):
            _procedure = procedure
        else:
            raise ValueError("Function parameter must be a function")

        self.procedure = _procedure       

    def add_child(self, node):
        '''
        adds child node to list of children

        Parameters
        ---------
        node: Node instance
            - node to be added as a child of instance
        '''
        node.parent = self
        self.children.append(node)

    def add_children(self, nodes):
        '''
        adds multiple children to instance

        Parameters
        ---------
        nodes: list
            - list of Node instances to be added as children of instance
        '''
        for node in nodes:
            self.add_child(node)

    def show_menu(self):
        '''
        navigates to next menu based on user selection

        Returns
        ---------
        self.children[index]: Node instance
            - Node to display in the application after user selection
        '''
        Node.last_node = self
        os.system('cls' if os.name == 'nt' else 'clear')
        user_selection, index = pick([child.option_label for child in self.children], self.title_label)

        return self.children[index]

    def run_procedure(self):
        '''
        runs procedure stored in instance

        Returns
        ---------
        Node instance
            - Node to display in the application after procedure runs
        '''
        default_next = self if len(self.children) > 0 else self.parent
        next_node = self.procedure()

        return next_node if next_node else default_next
#cli.py
from menu_tree import MenuTree, Node
import sys
from pick import pick

# //////////////////////////////////////
# INITIALIZE TREE

main = Node(option_label="Main Menu")
menu = MenuTree(main)

# //////////////////////////////////////
# BASIC REUSABLE FUNCTIONALITY

# go to main menu

to_main = Node(option_label="Main Menu")
to_main.add_procedure(lambda: menu.root)

# go to previous menu

go_back = Node(option_label="Previous Menu")
go_back.add_procedure(lambda: Node.last_node.parent)

# exit application

exit_app = Node(option_label="Exit App")
exit_app.add_procedure(lambda: sys.exit())

# //////////////////////////////////////
# ADD APPLICATION-SPECIFIC NODES

pet_names = ["Brooke", "Buster", "Baxter"]

pets = Node(option_label="Pets")

# select pet

def store_pet_selection(ref_node):
    user_input, index = pick(pet_names, f"Select pet from the following options")
    ref_node.data_ref = user_input

select_pet = Node(option_label="Select Pet")
select_pet.add_procedure(lambda: store_pet_selection(ref_node=select_pet))

# greet pet

def greet_pet(greeting, pet_name):
    print(f'{greeting}, {pet_name}!')
    print("press any key to continue")
    input("")

# say hi to pet

say_hi_to_pet = Node(option_label="Say Hi to Pet")
say_hi_to_pet.add_procedure(lambda: greet_pet("Hi", select_pet.data_ref))

# say bye to pet

say_bye_to_pet= Node(option_label="Say Bye to Pet")
say_bye_to_pet.add_procedure(lambda: greet_pet("Bye", select_pet.data_ref))

# attach nodes to parent elements

select_pet.add_children([say_hi_to_pet, say_bye_to_pet, go_back, to_main, exit_app])
pets.add_children([select_pet, to_main, exit_app])
main.add_children([pets, exit_app])

# //////////////////////////////////////
# RUN CLI

if __name__ == "__main__":

    node = menu.root # set initial node to root
    while True:
        if node.procedure:
            node = node.run_procedure() # invoke callback function if user is at the end of the menu tree

        if len(node.children) > 0:
            node = node.show_menu() # show next menu if the user is not at the end of the menu tree