build_docs.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. #!/usr/bin/env python
  2. # coding=utf-8
  3. #
  4. # Top-level docs builder
  5. #
  6. # This is just a front-end to sphinx-build that can call it multiple times for different language/target combinations
  7. #
  8. # Will build out to _build/LANG/TARGET by default
  9. #
  10. # Specific custom docs functionality should be added in conf_common.py or in a Sphinx extension, not here.
  11. #
  12. from __future__ import print_function
  13. import argparse
  14. import math
  15. import multiprocessing
  16. import os
  17. import os.path
  18. import subprocess
  19. import sys
  20. import re
  21. from collections import namedtuple
  22. LANGUAGES = ["en", "zh_CN"]
  23. TARGETS = ["esp32", "esp32s2"]
  24. SPHINX_WARN_LOG = "sphinx-warning-log.txt"
  25. SPHINX_SANITIZED_LOG = "sphinx-warning-log-sanitized.txt"
  26. SPHINX_KNOWN_WARNINGS = os.path.join(os.environ["IDF_PATH"], "docs", "sphinx-known-warnings.txt")
  27. DXG_WARN_LOG = "doxygen-warning-log.txt"
  28. DXG_SANITIZED_LOG = "doxygen-warning-log-sanitized.txt"
  29. DXG_KNOWN_WARNINGS = os.path.join(os.environ["IDF_PATH"], "docs", "doxygen-known-warnings.txt")
  30. SANITIZE_FILENAME_REGEX = re.compile("[^:]*/([^/:]*)(:.*)")
  31. SANITIZE_LINENUM_REGEX = re.compile("([^:]*)(:[0-9]+:)(.*)")
  32. LogMessage = namedtuple("LogMessage", "original_text sanitized_text")
  33. languages = LANGUAGES
  34. targets = TARGETS
  35. def main():
  36. # check Python dependencies for docs
  37. try:
  38. subprocess.check_call([sys.executable,
  39. os.path.join(os.environ["IDF_PATH"],
  40. "tools",
  41. "check_python_dependencies.py"),
  42. "-r",
  43. "{}/docs/requirements.txt".format(os.environ["IDF_PATH"])
  44. ])
  45. except subprocess.CalledProcessError:
  46. raise SystemExit(2) # stdout will already have these errors
  47. parser = argparse.ArgumentParser(description='build_docs.py: Build IDF docs', prog='build_docs.py')
  48. parser.add_argument("--language", "-l", choices=LANGUAGES, required=False)
  49. parser.add_argument("--target", "-t", choices=TARGETS, required=False)
  50. parser.add_argument("--build-dir", "-b", type=str, default="_build")
  51. parser.add_argument("--sphinx-parallel-builds", "-p", choices=["auto"] + [str(x) for x in range(8)],
  52. help="Parallel Sphinx builds - number of independent Sphinx builds to run", default="auto")
  53. parser.add_argument("--sphinx-parallel-jobs", "-j", choices=["auto"] + [str(x) for x in range(8)],
  54. help="Sphinx parallel jobs argument - number of threads for each Sphinx build to use", default="1")
  55. action_parsers = parser.add_subparsers(dest='action')
  56. build_parser = action_parsers.add_parser('build', help='Build documentation')
  57. build_parser.add_argument("--check-warnings-only", "-w", action='store_true')
  58. action_parsers.add_parser('linkcheck', help='Check links (a current IDF revision should be uploaded to GitHub)')
  59. action_parsers.add_parser('gh-linkcheck', help='Checking for hardcoded GitHub links')
  60. args = parser.parse_args()
  61. global languages
  62. if args.language is None:
  63. print("Building all languages")
  64. languages = LANGUAGES
  65. else:
  66. languages = [args.language]
  67. global targets
  68. if args.target is None:
  69. print("Building all targets")
  70. targets = TARGETS
  71. else:
  72. targets = [args.target]
  73. if args.action == "build" or args.action is None:
  74. sys.exit(action_build(args))
  75. if args.action == "linkcheck":
  76. sys.exit(action_linkcheck(args))
  77. if args.action == "gh-linkcheck":
  78. sys.exit(action_gh_linkcheck(args))
  79. def parallel_call(args, callback):
  80. num_sphinx_builds = len(languages) * len(targets)
  81. num_cpus = multiprocessing.cpu_count()
  82. if args.sphinx_parallel_builds == "auto":
  83. # at most one sphinx build per CPU, up to the number of CPUs
  84. args.sphinx_parallel_builds = min(num_sphinx_builds, num_cpus)
  85. else:
  86. args.sphinx_parallel_builds = int(args.sphinx_parallel_builds)
  87. # Force -j1 because sphinx works incorrectly
  88. args.sphinx_parallel_jobs = 1
  89. if args.sphinx_parallel_jobs == "auto":
  90. # N CPUs per build job, rounded up - (maybe smarter to round down to avoid contention, idk)
  91. args.sphinx_parallel_jobs = int(math.ceil(num_cpus / args.sphinx_parallel_builds))
  92. else:
  93. args.sphinx_parallel_jobs = int(args.sphinx_parallel_jobs)
  94. print("Will use %d parallel builds and %d jobs per build" % (args.sphinx_parallel_builds, args.sphinx_parallel_jobs))
  95. pool = multiprocessing.Pool(args.sphinx_parallel_builds)
  96. if args.sphinx_parallel_jobs > 1:
  97. print("WARNING: Sphinx parallel jobs currently produce incorrect docs output with Sphinx 1.8.5")
  98. # make a list of all combinations of build_docs() args as tuples
  99. #
  100. # there's probably a fancy way to do this with itertools but this way is actually readable
  101. entries = []
  102. for target in targets:
  103. for language in languages:
  104. build_dir = os.path.realpath(os.path.join(args.build_dir, language, target))
  105. entries.append((language, target, build_dir, args.sphinx_parallel_jobs))
  106. print(entries)
  107. errcodes = pool.map(callback, entries)
  108. print(errcodes)
  109. is_error = False
  110. for ret in errcodes:
  111. if ret != 0:
  112. print("\nThe following language/target combinations failed to build:")
  113. is_error = True
  114. break
  115. if is_error:
  116. for ret, entry in zip(errcodes, entries):
  117. if ret != 0:
  118. print("language: %s, target: %s, errcode: %d" % (entry[0], entry[1], ret))
  119. #Don't re-throw real error code from each parallel process
  120. return 1
  121. else:
  122. return 0
  123. def sphinx_call(language, target, build_dir, sphinx_parallel_jobs, buildername):
  124. # Note: because this runs in a multiprocessing Process, everything which happens here should be isolated to a single process
  125. # (ie it doesn't matter if Sphinx is using global variables, as they're it's own copy of the global variables)
  126. # wrap stdout & stderr in a way that lets us see which build_docs instance they come from
  127. #
  128. # this doesn't apply to subprocesses, they write to OS stdout & stderr so no prefix appears
  129. prefix = "%s/%s: " % (language, target)
  130. print("Building in build_dir: %s" % (build_dir))
  131. try:
  132. os.makedirs(build_dir)
  133. except OSError:
  134. print("Cannot call Sphinx in an existing directory!")
  135. return 1
  136. environ = {}
  137. environ.update(os.environ)
  138. environ['BUILDDIR'] = build_dir
  139. args = [sys.executable, "-u", "-m", "sphinx.cmd.build",
  140. "-j", str(sphinx_parallel_jobs),
  141. "-b", buildername,
  142. "-d", os.path.join(build_dir, "doctrees"),
  143. "-w", SPHINX_WARN_LOG,
  144. "-t", target,
  145. "-D", "idf_target={}".format(target),
  146. os.path.join(os.path.abspath(os.path.dirname(__file__)), language), # srcdir for this language
  147. os.path.join(build_dir, buildername) # build directory
  148. ]
  149. saved_cwd = os.getcwd()
  150. os.chdir(build_dir) # also run sphinx in the build directory
  151. print("Running '%s'" % (" ".join(args)))
  152. ret = 1
  153. try:
  154. # Note: we can't call sphinx.cmd.build.main() here as multiprocessing doesn't est >1 layer deep
  155. # and sphinx.cmd.build() also does a lot of work in the calling thread, especially for j ==1,
  156. # so using a Pyhthon thread for this part is a poor option (GIL)
  157. p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
  158. for c in iter(lambda: p.stdout.readline(), ''):
  159. sys.stdout.write(prefix)
  160. sys.stdout.write(c)
  161. ret = p.wait()
  162. assert (ret is not None)
  163. sys.stdout.flush()
  164. except KeyboardInterrupt: # this seems to be the only way to get Ctrl-C to kill everything?
  165. p.kill()
  166. os.chdir(saved_cwd)
  167. return 130 #FIXME It doesn't return this errorcode, why? Just prints stacktrace
  168. os.chdir(saved_cwd)
  169. return ret
  170. def action_build(args):
  171. if not args.check_warnings_only:
  172. ret = parallel_call(args, call_build_docs)
  173. if ret != 0:
  174. return ret
  175. # check Doxygen warnings:
  176. ret = 0
  177. for target in targets:
  178. for language in languages:
  179. build_dir = os.path.realpath(os.path.join(args.build_dir, language, target))
  180. ret += check_docs(language, target,
  181. log_file=os.path.join(build_dir, DXG_WARN_LOG),
  182. known_warnings_file=DXG_KNOWN_WARNINGS,
  183. out_sanitized_log_file=os.path.join(build_dir, DXG_SANITIZED_LOG))
  184. # check Sphinx warnings:
  185. for target in targets:
  186. for language in languages:
  187. build_dir = os.path.realpath(os.path.join(args.build_dir, language, target))
  188. ret += check_docs(language, target,
  189. log_file=os.path.join(build_dir, SPHINX_WARN_LOG),
  190. known_warnings_file=SPHINX_KNOWN_WARNINGS,
  191. out_sanitized_log_file=os.path.join(build_dir, SPHINX_SANITIZED_LOG))
  192. if ret != 0:
  193. return ret
  194. def call_build_docs(entry):
  195. return sphinx_call(*entry, buildername="html")
  196. def sanitize_line(line):
  197. """
  198. Clear a log message from insignificant parts
  199. filter:
  200. - only filename, no path at the beginning
  201. - no line numbers after the filename
  202. """
  203. line = re.sub(SANITIZE_FILENAME_REGEX, r'\1\2', line)
  204. line = re.sub(SANITIZE_LINENUM_REGEX, r'\1:line:\3', line)
  205. return line
  206. def check_docs(language, target, log_file, known_warnings_file, out_sanitized_log_file):
  207. """
  208. Check for Documentation warnings in `log_file`: should only contain (fuzzy) matches to `known_warnings_file`
  209. It prints all unknown messages with `target`/`language` prefix
  210. It leaves `out_sanitized_log_file` file for observe and debug
  211. """
  212. # Sanitize all messages
  213. all_messages = list()
  214. with open(log_file) as f, open(out_sanitized_log_file, 'w') as o:
  215. for line in f:
  216. sanitized_line = sanitize_line(line)
  217. all_messages.append(LogMessage(line, sanitized_line))
  218. o.write(sanitized_line)
  219. known_messages = list()
  220. with open(known_warnings_file) as k:
  221. for known_line in k:
  222. known_messages.append(known_line)
  223. # Collect all new messages that are not match with the known messages.
  224. # The order is an important.
  225. new_messages = list()
  226. known_idx = 0
  227. for msg in all_messages:
  228. try:
  229. known_idx = known_messages.index(msg.sanitized_text, known_idx)
  230. except ValueError:
  231. new_messages.append(msg)
  232. if new_messages:
  233. print("\n%s/%s: Build failed due to new/different warnings (%s):\n" % (language, target, log_file))
  234. for msg in new_messages:
  235. print("%s/%s: %s" % (language, target, msg.original_text), end='')
  236. print("\n%s/%s: (Check files %s and %s for full details.)" % (language, target, known_warnings_file, log_file))
  237. return 1
  238. return 0
  239. def action_linkcheck(args):
  240. return parallel_call(args, call_linkcheck)
  241. def call_linkcheck(entry):
  242. return sphinx_call(*entry, buildername="linkcheck")
  243. GH_LINK_FILTER = ["https://github.com/espressif/esp-idf/tree",
  244. "https://github.com/espressif/esp-idf/blob",
  245. "https://github.com/espressif/esp-idf/raw"]
  246. def action_gh_linkcheck(args):
  247. print("Checking for hardcoded GitHub links\n")
  248. find_args = ['find',
  249. os.path.join(os.path.abspath(os.path.dirname(__file__)), ".."),
  250. '-name',
  251. '*.rst']
  252. grep_args = ['xargs',
  253. 'grep',
  254. r'\|'.join(GH_LINK_FILTER)]
  255. p1 = subprocess.Popen(find_args, stdout=subprocess.PIPE)
  256. p2 = subprocess.Popen(grep_args, stdin=p1.stdout, stdout=subprocess.PIPE)
  257. p1.stdout.close()
  258. found_gh_links, _ = p2.communicate()
  259. if found_gh_links:
  260. print(found_gh_links)
  261. print("WARNINIG: Some .rst files contain hardcoded Github links.")
  262. print("Please check above output and replace links with one of the following:")
  263. print("- :idf:`dir` - points to directory inside ESP-IDF")
  264. print("- :idf_file:`file` - points to file inside ESP-IDF")
  265. print("- :idf_raw:`file` - points to raw view of the file inside ESP-IDF")
  266. print("- :component:`dir` - points to directory inside ESP-IDF components dir")
  267. print("- :component_file:`file` - points to file inside ESP-IDF components dir")
  268. print("- :component_raw:`file` - points to raw view of the file inside ESP-IDF components dir")
  269. print("- :example:`dir` - points to directory inside ESP-IDF examples dir")
  270. print("- :example_file:`file` - points to file inside ESP-IDF examples dir")
  271. print("- :example_raw:`file` - points to raw view of the file inside ESP-IDF examples dir")
  272. print("These link types will point to the correct GitHub version automatically")
  273. return 1
  274. else:
  275. print("No hardcoded links found")
  276. return 0
  277. if __name__ == "__main__":
  278. main()