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