#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import print_function
__all__ = ['start', 'customize', 'CMD_SUFFIX', 'Program', 'Command']
import sys
import inspect
import re
from os.path import basename
from collections import defaultdict
from .util import json, autotype, getargspec
Empty = type('Empty', (object, ), {
'__nonzero__': lambda self: False,
'__repr__' : lambda self: 'Empty',
})()
[docs]class Command(object):
'''Make a Python function or a built-in function accepts arguments from
command line.
:param func: a function you want to convert
:type func: Python function or built-in function
:param name: the name of this command
:type name: str
.. versionchanged:: 0.1.5
It is rewritten again. The API is same as the previous version, but some
behaviors may be different. Please read :py:meth:`Command.parse` for
more details.
.. versionchanged:: 0.1.4
It is almost rewritten.
'''
arg_desc_re = re.compile(r'^\s*-')
'''It is used to filter argument descriptions in a docstring.
The regex is ``r'^\s*-'`` by default. It means any line starts with a hyphen
(-), and whitespace characters before this hyphen are ignored.
'''
arg_re = re.compile(r'-(?P<long>-)?(?P<key>(?(long)[^ =,]+|.))[ =]?(?P<meta>[^ ,]+)?')
'''After it gets descriptions by :py:attr:`Command.arg_desc_re` from a
docstring, it extracts an argument name (or alias) and a metavar from each
description by this regex.
The regex is
``-(?P<long>-)?(?P<key>(?(long)[^ =,]+|.))[ =]?(?P<meta>[^ ,]+)?``
by default. The following formats will be parsed correctly:
- ``--key meta``
- ``--key=meta``
- ``-k meta``
- ``-k=meta``
- ``-kmeta``
'''
arg_type_map = {
'n': int, 'num': int, 'number': int,
'i': int, 'int': int, 'integer': int,
's': str, 'str': str, 'string': str,
'f': float, 'float': float,
'json': json,
None: autotype
}
'''A metavar implies a type.
The ``n``, ``num``, ``number``, ``i``, ``int`` and ``integer`` mean a `int`.
The ``s``, ``str`` and ``string`` mean a `str`.
The ``f`` and ``float`` mean a `float`.
It also supports to use ``json``. It converts a json from user to a Python
type.
If you don't set a metavar, it will try to guess a correct type.
The metavars here are normalized. Metavars from docstrings will be
normalized, too. For example, ``JSON`` and ``<json>`` are equal to ``json``.
'''
def __init__(self, func, name=None):
self.name = name
self.func = func
arg_names, vararg_name, keyarg_name, arg_defaults = getargspec(func)
# copy the argument spec info to instance
self.arg_names = arg_names
self.vararg_name = vararg_name
self.keyarg_name = keyarg_name
self.arg_defaults = arg_defaults or tuple()
# additional information
self.no_defult_args_len = len(self.arg_names) - len(self.arg_defaults)
self.arg_name_set = set(arg_names)
self.arg_default_map = dict(zip(
*map(reversed, (self.arg_names, self.arg_defaults))
))
# try to find metas and aliases out
self.arg_meta_map = {}
self.alias_arg_map = {}
doc = inspect.getdoc(func)
if not doc: return
for line in doc.splitlines():
if not self.arg_desc_re.match(line): continue
arg_part, _, desc_part = line.strip().partition(' ')
aliases_set = set()
for m in self.arg_re.finditer(arg_part):
key, meta = m.group('key', 'meta')
key = key.replace('-', '_')
self.arg_meta_map[key] = meta
aliases_set.add(key)
arg_name_set = self.arg_name_set & aliases_set
if not arg_name_set: continue
aliases_set -= arg_name_set
arg_name = arg_name_set.pop()
for alias in aliases_set:
self.alias_arg_map[alias] = arg_name
[docs] def dealias(self, alias):
'''It maps `alias` to an argument name. If this `alias` maps noting, it
return `alias` itself.
:param key: an alias
:type key: str
:rtype: str
'''
return self.alias_arg_map.get(alias, alias)
[docs] def cast(self, arg_name, val):
'''Cast `val` by `arg_name`.
:param arg_name: an argument name
:type arg_name: str
:param val: a value
:type val: any
:rtype: any
'''
meta = self.arg_meta_map.get(arg_name)
if meta is not None:
meta = meta.strip('<>').lower()
type = self.arg_type_map[meta]
return type(val)
[docs] def parse(self, raw_args=None):
"""Parse the raw arguments.
:param raw_args: raw arguments
:type raw_args: a list or a str
:rtype: double-tuple: (pargs, kargs)
.. versionadded:: 0.1.5
Here are examples:
>>> def repeat(message, times=2, count=False):
... '''It repeats the message.
...
... -m=<str>, --message=<str> The description of this option.
... -t=<int>, --times=<int>
... -c, --count
... '''
... s = message * times
... return len(s) if count else s
...
>>> repeat('string', 3)
'stringstringstring'
Make a :class:`~clime.core.Command` instance:
>>> repeat_cmd = Command(repeat)
>>> repeat_cmd.build_usage()
'repeat [-t <int> | --times=<int>] [-c | --count] <message>'
>>> repeat_cmd.execute('Hi!')
'Hi!Hi!'
You can also use options (keyword arguments) to assign arguments
(positional arguments):
>>> repeat_cmd.execute('--message=Hi!')
'Hi!Hi!'
>>> repeat_cmd.execute('--message Hi!')
'Hi!Hi!'
The short version defined in docstring:
>>> repeat_cmd.execute('-mHi!')
'Hi!Hi!'
>>> repeat_cmd.execute('-m=Hi!')
'Hi!Hi!'
>>> repeat_cmd.execute('-m Hi!')
'Hi!Hi!'
It counts how many times options appear, if you don't specify a value:
>>> repeat_cmd.execute('--times=4 Hi!')
'Hi!Hi!Hi!Hi!'
>>> repeat_cmd.execute('Hi! -tttt')
'Hi!Hi!Hi!Hi!'
>>> repeat_cmd.execute('-ttttm Hi!')
'Hi!Hi!Hi!Hi!'
However, if a default value is a boolean, it just switches the boolean
value and does it only one time.
Mix them all:
>>> repeat_cmd.execute('-tttt --count Hi!')
12
>>> repeat_cmd.execute('-ttttc Hi!')
12
>>> repeat_cmd.execute('-ttttcc Hi!')
12
>>> repeat_cmd.execute('-ttccttm Hi!')
12
It is also supported to collect arbitrary arguments:
>>> def everything(*args, **kargs):
... return args, kargs
>>> everything_cmd = Command(everything)
>>> everything_cmd.build_usage()
'everything [--<key>=<value>...] [<args>...]'
>>> everything_cmd.execute('1 2 3')
((1, 2, 3), {})
>>> everything_cmd.execute('--x=1 --y=2 --z=3')
((), {'y': 2, 'x': 1, 'z': 3})
"""
if raw_args is None:
raw_args = sys.argv[1:]
elif isinstance(raw_args, str):
raw_args = raw_args.split()
# collect arguments from the raw arguments
pargs = []
kargs = defaultdict(list)
# consume raw_args
while raw_args:
# try to find `arg_name` and `val`
arg_name = None
val = Empty
# '-a...', '--arg...', but no '-'
if raw_args[0].startswith('-') and len(raw_args[0]) >= 2:
# partition by eq sign
# -m=hello
# --message=hello -> val='hello'
# --message= -> val=''
# --bool -> val=Empty
before_eq_str, eq_str, val = raw_args.pop(0).partition('=')
if not eq_str:
val = Empty
if before_eq_str.startswith('--'):
arg_name = self.dealias(before_eq_str[2:].replace('-', '_'))
else:
# if it starts with only '-', it may be various
# find the start index (sep) of val
# '-nnn' -> sep=4 (the length of this str)
# '-nnnmhello' -> sep=5 (the char 'h')
sep = 1
for c in before_eq_str[1:]:
if c in self.arg_name_set or c in self.alias_arg_map:
sep += 1
else:
break
# handle the bool option sequence
# '-nnn' -> 'nn'
# '-nnnmhello' -> 'nnn'
for c in before_eq_str[1:sep-1]:
arg_name = self.dealias(c)
kargs[arg_name].append(Empty)
# handle the last option
# '-nnn' -> 'n' (the 3rd n)
# '-nnnmhello' -> 'm'
arg_name = self.dealias(before_eq_str[sep-1])
if val is Empty:
val = before_eq_str[sep:] or Empty
# handle if the val is next raw_args
# --message hello
# --bool hello (note the hello shall be a pargs)
# -nnnm hello
# -nnnb hello
if (
# didn't get val
val is Empty and
# this arg_name need a explicit val
not isinstance(self.arg_default_map.get(arg_name), bool) and
# we have thing to take
raw_args and not raw_args[0].startswith('-')
):
val = raw_args.pop(0)
else:
val = raw_args.pop(0)
if arg_name:
kargs[arg_name].append(val)
else:
pargs.append(val)
# compact the collected kargs
kargs = dict(kargs)
for arg_name, collected_vals in kargs.items():
default = self.arg_default_map.get(arg_name)
if isinstance(default, bool):
# switch the boolean value if default is a bool
kargs[arg_name] = not default
elif all(val is Empty for val in collected_vals):
if isinstance(default, int):
kargs[arg_name] = len(collected_vals)
else:
kargs[arg_name] = None
else:
# take the last value
val = next(val for val in reversed(collected_vals) if val is not Empty)
# cast this key arg
if not self.keyarg_name or arg_name in self.arg_meta_map:
kargs[arg_name] = self.cast(arg_name, val)
else:
kargs[arg_name] = self.cast(self.keyarg_name, val)
# add the defaults to kargs
for arg_name, default in self.arg_default_map.items():
if arg_name not in kargs:
kargs[arg_name] = default
# keyword-first resolving
isbuiltin = inspect.isbuiltin(self.func)
for pos, name in enumerate(self.arg_names):
if name in kargs and (pos < len(pargs) or isbuiltin):
pargs.insert(pos, kargs.pop(name))
# cast the pos args
for i, parg in enumerate(pargs):
if i < self.no_defult_args_len:
pargs[i] = self.cast(self.arg_names[i], parg)
elif self.vararg_name:
pargs[i] = self.cast(self.vararg_name, parg)
return (pargs, kargs)
scan = parse
'''
.. deprecated:: 0.1.5
Use :py:meth:`Command.parse` instead.
'''
[docs] def execute(self, raw_args=None):
'''Execute this command with `raw_args`.
:param raw_args: raw arguments
:type raw_args: a list or a str
:rtype: any
'''
pargs, kargs = self.parse(raw_args)
return self.func(*pargs, **kargs)
[docs] def build_usage(self, without_name=False):
'''Build the usage of this command.
:param without_name: Make it return an usage without the function name.
:type without_name: bool
:rtype: str
'''
# build reverse alias map
alias_arg_rmap = {}
for alias, arg_name in self.alias_arg_map.items():
aliases = alias_arg_rmap.setdefault(arg_name, [])
aliases.append(alias)
usage = []
# build the arguments which have default value
if self.arg_defaults:
for arg_name in self.arg_names[-len(self.arg_defaults):]:
pieces = []
for name in alias_arg_rmap.get(arg_name, [])+[arg_name]:
is_long_opt = len(name) > 1
pieces.append('%s%s' % ('-' * (1+is_long_opt), name.replace('_', '-')))
meta = self.arg_meta_map.get(name)
if meta is None:
# autometa
default = self.arg_default_map[self.dealias(name)]
if isinstance(default, bool):
continue
elif default is None:
meta = '<value>'
else:
meta = '{!r}'.format(default)
if is_long_opt:
pieces[-1] += '='+meta
else:
pieces[-1] += ' '+meta
usage.append('[%s]' % ' | '.join(pieces))
if self.keyarg_name:
usage.append('[--<key>=<value>...]')
# build the arguments which don't have default value
usage.extend('<%s>' % name.replace('_', '-') for name in self.arg_names[:-len(self.arg_defaults) or None])
if self.vararg_name:
usage.append('[<%s>...]' % self.vararg_name.replace('_', '-'))
if without_name:
return '%s' % ' '.join(usage)
else:
return '%s %s' % ((self.name or self.func.__name__).replace('_', '-'), ' '.join(usage))
get_usage = build_usage
'''
.. deprecated:: 0.2.5
Use :py:meth:`Command.build_usage` instead.
'''
CMD_SUFFIX = re.compile('^(?P<name>.*?)_cmd$')
'''
It matches the function whose name ends with ``_cmd``.
The regex is ``^(?P<name>.*?)_cmd$``.
Usually, it is used with :py:func:`start`:
::
import clime
clime.start(white_pattern=clime.CMD_SUFFIX)
'''
[docs]class Program(object):
'''Convert a module or mapping into a multi-command CLI program.
.. seealso::
There is a shortcut of using :py:class:`Program` --- :py:func:`start`.
:param obj: an `object` you want to convert
:type obj: a module or a mapping
:param default: the default command name
:type default: str
:param white_list: the white list of commands; By default, it will use the attribute, ``__all__``, of a module, if it finds.
:type white_list: list
:param white_pattern: the white pattern of commands; The regex should have a group named ``name``.
:type white_pattern: RegexObject
:param black_list: the black list of commands
:type black_list: list
:param ignore_help: Let it treat ``--help`` or ``-h`` as a normal argument.
:type ignore_help: bool
:param ignore_return: prevents it from printing the return value.
:type ignore_return: bool
:param name: the name of this program; It is used to show error messages. By default, it takes the first arguments from CLI.
:type name: str
:param doc: the documentation for this program
:type doc: str
:param debug: It will print a full traceback if it is True.
:type name: bool
.. versionchanged:: 0.3
The ``-h`` option also triggers help text now.
.. versionadded:: 0.1.9
Added `white_pattern`.
.. versionadded:: 0.1.6
Added `ignore_return`.
.. versionadded:: 0.1.5
Added `white_list`, `black_list`, `ignore_help`, `doc` and `debug`.
.. versionchanged:: 0.1.5
Renamed `defcmd` and `progname`.
.. versionchanged:: 0.1.4
It is almost rewritten.
'''
def __init__(self, obj=None, default=None, white_list=None, white_pattern=None, black_list=None, ignore_help=False, ignore_return=False, name=None, doc=None, debug=False):
obj = obj or sys.modules['__main__']
self.obj = obj
if hasattr(obj, 'items'):
obj_items = obj.items()
else:
obj_items = inspect.getmembers(obj)
if not white_list and hasattr(obj, '__all__'):
white_list = obj.__all__
tests = (inspect.isbuiltin, inspect.isfunction, inspect.ismethod)
self.command_funcs = {}
for obj_name, obj in obj_items:
if obj_name.startswith('_'): continue
if not any(test(obj) for test in tests): continue
if white_list is not None and obj_name not in white_list: continue
if black_list is not None and obj_name in black_list: continue
if white_pattern:
match = white_pattern.match(obj_name)
if not match: continue
obj_name = match.group('name')
self.command_funcs[obj_name] = obj
self.default = default
if len(self.command_funcs) == 1:
self.default = list(self.command_funcs.keys())[0]
self.ignore_help = ignore_help
self.ignore_return = ignore_return
self.name = name or basename(sys.argv[0])
self.doc = doc
self.debug = debug
[docs] def complain(self, msg):
'''Print `msg` with the name of this program to `stderr`.'''
print('%s: %s' % (self.name, msg), file=sys.stdout)
[docs] def main(self, raw_args=None):
'''Start to parse the raw arguments and send them to a
:py:class:`~clime.core.Command` instance.
:param raw_args: The arguments from command line. By default, it takes from ``sys.argv``.
:type raw_args: list
'''
if raw_args is None:
raw_args = sys.argv[1:]
elif isinstance(raw_args, str):
raw_args = raw_args.split()
# try to find a command name in the raw arguments.
cmd_name = None
cmd_func = None
if len(raw_args) == 0:
pass
elif not self.ignore_help and raw_args[0] in ('--help', '-h'):
self.print_usage()
return
else:
cmd_func = self.command_funcs.get(raw_args[0].replace('-', '_'))
if cmd_func is not None:
cmd_name = raw_args.pop(0).replace('-', '_')
if cmd_func is None:
# we can't find a command name in normal procedure
if self.default:
cmd_name = cmd_name
cmd_func = self.command_funcs[self.default]
else:
self.print_usage()
return
if not self.ignore_help and '--help' in raw_args:
# the user requires help of this command
self.print_usage(cmd_name)
return
# convert the function to a Command object
cmd = Command(cmd_func, cmd_name)
try:
# execute the command with the raw arguments
return_val = cmd.execute(raw_args)
except BaseException as e:
if self.debug:
raise
self.complain('exception: {}: {}'.format(
e.__class__.__name__,
e
))
sys.exit(1)
if not self.ignore_return and return_val is not None:
if inspect.isgenerator(return_val):
for i in return_val:
print(i)
else:
print(return_val)
[docs] def print_usage(self, cmd_name=None):
'''Print the usage(s) of all commands or a command.'''
def append_usage(cmd_name, without_name=False):
# nonlocal usages
cmd_func = self.command_funcs[cmd_name]
usages.append(Command(cmd_func, cmd_name).build_usage(without_name))
usages = []
if cmd_name is None:
# prepare all usages
if self.default is not None:
append_usage(self.default, True)
for name in sorted(self.command_funcs.keys()):
append_usage(name)
else:
# prepare the usage of a command
if self.default == cmd_name:
append_usage(cmd_name, without_name=True)
append_usage(cmd_name)
# print the usages
iusages = iter(usages)
try:
print('usage:', next(iusages))
except StopIteration: # Empty usages; print nothing.
pass
else:
for usage in iusages:
print(' or:', usage)
# find the doc
# find the module-level doc
if cmd_name is None:
if self.doc:
doc = self.doc
elif inspect.ismodule(self.obj):
doc = inspect.getdoc(self.obj)
else:
doc = None
# fallback to default command if still not found
if not doc:
cmd_name = self.default
if cmd_name:
doc = inspect.getdoc(self.command_funcs[cmd_name])
# print the doc
if doc:
print()
print(doc)
print()
[docs]def start(*args, **kargs):
'''It is same as ``Program(*args, **kargs).main()``.
.. versionchanged:: 1.0
renamed from `customize` to `start`
.. versionadded:: 0.1.6
.. seealso::
:py:class:`Program` has the detail of arguments.
'''
prog = Program(*args, **kargs)
prog.main()
return prog
# for backward compatibility
customize = start
'''
.. deprecated:: 0.1.6
Use :py:func:`start` instead.
'''
if __name__ == '__main__':
import doctest
doctest.testmod()
def read_json(json=None):
'''
options:
--json=<json>
'''
return json
read_json_cmd = Command(read_json)
print('---')
print(read_json_cmd.build_usage())
print(read_json_cmd.execute('[1,2,3]'))
print(read_json_cmd.execute(['--json', '{"x": 1}']))
print('---')
prog = Program(white_list=['read_json'], debug=True)
prog.main()
# python -m clime.core read-json --help
# python -m clime.core read-json '{"x": 1}'
# python -m clime.core read-json --json='{"x":1}'