Skip to main content

Command line menu interface

Project description

MenuCMD

pip install menucmd

MenuCMD provides a lightweight, functional‑style framework for building interactive command‑line menus. It keeps business logic (your functions) completely separate from the navigation layer, so the same code can run both interactively and unattended.

Use it for quick debugging utilities, as a CLI frontend for scripts, or whenever you need lazy/deferred evaluation. The package includes a simple .mcmd DSL to describe static menus in plain text.


Sections

1. Hello World

2. Multiple Menus

3. Function Composition

4. Lazy Evaluation via Bind

5. Menu Methods

6. Other Menu Attributes

7. MCMDlang (DSL)

8. Builtins


1. Hello World

Initializing a Menu

Start by importing the Menu class.

from menucmd import Menu

Create a new menu with a custom name.
This is what will appear at the top of the menu when run.

#Create New Menu
menu1 = Menu(name = "First Menu")

Menu Items

Create menu items using the Item class with three main parts:

from menucmd import Item

Item(
    key="x", 
    message="display message", 
    funcs=[(func1, arg1), (func2, arg2), ...]
)
  • key: Key to press to select this item (e.g., "x", "1", "a")
  • message: Text displayed in the menu
  • funcs: List of (function, arguments) tuples, executed left-to-right

Writing Function Arguments

In the funcs list, functions execute left-to-right. Each function is paired with its arguments as a tuple:

funcs=[(func1, arg1), (func2, arg2)]
# Executes as: func1(args1), then func2(arg2)

Single vs. multiple arguments:

  • One argument: (func, arg) or (func, (arg,)) both work
  • Multiple: (func, (arg1, arg2)) - must use tuple
  • Tuple as argument: (func, (my_tuple,)) - wrap with comma to distinguish from multiple args

Example: Print a message when user presses "x":

menu.append(
    Item(key="x", message="print greeting", funcs=[(print, "Hello World!")])
)

You can add multiple items at once:

menu.append(
    Item(key="x", message="say hello", funcs=[(print, "Hello!")]),
    Item(key="y", message="say goodbye", funcs=[(print, "Goodbye!")]),
)

Running 'Hello World'

Now all that's left is to run the menu by calling it with no arguments.

from menucmd import Menu, Item

#Create New Menu
menu1 = Menu(name="First Menu")

#Add an Item
menu1.append(
    Item(key="x", message="hello world program", funcs=[
        (print, "hello world!")
    ])
)

#Run menu
menu1()

The result should look like:

First Menu
[x]- hello world program
[e]- exit

Inputting 'x' will print the desired text, returning to the menu:

First Menu
[x]- hello world program
[e]- exit
x
hello world!

First Menu
[x]- hello world program
[e]- exit

In addition to the items you add, all menus will automatically add an exit key at the end of the list which will break out of the menu by default. (You can change this behaviour when initializing the menu)

When there is no more code to be run after the menu breaks, the program ends.


2. Multiple Menus

Menu Hooks: open_menu, to_menu, open_self

Menus run as functions by default and return a result. When navigating between menus, you may think to call another menu in the function chain like:

menu.append(
    Item(key="a", message="open menu", funcs=[
        (menu, ())
    ])
)

The problem here is that every time a menu is called, it will add to the python callstack and the recursion depth limit will eventually be reached. For menus that dont lead to other menus and must simply return a value, direct evaluation is fine.

In the case of menu navigation, use the following hooks:

  • open_menu(menu, arg=None): opens menu with optional argument
  • to_menu(menu): opens menu in Menu.*_to functions
  • open_self(arg=None): opens current menu with optional argument

Updated example:

menu.append(
    Item(key="a", message="open menu", funcs=[
        (open_menu, (menu2,))
    ])
)

Menu hooks will cause the chain to exit and any functions later in the chain will not be run!


Defining Two Menus

You can open other menus by running them as functions, letting the user navigate through a deeper menu structure.

First create a new Menu instance in the same way as menu1:

#Create New Menus
menu1 = Menu(name="First Menu")
menu2 = Menu(name="Second Menu")

Then, add another entry to menu1 that runs menu2 with no arguments.

from menucmd import open_menu

menu1.append(
    Item(key="x", message="hello world program", funcs=[
        (print, "hello world!")
    ]),
    Item(key="a", message="menu2", funcs=[
        (open_menu, (menu2,))
    ])
)

If we run the code and navigate to menu2, the following will happen:

First Menu
[x]- hello world program
[a]- menu2
[e]- exit
a
--*No Entries*--

Process finished with exit code 0

We defined no entries for menu2 so it automatically exits and, subsequently, the program, as menu1 has no more code to run.

This results in the same outcome as pressing the exit key in menu2.


Exit Key Control: exit_to, exit_key, exit_message

Control what happens when the user presses the exit key:

# Change which key exits (default is 'e')
menu = Menu(exit_key='q')

# Change the exit message shown on menu
menu = Menu(exit_message='quit')

# Change what happens when exiting (default returns from menu)
menu = Menu(exit_to=to_menu(other_menu))  # Go to other_menu instead

Adding an entry to menu2 and setting its exit_to will return to menu1 when the user exits menu2. A concise example:

menu1 = Menu(name="First Menu")
menu2 = Menu(name="Second Menu", exit_to=to_menu(menu1), exit_message="to menu1")

menu1.append(
    Item(key="a", message="open menu2", funcs=[(open_menu, (menu2,))]),
)

menu2.append(
    Item(key="b", message="say goodbye", funcs=[(print, "Goodbye!")])
)

menu1()

When the user exits from menu2, control returns to menu1.


Passing Data Between Menus

A menu can call another and pass data via result. Use open_menu to transfer control while passing the value:

menu_a = Menu(name="Menu A")
menu_b = Menu(name="Menu B")

menu_a.append(
    Item(key="open_b", message="go to Menu B", funcs=[
        (input, "Your name: "),
        (open_menu, (menu_b, result))  # Pass name to menu_b
    ])
)

menu_b.append(
    Item(key="greet", message="say hello", funcs=[
        (print, ("Hello", result)),  # result[0] = name from menu_a (after arg_to)
        (open_menu, (menu_a, result))  # Return to menu_a with same name
    ])
)

Controlling End Behavior: end_to

By default, after a function chain completes, the menu opens itself when the chain's last function returns None. Change this with end_to:

menu = Menu(end_to=to_menu(other_menu))  # After chain, open other_menu

Practical example: Create a simple info display that returns to the main menu:

main_menu = Menu(name="Main")
info_menu = Menu(name="Info", end_to=to_menu(main_menu))

info_menu.append(
    Item(key="show", message="show info", funcs=[
        (print, "This is information")
        # After print returns None, end_to=main_menu triggers
    ])
)

main_menu.append(
    Item(key="info", message="view info", funcs=[
        (open_menu, (info_menu, ()))
    ])
)

3. Function Composition

Using the Menu.result Object

When you chain functions, you often need the output of one function as input to the next. That's what Menu.result does.

(You may choose to rename it to result)

result serves as a special placeholder, replacing the output of the previous function during execution.

Simple example: Get number → convert to int → square it → print:

from menucmd import Menu, Item, Result

result = Menu.result

menu = Menu(name="Square a Number")
menu.append(
    Item(key="n", message="square a number", funcs=[
        (input, "Enter number: "),
        (int, result),           # Convert string to int
        (lambda x: x**2, result),  # Square it
        (print, result)            # Print result
    ])
)

When you run this and enter "5":

  • input() returns "5"
  • int("5") returns 5
  • lambda x: x**2 receives 5 and returns 25
  • print(25) displays the result

Accessing Previous Results

By default, result refers to the immediately previous output (equivalent to result[-1]).

To access earlier results in the chain, use indexing:

  • result[0] - the initial menu argument *(after arg_to)
  • result[1] - first function output
  • result[2] - second function output
  • result[-1] - most recent output (same as result)
  • result[-2] - output before last

