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