1
2
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
18 _nameRegex = re.compile('^(\d*-\d*-\d*)\s*(.*)$')
19
20
21 _addressRegex = re.compile('^([^<]*)<(.*)>$')
22
23
24 _byRegex = re.compile(' by: ([^<]*)\s*.*$')
25
26
27 _fileRegex = re.compile('^\s*\* (.[^:\s\(]*).*')
28
29
30 _releaseRegex = re.compile(r'^=== release (.*) ===$')
31
32
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"
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
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
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
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
126 name = m.expand("\\1").strip()
127 if name:
128 self.contributors.append(name)
129
130
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
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
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
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
193 self._path = path
194 self._blocks = []
195 self._entries = []
196 self._releases = {}
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
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
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
242 if block:
243 yield block
244 block = []
245
246 block.append(line)
247
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
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
281 if not match:
282 foundAllNeedles = False
283
284 if foundAllNeedles:
285 res.append(entry)
286
287 return res
288
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
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
335 usage = "[path to directory or ChangeLog file]"
336 summary = "get a list of contributors since the previous release"
337 aliases = ["cont", "contrib"]
338
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
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
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
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
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
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 """
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
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
510
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
519
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
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
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
572 ctagsFiles = files[:]
573 for f in files:
574 if not os.path.exists(f):
575 ctagsFiles.remove(f)
576
577
578 binary = self.getCTags()
579
580 if binary:
581 self.stdout.write('Extracting affected tags from source.\n')
582
583
584
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
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
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
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
677 cmd = "mv %s %s" % (tmpPath, clPath)
678 self.debug(cmd)
679 os.system(cmd)
680
681 return 0
682
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
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
703 self.clPath = self.getClPath(options.changelog)
704
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