1""" 2External content 3################ 4 5Copyright (c) 2021 Nordic Semiconductor ASA 6SPDX-License-Identifier: Apache-2.0 7 8Introduction 9============ 10 11This extension allows to import sources from directories out of the Sphinx 12source directory. They are copied to the source directory before starting the 13build. Note that the copy is *smart*, that is, only updated files are actually 14copied. Therefore, incremental builds detect changes correctly and behave as 15expected. 16 17Paths for external content ingluded via e.g. figure, literalinclude, etc. 18are adjusted as needed. 19 20Configuration options 21===================== 22 23- ``external_content_contents``: A list of external contents. Each entry is 24 a tuple with two fields: the external base directory and a file glob pattern. 25- ``external_content_directives``: A list of directives that should be analyzed 26 and their paths adjusted if necessary. Defaults to ``DEFAULT_DIRECTIVES``. 27- ``external_content_keep``: A list of file globs (relative to the destination 28 directory) that should be kept even if they do not exist in the source 29 directory. This option can be useful for auto-generated files in the 30 destination directory. 31""" 32 33import filecmp 34import os 35from pathlib import Path 36import re 37import shutil 38import tempfile 39from typing import Dict, Any, List, Optional 40 41from sphinx.application import Sphinx 42 43 44__version__ = "0.1.0" 45 46 47DEFAULT_DIRECTIVES = ("figure", "image", "include", "literalinclude") 48"""Default directives for included content.""" 49 50 51def adjust_includes( 52 fname: Path, 53 basepath: Path, 54 directives: List[str], 55 encoding: str, 56 dstpath: Optional[Path] = None, 57) -> None: 58 """Adjust included content paths. 59 60 Args: 61 fname: File to be processed. 62 basepath: Base path to be used to resolve content location. 63 directives: Directives to be parsed and adjusted. 64 encoding: Sources encoding. 65 dstpath: Destination path for fname if its path is not the actual destination. 66 """ 67 68 if fname.suffix != ".rst": 69 return 70 71 dstpath = dstpath or fname.parent 72 73 def _adjust(m): 74 directive, fpath = m.groups() 75 76 # ignore absolute paths 77 if fpath.startswith("/"): 78 fpath_adj = fpath 79 else: 80 fpath_adj = Path(os.path.relpath(basepath / fpath, dstpath)).as_posix() 81 82 return f".. {directive}:: {fpath_adj}" 83 84 with open(fname, "r+", encoding=encoding) as f: 85 content = f.read() 86 content_adj, modified = re.subn( 87 r"\.\. (" + "|".join(directives) + r")::\s*([^`\n]+)", _adjust, content 88 ) 89 if modified: 90 f.seek(0) 91 f.write(content_adj) 92 f.truncate() 93 94 95def sync_contents(app: Sphinx) -> None: 96 """Synhronize external contents. 97 98 Args: 99 app: Sphinx application instance. 100 """ 101 102 srcdir = Path(app.srcdir).resolve() 103 to_copy = [] 104 to_delete = set(f for f in srcdir.glob("**/*") if not f.is_dir()) 105 to_keep = set( 106 f 107 for k in app.config.external_content_keep 108 for f in srcdir.glob(k) 109 if not f.is_dir() 110 ) 111 112 for content in app.config.external_content_contents: 113 prefix_src, glob = content 114 for src in prefix_src.glob(glob): 115 if src.is_dir(): 116 to_copy.extend( 117 [(f, prefix_src) for f in src.glob("**/*") if not f.is_dir()] 118 ) 119 else: 120 to_copy.append((src, prefix_src)) 121 122 for entry in to_copy: 123 src, prefix_src = entry 124 dst = (srcdir / src.relative_to(prefix_src)).resolve() 125 126 if dst in to_delete: 127 to_delete.remove(dst) 128 129 if not dst.parent.exists(): 130 dst.parent.mkdir(parents=True) 131 132 # just copy if it does not exist 133 if not dst.exists(): 134 shutil.copy(src, dst) 135 adjust_includes( 136 dst, 137 src.parent, 138 app.config.external_content_directives, 139 app.config.source_encoding, 140 ) 141 # if origin file is modified only copy if different 142 elif src.stat().st_mtime > dst.stat().st_mtime: 143 with tempfile.TemporaryDirectory() as td: 144 # adjust origin includes before comparing 145 src_adjusted = Path(td) / src.name 146 shutil.copy(src, src_adjusted) 147 adjust_includes( 148 src_adjusted, 149 src.parent, 150 app.config.external_content_directives, 151 app.config.source_encoding, 152 dstpath=dst.parent, 153 ) 154 155 if not filecmp.cmp(src_adjusted, dst): 156 dst.unlink() 157 shutil.move(os.fspath(src_adjusted), os.fspath(dst)) 158 159 # remove any previously copied file not present in the origin folder, 160 # excepting those marked to be kept. 161 for file in to_delete - to_keep: 162 file.unlink() 163 164 165def setup(app: Sphinx) -> Dict[str, Any]: 166 app.add_config_value("external_content_contents", [], "env") 167 app.add_config_value("external_content_directives", DEFAULT_DIRECTIVES, "env") 168 app.add_config_value("external_content_keep", [], "") 169 170 app.connect("builder-inited", sync_contents) 171 172 return { 173 "version": __version__, 174 "parallel_read_safe": True, 175 "parallel_write_safe": True, 176 } 177