[docs] 1# @Test suite for source analysis configuration validation, TEST_CONF_1, test, [IMPL_OLP_1, IMPL_MLN_1]
2from pathlib import Path
3
4import pytest
5
6from sphinx_codelinks.config import (
7 MultilineNeedsConfig,
8 NeedIdRefsConfig,
9 OneLineCommentStyle,
10 SourceAnalyseConfig,
11 convert_analyse_config,
12 generate_project_configs,
13)
14from sphinx_codelinks.needextend_write import MarkedContentSchema
15from sphinx_codelinks.source_discover.config import SourceDiscoverConfig
16from sphinx_needs_testkit import build_warnings
17
18from .conftest import TEST_DIR
19from .test_need_id_refs import _SHOWS_WARNING_TYPES, _build, _MakeApp, _project
20
21
22@pytest.mark.parametrize(
23 ("analyse_config", "result"),
24 [
25 (
26 SourceAnalyseConfig(
27 src_files=[
28 TEST_DIR / "data" / "dcdc" / "charge" / "demo_1.cpp",
29 ],
30 src_dir=TEST_DIR / "data" / "dcdc",
31 comment_type=123,
32 ),
33 [
34 "Schema validation error in field 'comment_type': 123 is not of type 'string'",
35 ],
36 ),
37 (
38 SourceAnalyseConfig(
39 src_files=None,
40 src_dir=TEST_DIR / "data" / "dcdc",
41 comment_type=123,
42 ),
43 [
44 "Schema validation error in field 'comment_type': 123 is not of type 'string'",
45 "Schema validation error in field 'src_files': None is not of type 'array'",
46 ],
47 ),
48 ],
49)
50def test_config_schema_validator_negative(analyse_config, result):
51 errors = analyse_config.check_schema()
52 assert sorted(errors) == sorted(result)
53
54
55@pytest.mark.parametrize(
56 "oneline_config, result",
57 [
58 (
59 OneLineCommentStyle(
60 start_sequence="[[",
61 end_sequence="]]",
62 field_split_char=",",
63 needs_fields=[
64 {"name": "title"},
65 {"name": "id"},
66 {"name": "type", "default": "impl"},
67 {"name": "links", "type": "list[]", "default": []}, # wrong type
68 ],
69 ),
70 [
71 "Schema validation error in need_fields 'links': 'list[]' is not one of ['str', 'list[str]']"
72 ],
73 ),
74 (
75 OneLineCommentStyle(
76 start_sequence="[[",
77 end_sequence="]]",
78 field_split_char=",",
79 needs_fields=[
80 {"name": "title"},
81 {"name": "id"},
82 {"name": "type", "default": 123}, # int is invalid
83 {"name": "links", "type": "list[str]", "default": []},
84 ],
85 ),
86 [
87 "Schema validation error in need_fields 'type': 123 is not of type 'string'"
88 ],
89 ),
90 (
91 OneLineCommentStyle(
92 start_sequence="[[",
93 end_sequence="]]",
94 field_split_char=",",
95 needs_fields=[
96 {"name": "title", "qwe": "qwe"}, # invalid qwe filed
97 {"name": "id"},
98 {"name": "type", "default": "impl"},
99 {"name": "links", "type": "list[str]", "default": []},
100 ],
101 ),
102 [
103 "Schema validation error in need_fields 'title': Additional properties are not allowed ('qwe' was unexpected)"
104 ],
105 ),
106 (
107 OneLineCommentStyle(
108 start_sequence="[[",
109 end_sequence="]]",
110 field_split_char=",",
111 needs_fields=[
112 {"name": "title"},
113 {"name": "id"},
114 {
115 "name": "type",
116 "type: ": "list[str]",
117 "default": "impl",
118 }, # wring combination of type and default
119 {"name": "links", "type": "list[str]", "default": []},
120 ],
121 ),
122 [
123 "Schema validation error in need_fields 'type': Additional properties are not allowed ('type: ' was unexpected)"
124 ],
125 ),
126 (
127 OneLineCommentStyle(
128 start_sequence="[[",
129 end_sequence="]]",
130 field_split_char=",",
131 needs_fields=[
132 {"name": "id"} # "title" and "type" are not given
133 ],
134 ),
135 ["Missing required fields: ['title', 'type']"],
136 ),
137 (
138 OneLineCommentStyle(
139 start_sequence="[[",
140 end_sequence="]]",
141 field_split_char=",",
142 needs_fields=[
143 {"name": "id"},
144 {"name": "id"}, # duplicate
145 ],
146 ),
147 [
148 "Missing required fields: ['title', 'type']",
149 "Field 'id' is defined multiple times.",
150 ],
151 ),
152 (
153 OneLineCommentStyle(
154 start_sequence=1234, # wrong type
155 end_sequence=5678,
156 field_split_char=2222,
157 needs_fields=[
158 {"name": "id"},
159 ],
160 ),
161 [
162 "Schema validation error in field 'field_split_char': 2222 is not of type 'string'",
163 "Schema validation error in field 'end_sequence': 5678 is not of type 'string'",
164 "Schema validation error in field 'start_sequence': 1234 is not of type 'string'",
165 "Missing required fields: ['title', 'type']",
166 ],
167 ),
168 (
169 OneLineCommentStyle(
170 start_sequence="@need",
171 end_sequence="\n",
172 field_split_char=",",
173 needs_fields=[
174 {"name": "id"},
175 {"name": "implements", "type": "list[str]", "default": []},
176 {"name": "type", "default": "impl"},
177 {"name": "title"}, # required after optional
178 ],
179 ),
180 [
181 "Field 'title' without a default follows field 'implements' which has a default. "
182 "Fields without defaults must be defined before fields with defaults.",
183 ],
184 ),
185 ],
186)
187def test_oneline_schema_validator_negative(oneline_config, result):
188 errors = oneline_config.check_fields_configuration()
189 assert sorted(errors) == sorted(result)
190
191
192@pytest.mark.parametrize(
193 "oneline_config",
194 [
195 OneLineCommentStyle(
196 start_sequence="[[",
197 end_sequence="]]",
198 field_split_char=",",
199 needs_fields=[
200 {"name": "title"},
201 {"name": "id"},
202 {"name": "type", "default": "impl"},
203 {"name": "links", "type": "list[str]", "default": []},
204 ],
205 ),
206 OneLineCommentStyle(
207 start_sequence="[[",
208 end_sequence="]]",
209 field_split_char=",",
210 needs_fields=[
211 {"name": "title"}, # minimum need_fields config
212 {"name": "type"},
213 ],
214 ),
215 OneLineCommentStyle(
216 needs_fields=[ # minimum config
217 {"name": "title"},
218 {"name": "type"},
219 ],
220 ),
221 ],
222)
223def test_oneline_schema_validator_positive(oneline_config):
224 assert len(oneline_config.check_fields_configuration()) == 0
225
226
227def test_multiline_needs_config_defaults_are_valid() -> None:
228 config = MultilineNeedsConfig()
229
230 assert (config.start_sequence, config.end_sequence) == ("@need", "@endneed")
231 assert config.default_markup == "rst"
232 assert config.markups == {"rst": ".rst", "md": ".md"}
233 assert config.check_fields_configuration() == []
234
235
236@pytest.mark.parametrize(
237 ("kwargs", "error"),
238 [
239 pytest.param(
240 {"default_markup": "myst"},
241 "default_markup 'myst' is not a key of markups ('md', 'rst').",
242 id="default_markup_not_in_markups",
243 ),
244 pytest.param(
245 {"start_sequence": "@block", "end_sequence": "@block"},
246 "start_sequence and end_sequence cannot be the same.",
247 id="same_sequences",
248 ),
249 pytest.param(
250 {"start_sequence": ""},
251 "Schema validation error in field 'start_sequence': ",
252 id="empty_start",
253 ),
254 pytest.param(
255 {"markups": {"rst": ".rst", "empty": ""}},
256 "Schema validation error in field 'markups': '' should be non-empty",
257 id="markup_suffix_empty",
258 ),
259 pytest.param(
260 {"markups": {"rst": 1}},
261 "Schema validation error in field 'markups': 1 is not of type 'string'",
262 id="markup_suffix_not_a_string",
263 ),
264 ],
265)
266def test_multiline_needs_config_negative(kwargs: dict, error: str) -> None:
267 errors = MultilineNeedsConfig(**kwargs).check_fields_configuration()
268
269 assert any(message.startswith(error) for message in errors), errors
270
271
272def test_multiline_needs_config_errors_reach_the_analyse_config() -> None:
273 """Checked when the feature is on, under their own heading."""
274 config = SourceAnalyseConfig(
275 get_multiline_needs=True,
276 multiline_needs_config=MultilineNeedsConfig(default_markup="txt"),
277 )
278
279 errors = config.check_fields_configuration()
280
281 assert "MultilineNeeds configuration errors:" in errors
282 assert "default_markup 'txt' is not a key of markups ('md', 'rst')." in errors
283 assert (
284 SourceAnalyseConfig(
285 multiline_needs_config=MultilineNeedsConfig(default_markup="txt")
286 ).check_fields_configuration()
287 == []
288 ), "not checked while the feature is off"
289
290
291def test_multiline_markers_are_checked_only_when_the_feature_is_on() -> None:
292 """A reference marker ``@need`` (valid before multi-line needs existed) is a clash
293 only for a project that switches multi-line needs on."""
294 refs = NeedIdRefsConfig(markers=["@need"])
295
296 assert (
297 SourceAnalyseConfig(need_id_refs_config=refs).check_markers_mutually_exclusive()
298 == []
299 )
300 assert SourceAnalyseConfig(
301 get_multiline_needs=True, need_id_refs_config=refs
302 ).check_markers_mutually_exclusive() == ["Marker @need is defined multiple times"]
303
304
305def test_multiline_markers_join_the_mutual_exclusion_check() -> None:
306 """Equality only: the default one-line start ``@`` is a prefix of ``@need`` and that
307 is not a clash; a multi-line marker equal to another marker is."""
308 assert SourceAnalyseConfig().check_markers_mutually_exclusive() == []
309 clash = SourceAnalyseConfig(
310 get_multiline_needs=True,
311 multiline_needs_config=MultilineNeedsConfig(start_sequence="@need-ids:"),
312 )
313 assert clash.check_markers_mutually_exclusive() == [
314 "Marker @need-ids: is defined multiple times"
315 ]
316
317
318def test_convert_analyse_config_reads_the_multiline_needs_table() -> None:
319 config = convert_analyse_config(
320 {
321 "get_multiline_needs": True,
322 "multiline_needs": {
323 "start_sequence": "@spec",
324 "end_sequence": "@endspec",
325 "default_markup": "myst",
326 "markups": {"myst": ".md"},
327 },
328 }
329 )
330
331 assert config.get_multiline_needs is True
332 assert config.multiline_needs_config == MultilineNeedsConfig(
333 start_sequence="@spec",
334 end_sequence="@endspec",
335 default_markup="myst",
336 markups={"myst": ".md"},
337 )
338
339
340def test_multiline_needs_are_not_forced_on() -> None:
341 """Unlike ``get_oneline_needs`` (#1889), nothing switches the extraction on."""
342 projects: dict = {"p": {"source_discover": {"src_dir": "."}, "analyse": {}}}
343
344 generate_project_configs(projects)
345
346 assert projects["p"]["analyse_config"].get_multiline_needs is False
347
348
349def test_convert_multiline_needs_config_rejects_an_unknown_key() -> None:
350 with pytest.raises(TypeError, match="Invalid multiline_needs configuration"):
351 convert_analyse_config({"multiline_needs": {"open": "@need"}})
352
353
354@pytest.mark.parametrize(
355 ("section", "named"),
356 [
357 ({"get_rst": True}, "'get_rst' is no longer supported"),
358 ({"marked_rst": {}}, "'[analyse.marked_rst]' is no longer supported"),
359 ],
360)
361def test_convert_analyse_config_names_the_replacement_of_a_removed_key(
362 section: dict, named: str
363) -> None:
364 """A TypeError, as every other bad key of the section raises, but naming the cure."""
365 with pytest.raises(TypeError) as excinfo:
366 convert_analyse_config(section)
367
368 assert named in str(excinfo.value)
369 assert "multi-line needs" in str(excinfo.value)
370
371
372GET_RST_IGNORED = (
373 "analyse: 'get_rst' is no longer read: the marked-rst blocks were replaced by "
374 "multi-line needs (the @need and @endneed markers); get_rst = false asks for "
375 "nothing and can be removed"
376)
377
378
379def test_convert_analyse_config_tolerates_get_rst_false_silently(
380 caplog: pytest.LogCaptureFixture,
381) -> None:
382 """``get_rst = false`` asks for nothing the removed blocks did, and configuration
383 scaffolds wrote it into shared ``ubproject.toml`` files: the converter, called on
384 every configuration read, drops it without a word; the build and the CLI warn once
385 per project (``test_get_rst_false_warns_once_per_project_per_build``)."""
386 with caplog.at_level("WARNING"):
387 config = convert_analyse_config({"get_rst": False})
388
389 assert caplog.records == []
390 assert config.get_multiline_needs is False
391
392
393def test_get_rst_false_warns_once_per_project_per_build(
394 tmp_path: Path, make_app: _MakeApp
395) -> None:
396 """The scaffold's shape -- two projects with ``get_rst = false``, a directive each:
397 one warning per project, at configuration time, on a fresh build and on an
398 incremental one alike."""
399 _project(
400 tmp_path,
401 files={
402 "src/refs.cpp": "// no markers\n",
403 "tests/t.cpp": "// no markers\n",
404 "docs/page_t.rst": "T\n=\n\n.. src-trace::\n :project: tests\n",
405 },
406 toml_extra="""
407[codelinks.projects.src.analyse]
408get_rst = false
409
410[codelinks.projects.tests]
411remote_url_pattern = "https://github.com/example/demo/blob/{commit}/{path}#L{line}"
412
413[codelinks.projects.tests.source_discover]
414src_dir = "../tests"
415comment_type = "cpp"
416
417[codelinks.projects.tests.analyse]
418get_rst = false
419""",
420 )
421 suffix = " [codelinks.config]" if _SHOWS_WARNING_TYPES else ""
422 # one per project, each naming it: the two lines differ by the name alone
423 expected = [
424 f"WARNING: Project {name!r}: {GET_RST_IGNORED}{suffix}"
425 for name in ("src", "tests")
426 ]
427
428 fresh = _build(tmp_path, make_app)
429 assert [w for w in build_warnings(fresh) if "get_rst" in w] == expected
430
431 (tmp_path / "docs" / "page_t.rst").write_text(
432 "T\n=\n\nEdited.\n\n.. src-trace::\n :project: tests\n", encoding="utf-8"
433 )
434 incremental = _build(tmp_path, make_app, freshenv=False)
435 assert [w for w in build_warnings(incremental) if "get_rst" in w] == expected
436
437
438@pytest.mark.parametrize(
439 "section",
440 [{"get_rst": True}, {"get_rst": False, "marked_rst": {}}, {"get_rst": 0}],
441 ids=["get_rst_true", "get_rst_false_and_marked_rst", "get_rst_zero"],
442)
443def test_convert_analyse_config_still_refuses_get_rst_true_or_marked_rst(
444 section: dict,
445) -> None:
446 """Only ``get_rst = false`` with nothing else removed is tolerated."""
447 with pytest.raises(TypeError, match="no longer supported"):
448 convert_analyse_config(section)
449
450
451def test_an_unknown_analyse_key_still_fails_in_the_constructor() -> None:
452 """The unknown-key policy for every other key is unchanged."""
453 with pytest.raises(TypeError, match="unexpected keyword argument 'foo'"):
454 convert_analyse_config({"foo": 1})
455
456
457@pytest.mark.parametrize(
458 "make",
459 [
460 NeedIdRefsConfig,
461 MultilineNeedsConfig,
462 OneLineCommentStyle,
463 SourceDiscoverConfig,
464 # its fields have no defaults; the guard runs before any field is read
465 lambda: object.__new__(MarkedContentSchema),
466 ],
467 ids=[
468 "NeedIdRefsConfig",
469 "MultilineNeedsConfig",
470 "OneLineCommentStyle",
471 "SourceDiscoverConfig",
472 "MarkedContentSchema",
473 ],
474)
475def test_check_schema_skips_a_field_without_schema(make, monkeypatch):
476 """A field whose ``get_schema`` is ``None`` is skipped, as SourceAnalyseConfig does.
477
478 ``jsonschema.validate(schema=None)`` raises ``TypeError``, which ``check_schema``
479 does not catch. Every field of these classes has a schema today, so this pins the
480 guard rather than a reachable path.
481 """
482 config = make()
483 monkeypatch.setattr(type(config), "get_schema", classmethod(lambda cls, name: None))
484 assert config.check_schema() == []