jenkins-bot has submitted this change and it was merged.
Change subject: Add base class for creating CLI wrappers
......................................................................
Add base class for creating CLI wrappers
Add scap.cli.Application class to handle the boilerplate of argument
processing, configuration loading and error processing when creating
command line scripts.
Convert the mwversionsinuse script to use the new class as an example of
usage.
Change-Id: I1ccf437bc6e424488605250cb25fde6aa0555b56
---
M bin/mwversionsinuse
M scap/__init__.py
A scap/cli.py
M scap/main.py
4 files changed, 209 insertions(+), 38 deletions(-)
Approvals:
Ori.livneh: Looks good to me, approved
jenkins-bot: Verified
diff --git a/bin/mwversionsinuse b/bin/mwversionsinuse
index e7fb97b..a55b94e 100755
--- a/bin/mwversionsinuse
+++ b/bin/mwversionsinuse
@@ -14,4 +14,4 @@
sys.path.append(scap_src)
import scap
-sys.exit(scap.mwversionsinuse())
+scap.MWVersionsInUse.run()
diff --git a/scap/__init__.py b/scap/__init__.py
index 7d0faec..fc1bcbd 100644
--- a/scap/__init__.py
+++ b/scap/__init__.py
@@ -6,17 +6,18 @@
configuration to a group of servers via SSH and rsync.
"""
+from .main import MWVersionsInUse
from .main import scap
from .main import sync_common
-from .main import mwversionsinuse
+
from . import log
__all__ = (
+ 'MWVersionsInUse',
'scap',
'sync_common',
- 'mwversionsinuse',
)
-any((scap, sync_common, mwversionsinuse)) # Ignore unused import warning
+any((MWVersionsInUse, scap, sync_common)) # Ignore unused import warning
log.setup_loggers()
diff --git a/scap/cli.py b/scap/cli.py
new file mode 100644
index 0000000..a5a1254
--- /dev/null
+++ b/scap/cli.py
@@ -0,0 +1,190 @@
+# -*- coding: utf-8 -*-
+"""
+ scap.cli
+ ~~~~~~~~
+ Classes and helpers for creating command line interfaces
+
+"""
+import argparse
+import logging
+import os
+import sys
+
+from . import config
+
+
+ATTR_ARGUMENTS = '_app_arguments'
+
+
+class Application(object):
+ """Base class for creating command line applications."""
+ program_name = None
+ _logger = None
+ arguments = None
+ config = None
+
+ def __init__(self, exe_name):
+ if self.program_name is None:
+ self.program_name = os.path.basename(exe_name)
+ self.exe_name = exe_name
+
+ @property
+ def logger(self):
+ """Lazy getter for a logger instance."""
+ if self._logger is None:
+ self._logger = logging.getLogger(self.program_name)
+ return self._logger
+
+ def _parse_arguments(self, argv):
+ """Parse command line arguments.
+
+ :returns: Tuple of parsed argument Namespace and list of any extra
+ arguments that were not parsed.
+ """
+ self._argparser = self._build_argparser()
+ return self._argparser.parse_known_args(argv)
+
+ def _build_argparser(self):
+ parser = argparse.ArgumentParser(description=self.__doc__)
+
+ # Look for arguments that were added to our main method
+ local_args = getattr(self.main, ATTR_ARGUMENTS, [])
+
+ # List is built from the bottom up so reverse it to make the parser
+ # arguments read in the same order as they were declared.
+ for argspec in reversed(local_args):
+ flags = argspec.pop('_flags')
+ parser.add_argument(*flags, **argspec)
+
+ parser.add_argument('-c', '--conf', dest='conf_file',
+ type=argparse.FileType('r'),
+ help='Path to configuration file')
+ parser.add_argument('-D', '--define', dest='defines',
+ action='append', type=lambda v: tuple(v.split(':')),
+ help='Set a configuration value', metavar='<name>:<value>')
+
+ return parser
+
+ def _process_arguments(self, args, extra_args):
+ """Validate and process command line arguments.
+
+ Default behavior is to abort the application with an error if any
+ unparsed arguments were found.
+
+ :returns: Tuple of (args, extra_args) after processing
+ """
+ if extra_args:
+ self._argparser.error('extra arguments found: %s' %
+ ' '.join(extra_args))
+ return args, extra_args
+
+ def _load_config(self):
+ """Load configuration."""
+ defines = None
+ if self.arguments.defines:
+ defines = dict(self.arguments.defines)
+ self.config = config.load(self.arguments.conf_file, defines)
+
+ def main(self, *extra_args):
+ """Main business logic of the application.
+
+ Parsed command line arguments are available in self.arguments. Global
+ configuration is available in self.config. Unparsed command line
+ arguments are passed as positional arguments.
+
+ :returns: exit status
+ """
+ raise NotImplementedError()
+
+ def _handle_system_exit(self, ex):
+ """Handle a SystemExit error.
+
+ :returns: exit status
+ """
+ raise
+
+ def _handle_keyboard_interrupt(self, ex):
+ """Handle ctrl-c from interactive user.
+
+ :returns: exit status
+ """
+ self.logger.warning('%s aborted', self.program_name)
+ return 130
+
+ def _handle_exception(self, ex):
+ """Handle unhandled exceptions and errors.
+
+ :returns: exit status
+ """
+ self.logger.debug('Unhandled error:', exc_info=True)
+ self.logger.error('%s failed: <%s> %s',
+ self.program_name, type(ex).__name__, ex)
+ return 70
+
+ def _before_exit(self, exit_status):
+ """Do any final cleanup or processing before the application exits.
+
+ Called after :meth:`main` and before `sys.exit` even when an exception
+ occurs.
+
+ :returns: exit status
+ """
+ pass
+
+ @classmethod
+ def run(cls, argv=sys.argv, exit=True):
+ """Construct and run an application.
+
+ Calls ``sys.exit`` with exit status returned by application by
+ default. Setting ``exit`` to ``False`` will instead return the class
+ instance and it's exit status. This would generally only be done when
+ testing.
+
+ :param cls: Class to create and run
+ :param argv: Command line arguments
+ :param exit: Call sys.exit after execution
+ :returns: Tuple of class instance and exit status when not exiting
+ """
+ argv = list(argv)
+ app = cls(argv.pop(0))
+ exit_status = 0
+ try:
+ args, extra_args = app._parse_arguments(argv)
+ args, extra_args = app._process_arguments(args, extra_args)
+ app.arguments = args
+ app._load_config()
+ exit_status = app.main(extra_args)
+
+ except SystemExit as ex:
+ # Triggered by sys.exit() calls
+ exit_status = app._handle_system_exit(ex)
+
+ except KeyboardInterrupt as ex:
+ # Handle ctrl-c from interactive user
+ exit_status = app._handle_keyboard_interrupt(ex)
+
+ except Exception as ex:
+ # Handle all unhandled exceptions and errors
+ exit_status = app._handle_exception(ex)
+
+ finally:
+ exit_status = app._before_exit(exit_status)
+
+ if exit:
+ sys.exit(exit_status)
+ else:
+ return (app, exit_status)
+
+
+def argument(*args, **kwargs):
+ """Decorator used to declare a command line argument on a
+ :class:`Application`'s ``main`` method.
+
+ Use with the same signature as ``ArgumentParser.add_argument``.
+ """
+ def wrapper(func):
+ arguments = getattr(func, ATTR_ARGUMENTS, [])
+ arguments.append(dict(_flags=args, **kwargs))
+ setattr(func, ATTR_ARGUMENTS, arguments)
+ return func
+ return wrapper
diff --git a/scap/main.py b/scap/main.py
index d19bbe9..6c9f936 100644
--- a/scap/main.py
+++ b/scap/main.py
@@ -10,6 +10,7 @@
import os
import time
+from . import cli
from . import config
from . import log
from . import tasks
@@ -37,27 +38,15 @@
return cfg
-def mwversionsinuse():
- """Get a list of the active MediaWiki versions.
+class MWVersionsInUse(cli.Application):
+ """Get a list of the active MediaWiki versions."""
- :returns: Integer exit status suitable for use with ``sys.exit``
- """
- logger = logging.getLogger('wikiversions')
- try:
- parser = get_argparser(
- description='Get a list of the active MW versions')
- parser.add_argument('--withdb', action='store_true',
- help='Add `=wikidb` with some wiki using the version.')
- args, unexpected = parser.parse_known_args()
+ @cli.argument('--withdb', action='store_true',
+ help='Add `=wikidb` with some wiki using the version.')
+ def main(self, *extra_args):
+ versions = utils.wikiversions(self.config['deploy_dir'])
- cfg = load_config(args)
-
- if unexpected:
- logger.warning('Unexpected argument(s) ignored: %s', unexpected)
-
- versions = utils.wikiversions(cfg['deploy_dir'])
-
- if args.withdb:
+ if self.arguments.withdb:
output = ['%s=%s' % (version, wikidb)
for version, wikidb in versions.items()]
else:
@@ -66,22 +55,13 @@
print ' '.join(output)
return 0
- except SystemExit:
- # Triggered by sys.exit() calls
- raise
+ def _process_arguments(self, args, extra_args):
+ """Log warnings about unexpected arguments but don't exit."""
+ if extra_args:
+ self.logger.warning(
+ 'Unexpected argument(s) ignored: %s', extra_args)
- except KeyboardInterrupt:
- # Handle ctrl-c from interactive user
- if logger:
- logger.warning('wikiversions aborted')
- return 1
-
- except Exception as ex:
- # Handle all unhandled exceptions and errors
- if logger:
- logger.debug('Unhandled error:', exc_info=True)
- logger.error('wikiversions failed: <%s> %s', type(ex).__name__, ex)
- return 1
+ return args, extra_args
def sync_common():
--
To view, visit https://gerrit.wikimedia.org/r/116456
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: merged
Gerrit-Change-Id: I1ccf437bc6e424488605250cb25fde6aa0555b56
Gerrit-PatchSet: 4
Gerrit-Project: mediawiki/tools/scap
Gerrit-Branch: master
Gerrit-Owner: BryanDavis <[email protected]>
Gerrit-Reviewer: Aaron Schulz <[email protected]>
Gerrit-Reviewer: BryanDavis <[email protected]>
Gerrit-Reviewer: Hashar <[email protected]>
Gerrit-Reviewer: Ori.livneh <[email protected]>
Gerrit-Reviewer: Reedy <[email protected]>
Gerrit-Reviewer: jenkins-bot <>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits