common/migration.py
changeset 0 b97547f5f1fa
child 447 0e52d72104a6
equal deleted inserted replaced
-1:000000000000 0:b97547f5f1fa
       
     1 """utility to ease migration of application version to newly installed
       
     2 version
       
     3 
       
     4 :organization: Logilab
       
     5 :copyright: 2001-2008 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     6 :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     7 """
       
     8 __docformat__ = "restructuredtext en"
       
     9 
       
    10 import sys
       
    11 import os
       
    12 import logging
       
    13 from tempfile import mktemp
       
    14 from os.path import exists, join, basename, splitext
       
    15 
       
    16 from logilab.common.decorators import cached
       
    17 from logilab.common.configuration import REQUIRED, read_old_config
       
    18 
       
    19 
       
    20 def migration_files(config, toupgrade):
       
    21     """return an orderer list of path of scripts to execute to upgrade
       
    22     an installed application according to installed cube and cubicweb versions
       
    23     """
       
    24     merged = []
       
    25     for cube, fromversion, toversion in toupgrade:
       
    26         if cube == 'cubicweb':
       
    27             migrdir = config.migration_scripts_dir()
       
    28         else:
       
    29             migrdir = config.cube_migration_scripts_dir(cube)
       
    30         scripts = filter_scripts(config, migrdir, fromversion, toversion)
       
    31         merged += [s[1] for s in scripts]
       
    32     if config.accept_mode('Any'):
       
    33         migrdir = config.migration_scripts_dir()
       
    34         merged.insert(0, join(migrdir, 'bootstrapmigration_repository.py'))
       
    35     return merged
       
    36 
       
    37 
       
    38 def filter_scripts(config, directory, fromversion, toversion, quiet=True):
       
    39     """return a list of paths of migration files to consider to upgrade
       
    40     from a version to a greater one
       
    41     """
       
    42     from logilab.common.changelog import Version # doesn't work with appengine
       
    43     assert fromversion
       
    44     assert toversion
       
    45     assert isinstance(fromversion, tuple), fromversion.__class__
       
    46     assert isinstance(toversion, tuple), toversion.__class__
       
    47     assert fromversion <= toversion, (fromversion, toversion)
       
    48     if not exists(directory):
       
    49         if not quiet:
       
    50             print directory, "doesn't exists, no migration path"
       
    51         return []
       
    52     if fromversion == toversion:
       
    53         return []
       
    54     result = []
       
    55     for fname in os.listdir(directory):
       
    56         if fname.endswith('.pyc') or fname.endswith('.pyo') \
       
    57                or fname.endswith('~'):
       
    58             continue
       
    59         fpath = join(directory, fname)
       
    60         try:
       
    61             tver, mode = fname.split('_', 1)
       
    62         except ValueError:
       
    63             continue
       
    64         mode = mode.split('.', 1)[0]
       
    65         if not config.accept_mode(mode):
       
    66             continue
       
    67         try:
       
    68             tver = Version(tver)
       
    69         except ValueError:
       
    70             continue
       
    71         if tver <= fromversion:
       
    72             continue
       
    73         if tver > toversion:
       
    74             continue
       
    75         result.append((tver, fpath))
       
    76     # be sure scripts are executed in order
       
    77     return sorted(result)
       
    78 
       
    79 
       
    80 IGNORED_EXTENSIONS = ('.swp', '~')
       
    81 
       
    82 
       
    83 def execscript_confirm(scriptpath):
       
    84     """asks for confirmation before executing a script and provides the
       
    85     ability to show the script's content
       
    86     """
       
    87     while True:
       
    88         confirm = raw_input('** execute %r (Y/n/s[how]) ?' % scriptpath)
       
    89         confirm = confirm.strip().lower()
       
    90         if confirm in ('n', 'no'):
       
    91             return False
       
    92         elif confirm in ('s', 'show'):
       
    93             stream = open(scriptpath)
       
    94             scriptcontent = stream.read()
       
    95             stream.close()
       
    96             print
       
    97             print scriptcontent
       
    98             print
       
    99         else:
       
   100             return True
       
   101 
       
   102 def yes(*args, **kwargs):
       
   103     return True
       
   104 
       
   105 
       
   106 class MigrationHelper(object):
       
   107     """class holding CubicWeb Migration Actions used by migration scripts"""
       
   108 
       
   109     def __init__(self, config, interactive=True, verbosity=1):
       
   110         self.config = config
       
   111         self.config.init_log(logthreshold=logging.ERROR, debug=True)
       
   112         # 0: no confirmation, 1: only main commands confirmed, 2 ask for everything
       
   113         self.verbosity = verbosity
       
   114         self.need_wrap = True
       
   115         if not interactive or not verbosity:
       
   116             self.confirm = yes
       
   117             self.execscript_confirm = yes
       
   118         else:
       
   119             self.execscript_confirm = execscript_confirm
       
   120         self._option_changes = []
       
   121         self.__context = {'confirm': self.confirm,
       
   122                           'config': self.config,
       
   123                           'interactive_mode': interactive,
       
   124                           }
       
   125 
       
   126     def repo_connect(self):
       
   127         return self.config.repository()
       
   128         
       
   129     def migrate(self, vcconf, toupgrade, options):
       
   130         """upgrade the given set of cubes
       
   131         
       
   132         `cubes` is an ordered list of 3-uple:
       
   133         (cube, fromversion, toversion)
       
   134         """
       
   135         if options.fs_only:
       
   136             # monkey path configuration.accept_mode so database mode (e.g. Any)
       
   137             # won't be accepted
       
   138             orig_accept_mode = self.config.accept_mode
       
   139             def accept_mode(mode):
       
   140                 if mode == 'Any':
       
   141                     return False
       
   142                 return orig_accept_mode(mode)
       
   143             self.config.accept_mode = accept_mode
       
   144         scripts = migration_files(self.config, toupgrade)
       
   145         if scripts:
       
   146             vmap = dict( (pname, (fromver, tover)) for pname, fromver, tover in toupgrade)
       
   147             self.__context.update({'applcubicwebversion': vcconf['cubicweb'],
       
   148                                    'cubicwebversion': self.config.cubicweb_version(),
       
   149                                    'versions_map': vmap})
       
   150             self.scripts_session(scripts)
       
   151         else:
       
   152             print 'no migration script to execute'            
       
   153 
       
   154     def shutdown(self):
       
   155         pass
       
   156     
       
   157     def __getattribute__(self, name):
       
   158         try:
       
   159             return object.__getattribute__(self, name)
       
   160         except AttributeError:
       
   161             cmd = 'cmd_%s' % name
       
   162             if hasattr(self, cmd):
       
   163                 meth = getattr(self, cmd) 
       
   164                 return lambda *args, **kwargs: self.interact(args, kwargs,
       
   165                                                              meth=meth)
       
   166             raise
       
   167         raise AttributeError(name)
       
   168             
       
   169     def interact(self, args, kwargs, meth):
       
   170         """execute the given method according to user's confirmation"""
       
   171         msg = 'execute command: %s(%s) ?' % (
       
   172             meth.__name__[4:],
       
   173             ', '.join([repr(arg) for arg in args] +
       
   174                       ['%s=%r' % (n,v) for n,v in kwargs.items()]))
       
   175         if 'ask_confirm' in kwargs:
       
   176             ask_confirm = kwargs.pop('ask_confirm')
       
   177         else:
       
   178             ask_confirm = True
       
   179         if not ask_confirm or self.confirm(msg):
       
   180             return meth(*args, **kwargs)
       
   181 
       
   182     def confirm(self, question, shell=True, abort=True, retry=False):
       
   183         """ask for confirmation and return true on positive answer
       
   184 
       
   185         if `retry` is true the r[etry] answer may return 2
       
   186         """
       
   187         print question,
       
   188         possibleanswers = 'Y/n'
       
   189         if abort:
       
   190             possibleanswers += '/a[bort]'
       
   191         if shell:
       
   192             possibleanswers += '/s[hell]'
       
   193         if retry:
       
   194             possibleanswers += '/r[etry]'
       
   195         try:
       
   196             confirm = raw_input('(%s): ' % ( possibleanswers, ))
       
   197             answer = confirm.strip().lower()
       
   198         except (EOFError, KeyboardInterrupt):
       
   199             answer = 'abort'
       
   200         if answer in ('n', 'no'):
       
   201             return False
       
   202         if answer in ('r', 'retry'):
       
   203             return 2
       
   204         if answer in ('a', 'abort'):
       
   205             self.rollback()
       
   206             raise SystemExit(1)
       
   207         if shell and answer in ('s', 'shell'):
       
   208             self.interactive_shell()
       
   209             return self.confirm(question)
       
   210         return True
       
   211 
       
   212     def interactive_shell(self):
       
   213         self.confirm = yes
       
   214         self.need_wrap = False
       
   215         # avoid '_' to be added to builtins by sys.display_hook
       
   216         def do_not_add___to_builtins(obj):
       
   217             if obj is not None:
       
   218                 print repr(obj)
       
   219         sys.displayhook = do_not_add___to_builtins
       
   220         local_ctx = self._create_context()
       
   221         try:
       
   222             import readline
       
   223             from rlcompleter import Completer
       
   224         except ImportError:
       
   225             # readline not available
       
   226             pass
       
   227         else:        
       
   228             readline.set_completer(Completer(local_ctx).complete)
       
   229             readline.parse_and_bind('tab: complete')
       
   230             histfile = os.path.join(os.environ["HOME"], ".eshellhist")
       
   231             try:
       
   232                 readline.read_history_file(histfile)
       
   233             except IOError:
       
   234                 pass
       
   235         from code import interact
       
   236         banner = """entering the migration python shell
       
   237 just type migration commands or arbitrary python code and type ENTER to execute it
       
   238 type "exit" or Ctrl-D to quit the shell and resume operation"""
       
   239         # give custom readfunc to avoid http://bugs.python.org/issue1288615
       
   240         def unicode_raw_input(prompt):
       
   241             return unicode(raw_input(prompt), sys.stdin.encoding)
       
   242         interact(banner, readfunc=unicode_raw_input, local=local_ctx)
       
   243         readline.write_history_file(histfile)
       
   244         # delete instance's confirm attribute to avoid questions
       
   245         del self.confirm
       
   246         self.need_wrap = True
       
   247 
       
   248     @cached
       
   249     def _create_context(self):
       
   250         """return a dictionary to use as migration script execution context"""
       
   251         context = self.__context
       
   252         for attr in dir(self):
       
   253             if attr.startswith('cmd_'):
       
   254                 if self.need_wrap:
       
   255                     context[attr[4:]] = getattr(self, attr[4:])
       
   256                 else:
       
   257                     context[attr[4:]] = getattr(self, attr)
       
   258         return context
       
   259     
       
   260     def process_script(self, migrscript, funcname=None, *args, **kwargs):
       
   261         """execute a migration script
       
   262         in interactive mode,  display the migration script path, ask for
       
   263         confirmation and execute it if confirmed
       
   264         """
       
   265         assert migrscript.endswith('.py'), migrscript
       
   266         if self.execscript_confirm(migrscript):
       
   267             scriptlocals = self._create_context().copy()
       
   268             if funcname is None:
       
   269                 pyname = '__main__'
       
   270             else:
       
   271                 pyname = splitext(basename(migrscript))[0]
       
   272             scriptlocals.update({'__file__': migrscript, '__name__': pyname})
       
   273             execfile(migrscript, scriptlocals)
       
   274             if funcname is not None:
       
   275                 try:
       
   276                     func = scriptlocals[funcname]
       
   277                     self.info('found %s in locals', funcname)
       
   278                     assert callable(func), '%s (%s) is not callable' % (func, funcname)
       
   279                 except KeyError:
       
   280                     self.critical('no %s in script %s', funcname, migrscript)
       
   281                     return None
       
   282                 return func(*args, **kwargs)
       
   283                     
       
   284     def scripts_session(self, migrscripts):
       
   285         """execute some scripts in a transaction"""
       
   286         try:
       
   287             for migrscript in migrscripts:
       
   288                 self.process_script(migrscript)
       
   289             self.commit()
       
   290         except:
       
   291             self.rollback()
       
   292             raise
       
   293 
       
   294     def cmd_option_renamed(self, oldname, newname):
       
   295         """a configuration option has been renamed"""
       
   296         self._option_changes.append(('renamed', oldname, newname))
       
   297 
       
   298     def cmd_option_group_change(self, option, oldgroup, newgroup):
       
   299         """a configuration option has been moved in another group"""
       
   300         self._option_changes.append(('moved', option, oldgroup, newgroup))
       
   301 
       
   302     def cmd_option_added(self, optname):
       
   303         """a configuration option has been added"""
       
   304         self._option_changes.append(('added', optname))
       
   305 
       
   306     def cmd_option_removed(self, optname):
       
   307         """a configuration option has been removed"""
       
   308         # can safely be ignored
       
   309         #self._option_changes.append(('removed', optname))
       
   310 
       
   311     def cmd_option_type_changed(self, optname, oldtype, newvalue):
       
   312         """a configuration option's type has changed"""
       
   313         self._option_changes.append(('typechanged', optname, oldtype, newvalue))
       
   314         
       
   315     def cmd_add_cube(self, cube):
       
   316         origcubes = self.config.cubes()
       
   317         newcubes = [p for p in self.config.expand_cubes([cube]) 
       
   318                        if not p in origcubes]
       
   319         if newcubes:
       
   320             assert cube in newcubes
       
   321             self.config.add_cubes(newcubes)
       
   322         return newcubes
       
   323 
       
   324     def cmd_remove_cube(self, cube):
       
   325         origcubes = self.config._cubes
       
   326         basecubes = list(origcubes)
       
   327         for pkg in self.config.expand_cubes([cube]):
       
   328             try:
       
   329                 basecubes.remove(pkg)
       
   330             except ValueError:
       
   331                 continue
       
   332         self.config._cubes = tuple(self.config.expand_cubes(basecubes))
       
   333         removed = [p for p in origcubes if not p in self.config._cubes]
       
   334         assert cube in removed, \
       
   335                "can't remove cube %s, used as a dependancy" % cube
       
   336         return removed
       
   337     
       
   338     def rewrite_configuration(self):
       
   339         # import locally, show_diffs unavailable in gae environment
       
   340         from cubicweb.toolsutils import show_diffs
       
   341         configfile = self.config.main_config_file()
       
   342         if self._option_changes:
       
   343             read_old_config(self.config, self._option_changes, configfile)
       
   344         newconfig = mktemp()
       
   345         for optdescr in self._option_changes:
       
   346             if optdescr[0] == 'added':
       
   347                 optdict = self.config.get_option_def(optdescr[1])
       
   348                 if optdict.get('default') is REQUIRED:
       
   349                     self.config.input_option(option, optdict)
       
   350         self.config.generate_config(open(newconfig, 'w'))
       
   351         show_diffs(configfile, newconfig)
       
   352         if exists(newconfig):
       
   353             os.unlink(newconfig)
       
   354 
       
   355 
       
   356 from logging import getLogger
       
   357 from cubicweb import set_log_methods
       
   358 set_log_methods(MigrationHelper, getLogger('cubicweb.migration'))