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 for k in app.config.external_content_keep for f in srcdir.glob(k) if not f.is_dir() 106 ) 107 108 def _pattern_excludes(f): 109 # backup files 110 return f.match('.#*') or f.match('*~') 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 [ 118 (f, prefix_src) 119 for f in src.glob("**/*") 120 if (not f.is_dir() and not _pattern_excludes(f)) 121 ] 122 ) 123 elif not _pattern_excludes(src): 124 to_copy.append((src, prefix_src)) 125 126 for entry in to_copy: 127 src, prefix_src = entry 128 dst = (srcdir / src.relative_to(prefix_src)).resolve() 129 130 if dst in to_delete: 131 to_delete.remove(dst) 132 133 if not dst.parent.exists(): 134 dst.parent.mkdir(parents=True) 135 136 # just copy if it does not exist 137 if not dst.exists(): 138 shutil.copy(src, dst) 139 adjust_includes( 140 dst, 141 src.parent, 142 app.config.external_content_directives, 143 app.config.source_encoding, 144 ) 145 # if origin file is modified only copy if different 146 elif src.stat().st_mtime > dst.stat().st_mtime: 147 with tempfile.TemporaryDirectory() as td: 148 # adjust origin includes before comparing 149 src_adjusted = Path(td) / src.name 150 shutil.copy(src, src_adjusted) 151 adjust_includes( 152 src_adjusted, 153 src.parent, 154 app.config.external_content_directives, 155 app.config.source_encoding, 156 dstpath=dst.parent, 157 ) 158 159 if not filecmp.cmp(src_adjusted, dst): 160 dst.unlink() 161 shutil.move(os.fspath(src_adjusted), os.fspath(dst)) 162 163 # remove any previously copied file not present in the origin folder, 164 # excepting those marked to be kept. 165 for file in to_delete - to_keep: 166 file.unlink() 167 168 169def setup(app: Sphinx) -> dict[str, Any]: 170 app.add_config_value("external_content_contents", [], "env") 171 app.add_config_value("external_content_directives", DEFAULT_DIRECTIVES, "env") 172 app.add_config_value("external_content_keep", [], "") 173 174 app.connect("builder-inited", sync_contents) 175 176 return { 177 "version": __version__, 178 "parallel_read_safe": True, 179 "parallel_write_safe": True, 180 } 181