Package moap :: Package command :: Module cl
[hide private]
[frames] | no frames]

Source Code for Module moap.command.cl

  1  # -*- Mode: Python; test-case-name: moap.test.test_commands_cl -*- 
  2  # vi:si:et:sw=4:sts=4:ts=4 
  3   
  4  import commands 
  5  import os 
  6  import pwd 
  7  import time 
  8  import re 
  9  import tempfile 
 10  import textwrap 
 11   
 12  from moap.util import log, util, ctags 
 13  from moap.vcs import vcs 
 14   
 15  description = "Read and act on ChangeLog" 
 16   
 17  # for matching the first line of an entry 
 18  _nameRegex = re.compile('^(\d*-\d*-\d*)\s*(.*)$') 
 19   
 20  # for matching the address out of the second part of the first line 
 21  _addressRegex = re.compile('^([^<]*)<(.*)>$') 
 22   
 23  # for matching contributors 
 24  _byRegex = re.compile(' by: ([^<]*)\s*.*$') 
 25   
 26  # for matching files changed 
 27  _fileRegex = re.compile('^\s*\* (.[^:\s\(]*).*') 
 28   
 29  # for matching release lines 
 30  _releaseRegex = re.compile(r'^=== release (.*) ===$') 
 31   
 32  # for ChangeLog template 
 33  _defaultReviewer = "<delete if not using a buddy>" 
 34  _defaultPatcher = "<delete if not someone else's patch>" 
 35  _defaultName = "Please set CHANGE_LOG_NAME or REAL_NAME environment variable" 
 36  _defaultMail = "Please set CHANGE_LOG_EMAIL_ADDRESS or " \ 
 37                 "EMAIL_ADDRESS environment variable" 
38 -class Entry:
39 """ 40 I represent one entry in a ChangeLog file. 41 42 @ivar lines: the original text block of the entry. 43 @type lines: str 44 """ 45 lines = None 46
47 - def match(self, needle, caseSensitive=False):
48 """ 49 Match the given needle against the given entry. 50 51 Subclasses should override this method. 52 53 @type caseSensitive: bool 54 @param caseSensitive: whether to do case sensitive searching 55 56 @returns: whether the entry contains the given needle. 57 """ 58 raise NotImplementedError
59 60
61 -class ChangeEntry(Entry):
62 """ 63 I represent one entry in a ChangeLog file. 64 65 @ivar text: the text of the message, without name line or 66 preceding/following newlines 67 @type text: str 68 @type date: str 69 @type name: str 70 @type address: str 71 @ivar files: list of files referenced in this ChangeLog entry 72 @type files: list of str 73 @ivar contributors: list of people who've contributed to this entry 74 @type contributors: str 75 @type notEdited: list of str 76 @ivar notEdited: list of fields with default template value 77 @type 78 """ 79 date = None 80 name = None 81 address = None 82 text = None 83 contributors = None 84 notEdited = None 85
86 - def __init__(self):
87 self.files = [] 88 self.contributors = [] 89 self.notEdited = []
90
91 - def _checkNotEdited(self, line):
92 if line.find(_defaultMail) >= 0: 93 self.notEdited.append("mail") 94 if line.find(_defaultName) >= 0: 95 self.notEdited.append("name") 96 if line.find(_defaultPatcher) >= 0: 97 self.notEdited.append("patched by") 98 if line.find(_defaultReviewer) >= 0: 99 self.notEdited.append("reviewer")
100
101 - def parse(self, lines):
102 """ 103 @type lines: list of str 104 """ 105 # first line is the "name" line 106 m = _nameRegex.search(lines[0].strip()) 107 self.date = m.expand("\\1") 108 self.name = m.expand("\\2") 109 m = _addressRegex.search(self.name) 110 if m: 111 self.name = m.expand("\\1").strip() 112 self.address = m.expand("\\2") 113 114 # all the other lines can contain files or contributors 115 self._checkNotEdited(lines[0]) 116 for line in lines[1:]: 117 self._checkNotEdited(line) 118 m = _fileRegex.search(line) 119 if m: 120 fileName = m.expand("\\1") 121 self.files.append(fileName) 122 m = _byRegex.search(line) 123 if m: 124 # only append entries that we actually have a name for 125 name = m.expand("\\1").strip() 126 if name: 127 self.contributors.append(name) 128 129 # create the text attribute 130 save = [] 131 for line in lines[1:]: 132 line = line.rstrip() 133 if len(line) > 0: 134 save.append(line) 135 self.text = "\n".join(save) + "\n"
136
137 - def match(self, needle, caseSensitive):
138 keys = ['text', 'name', 'date', 'address'] 139 140 if not caseSensitive: 141 needle = needle.lower() 142 143 for key in keys: 144 value = getattr(self, key) 145 146 if not value: 147 continue 148 149 if caseSensitive: 150 value = value.lower() 151 152 if value.find(needle) >= 0: 153 return True 154 155 return False
156
157 -class ReleaseEntry:
158 """ 159 I represent a release separator in a ChangeLog file. 160 """ 161 version = None 162
163 - def parse(self, lines):
164 """ 165 @type lines: list of str 166 """ 167 # first and only line is the "release" line 168 m = _releaseRegex.search(lines[0]) 169 self.version = m.expand("\\1")
170
171 - def match(self, needle, caseSensitive):
172 value = self.version 173 174 if not caseSensitive: 175 needle = needle.lower() 176 value = value.lower() 177 178 if value.find(needle) >= 0: 179 return True 180 181 return False
182
183 -class ChangeLogFile(log.Loggable):
184 """ 185 I represent a standard ChangeLog file. 186 187 Create me, then call parse() on me to parse the file into entries. 188 """ 189 logCategory = "ChangeLog" 190
191 - def __init__(self, path):
192 self._path = path 193 self._blocks = [] 194 self._entries = [] 195 self._releases = {} # map of release -> index in self._entries 196 self._handle = None
197
198 - def parse(self, allEntries=True):
199 """ 200 Parse the ChangeLog file into entries. 201 202 @param allEntries: whether to parse all, or stop on the first. 203 @type allEntries: bool 204 """ 205 def parseBlock(block): 206 self._blocks.append(block) 207 if _nameRegex.match(block[0]): 208 entry = ChangeEntry() 209 elif _releaseRegex.match(block[0]): 210 entry = ReleaseEntry() 211 212 # FIXME: shouldn't the base class handle this, then delegate ? 213 entry.lines = block 214 entry.parse(block) 215 self._entries.append(entry) 216 217 if isinstance(entry, ReleaseEntry): 218 self._releases[entry.version] = len(self._entries) - 1 219 220 return entry
221 222 for b in self.__blocks(): 223 parseBlock(b) 224 if not allEntries and self._entries: 225 return
226
227 - def __blocks(self):
228 if not self._handle: 229 self._handle = open(self._path, "r") 230 block = [] 231 for line in self._handle.readlines(): 232 if _nameRegex.match(line) or _releaseRegex.match(line): 233 # new entry starting, parse old block 234 if block: 235 yield block 236 block = [] 237 238 block.append(line) 239 # don't forget the last block 240 yield block 241 242 self._handle = None 243 self.debug('%d entries in %s' % (len(self._entries), self._path))
244
245 - def getEntry(self, num):
246 """ 247 Get the nth entry from the ChangeLog, starting from 0 for the most 248 recent one. 249 250 @raises IndexError: If no entry could be found 251 """ 252 return self._entries[num]
253
254 - def getReleaseIndex(self, release):
255 return self._releases[release]
256 257
258 - def find(self, needles, caseSensitive=False):
259 """ 260 Find and return all entries whose text matches all of the given strings. 261 262 @type needles: list of str 263 @param needles: the strings to look for 264 @type caseSensitive: bool 265 @param caseSensitive: whether to do case sensitive searching 266 """ 267 res = [] 268 for entry in self._entries: 269 foundAllNeedles = True 270 for needle in needles: 271 match = entry.match(needle, caseSensitive) 272 # all needles need to be found to be valid 273 if not match: 274 foundAllNeedles = False 275 276 if foundAllNeedles: 277 res.append(entry) 278 279 return res
280
281 -class Checkin(util.LogCommand):
282 usage = "checkin [path to directory or ChangeLog file]" 283 summary = "check in files listed in the latest ChangeLog entry" 284 description = """Check in the files listed in the latest ChangeLog entry. 285 286 Besides using the -c argument to 'changelog', you can also specify the path 287 to the ChangeLog file as an argument, so you can alias 288 'moap changelog checkin' to a shorter command. 289 290 Supported VCS systems: %s""" % ", ".join(vcs.getNames()) 291 aliases = ["ci", ] 292
293 - def do(self, args):
294 clPath = self.parentCommand.clPath 295 if args: 296 clPath = self.parentCommand.getClPath(args[0]) 297 298 clName = os.path.basename(clPath) 299 clDir = os.path.dirname(clPath) 300 if not os.path.exists(clPath): 301 self.stderr.write('No %s found in %s.\n' % (clName, clDir)) 302 return 3 303 304 v = vcs.detect(clDir) 305 if not v: 306 self.stderr.write('No VCS detected in %s\n' % clDir) 307 return 3 308 309 cl = ChangeLogFile(clPath) 310 # get latest entry 311 cl.parse(False) 312 entry = cl.getEntry(0) 313 if isinstance(entry, ChangeEntry) and entry.notEdited: 314 self.stderr.write( 315 'ChangeLog entry has not been updated properly:') 316 self.stderr.write("\n - ".join(['', ] + entry.notEdited) + "\n") 317 self.stderr.write("Please fix the entry and try again.") 318 return 3 319 self.debug('Commiting files %r' % entry.files) 320 ret = v.commit([clName, ] + entry.files, entry.text) 321 if not ret: 322 return 1 323 324 return 0
325
326 -class Contributors(util.LogCommand):
327 usage = "contributors [path to directory or ChangeLog file]" 328 summary = "get a list of contributors since the previous release" 329 aliases = ["cont", "contrib"] 330
331 - def addOptions(self):
332 self.parser.add_option('-r', '--release', 333 action="store", dest="release", 334 help="release to get contributors to")
335
336 - def do(self, args):
337 if args: 338 self.stderr.write("Deprecation warning:\n") 339 self.stderr.write("Please use the -c argument to 'changelog'" 340 " to pass a ChangeLog file.\n") 341 return 3 342 343 clPath = self.parentCommand.clPath 344 cl = ChangeLogFile(clPath) 345 cl.parse() 346 347 names = [] 348 # find entry to start at 349 i = 0 350 if self.options.release: 351 try: 352 i = cl.getReleaseIndex(self.options.release) + 1 353 except KeyError: 354 self.stderr.write('No release %s found in %s !\n' % ( 355 self.options.release, clPath)) 356 return 3 357 358 self.debug('Release %s is entry %d' % (self.options.release, i)) 359 360 # now scan all entries from that point downwards 361 while True: 362 try: 363 entry = cl.getEntry(i) 364 except IndexError: 365 break 366 if isinstance(entry, ReleaseEntry): 367 break 368 369 if not entry.name in names: 370 self.debug("Adding name %s" % entry.name) 371 names.append(entry.name) 372 for n in entry.contributors: 373 if not n in names: 374 self.debug("Adding name %s" % n) 375 names.append(n) 376 377 i += 1 378 379 names.sort() 380 self.stdout.write("\n".join(names) + "\n") 381 382 return 0
383
384 -class Diff(util.LogCommand):
385 summary = "show diff for all files from latest ChangeLog entry" 386 description = """ 387 Show the difference between local and repository copy of all files mentioned 388 in the latest ChangeLog entry. 389 390 Supported VCS systems: %s""" % ", ".join(vcs.getNames()) 391
392 - def addOptions(self):
393 self.parser.add_option('-E', '--no-entry', 394 action="store_false", dest="entry", default=True, 395 help="don't prefix the diff with the ChangeLog entry")
396
397 - def do(self, args):
398 if args: 399 self.stderr.write("Deprecation warning:\n") 400 self.stderr.write("Please use the -c argument to 'changelog'" 401 " to pass a ChangeLog file.\n") 402 return 3 403 404 clPath = self.parentCommand.clPath 405 path = os.path.dirname(clPath) 406 if not os.path.exists(clPath): 407 self.stderr.write('No ChangeLog found in %s.\n' % path) 408 return 3 409 410 v = vcs.detect(path) 411 if not v: 412 self.stderr.write('No VCS detected in %s\n' % path) 413 return 3 414 415 cl = ChangeLogFile(clPath) 416 cl.parse(False) 417 # get latest entry 418 entry = cl.getEntry(0) 419 if isinstance(entry, ReleaseEntry): 420 self.stderr.write('No ChangeLog change entry found in %s.\n' % path) 421 return 3 422 423 # start with the ChangeLog entry unless requested not to 424 if self.options.entry: 425 self.stdout.write("".join(entry.lines)) 426 427 for fileName in entry.files: 428 self.debug('diffing %s' % fileName) 429 diff = v.diff(fileName) 430 if diff: 431 self.stdout.write(diff) 432 self.stdout.write('\n')
433
434 -class Find(util.LogCommand):
435 summary = "show all ChangeLog entries containing the given string(s)" 436 description = """ 437 Shows all entries from the ChangeLog whose text contains the given string(s). 438 By default, this command matches case-insensitive. 439 """
440 - def addOptions(self):
441 self.parser.add_option('-c', '--case-sensitive', 442 action="store_true", dest="caseSensitive", default=False, 443 help="Match case when looking for matching ChangeLog entries")
444
445 - def do(self, args):
446 if not args: 447 self.stderr.write('Please give one or more strings to find.\n') 448 return 3 449 450 needles = args 451 452 cl = ChangeLogFile(self.parentCommand.clPath) 453 cl.parse() 454 entries = cl.find(needles, self.options.caseSensitive) 455 for entry in entries: 456 self.stdout.write("".join(entry.lines)) 457 458 return 0
459
460 -class Prepare(util.LogCommand):
461 summary = "prepare ChangeLog entry from local diff" 462 description = """This command prepares a new ChangeLog entry by analyzing 463 the local changes gotten from the VCS system used. 464 465 It uses ctags to extract the tags affected by the changes, and adds them 466 to the ChangeLog entries. 467 468 It decides your name based on your account settings, the REAL_NAME or 469 CHANGE_LOG_NAME environment variables. 470 It decides your e-mail address based on the CHANGE_LOG_EMAIL_ADDRESS or 471 EMAIL_ADDRESS environment variable. 472 473 Besides using the -c argument to 'changelog', you can also specify the path 474 to the ChangeLog file as an argument, so you can alias 475 'moap changelog checkin' to a shorter command. 476 477 Supported VCS systems: %s""" % ", ".join(vcs.getNames()) 478 usage = "prepare [path to directory or ChangeLog file]" 479 aliases = ["pr", "prep", ] 480
481 - def getCTags(self):
482 """ 483 Get a binary that is ctags-like. 484 """ 485 binary = None 486 for candidate in ["ctags", "exuberant-ctags", "ctags-exuberant"]: 487 self.debug('Checking for existence of %s' % candidate) 488 if os.system('which %s > /dev/null 2>&1' % candidate) == 0: 489 self.debug('Checking for exuberance of %s' % candidate) 490 output = commands.getoutput("%s --version" % candidate) 491 if output.startswith("Exuberant"): 492 binary = candidate 493 break 494 495 if not binary: 496 self.stderr.write('Warning: no exuberant ctags found.\n') 497 from moap.util import deps 498 deps.handleMissingDependency(deps.ctags()) 499 self.stderr.write('\n') 500 501 return binary
502
503 - def addOptions(self):
504 self.parser.add_option('-c', '--ctags', 505 action="store_true", dest="ctags", default=False, 506 help="Use ctags to extract and add changed tags to ChangeLog entry")
507
508 - def do(self, args):
509 def filePathRelative(vcsPath, filePath): 510 # the paths are absolute because we asked for an absolute path 511 # diff strip them to be relative 512 if filePath.startswith(vcsPath): 513 filePath = filePath[len(vcsPath) + 1:] 514 return filePath
515 516 def writeLine(about): 517 line = "\t* %s:\n" % about 518 # wrap to maximum 72 characters, and keep tabs 519 lines = textwrap.wrap(line, 72, expand_tabs=False, 520 replace_whitespace=False, 521 subsequent_indent="\t ") 522 os.write(fd, "\n".join(lines) + '\n')
523 524 clPath = self.parentCommand.clPath 525 if args: 526 clPath = self.parentCommand.getClPath(args[0]) 527 528 vcsPath = os.path.dirname(os.path.abspath(clPath)) 529 v = vcs.detect(vcsPath) 530 if not v: 531 self.stderr.write('No VCS detected in %s\n' % vcsPath) 532 return 3 533 534 self.stdout.write('Updating %s from %s repository.\n' % (clPath, 535 v.name)) 536 try: 537 v.update(clPath) 538 except vcs.VCSException, e: 539 self.stderr.write('Could not update %s:\n%s\n' % ( 540 clPath, e.args[0])) 541 return 3 542 543 self.stdout.write('Finding changes.\n') 544 changes = v.getChanges(vcsPath) 545 propertyChanges = v.getPropertyChanges(vcsPath) 546 added = v.getAdded(vcsPath) 547 deleted = v.getDeleted(vcsPath) 548 549 # filter out the ChangeLog we're preparing 550 if os.path.abspath(clPath) in changes.keys(): 551 del changes[os.path.abspath(clPath)] 552 553 if not (changes or propertyChanges or added or deleted): 554 self.stdout.write('No changes detected.\n') 555 return 0 556 557 if changes: 558 files = changes.keys() 559 files.sort() 560 561 ct = ctags.CTags() 562 if self.options.ctags: 563 # run ctags only on files that aren't deleted 564 ctagsFiles = files[:] 565 for f in files: 566 if not os.path.exists(f): 567 ctagsFiles.remove(f) 568 569 # get the tags for all the files we're looking at 570 binary = self.getCTags() 571 572 if binary: 573 self.stdout.write('Extracting affected tags from source.\n') 574 command = "%s -u --fields=+nlS -f - %s" % ( 575 binary, " ".join(ctagsFiles)) 576 self.debug('Running command %s' % command) 577 output = commands.getoutput(command) 578 ct.addString(output) 579 580 # prepare header for entry 581 date = time.strftime('%Y-%m-%d') 582 for name in [ 583 os.environ.get('CHANGE_LOG_NAME'), 584 os.environ.get('REAL_NAME'), 585 pwd.getpwuid(os.getuid()).pw_gecos, 586 _defaultName]: 587 if name: 588 break 589 590 for mail in [ 591 os.environ.get('CHANGE_LOG_EMAIL_ADDRESS'), 592 os.environ.get('EMAIL_ADDRESS'), 593 _defaultMail]: 594 if mail: 595 break 596 597 self.stdout.write('Editing %s.\n' % clPath) 598 (fd, tmpPath) = tempfile.mkstemp(suffix='.moap') 599 os.write(fd, "%s %s <%s>\n\n" % (date, name, mail)) 600 os.write(fd, "\treviewed by: %s\n" % _defaultReviewer); 601 os.write(fd, "\tpatch by: %s\n" % _defaultPatcher); 602 os.write(fd, "\n") 603 604 if changes: 605 self.debug('Analyzing changes') 606 for filePath in files: 607 if not os.path.exists(filePath): 608 self.debug("%s not found, assuming it got deleted" % 609 filePath) 610 continue 611 612 lines = changes[filePath] 613 tags = [] 614 for oldLine, oldCount, newLine, newCount in lines: 615 self.log("Looking in file %s, newLine %r, newCount %r" % ( 616 filePath, newLine, newCount)) 617 try: 618 for t in ct.getTags(filePath, newLine, newCount): 619 # we want unique tags, not several hits for one 620 if not t in tags: 621 tags.append(t) 622 except KeyError: 623 pass 624 625 filePath = filePathRelative(vcsPath, filePath) 626 tagPart = "" 627 if tags: 628 parts = [] 629 for tag in tags: 630 if tag.klazz: 631 parts.append('%s.%s' % (tag.klazz, tag.name)) 632 else: 633 parts.append(tag.name) 634 tagPart = " (" + ", ".join(parts) + ")" 635 writeLine(filePath + tagPart) 636 637 if propertyChanges: 638 self.debug('Handling property changes') 639 for filePath, properties in propertyChanges.items(): 640 filePath = filePathRelative(vcsPath, filePath) 641 writeLine("%s (%s)" % (filePath, ", ".join(properties))) 642 643 if added: 644 self.debug('Handling path additions') 645 for path in added: 646 path = filePathRelative(vcsPath, path) 647 writeLine("%s (added)" % path) 648 649 if deleted: 650 self.debug('Handling path deletions') 651 for path in deleted: 652 path = filePathRelative(vcsPath, path) 653 writeLine("%s (deleted)" % path) 654 655 os.write(fd, "\n") 656 657 # copy rest of ChangeLog file 658 if os.path.exists(clPath): 659 self.debug('Appending from old %s' % clPath) 660 handle = open(clPath) 661 while True: 662 data = handle.read() 663 if not data: 664 break 665 os.write(fd, data) 666 os.close(fd) 667 # FIXME: figure out a nice pythonic move for cross-device links instead 668 cmd = "mv %s %s" % (tmpPath, clPath) 669 self.debug(cmd) 670 os.system(cmd) 671 672 return 0 673
674 -class ChangeLog(util.LogCommand):
675 """ 676 ivar clPath: path to the ChangeLog file, for subcommands to use. 677 type clPath: str 678 """ 679 usage = "changelog %command" 680 681 summary = "act on ChangeLog file" 682 description = """Act on a ChangeLog file. 683 684 Some of the commands use the version control system in use. 685 686 Supported VCS systems: %s""" % ", ".join(vcs.getNames()) 687 subCommandClasses = [Checkin, Contributors, Diff, Find, Prepare] 688 aliases = ["cl", ] 689
690 - def addOptions(self):
691 self.parser.add_option('-C', '--ChangeLog', 692 action="store", dest="changelog", default="ChangeLog", 693 help="path to ChangeLog file or directory containing it")
694
695 - def handleOptions(self, options):
696 self.clPath = self.getClPath(options.changelog)
697
698 - def getClPath(self, clPath):
699 """ 700 Helper for subcommands to expand a patch to either a file or a dir, 701 to a path to the ChangeLog file. 702 """ 703 if os.path.isdir(clPath): 704 clPath = os.path.join(clPath, "ChangeLog") 705 706 self.debug('changelog: path %s' % clPath) 707 return clPath
708