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