[docs]  1# @Test suite for one one-line need per id when src-trace scopes overlap, TEST_ONE_NEED_PER_ID_1, test, [IMPL_LNK_1]
  2"""A one-line need two ``src-trace`` directives both find is created once (#2042).
  3
  4Every case builds a copy of ``doc_test/need_id_refs`` (see ``test_need_id_refs``) with
  5``src/impl.cpp`` holding the one-line need ``IMPL_1``, and ``index``'s directive narrowed
  6to ``:file: refs.cpp`` unless a case says otherwise, so that only the directives a case
  7adds find ``IMPL_1``. The first owner keeps an id -- the directive read first, a
  8hand-written need, whatever defined it -- and every other directive finding it skips it
  9with one ``codelinks.duplicate_need`` warning at the marker's line. A document that
 10skipped a need is read again when the owner's document changes or goes, so the need
 11moves instead of vanishing.
 12"""
 13
 14import hashlib
 15import re
 16from pathlib import Path
 17
 18import pytest
 19from sphinx.testing.util import SphinxTestApp
 20from sphinx.util.console import strip_colors
 21from sphinx.util.parallel import parallel_available
 22
 23from sphinx_needs_testkit import build_warnings
 24
 25from .test_need_id_refs import (
 26    _SHOWS_WARNING_TYPES,
 27    DANGLING,
 28    _build,
 29    _json,
 30    _MakeApp,
 31    _project,
 32)
 33from .test_rediscovery import _scoped, _status, _touch_later
 34from .test_url_links import _card_links as _field_links
 35
 36IMPL = "// @first impl, IMPL_1, impl, [REQ_001]\nvoid impl() {}\n"
 37#: the files every case starts from: index traces refs.cpp only
 38FILES = {
 39    **_scoped(":file: refs.cpp"),
 40    "src/impl.cpp": IMPL,
 41    "src/other.cpp": "// nothing to trace\n",
 42}
 43
 44
 45def _trace(option: str = ":file: impl.cpp", project: str = "src") -> str:
 46    return f"\n.. src-trace::\n   :project: {project}\n   {option}\n"
 47
 48
 49def _duplicate(
 50    owner: str,
 51    docname: str,
 52    location: str = "src/impl.cpp:1",
 53    need_id: str = "IMPL_1",
 54) -> str:
 55    suffix = " [codelinks.duplicate_need]" if _SHOWS_WARNING_TYPES else ""
 56    cure = (
 57        "give the markers distinct ids"
 58        if owner == docname
 59        else "narrow one directive's scope"
 60    )
 61    return (
 62        f"{location}: WARNING: one-line need {need_id!r} is already defined in document "
 63        f"{owner!r}: not created again by the src-trace directive in {docname!r} "
 64        f"({cure}){suffix}"
 65    )
 66
 67
 68def _duplicates(app: SphinxTestApp) -> list[str]:
 69    return [w for w in build_warnings(app) if "is already defined" in w]
 70
 71
 72def _owner(app: SphinxTestApp, need_id: str = "IMPL_1") -> str | None:
 73    """The document ``need_id`` is defined in, per ``needs.json`` (``None``: no need)."""
 74    need = _json(app)["needs"].get(need_id)
 75    return None if need is None else need["docname"]
 76
 77
 78def _cards(app: SphinxTestApp, page: str) -> int:
 79    html = Path(app.outdir, page).read_text(encoding="utf-8")
 80    return len(re.findall(r'id="IMPL_1"', html))
 81
 82
 83def _rewrite(path: Path, old: str, new: str) -> None:
 84    text = path.read_text(encoding="utf-8")
 85    assert old in text
 86    path.write_text(text.replace(old, new), encoding="utf-8")
 87    _touch_later(path)
 88
 89
 90#: a one-line style without ``id``: Sphinx-Needs generates it from the type's prefix and
 91#: the title (local URLs off here; on, in the #2082 case)
 92IDLESS = {
 93    "toml_extra": (
 94        "\n[codelinks.projects.src.analyse.oneline_comment_style]\n"
 95        'needs_fields = [{ name = "title" }, { name = "type", default = "impl" }]\n'
 96    ),
 97    "toml_replace": ("set_local_url = true", "set_local_url = false"),
 98}
 99IDLESS_FILES = {**FILES, "src/impl.cpp": "// @first impl\nvoid impl() {}\n"}
