6
\$\begingroup\$

I have a Python command line application that needs to ask the user for confirmation at some point. I want to add a --assume-yes/--assume-no command line flag to skip the confirmation. I have several ideas how to handle this flag and am not sure which is good/best/terrible.

Initial code

# main.py, this file is simplified, "work" knows nothing about the structure of "args"
import cli, interactive

def work(foo):
    if interactive.confirm(f"do you want {foo}"):
        print("ok, I will do it")
    else:
        print("skipping")

if __name__ == "__main__":
    args = cli.parse()
    work(args.foo)
# cli.py, this is simplified
import argparse

def parse():
    parser = argparse.ArgumentParser()
    parser.add_argument("--foo")
    parser.add_argument("--assume-yes", action="store_true", dest="assume",
                        default=None)
    parser.add_argument("--assume-no", action="store_false", dest="assume")
    args = parser.parse_args()
    # how to handle args.assume at this point? see below for my ideas
    return args
# interactive.py, this is the original function from my project
def confirm(message: str, accept_enter_key: bool = True) -> bool:
    """Ask the user for confirmation on the terminal.

    :param message: the question to print
    :param accept_enter_key: Accept ENTER as alternative for "n"
    :returns: the answer of the user
    """
    while True:
        answer = input(message + ' (y/N) ')
        answer = answer.lower()
        if answer == 'y':
            return True
        if answer == 'n':
            return False
        if answer == '' and accept_enter_key:
            return False
        print('Please answer with "y" for yes or "n" for no.')

Ideas how to handle args.assume

I came up with three ideas but I can find something about each idea that I don't like:

  1. replace interactive.confirm with another function dynamically

    # insert this into cli.py
        if args.assume is not None:
            import interactive
            interactive.confirm = lambda _, __=None: args.assume
    

    I do not like this because it changes a function on the fly. I like it because I can forget the args.assume thing in the rest of my application. It does not show up in the unit tests of interactive.confirm.

  2. add an extra argument to all functions that call interactive.confirm and to the function itself:

    # in interactive.py
    def confirm(message: str, assume: bool|None, accept_enter_key: bool = True) -> bool:
        if assume is not None:
            return assume
        ...
    # in main.py
    def work(foo, assume):
        if interactive.confirm(f"do you want {foo}", assume):
        ...
    
    if __name__ == "__main__":
        args = cli.parse()
        work(args.foo, args.assume)
    

    I don't like this because I have to change the signature of many functions in my program and the new argument has to be tested in the unittest of interactive.confirm.

  3. save the value of args.assume to a global variable that is read by interactive.confirm:

    # insert this into cli.py
        if args.assume is not None:
            import interactive
            interactive.assume = args.assume
    # and in interactive.py
    def confirm(message: str, accept_enter_key: bool = True) -> bool:
        if assume is not None:
            return assume
        ...
    

    This (nearly) has the advantage of (1) because it does not touch any other code but the command line parser and confirm. But it uses a global mutable variable to toggle program behaviour which I thought was bad style.

Question

What do you think about my ideas to implement the assume logic? What pros and cons did I forget? Do you know another way to implement it?

Background

The console application is khard. confirm is called directly in nine places by six other functions. Some of these get called by other functions in turn.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ "But it uses a global mutable variable to toggle program behaviour which I thought was bad style." yes, to an extent. But monkey-patching is treating functions as global mutable variables, which also has the lovely side-effect of making your code impossible to read. \$\endgroup\$
    – Passer By
    Commented Sep 25, 2023 at 15:08

3 Answers 3

6
\$\begingroup\$

Of the three approaches, the first seems best to me. We can do a little better by giving names to the the "assume" versions of the function, and include them in interactive.py:

def confirm(message: str, accept_enter_key: bool = True) -> bool:
    # implementation unchanged

def assume_yes(_: str, __:bool = True):
    return True

def assume_no(_: str, __:bool = True):
    return True

Then we can set the implementation to use in args:

    parser.add_argument("--assume-yes", action="store_const",
                        const=interactive.assume_yes, dest="confirm",
                        default=interactive.confirm)
    parser.add_argument("--assume-no", action="store_const",
                        const=interactive.assume_no, dest="confirm")

Then use args.confirm() in place of interactive.confirm() throughout the code.

I think this is more amenable to unit-testing, and avoids global state (either explicit in global variables or implicit by assigning to interactive.confirm).


A review of the confirm() implementation:

       answer = input(message + ' (y/N) ')

This is misleading, as n is default only if accept_enter_key is true. We should be showing (y/n) here if there's no default answer.

We can only accept "no" as default; that might be sufficient for this application, but a more generally useful implementation would allow the second argument to be true, false or none:

def confirm(message: str, default: Optional[bool] = None) -> bool:
    # implementation unchanged
    """Ask the user for confirmation on the terminal.

    :param message: the question to print
    :param default: the default answer if user just presses Enter
    :returns: the answer of the user
    """
    keys = ['n', 'y']
    get_key = lambda v: (str.lower,str.upper)[v == default](keys[v])
    prompt = f"{message} ({get_key(True)}/{get_key(False)}) "
    while True:
        answer = input(prompt)
        answer = answer.lower()
        if answer == keys[True]:
            return True
        if answer == keys[False]:
            return False
        if answer == '' and default is not None:
            return default
        print('Please answer with "y" for yes or "n" for no.')

This is also one step closer to internationalising this function (since we now use the keys array rather than embedding y and n in multiple places).

\$\endgroup\$
1
  • 3
    \$\begingroup\$ Passing args.confirm around is actually more than "a little better" than monkey-patching as it takes care of corner cases such as some modules using an from interactive import confirm approach and all of a sudden you can't understand why one of your confirm call does not use the patched version and still ask you for confirmation. \$\endgroup\$ Commented Sep 26, 2023 at 8:41
5
\$\begingroup\$

Don't assign to args within the __main__ guard - it's still global; it needs to be moved to a function.

--assume-yes and --assume-no need to be mutually exclusive for the argparse setup to make sense. The easiest way is to convert this to one argument that accepts yes or no. Or use add_mutually_exclusive_group.

I recommend that you use early short-circuiting for assume and not work it into the logic of the confirm function itself, which shouldn't concern itself with command-line parameters. There are many ways; I demonstrate a simple dict-based one that's a variation of your (1). This is a form of the dependency injection pattern.

The way it's shown with monkey-patching (2) is not great; better to pass the method in rather than replacing it for the entire module.

Don't do (3) the global-variable approach.

The current default for confirm is False. You can make this parametric if you want. The typical format for Unix-likes to indicate enter-defaults is to surround them in brackets, and (EBNF-like) pipe-separated options, as in [y]|n.

Don't thread args through the program; it's weakly-typed. It should terminate in main.

Suggested

import argparse
from typing import Protocol


def parse_bool(string: str) -> bool:
    prefix = string.lower()[:1]
    if prefix in {'y', 'n'}:
        return prefix == 'y'
    raise ValueError('Invalid input')


def parse() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument('--foo', help='it is a mystery')
    parser.add_argument('--assume', help='it is a mystery', type=parse_bool)
    args = parser.parse_args()

    return args


class ConfirmCall(Protocol):
    def __call__(self, message: str, default: bool | None = False) -> bool:
        ...


def confirm_input(message: str, default: bool | None = False) -> bool:
    prompt = (
        f'{message} ('
        f'{"[y]" if default is True else "y"}|'
        f'{"[n]" if default is False else "n"})? '
    )

    while True:
        answer = input(prompt)
        if answer == '' and default is not None:
            return default
        try:
            return parse_bool(answer)
        except ValueError:
            pass


CONFIRM_METHODS: dict[bool | None, ConfirmCall] = {
    None: confirm_input,
    True: lambda *args, **kwargs: True,
    False: lambda *args, **kwargs: False,
}


def work(foo: str, confirm: ConfirmCall) -> None:
    if confirm(f'Do you want {foo}'):
        print('it is a mystery')
    else:
        print('it is even more of a mystery')


