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 self.files.append(fileName)
122 m = _byRegex.search(line)
123 if m:
124
125 name = m.expand("\\1").strip()
126 if name:
127 self.contributors.append(name)
128
129
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
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
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
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
192 self._path = path
193 self._blocks = []
194 self._entries = []
195 self._releases = {}
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
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
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
234 if block:
235 yield block
236 block = []
237
238 block.append(line)
239
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
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
273 if not match:
274 foundAllNeedles = False
275
276 if foundAllNeedles:
277 res.append(entry)
278
279 return res
280
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
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
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
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
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
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
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
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
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 """
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
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
502
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
511
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
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
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
564 ctagsFiles = files[:]
565 for f in files:
566 if not os.path.exists(f):
567 ctagsFiles.remove(f)
568
569
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
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
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
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
668 cmd = "mv %s %s" % (tmpPath, clPath)
669 self.debug(cmd)
670 os.system(cmd)
671
672 return 0
673
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
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
696 self.clPath = self.getClPath(options.changelog)
697
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