Source code for xoutil.cli
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# ---------------------------------------------------------------------
# Copyright (c) Merchise Autrement [~º/~] and Contributors
# All rights reserved.
#
# This is free software; you can do what the LICENCE file allows you to.
#
'''Tools for Command-Line Interface (CLI) applications.
CLI is a mean of interaction with a computer program where the user (or
client) issues commands to the program in the form of successive lines of text
(command lines).
Commands can be registered by:
- sub-classing the `Command`:class:,
- using `~abc.ABCMeta.register`:meth: ABC mechanism for virtual sub-classes,
- redefining `~`Command.sub_commands`` class method.
.. versionadded:: 1.4.1
'''
from __future__ import (division as _py3_division,
print_function as _py3_print,
absolute_import as _py3_abs_import)
from xoutil.eight.abc import abstractmethod, ABCMeta
from xoutil.eight.meta import metaclass
from xoutil.objects import staticproperty
from xoutil.cli.tools import command_name, program_name
[docs]class CommandMeta(ABCMeta):
'''Meta-class for all commands.'''
def __new__(meta, name, bases, namespace):
cls = super(CommandMeta, meta).__new__(meta, name, bases, namespace)
cls.__subcommands_registry__ = set()
return cls
[docs] def register(cls, subclass):
'''Register a virtual subclass of a Command.
Returns the sub-command, to allow usage as a class decorator.
.. note:: Python 3.7 hides internal registry (``_abc_registry``), so
a sub-commands registry is implemented.
'''
cls.__subcommands_registry__.add(subclass)
res = super(CommandMeta, cls).register(subclass)
if res is None:
res = subclass
return res
[docs] def cli_name(cls):
'''Calculate the command name.
Standard method uses `~xoutil.cli.tools.hyphen_name`. Redefine it
to obtain a different behaviour.
Example::
>>> class MyCommand(Command):
... pass
>>> MyCommand.cli_name() == 'my-command'
True
'''
from xoutil.eight import string_types
from xoutil.cli.tools import hyphen_name
unset = object()
names = ('command_cli_name', '__command_name__')
i, res = 0, unset
while i < len(names) and res is unset:
name = names[i]
res = getattr(cls, names[i], unset)
if res is unset:
i += 1
elif not isinstance(res, string_types):
msg = "Attribute '{}' must be a string.".format(name)
raise TypeError(msg)
if res is unset:
res = hyphen_name(cls.__name__)
return res
def get_setting(cls, name, *default):
aux = len(default)
if aux < 2:
unset = object()
default = default[0] if aux == 1 else unset
res = cls.__settings__.get(name, default)
if res is not unset:
return res
else:
raise KeyError(name)
else:
msg = 'get_setting() takes at most 3 arguments ({} given)'
raise TypeError(msg.format(aux + 2))
def set_setting(cls, name, value):
cls.__settings__[name] = value # TODO: Check type
[docs] def set_default_command(cls, cmd=None):
'''Default command is called when no one is specified.
A command is detected when its name appears as the first command-line
argument.
To specify a default command, use this method with the command as a
string (the command name) or the command class.
If the command is specified, then the calling class is the selected
one.
For example::
>>> Command.set_default_command('server') # doctest: +SKIP
>>> Server.set_default_command() # doctest: +SKIP
>>> Command.set_default_command(Server) # doctest: +SKIP
'''
if cls is Command:
if cmd is not None:
from xoutil.eight import string_types as text
name = cmd if isinstance(cmd, text) else command_name(cmd)
else:
# TODO: consider reset to None
raise ValueError('missing command specification!')
else:
if cmd is None:
name = command_name(cls)
else:
raise ValueError('redundant command specification', cls, cmd)
Command.set_setting('default_command', name)
[docs]class Command(metaclass(CommandMeta)):
'''Base for all commands.'''
__settings__ = {
# 'default_command' : None
}
__registry_cache__ = {}
def __str__(self):
return command_name(type(self))
def __repr__(self):
return '<command: %s>' % command_name(type(self))
@staticproperty
def registry():
'''Obtain all registered commands.'''
res = Command.__registry_cache__
if not res:
Command._settle_cache(Command)
assert res.pop(command_name(Command), None) is None
Command._check_help()
return res
[docs] @abstractmethod
def run(self, args=None):
'''Must return a valid value for "sys.exit"'''
raise NotImplementedError
@staticmethod
def _settle_cache(source, recursed=None):
'''Initialize '__registry_cache__'.'''
import sys
from xoutil.names import nameof
if recursed is None:
recursed = set()
name = nameof(source, inner=True, full=True)
if name not in recursed:
recursed.add(name)
sub_commands = type.__subclasses__(source)
virtuals = getattr(source, '__subcommands_registry__', ())
sub_commands.extend(virtuals)
cmds = getattr(source, '__commands__', None)
if cmds:
from collections import Iterable
if not isinstance(cmds, Iterable):
cmds = cmds()
sub_commands.extend(cmds)
if sub_commands:
for cmd in sub_commands:
Command._settle_cache(cmd, recursed=recursed)
else: # Only branch commands are OK to execute
if sys.version_info < (3, 0):
from types import MethodType as ValidMethodType
else:
from types import FunctionType as ValidMethodType
assert isinstance(source.run, ValidMethodType), \
'Invalid type %r for source %r' % (
type(source.run).__name__,
source
) # noqa
Command.__registry_cache__[command_name(source)] = source
else:
raise ValueError('Reused class "%s"!' % name)
@staticmethod
def _check_help():
'''Check that correct help command is present.'''
name = HELP_NAME
hlp = Command.__registry_cache__[name]
if hlp is not Help and not getattr(hlp, '__overwrite__', False):
Command.__registry_cache__[name] = Help
[docs]class Help(Command):
'''Show all commands.
Define the class attribute `__order__` to sort commands in special command
"help".
Commands could define its help in the first line of a sequence of
documentations until found:
- command class,
- "run" method,
- definition module.
This command could not be overwritten unless using the class attribute:
__overwrite__ = True
'''
__order__ = -9999
[docs] @classmethod
def get_arg_parser(cls):
'''This is an example on how to build local argument parser.
Use class method "get
'''
# TODO: Use 'add_subparsers' in this logic (see 'backlog.org').
res = getattr(cls, '_arg_parser')
if not res:
from argparse import ArgumentParser
res = ArgumentParser()
cls._arg_parser = res
return res
[docs] def run(self, args=[]):
print('The most commonly used "%s" commands are:' % program_name())
cmds = Command.registry
ordered = [(getattr(cmds[cmd], '__order__', 0), cmd) for cmd in cmds]
ordered.sort()
max_len = len(max(ordered, key=lambda x: len(x[1]))[1])
for _, cmd in ordered:
cmd_class = cmds[cmd]
doc = self._strip_doc(cmd_class.__doc__)
if not doc:
doc = self._strip_doc(cmd_class.run.__doc__)
if not doc:
import sys
mod_name = cmd_class.__module__
module = sys.modules.get(mod_name, None)
if module:
doc = self._strip_doc(module.__doc__)
doc = '"%s"' % (doc if doc else mod_name)
else:
doc = '"%s"' % mod_name
head = ' ' * 3 + cmd + ' ' * (2 + max_len - len(cmd))
print(head, doc)
@staticmethod
def _strip_doc(doc):
if doc:
doc = str('%s' % doc).strip()
return str(doc.split('\n')[0].strip('''"' \t\n\r'''))
else:
return ''
HELP_NAME = command_name(Help)
# TODO: Create "xoutil.config" here
del abstractmethod
del staticproperty