Example: Ask for a number, double it, then print both the original and doubled:

menu.append(
    Item(key="d", message="double a number", funcs=[
        (input, "Number: "),
        (int, result),                    # result[2] = 5
        (lambda x: x * 2, result),        # result[3] = 10
        (print, ("Original:", result[2])),  # or result[-2]
        (print, ("Doubled:", result[3]))    # or result[-2]
    ])
)

Named Result References

Instead of remembering which index is which, you can name results as you go. This makes complex chains much more readable:

menu.append(
    Item(key="n", message="square a number", funcs=[
        (input, "number: "),
        (int, result),
        (lambda x: x**2, result.num),      # Name prev result 'num'
        (lambda x: x, result.squared),     # Name prev result 'squared'
        (print, ("Number:", result.num)),
        (print, ("Squared:", result.squared))
    ])
)

Notes on naming:

  • The first time you use a name (e.g. result.num) it captures the previous function's result.
  • If you want to name the initial menu argument (result[0]), use an identity function like Menu.id or lambda x: x and then name that result.

Names are scoped to each menu item—each time an item runs, the names reset.


Expanding Results with .expand()

When a function returns multiple values (tuple or list), you can "unpack" them as separate arguments using expand():

# Without expand: a_func receives the tuple as one argument
(func_returns_tuple, (), a_func, result)

# With expand: a_func receives each tuple element as separate arguments  
(func_returns_tuple, (), a_func, result.expand())

# When evaluated, this is equivalent to: 
a_func(*func_returns_tuple())

Example:

def get_coordinates():
    return (10, 20)

menu.append(
    Item(key="p", message="print coordinates", funcs=[
        (get_coordinates, ()),
        (lambda x, y: print(f"X: {x}, Y: {y}"), result.expand())
    ])
)

This passes 10 and 20 as separate arguments instead of passing the tuple (10, 20) as one argument.


Transform Initial Menu Input: arg_to

The arg_to parameter transforms the input passed to a menu before function chains execute. This is useful when you want to preprocess the menu argument for multiple items.

A companion flag arg_to_first (default True) controls whether this transformation occurs before the user makes a selection or after. Setting it to False means the original argument is available during item selection, and the transformation happens only when the chosen chain runs.

# Every chain receives result[0] = square of the input
menu = Menu(arg_to=lambda x: x**2)

menu.append(
    Item(key="1", message="add 1", funcs=[
        (lambda x: x + 1, result),
        (print, result)
    ]),
    Item(key="2", message="double", funcs=[
        (lambda x: x * 2, result),
        (print, result)
    ])
)

menu(2)  # Both items now operate on 4 (2 squared)

Without arg_to, you'd need to square the input in every single chain.


Argument Execution Order: arg_to_first

Additionally you may pass the arg_to_first flag to control when the transformation runs:

def menu_arg_to(x):
    print('Evaluating menu argument')
    return x**2

# show the argument being transformed only after choice
menu(arg_to=menu_arg_to, arg_to_first=False)

If arg_to_first=True (the default) the argument is evaluated as soon as the menu opens, so you'll see the print message before any options appear. With arg_to_first=False, the raw argument is preserved while the user browses options; the transformation (and the print) happens only when a keyed item is chosen, just prior to executing its function chain. This allows menus to make display or navigation decisions based on the original input.


4. Lazy Evaluation via Bind

When and Why to Use Bind

Normally, when you write lambda x: x**2, Python evaluates it immediately if you try to use its result as an argument elsewhere. With Bind, you can wrap a function and its arguments to delay evaluation until later—when you actually need the result.

Problem without Bind:

menu.append(
    Item(key="x", message="square a number", funcs=[
        (input, "number: "),
        # result doesn't exist yet, but Python tries to call float(result)
        (lambda x: x**2, float(result)),  # This fails!
        (print, result)
    ])
)

Solution with Bind:

from menucmd import Menu, Item, Bind as B

menu.append(
    Item(key="x", message="square a number", funcs=[
        (input, "number: "),
        # Bind delays float(result) evaluation
        (lambda x: x**2, B(float, result)),
        (print, result)
    ])
)

Now float(result) only executes after result exists.


Using the Bind Class

A Bind object holds a function and arguments, evaluating them only when needed:

B(func, *args, **kwargs)

Menus evaluate Bind objects automatically during a chain; outside of menus, just call them with ():

lazy_func = B(print, "Hello")
lazy_func()  # Now it prints

Nested Bind Objects

Each level of nesting gets evaluated depth-first:

B(func1, B(func2, B(func3, value)))
# First evaluates B(func3, value)
# Then evaluates B(func2, func3(value))
# Finally evaluates B(func1, func2(func3(value)))

Currying and .fix()

By default, a Bind wrapper accepts extra call-time arguments which are appended to the bound arguments.

printer = B(print, "Message:")
printer("hello")    # Prints: Message: hello  (additional arg appended)
printer("a", "b") # Prints: Message: a b

If you want to lock the bound arguments so later call-time arguments are ignored, use .fix():

printer_fixed = B(print, "Message:").fix()
printer_fixed("hello")  # Prints: Message:  ("hello" ignored)

.fix() toggles the wrapper into a fixed state so additional call-time args are ignored. This is useful when binding polymorphic functions (like print) or when you want a wrapper to always behave identically regardless of later calls.


5. Menu Methods

append(), insert(), delete(), clear()

Modify menu contents after creation:

menu.append(item1, item2, ...)        # Add items
menu.insert(0, item)                  # Insert at position 0 (first item)
menu.delete(0, k=2)                   # Delete 2 items starting at position 0
menu.clear()                          # Remove all items (keeps exit key)

Note: These don't count the exit key in their position indexing.


ch_exit()

Change exit key properties after creation:

menu.ch_exit(exit_key="q", exit_message="quit", exit_to=to_menu(other_menu))

Only specified parameters are updated; omit any you don't want to change.


Menu Indexing and Slicing

Access and slice menu items like lists:

menu[0]        # Get first item
menu[1:3]      # Slice items 1-2, returns new Menu
menu[0] = new_item  # Replace item

6. Other Menu Attributes

invalid_key and empty_message

Use invalid_key to set the message shown when the user presses an invalid key, and empty_message to override the text displayed when a menu has no entries. Example:

menu = Menu(invalid_key="Invalid choice", empty_message="No entries")

Colors and Appearance: MenuColors, ItemColors, Colors, exit_colors

Style menus and items with ANSI escape codes. Three helper classes help you do this:

  • Colors provides a simple namespace of ANSI color/formatting codes (e.g. Colors.RED, Colors.BOLD).
  • MenuColors: a dataclass that defines default colors for menu elements. Instantiate it and pass via the colors argument when creating a Menu. Its fields include:
    • name – the menu title line.
    • empty_message – text shown when the menu has no entries.
    • invalid_key – message printed on bad keypress.
    • *All fields in ItemColors
  • ItemColors: a subset of MenuColors that overrides colors for individual items on an attribute‑by‑attribute basis. Its fields include:
    • key - the item key
    • key_dash - the dash between key and message
    • message - the item message

The exit_colors parameter in Menu also accepts an ItemColors instance and works in the same way as colors passed to an Item.


Set Global Colors: Menu.set_global_colors

You can set defaults across all menus by calling Menu.set_global_colors with a MenuColors and/or ItemColors instance. This merges your values into the class-level defaults, affecting every menu created thereafter.

from menucmd import Menu, MenuColors, ItemColors, Colors

# change the default key color for all menus
Menu.set_global_colors(colors=MenuColors(key=Colors.GREEN))

# make the exit key red in every menu
Menu.set_global_colors(exit_colors=ItemColors(key=Colors.RED))

Menu.kwargs

