[docs]  1# @Test suite for Sphinx extension source tracing functionality, TEST_EXT_1, test, [IMPL_LNK_1, IMPL_ONE_1, IMPL_MRST_1]
  2import shutil
  3from collections.abc import Callable
  4from dataclasses import fields
  5from pathlib import Path
  6
  7import pytest
  8from sphinx.environment import CONFIG_OK
  9from sphinx.testing.util import SphinxTestApp
 10
 11from sphinx_codelinks.analyse.projects import AnalyseProjects
 12from sphinx_codelinks.config import (
 13    SRC_TRACE_CACHE,
 14    CodeLinksConfig,
 15    check_configuration,
 16)
 17from sphinx_codelinks.sphinx_extension.source_tracing import set_config_to_sphinx
 18
 19
 20@pytest.mark.parametrize(
 21    ("codelinks_config", "result"),
 22    [
 23        (
 24            {
 25                "remote_url_field": 555,
 26                "local_url_field": 789,
 27                "set_local_url": "fdd",
 28                "set_remote_url": "TrueString",
 29                "projects": {
 30                    "dcdc": {
 31                        "remote_url_pattern": 44332,
 32                        "source_discover": {
 33                            "comment_type": "java",
 34                            "src_dir": ["../dcdc"],
 35                            "exclude": [123],
 36                            "include": [345],
 37                            "gitignore": "_true",
 38                        },
 39                        "analyse": {
 40                            "oneline_comment_style": {
 41                                "start_sequence": "[[",
 42                                "end_sequence": "]]",
 43                                "field_split_char": ",",
 44                                "needs_fields": [
 45                                    {
 46                                        "name": "title",
 47                                        "type": "list[]",
 48                                    },
 49                                    {
 50                                        "name": "type",
 51                                        "default": "impl",
 52                                        "type": "str",
 53                                    },
 54                                ],
 55                            },
 56                        },
 57                    }
 58                },
 59            },
 60            [
 61                "Project 'dcdc' has the following errors:",
 62                "Schema validation error in field 'exclude': 123 is not of type 'string'",
 63                "Schema validation error in field 'comment_type': 'java' is not one of ['bash', 'cpp', 'cs', 'go', 'jsonc', 'python', 'rust', 'yaml']",
 64                "Schema validation error in field 'gitignore': '_true' is not of type 'boolean'",
 65                "Schema validation error in field 'include': 345 is not of type 'string'",
 66                "Schema validation error in field 'src_dir': ['../dcdc'] is not of type 'string'",
 67                "Schema validation error in filed 'local_url_field': 789 is not of type 'string'",
 68                "Schema validation error in filed 'remote_url_field': 555 is not of type 'string'",
 69                "Schema validation error in filed 'set_local_url': 'fdd' is not of type 'boolean'",
 70                "Schema validation error in filed 'set_remote_url': 'TrueString' is not of type 'boolean'",
 71                "OneLineCommentStyle configuration errors:",
 72                "Schema validation error in need_fields 'title': 'list[]' is not one of ['str', 'list[str]']",
 73                "remote_url_pattern must be a string",
 74            ],
 75        ),
 76        (
 77            {
 78                "remote_url_field": "remote-url",
 79                "local_url_field": "local-url",
 80                "set_local_url": True,
 81                "set_remote_url": True,
 82                "projects": {
 83                    "dcdc": {
 84                        # intentionally not given "remote_url_pattern": "https://github.com/useblocks/sphinx-codelinks/blob/{commit}/{path}#L{line}",
 85                        "source_discover": {
 86                            "comment_type": "cpp",
 87                            "src_dir": "../dcdc",
 88                            "exclude": [],
 89                            "include": [],
 90                            "gitignore": True,
 91                        },
 92                        "analyse": {
 93                            "oneline_comment_style": {
 94                                "start_sequence": "[[",
 95                                "end_sequence": "]]",
 96                                "field_split_char": ",",
 97                                "needs_fields": [
 98                                    {
 99                                        "name": "title",
100                                        "type": "str",
101                                    },
102                                    {
103                                        "name": "type",
104                                        "default": "impl",
105                                        "type": "str",
106                                    },
107                                ],
108                            },
109                        },
110                    }
111                },
112            },
113            [
114                "Project 'dcdc' has the following errors:",
115                "remote_url_pattern must be given, as set_remote_url is enabled",
116            ],
117        ),
118    ],
119)
120def test_src_tracing_config_negative(
121    make_app: Callable[..., SphinxTestApp],
122    codelinks_config,
123    result,
124):
125    this_file_dir = Path(__file__).parent
126    sphinx_project = Path("data") / "sphinx"
127    app = make_app(srcdir=(this_file_dir / sphinx_project))
128    set_config_to_sphinx(codelinks_config, app.env.config)
129    codelinks_sphinx_config = CodeLinksConfig.from_sphinx(app.env.config)
130    errors = check_configuration(codelinks_sphinx_config)
131    assert sorted(errors) == sorted(result)
132
133
134def test_src_tracing_config_positive(make_app: Callable[..., SphinxTestApp], tmp_path):
135    codelinks_config = {
136        "remote_url_field": "remote-url",
137        "local_url_field": "local-url",
138        "set_local_url": True,
139        "set_remote_url": True,
140        "outdir": tmp_path,
141        "projects": {
142            "dcdc": {
143                "source_discover": {
144                    "comment_type": "cpp",
145                    "src_dir": "../dcdc",
146                    "exclude": ["**/*.hpp"],
147                    "include": ["**/*.cpp"],
148                    "gitignore": True,
149                },
150                "remote_url_pattern": "https://github.com/useblocks/sphinx-codelinks/blob/{commit}/{path}#L{line}",
151                "analyse": {
152                    "oneline_comment_style": {
153                        "start_sequence": "[[",
154                        "end_sequence": "]]",
155                        "field_split_char": ",",
156                        "needs_fields": [
157                            {
158                                "name": "title",
159                                "type": "str",
160                            },
161                            {
162                                "name": "type",
163                                "default": "impl",
164                                "type": "str",
165                            },
166                        ],
167                    },
168                },
169            }
170        },
171    }
172    this_file_dir = Path(__file__).parent
173    sphinx_project = Path("data") / "sphinx"
174    app = make_app(srcdir=(this_file_dir / sphinx_project))
175    set_config_to_sphinx(codelinks_config, app.env.config)
176    codelinks_sphinx_config = CodeLinksConfig.from_sphinx(app.env.config)
177    errors = check_configuration(codelinks_sphinx_config)
178    assert not errors
179
180
181@pytest.mark.parametrize(
182    ("sphinx_project", "source_code"),
183    [
184        (Path("data") / "sphinx", Path("data") / "dcdc"),
185        (
186            Path("doc_test") / "recursive_dirs",
187            Path("doc_test") / "recursive_dirs" / "dummy_src_lv1",
188        ),
189        (
190            Path("doc_test") / "minimum_config",
191            Path("doc_test") / "minimum_config",
192        ),
193        (
194            Path("doc_test") / "id_required",
195            Path("doc_test") / "id_required",
196        ),
197        (
198            Path("doc_test") / "cs_basic",
199            Path("doc_test") / "cs_basic",
200        ),
201        (
202            Path("doc_test") / "go_basic",
203            Path("doc_test") / "go_basic",
204        ),
205    ],
206)
207def test_build_html(
208    tmpdir: Path,
209    make_app: Callable[..., SphinxTestApp],
210    sphinx_project,
211    source_code,
212    snapshot_doctree,
213):
214    this_file_dir = Path(__file__).parent
215
216    sphinx_src_dir = tmpdir / sphinx_project
217    shutil.copytree(
218        this_file_dir / sphinx_project,
219        sphinx_src_dir,
220        dirs_exist_ok=True,
221    )
222    shutil.copytree(
223        this_file_dir / source_code,
224        tmpdir / source_code,
225        dirs_exist_ok=True,
226    )
227
228    app: SphinxTestApp = make_app(
229        srcdir=Path(sphinx_src_dir),
230        freshenv=True,
231    )
232    app.build()
233
234    html = Path(app.outdir, "index.html").read_text()
235    assert html
236
237    warnings = AnalyseProjects.load_warnings(Path(app.outdir) / SRC_TRACE_CACHE)
238    assert not warnings
239
240    assert app.env.get_doctree("index") == snapshot_doctree
241
242
243def test_incremental_build_keeps_src_trace_projects_unchanged(
244    tmpdir: Path,
245    make_app: Callable[..., SphinxTestApp],
246) -> None:
247    """An incremental rebuild with no source changes must not invalidate the env.
248
249    Regression test for the ``src-trace`` directive mutating the ``analyse_config``
250    object stored inside the ``rebuild="env"`` ``src_trace_projects`` config value.
251    The mutated object (populated ``src_dir``/``src_files``) was persisted into
252    ``environment.pickle``, so every incremental build compared it against the
253    freshly generated (empty) config and reported
254    ``[config changed ('src_trace_projects')]``, forcing a full re-read.
255    """
256    this_file_dir = Path(__file__).parent
257    sphinx_project = Path("data") / "sphinx"
258    source_code = Path("data") / "dcdc"
259
260    sphinx_src_dir = Path(tmpdir) / sphinx_project
261    shutil.copytree(this_file_dir / sphinx_project, sphinx_src_dir, dirs_exist_ok=True)
262    shutil.copytree(
263        this_file_dir / source_code, Path(tmpdir) / source_code, dirs_exist_ok=True
264    )
265
266    # First build populates environment.pickle in the shared build dir.
267    make_app(srcdir=sphinx_src_dir, freshenv=True).build()
268
269    # Second build reuses the same build dir and loads the pickled environment.
270    app = make_app(srcdir=sphinx_src_dir, freshenv=False)
271
272    captured: dict[str, object] = {}
273
274    def capture_config_status(_app, env, _added, _changed, _removed):
275        # ``env-get-outdated`` fires during read() after the config comparison
276        # but before config_status is reset to CONFIG_OK at the end of read().
277        captured["status"] = env.config_status
278        captured["extra"] = env.config_status_extra
279        return []
280
281    app.connect("env-get-outdated", capture_config_status)
282    app.build()
283
284    assert captured["status"] == CONFIG_OK, (
285        f"incremental build wrongly invalidated the environment: "
286        f"config changed{captured.get('extra')}"
287    )
288
289
290@pytest.fixture
291def minimal_sphinx_project(tmp_path: Path) -> Path:
292    """Minimal Sphinx project with no TOML config file next to conf.py."""
293    (tmp_path / "conf.py").write_text(
294        "extensions = ['sphinx_needs', 'sphinx_codelinks']\n"
295        "exclude_patterns = ['_build']\n"
296    )
297    (tmp_path / "index.rst").write_text("Minimal project\n===============\n")
298    return tmp_path
299
300
301def test_config_from_toml_defaults_to_ubproject_toml() -> None:
302    """The default config file is the shared ubproject.toml (ubcode-pub#75)."""
303    config_field = next(
304        field for field in fields(CodeLinksConfig) if field.name == "config_from_toml"
305    )
306    assert config_field.default == "ubproject.toml"
307
308
309def test_default_ubproject_toml_is_loaded(
310    minimal_sphinx_project: Path,
311    make_app: Callable[..., SphinxTestApp],
312) -> None:
313    """An ubproject.toml next to conf.py is loaded without any conf.py entry."""
314    (minimal_sphinx_project / "ubproject.toml").write_text(
315        "[codelinks.projects.demo]\n"
316        'remote_url_pattern = "https://example.com/{commit}/{path}#L{line}"\n'
317        "\n"
318        "[codelinks.projects.demo.source_discover]\n"
319        'src_dir = "./"\n'
320    )
321    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
322    app.build()
323
324    assert "demo" in app.config.src_trace_projects
325    assert app.warning.getvalue() == ""
326
327
328def test_default_ubproject_toml_without_codelinks_section_is_silent(
329    minimal_sphinx_project: Path,
330    make_app: Callable[..., SphinxTestApp],
331) -> None:
332    """A default ubproject.toml used by other tools but without [codelinks] is
333    silently ignored instead of warning."""
334    (minimal_sphinx_project / "ubproject.toml").write_text(
335        "[needs]\nid_required = true\n"
336    )
337    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
338    app.build()
339
340    assert app.config.src_trace_projects == {}
341    assert app.warning.getvalue() == ""
342
343
344def test_missing_default_ubproject_toml_is_silent(
345    minimal_sphinx_project: Path,
346    make_app: Callable[..., SphinxTestApp],
347) -> None:
348    """Without an ubproject.toml next to conf.py, the conf.py configuration is
349    used and no warning is emitted."""
350    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
351    app.build()
352
353    assert app.config.src_trace_projects == {}
354    assert app.warning.getvalue() == ""
355
356
357def test_explicit_toml_config_missing_warns(
358    minimal_sphinx_project: Path,
359    make_app: Callable[..., SphinxTestApp],
360) -> None:
361    """An explicitly configured TOML file that does not exist still warns."""
362    conf_py = minimal_sphinx_project / "conf.py"
363    conf_py.write_text(
364        conf_py.read_text() + '\nsrc_trace_config_from_toml = "nonexistent.toml"\n'
365    )
366    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
367    app.build()
368
369    assert "does not exist" in app.warning.getvalue()