[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})