Просмотр исходного кода

Add initial version of project

Jan Holthuis 5 лет назад
Родитель
Сommit
6906ab6439

+ 22 - 0
setup.py

@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from setuptools import setup
+
+setup(
+    name='sphinx-multiversion',
+    description='Add support for multiple versions to sphinx',
+    classifiers=[
+        'License :: OSI Approved :: BSD License',
+        "Programming Language :: Python :: 3",
+      ],
+    author='Jan Holthuis',
+    author_email='holthuis.jan@googlemail.com',
+    install_requires=['sphinx >= 2.1'],
+    license='MIT',
+    packages=['sphinx_multiversion'],
+    entry_points={
+        'console_scripts': [
+            'sphinx-multiversion=sphinx_multiversion:main',
+        ],
+    },
+ )

+ 8 - 0
sphinx_multiversion/__init__.py

@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+from .sphinx import setup
+from .main import main
+
+__all__ = [
+    "setup",
+    "main",
+]

+ 5 - 0
sphinx_multiversion/__main__.py

@@ -0,0 +1,5 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import sys
+from sphinx.ext.multiversion import main
+sys.exit(main())

+ 76 - 0
sphinx_multiversion/git.py

@@ -0,0 +1,76 @@
+# -*- coding: utf-8 -*-
+import collections
+import subprocess
+import re
+
+from . import sphinx
+
+VersionRef = collections.namedtuple('VersionRef', [
+    'name',
+    'source',
+    'refname',
+    'version',
+    'release',
+])
+
+
+def get_refs(gitroot):
+    cmd = ("git", "for-each-ref", "--format", "%(refname)", "refs")
+    output = subprocess.check_output(cmd, cwd=gitroot).decode()
+    for line in output.splitlines():
+        refname = line.strip()
+
+        # Parse refname
+        matchobj = re.match(r"^refs/(heads|tags|remotes/[^/]+)/(\S+)$", refname)
+        if not matchobj:
+            continue
+        source = matchobj.group(1)
+        name = matchobj.group(2)
+        yield (name, source, refname)
+
+
+def get_remotes(gitroot):
+    cmd = ("git", "remote", "--verbose")
+    output = subprocess.check_output(cmd, cwd=gitroot).decode()
+    for line in output.splitlines():
+        matchobj = re.match(r"^(\S+)\s+(\S+)\s+\(fetch\)$", line.strip())
+        if not matchobj:
+            continue
+
+        name = matchobj.group(1)
+        url = matchobj.group(2)
+
+        yield (name, url)
+
+
+def get_conf(gitroot, refname, confpath):
+    objectname = "{}:{}".format(refname, confpath)
+    cmd = ("git", "show", objectname)
+    return subprocess.check_output(cmd, cwd=gitroot).decode()
+
+
+def find_versions(gitroot, confpath, tag_whitelist, branch_whitelist, remote_whitelist):
+    for name, source, refname in get_refs(gitroot):
+        if source == 'tags':
+            if tag_whitelist is None or not re.match(tag_whitelist, name):
+                continue
+        elif source == 'heads':
+            if branch_whitelist is None or not re.match(branch_whitelist, name):
+                continue
+        elif remote_whitelist is not None and re.match(remote_whitelist, source):
+            if branch_whitelist is None or not re.match(branch_whitelist, name):
+                continue
+        else:
+            continue
+
+        conf = get_conf(gitroot, refname, confpath)
+        config = sphinx.parse_conf(conf)
+        version = config['version']
+        release = config['release']
+        yield VersionRef(name, source, refname, version, release)
+
+
+def shallow_clone(src, dst, branch):
+    cmd = ("git", "clone", "--no-hardlinks", "--single-branch", "--depth", "1",
+           "--branch", branch, src, dst)
+    subprocess.check_call(cmd)

+ 123 - 0
sphinx_multiversion/main.py

