main.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. # -*- coding: utf-8 -*-
  2. import itertools
  3. import argparse
  4. import json
  5. import logging
  6. import os
  7. import pathlib
  8. import re
  9. import string
  10. import subprocess
  11. import sys
  12. import tempfile
  13. from sphinx import config as sphinx_config
  14. from sphinx import project as sphinx_project
  15. from . import sphinx
  16. from . import git
  17. def main(argv=None):
  18. if not argv:
  19. argv = sys.argv[1:]
  20. parser = argparse.ArgumentParser()
  21. parser.add_argument("sourcedir", help="path to documentation source files")
  22. parser.add_argument("outputdir", help="path to output directory")
  23. parser.add_argument(
  24. "filenames",
  25. nargs="*",
  26. help="a list of specific files to rebuild. Ignored if -a is specified",
  27. )
  28. parser.add_argument(
  29. "-c",
  30. metavar="PATH",
  31. dest="confdir",
  32. help=(
  33. "path where configuration file (conf.py) is located "
  34. "(default: same as SOURCEDIR)"
  35. ),
  36. )
  37. parser.add_argument(
  38. "-C",
  39. action="store_true",
  40. dest="noconfig",
  41. help="use no config file at all, only -D options",
  42. )
  43. parser.add_argument(
  44. "-D",
  45. metavar="setting=value",
  46. action="append",
  47. dest="define",
  48. default=[],
  49. help="override a setting in configuration file",
  50. )
  51. parser.add_argument(
  52. "--dump-metadata",
  53. action="store_true",
  54. help="dump generated metadata and exit",
  55. )
  56. args, argv = parser.parse_known_args(argv)
  57. if args.noconfig:
  58. return 1
  59. logger = logging.getLogger(__name__)
  60. sourcedir_absolute = os.path.abspath(args.sourcedir)
  61. confdir_absolute = (
  62. os.path.abspath(args.confdir)
  63. if args.confdir is not None
  64. else sourcedir_absolute
  65. )
  66. # Conf-overrides
  67. confoverrides = {}
  68. for d in args.define:
  69. key, _, value = d.partition("=")
  70. confoverrides[key] = value
  71. # Parse config
  72. config = sphinx_config.Config.read(confdir_absolute, confoverrides)
  73. config.add("smv_tag_whitelist", sphinx.DEFAULT_TAG_WHITELIST, "html", str)
  74. config.add(
  75. "smv_branch_whitelist", sphinx.DEFAULT_TAG_WHITELIST, "html", str
  76. )
  77. config.add(
  78. "smv_remote_whitelist", sphinx.DEFAULT_REMOTE_WHITELIST, "html", str
  79. )
  80. config.add(
  81. "smv_released_pattern", sphinx.DEFAULT_RELEASED_PATTERN, "html", str
  82. )
  83. config.add(
  84. "smv_outputdir_format", sphinx.DEFAULT_OUTPUTDIR_FORMAT, "html", str
  85. )
  86. config.add("smv_prefer_remote_refs", False, "html", bool)
  87. config.pre_init_values()
  88. config.init_values()
  89. # Get relative paths to root of git repository
  90. gitroot = pathlib.Path(
  91. git.get_toplevel_path(cwd=sourcedir_absolute)
  92. ).resolve()
  93. cwd_absolute = os.path.abspath(".")
  94. cwd_relative = os.path.relpath(cwd_absolute, str(gitroot))
  95. logger.debug("Git toplevel path: %s", str(gitroot))
  96. sourcedir = os.path.relpath(sourcedir_absolute, str(gitroot))
  97. logger.debug(
  98. "Source dir (relative to git toplevel path): %s", str(sourcedir)
  99. )
  100. if args.confdir:
  101. confdir = os.path.relpath(confdir_absolute, str(gitroot))
  102. else:
  103. confdir = sourcedir
  104. logger.debug("Conf dir (relative to git toplevel path): %s", str(confdir))
  105. conffile = os.path.join(confdir, "conf.py")
  106. # Get git references
  107. gitrefs = git.get_refs(
  108. str(gitroot),
  109. config.smv_tag_whitelist,
  110. config.smv_branch_whitelist,
  111. config.smv_remote_whitelist,
  112. files=(sourcedir, conffile),
  113. )
  114. # Order git refs
  115. if config.smv_prefer_remote_refs:
  116. gitrefs = sorted(gitrefs, key=lambda x: (not x.is_remote, *x))
  117. else:
  118. gitrefs = sorted(gitrefs, key=lambda x: (x.is_remote, *x))
  119. logger = logging.getLogger(__name__)
  120. with tempfile.TemporaryDirectory() as tmp:
  121. # Generate Metadata
  122. metadata = {}
  123. outputdirs = set()
  124. for gitref in gitrefs:
  125. # Clone Git repo
  126. repopath = os.path.join(tmp, gitref.commit)
  127. try:
  128. git.copy_tree(str(gitroot), gitroot.as_uri(), repopath, gitref)
  129. except (OSError, subprocess.CalledProcessError):
  130. logger.error(
  131. "Failed to copy git tree for %s to %s",
  132. gitref.refname,
  133. repopath,
  134. )
  135. continue
  136. # Find config
  137. confpath = os.path.join(repopath, confdir)
  138. try:
  139. current_config = sphinx_config.Config.read(
  140. confpath, confoverrides,
  141. )
  142. except (OSError, sphinx_config.ConfigError):
  143. logger.error(
  144. "Failed load config for %s from %s",
  145. gitref.refname,
  146. confpath,
  147. )
  148. continue
  149. current_config.pre_init_values()
  150. current_config.init_values()
  151. # Ensure that there are not duplicate output dirs
  152. outputdir = config.smv_outputdir_format.format(
  153. ref=gitref, config=current_config,
  154. )
  155. if outputdir in outputdirs:
  156. logger.warning(
  157. "outputdir '%s' for %s conflicts with other versions",
  158. outputdir,
  159. gitref.refname,
  160. )
  161. continue
  162. outputdirs.add(outputdir)
  163. # Get List of files
  164. source_suffixes = current_config.source_suffix
  165. if isinstance(source_suffixes, str):
  166. source_suffixes = [current_config.source_suffix]
  167. current_sourcedir = os.path.join(repopath, sourcedir)
  168. project = sphinx_project.Project(
  169. current_sourcedir, source_suffixes
  170. )
  171. metadata[gitref.name] = {
  172. "name": gitref.name,
  173. "version": current_config.version,
  174. "release": current_config.release,
  175. "is_released": bool(
  176. re.match(config.smv_released_pattern, gitref.refname)
  177. ),
  178. "source": gitref.source,
  179. "creatordate": gitref.creatordate.strftime(sphinx.DATE_FMT),
  180. "basedir": repopath,
  181. "sourcedir": current_sourcedir,
  182. "outputdir": os.path.join(
  183. os.path.abspath(args.outputdir), outputdir
  184. ),
  185. "confdir": confdir_absolute,
  186. "docnames": list(project.discover()),
  187. }
  188. if args.dump_metadata:
  189. print(json.dumps(metadata, indent=2))
  190. return 0
  191. if not metadata:
  192. logger.error("No matching refs found!")
  193. return 2
  194. # Write Metadata
  195. metadata_path = os.path.abspath(os.path.join(tmp, "versions.json"))
  196. with open(metadata_path, mode="w") as fp:
  197. json.dump(metadata, fp, indent=2)
  198. # Run Sphinx
  199. argv.extend(["-D", "smv_metadata_path={}".format(metadata_path)])
  200. for version_name, data in metadata.items():
  201. os.makedirs(data["outputdir"], exist_ok=True)
  202. defines = itertools.chain(
  203. *(
  204. ("-D", string.Template(d).safe_substitute(data))
  205. for d in args.define
  206. )
  207. )
  208. current_argv = argv.copy()
  209. current_argv.extend(
  210. [
  211. *defines,
  212. "-D",
  213. "smv_current_version={}".format(version_name),
  214. "-c",
  215. data["confdir"],
  216. data["sourcedir"],
  217. data["outputdir"],
  218. *args.filenames,
  219. ]
  220. )
  221. logger.debug("Running sphinx-build with args: %r", current_argv)
  222. cmd = (sys.executable, "-m", "sphinx", *current_argv)
  223. current_cwd = os.path.join(data["basedir"], cwd_relative)
  224. subprocess.check_call(cmd, cwd=current_cwd)
  225. return 0