100#: what Sphinx-Needs generates for ``first impl``: the ``impl`` type's prefix (``I_``) and
101#: the first five characters of the title's SHA-1 (``needs_id_length``), upper case
102GENERATED = "I_" + hashlib.sha1(b"first impl").hexdigest().upper()[:5]
103
104
105def test_two_directives_in_one_document_define_the_need_once(
106    tmp_path: Path, make_app: _MakeApp
107) -> None:
108    """``index`` traces the whole project and then ``impl.cpp`` again: one need, one
109    warning at the marker's line, and the build goes on (it used to abort with
110    ``duplicate_id``)."""
111    _project(
112        tmp_path,
113        files={**FILES, **_scoped(None)},
114        append={"docs/index.rst": _trace()},
115    )
116    app = _build(tmp_path, make_app)
117
118    assert _owner(app) == "index"
119    assert _duplicates(app) == [_duplicate("index", "index")]
120    assert _cards(app, "index.html") == 1
121
122
123def test_two_documents_the_earlier_sorting_one_defines_it(
124    tmp_path: Path, make_app: _MakeApp
125) -> None:
126    """Sphinx reads in sorted order: ``page1`` defines ``IMPL_1``, ``page2`` skips it."""
127    _project(
128        tmp_path,
129        files=FILES,
130        append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
131    )
132    app = _build(tmp_path, make_app)
133
134    assert _owner(app) == "page1"
135    assert _duplicates(app) == [_duplicate("page1", "page2")]
136    # read time, before the attach's warning
137    assert build_warnings(app) == [_duplicate("page1", "page2"), DANGLING]
138    assert (_cards(app, "page1.html"), _cards(app, "page2.html")) == (1, 0)
139
140
141def test_the_need_moves_when_its_owner_is_removed(
142    tmp_path: Path, make_app: _MakeApp
143) -> None:
144    """``page2`` skipped ``IMPL_1``; once ``page1`` is gone, ``page2`` is read again
145    and defines it."""
146    _project(
147        tmp_path,
148        files=FILES,
149        append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
150    )
151    assert _owner(_build(tmp_path, make_app)) == "page1"
152
153    (tmp_path / "docs" / "page1.rst").unlink()
154    app = _build(tmp_path, make_app, freshenv=False)
155
156    assert "0 added, 2 changed, 1 removed" in _status(app)  # index: its glob toctree
157    assert re.search(r"reading sources\.\.\. \[[^\]]*\] page2\b", _status(app))
158    assert _owner(app) == "page2"
159    assert _duplicates(app) == []
160
161
162def test_the_need_moves_when_its_owner_no_longer_traces_it(
163    tmp_path: Path, make_app: _MakeApp
164) -> None:
165    """``page1``'s directive is narrowed to ``other.cpp``: ``page2`` is read again in
166    the same build and defines ``IMPL_1``."""
167    _project(
168        tmp_path,
169        files=FILES,
170        append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
171    )
172    _build(tmp_path, make_app)
173
174    _rewrite(tmp_path / "docs" / "page1.rst", ":file: impl.cpp", ":file: other.cpp")
175    app = _build(tmp_path, make_app, freshenv=False)
176
177    assert "0 added, 2 changed, 0 removed" in _status(app)
178    assert _owner(app) == "page2"
179    assert _duplicates(app) == []
180
181
182def test_the_need_moves_when_its_owner_loses_the_file_to_an_ignore_rule(
183    tmp_path: Path, make_app: _MakeApp
184) -> None:
185    """A ``.gitignore`` takes ``impl.cpp`` out of ``page1``'s directory scope: ``page1``
186    is read again because its scope changed (no dependency of it changed), and so is
187    ``page2``, which skipped the need."""
188    _project(
189        tmp_path,
190        files=FILES,
191        append={
192            "docs/page1.rst": _trace(":directory: ."),
193            "docs/page2.rst": _trace(),
194        },
195    )
196    assert _owner(_build(tmp_path, make_app)) == "page1"
197
198    (tmp_path / "src" / ".gitignore").write_text("impl.cpp\n", encoding="utf-8")
199    app = _build(tmp_path, make_app, freshenv=False)
200
201    assert "0 added, 2 changed, 0 removed" in _status(app)
202    assert _owner(app) == "page2"
203    assert _duplicates(app) == []
204
205
206def test_the_earlier_sorting_directive_takes_the_need_when_both_are_read(
207    tmp_path: Path, make_app: _MakeApp
208) -> None:
209    """An incremental build keeps the incumbent: ``page2`` defined ``IMPL_1`` before
210    ``page1`` traced it, and ``page1``, read alone, defers. Once both are read in one
211    build, the earlier-sorting ``page1`` defines it -- what a fresh build gives -- since
212    ``page2``'s old need is about to be purged."""
213    _project(tmp_path, files=FILES, append={"docs/page2.rst": _trace()})
214    assert _owner(_build(tmp_path, make_app)) == "page2"
215
216    page1 = tmp_path / "docs" / "page1.rst"
217    page1.write_text(page1.read_text(encoding="utf-8") + _trace(), encoding="utf-8")
218    _touch_later(page1)
219    incumbent = _build(tmp_path, make_app, freshenv=False)
220    assert "0 added, 1 changed, 0 removed" in _status(incumbent)
221    assert _owner(incumbent) == "page2"
222    assert _duplicates(incumbent) == [_duplicate("page2", "page1")]
223
224    for page in ("page1", "page2"):
225        path = tmp_path / "docs" / f"{page}.rst"
226        path.write_text(path.read_text(encoding="utf-8") + "\n", encoding="utf-8")
227        _touch_later(path)
228    app = _build(tmp_path, make_app, freshenv=False)
229
230    assert "0 added, 2 changed, 0 removed" in _status(app)
231    assert _owner(app) == "page1"
232    assert _duplicates(app) == [_duplicate("page1", "page2")]
233
234
235def test_a_hand_written_need_keeps_its_id(tmp_path: Path, make_app: _MakeApp) -> None:
236    """Whatever defined the id first owns it -- here an rst need in ``page1``; the
237    directive in ``page2`` skips the marker (it used to abort with ``duplicate_id``)."""
238    _project(
239        tmp_path,
240        files=FILES,
241        append={
242            "docs/page1.rst": "\n.. impl:: Hand-written\n   :id: IMPL_1\n",
243            "docs/page2.rst": _trace(),
244        },
245    )
246    app = _build(tmp_path, make_app)
247
248    assert _owner(app) == "page1"
249    assert _json(app)["needs"]["IMPL_1"]["title"] == "Hand-written"
250    assert _duplicates(app) == [_duplicate("page1", "page2")]
251
252
253def test_an_unchanged_owner_rereads_nothing(tmp_path: Path, make_app: _MakeApp) -> None:
254    """The skipping document is read again only when its owner changes or goes."""
255    _project(
256        tmp_path,
257        files=FILES,
258        append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
259    )
260    _build(tmp_path, make_app)
261    app = _build(tmp_path, make_app, freshenv=False)
262
263    assert "0 added, 0 changed, 0 removed" in _status(app)
264    assert _owner(app) == "page1"
265
266
267def test_nested_projects_define_the_need_once(
268    tmp_path: Path, make_app: _MakeApp
269) -> None:
270    """Ids are global: ``src`` and ``sub`` (``src/sub``) both find ``sub/x.cpp``; the
271    project read first defines the need, the other skips it -- no configuration
272    error."""
273    _project(
274        tmp_path,
275        files={**_scoped(None), "src/sub/x.cpp": IMPL},
276        append={"docs/later.rst": _trace("", project="sub")},
277        toml_extra=(
278            "\n[codelinks.projects.sub]\n"
279            'remote_url_pattern = "https://github.com/example/demo/blob/{commit}/{path}#L{line}"\n'
280            "\n[codelinks.projects.sub.source_discover]\n"
281            'src_dir = "../src/sub"\n'
282            'comment_type = "cpp"\n'
283        ),
284    )
285    app = _build(tmp_path, make_app)
286
287    assert _owner(app) == "index"
288    assert _duplicates(app) == [_duplicate("index", "later", "src/sub/x.cpp:1")]
289
290
291def test_the_summary_line_counts_the_skipped_needs(
292    tmp_path: Path, make_app: _MakeApp
293) -> None:
294    _project(
295        tmp_path,
296        files=FILES,
297        append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
298    )
299    app = _build(tmp_path, make_app)
300
301    lines = re.findall(r"codelinks \[src\]: [^\n]*", _status(app))
302    # one line per directive (index, page1, page2) and the attach's; only page2 skipped
303    assert len(lines) == 4
304    assert [line for line in lines if "skipped" in line] == [
305        "codelinks [src]: 1 file, 1 marker, 1 skipped (already defined)"
306    ]
307
308
309@pytest.mark.skipif(
310    not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
311)
312def test_parallel_directives_in_two_chunks_leave_one_need(
313    tmp_path: Path, make_app: _MakeApp
314) -> None:
315    """``-j 2`` reads ``index .. page2`` and ``page3 .. sub/deep`` in two workers, which
316    cannot see each other's needs: both directives define ``IMPL_1``, and Sphinx-Needs'
317    merge keeps one and warns ``needs.duplicate_id`` -- accepted, and documented."""
318    _project(
319        tmp_path,
320        files=FILES,
321        append={"docs/page1.rst": _trace(), "docs/page3.rst": _trace()},
322    )
323    app = _build(tmp_path, make_app, parallel=2)
324
325    status = strip_colors(app._status.getvalue())
326    assert re.search(r"reading sources\.\.\. \[\s*\d+%\] \S+ \.\. \S+", status)
327    assert _owner(app) in {"page1", "page3"}
328    merged = [w for w in build_warnings(app) if "IMPL_1 already exists" in w]
329    assert len(merged) == 1, build_warnings(app)
330    if _SHOWS_WARNING_TYPES:
331        assert merged[0].endswith("[needs.duplicate_id]")
332    assert _duplicates(app) == []
333
334
335def test_a_scope_record_pickled_before_deferred_existed_still_loads() -> None:
336    """An environment pickled by an earlier release holds ``ScopeRecord`` objects
337    without the ``deferred`` field; they load, and defer nothing."""
338    import pickle
339
340    from sphinx_codelinks.sphinx_extension.rediscovery import ScopeRecord
341
342    record = ScopeRecord(project="src", kind="file", target="a.cpp", fingerprint=())
343    object.__delattr__(record, "deferred")  # the state an older class pickled
344    assert "deferred" not in vars(record)
345
346    loaded = pickle.loads(pickle.dumps(record))
347    assert loaded.deferred == ()
348    assert loaded == ScopeRecord(
349        project="src", kind="file", target="a.cpp", fingerprint=()
350    )
351
352
353@pytest.mark.parametrize("layout", ["one-document", "two-documents"])
354def test_a_generated_id_is_defined_once(
355    tmp_path: Path, make_app: _MakeApp, layout: str
356) -> None:
357    """A one-line style without ``id``: the id Sphinx-Needs would generate is checked
358    like a written one (it used to abort with ``Unique ID could not be generated``)."""
359    if layout == "one-document":
360        files = {**IDLESS_FILES, **_scoped(None)}
361        append = {"docs/index.rst": _trace()}
362        owner, other = "index", "index"
363    else:
364        files = IDLESS_FILES
365        append = {"docs/page1.rst": _trace(), "docs/page2.rst": _trace()}
366        owner, other = "page1", "page2"
367    _project(tmp_path, files=files, append=append, **IDLESS)  # type: ignore[arg-type]
368    app = _build(tmp_path, make_app)
369
370    needs = _json(app)["needs"]
371    assert [n for n, need in needs.items() if need["title"] == "first impl"] == [
372        GENERATED
373    ]
374    assert _owner(app, GENERATED) == owner
375    assert _duplicates(app) == [_duplicate(owner, other, need_id=GENERATED)]
376
377
378def test_a_generated_id_with_local_urls_links_its_source_page(
379    tmp_path: Path, make_app: _MakeApp
380) -> None:
381    """A one-line style without ``id`` and local URLs on (no remote URL): the build
382    goes on (it used to abort with ``KeyError: 'id'``), the card's local link names the
383    source page, and that page's ``[docs]`` anchor links back to the generated id
384    (#2082)."""
385    _project(
386        tmp_path,
387        files={**IDLESS_FILES, **_scoped(None)},
388        toml_extra=IDLESS["toml_extra"],
389        toml_replace=("set_remote_url = true", "set_remote_url = false"),
390    )
391    app = _build(tmp_path, make_app)
392
393    assert build_warnings(app) == [DANGLING]
394    needs = _json(app)["needs"]
395    assert needs[GENERATED]["local-url"] == "src/impl.cpp#L1"
396    index = Path(app.outdir, "index.html").read_text(encoding="utf-8")
397    assert _field_links(index, "local-url") == ["src/impl.html#L-1"]
398    page = Path(app.outdir, "src", "impl.html").read_text(encoding="utf-8")
399    assert re.findall(
400        r'<a class="viewcode-back" href="([^"]*)">\[docs\]</a>', page
401    ) == [f"../index.html#{GENERATED}"]
402
403
404@pytest.mark.parametrize("generated", [False, True], ids=["written-id", "generated-id"])
405def test_a_directive_moved_to_another_document(
406    tmp_path: Path, make_app: _MakeApp, generated: bool
407) -> None:
408    """No overlap at any time: ``page2``'s trace moves to ``page1`` in one incremental
409    build. ``page1`` is read first, while ``page2``'s old need is still in the store; it
410    is about to be purged, so ``page1`` defines the need (this used to abort with
411    ``duplicate_id``)."""
412    need_id = GENERATED if generated else "IMPL_1"
413    _project(
414        tmp_path,
415        files=IDLESS_FILES if generated else FILES,
416        append={"docs/page2.rst": _trace()},
417        **(IDLESS if generated else {}),  # type: ignore[arg-type]
418    )
419    assert _owner(_build(tmp_path, make_app), need_id) == "page2"
420
421    _rewrite(tmp_path / "docs" / "page2.rst", ":file: impl.cpp", ":file: other.cpp")
422    page1 = tmp_path / "docs" / "page1.rst"
423    page1.write_text(page1.read_text(encoding="utf-8") + _trace(), encoding="utf-8")
424    _touch_later(page1)
425    app = _build(tmp_path, make_app, freshenv=False)
426
427    assert "0 added, 2 changed, 0 removed" in _status(app)
428    assert _owner(app, need_id) == "page1"
429    assert _duplicates(app) == []