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