@@ -0,0 +1,123 @@
+# -*- coding: utf-8 -*-
+import os
+import json
+import pathlib
+import subprocess
+import sys
+import tempfile
+
+from sphinx.cmd import build as sphinx_build
+from sphinx import project as sphinx_project
+
+from . import sphinx
+from . import git
+
+
+def main(argv=None):
+    if not argv:
+        argv = sys.argv[1:]
+
+    parser = sphinx_build.get_parser()
+    args = parser.parse_args(argv)
+
+    # Find the indices
+    srcdir_index = None
+    outdir_index = None
+    for i, value in enumerate(argv):
+        if value == args.sourcedir:
+            argv[i] = '{{{SOURCEDIR}}}'
+            test_args = parser.parse_args(argv)
+            if test_args.sourcedir == argv[i]:
+                srcdir_index = i
+            argv[i] = args.sourcedir
+
+        if value == args.outputdir:
+            argv[i] = '{{{OUTPUTDIR}}}'
+            test_args = parser.parse_args(argv)
+            if test_args.outputdir == argv[i]:
+                outdir_index = i
+            argv[i] = args.outputdir
+
+    if srcdir_index is None:
+        raise ValueError("Failed to find srcdir index")
+    if outdir_index is None:
+        raise ValueError("Failed to find outdir index")
+
+    # Parse config
+    confpath = os.path.join(args.confdir, 'conf.py')
+    with open(confpath, mode='r') as f:
+        config = sphinx.parse_conf(f.read())
+
+    for d in args.define:
+        key, _, value = d.partition('=')
+        config[key] = value
+
+    tag_whitelist = config.get('smv_tag_whitelist', sphinx.DEFAULT_TAG_WHITELIST)
+    branch_whitelist = config.get('smv_branch_whitelist', sphinx.DEFAULT_BRANCH_WHITELIST)
+    remote_whitelist = config.get('smv_remote_whitelist', sphinx.DEFAULT_REMOTE_WHITELIST)
+    outputdir_format = config.get('smv_outputdir_format', sphinx.DEFAULT_OUTPUTDIR_FORMAT)
+
+    gitroot = pathlib.Path('.').resolve()
+    versions = git.find_versions(str(gitroot), 'source/conf.py', tag_whitelist, branch_whitelist, remote_whitelist)
+
+    remotes = dict(git.get_remotes(str(gitroot)))
+
+    with tempfile.TemporaryDirectory() as tmp:
+        # Generate Metadata
+        metadata = {}
+        outputdirs = set()
+        sourcedir = os.path.relpath(args.sourcedir, str(gitroot))
+        for versionref in versions:
+            # Ensure that there are not duplicate output dirs
+            outputdir = sphinx.format_outputdir(
+                outputdir_format, versionref, language=config["language"])
+            if outputdir in outputdirs:
+                print("outputdir '%s' of version %r conflicts with other versions!"
+                      % (outputdir, versionref))
+                continue
+            outputdirs.add(outputdir)
+
+            # Clone Git repo
+            repopath = os.path.join(tmp, str(hash(versionref)))
+            srcdir = os.path.join(repopath, sourcedir)
+            if versionref.source.startswith('remotes/'):
+                repo_url = remotes[versionref.source.partition("/")[2]]
+            else:
+                repo_url = gitroot.as_uri()
+            try:
+                git.shallow_clone(repo_url, repopath, versionref.name)
+            except subprocess.CalledProcessError:
+                outputdirs.remove(outputdir)
+                continue
+
+            # Get List of files
+            source_suffixes = config.get("source_suffix", "")
+            if isinstance(source_suffixes, str):
+                source_suffixes = [source_suffixes]
+            project = sphinx_project.Project(srcdir, source_suffixes)
+            metadata[versionref.name] = {
+                "name": versionref.name,
+                "source": versionref.source,
+                "sourcedir": srcdir,
+                "outputdir": outputdir,
+                "docnames": list(project.discover())
+            }
+        metadata_path = os.path.abspath(os.path.join(tmp, "versions.json"))
+        with open(metadata_path, mode='w') as fp:
+            json.dump(metadata, fp, indent=2)
+
+        # Run Sphinx
+        argv.extend(["-D", "smv_metadata_path={}".format(metadata_path)])
+        for version_name, data in metadata.items():
+            current_argv = argv.copy()
+            current_argv.extend([
+                "-D", "smv_current_version={}".format(version_name),
+            ])
+
+            outdir = os.path.join(args.outputdir, data["outputdir"])
+            current_argv[srcdir_index] = data["sourcedir"]
+            current_argv[outdir_index] = outdir
+            os.makedirs(outdir, exist_ok=True)
+            status = sphinx_build.build_main(current_argv)
+            if status not in (0, None):
+                break

+ 139 - 0
sphinx_multiversion/sphinx.py

