[docs] 1# @Test suite for the URL fields' string links, TEST_URL_LINKS_1, test, [IMPL_URL_LINKS_1]
2"""The ``local-url`` / ``remote-url`` string links, registered at ``config-inited``.
3
4The directive used to write them into the configuration at read time, which a ``-j N``
5worker never hands back, which changed the configuration on every build, and which let
6the last project read decide every need's remote link.
7"""
8
9import json
10import re
11import subprocess
12from collections.abc import Callable
13from pathlib import Path, PureWindowsPath
14from types import SimpleNamespace
15
16import pytest
17import sphinx
18from sphinx.testing.util import SphinxTestApp
19from sphinx.util.parallel import parallel_available
20
21from sphinx_codelinks.sphinx_extension.directives.src_trace import (
22 generate_remote_url,
23 generate_str_link_name,
24)
25from sphinx_codelinks.sphinx_extension.string_links import url_string_link
26from sphinx_needs.config import NeedsSphinxConfig
27from sphinx_needs.string_links import compiled_string_links
28from sphinx_needs_testkit import assert_no_warnings, build_warnings
29
30GITHUB = "https://github.com/example/demo/blob/{commit}/{path}#L{line}"
31GITLAB = "https://gitlab.example.com/demo/-/blob/{commit}/{path}?ref=b#L{line}"
32GITWEB = "https://git.example.com/?p=demo.git;a=blob;f={path};hb={commit}#l{line}"
33
34#: Sphinx 8 renders a warning's ``[type.subtype]`` itself; 7.4 does not
35_SHOWS_WARNING_TYPES = sphinx.version_info >= (8,)
36
37#: a conf.py ``setup`` recording ``needs_string_links`` just before Sphinx-Needs
38#: compiles them (its listener is at priority 551)
39_PROBE = """
40def setup(app):
41 def probe(app, config):
42 app._codelinks_probe = dict(config.needs_string_links)
43 app.connect("config-inited", probe, priority=550)
44"""
45
46
47def _project(
48 root: Path,
49 *,
50 patterns: dict[str, str],
51 pages: int = 0,
52 conf_extra: str = "",
53) -> str:
54 """A git repository with one source directory per project and a docs directory.
55
56 ``index`` traces the first project and ``later`` the second (if any); ``pages``
57 more documents make a ``-j 2`` read genuinely parallel. Returns the commit.
58 """
59 docs = root / "docs"
60 docs.mkdir()
61 toml = ["[codelinks]", "set_local_url = true", "set_remote_url = true", ""]
62 for index, (name, pattern) in enumerate(patterns.items()):
63 src = root / f"src{name}"
64 src.mkdir()
65 (src / f"{name}.cpp").write_text(
66 "int x = 0;\n" * index + f"// [[need in {name}, IMPL_{name.upper()}]]\n",
67 encoding="utf-8",
68 )
69 toml += [
70 f"[codelinks.projects.{name}]",
71 f'remote_url_pattern = "{pattern}"',
72 f"[codelinks.projects.{name}.source_discover]",
73 f'src_dir = "../src{name}"',
74 'comment_type = "cpp"',
75 f"[codelinks.projects.{name}.analyse.oneline_comment_style]",
76 'start_sequence = "[["',
77 'end_sequence = "]]"',
78 'needs_fields = [{ name = "title" }, { name = "id" }, '
79 '{ name = "type", default = "impl" }]',
80 "",
81 ]
82 (docs / "ubproject.toml").write_text("\n".join(toml), encoding="utf-8")
83 (docs / "conf.py").write_text(
84 "extensions = ['sphinx_needs', 'sphinx_codelinks']\n"
85 "exclude_patterns = ['_build']\n"
86 "needs_build_json = True\n" + conf_extra,
87 encoding="utf-8",
88 )
89 names = list(patterns)
90 toctree = ".. toctree::\n :glob:\n\n *\n\n" if pages or len(names) > 1 else ""
91 (docs / "index.rst").write_text(
92 f"Index\n=====\n\n{toctree}.. src-trace::\n :project: {names[0]}\n",
93 encoding="utf-8",
94 )
95 if len(names) > 1:
96 (docs / "later.rst").write_text(
97 f"Later\n=====\n\n.. src-trace::\n :project: {names[1]}\n",
98 encoding="utf-8",
99 )
100 for page in range(pages):
101 (docs / f"page{page}.rst").write_text(
102 f"Page {page}\n=======\n\ntext\n", encoding="utf-8"
103 )
104 git = ["git", "-c", "user.name=t", "-c", "user.email=t@t"]
105 git += ["-c", "commit.gpgsign=false"]
106 subprocess.run([*git, "init", "--quiet"], cwd=root, check=True)
107 subprocess.run(
108 [*git, "remote", "add", "origin", "https://github.com/example/demo.git"],
109 cwd=root,
110 check=True,
111 )
112 subprocess.run([*git, "add", "-A"], cwd=root, check=True)
113 subprocess.run([*git, "commit", "--quiet", "-m", "init"], cwd=root, check=True)
114 return subprocess.run(
115 ["git", "rev-parse", "HEAD"],
116 cwd=root,
117 check=True,
118 capture_output=True,
119 text=True,
120 ).stdout.strip()
121
122
123def _card_links(html: str, field: str) -> list[str | None]:
124 """The href in each card's ``field`` row, or ``None`` for a row of plain text."""
125 rows = re.findall(
126 rf'needs_{re.escape(field)}"><span class="needs_label">[^<]*</span>'
127 r'<span class="needs_data">(.*?)</span></span>',
128 html,
129 re.S,
130 )
131 hrefs: list[str | None] = []
132 for row in rows:
133 found = re.search(r'href="([^"]*)"', row)
134 hrefs.append(found.group(1) if found else None)
135 return hrefs
136
137
138def _needs(app: SphinxTestApp) -> dict[str, dict[str, object]]:
139 data = json.loads(Path(app.outdir, "needs.json").read_text(encoding="utf-8"))
140 return data["versions"][data["current_version"]]["needs"]
141
142
143#: a one-line need at line 7, as ``_line_span`` reads it
144_NEED_AT_7 = SimpleNamespace(
145 source_map={"start": {"row": 6, "column": 0}, "end": {"row": 6, "column": 0}}
146)
147
148
149def test_remote_url_path_is_posix_for_windows_paths() -> None:
150 """A URL path is POSIX: built from Windows paths (``str()`` would give
151 ``srca\\a.cpp``), the filled-in pattern still holds ``srca/a.cpp``."""
152 out = PureWindowsPath("C:/docs/_build/html")
153 url = generate_remote_url(
154 _NEED_AT_7, # ty: ignore[invalid-argument-type]
155 out / "srca" / "a.cpp", # ty: ignore[invalid-argument-type]
156 {
157 "remote_src_dir": PureWindowsPath("srca"),
158 "target_dir": out / "srca",
159 }, # ty: ignore[invalid-argument-type]
160 GITHUB,
161 "a" * 40,
162 )
163 assert url == GITHUB.format(commit="a" * 40, path="srca/a.cpp", line=7)
164 assert "\\" not in url
165
166
167def test_local_url_value_is_posix_for_windows_paths() -> None:
168 """The local value becomes the link's href (``<value>.html#L-<line>``): POSIX too."""
169 value = generate_str_link_name(
170 _NEED_AT_7, # ty: ignore[invalid-argument-type]
171 PureWindowsPath("..", "srca", "a.cpp"), # ty: ignore[invalid-argument-type]
172 )
173 assert value == "../srca/a.cpp#L7"
174
175
176@pytest.mark.parametrize(
177 ("url", "name"),
178 [
179 pytest.param(
180 "https://github.com/o/r/blob/" + "a" * 40 + "/src/x.cpp#L3",
181 "src/x.cpp#L3",
182 id="github",
183 ),
184 pytest.param(
185 "https://gitlab.com/o/r/-/blob/" + "0123456789" * 4 + "/a/b.c#L10-L12",
186 "a/b.c#L10-L12",
187 id="gitlab-span",
188 ),
189 pytest.param(
190 "https://bitbucket.org/o/r/src/" + "f" * 64 + "/x.py#lines-2",
191 "x.py#lines-2",
192 id="bitbucket-sha256",
193 ),
194 pytest.param(
195 "https://example.com/src/main.py#L1",
196 "https://example.com/src/main.py#L1",
197 id="no-commit-falls-back-to-the-url",
198 ),
199 pytest.param(
200 "https://github.com/cafe1234/r/blob/None/x.cpp#L1",
201 "https://github.com/cafe1234/r/blob/None/x.cpp#L1",
202 id="no-git-rev-falls-back-to-the-url",
203 ),
204 pytest.param("foo", None, id="not-a-url"),
205 pytest.param("srca/a.cpp#L7", None, id="old-fragment-shape"),
206 pytest.param("javascript:alert(1)", None, id="scheme-without-slashes"),
207 ],
208)
209def test_url_string_link_names_the_location_after_the_commit(
210 url: str, name: str | None
211) -> None:
212 """The identity link targets the URL itself and is named by its ``path#Lline``
213 tail -- the part after a full commit hash -- or by the URL when there is none. A
214 value with no ``scheme://`` is not a URL: no match, so Sphinx-Needs renders text."""
215 entry = url_string_link("remote-url")
216 match = re.search(entry["regex"], url)
217 if name is None:
218 assert match is None
219 return
220 assert match is not None
221 groups = match.groupdict()
222 assert groups["codelinks_url"] == url
223 assert (groups["codelinks_location"] or groups["codelinks_url"]) == name
224
225
226def test_string_links_are_registered_before_sphinx_needs_compiles_them(
227 tmp_path: Path, make_app: Callable[..., SphinxTestApp]
228) -> None:
229 """Both entries are in ``needs_string_links`` before Sphinx-Needs' listener at
230 priority 551 runs, are compiled by it, and render as links on the card."""
231 commit = _project(tmp_path, patterns={"a": GITHUB}, conf_extra=_PROBE)
232 app = make_app(srcdir=tmp_path / "docs", freshenv=True)
233
234 probe = app._codelinks_probe # ty: ignore[unresolved-attribute]
235 # the URL fields, and the field @need-ids references are attached to
236 assert set(probe) == {"local-url", "remote-url", "code_url"}
237 assert probe["remote-url"] == url_string_link("remote-url")
238 compiled = compiled_string_links(NeedsSphinxConfig(app.config))
239 assert {"local-url", "remote-url", "code_url"} <= set(compiled)
240
241 app.build()
242 assert_no_warnings(app)
243 html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
244 url = GITHUB.format(commit=commit, path="srca/a.cpp", line=1)
245 assert _card_links(html, "remote-url") == [url]
246 assert _card_links(html, "local-url") == ["srca/a.html#L-1"]
247 # the local link's name: the value up to the extension, and the line
248 assert '<a class="reference external" href="srca/a.html#L-1">srca/a#L1</a>' in html
249
250
251def test_user_string_links_are_kept_and_not_mutated(
252 tmp_path: Path, make_app: Callable[..., SphinxTestApp]
253) -> None:
254 """codelinks adds its entries beside the user's, and rebinds the value rather than
255 writing into the conf.py dict."""
256 user_entry = (
257 "{'regex': r'^(?P<v>.+)$', 'link_url': '{{v}}', 'link_name': '{{v}}', "
258 "'options': ['title']}"
259 )
260 _project(
261 tmp_path,
262 patterns={"a": GITHUB},
263 conf_extra=f"USER_LINKS = {{'mine': {user_entry}}}\n"
264 "needs_string_links = USER_LINKS\n",
265 )
266 app = make_app(srcdir=tmp_path / "docs", freshenv=True)
267
268 assert set(app.config.needs_string_links) == {
269 "mine",
270 "local-url",
271 "remote-url",
272 "code_url",
273 }
274 assert set(app.config._raw_config["USER_LINKS"]) == {"mine"}
275
276
277@pytest.mark.skipif(
278 not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
279)
280def test_parallel_build_renders_the_url_links(
281 tmp_path: Path, make_app: Callable[..., SphinxTestApp]
282) -> None:
283 """Under ``-j 2`` the card's URL rows are links, exactly as in a serial build."""
284 commit = _project(tmp_path, patterns={"a": GITHUB}, pages=7)
285 app = make_app(srcdir=tmp_path / "docs", freshenv=True, parallel=2)
286 app.build()
287
288 assert_no_warnings(app)
289 html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
290 url = GITHUB.format(commit=commit, path="srca/a.cpp", line=1)
291 assert _card_links(html, "remote-url") == [url]
292 assert _card_links(html, "local-url") == ["srca/a.html#L-1"]
293
294
295def test_each_project_links_with_its_own_remote_url_pattern(
296 tmp_path: Path, make_app: Callable[..., SphinxTestApp]
297) -> None:
298 """Two projects with different patterns: each need's card links with its own
299 project's pattern (it used to be the pattern of the project read last, for every
300 need), and ``needs.json`` holds that URL."""
301 commit = _project(tmp_path, patterns={"a": GITHUB, "b": GITLAB})
302 app = make_app(srcdir=tmp_path / "docs", freshenv=True)
303 app.build()
304
305 assert_no_warnings(app)
306 url_a = GITHUB.format(commit=commit, path="srca/a.cpp", line=1)
307 url_b = GITLAB.format(commit=commit, path="srcb/b.cpp", line=2)
308 # the cards first: on master these name the last-read-wins defect
309 index = Path(app.outdir, "index.html").read_text(encoding="utf-8")
310 later = Path(app.outdir, "later.html").read_text(encoding="utf-8")
311 assert _card_links(index, "remote-url") == [url_a]
312 assert _card_links(later, "remote-url") == [url_b]
313 needs = _needs(app)
314 assert needs["IMPL_A"]["remote-url"] == url_a
315 assert needs["IMPL_B"]["remote-url"] == url_b
316 assert needs["IMPL_A"]["local-url"] == "srca/a.cpp#L1"
317
318
319def test_a_needs_string_links_that_is_not_a_dict_is_left_alone(
320 tmp_path: Path, make_app: Callable[..., SphinxTestApp]
321) -> None:
322 """codelinks adds nothing to a ``needs_string_links`` that is not a dict; the build
323 goes on with Sphinx-Needs' own warning about it."""
324 _project(tmp_path, patterns={"a": GITHUB}, conf_extra="needs_string_links = []\n")
325 app = make_app(srcdir=tmp_path / "docs", freshenv=True)
326 app.build()
327
328 warnings = build_warnings(app)
329 assert len(warnings) == 1, warnings
330 assert "needs_string_links must be a dict, got []." in warnings[0]
331
332
333def test_rebuild_does_not_change_the_string_links(
334 tmp_path: Path, make_app: Callable[..., SphinxTestApp]
335) -> None:
336 """An unchanged rebuild reads the same ``needs_string_links`` the last build wrote:
337 no "configuration has changed" naming it, and the links still render."""
338 commit = _project(tmp_path, patterns={"a": GITHUB})
339 make_app(srcdir=tmp_path / "docs", freshenv=True).build()
340 for _ in range(2):
341 app = make_app(srcdir=tmp_path / "docs", freshenv=False)
342 app.build()
343 status = app._status.getvalue()
344 assert "0 added, 0 changed, 0 removed" in status
345 assert "needs_string_links" not in status
346
347 html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
348 url = GITHUB.format(commit=commit, path="srca/a.cpp", line=1)
349 assert _card_links(html, "remote-url") == [url]
350
351
352@pytest.mark.parametrize(
353 "pattern",
354 [
355 pytest.param(GITWEB, id="semicolon"),
356 pytest.param("https://example.com/{commit}/{path},view#L{line}", id="comma"),
357 ],
358)
359def test_pattern_with_a_separator_warns(
360 tmp_path: Path, make_app: Callable[..., SphinxTestApp], pattern: str
361) -> None:
362 """Sphinx-Needs splits a string-linked value on ``,`` and ``;``: a pattern holding
363 either cannot render as one link, and the configuration check says so."""
364 _project(tmp_path, patterns={"a": pattern})
365 app = make_app(srcdir=tmp_path / "docs", freshenv=True)
366 app.build()
367
368 suffix = " [codelinks.remote_url_pattern]" if _SHOWS_WARNING_TYPES else ""
369 assert build_warnings(app) == [
370 f"WARNING: Project 'a': remote_url_pattern {pattern!r} contains ',' or ';'. "
371 "Sphinx-Needs splits string-linked values on ',' and ';', so this pattern's "
372 f"links will not render as one link.{suffix}"
373 ]
374
375
376def test_pattern_separator_warning_needs_remote_urls(
377 tmp_path: Path, make_app: Callable[..., SphinxTestApp]
378) -> None:
379 """With remote URLs off the pattern is never filled in: no warning."""
380 _project(tmp_path, patterns={"a": GITWEB})
381 toml = tmp_path / "docs" / "ubproject.toml"
382 toml.write_text(
383 toml.read_text(encoding="utf-8").replace(
384 "set_remote_url = true", "set_remote_url = false"
385 ),
386 encoding="utf-8",
387 )
388 app = make_app(srcdir=tmp_path / "docs", freshenv=True)
389 app.build()
390
391 assert_no_warnings(app)
392
393
394def test_pattern_separator_warning_is_suppressible(
395 tmp_path: Path, make_app: Callable[..., SphinxTestApp]
396) -> None:
397 _project(
398 tmp_path,
399 patterns={"a": GITWEB},
400 conf_extra='suppress_warnings = ["codelinks.remote_url_pattern"]\n',
401 )
402 app = make_app(srcdir=tmp_path / "docs", freshenv=True)
403 app.build()
404
405 assert_no_warnings(app)