##// END OF EJS Templates
py3: handle keyword arguments in hgext/releasenotes.py...
Pulkit Goyal -
r35004:e68dd190 default
parent child Browse files
Show More
@@ -1,633 +1,636 b''
1 1 # Copyright 2017-present Gregory Szorc <gregory.szorc@gmail.com>
2 2 #
3 3 # This software may be used and distributed according to the terms of the
4 4 # GNU General Public License version 2 or any later version.
5 5
6 6 """generate release notes from commit messages (EXPERIMENTAL)
7 7
8 8 It is common to maintain files detailing changes in a project between
9 9 releases. Maintaining these files can be difficult and time consuming.
10 10 The :hg:`releasenotes` command provided by this extension makes the
11 11 process simpler by automating it.
12 12 """
13 13
14 14 from __future__ import absolute_import
15 15
16 16 import difflib
17 17 import errno
18 18 import re
19 19 import sys
20 20 import textwrap
21 21
22 22 from mercurial.i18n import _
23 23 from mercurial import (
24 24 config,
25 25 error,
26 26 minirst,
27 27 node,
28 pycompat,
28 29 registrar,
29 30 scmutil,
30 31 util,
31 32 )
32 33
33 34 cmdtable = {}
34 35 command = registrar.command(cmdtable)
35 36
36 37 try:
37 38 import fuzzywuzzy.fuzz as fuzz
38 39 fuzz.token_set_ratio
39 40 except ImportError:
40 41 fuzz = None
41 42
42 43 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
43 44 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
44 45 # be specifying the version(s) of Mercurial they are tested with, or
45 46 # leave the attribute unspecified.
46 47 testedwith = 'ships-with-hg-core'
47 48
48 49 DEFAULT_SECTIONS = [
49 50 ('feature', _('New Features')),
50 51 ('bc', _('Backwards Compatibility Changes')),
51 52 ('fix', _('Bug Fixes')),
52 53 ('perf', _('Performance Improvements')),
53 54 ('api', _('API Changes')),
54 55 ]
55 56
56 57 RE_DIRECTIVE = re.compile('^\.\. ([a-zA-Z0-9_]+)::\s*([^$]+)?$')
57 58 RE_ISSUE = r'\bissue ?[0-9]{4,6}(?![0-9])\b'
58 59
59 60 BULLET_SECTION = _('Other Changes')
60 61
61 62 class parsedreleasenotes(object):
62 63 def __init__(self):
63 64 self.sections = {}
64 65
65 66 def __contains__(self, section):
66 67 return section in self.sections
67 68
68 69 def __iter__(self):
69 70 return iter(sorted(self.sections))
70 71
71 72 def addtitleditem(self, section, title, paragraphs):
72 73 """Add a titled release note entry."""
73 74 self.sections.setdefault(section, ([], []))
74 75 self.sections[section][0].append((title, paragraphs))
75 76
76 77 def addnontitleditem(self, section, paragraphs):
77 78 """Adds a non-titled release note entry.
78 79
79 80 Will be rendered as a bullet point.
80 81 """
81 82 self.sections.setdefault(section, ([], []))
82 83 self.sections[section][1].append(paragraphs)
83 84
84 85 def titledforsection(self, section):
85 86 """Returns titled entries in a section.
86 87
87 88 Returns a list of (title, paragraphs) tuples describing sub-sections.
88 89 """
89 90 return self.sections.get(section, ([], []))[0]
90 91
91 92 def nontitledforsection(self, section):
92 93 """Returns non-titled, bulleted paragraphs in a section."""
93 94 return self.sections.get(section, ([], []))[1]
94 95
95 96 def hastitledinsection(self, section, title):
96 97 return any(t[0] == title for t in self.titledforsection(section))
97 98
98 99 def merge(self, ui, other):
99 100 """Merge another instance into this one.
100 101
101 102 This is used to combine multiple sources of release notes together.
102 103 """
103 104 if not fuzz:
104 105 ui.warn(_("module 'fuzzywuzzy' not found, merging of similar "
105 106 "releasenotes is disabled\n"))
106 107
107 108 for section in other:
108 109 existingnotes = converttitled(self.titledforsection(section)) + \
109 110 convertnontitled(self.nontitledforsection(section))
110 111 for title, paragraphs in other.titledforsection(section):
111 112 if self.hastitledinsection(section, title):
112 113 # TODO prompt for resolution if different and running in
113 114 # interactive mode.
114 115 ui.write(_('%s already exists in %s section; ignoring\n') %
115 116 (title, section))
116 117 continue
117 118
118 119 incoming_str = converttitled([(title, paragraphs)])[0]
119 120 if section == 'fix':
120 121 issue = getissuenum(incoming_str)
121 122 if issue:
122 123 if findissue(ui, existingnotes, issue):
123 124 continue
124 125
125 126 if similar(ui, existingnotes, incoming_str):
126 127 continue
127 128
128 129 self.addtitleditem(section, title, paragraphs)
129 130
130 131 for paragraphs in other.nontitledforsection(section):
131 132 if paragraphs in self.nontitledforsection(section):
132 133 continue
133 134
134 135 incoming_str = convertnontitled([paragraphs])[0]
135 136 if section == 'fix':
136 137 issue = getissuenum(incoming_str)
137 138 if issue:
138 139 if findissue(ui, existingnotes, issue):
139 140 continue
140 141
141 142 if similar(ui, existingnotes, incoming_str):
142 143 continue
143 144
144 145 self.addnontitleditem(section, paragraphs)
145 146
146 147 class releasenotessections(object):
147 148 def __init__(self, ui, repo=None):
148 149 if repo:
149 150 sections = util.sortdict(DEFAULT_SECTIONS)
150 151 custom_sections = getcustomadmonitions(repo)
151 152 if custom_sections:
152 153 sections.update(custom_sections)
153 154 self._sections = list(sections.iteritems())
154 155 else:
155 156 self._sections = list(DEFAULT_SECTIONS)
156 157
157 158 def __iter__(self):
158 159 return iter(self._sections)
159 160
160 161 def names(self):
161 162 return [t[0] for t in self._sections]
162 163
163 164 def sectionfromtitle(self, title):
164 165 for name, value in self._sections:
165 166 if value == title:
166 167 return name
167 168
168 169 return None
169 170
170 171 def converttitled(titledparagraphs):
171 172 """
172 173 Convert titled paragraphs to strings
173 174 """
174 175 string_list = []
175 176 for title, paragraphs in titledparagraphs:
176 177 lines = []
177 178 for para in paragraphs:
178 179 lines.extend(para)
179 180 string_list.append(' '.join(lines))
180 181 return string_list
181 182
182 183 def convertnontitled(nontitledparagraphs):
183 184 """
184 185 Convert non-titled bullets to strings
185 186 """
186 187 string_list = []
187 188 for paragraphs in nontitledparagraphs:
188 189 lines = []
189 190 for para in paragraphs:
190 191 lines.extend(para)
191 192 string_list.append(' '.join(lines))
192 193 return string_list
193 194
194 195 def getissuenum(incoming_str):
195 196 """
196 197 Returns issue number from the incoming string if it exists
197 198 """
198 199 issue = re.search(RE_ISSUE, incoming_str, re.IGNORECASE)
199 200 if issue:
200 201 issue = issue.group()
201 202 return issue
202 203
203 204 def findissue(ui, existing, issue):
204 205 """
205 206 Returns true if issue number already exists in notes.
206 207 """
207 208 if any(issue in s for s in existing):
208 209 ui.write(_('"%s" already exists in notes; ignoring\n') % issue)
209 210 return True
210 211 else:
211 212 return False
212 213
213 214 def similar(ui, existing, incoming_str):
214 215 """
215 216 Returns true if similar note found in existing notes.
216 217 """
217 218 if len(incoming_str.split()) > 10:
218 219 merge = similaritycheck(incoming_str, existing)
219 220 if not merge:
220 221 ui.write(_('"%s" already exists in notes file; ignoring\n')
221 222 % incoming_str)
222 223 return True
223 224 else:
224 225 return False
225 226 else:
226 227 return False
227 228
228 229 def similaritycheck(incoming_str, existingnotes):
229 230 """
230 231 Returns false when note fragment can be merged to existing notes.
231 232 """
232 233 # fuzzywuzzy not present
233 234 if not fuzz:
234 235 return True
235 236
236 237 merge = True
237 238 for bullet in existingnotes:
238 239 score = fuzz.token_set_ratio(incoming_str, bullet)
239 240 if score > 75:
240 241 merge = False
241 242 break
242 243 return merge
243 244
244 245 def getcustomadmonitions(repo):
245 246 ctx = repo['.']
246 247 p = config.config()
247 248
248 249 def read(f, sections=None, remap=None):
249 250 if f in ctx:
250 251 data = ctx[f].data()
251 252 p.parse(f, data, sections, remap, read)
252 253 else:
253 254 raise error.Abort(_(".hgreleasenotes file \'%s\' not found") %
254 255 repo.pathto(f))
255 256
256 257 if '.hgreleasenotes' in ctx:
257 258 read('.hgreleasenotes')
258 259 return p['sections']
259 260
260 261 def checkadmonitions(ui, repo, directives, revs):
261 262 """
262 263 Checks the commit messages for admonitions and their validity.
263 264
264 265 .. abcd::
265 266
266 267 First paragraph under this admonition
267 268
268 269 For this commit message, using `hg releasenotes -r . --check`
269 270 returns: Invalid admonition 'abcd' present in changeset 3ea92981e103
270 271
271 272 As admonition 'abcd' is neither present in default nor custom admonitions
272 273 """
273 274 for rev in revs:
274 275 ctx = repo[rev]
275 276 admonition = re.search(RE_DIRECTIVE, ctx.description())
276 277 if admonition:
277 278 if admonition.group(1) in directives:
278 279 continue
279 280 else:
280 281 ui.write(_("Invalid admonition '%s' present in changeset %s"
281 282 "\n") % (admonition.group(1), ctx.hex()[:12]))
282 283 sim = lambda x: difflib.SequenceMatcher(None,
283 284 admonition.group(1), x).ratio()
284 285
285 286 similar = [s for s in directives if sim(s) > 0.6]
286 287 if len(similar) == 1:
287 288 ui.write(_("(did you mean %s?)\n") % similar[0])
288 289 elif similar:
289 290 ss = ", ".join(sorted(similar))
290 291 ui.write(_("(did you mean one of %s?)\n") % ss)
291 292
292 293 def _getadmonitionlist(ui, sections):
293 294 for section in sections:
294 295 ui.write("%s: %s\n" % (section[0], section[1]))
295 296
296 297 def parsenotesfromrevisions(repo, directives, revs):
297 298 notes = parsedreleasenotes()
298 299
299 300 for rev in revs:
300 301 ctx = repo[rev]
301 302
302 303 blocks, pruned = minirst.parse(ctx.description(),
303 304 admonitions=directives)
304 305
305 306 for i, block in enumerate(blocks):
306 307 if block['type'] != 'admonition':
307 308 continue
308 309
309 310 directive = block['admonitiontitle']
310 311 title = block['lines'][0].strip() if block['lines'] else None
311 312
312 313 if i + 1 == len(blocks):
313 314 raise error.Abort(_('release notes directive %s lacks content')
314 315 % directive)
315 316
316 317 # Now search ahead and find all paragraphs attached to this
317 318 # admonition.
318 319 paragraphs = []
319 320 for j in range(i + 1, len(blocks)):
320 321 pblock = blocks[j]
321 322
322 323 # Margin blocks may appear between paragraphs. Ignore them.
323 324 if pblock['type'] == 'margin':
324 325 continue
325 326
326 327 if pblock['type'] != 'paragraph':
327 328 raise error.Abort(_('unexpected block in release notes '
328 329 'directive %s') % directive)
329 330
330 331 if pblock['indent'] > 0:
331 332 paragraphs.append(pblock['lines'])
332 333 else:
333 334 break
334 335
335 336 # TODO consider using title as paragraph for more concise notes.
336 337 if not paragraphs:
337 338 repo.ui.warn(_("error parsing releasenotes for revision: "
338 339 "'%s'\n") % node.hex(ctx.node()))
339 340 if title:
340 341 notes.addtitleditem(directive, title, paragraphs)
341 342 else:
342 343 notes.addnontitleditem(directive, paragraphs)
343 344
344 345 return notes
345 346
346 347 def parsereleasenotesfile(sections, text):
347 348 """Parse text content containing generated release notes."""
348 349 notes = parsedreleasenotes()
349 350
350 351 blocks = minirst.parse(text)[0]
351 352
352 353 def gatherparagraphsbullets(offset, title=False):
353 354 notefragment = []
354 355
355 356 for i in range(offset + 1, len(blocks)):
356 357 block = blocks[i]
357 358
358 359 if block['type'] == 'margin':
359 360 continue
360 361 elif block['type'] == 'section':
361 362 break
362 363 elif block['type'] == 'bullet':
363 364 if block['indent'] != 0:
364 365 raise error.Abort(_('indented bullet lists not supported'))
365 366 if title:
366 367 lines = [l[1:].strip() for l in block['lines']]
367 368 notefragment.append(lines)
368 369 continue
369 370 else:
370 371 lines = [[l[1:].strip() for l in block['lines']]]
371 372
372 373 for block in blocks[i + 1:]:
373 374 if block['type'] in ('bullet', 'section'):
374 375 break
375 376 if block['type'] == 'paragraph':
376 377 lines.append(block['lines'])
377 378 notefragment.append(lines)
378 379 continue
379 380 elif block['type'] != 'paragraph':
380 381 raise error.Abort(_('unexpected block type in release notes: '
381 382 '%s') % block['type'])
382 383 if title:
383 384 notefragment.append(block['lines'])
384 385
385 386 return notefragment
386 387
387 388 currentsection = None
388 389 for i, block in enumerate(blocks):
389 390 if block['type'] != 'section':
390 391 continue
391 392
392 393 title = block['lines'][0]
393 394
394 395 # TODO the parsing around paragraphs and bullet points needs some
395 396 # work.
396 397 if block['underline'] == '=': # main section
397 398 name = sections.sectionfromtitle(title)
398 399 if not name:
399 400 raise error.Abort(_('unknown release notes section: %s') %
400 401 title)
401 402
402 403 currentsection = name
403 404 bullet_points = gatherparagraphsbullets(i)
404 405 if bullet_points:
405 406 for para in bullet_points:
406 407 notes.addnontitleditem(currentsection, para)
407 408
408 409 elif block['underline'] == '-': # sub-section
409 410 if title == BULLET_SECTION:
410 411 bullet_points = gatherparagraphsbullets(i)
411 412 for para in bullet_points:
412 413 notes.addnontitleditem(currentsection, para)
413 414 else:
414 415 paragraphs = gatherparagraphsbullets(i, True)
415 416 notes.addtitleditem(currentsection, title, paragraphs)
416 417 else:
417 418 raise error.Abort(_('unsupported section type for %s') % title)
418 419
419 420 return notes
420 421
421 422 def serializenotes(sections, notes):
422 423 """Serialize release notes from parsed fragments and notes.
423 424
424 425 This function essentially takes the output of ``parsenotesfromrevisions()``
425 426 and ``parserelnotesfile()`` and produces output combining the 2.
426 427 """
427 428 lines = []
428 429
429 430 for sectionname, sectiontitle in sections:
430 431 if sectionname not in notes:
431 432 continue
432 433
433 434 lines.append(sectiontitle)
434 435 lines.append('=' * len(sectiontitle))
435 436 lines.append('')
436 437
437 438 # First pass to emit sub-sections.
438 439 for title, paragraphs in notes.titledforsection(sectionname):
439 440 lines.append(title)
440 441 lines.append('-' * len(title))
441 442 lines.append('')
442 443
443 444 wrapper = textwrap.TextWrapper(width=78)
444 445 for i, para in enumerate(paragraphs):
445 446 if i:
446 447 lines.append('')
447 448 lines.extend(wrapper.wrap(' '.join(para)))
448 449
449 450 lines.append('')
450 451
451 452 # Second pass to emit bullet list items.
452 453
453 454 # If the section has titled and non-titled items, we can't
454 455 # simply emit the bullet list because it would appear to come
455 456 # from the last title/section. So, we emit a new sub-section
456 457 # for the non-titled items.
457 458 nontitled = notes.nontitledforsection(sectionname)
458 459 if notes.titledforsection(sectionname) and nontitled:
459 460 # TODO make configurable.
460 461 lines.append(BULLET_SECTION)
461 462 lines.append('-' * len(BULLET_SECTION))
462 463 lines.append('')
463 464
464 465 for paragraphs in nontitled:
465 466 wrapper = textwrap.TextWrapper(initial_indent='* ',
466 467 subsequent_indent=' ',
467 468 width=78)
468 469 lines.extend(wrapper.wrap(' '.join(paragraphs[0])))
469 470
470 471 wrapper = textwrap.TextWrapper(initial_indent=' ',
471 472 subsequent_indent=' ',
472 473 width=78)
473 474 for para in paragraphs[1:]:
474 475 lines.append('')
475 476 lines.extend(wrapper.wrap(' '.join(para)))
476 477
477 478 lines.append('')
478 479
479 480 if lines and lines[-1]:
480 481 lines.append('')
481 482
482 483 return '\n'.join(lines)
483 484
484 485 @command('releasenotes',
485 486 [('r', 'rev', '', _('revisions to process for release notes'), _('REV')),
486 487 ('c', 'check', False, _('checks for validity of admonitions (if any)'),
487 488 _('REV')),
488 489 ('l', 'list', False, _('list the available admonitions with their title'),
489 490 None)],
490 491 _('hg releasenotes [-r REV] [-c] FILE'))
491 492 def releasenotes(ui, repo, file_=None, **opts):
492 493 """parse release notes from commit messages into an output file
493 494
494 495 Given an output file and set of revisions, this command will parse commit
495 496 messages for release notes then add them to the output file.
496 497
497 498 Release notes are defined in commit messages as ReStructuredText
498 499 directives. These have the form::
499 500
500 501 .. directive:: title
501 502
502 503 content
503 504
504 505 Each ``directive`` maps to an output section in a generated release notes
505 506 file, which itself is ReStructuredText. For example, the ``.. feature::``
506 507 directive would map to a ``New Features`` section.
507 508
508 509 Release note directives can be either short-form or long-form. In short-
509 510 form, ``title`` is omitted and the release note is rendered as a bullet
510 511 list. In long form, a sub-section with the title ``title`` is added to the
511 512 section.
512 513
513 514 The ``FILE`` argument controls the output file to write gathered release
514 515 notes to. The format of the file is::
515 516
516 517 Section 1
517 518 =========
518 519
519 520 ...
520 521
521 522 Section 2
522 523 =========
523 524
524 525 ...
525 526
526 527 Only sections with defined release notes are emitted.
527 528
528 529 If a section only has short-form notes, it will consist of bullet list::
529 530
530 531 Section
531 532 =======
532 533
533 534 * Release note 1
534 535 * Release note 2
535 536
536 537 If a section has long-form notes, sub-sections will be emitted::
537 538
538 539 Section
539 540 =======
540 541
541 542 Note 1 Title
542 543 ------------
543 544
544 545 Description of the first long-form note.
545 546
546 547 Note 2 Title
547 548 ------------
548 549
549 550 Description of the second long-form note.
550 551
551 552 If the ``FILE`` argument points to an existing file, that file will be
552 553 parsed for release notes having the format that would be generated by this
553 554 command. The notes from the processed commit messages will be *merged*
554 555 into this parsed set.
555 556
556 557 During release notes merging:
557 558
558 559 * Duplicate items are automatically ignored
559 560 * Items that are different are automatically ignored if the similarity is
560 561 greater than a threshold.
561 562
562 563 This means that the release notes file can be updated independently from
563 564 this command and changes should not be lost when running this command on
564 565 that file. A particular use case for this is to tweak the wording of a
565 566 release note after it has been added to the release notes file.
566 567
567 568 The -c/--check option checks the commit message for invalid admonitions.
568 569
569 570 The -l/--list option, presents the user with a list of existing available
570 571 admonitions along with their title. This also includes the custom
571 572 admonitions (if any).
572 573 """
574
575 opts = pycompat.byteskwargs(opts)
573 576 sections = releasenotessections(ui, repo)
574 577
575 578 listflag = opts.get('list')
576 579
577 580 if listflag and opts.get('rev'):
578 581 raise error.Abort(_('cannot use both \'--list\' and \'--rev\''))
579 582 if listflag and opts.get('check'):
580 583 raise error.Abort(_('cannot use both \'--list\' and \'--check\''))
581 584
582 585 if listflag:
583 586 return _getadmonitionlist(ui, sections)
584 587
585 588 rev = opts.get('rev')
586 589 revs = scmutil.revrange(repo, [rev or 'not public()'])
587 590 if opts.get('check'):
588 591 return checkadmonitions(ui, repo, sections.names(), revs)
589 592
590 593 incoming = parsenotesfromrevisions(repo, sections.names(), revs)
591 594
592 595 if file_ is None:
593 596 ui.pager('releasenotes')
594 597 return ui.write(serializenotes(sections, incoming))
595 598
596 599 try:
597 600 with open(file_, 'rb') as fh:
598 601 notes = parsereleasenotesfile(sections, fh.read())
599 602 except IOError as e:
600 603 if e.errno != errno.ENOENT:
601 604 raise
602 605
603 606 notes = parsedreleasenotes()
604 607
605 608 notes.merge(ui, incoming)
606 609
607 610 with open(file_, 'wb') as fh:
608 611 fh.write(serializenotes(sections, notes))
609 612
610 613 @command('debugparsereleasenotes', norepo=True)
611 614 def debugparsereleasenotes(ui, path, repo=None):
612 615 """parse release notes and print resulting data structure"""
613 616 if path == '-':
614 617 text = sys.stdin.read()
615 618 else:
616 619 with open(path, 'rb') as fh:
617 620 text = fh.read()
618 621
619 622 sections = releasenotessections(ui, repo)
620 623
621 624 notes = parsereleasenotesfile(sections, text)
622 625
623 626 for section in notes:
624 627 ui.write(_('section: %s\n') % section)
625 628 for title, paragraphs in notes.titledforsection(section):
626 629 ui.write(_(' subsection: %s\n') % title)
627 630 for para in paragraphs:
628 631 ui.write(_(' paragraph: %s\n') % ' '.join(para))
629 632
630 633 for paragraphs in notes.nontitledforsection(section):
631 634 ui.write(_(' bullet point:\n'))
632 635 for para in paragraphs:
633 636 ui.write(_(' paragraph: %s\n') % ' '.join(para))
General Comments 0
You need to be logged in to leave comments. Login now