main.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. # -*- coding: utf-8 -*-
  2. import itertools
  3. import argparse
  4. import multiprocessing
  5. import contextlib
  6. import json
  7. import logging
  8. import os
  9. import pathlib
  10. import re
  11. import string
  12. import subprocess
  13. import sys
  14. import tempfile
  15. from sphinx import config as sphinx_config
  16. from sphinx import project as sphinx_project
  17. from . import sphinx
  18. from . import git
  19. @contextlib.contextmanager
  20. def working_dir(path):
  21. prev_cwd = os.getcwd()
  22. os.chdir(path)
  23. try:
  24. yield
  25. finally:
  26. os.chdir(prev_cwd)
  27. def load_sphinx_config_worker(q, confpath, confoverrides, add_defaults):
  28. try:
  29. with working_dir(confpath):
  30. current_config = sphinx_config.Config.read(
  31. confpath,
  32. confoverrides,
  33. )
  34. if add_defaults:
  35. current_config.add(
  36. "smv_tag_whitelist", sphinx.DEFAULT_TAG_WHITELIST, "html", str
  37. )
  38. current_config.add(
  39. "smv_branch_whitelist",
  40. sphinx.DEFAULT_TAG_WHITELIST,
  41. "html",
  42. str,
  43. )
  44. current_config.add(
  45. "smv_remote_whitelist",
  46. sphinx.DEFAULT_REMOTE_WHITELIST,
  47. "html",
  48. str,
  49. )
  50. current_config.add(
  51. "smv_released_pattern",
  52. sphinx.DEFAULT_RELEASED_PATTERN,
  53. "html",
  54. str,
  55. )
  56. current_config.add(
  57. "smv_outputdir_format",
  58. sphinx.DEFAULT_OUTPUTDIR_FORMAT,
  59. "html",
  60. str,
  61. )
  62. current_config.add("smv_prefer_remote_refs", False, "html", bool)
  63. current_config.pre_init_values()
  64. current_config.init_values()
  65. except Exception as err:
  66. q.put(err)
  67. return
  68. q.put(current_config)
  69. def load_sphinx_config(confpath, confoverrides, add_defaults=False):
  70. q = multiprocessing.Queue()
  71. proc = multiprocessing.Process(
  72. target=load_sphinx_config_worker,
  73. args=(q, confpath, confoverrides, add_defaults),
  74. )
  75. proc.start()
  76. proc.join()
  77. result = q.get_nowait()
  78. if isinstance(result, Exception):
  79. raise result
  80. return result
  81. def get_python_flags():
  82. if sys.flags.bytes_warning:
  83. yield "-b"
  84. if sys.flags.debug:
  85. yield "-d"
  86. if sys.flags.hash_randomization:
  87. yield "-R"
  88. if sys.flags.ignore_environment:
  89. yield "-E"
  90. if sys.flags.inspect:
  91. yield "-i"
  92. if sys.flags.isolated:
  93. yield "-I"
  94. if sys.flags.no_site:
  95. yield "-S"
  96. if sys.flags.no_user_site:
  97. yield "-s"
  98. if sys.flags.optimize:
  99. yield "-O"
  100. if sys.flags.quiet:
  101. yield "-q"
  102. if sys.flags.verbose:
  103. yield "-v"
  104. for option, value in sys._xoptions.items():
  105. if value is True:
  106. yield from ("-X", option)
  107. else:
  108. yield from ("-X", "{}={}".format(option, value))
  109. def main(argv=None):
  110. if not argv:
  111. argv = sys.argv[1:]
  112. parser = argparse.ArgumentParser()
  113. parser.add_argument("sourcedir", help="path to documentation source files")
  114. parser.add_argument("outputdir", help="path to output directory")
  115. parser.add_argument(
  116. "filenames",
  117. nargs="*",
  118. help="a list of specific files to rebuild. Ignored if -a is specified",
  119. )
  120. parser.add_argument(
  121. "-c",
  122. metavar="PATH",
  123. dest="confdir",
  124. help=(
  125. "path where configuration file (conf.py) is located "
  126. "(default: same as SOURCEDIR)"
  127. ),
  128. )
  129. parser.add_argument(
  130. "-C",
  131. action="store_true",
  132. dest="noconfig",
  133. help="use no config file at all, only -D options",
  134. )
  135. parser.add_argument(
  136. "-D",
  137. metavar="setting=value",
  138. action="append",
  139. dest="define",
  140. default=[],
  141. help="override a setting in configuration file",
  142. )
  143. parser.add_argument(
  144. "--dump-metadata",
  145. action="store_true",
  146. help="dump generated metadata and exit",
  147. )
  148. args, argv = parser.parse_known_args(argv)
  149. if args.noconfig:
  150. return 1
  151. logger = logging.getLogger(__name__)
  152. sourcedir_absolute = os.path.abspath(args.sourcedir)
  153. confdir_absolute = (
  154. os.path.abspath(args.confdir)
  155. if args.confdir is not None
  156. else sourcedir_absolute
  157. )
  158. # Conf-overrides
  159. confoverrides = {}
  160. for d in args.define:
  161. key, _, value = d.partition("=")
  162. confoverrides[key] = value
  163. # Parse config
  164. config = load_sphinx_config(
  165. confdir_absolute, confoverrides, add_defaults=True
  166. )
  167. # Get relative paths to root of git repository
  168. gitroot = pathlib.Path(
  169. git.get_toplevel_path(cwd=sourcedir_absolute)
  170. ).resolve()
  171. cwd_absolute = os.path.abspath(".")
  172. cwd_relative = os.path.relpath(cwd_absolute, str(gitroot))
  173. logger.debug("Git toplevel path: %s", str(gitroot))
  174. sourcedir = os.path.relpath(sourcedir_absolute, str(gitroot))
  175. logger.debug(
  176. "Source dir (relative to git toplevel path): %s", str(sourcedir)
  177. )
  178. if args.confdir:
  179. confdir = os.path.relpath(confdir_absolute, str(gitroot))
  180. else:
  181. confdir = sourcedir
  182. logger.debug("Conf dir (relative to git toplevel path): %s", str(confdir))
  183. conffile = os.path.join(confdir, "conf.py")
  184. # Get git references
  185. gitrefs = git.get_refs(
  186. str(gitroot),
  187. config.smv_tag_whitelist,
  188. config.smv_branch_whitelist,
  189. config.smv_remote_whitelist,
  190. files=(sourcedir, conffile),
  191. )
  192. # Order git refs
  193. if config.smv_prefer_remote_refs:
  194. gitrefs = sorted(gitrefs, key=lambda x: (not x.is_remote, *x))
  195. else:
  196. gitrefs = sorted(gitrefs, key=lambda x: (x.is_remote, *x))
  197. logger = logging.getLogger(__name__)
  198. with tempfile.TemporaryDirectory() as tmp:
  199. # Generate Metadata
  200. metadata = {}
  201. outputdirs = set()
  202. for gitref in gitrefs:
  203. # Clone Git repo
  204. repopath = os.path.join(tmp, gitref.commit)
  205. commit = str(gitref.commit)
  206. try:
  207. git.copy_tree(str(gitroot), gitroot.as_uri(), repopath, gitref)
  208. except (OSError, subprocess.CalledProcessError):
  209. logger.error(
  210. "Failed to copy git tree for %s to %s",
  211. gitref.refname,
  212. repopath,
  213. )
  214. continue
  215. # Find config
  216. confpath = os.path.join(repopath, confdir)
  217. try:
  218. current_config = load_sphinx_config(confpath, confoverrides)
  219. except (OSError, sphinx_config.ConfigError):
  220. logger.error(
  221. "Failed load config for %s from %s",
  222. gitref.refname,
  223. confpath,
  224. )
  225. continue
  226. # Ensure that there are not duplicate output dirs
  227. outputdir = config.smv_outputdir_format.format(
  228. ref=gitref,
  229. config=current_config,
  230. )
  231. if outputdir in outputdirs:
  232. logger.warning(
  233. "outputdir '%s' for %s conflicts with other versions",
  234. outputdir,
  235. gitref.refname,
  236. )
  237. continue
  238. outputdirs.add(outputdir)
  239. # Get List of files
  240. source_suffixes = current_config.source_suffix
  241. if isinstance(source_suffixes, str):
  242. source_suffixes = [current_config.source_suffix]
  243. current_sourcedir = os.path.join(repopath, sourcedir)
  244. project = sphinx_project.Project(
  245. current_sourcedir, source_suffixes
  246. )
  247. metadata[gitref.name] = {
  248. "name": gitref.name,
  249. "version": current_config.version,
  250. "release": current_config.release,
  251. "rst_prolog": current_config.rst_prolog,
  252. "is_released": bool(
  253. re.match(config.smv_released_pattern, gitref.refname)
  254. ),
  255. "source": gitref.source,
  256. "creatordate": gitref.creatordate.strftime(sphinx.DATE_FMT),
  257. "basedir": repopath,
  258. "sourcedir": current_sourcedir,
  259. "outputdir": os.path.join(
  260. os.path.abspath(args.outputdir), outputdir
  261. ),
  262. "confdir": confpath,
  263. "docnames": list(project.discover()),
  264. "commit": commit
  265. }
  266. if args.dump_metadata:
  267. print(json.dumps(metadata, indent=2))
  268. return 0
  269. if not metadata:
  270. logger.error("No matching refs found!")
  271. return 2
  272. # Write Metadata
  273. metadata_path = os.path.abspath(os.path.join(tmp, "versions.json"))
  274. with open(metadata_path, mode="w") as fp:
  275. json.dump(metadata, fp, indent=2)
  276. # Run Sphinx
  277. argv.extend(["-D", "smv_metadata_path={}".format(metadata_path)])
  278. for version_name, data in metadata.items():
  279. os.makedirs(data["outputdir"], exist_ok=True)
  280. defines = itertools.chain(
  281. *(
  282. ("-D", string.Template(d).safe_substitute(data))
  283. for d in args.define
  284. )
  285. )
  286. current_argv = argv.copy()
  287. current_argv.extend(
  288. [
  289. *defines,
  290. "-D",
  291. "smv_current_version={}".format(version_name),
  292. "-c",
  293. confdir_absolute,
  294. data["sourcedir"],
  295. data["outputdir"],
  296. *args.filenames,
  297. ]
  298. )
  299. logger.debug("Running sphinx-build with args: %r", current_argv)
  300. cmd = (
  301. sys.executable,
  302. *get_python_flags(),
  303. "-m",
  304. "sphinx",
  305. *current_argv,
  306. )
  307. current_cwd = os.path.join(data["basedir"], cwd_relative)
  308. env = os.environ.copy()
  309. env.update(
  310. {
  311. "SPHINX_MULTIVERSION_NAME": data["name"],
  312. "SPHINX_MULTIVERSION_VERSION": data["version"],
  313. "SPHINX_MULTIVERSION_RELEASE": data["release"],
  314. "SPHINX_MULTIVERSION_SOURCEDIR": data["sourcedir"],
  315. "SPHINX_MULTIVERSION_OUTPUTDIR": data["outputdir"],
  316. "SPHINX_MULTIVERSION_CONFDIR": data["confdir"],
  317. "SPHINX_MULTIVERSION_GIT_COMMIT": data["commit"],
  318. }
  319. )
  320. subprocess.check_call(cmd, cwd=current_cwd, env=env)
  321. return 0