def main() -> None:
    args = parse()
    confirm = CONFIRM_METHODS[args.assume]
    work(args.foo, confirm)


if __name__ == '__main__':
    main()
\$\endgroup\$
2
\$\begingroup\$

An online search for "python configuration management" will return a slew of libraries and articles proposing solutions related to this topic. Which suggests there isn't a single answer that makes sense in all cases.

Global variables

Contrary to the other answer I suggest variations on option 3 (i.e., global variables) in combination with the namespace argument to parse_args(). The namespace argument lets you provide an object to which parsed cli arguments are attached. Remember, everything is an object, so you can pass in a class, and instance of a class, a module, function, method, etc. (Ironically, you can't pass in object(), because a bare object doesn't have the machinery to add attributes)

For small programs (1 or a small number of files), pass in the main module as the namespace to parse_args(), and the results of are created as globals.

import argparse
import sys

# Global constants. These are default values, 
# but can be set by cli options
ASSUME_YES = None
FOO = 6.02e23

parser = argparse.ArgumentParser()
parser.add_argument("--foo", dest='FOO')
parser.add_argument("--assume-yes", action="store_true", dest="ASSUME_YES",
                    default=None)

parser.parse_args(namespace=sys.modules['__main__'])

This will then store the results of cli parsing as attributes on the main module, which makes them global variables. Use ALL CAPS variable names for dest in add_argument() to make them look like traditional "manifest constants".

Module level variables

If the program has more than a few files/modules, this technique can be modified to create module level constants. Group the cli arguments by module and use parse_known_args():

import argparse
import interactive
import sys

# Global constants. These are default values, 
# but can be set by cli options
ASSUME = None
FOO = 6.02e23

# main args get turned into globals
main_parser = argparse.ArgumentParser()
main_parser.add_argument("--foo", dest='FOO')
ns, unknown_args = parser.parse_known_args(namespace=sys.modules['__main__'])

# these cli args get turned into module level
# variables in the interactive.py module
interactive_parser = argparse.ArgumentParser()
interactive_parser.add_argument("--assume-yes", 
                                action="store_true",
                                dest="ASSUME_YES",
                                default=None)
_, unknown_args = interactive_parser.parse_known_args(args=unknown_args,
                                                namespace=interactive)   # <<<

parse_known_args() returns the namespace and a list of any args that weren't parsed, i.e., the unknown args. The calls to parse_known_args() are setup in a chain so that unknown args from one call are passed on to the next call. If desired add a final call to parse_args() to collect any remaining cli arguments that don't need special handling.

Obviously, this code could be reorganized so that each module has a function to parse the cli args it recognizes and sets module level variables, and returns the unparsed cli args.

Global and/or module config objects

If you don't like global or module level variables, use a config object:

import argparse
import sys

# default configuration, some values can be set
# by cli arguments
config = argparse.Namespace(
    assume_yes=None,
    foo=6.02e23
)

parser = argparse.ArgumentParser()
parser.add_argument("--foo", dest='foo')
parser.add_argument("--assume-yes", action="store_true", dest="assume_yes",
                    default=None)

parser.parse_args(namespace=config)   # <<<<

This creates a global "config" object with attributes set to the cli args. Code similar to the code in the module level variables can be used to set module level config objects.

interactive.py

To minimize changes to other code,

# rename the old confirm()
def interactive_confirm(message: str, accept_enter_key: bool = True) -> bool:
    ...

# add a new confirm() that implements the assume-yes/no logic
def confirm(message: str, accept_enter_key: bool = True) -> bool:
    if ASSUME_NO:
        return False

    return ASSUME_TRUE or interactive_confirm(message, accept_enter_key)

Globals in Python

To change a global or module level variable from inside a function requires some deliberate steps like:

  • assigning to something of the form "module_name.var_name"
  • using a global or nonlocal statement
  • using setattr() on a module

Without taking those steps, an assignment just creates a function local variable. So in Python, global variables may not be a bad as some other languages.

\$\endgroup\$

Not the answer you're looking for? Browse other questions tagged or ask your own question.