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