[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() == []