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