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 included 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 35import re 36import shutil 37import tempfile 38from pathlib import Path 39from typing import Any 40 41from sphinx.application import Sphinx 42 43__version__ = "0.1.0" 44 45 46DEFAULT_DIRECTIVES = ("figure", "image", "include", "literalinclude") 47"""Default directives for included content.""" 48 49 50def adjust_includes( 51 fname: Path, 52 basepath: Path, 53 directives: list[str], 54 encoding: str, 55 dstpath: Path | None = None, 56) -> None: 57 """Adjust included content paths. 58 59 Args: 60 fname: File to be processed. 61 basepath: Base path to be used to resolve content location. 62 directives: Directives to be parsed and adjusted. 63 encoding: Sources encoding. 64 dstpath: Destination path for fname if its path is not the actual destination. 65 """ 66 67 if fname.suffix != ".rst": 68 return 69 70 dstpath = dstpath or fname.parent 71 72 def _adjust(m): 73 directive, fpath = m.groups() 74 75 # ignore absolute paths 76 if fpath.startswith("/"): 77 fpath_adj = fpath 78 else: 79 fpath_adj = Path(os.path.relpath(basepath / fpath, dstpath)).as_posix() 80 81 return f".. {directive}:: {fpath_adj}" 82 83 with open(fname, "r+", encoding=encoding) as f: 84 content = f.read() 85 content_adj, modified = re.subn( 86 r"\.\. (" + "|".join(directives) + r")::\s*([^`\n]+)", _adjust, content 87 ) 88 if modified: 89 f.seek(0) 90 f.write(content_adj) 91 f.truncate() 92 93 94def sync_contents(app: Sphinx) -> None: 95 """Synchronize external contents. 96 97 Args: 98 app: Sphinx application instance. 99 """ 100 101 srcdir = Path(app.srcdir).resolve() 102 to_copy = [] 103 to_delete = set(f for f in srcdir.glob("**/*") if not f.is_dir()) 104 to_keep = set( 105 f 106 for k in app.config.external_content_keep 107 for f in srcdir.glob(k) 108 if not f.is_dir() 109 ) 110 111 for content in app.config.external_content_contents: 112 prefix_src, glob = content 113 for src in prefix_src.glob(glob): 114 if src.is_dir(): 115 to_copy.extend( 116 [(f, prefix_src) for f in src.glob("**/*") if not f.is_dir()] 117 ) 118 else: 119 to_copy.append((src, prefix_src)) 120 121 for entry in to_copy: 122 src, prefix_src = entry 123 dst = (srcdir / src.relative_to(prefix_src)).resolve() 124 125 if dst in to_delete: 126 to_delete.remove(dst) 127 128 if not dst.parent.exists(): 129 dst.parent.mkdir(parents=True) 130 131 # just copy if it does not exist 132 if not dst.exists(): 133 shutil.copy(src, dst) 134 adjust_includes( 135 dst, 136 src.parent, 137 app.config.external_content_directives, 138 app.config.source_encoding, 139 ) 140 # if origin file is modified only copy if different 141 elif src.stat().st_mtime > dst.stat().st_mtime: 142 with tempfile.TemporaryDirectory() as td: 143 # adjust origin includes before comparing 144 src_adjusted = Path(td) / src.name 145 shutil.copy(src, src_adjusted) 146 adjust_includes( 147 src_adjusted, 148 src.parent, 149 app.config.external_content_directives, 150 app.config.source_encoding, 151 dstpath=dst.parent, 152 ) 153 154 if not filecmp.cmp(src_adjusted, dst): 155 dst.unlink() 156 shutil.move(os.fspath(src_adjusted), os.fspath(dst)) 157 158 # remove any previously copied file not present in the origin folder, 159 # excepting those marked to be kept. 160 for file in to_delete - to_keep: 161 file.unlink() 162 163 164def setup(app: Sphinx) -> dict[str, Any]: 165 app.add_config_value("external_content_contents", [], "env") 166 app.add_config_value("external_content_directives", DEFAULT_DIRECTIVES, "env") 167 app.add_config_value("external_content_keep", [], "") 168 169 app.connect("builder-inited", sync_contents) 170 171 return { 172 "version": __version__, 173 "parallel_read_safe": True, 174 "parallel_write_safe": True, 175 } 176