systemd: restart daemon on-failure (#302)
[rsync.git] / md-convert
1 #!/usr/bin/env python3
2
3 # This script transforms markdown files into html and (optionally) nroff. The
4 # output files are written into the current directory named for the input file
5 # without the .md suffix and either the .html suffix or no suffix.
6 #
7 # If the input .md file has a section number at the end of the name (e.g.,
8 # rsync.1.md) a nroff file is also output (PROJ.NUM.md -> PROJ.NUM).
9 #
10 # The markdown input format has one extra extension: if a numbered list starts
11 # at 0, it is turned into a description list. The dl's dt tag is taken from the
12 # contents of the first tag inside the li, which is usually a p, code, or
13 # strong tag.
14 #
15 # The cmarkgfm or commonmark lib is used to transforms the input file into
16 # html.  Then, the html.parser is used as a state machine that lets us tweak
17 # the html and (optionally) output nroff data based on the html tags.
18 #
19 # If the string @USE_GFM_PARSER@ exists in the file, the string is removed and
20 # a github-flavored-markup parser is used to parse the file.
21 #
22 # The man-page .md files also get the vars @VERSION@, @BINDIR@, and @LIBDIR@
23 # substituted.  Some of these values depend on the Makefile $(prefix) (see the
24 # generated Makefile).  If the maintainer wants to build files for /usr/local
25 # while creating release-ready man-page files for /usr, use the environment to
26 # set RSYNC_OVERRIDE_PREFIX=/usr.
27
28 # Copyright (C) 2020 - 2021 Wayne Davison
29 #
30 # This program is freely redistributable.
31
32 import os, sys, re, argparse, subprocess, time
33 from html.parser import HTMLParser
34
35 VALID_PAGES = 'README INSTALL COPYING rsync.1 rrsync.1 rsync-ssl.1 rsyncd.conf.5'.split()
36
37 CONSUMES_TXT = set('h1 h2 h3 p li pre'.split())
38
39 HTML_START = """\
40 <html><head>
41 <title>%TITLE%</title>
42 <meta charset="UTF-8"/>
43 <link href="https://fonts.googleapis.com/css2?family=Roboto&family=Roboto+Mono&display=swap" rel="stylesheet">
44 <style>
45 body {
46   max-width: 50em;
47   margin: auto;
48 }
49 body, b, strong, u {
50   font-family: 'Roboto', sans-serif;
51 }
52 a.tgt { font-face: symbol; font-weight: 400; font-size: 70%; visibility: hidden; text-decoration: none; color: #ddd; padding: 0 4px; border: 0; }
53 a.tgt:after { content: '🔗'; }
54 a.tgt:hover { color: #444; background-color: #eaeaea; }
55 h1:hover > a.tgt, h2:hover > a.tgt, h3:hover > a.tgt, dt:hover > a.tgt { visibility: visible; }
56 code {
57   font-family: 'Roboto Mono', monospace;
58   font-weight: bold;
59   white-space: pre;
60 }
61 pre code {
62   display: block;
63   font-weight: normal;
64 }
65 blockquote pre code {
66   background: #f1f1f1;
67 }
68 dd p:first-of-type {
69   margin-block-start: 0em;
70 }
71 </style>
72 </head><body>
73 """
74
75 TABLE_STYLE = """\
76 table {
77   border-color: grey;
78   border-spacing: 0;
79 }
80 tr {
81   border-top: 1px solid grey;
82 }
83 tr:nth-child(2n) {
84   background-color: #f6f8fa;
85 }
86 th, td {
87   border: 1px solid #dfe2e5;
88   text-align: center;
89   padding-left: 1em;
90   padding-right: 1em;
91 }
92 """
93
94 MAN_HTML_END = """\
95 <div style="float: right"><p><i>%s</i></p></div>
96 """
97
98 HTML_END = """\
99 </body></html>
100 """
101
102 MAN_START = r"""
103 .TH "%s" "%s" "%s" "%s" "User Commands"
104 .\" prefix=%s
105 """.lstrip()
106
107 MAN_END = """\
108 """
109
110 NORM_FONT = ('\1', r"\fP")
111 BOLD_FONT = ('\2', r"\fB")
112 UNDR_FONT = ('\3', r"\fI")
113 NBR_DASH = ('\4', r"\-")
114 NBR_SPACE = ('\xa0', r"\ ")
115
116 FILENAME_RE = re.compile(r'^(?P<fn>(?P<srcdir>.+/)?(?P<name>(?P<prog>[^/]+?)(\.(?P<sect>\d+))?)\.md)$')
117 ASSIGNMENT_RE = re.compile(r'^(\w+)=(.+)')
118 QUOTED_RE = re.compile(r'"(.+?)"')
119 VAR_REF_RE = re.compile(r'\$\{(\w+)\}')
120 VERSION_RE = re.compile(r' (\d[.\d]+)[, ]')
121 BIN_CHARS_RE = re.compile(r'[\1-\7]+')
122 SPACE_DOUBLE_DASH_RE = re.compile(r'\s--(\s)')
123 NON_SPACE_SINGLE_DASH_RE = re.compile(r'(^|\W)-')
124 WHITESPACE_RE = re.compile(r'\s')
125 CODE_BLOCK_RE = re.compile(r'[%s]([^=%s]+)[=%s]' % (BOLD_FONT[0], NORM_FONT[0], NORM_FONT[0]))
126 NBR_DASH_RE = re.compile(r'[%s]' % NBR_DASH[0])
127 INVALID_TARGET_CHARS_RE = re.compile(r'[^-A-Za-z0-9._]')
128 INVALID_START_CHAR_RE = re.compile(r'^([^A-Za-z0-9])')
129 MANIFY_LINESTART_RE = re.compile(r"^(['.])", flags=re.M)
130
131 md_parser = None
132 env_subs = { }
133
134 warning_count = 0
135
136 def main():
137     for mdfn in args.mdfiles:
138         parse_md_file(mdfn)
139
140     if args.test:
141         print("The test was successful.")
142
143
144 def parse_md_file(mdfn):
145     fi = FILENAME_RE.match(mdfn)
146     if not fi:
147         die('Failed to parse a md input file name:', mdfn)
148     fi = argparse.Namespace(**fi.groupdict())
149     fi.want_manpage = not not fi.sect
150     if fi.want_manpage:
151         fi.title = fi.prog + '(' + fi.sect + ') manpage'
152     else:
153         fi.title = fi.prog + ' for rsync'
154
155     if fi.want_manpage:
156         if not env_subs:
157             find_man_substitutions()
158         prog_ver = 'rsync ' + env_subs['VERSION']
159         if fi.prog != 'rsync':
160             prog_ver = fi.prog + ' from ' + prog_ver
161         fi.man_headings = (fi.prog, fi.sect, env_subs['date'], prog_ver, env_subs['prefix'])
162
163     with open(mdfn, 'r', encoding='utf-8') as fh:
164         txt = fh.read()
165
166     use_gfm_parser = '@USE_GFM_PARSER@' in txt
167     if use_gfm_parser:
168         txt = txt.replace('@USE_GFM_PARSER@', '')
169
170     if fi.want_manpage:
171         txt = (txt.replace('@VERSION@', env_subs['VERSION'])
172                   .replace('@BINDIR@', env_subs['bindir'])
173                   .replace('@LIBDIR@', env_subs['libdir']))
174
175     if use_gfm_parser:
176         if not gfm_parser:
177             die('Input file requires cmarkgfm parser:', mdfn)
178         fi.html_in = gfm_parser(txt)
179     else:
180         fi.html_in = md_parser(txt)
181     txt = None
182
183     TransformHtml(fi)
184
185     if args.test:
186         return
187
188     output_list = [ (fi.name + '.html', fi.html_out) ]
189     if fi.want_manpage:
190         output_list += [ (fi.name, fi.man_out) ]
191     for fn, txt in output_list:
192         if args.dest and args.dest != '.':
193             fn = os.path.join(args.dest, fn)
194         if os.path.lexists(fn):
195             os.unlink(fn)
196         print("Wrote:", fn)
197         with open(fn, 'w', encoding='utf-8') as fh:
198             fh.write(txt)
199
200
201 def find_man_substitutions():
202     srcdir = os.path.dirname(sys.argv[0]) + '/'
203     mtime = 0
204
205     git_dir = srcdir + '.git'
206     if os.path.lexists(git_dir):
207         mtime = int(subprocess.check_output(['git', '--git-dir', git_dir, 'log', '-1', '--format=%at']))
208
209     # Allow "prefix" to be overridden via the environment:
210     env_subs['prefix'] = os.environ.get('RSYNC_OVERRIDE_PREFIX', None)
211
212     if args.test:
213         env_subs['VERSION'] = '1.0.0'
214         env_subs['bindir'] = '/usr/bin'
215         env_subs['libdir'] = '/usr/lib/rsync'
216     else:
217         for fn in (srcdir + 'version.h', 'Makefile'):
218             try:
219                 st = os.lstat(fn)
220             except OSError:
221                 die('Failed to find', srcdir + fn)
222             if not mtime:
223                 mtime = st.st_mtime
224
225         with open(srcdir + 'version.h', 'r', encoding='utf-8') as fh:
226             txt = fh.read()
227         m = QUOTED_RE.search(txt)
228         env_subs['VERSION'] = m.group(1)
229
230         with open('Makefile', 'r', encoding='utf-8') as fh:
231             for line in fh:
232                 m = ASSIGNMENT_RE.match(line)
233                 if not m:
234                     continue
235                 var, val = (m.group(1), m.group(2))
236                 if var == 'prefix' and env_subs[var] is not None:
237                     continue
238                 while VAR_REF_RE.search(val):
239                     val = VAR_REF_RE.sub(lambda m: env_subs[m.group(1)], val)
240                 env_subs[var] = val
241                 if var == 'srcdir':
242                     break
243
244     env_subs['date'] = time.strftime('%d %b %Y', time.localtime(mtime))
245
246
247 def html_via_commonmark(txt):
248     return commonmark.HtmlRenderer().render(commonmark.Parser().parse(txt))
249
250
251 class TransformHtml(HTMLParser):
252     def __init__(self, fi):
253         HTMLParser.__init__(self, convert_charrefs=True)
254
255         self.fn = fi.fn
256
257         st = self.state = argparse.Namespace(
258                 list_state = [ ],
259                 p_macro = ".P\n",
260                 at_first_tag_in_li = False,
261                 at_first_tag_in_dd = False,
262                 dt_from = None,
263                 in_pre = False,
264                 in_code = False,
265                 html_out = [ HTML_START.replace('%TITLE%', fi.title) ],
266                 man_out = [ ],
267                 txt = '',
268                 want_manpage = fi.want_manpage,
269                 created_hashtags = set(),
270                 derived_hashtags = set(),
271                 referenced_hashtags = set(),
272                 bad_hashtags = set(),
273                 latest_targets = [ ],
274                 opt_prefix = 'opt',
275                 a_txt_start = None,
276                 target_suf = '',
277                 )
278
279         if st.want_manpage:
280             st.man_out.append(MAN_START % fi.man_headings)
281
282         if '</table>' in fi.html_in:
283             st.html_out[0] = st.html_out[0].replace('</style>', TABLE_STYLE + '</style>')
284
285         self.feed(fi.html_in)
286         fi.html_in = None
287
288         if st.want_manpage:
289             st.html_out.append(MAN_HTML_END % env_subs['date'])
290         st.html_out.append(HTML_END)
291         st.man_out.append(MAN_END)
292
293         fi.html_out = ''.join(st.html_out)
294         st.html_out = None
295
296         fi.man_out = ''.join(st.man_out)
297         st.man_out = None
298
299         for tgt, txt in st.derived_hashtags:
300             derived = txt2target(txt, tgt)
301             if derived not in st.created_hashtags:
302                 txt = BIN_CHARS_RE.sub('', txt.replace(NBR_DASH[0], '-').replace(NBR_SPACE[0], ' '))
303                 warn('Unknown derived hashtag link in', self.fn, 'based on:', (tgt, txt))
304
305         for bad in st.bad_hashtags:
306             if bad in st.created_hashtags:
307                 warn('Missing "#" in hashtag link in', self.fn + ':', bad)
308             else:
309                 warn('Unknown non-hashtag link in', self.fn + ':', bad)
310
311         for bad in st.referenced_hashtags - st.created_hashtags:
312             warn('Unknown hashtag link in', self.fn + ':', '#' + bad)
313
314     def handle_starttag(self, tag, attrs_list):
315         st = self.state
316         if args.debug:
317             self.output_debug('START', (tag, attrs_list))
318         if st.at_first_tag_in_li:
319             if st.list_state[-1] == 'dl':
320                 st.dt_from = tag
321                 if tag == 'p':
322                     tag = 'dt'
323                 else:
324                     st.html_out.append('<dt>')
325             elif tag == 'p':
326                 st.at_first_tag_in_dd = True # Kluge to suppress a .P at the start of an li.
327             st.at_first_tag_in_li = False
328         if tag == 'p':
329             if not st.at_first_tag_in_dd:
330                 st.man_out.append(st.p_macro)
331         elif tag == 'li':
332             st.at_first_tag_in_li = True
333             lstate = st.list_state[-1]
334             if lstate == 'dl':
335                 return
336             if lstate == 'o':
337                 st.man_out.append(".IP o\n")
338             else:
339                 st.man_out.append(".IP " + str(lstate) + ".\n")
340                 st.list_state[-1] += 1
341         elif tag == 'blockquote':
342             st.man_out.append(".RS 4\n")
343         elif tag == 'pre':
344             st.in_pre = True
345             st.man_out.append(st.p_macro + ".nf\n")
346         elif tag == 'code' and not st.in_pre:
347             st.in_code = True
348             st.txt += BOLD_FONT[0]
349         elif tag == 'strong' or tag == 'b':
350             st.txt += BOLD_FONT[0]
351         elif tag == 'em' or  tag == 'i':
352             if st.want_manpage:
353                 tag = 'u' # Change it into underline to be more like the manpage
354                 st.txt += UNDR_FONT[0]
355         elif tag == 'ol':
356             start = 1
357             for var, val in attrs_list:
358                 if var == 'start':
359                     start = int(val) # We only support integers.
360                     break
361             if st.list_state:
362                 st.man_out.append(".RS\n")
363             if start == 0:
364                 tag = 'dl'
365                 attrs_list = [ ]
366                 st.list_state.append('dl')
367             else:
368                 st.list_state.append(start)
369             st.man_out.append(st.p_macro)
370             st.p_macro = ".IP\n"
371         elif tag == 'ul':
372             st.man_out.append(st.p_macro)
373             if st.list_state:
374                 st.man_out.append(".RS\n")
375                 st.p_macro = ".IP\n"
376             st.list_state.append('o')
377         elif tag == 'hr':
378             st.man_out.append(".l\n")
379             st.html_out.append("<hr />")
380             return
381         elif tag == 'a':
382             st.a_href = None
383             for var, val in attrs_list:
384                 if var == 'href':
385                     if val.startswith(('https://', 'http://', 'mailto:', 'ftp:')):
386                         pass # nothing to check
387                     elif '#' in val:
388                         pg, tgt = val.split('#', 2)
389                         if pg and pg not in VALID_PAGES or '#' in tgt:
390                             st.bad_hashtags.add(val)
391                         elif tgt in ('', 'opt', 'dopt'):
392                             st.a_href = val
393                         elif pg == '':
394                             st.referenced_hashtags.add(tgt)
395                             if tgt in st.latest_targets:
396                                 warn('Found link to the current section in', self.fn + ':', val)
397                     elif val not in VALID_PAGES:
398                         st.bad_hashtags.add(val)
399             st.a_txt_start = len(st.txt)
400         st.html_out.append('<' + tag + ''.join(' ' + var + '="' + htmlify(val) + '"' for var, val in attrs_list) + '>')
401         st.at_first_tag_in_dd = False
402
403
404     def handle_endtag(self, tag):
405         st = self.state
406         if args.debug:
407             self.output_debug('END', (tag,))
408         if tag in CONSUMES_TXT or st.dt_from == tag:
409             txt = st.txt.strip()
410             st.txt = ''
411         else:
412             txt = None
413         add_to_txt = None
414         if tag == 'h1':
415             tgt = txt
416             target_suf = ''
417             if tgt.startswith('NEWS for '):
418                 m = VERSION_RE.search(tgt)
419                 if m:
420                     tgt = m.group(1)
421                     st.target_suf = '-' + tgt
422             self.add_targets(tag, tgt)
423         elif tag == 'h2':
424             st.man_out.append(st.p_macro + '.SH "' + manify(txt) + '"\n')
425             self.add_targets(tag, txt, st.target_suf)
426             st.opt_prefix = 'dopt' if txt == 'DAEMON OPTIONS' else 'opt'
427         elif tag == 'h3':
428             st.man_out.append(st.p_macro + '.SS "' + manify(txt) + '"\n')
429             self.add_targets(tag, txt, st.target_suf)
430         elif tag == 'p':
431             if st.dt_from == 'p':
432                 tag = 'dt'
433                 st.man_out.append('.IP "' + manify(txt) + '"\n')
434                 if txt.startswith(BOLD_FONT[0]):
435                     self.add_targets(tag, txt)
436                 st.dt_from = None
437             elif txt != '':
438                 st.man_out.append(manify(txt) + "\n")
439         elif tag == 'li':
440             if st.list_state[-1] == 'dl':
441                 if st.at_first_tag_in_li:
442                     die("Invalid 0. -> td translation")
443                 tag = 'dd'
444             if txt != '':
445                 st.man_out.append(manify(txt) + "\n")
446             st.at_first_tag_in_li = False
447         elif tag == 'blockquote':
448             st.man_out.append(".RE\n")
449         elif tag == 'pre':
450             st.in_pre = False
451             st.man_out.append(manify(txt) + "\n.fi\n")
452         elif (tag == 'code' and not st.in_pre):
453             st.in_code = False
454             add_to_txt = NORM_FONT[0]
455         elif tag == 'strong' or tag == 'b':
456             add_to_txt = NORM_FONT[0]
457         elif tag == 'em' or  tag == 'i':
458             if st.want_manpage:
459                 tag = 'u' # Change it into underline to be more like the manpage
460                 add_to_txt = NORM_FONT[0]
461         elif tag == 'ol' or tag == 'ul':
462             if st.list_state.pop() == 'dl':
463                 tag = 'dl'
464             if st.list_state:
465                 st.man_out.append(".RE\n")
466             else:
467                 st.p_macro = ".P\n"
468             st.at_first_tag_in_dd = False
469         elif tag == 'hr':
470             return
471         elif tag == 'a':
472             if st.a_href:
473                 atxt = st.txt[st.a_txt_start:]
474                 find = 'href="' + st.a_href + '"'
475                 for j in range(len(st.html_out)-1, 0, -1):
476                     if find in st.html_out[j]:
477                         pg, tgt = st.a_href.split('#', 2)
478                         derived = txt2target(atxt, tgt)
479                         if pg == '':
480                             if derived in st.latest_targets:
481                                 warn('Found link to the current section in', self.fn + ':', st.a_href)
482                             st.derived_hashtags.add((tgt, atxt))
483                         st.html_out[j] = st.html_out[j].replace(find, 'href="' + pg + '#' + derived + '"')
484                         break
485                 else:
486                     die('INTERNAL ERROR: failed to find href in html data:', find)
487         st.html_out.append('</' + tag + '>')
488         if add_to_txt:
489             if txt is None:
490                 st.txt += add_to_txt
491             else:
492                 txt += add_to_txt
493         if st.dt_from == tag:
494             st.man_out.append('.IP "' + manify(txt) + '"\n')
495             st.html_out.append('</dt><dd>')
496             st.at_first_tag_in_dd = True
497             st.dt_from = None
498         elif tag == 'dt':
499             st.html_out.append('<dd>')
500             st.at_first_tag_in_dd = True
501
502
503     def handle_data(self, txt):
504         st = self.state
505         if '](' in txt:
506             warn('Malformed link in', self.fn + ':', txt)
507         if args.debug:
508             self.output_debug('DATA', (txt,))
509         if st.in_pre:
510             html = htmlify(txt)
511         else:
512             txt = SPACE_DOUBLE_DASH_RE.sub(NBR_SPACE[0] + r'--\1', txt).replace('--', NBR_DASH[0]*2)
513             txt = NON_SPACE_SINGLE_DASH_RE.sub(r'\1' + NBR_DASH[0], txt)
514             html = htmlify(txt)
515             if st.in_code:
516                 txt = WHITESPACE_RE.sub(NBR_SPACE[0], txt)
517                 html = html.replace(NBR_DASH[0], '-').replace(NBR_SPACE[0], ' ') # <code> is non-breaking in CSS
518         st.html_out.append(html.replace(NBR_SPACE[0], '&nbsp;').replace(NBR_DASH[0], '-&#8288;'))
519         st.txt += txt
520
521
522     def add_targets(self, tag, txt, suf=None):
523         st = self.state
524         tag = '<' + tag + '>'
525         targets = CODE_BLOCK_RE.findall(txt)
526         if not targets:
527             targets = [ txt ]
528         tag_pos = 0
529         for txt in targets:
530             txt = txt2target(txt, st.opt_prefix)
531             if not txt:
532                 continue
533             if suf:
534                 txt += suf
535             if txt in st.created_hashtags:
536                 for j in range(2, 1000):
537                     chk = txt + '-' + str(j)
538                     if chk not in st.created_hashtags:
539                         print('Made link target unique:', chk)
540                         txt = chk
541                         break
542             if tag_pos == 0:
543                 tag_pos -= 1
544                 while st.html_out[tag_pos] != tag:
545                     tag_pos -= 1
546                 st.html_out[tag_pos] = tag[:-1] + ' id="' + txt + '">'
547                 st.html_out.append('<a href="#' + txt + '" class="tgt"></a>')
548                 tag_pos -= 1 # take into account the append
549             else:
550                 st.html_out[tag_pos] = '<span id="' + txt + '"></span>' + st.html_out[tag_pos]
551             st.created_hashtags.add(txt)
552         st.latest_targets = targets
553
554
555     def output_debug(self, event, extra):
556         import pprint
557         st = self.state
558         if args.debug < 2:
559             st = argparse.Namespace(**vars(st))
560             if len(st.html_out) > 2:
561                 st.html_out = ['...'] + st.html_out[-2:]
562             if len(st.man_out) > 2:
563                 st.man_out = ['...'] + st.man_out[-2:]
564         print(event, extra)
565         pprint.PrettyPrinter(indent=2).pprint(vars(st))
566
567
568 def txt2target(txt, opt_prefix):
569     txt = txt.strip().rstrip(':')
570     m = CODE_BLOCK_RE.search(txt)
571     if m:
572         txt = m.group(1)
573     txt = NBR_DASH_RE.sub('-', txt)
574     txt = BIN_CHARS_RE.sub('', txt)
575     txt = INVALID_TARGET_CHARS_RE.sub('_', txt)
576     if opt_prefix and txt.startswith('-'):
577         txt = opt_prefix + txt
578     else:
579         txt = INVALID_START_CHAR_RE.sub(r't\1', txt)
580     return txt
581
582
583 def manify(txt):
584     return MANIFY_LINESTART_RE.sub(r'\&\1', txt.replace('\\', '\\\\')
585             .replace(NBR_SPACE[0], NBR_SPACE[1])
586             .replace(NBR_DASH[0], NBR_DASH[1])
587             .replace(NORM_FONT[0], NORM_FONT[1])
588             .replace(BOLD_FONT[0], BOLD_FONT[1])
589             .replace(UNDR_FONT[0], UNDR_FONT[1]))
590
591
592 def htmlify(txt):
593     return txt.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
594
595
596 def warn(*msg):
597     print(*msg, file=sys.stderr)
598     global warning_count
599     warning_count += 1
600
601
602 def die(*msg):
603     warn(*msg)
604     sys.exit(1)
605
606
607 if __name__ == '__main__':
608     parser = argparse.ArgumentParser(description="Output html and (optionally) nroff for markdown pages.", add_help=False)
609     parser.add_argument('--test', action='store_true', help="Just test the parsing without outputting any files.")
610     parser.add_argument('--dest', metavar='DIR', help="Put files into DIR instead of the current directory.")
611     parser.add_argument('--debug', '-D', action='count', default=0, help='Output copious info on the html parsing. Repeat for even more.')
612     parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
613     parser.add_argument("mdfiles", nargs='+', help="The source .md files to convert.")
614     args = parser.parse_args()
615
616     try:
617         import cmarkgfm
618         md_parser = cmarkgfm.markdown_to_html
619         gfm_parser = cmarkgfm.github_flavored_markdown_to_html
620     except:
621         try:
622             import commonmark
623             md_parser = html_via_commonmark
624         except:
625             die("Failed to find cmarkgfm or commonmark for python3.")
626         gfm_parser = None
627
628     main()
629     if warning_count:
630         sys.exit(1)