Style checker for sphinx (or other) rst documentation.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

main.py 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. # Copyright (C) 2014 Ivan Melnikov <iv at altlinux dot org>
  2. #
  3. # Author: Joshua Harlow <harlowja@yahoo-inc.com>
  4. #
  5. # Licensed under the Apache License, Version 2.0 (the "License"); you may
  6. # not use this file except in compliance with the License. You may obtain
  7. # a copy of the License at
  8. #
  9. # http://www.apache.org/licenses/LICENSE-2.0
  10. #
  11. # Unless required by applicable law or agreed to in writing, software
  12. # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  13. # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  14. # License for the specific language governing permissions and limitations
  15. # under the License.
  16. """Check documentation for simple style requirements.
  17. What is checked:
  18. - invalid rst format - D000
  19. - lines should not be longer than 79 characters - D001
  20. - RST exception: line with no whitespace except in the beginning
  21. - RST exception: lines with http or https urls
  22. - RST exception: literal blocks
  23. - RST exception: rst target directives
  24. - no trailing whitespace - D002
  25. - no tabulation for indentation - D003
  26. - no carriage returns (use unix newlines) - D004
  27. """
  28. import argparse
  29. import collections
  30. import logging
  31. import os
  32. import sys
  33. if __name__ == '__main__':
  34. # Only useful for when running directly (for dev/debugging).
  35. sys.path.insert(0, os.path.abspath(os.getcwd()))
  36. sys.path.insert(0, os.path.abspath(os.path.join(os.pardir, os.getcwd())))
  37. import six
  38. from six.moves import configparser
  39. from stevedore import extension
  40. from doc8 import checks
  41. from doc8 import parser as file_parser
  42. from doc8 import utils
  43. from doc8 import version
  44. FILE_PATTERNS = ['.rst', '.txt']
  45. MAX_LINE_LENGTH = 79
  46. CONFIG_FILENAMES = [
  47. "doc8.ini",
  48. "tox.ini",
  49. "pep8.ini",
  50. "setup.cfg",
  51. ]
  52. def split_set_type(text):
  53. return set([i.strip() for i in text.split(",") if i.strip()])
  54. def merge_sets(sets):
  55. m = set()
  56. for s in sets:
  57. m.update(s)
  58. return m
  59. def extract_config(args):
  60. parser = configparser.RawConfigParser()
  61. read_files = []
  62. if args['config']:
  63. for fn in args['config']:
  64. with open(fn, 'r') as fh:
  65. parser.readfp(fh, filename=fn)
  66. read_files.append(fn)
  67. else:
  68. read_files.extend(parser.read(CONFIG_FILENAMES))
  69. if not read_files:
  70. return {}
  71. cfg = {}
  72. try:
  73. cfg['max_line_length'] = parser.getint("doc8", "max-line-length")
  74. except (configparser.NoSectionError, configparser.NoOptionError):
  75. pass
  76. try:
  77. cfg['ignore'] = split_set_type(parser.get("doc8", "ignore"))
  78. except (configparser.NoSectionError, configparser.NoOptionError):
  79. pass
  80. try:
  81. cfg['ignore_path'] = split_set_type(parser.get("doc8",
  82. "ignore_path"))
  83. except (configparser.NoSectionError, configparser.NoOptionError):
  84. pass
  85. try:
  86. cfg['allow_long_titles'] = parser.getboolean("doc8",
  87. "allow-long-titles")
  88. except (configparser.NoSectionError, configparser.NoOptionError):
  89. pass
  90. try:
  91. cfg['sphinx'] = parser.getboolean("doc8", "sphinx")
  92. except (configparser.NoSectionError, configparser.NoOptionError):
  93. pass
  94. try:
  95. cfg['verbose'] = parser.getboolean("doc8", "verbose")
  96. except (configparser.NoSectionError, configparser.NoOptionError):
  97. pass
  98. try:
  99. extensions = parser.get("doc8", "extensions")
  100. extensions = extensions.split(",")
  101. extensions = [s.strip() for s in extensions if s.strip()]
  102. if extensions:
  103. cfg['extension'] = extensions
  104. except (configparser.NoSectionError, configparser.NoOptionError):
  105. pass
  106. return cfg
  107. def fetch_checks(cfg):
  108. base = [
  109. checks.CheckValidity(cfg),
  110. checks.CheckTrailingWhitespace(cfg),
  111. checks.CheckIndentationNoTab(cfg),
  112. checks.CheckCarriageReturn(cfg),
  113. checks.CheckMaxLineLength(cfg),
  114. ]
  115. mgr = extension.ExtensionManager(
  116. namespace='doc8.extension.check',
  117. invoke_on_load=True,
  118. invoke_args=(cfg.copy(),),
  119. )
  120. addons = []
  121. for e in mgr:
  122. addons.append(e.obj)
  123. return base + addons
  124. def setup_logging(verbose):
  125. if verbose:
  126. level = logging.DEBUG
  127. else:
  128. level = logging.ERROR
  129. logging.basicConfig(level=level,
  130. format='%(levelname)s: %(message)s', stream=sys.stdout)
  131. def main():
  132. parser = argparse.ArgumentParser(
  133. prog='doc8',
  134. description=__doc__,
  135. formatter_class=argparse.RawDescriptionHelpFormatter)
  136. default_configs = ", ".join(CONFIG_FILENAMES)
  137. parser.add_argument("paths", metavar='path', type=str, nargs='*',
  138. help=("Path to scan for doc files"
  139. " (default: current directory)."),
  140. default=[os.getcwd()])
  141. parser.add_argument("--config", metavar='path', action="append",
  142. help="User config file location"
  143. " (default: %s)." % default_configs,
  144. default=[])
  145. parser.add_argument("--allow-long-titles", action="store_true",
  146. help="Allow long section titles (default: False).",
  147. default=False)
  148. parser.add_argument("--ignore", action="append", metavar="code",
  149. help="Ignore the given error code(s).",
  150. type=split_set_type,
  151. default=[])
  152. parser.add_argument("--no-sphinx", action="store_false",
  153. help="Do not ignore sphinx specific false positives.",
  154. default=True, dest='sphinx')
  155. parser.add_argument("--ignore-path", action="append", default=[],
  156. help="Ignore the given directory or file (globs"
  157. " are supported).", metavar='path')
  158. parser.add_argument("--max-line-length", action="store", metavar="int",
  159. type=int,
  160. help="Maximum allowed line"
  161. " length (default: %s)." % MAX_LINE_LENGTH,
  162. default=MAX_LINE_LENGTH)
  163. parser.add_argument("-e", "--extension", action="append",
  164. metavar="extension",
  165. help="Check file extensions of the given type"
  166. " (default: %s)." % ", ".join(FILE_PATTERNS),
  167. default=list(FILE_PATTERNS))
  168. parser.add_argument("-v", "--verbose", dest="verbose", action='store_true',
  169. help="Run in verbose mode.", default=False)
  170. parser.add_argument("--version", dest="version", action='store_true',
  171. help="Show the version and exit.", default=False)
  172. args = vars(parser.parse_args())
  173. if args.get('version'):
  174. print(version.version_string())
  175. return 0
  176. args['ignore'] = merge_sets(args['ignore'])
  177. cfg = extract_config(args)
  178. args['ignore'].update(cfg.pop("ignore", set()))
  179. if 'sphinx' in cfg:
  180. args['sphinx'] = cfg.pop("sphinx")
  181. args['extension'].extend(cfg.pop('extension', []))
  182. args['ignore_path'].extend(cfg.pop('ignore_path', []))
  183. args.update(cfg)
  184. setup_logging(args.get('verbose'))
  185. print("Scanning...")
  186. files = collections.deque()
  187. ignored_paths = args.pop('ignore_path')
  188. files_ignored = 0
  189. files_selected = 0
  190. file_iter = utils.find_files(args.pop('paths', []),
  191. args.pop('extension', []), ignored_paths)
  192. for filename, ignoreable in file_iter:
  193. if ignoreable:
  194. files_ignored += 1
  195. if args.get('verbose'):
  196. print(" Ignoring '%s'" % (filename))
  197. else:
  198. files_selected += 1
  199. files.append(file_parser.parse(filename))
  200. if args.get('verbose'):
  201. print(" Selecting '%s'" % (filename))
  202. ignoreables = frozenset(args.pop('ignore', []))
  203. error_counts = {}
  204. while files:
  205. f = files.popleft()
  206. if args.get('verbose'):
  207. print("Validating %s" % f)
  208. for c in fetch_checks(args):
  209. try:
  210. # http://legacy.python.org/dev/peps/pep-3155/
  211. check_name = c.__class__.__qualname__
  212. except AttributeError:
  213. check_name = ".".join([c.__class__.__module__,
  214. c.__class__.__name__])
  215. error_counts.setdefault(check_name, 0)
  216. try:
  217. extension_matcher = c.EXT_MATCHER
  218. except AttributeError:
  219. pass
  220. else:
  221. if not extension_matcher.match(f.extension):
  222. if args.get('verbose'):
  223. print(" Skipping check '%s' since it does not"
  224. " understand parsing a file with extension '%s'"
  225. % (check_name, f.extension))
  226. continue
  227. try:
  228. reports = set(c.REPORTS)
  229. except AttributeError:
  230. pass
  231. else:
  232. reports = reports - ignoreables
  233. if not reports:
  234. if args.get('verbose'):
  235. print(" Skipping check '%s', determined to only"
  236. " check ignoreable codes" % check_name)
  237. continue
  238. if args.get('verbose'):
  239. print(" Running check '%s'" % check_name)
  240. if isinstance(c, checks.ContentCheck):
  241. for line_num, code, message in c.report_iter(f):
  242. if code in ignoreables:
  243. continue
  244. if args.get('verbose'):
  245. print(' - %s:%s: %s %s'
  246. % (f.filename, line_num, code, message))
  247. else:
  248. print('%s:%s: %s %s'
  249. % (f.filename, line_num, code, message))
  250. error_counts[check_name] += 1
  251. elif isinstance(c, checks.LineCheck):
  252. for line_num, line in enumerate(f.lines_iter(), 1):
  253. for code, message in c.report_iter(line):
  254. if code in ignoreables:
  255. continue
  256. if args.get('verbose'):
  257. print(' - %s:%s: %s %s'
  258. % (f.filename, line_num, code, message))
  259. else:
  260. print('%s:%s: %s %s'
  261. % (f.filename, line_num, code, message))
  262. error_counts[check_name] += 1
  263. else:
  264. raise TypeError("Unknown check type: %s, %s"
  265. % (type(c), c))
  266. total_errors = sum(six.itervalues(error_counts))
  267. print("=" * 8)
  268. print("Total files scanned = %s" % (files_selected))
  269. print("Total files ignored = %s" % (files_ignored))
  270. print("Total accumulated errors = %s" % total_errors)
  271. if error_counts:
  272. print("Detailed error counts:")
  273. for check_name in sorted(six.iterkeys(error_counts)):
  274. check_errors = error_counts[check_name]
  275. print(" - %s = %s" % (check_name, check_errors))
  276. if total_errors:
  277. return 1
  278. else:
  279. return 0
  280. if __name__ == "__main__":
  281. sys.exit(main())