toolsutils.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     1 # copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    16 # You should have received a copy of the GNU Lesser General Public License along
       
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
       
    18 """some utilities for cubicweb command line tools"""
       
    19 from __future__ import print_function
       
    20 
       
    21 __docformat__ = "restructuredtext en"
       
    22 
       
    23 # XXX move most of this in logilab.common (shellutils ?)
       
    24 
       
    25 import io
       
    26 import os, sys
       
    27 import subprocess
       
    28 from os import listdir, makedirs, environ, chmod, walk, remove
       
    29 from os.path import exists, join, abspath, normpath
       
    30 import re
       
    31 from rlcompleter import Completer
       
    32 try:
       
    33     import readline
       
    34 except ImportError: # readline not available, no completion
       
    35     pass
       
    36 try:
       
    37     from os import symlink
       
    38 except ImportError:
       
    39     def symlink(*args):
       
    40         raise NotImplementedError
       
    41 
       
    42 from six import add_metaclass
       
    43 
       
    44 from logilab.common.clcommands import Command as BaseCommand
       
    45 from logilab.common.shellutils import ASK
       
    46 
       
    47 from cubicweb import warning # pylint: disable=E0611
       
    48 from cubicweb import ConfigurationError, ExecutionError
       
    49 
       
    50 def underline_title(title, car='-'):
       
    51     return title+'\n'+(car*len(title))
       
    52 
       
    53 def iter_dir(directory, condition_file=None, ignore=()):
       
    54     """iterate on a directory"""
       
    55     for sub in listdir(directory):
       
    56         if sub in ('CVS', '.svn', '.hg'):
       
    57             continue
       
    58         if condition_file is not None and \
       
    59                not exists(join(directory, sub, condition_file)):
       
    60             continue
       
    61         if sub in ignore:
       
    62             continue
       
    63         yield sub
       
    64 
       
    65 def create_dir(directory):
       
    66     """create a directory if it doesn't exist yet"""
       
    67     try:
       
    68         makedirs(directory)
       
    69         print('-> created directory %s' % directory)
       
    70     except OSError as ex:
       
    71         import errno
       
    72         if ex.errno != errno.EEXIST:
       
    73             raise
       
    74         print('-> no need to create existing directory %s' % directory)
       
    75 
       
    76 def create_symlink(source, target):
       
    77     """create a symbolic link"""
       
    78     if exists(target):
       
    79         remove(target)
       
    80     symlink(source, target)
       
    81     print('[symlink] %s <-- %s' % (target, source))
       
    82 
       
    83 def create_copy(source, target):
       
    84     import shutil
       
    85     print('[copy] %s <-- %s' % (target, source))
       
    86     shutil.copy2(source, target)
       
    87 
       
    88 def rm(whatever):
       
    89     import shutil
       
    90     shutil.rmtree(whatever)
       
    91     print('-> removed %s' % whatever)
       
    92 
       
    93 def show_diffs(appl_file, ref_file, askconfirm=True):
       
    94     """interactivly replace the old file with the new file according to
       
    95     user decision
       
    96     """
       
    97     import shutil
       
    98     pipe = subprocess.Popen(['diff', '-u', appl_file, ref_file], stdout=subprocess.PIPE)
       
    99     diffs = pipe.stdout.read()
       
   100     if diffs:
       
   101         if askconfirm:
       
   102             print()
       
   103             print(diffs)
       
   104             action = ASK.ask('Replace ?', ('Y', 'n', 'q'), 'Y').lower()
       
   105         else:
       
   106             action = 'y'
       
   107         if action == 'y':
       
   108             try:
       
   109                 shutil.copyfile(ref_file, appl_file)
       
   110             except IOError:
       
   111                 os.system('chmod a+w %s' % appl_file)
       
   112                 shutil.copyfile(ref_file, appl_file)
       
   113             print('replaced')
       
   114         elif action == 'q':
       
   115             sys.exit(0)
       
   116         else:
       
   117             copy_file = appl_file + '.default'
       
   118             copy = open(copy_file, 'w')
       
   119             copy.write(open(ref_file).read())
       
   120             copy.close()
       
   121             print('keep current version, the new file has been written to', copy_file)
       
   122     else:
       
   123         print('no diff between %s and %s' % (appl_file, ref_file))
       
   124 
       
   125 SKEL_EXCLUDE = ('*.py[co]', '*.orig', '*~', '*_flymake.py')
       
   126 def copy_skeleton(skeldir, targetdir, context,
       
   127                   exclude=SKEL_EXCLUDE, askconfirm=False):
       
   128     import shutil
       
   129     from fnmatch import fnmatch
       
   130     skeldir = normpath(skeldir)
       
   131     targetdir = normpath(targetdir)
       
   132     for dirpath, dirnames, filenames in walk(skeldir):
       
   133         tdirpath = dirpath.replace(skeldir, targetdir)
       
   134         create_dir(tdirpath)
       
   135         for fname in filenames:
       
   136             if any(fnmatch(fname, pat) for pat in exclude):
       
   137                 continue
       
   138             fpath = join(dirpath, fname)
       
   139             if 'CUBENAME' in fname:
       
   140                 tfpath = join(tdirpath, fname.replace('CUBENAME', context['cubename']))
       
   141             elif 'DISTNAME' in fname:
       
   142                 tfpath = join(tdirpath, fname.replace('DISTNAME', context['distname']))
       
   143             else:
       
   144                 tfpath = join(tdirpath, fname)
       
   145             if fname.endswith('.tmpl'):
       
   146                 tfpath = tfpath[:-5]
       
   147                 if not askconfirm or not exists(tfpath) or \
       
   148                        ASK.confirm('%s exists, overwrite?' % tfpath):
       
   149                     fill_templated_file(fpath, tfpath, context)
       
   150                     print('[generate] %s <-- %s' % (tfpath, fpath))
       
   151             elif exists(tfpath):
       
   152                 show_diffs(tfpath, fpath, askconfirm)
       
   153             else:
       
   154                 shutil.copyfile(fpath, tfpath)
       
   155 
       
   156 def fill_templated_file(fpath, tfpath, context):
       
   157     with io.open(fpath, encoding='ascii') as fobj:
       
   158         template = fobj.read()
       
   159     with io.open(tfpath, 'w', encoding='ascii') as fobj:
       
   160         fobj.write(template % context)
       
   161 
       
   162 def restrict_perms_to_user(filepath, log=None):
       
   163     """set -rw------- permission on the given file"""
       
   164     if log:
       
   165         log('set permissions to 0600 for %s', filepath)
       
   166     else:
       
   167         print('-> set permissions to 0600 for %s' % filepath)
       
   168     chmod(filepath, 0o600)
       
   169 
       
   170 def read_config(config_file, raise_if_unreadable=False):
       
   171     """read some simple configuration from `config_file` and return it as a
       
   172     dictionary. If `raise_if_unreadable` is false (the default), an empty
       
   173     dictionary will be returned if the file is inexistant or unreadable, else
       
   174     :exc:`ExecutionError` will be raised.
       
   175     """
       
   176     from logilab.common.fileutils import lines
       
   177     config = current = {}
       
   178     try:
       
   179         for line in lines(config_file, comments='#'):
       
   180             try:
       
   181                 option, value = line.split('=', 1)
       
   182             except ValueError:
       
   183                 option = line.strip().lower()
       
   184                 if option[0] == '[':
       
   185                     # start a section
       
   186                     section = option[1:-1]
       
   187                     assert section not in config, \
       
   188                            'Section %s is defined more than once' % section
       
   189                     config[section] = current = {}
       
   190                     continue
       
   191                 sys.stderr.write('ignoring malformed line\n%r\n' % line)
       
   192                 continue
       
   193             option = option.strip().replace(' ', '_')
       
   194             value = value.strip()
       
   195             current[option] = value or None
       
   196     except IOError as ex:
       
   197         if raise_if_unreadable:
       
   198             raise ExecutionError('%s. Are you logged with the correct user '
       
   199                                  'to use this instance?' % ex)
       
   200         else:
       
   201             warning('missing or non readable configuration file %s (%s)',
       
   202                     config_file, ex)
       
   203     return config
       
   204 
       
   205 
       
   206 _HDLRS = {}
       
   207 
       
   208 class metacmdhandler(type):
       
   209     def __new__(mcs, name, bases, classdict):
       
   210         cls = super(metacmdhandler, mcs).__new__(mcs, name, bases, classdict)
       
   211         if getattr(cls, 'cfgname', None) and getattr(cls, 'cmdname', None):
       
   212             _HDLRS.setdefault(cls.cmdname, []).append(cls)
       
   213         return cls
       
   214 
       
   215 
       
   216 @add_metaclass(metacmdhandler)
       
   217 class CommandHandler(object):
       
   218     """configuration specific helper for cubicweb-ctl commands"""
       
   219     def __init__(self, config):
       
   220         self.config = config
       
   221 
       
   222 
       
   223 class Command(BaseCommand):
       
   224     """base class for cubicweb-ctl commands"""
       
   225 
       
   226     def config_helper(self, config, required=True, cmdname=None):
       
   227         if cmdname is None:
       
   228             cmdname = self.name
       
   229         for helpercls in _HDLRS.get(cmdname, ()):
       
   230             if helpercls.cfgname == config.name:
       
   231                 return helpercls(config)
       
   232         if config.name == 'all-in-one':
       
   233             for helpercls in _HDLRS.get(cmdname, ()):
       
   234                 if helpercls.cfgname == 'repository':
       
   235                     return helpercls(config)
       
   236         if required:
       
   237             msg = 'No helper for command %s using %s configuration' % (
       
   238                 cmdname, config.name)
       
   239             raise ConfigurationError(msg)
       
   240 
       
   241     def fail(self, reason):
       
   242         print("command failed:", reason)
       
   243         sys.exit(1)
       
   244 
       
   245 
       
   246 CONNECT_OPTIONS = (
       
   247     ("user",
       
   248      {'short': 'u', 'type' : 'string', 'metavar': '<user>',
       
   249       'help': 'connect as <user> instead of being prompted to give it.',
       
   250       }
       
   251      ),
       
   252     ("password",
       
   253      {'short': 'p', 'type' : 'password', 'metavar': '<password>',
       
   254       'help': 'automatically give <password> for authentication instead of \
       
   255 being prompted to give it.',
       
   256       }),
       
   257     ("host",
       
   258      {'short': 'H', 'type' : 'string', 'metavar': '<hostname>',
       
   259       'default': None,
       
   260       'help': 'specify the name server\'s host name. Will be detected by \
       
   261 broadcast if not provided.',
       
   262       }),
       
   263     )
       
   264 
       
   265 ## cwshell helpers #############################################################
       
   266 
       
   267 class AbstractMatcher(object):
       
   268     """Abstract class for CWShellCompleter's matchers.
       
   269 
       
   270     A matcher should implement a ``possible_matches`` method. This
       
   271     method has to return the list of possible completions for user's input.
       
   272     Because of the python / readline interaction, each completion should
       
   273     be a superset of the user's input.
       
   274 
       
   275     NOTE: readline tokenizes user's input and only passes last token to
       
   276     completers.
       
   277     """
       
   278 
       
   279     def possible_matches(self, text):
       
   280         """return possible completions for user's input.
       
   281 
       
   282         Parameters:
       
   283             text: the user's input
       
   284 
       
   285         Return:
       
   286             a list of completions. Each completion includes the original input.
       
   287         """
       
   288         raise NotImplementedError()
       
   289 
       
   290 
       
   291 class RQLExecuteMatcher(AbstractMatcher):
       
   292     """Custom matcher for rql queries.
       
   293 
       
   294     If user's input starts with ``rql(`` or ``session.execute(`` and
       
   295     the corresponding rql query is incomplete, suggest some valid completions.
       
   296     """
       
   297     query_match_rgx = re.compile(
       
   298         r'(?P<func_prefix>\s*(?:rql)'  # match rql, possibly indented
       
   299         r'|'                           # or
       
   300         r'\s*(?:\w+\.execute))'        # match .execute, possibly indented
       
   301         # end of <func_prefix>
       
   302         r'\('                          # followed by a parenthesis
       
   303         r'(?P<quote_delim>["\'])'      # a quote or double quote
       
   304         r'(?P<parameters>.*)')         # and some content
       
   305 
       
   306     def __init__(self, local_ctx, req):
       
   307         self.local_ctx = local_ctx
       
   308         self.req = req
       
   309         self.schema = req.vreg.schema
       
   310         self.rsb = req.vreg['components'].select('rql.suggestions', req)
       
   311 
       
   312     @staticmethod
       
   313     def match(text):
       
   314         """check if ``text`` looks like a call to ``rql`` or ``session.execute``
       
   315 
       
   316         Parameters:
       
   317             text: the user's input
       
   318 
       
   319         Returns:
       
   320             None if it doesn't match, the query structure otherwise.
       
   321         """
       
   322         query_match = RQLExecuteMatcher.query_match_rgx.match(text)
       
   323         if query_match is None:
       
   324             return None
       
   325         parameters_text = query_match.group('parameters')
       
   326         quote_delim = query_match.group('quote_delim')
       
   327         # first parameter is fully specified, no completion needed
       
   328         if re.match(r"(.*?)%s" % quote_delim, parameters_text) is not None:
       
   329             return None
       
   330         func_prefix = query_match.group('func_prefix')
       
   331         return {
       
   332             # user's input
       
   333             'text': text,
       
   334             # rql( or session.execute(
       
   335             'func_prefix': func_prefix,
       
   336             # offset of rql query
       
   337             'rql_offset': len(func_prefix) + 2,
       
   338             # incomplete rql query
       
   339             'rql_query': parameters_text,
       
   340             }
       
   341 
       
   342     def possible_matches(self, text):
       
   343         """call ``rql.suggestions`` component to complete user's input.
       
   344         """
       
   345         # readline will only send last token, but we need the entire user's input
       
   346         user_input = readline.get_line_buffer()
       
   347         query_struct = self.match(user_input)
       
   348         if query_struct is None:
       
   349             return []
       
   350         else:
       
   351             # we must only send completions of the last token => compute where it
       
   352             # starts relatively to the rql query itself.
       
   353             completion_offset = readline.get_begidx() - query_struct['rql_offset']
       
   354             rql_query = query_struct['rql_query']
       
   355             return [suggestion[completion_offset:]
       
   356                     for suggestion in self.rsb.build_suggestions(rql_query)]
       
   357 
       
   358 
       
   359 class DefaultMatcher(AbstractMatcher):
       
   360     """Default matcher: delegate to standard's `rlcompleter.Completer`` class
       
   361     """
       
   362     def __init__(self, local_ctx):
       
   363         self.completer = Completer(local_ctx)
       
   364 
       
   365     def possible_matches(self, text):
       
   366         if "." in text:
       
   367             return self.completer.attr_matches(text)
       
   368         else:
       
   369             return self.completer.global_matches(text)
       
   370 
       
   371 
       
   372 class CWShellCompleter(object):
       
   373     """Custom auto-completion helper for cubicweb-ctl shell.
       
   374 
       
   375     ``CWShellCompleter`` provides a ``complete`` method suitable for
       
   376     ``readline.set_completer``.
       
   377 
       
   378     Attributes:
       
   379         matchers: the list of ``AbstractMatcher`` instances that will suggest
       
   380                   possible completions
       
   381 
       
   382     The completion process is the following:
       
   383 
       
   384     - readline calls the ``complete`` method with user's input,
       
   385     - the ``complete`` method asks for each known matchers if
       
   386       it can suggest completions for user's input.
       
   387     """
       
   388 
       
   389     def __init__(self, local_ctx):
       
   390         # list of matchers to ask for possible matches on completion
       
   391         self.matchers = [DefaultMatcher(local_ctx)]
       
   392         self.matchers.insert(0, RQLExecuteMatcher(local_ctx, local_ctx['session']))
       
   393 
       
   394     def complete(self, text, state):
       
   395         """readline's completer method
       
   396 
       
   397         cf http://docs.python.org/2/library/readline.html#readline.set_completer
       
   398         for more details.
       
   399 
       
   400         Implementation inspired by `rlcompleter.Completer`
       
   401         """
       
   402         if state == 0:
       
   403             # reset self.matches
       
   404             self.matches = []
       
   405             for matcher in self.matchers:
       
   406                 matches = matcher.possible_matches(text)
       
   407                 if matches:
       
   408                     self.matches = matches
       
   409                     break
       
   410             else:
       
   411                 return None # no matcher able to handle `text`
       
   412         try:
       
   413             return self.matches[state]
       
   414         except IndexError:
       
   415             return None