When a function needs keyword arguments, wrap them with Menu.kwargs (which is just a dict):

def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

menu.append(
    Item(key="g", message="greet", funcs=[
        (input, "Name: "),
        (greet, (result, Menu.kwargs(greeting="Hi")))
    ])
)

Both syntaxes work:

Menu.kwargs(greeting="Hi")
Menu.kwargs({"greeting": "Hi"})

Early Exit: escape, escape_to

Sometimes you want a function in the chain to abort execution and exit early. Use Menu.escape to signal this:

def validate_input(text):
    if len(text) < 3:
        return Menu.escape  # Abort chain and go to escape_to
    return text

menu.append(
    Item(key="enter", message="enter text", funcs=[
        (input, "Text (3+ chars): "),
        (validate_input, result),
        (print, ("You entered:", result))
    ])
)

By default, escape re-runs the current menu. Change this with escape_to:

menu_a = Menu()
menu_b = Menu(escape_to=to_menu(menu_a))  # On escape, return to menu_a instead

menu_b.append(
    Item(key="enter", message="enter text", funcs=[
        (input, "Text (3+ chars): "),
        (validate_input, result),
        (print, ("You entered:", result))
    ])
)

Built-in escape helpers:

  • escape_on(value1, value2) - returns escape if the two values are equal
  • f_escape(*args, **kwargs) - always returns escape (useful in chains)

Example with escape_on:

menu.append(
    Item(key="enter", message="guess a number", funcs=[
        (input, "Guess: "),
        (escape_on, (result, "0")),  # Treat "0" as cancel
        (int, result),
        (print, ("Your guess:", result))
    ])
)

Menu.self

Use Menu.self to get a reference to the menu itself inside a function chain:

menu = Menu(name="Main Menu")
menu.append(
    Item(key="count", message="show item count", funcs=[
        (lambda m: len(m.menu_item_list), Menu.self),
        (print, result)
    ])
)

This is useful for introspection or passing the menu to another function.


clear_readout

Controls whether the menu display is cleared from the terminal after user input.

menu = Menu(clear_readout=False)  # Keep menu history in terminal

By default (clear_readout=True), each menu clears the previous output, showing only the current menu.


Keyword Shortcuts: exit_to, end_to, escape_to

When multiple *_to parameters tie to the same menu, use shortcuts:

menu_home = Menu()

# Instead of:
submenu = Menu(exit_to=(menu_home), end_to=(menu_home), escape_to=(menu_home))

# Use:
submenu = Menu(exit_to=to_menu(menu_home), end_to=Menu.exit_to, escape_to=Menu.exit_to)

Menu.exit_to copies the exit_to value. Menu.end_to copies end_to. This reduces repetition in complex menu hierarchies.


7. MCMDlang (DSL)

MenuCMD also comes with a simple dsl that abstracts away the menu creation process in main(). While it cannot create dynamic menus, it can access any function that does using the python method of appending items.


Formatting Rules:

  • Indents need not be proper tabs, as long as they are four spaces.
  • Menu id, function references, and function calls are written without quotes.
  • Comments are one line only! They will not be removed at the end of a line.
  • Blank lines do not matter.
  • You can include Colors and ExitColors blocks inside a Menu declaration. ExitColors works like an item color block but applies only to the exit key.

Example .mcmd file:

### in menus.mcmd ###

Menu:
    name: "Main Menu"
    id: main_menu
    exit_key: "e"
    exit_message: "exit"

    Colors:
        key: C.LIGHT_BLUE + C.BOLD
    ExitColors:
        key: C.RED + C.BOLD
        message: C.FAINT
    
    # <- This is a comment
    
    Item:                # <- This is NOT a comment!
        key: "x"
        message: "Hello World!"
        func: input("your name: ")
        func: print(B(lambda x: f"Hello {x}!", result))
        
    Item:
        key: "y"
        message: "Go to menu2" 
        func: open_menu(menu2)
        
Menu:
    name: "Second Menu"
    id: menu2
    exit_to: to_menu(main_menu)
    
    Item:
        key: "z"
        message: "Goodbye World!"
        func: print("Goodbye World!")

build_menus

Then import build_menus, and run with the file path:

from menucmd.dsl import build_menus

#Build menus from mcmd
menus = build_menus("menus.mcmd")

#Populate namespace 
main_menu = menus["main_menu"]
menu2 = menus["menu2"]

#Run
main_menu()

build_menus imports the scope from where you call it and creates pointers between menus extracted from the id field under the Menu declaration. You can hash the returned object by ids as a dictionary, or return menus from attributes:

menus['menu_id'] = menu.menu_id

In addtion, the following shorthand refs are included in MCMDlang by default:

  • result = Menu.result
  • B = Bind
  • C = Colors
  • kwargs = Menu.kwargs
  • self = Menu.self
  • All builtins and menu hooks

8. Builtins

So far, this tutorial has approached creating menus as separate entities from the functions they compose. While this is an intended feature of the module, you may still use menus within functions. menucmd has a number of builtin functions to create template menus and to make in-line composition easier.

from menucmd.builtins import *

In-line Functions

Utility functions to control execution flow within function chains.


escape_on(value1, value2)

Returns escape if the values are equal, otherwise returns value1. Useful for escaping on specific input:

menu.append(
    Item(key="input", message="enter something", funcs=[
        (input, "Enter text (q to quit): "),
        (escape_on, (result, "q")),  # Escape if user enters "q"
        (print, ("You entered:", result)),
        (print, result)
    ])
)

f_escape(*args, **kwargs)

Always returns escape regardless of arguments. Useful for unconditionally ending a chain.


f_end(*args, **kwargs)

Always returns None, triggering end_to behavior. Useful to explicitly end a chain:

menu.append(
    Item(key="x", message="Manual End", funcs=[
        (input, "Pick a number"),
        (f_end, ())  # Explicitly trigger end_to
    ])
)

f_switch(index, func_list)

Pick which function to run based on a previous result.
Returns Bind(lambda b: func_list[b], n).

def plus(x, y):
    return x + y

def times(x, y):
    return x * y

menu.append(
    Item(key="choose", message="pick operation", funcs=[
        (input, "1=plus, 2=times: "),
        (f_switch(result, [plus, times]), (5, 3)),
        (print, result)
    ])
)

If a function in the list does not have enough arguments for what you try to curry to it ((5, 3) in the above example), then it will ignore those arguments.


Builtin Menus

Ready-made menu templates for common interactions. All accept optional **kwargs to customize the menu (e.g., name="Custom Name").


yesno_ver(yes=True, no=False, yes_message="yes", **kwargs)

Simple yes/no dialog:

if yesno_ver():
    print("User said yes!")
else:
    print("User said no!")

Returns the yes parameter on yes, no parameter on no (customize with keyword args).


choose_item(entries, exit_val=None, **kwargs)

Let user select one item from a list/tuple/dict/set:

options = ["Red", "Green", "Blue"]
choice = choose_item(options)
if choice != None:
    print(f"You chose: {choice}")

Returns the selected item, or exit_val (default None) if user exits.


choose_items(entries, **kwargs)

Let user select multiple items (inverse of edit_list):

options = ["Python", "JavaScript", "Rust"]
selected = choose_items(options)
print(f"You selected: {selected}")

Returns a collection of all selected items.


edit_list(entries, **kwargs)

Let user remove items from a list/tuple/dict/set by interactively selecting them:

items = ["apple", "banana", "cherry"]
remaining = edit_list(items)
print(f"Remaining items: {remaining}")

Displays the collection as a menu, removes selected items, shows menu again until exit.

Dynamic Menus (WIP)

Advanced feature for dynamically modifying menus during execution. This section is under development; use the static menu patterns above for production code.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

menucmd-1.2.9.tar.gz (41.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

menucmd-1.2.9-py3-none-any.whl (35.6 kB view details)

Uploaded Python 3

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page