Volans has uploaded a new change for review. ( 
https://gerrit.wikimedia.org/r/382481 )

Change subject: CLI: extract parser definition from parse_args()
......................................................................

CLI: extract parser definition from parse_args()

* Extract the ArgumentParser definition from parse_args() into a
  get_parser() function for easier testability and documentation
  generation.
* Uniform help messages in ArgumentParser options.

Change-Id: I99a65ba374860e479c7a1046f4aa72a898ab8730
---
M cumin/cli.py
M cumin/tests/unit/test_cli.py
2 files changed, 94 insertions(+), 42 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/operations/software/cumin 
refs/changes/81/382481/1

diff --git a/cumin/cli.py b/cumin/cli.py
index 86209b5..ded2d2a 100644
--- a/cumin/cli.py
+++ b/cumin/cli.py
@@ -55,11 +55,12 @@
     """Custom KeyboardInterrupt exception class for the SIGINT signal 
handler."""
 
 
-def parse_args(argv=None):
-    """Parse command line arguments and return them.
+def get_parser():
+    """Create and return the command line arguments parser.
 
-    Arguments:
-        argv: the list of arguments to use. If None, the command line ones are 
used [optional, default: None]
+    Returns:
+        argparse.ArgumentParser: the parser object.
+
     """
     sync_mode = 'sync'
     async_mode = 'async'
@@ -69,13 +70,14 @@
     transports_names = [name for _, name, ispkg in 
pkgutil.iter_modules(transports.__path__) if not ispkg]
 
     parser = argparse.ArgumentParser(
+        prog='cumin',
         description='Cumin CLI - Automation and orchestration framework 
written in Python',
         epilog='More details at https://wikitech.wikimedia.org/wiki/Cumin')
     parser.add_argument('-c', '--config', default='/etc/cumin/config.yaml',
                         help='configuration file. [default: 
/etc/cumin/config.yaml]')
-    parser.add_argument('--global-timeout', type=int, default=None,
+    parser.add_argument('--global-timeout', type=int,
                         help='Global timeout in seconds (int) for the whole 
execution. [default: None (unlimited)]')
-    parser.add_argument('-t', '--timeout', type=int, default=None,
+    parser.add_argument('-t', '--timeout', type=int,
                         help=('Timeout in seconds (int) for the the execution 
of every command in each host. '
                               '[default: None (unlimited)]'))
     parser.add_argument('-m', '--mode', choices=(sync_mode, async_mode),
@@ -84,47 +86,65 @@
                               '-p/--success-percentage is reached. In async 
mode, execute on each host independently '
                               'from each other, the list of commands, aborting 
the execution on any given host at the '
                               'first command that fails.'))
-    parser.add_argument('-p', '--success-percentage', type=int, 
choices=xrange(101), metavar='0-100', default=100,
+    parser.add_argument('-p', '--success-percentage', type=int, 
choices=xrange(101), metavar='PCT', default=100,
                         help=(('Percentage threshold to consider an execution 
unit successful. Required in sync mode, '
-                               'optional in async mode when -b/--batch-size is 
used. [default: 100]')))
+                               'optional in async mode when -b/--batch-size is 
used. Accepted values are integers '
+                               'in the range 0-100. [default: 100]')))
     parser.add_argument('-b', '--batch-size', type=int,
                         help=('The commands will be executed with a sliding 
batch of this size. The batch mode depends '
                               'on the -m/--mode option when multiple commands 
are specified. In sync mode the first '
                               'command is executed in batch to all hosts 
before proceeding with the next one. In async '
                               'mode all commands are executed on the first 
batch of hosts, proceeding with the next '
                               'hosts as soon as one host completes all the 
commands. The -p/--success-percentage is '
-                              'checked before starting the execution in each 
hosts.'))
-    parser.add_argument('-s', '--batch-sleep', type=float, default=None,
+                              'checked before starting the execution in each 
host. [default: None (# of hosts)]'))
+    parser.add_argument('-s', '--batch-sleep', type=float,
                         help=('Sleep in seconds (float) to wait before 
starting the execution on the next host when '
                               '-b/--batch-size is used. [default: None]'))
     parser.add_argument('-x', '--ignore-exit-codes', action='store_true',
-                        help='USE WITH CAUTION! Treat any executed command as 
successful, ignoring the exit codes.')
-    parser.add_argument('-o', '--output', choices=OUTPUT_FORMATS, 
help='Specify a different output format.')
-    parser.add_argument('-i', '--interactive', action='store_true', help='Drop 
into a Python shell with the results.')
+                        help=('USE WITH CAUTION! Treat any executed command as 
successful, ignoring the exit codes. '
+                              '[default: False]'))
+    parser.add_argument('-o', '--output', choices=OUTPUT_FORMATS,
+                        help='Specify a different output format. [default: 
None]')
+    parser.add_argument('-i', '--interactive', action='store_true',
+                        help='Drop into a Python shell with the results. 
[default: False]')
     parser.add_argument('--force', action='store_true',
-                        help='USE WITH CAUTION! Force the execution without 
confirmation of the affected hosts. ')
+                        help=('USE WITH CAUTION! Force the execution without 
confirmation of the affected hosts. '
+                              '[default: False]'))
     parser.add_argument('--backend', choices=backends_names,
                         help=('Override the default backend selected in the 
configuration file for this execution. The '
                               'backend-specific configuration must be already 
present in the configuration file. '
-                              '[optional]'))
+                              '[default: None]'))
     parser.add_argument('--transport', choices=transports_names,
                         help=('Override the default transport selected in the 
configuration file for this execution. '
                               'The transport-specific configuration must 
already be present in the configuration file. '
-                              '[optional]'))
+                              '[default: None]'))
     parser.add_argument('--dry-run', action='store_true',
-                        help='Do not execute any command, just return the list 
of matching hosts and exit.')
+                        help=('Do not execute any command, just return the 
list of matching hosts and exit. '
+                              '[default: False]'))
     parser.add_argument('--version', action='version', version='%(prog)s 
{version}'.format(version=cumin.__version__))
-    parser.add_argument('-d', '--debug', action='store_true', help='Set log 
level to DEBUG.')
+    parser.add_argument('-d', '--debug', action='store_true', help='Set log 
level to DEBUG. [default: False]')
     parser.add_argument('--trace', action='store_true',
-                        help='Set log level to TRACE, a custom logging level 
intended for development debugging.')
+                        help=('Set log level to TRACE, a custom logging level 
intended for development debugging. '
+                              '[default: False]'))
     parser.add_argument('hosts', metavar='HOSTS_QUERY', help='Hosts selection 
query')
     parser.add_argument('commands', metavar='COMMAND', nargs='*',
                         help='Command to be executed. If no commands are 
speficied, --dry-run is set.')
 
-    if argv is None:
-        parsed_args = parser.parse_args()
-    else:
-        parsed_args = parser.parse_args(argv)
+    return parser
+
+
+def parse_args(argv):
+    """Parse command line arguments, validate and return them.
+
+    Arguments:
+        argv: the list of command line arguments to use.
+
+    Returns:
+        argparse.Namespace: the parsed arguments.
+
+    """
+    parser = get_parser()
+    parsed_args = parser.parse_args(argv)
 
     # Validation and default values
     num_commands = len(parsed_args.commands)
@@ -334,7 +354,7 @@
         # Define a help function h() that will be available in the interactive 
shell to print the help message.
         # The name is to not shadow the Python built-in help() that might be 
usefult too to inspect objects.
         def h():  # pylint: disable=unused-variable,invalid-name
-            """Helper function for the interactive shell."""
+            """Print the help message in interactive shell."""
             tqdm.write(INTERACTIVE_BANNER)
         code.interact(banner=INTERACTIVE_BANNER, local=locals())
     elif args.output is not None:
@@ -344,12 +364,31 @@
     return exit_code
 
 
+def validate_config(config):
+    """Perform validation of mandatory keys in the configuration.
+
+    Arguments:
+        config (dict): the loaded configuration to validate.
+
+    Raises:
+        cumin.CuminError: if a mandatory configuration key is missing.
+
+    """
+    if 'log_file' not in config:
+        raise cumin.CuminError("Missing required parameter 'log_file' in the 
configuration file '{config}'".format(
+            config=config))
+
+
 def main(argv=None):
     """CLI entry point. Execute commands on hosts according to arguments.
 
     Arguments:
-        argv: the list of arguments to use. If None, the command line ones are 
used [optional, default: None]
+        argv: the list of command line arguments to use. If not specified it 
will be automatically taken from sys.argv
+            [optional, default: None]
     """
+    if argv is None:
+        argv = sys.argv[1:]
+
     signal.signal(signal.SIGINT, sigint_handler)
     colorama.init()
 
@@ -358,11 +397,7 @@
         args = parse_args(argv)
         user = get_running_user()
         config = cumin.Config(args.config)
-
-        if 'log_file' not in config:
-            raise cumin.CuminError(("Missing required parameter 'log_file' in 
the configuration file "
-                                    "'{config}'").format(config=args.config))
-
+        validate_config(args.config)
         setup_logging(config['log_file'], debug=args.debug, trace=args.trace)
     except cumin.CuminError as e:
         stderr(e)
diff --git a/cumin/tests/unit/test_cli.py b/cumin/tests/unit/test_cli.py
index 9979fb4..957f53e 100644
--- a/cumin/tests/unit/test_cli.py
+++ b/cumin/tests/unit/test_cli.py
@@ -1,10 +1,13 @@
 """CLI tests."""
+import argparse
+
 from logging import DEBUG, INFO
 
 import mock
 import pytest
 
 from cumin import cli, CuminError, LOGGING_TRACE_LEVEL_NUMBER, transports
+
 
 # Environment variables
 _ENV = {'USER': 'root', 'SUDO_USER': 'user'}
@@ -23,19 +26,22 @@
         assert args.commands == ['command1', 'command2']
 
 
+def test_get_parser():
+    """Calling get_parser() should return a populated argparse.ArgumentParser 
object."""
+    parser = cli.get_parser()
+    assert isinstance(parser, argparse.ArgumentParser)
+    assert parser.prog == 'cumin'
+
+
 def test_parse_args_ok():
     """A standard set of command line parameters should be properly parsed 
into their respective variables."""
-    args = cli.parse_args(argv=_ARGV)
+    args = cli.parse_args(_ARGV)
     _validate_parsed_args(args)
-
-    with mock.patch.object(cli.sys, 'argv', ['progname'] + _ARGV):
-        args = cli.parse_args()
-        _validate_parsed_args(args)
 
 
 def test_parse_args_no_commands():
     """If no commands are specified, dry-run mode should be implied."""
-    args = cli.parse_args(argv=_ARGV[:-2])
+    args = cli.parse_args(_ARGV[:-2])
     _validate_parsed_args(args, no_commands=True)
 
 
@@ -43,7 +49,7 @@
     """If mode is not speficied with multiple commands, parsing the args 
should raise a parser error."""
     index = _ARGV.index('-m')
     with pytest.raises(SystemExit):
-        cli.parse_args(argv=_ARGV[:index] + _ARGV[index + 1:])
+        cli.parse_args(_ARGV[:index] + _ARGV[index + 1:])
 
 
 def test_get_running_user():
@@ -133,7 +139,7 @@
 @mock.patch('cumin.cli.sys.stdout.isatty')
 def test_get_hosts_ok(isatty, mocked_raw_input, stderr):
     """Calling get_hosts() should query the backend and return the list of 
hosts."""
-    args = cli.parse_args(argv=['D{host1}', 'command1'])
+    args = cli.parse_args(['D{host1}', 'command1'])
     config = {'backend': 'direct'}
     isatty.return_value = True
 
@@ -159,7 +165,7 @@
 @mock.patch('cumin.cli.sys.stdout.isatty')
 def test_get_hosts_no_tty_ko(isatty, stderr):
     """Calling get_hosts() without a TTY should raise CuminError if --dry-run 
or --force are not specified."""
-    args = cli.parse_args(argv=['D{host1}', 'command1'])
+    args = cli.parse_args(['D{host1}', 'command1'])
     config = {'backend': 'direct'}
     isatty.return_value = False
     with pytest.raises(CuminError, match='Not in a TTY but neither DRY-RUN nor 
FORCE mode were specified'):
@@ -171,7 +177,7 @@
 @mock.patch('cumin.cli.sys.stdout.isatty')
 def test_get_hosts_no_tty_dry_run(isatty, stderr):
     """Calling get_hosts() with or without a TTY with --dry-run should return 
an empty list."""
-    args = cli.parse_args(argv=['--dry-run', 'D{host1}', 'command1'])
+    args = cli.parse_args(['--dry-run', 'D{host1}', 'command1'])
     config = {'backend': 'direct'}
     assert cli.get_hosts(args, config) == []
     isatty.return_value = True
@@ -183,7 +189,7 @@
 @mock.patch('cumin.cli.sys.stdout.isatty')
 def test_get_hosts_no_tty_force(isatty, stderr):
     """Calling get_hosts() with or without a TTY with --force should return 
the list of hosts."""
-    args = cli.parse_args(argv=['--force', 'D{host1}', 'command1'])
+    args = cli.parse_args(['--force', 'D{host1}', 'command1'])
     config = {'backend': 'direct'}
     assert cli.get_hosts(args, config) == cli.NodeSet('host1')
     isatty.return_value = True
@@ -195,10 +201,21 @@
 @mock.patch('cumin.cli.stderr')
 def test_run(stderr, transport):
     """Calling run() should query the hosts and execute the commands on the 
transport."""
-    args = cli.parse_args(argv=['--force', 'D{host1}', 'command1'])
+    args = cli.parse_args(['--force', 'D{host1}', 'command1'])
     config = {'backend': 'direct', 'transport': 'clustershell'}
     cli.run(args, config)
     assert transport.new.call_args[0][0] is config
     assert isinstance(transport.new.call_args[0][1], transports.Target)
     assert transport.new.call_args[1]['logger'] is cli.logger
     assert stderr.called
+
+
+def test_validate_config_valid():
+    """A valid config should be validated without raising exception."""
+    cli.validate_config({'log_file': '/var/log/cumin/cumin.log'})
+
+
+def test_validate_config_invalid():
+    """An invalid config should raise CuminError."""
+    with pytest.raises(CuminError, match='Missing required parameter'):
+        cli.validate_config({})

-- 
To view, visit https://gerrit.wikimedia.org/r/382481
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I99a65ba374860e479c7a1046f4aa72a898ab8730
Gerrit-PatchSet: 1
Gerrit-Project: operations/software/cumin
Gerrit-Branch: master
Gerrit-Owner: Volans <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to