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