2 # Copyright (c) 2014-2019, The Tor Project, Inc.
3 # See LICENSE for licensing information
5 # This script reformats a section of the changelog to wrap everything to
6 # the right width and put blank lines in the right places. Eventually,
7 # it might include a linter.
9 # To run it, pipe a section of the changelog (starting with "Changes
10 # in Tor 0.x.y.z-alpha" through the script.)
12 # Future imports for Python 2.7, mandatory in 3.0
13 from __future__
import division
14 from __future__
import print_function
15 from __future__
import unicode_literals
22 # ==============================
23 # Oh, look! It's a cruddy approximation to Knuth's elegant text wrapping
24 # algorithm, with totally ad hoc parameters!
26 # We're trying to minimize:
27 # The total of the cubes of ragged space on underflowed intermediate lines,
29 # 100 * the fourth power of overflowed characters
31 # .1 * a bit more than the cube of ragged space on the last line.
33 # OPENPAREN_PENALTY for each line that starts with (
35 # We use an obvious dynamic programming algorithm to sorta approximate this.
36 # It's not coded right or optimally, but it's fast enough for changelogs
38 # (Code found in an old directory of mine, lightly cleaned. -NM)
46 LASTLINE_UNDERFLOW_EXPONENT
= 1
47 LASTLINE_UNDERFLOW_PENALTY
= 1
49 UNDERFLOW_EXPONENT
= 3
53 OVERFLOW_PENALTY
= 2000
55 ORPHAN_PENALTY
= 10000
57 OPENPAREN_PENALTY
= 200
59 def generate_wrapping(words
, divisions
):
65 line
= " ".join(w
).replace("\xff ","-").replace("\xff","-")
66 lines
.append(line
.strip())
69 def wrapping_quality(words
, divisions
, width1
, width2
):
72 lines
= generate_wrapping(words
, divisions
)
81 total
+= OPENPAREN_PENALTY
84 total
+= OVERFLOW_PENALTY
* (
85 (length
- width
) ** OVERFLOW_EXPONENT
)
88 e
,p
= (LASTLINE_UNDERFLOW_EXPONENT
, LASTLINE_UNDERFLOW_PENALTY
)
90 total
+= ORPHAN_PENALTY
92 e
,p
= (UNDERFLOW_EXPONENT
, UNDERFLOW_PENALTY
)
94 total
+= p
* ((width
- length
) ** e
)
98 def wrap_graf(words
, prefix_len1
=0, prefix_len2
=0, width
=72):
99 wrapping_after
= [ (0,), ]
101 w1
= width
- prefix_len1
102 w2
= width
- prefix_len2
104 for i
in range(1, len(words
)+1):
108 t
= wrapping_after
[j
]
111 wq1
= wrapping_quality(words
, t1
, w1
, w2
)
112 wq2
= wrapping_quality(words
, t2
, w1
, w2
)
120 wrapping_after
.append( best_so_far
)
122 lines
= generate_wrapping(words
, wrapping_after
[-1])
126 def hyphenatable(word
):
130 if re
.match(r
'^[^\d\-]\D*-', word
):
131 stripped
= re
.sub(r
'^\W+','',word
)
132 stripped
= re
.sub(r
'\W+$','',word
)
133 return stripped
not in NO_HYPHENATE
137 def split_paragraph(s
):
138 "Split paragraph into words; tuned for Tor."
141 for word
in s
.split():
142 if hyphenatable(word
):
144 a
,word
= word
.split("-",1)
149 def fill(text
, width
, initial_indent
, subsequent_indent
):
150 words
= split_paragraph(text
)
151 lines
= wrap_graf(words
, len(initial_indent
), len(subsequent_indent
),
153 res
= [ initial_indent
, lines
[0], "\n" ]
154 for line
in lines
[1:]:
155 res
.append(subsequent_indent
)
160 # ==============================
172 def head_parser(line
):
173 if re
.match(r
'^Changes in', line
):
175 elif re
.match(r
'^[A-Za-z]', line
):
177 elif re
.match(r
'^ o ', line
):
179 elif re
.match(r
'^\s*$', line
):
184 def body_parser(line
):
185 if re
.match(r
'^ o ', line
):
187 elif re
.match(r
'^ -',line
):
189 elif re
.match(r
'^ \S', line
):
191 elif re
.match(r
'^\s*$', line
):
193 elif re
.match(r
'^Changes in', line
):
195 elif re
.match(r
'^\s+\S', line
):
198 print("Weird line %r"%line
, file=sys
.stderr
)
200 def clean_head(head
):
204 m
= re
.match(r
'^ +o (.*)', s
)
206 print("Can't score %r"%s, file=sys
.stderr
)
208 lw
= m
.group(1).lower()
209 if lw
.startswith("security") and "feature" not in lw
:
211 elif lw
.startswith("deprecated version"):
213 elif lw
.startswith("directory auth"):
215 elif (('new' in lw
and 'requirement' in lw
) or
216 ('new' in lw
and 'dependenc' in lw
) or
217 ('build' in lw
and 'requirement' in lw
) or
218 ('removed' in lw
and 'platform' in lw
)):
220 elif lw
.startswith("major feature"):
222 elif lw
.startswith("major bug"):
224 elif lw
.startswith("major"):
226 elif lw
.startswith("minor feature"):
228 elif lw
.startswith("minor bug"):
230 elif lw
.startswith("minor"):
246 class ChangeLog(object):
247 def __init__(self
, wrapText
=True, blogOrder
=True, drupalBreak
=False):
253 self
.cursection
= None
255 self
.wrapText
= wrapText
256 self
.blogOrder
= blogOrder
257 self
.drupalBreak
= drupalBreak
259 def addLine(self
, tp
, line
):
262 if tp
== TP_MAINHEAD
:
263 assert not self
.mainhead
266 elif tp
== TP_PREHEAD
:
267 self
.prehead
.append(line
)
269 elif tp
== TP_HEADTEXT
:
270 if self
.curgraf
is None:
272 self
.headtext
.append(self
.curgraf
)
273 self
.curgraf
.append(line
)
278 elif tp
== TP_SECHEAD
:
279 self
.cursection
= [ self
.lineno
, line
, [] ]
280 self
.sections
.append(self
.cursection
)
282 elif tp
== TP_ITEMFIRST
:
283 item
= ( self
.lineno
, [ [line
] ])
284 self
.curgraf
= item
[1][0]
285 self
.cursection
[2].append(item
)
287 elif tp
== TP_ITEMBODY
:
288 if self
.curgraf
is None:
290 self
.cursection
[2][-1][1].append(self
.curgraf
)
291 self
.curgraf
.append(line
)
294 assert False # This should be unreachable.
296 def lint_head(self
, line
, head
):
297 m
= re
.match(r
'^ *o ([^\(]+)((?:\([^\)]+\))?):', head
)
299 print("Weird header format on line %s"%line
, file=sys
.stderr
)
301 def lint_item(self
, line
, grafs
, head_type
):
306 for sec_line
, sec_head
, items
in self
.sections
:
307 head_type
= self
.lint_head(sec_line
, sec_head
)
308 for item_line
, grafs
in items
:
309 self
.lint_item(item_line
, grafs
, head_type
)
311 def dumpGraf(self
,par
,indent1
,indent2
=-1):
312 if not self
.wrapText
:
319 text
= " ".join(re
.sub(r
'\s+', ' ', line
.strip()) for line
in par
)
321 sys
.stdout
.write(fill(text
,
323 initial_indent
=" "*indent1
,
324 subsequent_indent
=" "*indent2
))
326 def dumpPreheader(self
, graf
):
327 self
.dumpGraf(graf
, 0)
330 def dumpMainhead(self
, head
):
333 def dumpHeadGraf(self
, graf
):
334 self
.dumpGraf(graf
, 2)
337 def dumpSectionHeader(self
, header
):
340 def dumpStartOfSections(self
):
343 def dumpEndOfSections(self
):
346 def dumpEndOfSection(self
):
349 def dumpEndOfChangelog(self
):
352 def dumpDrupalBreak(self
):
355 def dumpItem(self
, grafs
):
356 self
.dumpGraf(grafs
[0],4,6)
357 for par
in grafs
[1:]:
359 self
.dumpGraf(par
,6,6)
361 def collateAndSortSections(self
):
364 for _
, head
, items
in self
.sections
:
365 head
= clean_head(head
)
367 s
= sectionsByHead
[head
]
369 s
= sectionsByHead
[head
] = []
370 heads
.append( (head_score(head
), head
.lower(), head
, s
) )
375 self
.sections
= [ (0, head
, items
) for _1
,_2
,head
,items
in heads
]
379 self
.dumpPreheader(self
.prehead
)
381 if not self
.blogOrder
:
382 self
.dumpMainhead(self
.mainhead
)
384 for par
in self
.headtext
:
385 self
.dumpHeadGraf(par
)
388 self
.dumpMainhead(self
.mainhead
)
390 drupalBreakAfter
= None
391 if self
.drupalBreak
and len(self
.sections
) > 4:
392 drupalBreakAfter
= self
.sections
[1][2]
394 self
.dumpStartOfSections()
395 for _
,head
,items
in self
.sections
:
396 if not head
.endswith(':'):
397 print("adding : to %r"%head
, file=sys
.stderr
)
399 self
.dumpSectionHeader(head
)
400 for _
,grafs
in items
:
402 self
.dumpEndOfSection()
403 if items
is drupalBreakAfter
:
404 self
.dumpDrupalBreak()
405 self
.dumpEndOfSections()
406 self
.dumpEndOfChangelog()
408 # Map from issue prefix to pair of (visible prefix, url prefix)
410 "" : ( "", "tpo/core/tor" ),
411 "tor#" : ( "", "tpo/core/tor" ),
412 "chutney#" : ( "chutney#", "tpo/core/chutney" ),
413 "torspec#" : ( "torspec#", "tpo/core/torspec" ),
414 "trunnel#" : ( "trunnel#", "tpo/core/trunnel" ),
415 "torsocks#" : ( "torsocks#", "tpo/core/torsocks"),
418 # Let's turn bugs to html.
419 BUG_PAT
= re
.compile('(bug|ticket|issue|feature)\s+([\w/]+#)?(\d{4,6})', re
.I
)
422 prefix
= m
.group(2) or ""
425 disp_prefix
, url_prefix
= ISSUE_PREFIX_MAP
[prefix
]
427 print("Can't figure out URL for {}{}".format(prefix
,bugno
),
429 return "{} {}{}".format(kind
, prefix
, bugno
)
431 return "{} <a href='https://bugs.torproject.org/{}/{}'>{}{}</a>".format(
432 kind
, url_prefix
, bugno
, disp_prefix
, bugno
)
434 class HTMLChangeLog(ChangeLog
):
435 def __init__(self
, *args
, **kwargs
):
436 ChangeLog
.__init
__(self
, *args
, **kwargs
)
438 def htmlText(self
, graf
):
441 line
= line
.rstrip().replace("&","&")
442 line
= line
.rstrip().replace("<","<").replace(">",">")
443 output
.append(line
.strip())
444 output
= " ".join(output
)
445 output
= BUG_PAT
.sub(bug_html
, output
)
446 sys
.stdout
.write(output
)
448 def htmlPar(self
, graf
):
449 sys
.stdout
.write("<p>")
451 sys
.stdout
.write("</p>\n")
453 def dumpPreheader(self
, graf
):
456 def dumpMainhead(self
, head
):
457 sys
.stdout
.write("<h2>%s</h2>"%head
)
459 def dumpHeadGraf(self
, graf
):
462 def dumpSectionHeader(self
, header
):
463 header
= header
.replace(" o ", "", 1).lstrip()
464 sys
.stdout
.write(" <li>%s\n"%header
)
465 sys
.stdout
.write(" <ul>\n")
467 def dumpEndOfSection(self
):
468 sys
.stdout
.write(" </ul>\n\n")
470 def dumpEndOfChangelog(self
):
473 def dumpStartOfSections(self
):
476 def dumpEndOfSections(self
):
479 def dumpDrupalBreak(self
):
481 print("<p> </p>")
482 print("\n<!--break-->\n\n")
485 def dumpItem(self
, grafs
):
486 grafs
[0][0] = grafs
[0][0].replace(" - ", "", 1).lstrip()
487 sys
.stdout
.write(" <li>")
492 self
.htmlText(grafs
[0])
495 op
= optparse
.OptionParser(usage
="usage: %prog [options] [filename]")
496 op
.add_option('-W', '--no-wrap', action
='store_false',
497 dest
='wrapText', default
=True,
498 help='Do not re-wrap paragraphs')
499 op
.add_option('-S', '--no-sort', action
='store_false',
500 dest
='sort', default
=True,
501 help='Do not sort or collate sections')
502 op
.add_option('-o', '--output', dest
='output',
503 default
='-', metavar
='FILE', help="write output to FILE")
504 op
.add_option('-H', '--html', action
='store_true',
505 dest
='html', default
=False,
506 help="generate an HTML fragment")
507 op
.add_option('-1', '--first', action
='store_true',
508 dest
='firstOnly', default
=False,
509 help="write only the first section")
510 op
.add_option('-b', '--blog-header', action
='store_true',
511 dest
='blogOrder', default
=False,
512 help="Write the header in blog order")
513 op
.add_option('-B', '--blog', action
='store_true',
514 dest
='blogFormat', default
=False,
515 help="Set all other options as appropriate for a blog post")
516 op
.add_option('--inplace', action
='store_true',
517 dest
='inplace', default
=False,
518 help="Alter the ChangeLog in place")
519 op
.add_option('--drupal-break', action
='store_true',
520 dest
='drupalBreak', default
=False,
521 help='Insert a drupal-friendly <!--break--> as needed')
523 options
,args
= op
.parse_args()
525 if options
.blogFormat
:
526 options
.blogOrder
= True
529 options
.wrapText
= False
530 options
.firstOnly
= True
531 options
.drupalBreak
= True
534 op
.error("Too many arguments")
541 assert options
.output
== '-'
542 options
.output
= fname
545 sys
.stdin
= open(fname
, 'r')
550 ChangeLogClass
= HTMLChangeLog
552 ChangeLogClass
= ChangeLog
554 CL
= ChangeLogClass(wrapText
=options
.wrapText
,
555 blogOrder
=options
.blogOrder
,
556 drupalBreak
=options
.drupalBreak
)
559 for line
in sys
.stdin
:
573 if options
.output
!= '-':
574 fname_new
= options
.output
+".new"
575 fname_out
= options
.output
576 sys
.stdout
= open(fname_new
, 'w')
578 fname_new
= fname_out
= None
581 CL
.collateAndSortSections()
585 if options
.firstOnly
:
588 if nextline
is not None:
591 for line
in sys
.stdin
:
592 sys
.stdout
.write(line
)
594 if fname_new
is not None:
595 os
.rename(fname_new
, fname_out
)