@@ -0,0 +1,139 @@
+# -*- coding: utf-8 -*-
+import json
+import pathlib
+import collections
+import importlib.abc
+import logging
+import os
+import posixpath
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_TAG_WHITELIST = r'^.*$'
+DEFAULT_BRANCH_WHITELIST = r'^.*$'
+DEFAULT_REMOTE_WHITELIST = None
+DEFAULT_OUTPUTDIR_FORMAT = r'{version.version}/{language}'
+
+Version = collections.namedtuple('Version', ['version', 'url'])
+
+
+class VersionInfo:
+    def __init__(self, app, context, metadata):
+        self.app = app
+        self.context = context
+        self.metadata = metadata
+
+    @property
+    def tags(self):
+        return [
+            Version(v["name"], self.vpathto(v["name"]))
+            for v in self.metadata.values() if v["source"] == "tags"
+        ]
+
+    @property
+    def branches(self):
+        return [
+            Version(v["name"], self.vpathto(v["name"]))
+            for v in self.metadata.values() if v["source"] != "tags"
+        ]
+
+    def __iter__(self):
+        for item in self.tags:
+            yield item
+        for item in self.branches:
+            yield item
+
+    def vhasdoc(self, other_version_name):
+        if self.context["current_version"] == other_version_name:
+            return True
+
+        other_version = self.metadata[other_version_name]
+        return self.context["pagename"] in other_version["docnames"]
+
+    def vpathto(self, other_version_name):
+        if self.context["current_version"] == other_version_name:
+            return '{}.html'.format(
+                posixpath.split(self.context["pagename"])[-1])
+
+        # Find output root
+        current_version = self.metadata[self.context["current_version"]]
+        relpath = pathlib.PurePath(current_version["outputdir"])
+        outputroot = os.path.join(
+            *('..' for x in relpath.joinpath(self.context["pagename"]).parent.parts)
+        )
+
+        # Find output dir of other version
+        other_version = self.metadata[other_version_name]
+        outputdir = posixpath.join(outputroot, other_version["outputdir"])
+
+        if not self.vhasdoc(other_version_name):
+            return posixpath.join(outputdir, 'index.html')
+
+        return posixpath.join(outputdir, '{}.html'.format(self.context["pagename"]))
+
+
+def parse_conf(config):
+    module = {}
+    code = importlib.abc.InspectLoader.source_to_code(config)
+    exec(code, module)
+    return module
+
+
+def format_outputdir(fmt, versionref, language):
+    return fmt.format(version=versionref, language=language)
+
+
+def html_page_context(app, pagename, templatename, context, doctree):
+    context["latest_version"] = app.config.smv_latest_version
+    context["current_version"] = app.config.smv_current_version
+    context["html_theme"] = app.config.html_theme
+
+    versioninfo = VersionInfo(app, context, app.config.smv_metadata)
+    context["versions"] = versioninfo
+    context["vhasdoc"] = versioninfo.vhasdoc
+    context["vpathto"] = versioninfo.vpathto
+
+
+def config_inited(app, config):
+    """Update the Sphinx builder.
+    :param sphinx.application.Sphinx app: Sphinx application object.
+    """
+
+    if not config.smv_metadata:
+        if not config.smv_metadata_path:
+            return
+
+        with open(config.smv_metadata_path, mode="r") as f:
+            metadata = json.load(f)
+
+        config.smv_metadata = metadata
+
+    if not config.smv_current_version:
+        return
+
+    app.connect("html-page-context", html_page_context)
+
+    # Restore config values
+    conf_path = os.path.join(app.srcdir, "conf.py")
+    with open(conf_path, mode="r") as f:
+        conf = parse_conf(f.read())
+        config.version = conf['version']
+        config.release = conf['release']
+
+
+def setup(app):
+    app.add_config_value("smv_metadata", {}, "html")
+    app.add_config_value("smv_metadata_path", "", "html")
+    app.add_config_value("smv_current_version", "", "html")
+    app.add_config_value("smv_latest_version", "master", "html")
+    app.add_config_value("smv_tag_whitelist", DEFAULT_TAG_WHITELIST, "html")
+    app.add_config_value("smv_branch_whitelist", DEFAULT_BRANCH_WHITELIST, "html")
+    app.add_config_value("smv_remote_whitelist", DEFAULT_REMOTE_WHITELIST, "html")
+    app.add_config_value("smv_outputdir_format", DEFAULT_OUTPUTDIR_FORMAT, "html")
+    app.connect("config-inited", config_inited)
+
+    return {
+        "version": "0.1",
+        "parallel_read_safe": True,
+        "parallel_write_safe": True,
+    }