source: tailor/vcpx/tailor.py @ 442

Revision 442, 25.2 KB checked in by lele@…, 8 years ago (diff)

M-x whitespace-cleanup

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- Frontend capabilities
3# :Creato:   dom 04 lug 2004 00:40:54 CEST
4# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
5# :Licenza:  GNU General Public License
6#
7
8"""
9Implement the basic capabilities of the frontend.
10
11This implementation stores the relevant project information, needed to
12keep the whole thing going on, such as the last synced revision, in a
13unversioned file named ``tailor.info`` at the root.
14"""
15
16__docformat__ = 'reStructuredText'
17
18__version__ = '0.9.0'
19
20from optparse import OptionParser, OptionGroup, make_option
21from dualwd import DualWorkingDir
22from source import InvocationError
23from session import interactive
24from svn import SvnWorkingDir
25
26STATUS_FILENAME = 'tailor.info'
27LOG_FILENAME = 'tailor.log'
28
29def relpathto(source, dest):
30    """
31    Compute the relative path needed to point ``source`` from ``dest``.
32
33    Warning: ``dest`` is assumed to be a directory.
34    """
35
36    from os.path import abspath, split, commonprefix
37
38    source = abspath(source)
39    dest = abspath(dest)
40
41    if source.startswith(dest):
42        return source[len(dest)+1:]
43
44    prefix = commonprefix([source, dest])
45
46    source = source[len(prefix):]
47    dest = dest[len(prefix):]
48
49    return '../' * len(dest.split('/')) + source
50
51
52class TailorConfig(object):
53    """
54    Configuration of a set of tailorized projects.
55
56    The configuration is stored in a persistent dictionary keyed on the
57    relative path of each project. The information about a single project
58    is another dictionary.
59    """
60
61    def __init__(self, options):
62        from os.path import abspath, split
63
64        self.options = options
65        self.configfile = abspath(options.configfile)
66        self.basedir = split(self.configfile)[0]
67
68    def __call__(self, args):
69        from os.path import join, exists, split
70        from source import ChangesetApplicationFailure
71
72        self.__load()
73
74        if len(args) == 0:
75            fromconfig = True
76            if self.options.bootstrap:
77                f = lambda x: not exists(x)
78            else:
79                f = exists
80
81            args = [p for p in [join(self.basedir, r)
82                                for r in self.config.keys()] if f(p)]
83            args.sort()
84        else:
85            fromconfig = False
86
87        try:
88            for root in args:
89                if self.options.bootstrap:
90                    if not (fromconfig or self.options.repository):
91                        raise InvocationError('Need a repository to bootstrap '
92                                              '%r' % root, '--bootstrap')
93                else:
94                    if not self.config.has_key(relpathto(root, self.basedir)):
95                        raise UnknownProjectError("Project %r does not exist" %
96                                                  root)
97
98                tailored = TailorizedProject(root, self.options.verbose, self)
99
100                if self.options.bootstrap:
101                    if fromconfig:
102                        info = self.loadProject(root=root)
103                        self.options.source_kind = info['source_kind']
104                        self.options.target_kind = info['target_kind']
105                        self.options.repository = info['upstream_repos']
106                        self.options.module = info['module']
107                        self.options.subdir = info.get('subdir',
108                                                       split(info['module'])[1])
109                        self.options.revision = info['upstream_revision']
110
111                    tailored.bootstrap(self.options.source_kind,
112                                       self.options.target_kind,
113                                       self.options.repository,
114                                       self.options.module,
115                                       self.options.revision,
116                                       self.options.subdir)
117                elif self.options.migrate:
118                    tailored.migrateConfiguration()
119                elif self.options.update:
120                    try:
121                        tailored.update(self.options.single_commit,
122                                        self.options.concatenate_logs)
123                    except ChangesetApplicationFailure, e:
124                        print "Skipping '%s' because of errors:" % root, e
125        finally:
126            self.__save()
127
128    def __save(self):
129        from pprint import pprint
130
131        configfile = open(self.configfile, 'w')
132        pprint(self.config, configfile)
133        configfile.close()
134
135    def __load(self):
136        from os.path import exists
137
138        if exists(self.options.configfile):
139            configfile = open(self.configfile)
140            self.config = eval(configfile.read())
141            configfile.close()
142        else:
143            self.config = {}
144
145    def loadProject(self, project=None, root=None):
146        from os.path import split
147
148        relpath = relpathto(project and project.root or root, self.basedir)
149
150        info = self.config.get(relpath)
151        if info and project:
152            project.source_kind = info['source_kind']
153            project.target_kind = info['target_kind']
154            project.module = info['module']
155            project.subdir = info.get('subdir', split(project.module)[1])
156            project.upstream_repos = info['upstream_repos']
157            project.upstream_revision = info['upstream_revision']
158
159        return info
160
161    def saveProject(self, project):
162        relpath = relpathto(project.root, self.basedir)
163
164        self.config[relpath] = {
165            'source_kind': project.source_kind,
166            'target_kind': project.target_kind,
167            'module': project.module,
168            'subdir': project.subdir,
169            'upstream_repos': project.upstream_repos,
170            'upstream_revision': project.upstream_revision,
171            }
172
173
174class TailorizedProject(object):
175    """
176    A TailorizedProject has two main capabilities: it may be bootstrapped
177    from an upstream repository or brought in sync with current upstream
178    revision.
179    """
180
181    def __init__(self, root, verbose=False, config=None):
182        import logging
183        from os import makedirs
184        from os.path import join, exists, split
185
186        self.root = root
187        if not exists(root):
188            makedirs(root)
189
190        self.verbose = verbose
191        self.logger = logging.getLogger('tailor.%s' % split(root)[1])
192        hdlr = logging.FileHandler(join(root, LOG_FILENAME))
193        formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')
194        hdlr.setFormatter(formatter)
195        self.logger.addHandler(hdlr)
196        self.logger.setLevel(logging.INFO)
197
198        self.source_kind = self.target_kind = None
199
200        self.config = config
201
202    def migrateConfiguration(self):
203        self.__loadOldStatus()
204        self.__saveStatus()
205
206    def __saveOldStatus(self):
207        from os.path import join
208
209        statusfilename = join(self.root, STATUS_FILENAME)
210        f = open(statusfilename, 'w')
211        print >>f, self.source_kind
212        print >>f, self.target_kind
213        print >>f, self.module
214        print >>f, self.upstream_repos
215        print >>f, self.upstream_revision
216        print >>f, self.subdir
217        f.close()
218
219    def __saveStatus(self):
220        """
221        Save relevant project information in a persistent way.
222        """
223
224        if self.config:
225            self.config.saveProject(self)
226        else:
227            self.__saveOldStatus()
228
229    def __loadOldStatus(self):
230        from os.path import join, split
231
232        statusfilename = join(self.root, STATUS_FILENAME)
233        f = open(statusfilename)
234        self.source_kind = f.readline()[:-1]
235        self.target_kind = f.readline()[:-1]
236        self.module = f.readline()[:-1]
237        self.upstream_repos = f.readline()[:-1]
238        self.upstream_revision = f.readline()[:-1]
239        subdir = f.readline()
240        if subdir:
241            self.subdir = subdir[:-1]
242        else:
243            self.subdir = split(self.module)[1]
244        f.close()
245
246    def __loadStatus(self):
247        """
248        Load relevant project information.
249        """
250
251        if self.config:
252            self.config.loadProject(self)
253        else:
254            self.__loadOldStatus()
255
256        # Fix old configs
257
258        if self.source_kind == 'svn' and not '/' in self.module:
259            self.logger.warning('OLD config values for SVN')
260            print "The project at '%s' contains old values for" % self.root
261            print "the upstream repository (%s)" % self.upstream_repos
262            print "and module (%s)." % self.module
263            print "Please correct them, specifying the exact URL of the"
264            print "root of the SVN repository and then the prefix path up"
265            print "to the point you want, that must start with a slash."
266            print "This usually means splitting the repository URL above in"
267            print "two parts. For example, that could be"
268
269            crepo = self.upstream_repos
270            example_split = crepo.rfind('/', 6, crepo.rfind('/'))
271            if example_split > 0:
272                example_repo = crepo[:example_split]
273                example_module = crepo[example_split:]
274            else:
275                example_repo = 'http://svn.plone.org/collective'
276                example_module = '/ATContentTypes/trunk'
277
278            print "  Repository=%s" % example_repo
279            print "  Module=%s" % example_module
280            print "but your situation may vary, that's just an example!"
281            print
282            try:
283                self.repository = raw_input('Repository: ')
284                self.module = raw_input('Module/prefix: ')
285            except KeyboardInterrupt:
286                self.logger.warning("Leaving old config values, stopped by user")
287                raise
288
289    def bootstrap(self, source_kind, target_kind,
290                  repository, module, revision, subdir):
291        """
292        Bootstrap a new tailorized module.
293
294        Extract a copy of the ``repository`` at given ``revision`` in the
295        ``root`` directory and initialize a target repository with its content.
296
297        The actual information on the project are stored in a text file.
298        """
299
300        from os.path import split
301
302        if source_kind == 'svn':
303            if not (module and module.startswith('/')):
304                raise InvocationError('With SVN the module argument is '
305                                      'mandatory and must start with a "/"')
306
307        if repository.endswith('/'):
308            repository = repository[:-1]
309
310        if module and module.endswith('/'):
311            module = module[:-1]
312
313        if not subdir:
314            subdir = split(module or repository)[1] or ''
315
316        self.logger.info("Bootstrapping '%s'" % (self.root,))
317
318        dwd = DualWorkingDir(source_kind, target_kind)
319        self.logger.info("getting %s revision '%s' of '%s' from '%s'" % (
320            source_kind, revision, module, repository))
321
322        try:
323            actual = dwd.checkoutUpstreamRevision(self.root, repository,
324                                                  module, revision,
325                                                  subdir=subdir,
326                                                  logger=self.logger)
327        except:
328            self.logger.exception('Checkout failed!')
329            raise
330
331        # the above machinery checked out a copy under of the wc
332        # in the directory named as the last component of the module's name
333
334        if not module:
335            module = split(repository)[1]
336
337        try:
338            dwd.initializeNewWorkingDir(self.root, repository, module, subdir,
339                                        actual, revision=='INITIAL')
340        except:
341            self.logger.exception('Working copy initialization failed!')
342            raise
343
344        self.source_kind = source_kind
345        self.target_kind = target_kind
346        self.upstream_repos = repository
347        self.module = module
348        self.subdir = subdir
349        self.upstream_revision = actual.revision
350
351        self.__saveStatus()
352
353        self.logger.info("Bootstrap completed")
354
355    def applyable(self, root, changeset):
356        """
357        Print the changeset being applied.
358        """
359
360        if self.verbose:
361            print "Changeset %s:" % changeset.revision
362            try:
363                print changeset.log
364            except UnicodeEncodeError:
365                print ">>> Non-printable changelog <<<"
366
367        return True
368
369    def applied(self, root, changeset):
370        """
371        Save current status.
372        """
373
374        self.upstream_revision = changeset.revision
375        self.__saveStatus()
376        if self.verbose:
377            print
378
379    def update(self, single_commit, concatenate_logs):
380        """
381        Update an existing tailorized project.
382
383        Fetch the upstream changesets and apply them to the working copy.
384        Use the information stored in the ``tailor.info`` file to ask just
385        the new changeset since last bootstrap/synchronization.
386        """
387
388        from os.path import join
389
390        self.__loadStatus()
391        proj = join(self.root, self.subdir)
392
393        self.logger.info("Updating '%s' from revision '%s'" % (
394            self.module, self.upstream_revision))
395
396        if self.verbose:
397            print "\nUpdating '%s' from revision '%s'" % (
398                self.module, self.upstream_revision)
399
400        try:
401            dwd = DualWorkingDir(self.source_kind, self.target_kind)
402            changesets = dwd.getUpstreamChangesets(proj,
403                                                   self.upstream_repos,
404                                                   self.module,
405                                                   self.upstream_revision)
406        except KeyboardInterrupt:
407            print "Leaving '%s' unchanged" % proj
408            self.logger.info("Leaving '%s' unchanged, stopped by user" % proj)
409            return
410        except:
411            self.logger.exception("Unable to get changes for '%s'" % proj)
412            raise
413
414        nchanges = len(changesets)
415        if nchanges:
416            if self.verbose:
417                print "Applying %d upstream changesets" % nchanges
418
419            try:
420                last, conflicts = dwd.applyUpstreamChangesets(
421                    proj, self.module, changesets, applyable=self.applyable,
422                    applied=self.applied, logger=self.logger,
423                    delayed_commit=single_commit)
424            except:
425                self.logger.exception('Upstream change application failed')
426                raise
427
428            if last:
429                if single_commit:
430                    dwd.commitDelayedChangesets(proj, concatenate_logs)
431
432                self.logger.info("Update completed, now at revision '%s'" % (
433                    self.upstream_revision,))
434        else:
435            self.logger.info("Update completed with no upstream changes")
436
437
438GENERAL_OPTIONS = [
439    make_option("-i", "--interactive", default=False, action="store_true",
440                help="Start an interactive session."),
441    make_option("-D", "--debug", dest="debug",
442                action="store_true", default=False,
443                help="Print each executed command. This also keeps "
444                     "temporary files with the upstream logs, that are "
445                     "otherwise removed after use."),
446    make_option("-v", "--verbose", dest="verbose",
447                action="store_true", default=False,
448                help="Be verbose, echoing the changelog of each applied "
449                     "changeset to stdout."),
450    make_option("--configfile", metavar="CONFNAME",
451                help="Centralized storage of projects info.  With this "
452                     "option and no other arguments tailor will update "
453                     "every project found in the config file."),
454    make_option("--migrate-config", dest="migrate",
455                action="store_true", default=False,
456                help="Migrate old configuration to new centralized storage."),
457    make_option("--encoding", metavar="CHARSET", default=None,
458                help="Force the output encoding to given CHARSET, rather "
459                     "then using the user default settings specified in the "
460                     "environment."),
461]
462
463UPDATE_OPTIONS = [
464    make_option("--update", action="store_true", default=True,
465                help="Update the given repositories, fetching upstream "
466                     "changesets, applying and re-registering each one. "
467                     "This is the default behaviour."),
468    make_option("-S", "--single-commit", action="store_true", default=False,
469                help="Do a single, final commit on the target VC, effectively "
470                     "grouping together all upstream changeset into a single "
471                     "one, from the target VC point of view."),
472    make_option("-C", "--concatenate-logs", action="store_true", default=False,
473                help="With --single-commit, concatenate each changeset "
474                     "message log to the final changelog, instead of just "
475                     "the name of the patch."),
476    make_option("-F", "--patch-name-format", metavar="FORMAT",
477                help="Specify the prototype that will be used "
478                     "to compute the patch name.  The prototype may contain "
479                     "%(keyword)s such as 'module', 'author', 'date', "
480                     "'revision', 'firstlogline', 'remaininglog' for normal "
481                     "updates, otherwise 'module', 'authors', 'nchangesets', "
482                     "'mindate' and 'maxdate' when using --single-commit. It "
483                     "defaults to '%(module)s: changeset %(revision)s'; "
484                     "setting it to the empty string means that tailor will "
485                     "simply use the original changelog."),
486    make_option("-1", "--remove-first-log-line", action="store_true",
487                default=False,
488                help="Remove the first line of the upstream changelog. This "
489                     "is intended to go in pair with --patch-name-format, "
490                     "when using it's 'firstlogline' variable to build the "
491                     "name of the patch."),
492    make_option("-N", "--dont-refill-changelogs", action="store_true",
493                default=False,
494                help="Do not refill every changelog, but keep them as is. "
495                     "This is usefull when using --patch-name-format, or "
496                     "when upstream developers are already formatting their "
497                     "notes with a consistent layout."),
498]
499
500BOOTSTRAP_OPTIONS = [
501    make_option("-b", "--bootstrap", action="store_true", default=False,
502                help="Bootstrap mode, that is the initial copy of the "
503                     "upstream tree, given as an URI (see -R) and maybe "
504                     "a revision (-r).  This overrides --update."),
505    make_option("-s", "--source-kind", dest="source_kind", metavar="VC-KIND",
506                help="Select the backend for the upstream source "
507                     "version control VC-KIND. Default is 'cvs'.",
508                default="cvs"),
509    make_option("-t", "--target-kind", dest="target_kind", metavar="VC-KIND",
510                help="Select VC-KIND as backend for the shadow repository, "
511                     "with 'darcs' as default.",
512                default="darcs"),
513    make_option("-R", "--repository", dest="repository", metavar="REPOS",
514                help="Specify the upstream repository, from where bootstrap "
515                     "will checkout the module.  REPOS syntax depends on "
516                     "the source version control kind."),
517    make_option("-m", "--module", dest="module", metavar="MODULE",
518                help="Specify the module to checkout at bootstrap time. "
519                     "This has different meanings under the various upstream "
520                     "systems: with CVS it indicates the module, while under "
521                     "SVN it's the prefix of the tree you want and must begin "
522                     "with a slash. Since it's used in the description of the "
523                     "target repository, you may want to give it a value with "
524                     "darcs too even if it is otherwise ignored."),
525    make_option("-r", "--revision", dest="revision", metavar="REV",
526                help="Specify the revision bootstrap should checkout.  REV "
527                     "must be a valid 'name' for a revision in the upstream "
528                     "version control kind. For CVS it may be either a branch "
529                     "name, a timestamp or both separated by a space, and "
530                     "timestamp may be 'INITIAL' to denote the beginning of "
531                     "time for the given branch. Under Darcs, INITIAL is a "
532                     "shortcut for the name of the first patch in the upstream "
533                     "repository, otherwise it is interpreted as the name of "
534                     "a tag. Under Subversion, 'INITIAL' is the first patch "
535                     "that touches given repos/module, otherwise it must be "
536                     "an integer revision number. "
537                     "'HEAD', the default, means the latest version in all "
538                     "backends.",
539                default="HEAD"),
540    make_option("--subdir", metavar="DIR",
541                help="Force the subdirectory where the checkout will happen, "
542                     "by default it's the tail part of the module name."),
543]
544
545VC_SPECIFIC_OPTIONS = [
546    make_option("--use-svn-propset", action="store_true", default=False,
547                help="Use 'svn propset' to set the real date and author of "
548                     "each commit, instead of appending these information to "
549                     "the changelog. This requires some tweaks on the SVN "
550                     "repository to enable revision propchanges."),
551]
552
553class ExistingProjectError(Exception):
554    "Project seems already tailored"
555
556class UnknownProjectError(Exception):
557    "Project does not exist"
558
559class ProjectNotTailored(Exception):
560    "Not a tailored project"
561
562def main():
563    """
564    Script entry point.
565
566    Parse the command line options and arguments, and for each
567    specified working copy directory (the current working directory by
568    default) execute the tailorization steps.
569    """
570
571    from os import getcwd, chdir
572    from os.path import abspath, exists, join
573    from shwrap import ExternalCommand
574    from target import SyncronizableTargetWorkingDir
575    from changes import Changeset
576
577    parser = OptionParser(usage='%prog [options] [project ...]',
578                          version=__version__,
579                          option_list=GENERAL_OPTIONS)
580
581    bsoptions = OptionGroup(parser, "Bootstrap options")
582    bsoptions.add_options(BOOTSTRAP_OPTIONS)
583
584    upoptions = OptionGroup(parser, "Update options")
585    upoptions.add_options(UPDATE_OPTIONS)
586
587    vcoptions = OptionGroup(parser, "VC specific options")
588    vcoptions.add_options(VC_SPECIFIC_OPTIONS)
589
590    parser.add_option_group(bsoptions)
591    parser.add_option_group(upoptions)
592    parser.add_option_group(vcoptions)
593
594    options, args = parser.parse_args()
595
596    ExternalCommand.VERBOSE = options.debug
597    if options.encoding:
598        ExternalCommand.FORCE_ENCODING = options.encoding
599
600        # Make printouts be encoded as well. A better solution would be
601        # using the replace mechanism of the encoder, and keep printing
602        # in the user LC_CTYPE/LANG setting.
603
604        import codecs, sys
605        sys.stdout = codecs.getwriter(options.encoding)(sys.stdout)
606
607    if options.patch_name_format is not None:
608        SyncronizableTargetWorkingDir.PATCH_NAME_FORMAT = options.patch_name_format
609    SyncronizableTargetWorkingDir.REMOVE_FIRST_LOG_LINE = options.remove_first_log_line
610    Changeset.REFILL_MESSAGE = not options.dont_refill_changelogs
611
612    SvnWorkingDir.USE_PROPSET = options.use_svn_propset
613
614    if options.interactive:
615        interactive(options, args)
616    elif options.configfile:
617        config = TailorConfig(options)
618
619        config(map(abspath, args))
620    else:
621        # Good (?) old way
622
623        config = None
624
625        base = getcwd()
626
627        if len(args) == 0:
628            args.append(base)
629
630        while args:
631            chdir(base)
632
633            proj = args.pop(0)
634            root = abspath(proj)
635
636            if options.bootstrap:
637                if exists(join(root, STATUS_FILENAME)):
638                    raise ExistingProjectError(
639                        "Project %r cannot be bootstrapped twice" % proj)
640
641                if not options.repository:
642                    raise InvocationError('Need a repository to bootstrap %r' %
643                                          proj)
644            else:
645                if not exists(proj):
646                    raise UnknownProjectError("Project %r does not exist" %
647                                              proj)
648
649                if not exists(join(root, STATUS_FILENAME)):
650                    raise UnknownProjectError(
651                        "%r is not a tailorized project" % proj)
652
653            tailored = TailorizedProject(root, options.verbose, config)
654
655            if options.bootstrap:
656                tailored.bootstrap(options.source_kind, options.target_kind,
657                                   options.repository,
658                                   options.module,
659                                   options.revision,
660                                   options.subdir)
661            elif options.update:
662                tailored.update(options.single_commit,
663                                options.concatenate_logs)
Note: See TracBrowser for help on using the repository browser.