[docs] 1# @Test suite for need id references attached during the build, TEST_NEED_ID_REFS_1, test, [IMPL_LNK_1]
2"""The references of ``@need-ids:`` markers, attached to the needs they name.
3
4Every case builds a copy of ``doc_test/need_id_refs`` made into a git repository, so the
5remote URLs carry a real commit. ``src/refs.cpp`` references ``REQ_001`` (lines 1 and 3),
6``REQ_002`` (line 3), the unknown ``NOSUCH_ID`` (line 5) and ``REQ_003`` (line 7);
7``REQ_001`` is defined in ``index`` before the ``src-trace`` directive, ``REQ_002`` in
8``later`` -- read after it -- and ``REQ_003`` in ``sub/deep``. ``SPEC_001`` is referenced
9by nothing.
10"""
11
12import codecs
13import json
14import os
15import re
16import shutil
17import subprocess
18import sys
19from collections.abc import Callable
20from pathlib import Path
21from typing import Any
22
23import pytest
24import sphinx
25from sphinx.testing.util import SphinxTestApp
26from sphinx.util.console import strip_colors
27from sphinx.util.parallel import parallel_available
28
29from sphinx_codelinks.sphinx_extension.need_id_refs import need_id_refs_store
30from sphinx_needs.data import SphinxNeedsData
31from sphinx_needs_testkit import (
32 assert_no_warnings,
33 build_warnings,
34 sphinx_build_command,
35)
36
37FIXTURE = Path(__file__).parent / "doc_test" / "need_id_refs"
38GITHUB = "https://github.com/example/demo/blob/{commit}/{path}#L{line}"
39
40#: Sphinx 8 renders a warning's ``[type.subtype]`` itself; 7.4 does not
41_SHOWS_WARNING_TYPES = sphinx.version_info >= (8,)
42
43_MakeApp = Callable[..., SphinxTestApp]
44
45
46def _git(root: Path, *args: str) -> str:
47 command = ["git", "-c", "user.name=t", "-c", "user.email=t@t"]
48 command += ["-c", "commit.gpgsign=false", *args]
49 return subprocess.run(
50 command, cwd=root, check=True, capture_output=True, text=True
51 ).stdout.strip()
52
53
54def _project(
55 root: Path,
56 *,
57 toml_extra: str = "",
58 toml_replace: tuple[str, str] | None = None,
59 files: dict[str, str] | None = None,
60 append: dict[str, str] | None = None,
61 git: bool = True,
62) -> str:
63 """Copy the fixture into ``root``, change it, commit it; return the commit (or
64 ``""`` with ``git=False``: no repository at all)."""
65 shutil.copytree(FIXTURE, root, dirs_exist_ok=True)
66 toml = root / "docs" / "ubproject.toml"
67 text = toml.read_text(encoding="utf-8")
68 if toml_replace is not None:
69 assert toml_replace[0] in text
70 text = text.replace(*toml_replace)
71 toml.write_text(text + toml_extra, encoding="utf-8")
72 for relative, content in (files or {}).items():
73 path = root / relative
74 path.parent.mkdir(parents=True, exist_ok=True)
75 path.write_text(content, encoding="utf-8")
76 for relative, content in (append or {}).items():
77 path = root / relative
78 path.write_text(path.read_text(encoding="utf-8") + content, encoding="utf-8")
79 if not git:
80 return ""
81 _git(root, "init", "--quiet")
82 _git(root, "remote", "add", "origin", "https://github.com/example/demo.git")
83 _git(root, "add", "-A")
84 _git(root, "commit", "--quiet", "-m", "init")
85 return _git(root, "rev-parse", "HEAD")
86
87
88def _build(root: Path, make_app: _MakeApp, **kwargs: Any) -> SphinxTestApp:
89 kwargs.setdefault("freshenv", True)
90 app = make_app(srcdir=root / "docs", **kwargs)
91 app.build()
92 return app
93
94
95def _json(app: SphinxTestApp) -> dict[str, Any]:
96 data = json.loads(Path(app.outdir, "needs.json").read_text(encoding="utf-8"))
97 return data["versions"][data["current_version"]]
98
99
100def _refs(app: SphinxTestApp) -> dict[str, Any]:
101 """Each need's ``code_url``, or ``"<absent>"`` when needs.json has no such key."""
102 needs = _json(app)["needs"]
103 return {
104 need_id: need.get("code_url", "<absent>") for need_id, need in needs.items()
105 }
106
107
108def _url(commit: str, line: int, path: str = "src/refs.cpp") -> str:
109 return GITHUB.format(commit=commit, path=path, line=line)
110
111
112def _card_links(app: SphinxTestApp, page: str) -> list[list[tuple[str, str]]]:
113 """Per card on ``page``, the ``(href, name)`` of each link in its ``code_url`` row."""
114 html = Path(app.outdir, page).read_text(encoding="utf-8")
115 rows = re.findall(r'<span class="needs_code_url">(.*?)</div>', html, re.S)
116 return [re.findall(r'href="([^"]*)">([^<]*)</a>', row) for row in rows]
117
118
119def _warning(location: str, message: str) -> str:
120 suffix = " [codelinks.need_id_ref]" if _SHOWS_WARNING_TYPES else ""
121 return f"{location}: WARNING: {message}{suffix}"
122
123
124DANGLING = _warning("src/refs.cpp:5", "@need-ids reference to unknown need 'NOSUCH_ID'")
125
126
127def test_references_attach_to_needs_in_any_document(
128 tmp_path: Path, make_app: _MakeApp
129) -> None:
130 """Same document, a later one and a nested one alike; N references give N
131 entries, in line order; an unreferenced need carries nothing; nothing is
132 marked as modified; ``needs_schema`` declares the field as a list."""
133 commit = _project(tmp_path)
134 app = _build(tmp_path, make_app)
135
136 assert build_warnings(app) == [DANGLING]
137 refs = _refs(app)
138 assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
139 assert refs["REQ_002"] == [_url(commit, 3)]
140 assert refs["REQ_003"] == [_url(commit, 7)]
141 assert refs["SPEC_001"] is None
142 needs = _json(app)["needs"]
143 assert not any(need["is_modified"] for need in needs.values())
144 schema = _json(app)["needs_schema"]["properties"]["code_url"]
145 assert schema["type"] == ["array", "null"]
146 assert schema["items"] == {"type": "string"}
147 assert schema["default"] is None
148
149 assert _card_links(app, "index.html") == [
150 [
151 (_url(commit, 1), "src/refs.cpp#L1"),
152 (_url(commit, 3), "src/refs.cpp#L3"),
153 ]
154 ]
155 assert _card_links(app, "later.html") == [[(_url(commit, 3), "src/refs.cpp#L3")]]
156 # the value is remote, so the reference-only file is neither copied nor paged
157 assert not Path(app.outdir, "src").exists()
158
159
160def test_unknown_id_warns_at_the_source_line(
161 tmp_path: Path, make_app: _MakeApp
162) -> None:
163 _project(tmp_path)
164 app = _build(tmp_path, make_app)
165
166 assert build_warnings(app) == [DANGLING]
167 status = app._status.getvalue()
168 assert "codelinks [src]: 4 references attached, 1 unknown" in status
169
170
171def test_unknown_id_warning_is_suppressible(tmp_path: Path, make_app: _MakeApp) -> None:
172 _project(tmp_path)
173 app = _build(
174 tmp_path,
175 make_app,
176 confoverrides={"suppress_warnings": ["codelinks.need_id_ref"]},
177 )
178
179 assert_no_warnings(app)
180 assert _refs(app)["REQ_002"] is not None
181
182
183@pytest.mark.skipif(
184 not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
185)
186def test_parallel_build_attaches_as_a_serial_one(
187 tmp_path: Path, make_app: _MakeApp
188) -> None:
189 """``-j 2`` reads the directive in a worker; its records reach the main process."""
190 serial_root = tmp_path / "serial"
191 parallel_root = tmp_path / "parallel"
192 commit = _project(serial_root)
193 _project(parallel_root)
194 serial = _build(serial_root, make_app)
195 parallel = _build(parallel_root, make_app, parallel=2)
196 # the read really was parallel: Sphinx reports chunks, "page1 .. page3"
197 status = strip_colors(parallel._status.getvalue())
198 assert re.search(r"reading sources\.\.\. \[\s*\d+%\] \S+ \.\. \S+", status)
199
200 def _normalised(app: SphinxTestApp) -> dict[str, Any]:
201 own_commit = _git(Path(app.srcdir).parent, "rev-parse", "HEAD")
202 text = json.dumps(_refs(app)).replace(own_commit, "<commit>")
203 return json.loads(text)
204
205 assert _refs(serial)["REQ_002"] == [_url(commit, 3)]
206 assert _normalised(parallel) == _normalised(serial)
207 # a second application in one process also re-registers Sphinx' own nodes
208 assert [w for w in build_warnings(parallel) if "@need-ids" in w] == [DANGLING]
209
210
211def test_unchanged_rebuild_keeps_the_references(
212 tmp_path: Path, make_app: _MakeApp
213) -> None:
214 """Nothing is re-read, and the references still attach, from the pickled env."""
215 commit = _project(tmp_path)
216 _build(tmp_path, make_app)
217 app = _build(tmp_path, make_app, freshenv=False)
218
219 assert "0 added, 0 changed, 0 removed" in app._status.getvalue()
220 refs = _refs(app)
221 assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
222 assert refs["REQ_002"] == [_url(commit, 3)]
223
224
225def test_source_edit_clears_a_stale_reference(
226 tmp_path: Path, make_app: _MakeApp
227) -> None:
228 """Editing only the source re-reads the hosting document, whose old records are
229 dropped: ``REQ_002`` loses the reference the edit removed."""
230 commit = _project(tmp_path)
231 _build(tmp_path, make_app)
232 source = tmp_path / "src" / "refs.cpp"
233 source.write_text(
234 source.read_text(encoding="utf-8").replace(
235 "@need-ids: REQ_002, REQ_001", "@need-ids: REQ_001"
236 ),
237 encoding="utf-8",
238 )
239 later = source.stat().st_mtime + 10
240 os.utime(source, (later, later))
241 app = _build(tmp_path, make_app, freshenv=False)
242
243 assert "0 added, 1 changed, 0 removed" in app._status.getvalue()
244 refs = _refs(app)
245 assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
246 assert refs["REQ_002"] is None
247
248
249def test_user_needextend_of_the_field_wins(tmp_path: Path, make_app: _MakeApp) -> None:
250 """The attach runs before ``needextend`` is applied: a user's extend overrides it."""
251 commit = _project(
252 tmp_path,
253 files={
254 "docs/override.rst": (
255 "Override\n========\n\n"
256 ".. needextend:: REQ_002\n :code_url: user-override\n"
257 )
258 },
259 )
260 app = _build(tmp_path, make_app)
261
262 refs = _refs(app)
263 assert refs["REQ_002"] == ["user-override"]
264 assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
265
266
267def test_strict_schema_never_sees_the_field_on_an_unreferenced_need(
268 tmp_path: Path, make_app: _MakeApp
269) -> None:
270 """``SPEC_001`` is validated by a schema with ``unevaluatedProperties: false``:
271 the field is unset there, so it is stripped before validation."""
272 _project(tmp_path)
273 app = _build(
274 tmp_path,
275 make_app,
276 confoverrides={"needs_schema_definitions_from_json": "schemas.json"},
277 )
278
279 report = json.loads(
280 Path(app.outdir, "schema_violations.json").read_text(encoding="utf-8")
281 )
282 assert report["validated_needs_count"] >= 1
283 assert report["validation_warnings"] == {}
284 assert build_warnings(app) == [DANGLING]
285
286
287def test_overlapping_directives_do_not_double_the_references(
288 tmp_path: Path, make_app: _MakeApp
289) -> None:
290 """A second directive over the same file analyses it again (one walk per
291 directive): its records are the same, and each reference is attached once."""
292 commit = _project(
293 tmp_path,
294 append={
295 "docs/later.rst": "\n.. src-trace::\n :project: src\n :file: refs.cpp\n"
296 },
297 )
298 app = _build(tmp_path, make_app)
299
300 refs = _refs(app)
301 assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
302 assert refs["REQ_002"] == [_url(commit, 3)]
303 assert build_warnings(app) == [DANGLING]
304
305
306def test_two_projects_naming_one_field_share_its_list(
307 tmp_path: Path, make_app: _MakeApp
308) -> None:
309 gitlab = "https://gitlab.example.com/demo/-/blob/{commit}/{path}#L{line}"
310 commit = _project(
311 tmp_path,
312 toml_extra=(
313 "\n[codelinks.projects.two]\n"
314 f'remote_url_pattern = "{gitlab}"\n'
315 "[codelinks.projects.two.source_discover]\n"
316 'src_dir = "../src2"\n'
317 'comment_type = "cpp"\n'
318 ),
319 files={"src2/more.cpp": "// @need-ids: REQ_001\nvoid more() {}\n"},
320 append={"docs/later.rst": "\n.. src-trace::\n :project: two\n"},
321 )
322 app = _build(tmp_path, make_app)
323
324 assert _refs(app)["REQ_001"] == [
325 _url(commit, 1),
326 _url(commit, 3),
327 gitlab.format(commit=commit, path="src2/more.cpp", line=1),
328 ]
329 assert "codelinks [two]: 1 reference attached, 0 unknown" in app._status.getvalue()
330
331
332@pytest.mark.parametrize("remote", [True, False], ids=["remote-on", "local-only"])
333def test_empty_ref_url_field_attaches_nothing(
334 tmp_path: Path, make_app: _MakeApp, remote: bool
335) -> None:
336 """``ref_url_field = ""`` (ubCode's off switch): no field, no attach, no warning --
337 and the directive collects nothing and copies nothing for the project."""
338 _project(
339 tmp_path,
340 toml_replace=(
341 "[codelinks.projects.src]\n",
342 '[codelinks.projects.src]\nref_url_field = ""\n',
343 ),
344 )
345 if not remote:
346 toml = tmp_path / "docs" / "ubproject.toml"
347 toml.write_text(
348 toml.read_text(encoding="utf-8").replace(
349 "set_remote_url = true", "set_remote_url = false"
350 ),
351 encoding="utf-8",
352 )
353 app = _build(tmp_path, make_app)
354
355 assert need_id_refs_store(app.env) == {}
356 assert not Path(app.outdir, "src").exists()
357 assert_no_warnings(app)
358 assert "code_url" not in _json(app)["needs_schema"]["properties"]
359 assert set(_refs(app).values()) == {"<absent>"}
360 assert "code_url" not in app.config.needs_string_links
361
362
363def test_local_urls_when_remote_urls_are_off(
364 tmp_path: Path, make_app: _MakeApp
365) -> None:
366 """Without remote URLs an entry is the local one, relative to the need's own
367 document, and links to the source page generated beside the copied file."""
368 _project(tmp_path, toml_replace=("set_remote_url = true", "set_remote_url = false"))
369 app = _build(tmp_path, make_app)
370
371 refs = _refs(app)
372 assert refs["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
373 assert refs["REQ_003"] == ["../src/refs.cpp#L7"]
374 assert _card_links(app, "index.html") == [
375 [
376 ("src/refs.html#L-1", "src/refs.cpp#L1"),
377 ("src/refs.html#L-3", "src/refs.cpp#L3"),
378 ]
379 ]
380 assert _card_links(app, "sub/deep.html") == [
381 [("../src/refs.html#L-7", "../src/refs.cpp#L7")]
382 ]
383 page = Path(app.outdir, "src", "refs.html").read_text(encoding="utf-8")
384 assert 'id="L-7"' in page
385
386
387@pytest.mark.parametrize("local", [True, False], ids=["local-on", "local-off"])
388def test_no_remote_url_without_a_git_root(
389 tmp_path: Path, make_app: _MakeApp, local: bool
390) -> None:
391 """Outside a git repository nothing is written as a remote URL (#2045): the
392 created need has no ``remote-url``, and a reference falls back to the local link,
393 or attaches nothing when local URLs are off. The analysis warns once."""
394 _project(
395 tmp_path,
396 git=False,
397 toml_replace=None
398 if local
399 else ("set_local_url = true", "set_local_url = false"),
400 files={"src/impl.cpp": "// @implemented without git, IMPL_NOGIT\n"},
401 )
402 app = _build(tmp_path, make_app)
403
404 needs = _json(app)["needs"]
405 assert needs["IMPL_NOGIT"]["remote-url"] is None
406 refs = _refs(app)
407 if local:
408 assert refs["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
409 else:
410 assert refs["REQ_001"] is None
411 records = [ref for refs in need_id_refs_store(app.env).values() for ref in refs]
412 assert records and {ref.root for ref in records} == {"src_dir"}
413 git_root_warnings = [w for w in build_warnings(app) if "git root is not found" in w]
414 assert len(git_root_warnings) == 1, build_warnings(app)
415 assert not any("/blob/None" in json.dumps(need) for need in needs.values())
416
417
418@pytest.mark.parametrize("second", ["aaa", "two"])
419def test_two_projects_over_one_file_attach_each_reference_once(
420 tmp_path: Path, make_app: _MakeApp, second: str
421) -> None:
422 """Two projects whose source directories overlap analyse the same file: one entry
423 per reference, the FIRST PROJECT BY NAME keeping its URL -- whichever document is
424 read first (``aaa`` sorts before ``src`` but is traced in the later document) --
425 and the unknown id warned once."""
426 gitlab = "https://gitlab.example.com/demo/-/blob/{commit}/{path}#L{line}"
427 commit = _project(
428 tmp_path,
429 toml_extra=(
430 f"\n[codelinks.projects.{second}]\n"
431 f'remote_url_pattern = "{gitlab}"\n'
432 f"[codelinks.projects.{second}.source_discover]\n"
433 'src_dir = "../src"\n'
434 'comment_type = "cpp"\n'
435 ),
436 append={"docs/later.rst": f"\n.. src-trace::\n :project: {second}\n"},
437 )
438 app = _build(tmp_path, make_app)
439
440 refs = _refs(app)
441 if second == "aaa":
442 first = [
443 gitlab.format(commit=commit, path="src/refs.cpp", line=n) for n in (1, 3)
444 ]
445 else:
446 first = [_url(commit, 1), _url(commit, 3)]
447 assert refs["REQ_001"] == first
448 assert refs["REQ_002"] == first[1:]
449 assert build_warnings(app) == [DANGLING]
450
451
452def test_two_fields_over_one_file_each_keep_their_list(
453 tmp_path: Path, make_app: _MakeApp
454) -> None:
455 """Two projects over one file naming DIFFERENT fields: each field its own list (two
456 declared relations), and one unknown-id warning for the one marker."""
457 commit = _project(
458 tmp_path,
459 toml_extra=(
460 "\n[codelinks.projects.mirror]\n"
461 'ref_url_field = "test_url"\n'
462 f'remote_url_pattern = "{GITHUB}"\n'
463 "[codelinks.projects.mirror.source_discover]\n"
464 'src_dir = "../src"\n'
465 'comment_type = "cpp"\n'
466 ),
467 append={"docs/later.rst": "\n.. src-trace::\n :project: mirror\n"},
468 )
469 app = _build(tmp_path, make_app)
470
471 needs = _json(app)["needs"]
472 assert needs["REQ_001"]["code_url"] == [_url(commit, 1), _url(commit, 3)]
473 assert needs["REQ_001"]["test_url"] == [_url(commit, 1), _url(commit, 3)]
474 assert build_warnings(app) == [DANGLING]
475
476
477def _two_roots(root: Path, *, git: bool) -> None:
478 """Two projects whose files share the relative path but are different files: two
479 git repositories (``repoa``, ``repob``), or two plain source directories."""
480 docs = root / "docs"
481 docs.mkdir(parents=True)
482 toml = [
483 "[codelinks]",
484 "set_local_url = true",
485 f"set_remote_url = {str(git).lower()}",
486 ]
487 for name in ("a", "b"):
488 src = root / f"repo{name}" / "src" if git else root / f"src{name}"
489 src.mkdir(parents=True)
490 (src / "main.cpp").write_text("// @need-ids: REQ_001\n", encoding="utf-8")
491 relative = f"../repo{name}/src" if git else f"../src{name}"
492 toml += [
493 f"[codelinks.projects.{name}]",
494 f'remote_url_pattern = "https://github.com/example/repo{name}/blob/'
495 '{commit}/{path}#L{line}"',
496 f"[codelinks.projects.{name}.source_discover]",
497 f'src_dir = "{relative}"',
498 'comment_type = "cpp"',
499 ]
500 if git:
501 repo = root / f"repo{name}"
502 _git(repo, "init", "--quiet")
503 _git(repo, "add", "-A")
504 _git(repo, "commit", "--quiet", "-m", "init")
505 (docs / "ubproject.toml").write_text("\n".join(toml) + "\n", encoding="utf-8")
506 (docs / "conf.py").write_text(
507 (FIXTURE / "docs" / "conf.py").read_text(encoding="utf-8"), encoding="utf-8"
508 )
509 (docs / "index.rst").write_text(
510 "Two roots\n=========\n\n.. req:: Cross-cutting\n :id: REQ_001\n\n"
511 ".. src-trace::\n :project: a\n\n.. src-trace::\n :project: b\n",
512 encoding="utf-8",
513 )
514
515
516@pytest.mark.parametrize("git", [True, False], ids=["two-repositories", "two-src-dirs"])
517def test_same_relative_path_under_two_roots_is_two_files(
518 tmp_path: Path, make_app: _MakeApp, git: bool
519) -> None:
520 """``path`` is relative to each project's own root: ``src/main.cpp`` in two
521 repositories (or ``main.cpp`` in two source directories) is two files, so both
522 references are kept, and both projects report."""
523 _two_roots(tmp_path, git=git)
524 app = _build(tmp_path, make_app)
525
526 refs = _refs(app)["REQ_001"]
527 if git:
528 assert [url.split("/blob/")[0] for url in refs] == [
529 "https://github.com/example/repoa",
530 "https://github.com/example/repob",
531 ]
532 assert all(url.endswith("/src/main.cpp#L1") for url in refs)
533 else:
534 assert refs == ["srca/main.cpp#L1", "srcb/main.cpp#L1"]
535 status = app._status.getvalue()
536 assert "codelinks [a]: 1 reference attached, 0 unknown" in status
537 assert "codelinks [b]: 1 reference attached, 0 unknown" in status
538
539
540def test_no_remote_url_without_a_commit(tmp_path: Path, make_app: _MakeApp) -> None:
541 """A repository with no commit yet has no commit for the pattern: no remote URL,
542 no ``blob/None`` -- the local link instead (#2045)."""
543 _project(
544 tmp_path,
545 git=False,
546 files={"src/impl.cpp": "// @implemented before any commit, IMPL_NOCOMMIT\n"},
547 )
548 _git(tmp_path, "init", "--quiet")
549 _git(tmp_path, "remote", "add", "origin", "https://github.com/example/demo.git")
550 app = _build(tmp_path, make_app)
551
552 needs = _json(app)["needs"]
553 assert needs["IMPL_NOCOMMIT"]["remote-url"] is None
554 assert _refs(app)["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
555 assert not any("/blob/None" in json.dumps(need) for need in needs.values())
556
557
558def test_copies_are_byte_identical(tmp_path: Path, make_app: _MakeApp) -> None:
559 """With local URLs only, a referenced file and a file with a one-line need are
560 copied as bytes -- no codec, no newline translation -- and paged."""
561 refs = (FIXTURE / "src" / "refs.cpp").read_text(encoding="utf-8")
562 _project(
563 tmp_path,
564 toml_replace=("set_remote_url = true", "set_remote_url = false"),
565 files={
566 "src/refs.cpp": (refs + "// café: naïve ✓\n").replace("\n", "\r\n"),
567 "src/impl.cpp": "// @Implemented, IMPL_UTF8\r\n// déjà vu\r\n",
568 },
569 )
570 app = _build(tmp_path, make_app)
571
572 for name in ("refs.cpp", "impl.cpp"):
573 source = (tmp_path / "src" / name).read_bytes()
574 assert Path(app.outdir, "src", name).read_bytes() == source, name
575 page = Path(app.outdir, "src", name).with_suffix(".html")
576 assert page.exists(), name
577 assert "café: naïve ✓" in Path(app.outdir, "src", "refs.html").read_text(
578 encoding="utf-8"
579 )
580
581
582_INJECT = """
583def setup(app):
584 from sphinx_codelinks.analyse.references import NeedIdRef
585 from sphinx_codelinks.sphinx_extension.need_id_refs import need_id_refs_store
586
587 def inject(app, env, docnames):
588 need_id_refs_store(env)["<pre-analysed>"] = [
589 NeedIdRef(need_id=need_id, project="elsewhere", marker="@need-ids:",
590 path="x.cpp", lineno=1, start_column=0, end_column=7,
591 remote_url="https://example.com/x.cpp#L1")
592 for need_id in ("REQ_001", "REQ_002")
593 ]
594
595 app.connect("env-before-read-docs", inject)
596"""
597
598
599def test_records_of_an_unconfigured_project_warn_once(
600 tmp_path: Path, make_app: _MakeApp
601) -> None:
602 """Records reaching the store for a project this build does not configure (what a
603 pre-analysed file could carry) are ignored, with one warning per project."""
604 commit = _project(tmp_path, append={"docs/conf.py": _INJECT})
605 app = _build(tmp_path, make_app)
606
607 suffix = " [codelinks.need_id_ref]" if _SHOWS_WARNING_TYPES else ""
608 ignored = (
609 "WARNING: @need-ids records for project 'elsewhere', which this build does not "
610 f"configure, were ignored{suffix}"
611 )
612 assert sorted(build_warnings(app)) == sorted([DANGLING, ignored])
613 assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
614
615
616@pytest.mark.parametrize(
617 "declaration",
618 ['needs_fields = {"code_url": {}}', 'needs_extra_options = ["code_url"]'],
619 ids=["needs_fields", "needs_extra_options"],
620)
621def test_a_user_declaration_of_the_field_names_the_cure(
622 tmp_path: Path, make_app: _MakeApp, declaration: str
623) -> None:
624 """A ``needs_fields`` (or ``needs_extra_options``) entry for the references field
625 (the ``write rst`` route's habit) warns with the cause and the cure, beside
626 Sphinx-Needs' own duplicate."""
627 _project(tmp_path, append={"docs/conf.py": declaration + "\n"})
628 app = _build(tmp_path, make_app)
629
630 suffix = " [codelinks.config]" if _SHOWS_WARNING_TYPES else ""
631 assert (
632 "WARNING: codelinks registers 'code_url' for @need-ids references; remove the "
633 f"needs_fields declaration of it, or set ref_url_field{suffix}"
634 ) in build_warnings(app)
635
636
637def test_copies_and_pages_under_an_ascii_locale(tmp_path: Path) -> None:
638 """The byte copy and the source page do not depend on the locale's codec.
639
640 The in-process tests run under the suite's (UTF-8) locale, where a decode through the
641 locale codec cannot fail; this one builds in a subprocess under the C locale with
642 UTF-8 mode and locale coercion off, where it would (``UnicodeDecodeError: 'ascii'``).
643 Skipped only where the platform gives that environment a non-ASCII codec."""
644 env = {
645 **os.environ,
646 "LC_ALL": "C",
647 "LANG": "C",
648 "PYTHONUTF8": "0",
649 "PYTHONCOERCECLOCALE": "0",
650 }
651 env.pop("PYTHONIOENCODING", None)
652 probe = subprocess.run(
653 [sys.executable, "-c", "import locale; print(locale.getencoding())"],
654 env=env,
655 capture_output=True,
656 text=True,
657 check=True,
658 )
659 if codecs.lookup(probe.stdout.strip()).name != "ascii":
660 pytest.skip(f"the C locale's codec here is {probe.stdout.strip()!r}")
661 refs = (FIXTURE / "src" / "refs.cpp").read_text(encoding="utf-8")
662 _project(
663 tmp_path,
664 toml_replace=("set_remote_url = true", "set_remote_url = false"),
665 files={"src/refs.cpp": refs + "// café: naïve\n"},
666 )
667 out = tmp_path / "out"
668 result = subprocess.run(
669 sphinx_build_command("-b", "html", "-E", "-q", tmp_path / "docs", out),
670 env=env,
671 capture_output=True,
672 check=False,
673 )
674
675 assert result.returncode == 0, result.stderr.decode("utf-8", "replace")
676 source = (tmp_path / "src" / "refs.cpp").read_bytes()
677 assert (out / "src" / "refs.cpp").read_bytes() == source
678 assert "café: naïve" in (out / "src" / "refs.html").read_text(encoding="utf-8")
679
680
681def test_a_non_html_build_writes_no_source_copies(
682 tmp_path: Path, make_app: _MakeApp
683) -> None:
684 """The source copies and pages are written by the HTML builders only, at
685 ``html-collect-pages``: a LaTeX build of a local-only project -- a referenced file
686 and a file with a one-line need -- leaves no copy in its output, and the next HTML
687 build in the same process (an in-process server, a test run) builds as usual."""
688 _project(
689 tmp_path / "a",
690 toml_replace=("set_remote_url = true", "set_remote_url = false"),
691 files={"src/impl.cpp": "// @Implemented, IMPL_LATEX\n"},
692 )
693 _project(tmp_path / "b")
694 latex = _build(tmp_path / "a", make_app, buildername="latex")
695 assert "IMPL_LATEX" in SphinxNeedsData(latex.env).get_needs_view()
696 assert list(Path(latex.outdir).rglob("*.cpp")) == []
697 app = _build(tmp_path / "b", make_app)
698
699 # a second application in one process also re-registers Sphinx' own nodes
700 assert [w for w in build_warnings(app) if "@need-ids" in w] == [DANGLING]
701 assert Path(app.outdir, "index.html").exists()