1#! /usr/bin/env python
2# Copyright 2016 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6from __future__ import print_function
7
8import argparse
9import imp
10import os
11import re
12import sys
13import textwrap
14import types
15
16# A markdown code block template: https://goo.gl/9EsyRi
17_CODE_BLOCK_FORMAT = '''```{language}
18{code}
19```
20'''
21
22_DEVIL_ROOT = os.path.abspath(
23    os.path.join(os.path.dirname(__file__), '..', '..'))
24
25
26def md_bold(raw_text):
27  """Returns markdown-formatted bold text."""
28  return '**%s**' % md_escape(raw_text, characters='*')
29
30
31def md_code(raw_text, language):
32  """Returns a markdown-formatted code block in the given language."""
33  return _CODE_BLOCK_FORMAT.format(
34      language=language or '', code=md_escape(raw_text, characters='`'))
35
36
37def md_escape(raw_text, characters='*_'):
38  """Escapes * and _."""
39
40  def escape_char(m):
41    return '\\%s' % m.group(0)
42
43  pattern = '[%s]' % re.escape(characters)
44  return re.sub(pattern, escape_char, raw_text)
45
46
47def md_heading(raw_text, level):
48  """Returns markdown-formatted heading."""
49  adjusted_level = min(max(level, 0), 6)
50  return '%s%s%s' % ('#' * adjusted_level, ' ' if adjusted_level > 0 else '',
51                     raw_text)
52
53
54def md_inline_code(raw_text):
55  """Returns markdown-formatted inline code."""
56  return '`%s`' % md_escape(raw_text, characters='`')
57
58
59def md_italic(raw_text):
60  """Returns markdown-formatted italic text."""
61  return '*%s*' % md_escape(raw_text, characters='*')
62
63
64def md_link(link_text, link_target):
65  """returns a markdown-formatted link."""
66  return '[%s](%s)' % (md_escape(link_text, characters=']'),
67                       md_escape(link_target, characters=')'))
68
69
70class MarkdownHelpFormatter(argparse.HelpFormatter):
71  """A really bare-bones argparse help formatter that generates valid markdown.
72
73  This will generate something like:
74
75  usage
76
77  # **section heading**:
78
79  ## **--argument-one**
80
81  ```
82  argument-one help text
83  ```
84
85  """
86
87  #override
88  def _format_usage(self, usage, actions, groups, prefix):
89    usage_text = super(MarkdownHelpFormatter, self)._format_usage(
90        usage, actions, groups, prefix)
91    return md_code(usage_text, language=None)
92
93  #override
94  def format_help(self):
95    self._root_section.heading = md_heading(self._prog, level=1)
96    return super(MarkdownHelpFormatter, self).format_help()
97
98  #override
99  def start_section(self, heading):
100    super(MarkdownHelpFormatter, self).start_section(
101        md_heading(heading, level=2))
102
103  #override
104  def _format_action(self, action):
105    lines = []
106    action_header = self._format_action_invocation(action)
107    lines.append(md_heading(action_header, level=3))
108    if action.help:
109      lines.append(md_code(self._expand_help(action), language=None))
110    lines.extend(['', ''])
111    return '\n'.join(lines)
112
113
114class MarkdownHelpAction(argparse.Action):
115  def __init__(self,
116               option_strings,
117               dest=argparse.SUPPRESS,
118               default=argparse.SUPPRESS,
119               **kwargs):
120    super(MarkdownHelpAction, self).__init__(
121        option_strings=option_strings,
122        dest=dest,
123        default=default,
124        nargs=0,
125        **kwargs)
126
127  def __call__(self, parser, namespace, values, option_string=None):
128    parser.formatter_class = MarkdownHelpFormatter
129    parser.print_help()
130    parser.exit()
131
132
133def add_md_help_argument(parser):
134  """Adds --md-help to the given argparse.ArgumentParser.
135
136  Running a script with --md-help will print the help text for that script
137  as valid markdown.
138
139  Args:
140    parser: The ArgumentParser to which --md-help should be added.
141  """
142  parser.add_argument(
143      '--md-help',
144      action=MarkdownHelpAction,
145      help='print Markdown-formatted help text and exit.')
146
147
148def load_module_from_path(module_path):
149  """Load a module given only the path name.
150
151  Also loads package modules as necessary.
152
153  Args:
154    module_path: An absolute path to a python module.
155  Returns:
156    The module object for the given path.
157  """
158  module_names = [os.path.splitext(os.path.basename(module_path))[0]]
159  d = os.path.dirname(module_path)
160
161  while os.path.exists(os.path.join(d, '__init__.py')):
162    module_names.append(os.path.basename(d))
163    d = os.path.dirname(d)
164
165  d = [d]
166
167  module = None
168  full_module_name = ''
169  for package_name in reversed(module_names):
170    if module:
171      d = module.__path__
172      full_module_name += '.'
173    r = imp.find_module(package_name, d)
174    full_module_name += package_name
175    module = imp.load_module(full_module_name, *r)
176  return module
177
178
179def md_module(module_obj, module_link=None):
180  """Write markdown documentation for a module.
181
182  Documents public classes and functions.
183
184  Args:
185    module_obj: a module object that should be documented.
186  Returns:
187    A list of markdown-formatted lines.
188  """
189
190  def should_doc(name):
191    return (not isinstance(module_obj.__dict__[name], types.ModuleType)
192            and not name.startswith('_'))
193
194  stuff_to_doc = [
195      obj for name, obj in sorted(module_obj.__dict__.iteritems())
196      if should_doc(name)
197  ]
198
199  classes_to_doc = []
200  functions_to_doc = []
201
202  for s in stuff_to_doc:
203    if isinstance(s, types.TypeType):
204      classes_to_doc.append(s)
205    elif isinstance(s, types.FunctionType):
206      functions_to_doc.append(s)
207
208  heading_text = module_obj.__name__
209  if module_link:
210    heading_text = md_link(heading_text, module_link)
211
212  content = [
213      md_heading(heading_text, level=1),
214      '',
215      md_italic('This page was autogenerated. '
216                'Run `devil/bin/generate_md_docs` to update'),
217      '',
218  ]
219
220  for c in classes_to_doc:
221    content += md_class(c)
222  for f in functions_to_doc:
223    content += md_function(f)
224
225  print('\n'.join(content))
226
227  return 0
228
229
230def md_class(class_obj):
231  """Write markdown documentation for a class.
232
233  Documents public methods. Does not currently document subclasses.
234
235  Args:
236    class_obj: a types.TypeType object for the class that should be
237      documented.
238  Returns:
239    A list of markdown-formatted lines.
240  """
241  content = [md_heading(md_escape(class_obj.__name__), level=2)]
242  content.append('')
243  if class_obj.__doc__:
244    content.extend(md_docstring(class_obj.__doc__))
245
246  def should_doc(name, obj):
247    return (isinstance(obj, types.FunctionType)
248            and (name.startswith('__') or not name.startswith('_')))
249
250  methods_to_doc = [
251      obj for name, obj in sorted(class_obj.__dict__.iteritems())
252      if should_doc(name, obj)
253  ]
254
255  for m in methods_to_doc:
256    content.extend(md_function(m, class_obj=class_obj))
257
258  return content
259
260
261def md_docstring(docstring):
262  """Write a markdown-formatted docstring.
263
264  Returns:
265    A list of markdown-formatted lines.
266  """
267  content = []
268  lines = textwrap.dedent(docstring).splitlines()
269  content.append(md_escape(lines[0]))
270  lines = lines[1:]
271  while lines and (not lines[0] or lines[0].isspace()):
272    lines = lines[1:]
273
274  if not all(l.isspace() for l in lines):
275    content.append(md_code('\n'.join(lines), language=None))
276    content.append('')
277  return content
278
279
280def md_function(func_obj, class_obj=None):
281  """Write markdown documentation for a function.
282
283  Args:
284    func_obj: a types.FunctionType object for the function that should be
285      documented.
286  Returns:
287    A list of markdown-formatted lines.
288  """
289  if class_obj:
290    heading_text = '%s.%s' % (class_obj.__name__, func_obj.__name__)
291  else:
292    heading_text = func_obj.__name__
293  content = [md_heading(md_escape(heading_text), level=3)]
294  content.append('')
295
296  if func_obj.__doc__:
297    content.extend(md_docstring(func_obj.__doc__))
298
299  return content
300
301
302def main(raw_args):
303  """Write markdown documentation for the module at the provided path.
304
305  Args:
306    raw_args: the raw command-line args. Usually sys.argv[1:].
307  Returns:
308    An integer exit code. 0 for success, non-zero for failure.
309  """
310  parser = argparse.ArgumentParser()
311  parser.add_argument('--module-link')
312  parser.add_argument('module_path', type=os.path.realpath)
313  args = parser.parse_args(raw_args)
314
315  return md_module(
316      load_module_from_path(args.module_path), module_link=args.module_link)
317
318
319if __name__ == '__main__':
320  sys.exit(main(sys.argv[1:]))
321