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 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  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. - no newline at end of file - D005
  28. """
  29. import argparse
  30. import collections
  31. import logging
  32. import os
  33. import sys
  34. if __name__ == '__main__':
  35. # Only useful for when running directly (for dev/debugging).
  36. sys.path.insert(0, os.path.abspath(os.getcwd()))
  37. sys.path.insert(0, os.path.abspath(os.path.join(os.pardir, os.getcwd())))
  38. import six
  39. from six.moves import configparser
  40. from stevedore import extension
  41. from doc8 import checks
  42. from doc8 import parser as file_parser
  43. from doc8 import utils
  44. from doc8 import version
  45. FILE_PATTERNS = ['.rst', '.txt']
  46. MAX_LINE_LENGTH = 79
  47. CONFIG_FILENAMES = [
  48. "doc8.ini",
  49. "tox.ini",
  50. "pep8.ini",
  51. "setup.cfg",
  52. ]
  53. def split_set_type(text):
  54. return set([i.strip() for i in text.split(",") if i.strip()])
  55. def merge_sets(sets):
  56. m = set()
  57. for s in sets:
  58. m.update(s)
  59. return m
  60. def extract_config(args):
  61. parser = configparser.RawConfigParser()
  62. read_files = []
  63. if args['config']:
  64. for fn in args['config']:
  65. with open(fn, 'r') as fh:
  66. parser.readfp(fh, filename=fn)
  67. read_files.append(fn)
  68. else:
  69. read_files.extend(parser.read(CONFIG_FILENAMES))
  70. if not read_files:
  71. return {}
  72. cfg = {}
  73. try:
  74. cfg['max_line_length'] = parser.getint("doc8", "max-line-length")
  75. except (configparser.NoSectionError, configparser.NoOptionError):
  76. pass
  77. try:
  78. cfg['ignore'] = split_set_type(parser.get("doc8", "ignore"))
  79. except (configparser.NoSectionError, configparser.NoOptionError):
  80. pass
  81. try:
  82. cfg['ignore_path'] = split_set_type(parser.get("doc8",
  83. "ignore_path"))
  84. except (configparser.NoSectionError, configparser.NoOptionError):
  85. pass
  86. try:
  87. cfg['allow_long_titles'] = parser.getboolean("doc8",
  88. "allow-long-titles")
  89. except (configparser.NoSectionError, configparser.NoOptionError):
  90. pass
  91. try:
  92. cfg['sphinx'] = parser.getboolean("doc8", "sphinx")
  93. except (configparser.NoSectionError, configparser.NoOptionError):
  94. pass
  95. try:
  96. cfg['verbose'] = parser.getboolean("doc8", "verbose")
  97. except (configparser.NoSectionError, configparser.NoOptionError):
  98. pass
  99. try:
  100. cfg['default_extension'] = parser.get("doc8", "default-extension")
  101. except (configparser.NoSectionError, configparser.NoOptionError):
  102. pass
  103. try:
  104. extensions = parser.get("doc8", "extensions")
  105. extensions = extensions.split(",")
  106. extensions = [s.strip() for s in extensions if s.strip()]
  107. if extensions:
  108. cfg['extension'] = extensions
  109. except (configparser.NoSectionError, configparser.NoOptionError):
  110. pass
  111. return cfg
  112. def fetch_checks(cfg):
  113. base = [
  114. checks.CheckValidity(cfg),
  115. checks.CheckTrailingWhitespace(cfg),
  116. checks.CheckIndentationNoTab(cfg),
  117. checks.CheckCarriageReturn(cfg),
  118. checks.CheckMaxLineLength(cfg),
  119. checks.CheckNewlineEndOfFile(cfg),
  120. ]
  121. mgr = extension.ExtensionManager(
  122. namespace='doc8.extension.check',
  123. invoke_on_load=True,
  124. invoke_args=(cfg.copy(),),
  125. )
  126. addons = []
  127. for e in mgr:
  128. addons.append(e.obj)
  129. return base + addons
  130. def setup_logging(verbose):
  131. if verbose:
  132. level = logging.DEBUG
  133. else:
  134. level = logging.ERROR
  135. logging.basicConfig(level=level,
  136. format='%(levelname)s: %(message)s', stream=sys.stdout)
  137. def scan(cfg):
  138. print("Scanning...")
  139. files = collections.deque()
  140. ignored_paths = cfg.get('ignore_path', [])
  141. files_ignored = 0
  142. file_iter = utils.find_files(cfg.get('paths', []),
  143. cfg.get('extension', []), ignored_paths)
  144. default_extension = cfg.get('default_extension')
  145. for filename, ignoreable in file_iter:
  146. if ignoreable:
  147. files_ignored += 1
  148. if cfg.get('verbose'):
  149. print(" Ignoring '%s'" % (filename))
  150. else:
  151. f = file_parser.parse(filename,
  152. default_extension=default_extension)
  153. files.append(f)
  154. if cfg.get('verbose'):
  155. print(" Selecting '%s'" % (filename))
  156. return (files, files_ignored)
  157. def validate(cfg, files):
  158. print("Validating...")
  159. error_counts = {}
  160. ignoreables = frozenset(cfg.get('ignore', []))
  161. while files:
  162. f = files.popleft()
  163. if cfg.get('verbose'):
  164. print("Validating %s" % f)
  165. for c in fetch_checks(cfg):
  166. try:
  167. # http://legacy.python.org/dev/peps/pep-3155/
  168. check_name = c.__class__.__qualname__
  169. except AttributeError:
  170. check_name = ".".join([c.__class__.__module__,
  171. c.__class__.__name__])
  172. error_counts.setdefault(check_name, 0)
  173. try:
  174. extension_matcher = c.EXT_MATCHER
  175. except AttributeError:
  176. pass
  177. else:
  178. if not extension_matcher.match(f.extension):
  179. if cfg.get('verbose'):
  180. print(" Skipping check '%s' since it does not"
  181. " understand parsing a file with extension '%s'"
  182. % (check_name, f.extension))
  183. continue
  184. try:
  185. reports = set(c.REPORTS)
  186. except AttributeError:
  187. pass
  188. else:
  189. reports = reports - ignoreables
  190. if not reports:
  191. if cfg.get('verbose'):
  192. print(" Skipping check '%s', determined to only"
  193. " check ignoreable codes" % check_name)
  194. continue
  195. if cfg.get('verbose'):
  196. print(" Running check '%s'" % check_name)
  197. if isinstance(c, checks.ContentCheck):
  198. for line_num, code, message in c.report_iter(f):
  199. if code in ignoreables:
  200. continue
  201. if cfg.get('verbose'):
  202. print(' - %s:%s: %s %s'
  203. % (f.filename, line_num, code, message))
  204. else:
  205. print('%s:%s: %s %s'
  206. % (f.filename, line_num, code, message))
  207. error_counts[check_name] += 1
  208. elif isinstance(c, checks.LineCheck):
  209. for line_num, line in enumerate(f.lines_iter(), 1):
  210. for code, message in c.report_iter(line):
  211. if code in ignoreables:
  212. continue
  213. if cfg.get('verbose'):
  214. print(' - %s:%s: %s %s'
  215. % (f.filename, line_num, code, message))
  216. else:
  217. print('%s:%s: %s %s'
  218. % (f.filename, line_num, code, message))
  219. error_counts[check_name] += 1
  220. else:
  221. raise TypeError("Unknown check type: %s, %s"
  222. % (type(c), c))
  223. return error_counts
  224. def main():
  225. parser = argparse.ArgumentParser(
  226. prog='doc8',
  227. description=__doc__,
  228. formatter_class=argparse.RawDescriptionHelpFormatter)
  229. default_configs = ", ".join(CONFIG_FILENAMES)
  230. parser.add_argument("paths", metavar='path', type=str, nargs='*',
  231. help=("Path to scan for doc files"
  232. " (default: current directory)."),
  233. default=[os.getcwd()])
  234. parser.add_argument("--config", metavar='path', action="append",
  235. help="User config file location"
  236. " (default: %s)." % default_configs,
  237. default=[])
  238. parser.add_argument("--allow-long-titles", action="store_true",
  239. help="Allow long section titles (default: False).",
  240. default=False)
  241. parser.add_argument("--ignore", action="append", metavar="code",
  242. help="Ignore the given error code(s).",
  243. type=split_set_type,
  244. default=[])
  245. parser.add_argument("--no-sphinx", action="store_false",
  246. help="Do not ignore sphinx specific false positives.",
  247. default=True, dest='sphinx')
  248. parser.add_argument("--ignore-path", action="append", default=[],
  249. help="Ignore the given directory or file (globs"
  250. " are supported).", metavar='path')
  251. parser.add_argument("--default-extension", action="store",
  252. help="Default file extension to use when a file is"
  253. " found without a file extension.",
  254. default='', dest='default_extension',
  255. metavar='extension')
  256. parser.add_argument("--max-line-length", action="store", metavar="int",
  257. type=int,
  258. help="Maximum allowed line"
  259. " length (default: %s)." % MAX_LINE_LENGTH,
  260. default=MAX_LINE_LENGTH)
  261. parser.add_argument("-e", "--extension", action="append",
  262. metavar="extension",
  263. help="Check file extensions of the given type"
  264. " (default: %s)." % ", ".join(FILE_PATTERNS),
  265. default=list(FILE_PATTERNS))
  266. parser.add_argument("-v", "--verbose", dest="verbose", action='store_true',
  267. help="Run in verbose mode.", default=False)
  268. parser.add_argument("--version", dest="version", action='store_true',
  269. help="Show the version and exit.", default=False)
  270. args = vars(parser.parse_args())
  271. if args.get('version'):
  272. print(version.version_string())
  273. return 0
  274. args['ignore'] = merge_sets(args['ignore'])
  275. cfg = extract_config(args)
  276. args['ignore'].update(cfg.pop("ignore", set()))
  277. if 'sphinx' in cfg:
  278. args['sphinx'] = cfg.pop("sphinx")
  279. args['extension'].extend(cfg.pop('extension', []))
  280. args['ignore_path'].extend(cfg.pop('ignore_path', []))
  281. args.update(cfg)
  282. setup_logging(args.get('verbose'))
  283. files, files_ignored = scan(args)
  284. files_selected = len(files)
  285. error_counts = validate(args, files)
  286. total_errors = sum(six.itervalues(error_counts))
  287. print("=" * 8)
  288. print("Total files scanned = %s" % (files_selected))
  289. print("Total files ignored = %s" % (files_ignored))
  290. print("Total accumulated errors = %s" % (total_errors))
  291. if error_counts:
  292. print("Detailed error counts:")
  293. for check_name in sorted(six.iterkeys(error_counts)):
  294. check_errors = error_counts[check_name]
  295. print(" - %s = %s" % (check_name, check_errors))
  296. if total_errors:
  297. return 1
  298. else:
  299. return 0
  300. if __name__ == "__main__":
  301. sys.exit(main())