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