1from collections import deque
2from collections.abc import Collection
3from dataclasses import MISSING, dataclass, field, fields, replace
4from enum import Enum
5from pathlib import Path
6from typing import Any, Literal, TypedDict, cast
7
8from jsonschema import ValidationError, validate
9from sphinx.application import Sphinx
10from sphinx.config import Config as _SphinxConfig
11
12from sphinx_codelinks.source_discover.config import (
13 CommentType,
14 SourceDiscoverConfig,
15 SourceDiscoverSectionConfigType,
16)
17from sphinx_codelinks.source_discover.source_discover import SourceDiscover
18from ub_project import anchor, load_toml, select_table
19
20UNIX_NEWLINE = "\n"
21
22
23COMMENT_MARKERS = {
[docs] 24 # @Support C and C++ style comments, IMPL_C_1, impl, [FE_C_SUPPORT, FE_CPP]
25 CommentType.cpp: ["//", "/*"],
[docs] 26 # @Support Python style comments, IMPL_PY_1, impl, [FE_PY]
27 CommentType.python: ["#"],
28 CommentType.cs: ["//", "/*", "///"],
[docs] 29 # @Support Go style comments, IMPL_GO_2, impl, [FE_GO]
30 CommentType.go: ["//", "/*"],
31}
32ESCAPE = "\\"
33
34# Default C/C++ standard for the standalone/defines parse path when no
35# compile_commands.json entry supplies one.
36DEFAULT_CPP_STD = "c++17"
37
38
39class CommentCategory(str, Enum): # noqa: UP042 # StrEnum changes str(member), which reaches CLI warnings and error messages
40 comment = "comment"
41 docstring = "expression_statement"
42
43
44class NeedIdRefsMarkerType(TypedDict):
45 """A reference marker given as a table: its text and the field its URLs go to."""
46
47 marker: str
48 field: str
49
50
51class NeedIdRefsConfigType(TypedDict):
52 markers: list[str | NeedIdRefsMarkerType]
53
54
55MARKER_TABLE_KEYS: tuple[str, ...] = ("marker", "field")
56"""The keys of a reference marker given as a table, every one required."""
57
58
59def _marker_table(index: int, entry: dict[Any, Any]) -> tuple[str, str]:
60 """The ``(marker, field)`` of the marker table at ``markers[index]``.
61
62 :raises TypeError: If the table lacks a key, has another key, its ``marker`` is not
63 a non-empty string or its ``field`` not a string.
64 """
65 where = f"markers[{index}]"
66 unknown = [key for key in entry if key not in MARKER_TABLE_KEYS]
67 if unknown:
68 names = ", ".join(repr(key) for key in unknown)
69 raise TypeError(
70 f"{where}: unknown key {names}; a marker table takes 'marker' and 'field'"
71 )
72 missing = [key for key in MARKER_TABLE_KEYS if key not in entry]
73 if missing:
74 names = " and ".join(repr(key) for key in missing)
75 raise TypeError(
76 f"{where}: missing {names}; a marker table takes 'marker' and 'field'"
77 )
78 marker, field_name = entry["marker"], entry["field"]
79 if not isinstance(marker, str) or not marker:
80 raise TypeError(f"{where}: 'marker' must be a non-empty string")
81 if not isinstance(field_name, str):
82 raise TypeError(f"{where}: 'field' must be a string")
83 return marker, field_name
84
85
86@dataclass
87class NeedIdRefsConfig:
88 @classmethod
89 def field_names(cls) -> set[str]:
90 return {item.name for item in fields(cls)}
91
92 markers: list[str] = field(
93 default_factory=lambda: ["@need-ids:"],
94 metadata={
95 "schema": {
96 "type": "array",
97 "items": {
98 "anyOf": [
99 {"type": "string"},
100 {
101 "type": "object",
102 "properties": {
103 "marker": {"type": "string", "minLength": 1},
104 "field": {"type": "string"},
105 },
106 "required": list(MARKER_TABLE_KEYS),
107 "additionalProperties": False,
108 },
109 ]
110 },
111 }
112 },
113 )
114 """The markers to extract need ids from.
115
116 Each entry is a marker string, whose references go to the project's
117 ``ref_url_field``, or a table ``{"marker": ..., "field": ...}``, whose references go
118 to ``field`` instead (``""``: to no field). Once constructed, this is the list of
119 the marker texts, and :attr:`fields` holds the tables' fields.
120 """
121 fields: dict[str, str] = field(default_factory=dict, init=False)
122 """Each marker given as a table, mapped to its field (resolved from ``markers``)."""
123
124 def __post_init__(self) -> None:
125 """Split the marker tables of ``markers`` into their texts and :attr:`fields`.
126
127 :raises TypeError: For a malformed marker table (see :func:`_marker_table`).
128 """
129 if not isinstance(self.markers, list):
130 return # reported by check_schema
131 texts: list[str] = []
132 for index, entry in enumerate(self.markers):
133 if isinstance(entry, dict):
134 marker, field_name = _marker_table(index, entry)
135 texts.append(marker)
136 self.fields.setdefault(marker, field_name)
137 else:
138 texts.append(entry)
139 self.markers = texts
140
141 @classmethod
142 def get_schema(cls, name: str) -> dict[str, Any] | None:
143 _field = next(_field for _field in fields(cls) if _field.name is name)
144 if _field.metadata and "schema" in _field.metadata:
145 return cast(dict[str, Any], _field.metadata["schema"])
146 return None
147
148 def check_schema(self) -> list[str]:
149 errors = []
150 for _field_name in self.field_names():
151 schema = self.get_schema(_field_name)
152 if schema is None:
153 # no schema to check against; `validate` would raise TypeError
154 continue
155 value = getattr(self, _field_name)
156 try:
157 validate(
158 instance=value,
159 schema=schema,
160 )
161 except ValidationError as e:
162 errors.append(
163 f"Schema validation error in field '{_field_name}': {e.message}"
164 )
165 return errors
166
167
168class MultilineNeedsConfigType(TypedDict, total=False):
169 start_sequence: str
170 end_sequence: str
171 default_markup: str
172 markups: dict[str, str]
173
174
175DEFAULT_MARKUPS: dict[str, str] = {"rst": ".rst", "md": ".md"}
176"""The default ``markups`` table: markup tag -> the ``doctype`` suffix of the need."""
177
178
179@dataclass
180class MultilineNeedsConfig:
181 """The markers and markups of multi-line needs (``[analyse.multiline_needs]``)."""
182
183 @classmethod
184 def field_names(cls) -> set[str]:
185 return {item.name for item in fields(cls)}
186
187 start_sequence: str = field(
188 default="@need", metadata={"schema": {"type": "string", "minLength": 1}}
189 )
190 """The word that opens a multi-line need, at the start of its line."""
191 end_sequence: str = field(
192 default="@endneed", metadata={"schema": {"type": "string", "minLength": 1}}
193 )
194 """The word that closes a multi-line need, alone on its line."""
195 default_markup: str = field(
196 default="rst", metadata={"schema": {"type": "string", "minLength": 1}}
197 )
198 """The markup tag of a block whose open line names none; a key of ``markups``."""
199 markups: dict[str, str] = field(
200 default_factory=lambda: dict(DEFAULT_MARKUPS),
201 metadata={
202 "schema": {
203 "type": "object",
204 "additionalProperties": {"type": "string", "minLength": 1},
205 "minProperties": 1,
206 }
207 },
208 )
209 """Markup tag -> the ``doctype`` suffix the need's content is parsed with. Not
210 checked against any parser: the consumer decides what it can parse."""
211
212 @classmethod
213 def get_schema(cls, name: str) -> dict[str, Any] | None:
214 _field = next(_field for _field in fields(cls) if _field.name is name)
215 if _field.metadata and "schema" in _field.metadata:
216 return cast(dict[str, Any], _field.metadata["schema"])
217 return None
218
219 def check_schema(self) -> list[str]:
220 errors = []
221 for _field_name in self.field_names():
222 schema = self.get_schema(_field_name)
223 if schema is None:
224 # no schema to check against; `validate` would raise TypeError
225 continue
226 value = getattr(self, _field_name)
227 try:
228 validate(
229 instance=value,
230 schema=schema,
231 )
232 except ValidationError as e:
233 errors.append(
234 f"Schema validation error in field '{_field_name}': {e.message}"
235 )
236 return errors
237
238 def check_fields_configuration(self) -> list[str]:
239 errors = self.check_schema()
240 if self.start_sequence == self.end_sequence:
241 errors.append("start_sequence and end_sequence cannot be the same.")
242 if isinstance(self.markups, dict) and self.default_markup not in self.markups:
243 errors.append(
244 f"default_markup {self.default_markup!r} is not a key of markups "
245 f"({', '.join(repr(tag) for tag in sorted(self.markups))})."
246 )
247 return errors
248
249
250@dataclass
251class PreprocessorConfig:
252 """Opt-in libclang engine config. Presence => libclang engine for C/C++."""
253
254 compile_commands: Path | None = field(
255 default=None, metadata={"schema": {"type": ["string", "null"]}}
256 )
257 """Explicit path to compile_commands.json. If None, walk-up auto-discovery."""
258
259 defines: list[str] = field(
260 default_factory=list,
261 metadata={"schema": {"type": "array", "items": {"type": "string"}}},
262 )
263 """Fallback -D defines applied globally when no compile_commands.json applies."""
264
265 includes: list[Path] = field(
266 default_factory=list,
267 metadata={"schema": {"type": "array", "items": {"type": "string"}}},
268 )
269 """Fallback -I include dirs for the defines path."""
270
271 std: str = field(
272 default=DEFAULT_CPP_STD,
273 metadata={"schema": {"type": "string"}},
274 )
275 """C/C++ standard for the standalone/defines parse path (e.g. ``c++17``,
276 ``c++20``, ``c11``); libclang pins ``-x`` to match it. Files resolved from a
277 ``compile_commands.json`` entry use that entry's own ``-std`` instead."""
278
279
280def anchor_preproc_paths(preproc: PreprocessorConfig, base: Path) -> PreprocessorConfig:
281 """Resolve a preprocessor config's ``compile_commands`` and ``includes``
282 against ``base`` (the config file's directory), so a relative path resolves
283 against the TOML file rather than the process CWD — matching ``src_dir`` /
284 ``git_root``. An absolute path is not anchored, but it is resolved too
285 (symlinks followed, ``..`` folded), like every other path here.
286 """
287 return replace(
288 preproc,
289 compile_commands=(
290 anchor(preproc.compile_commands, base).resolve()
291 if preproc.compile_commands is not None
292 else None
293 ),
294 includes=[anchor(inc, base).resolve() for inc in preproc.includes],
295 )
296
297
298class FieldConfig(TypedDict, total=False):
299 name: str
300 type: Literal["str", "list[str]"]
301 default: str | list[str] | None
302
303
304class OneLineCommentStyleType(TypedDict):
305 start_sequence: str
306 end_sequence: str
307 field_split_char: str
308 needs_fields: list[FieldConfig]
309
310
311@dataclass
312class OneLineCommentStyle:
313 def __setattr__(self, name: str, value: Any) -> None:
314 if name == "needs_fields":
315 # apply default to fields
316 self.apply_needs_field_default(value)
317 return super().__setattr__(name, value)
318
319 @classmethod
320 def field_names(cls) -> set[str]:
321 return {item.name for item in fields(cls)}
322
323 start_sequence: str = field(default="@", metadata={"schema": {"type": "string"}})
324 """Chars sequence to indicate the start of the one-line comment."""
325
326 end_sequence: str = field(
327 default=UNIX_NEWLINE, metadata={"schema": {"type": "string"}}
328 )
329 """Chars sequence to indicate the end of the one-line comment."""
330
331 field_split_char: str = field(default=",", metadata={"schema": {"type": "string"}})
332 """Char sequence to split the fields."""
333
334 needs_fields: list[FieldConfig] = field(
335 default_factory=lambda: [
336 {"name": "title"},
337 {"name": "id"},
338 {"name": "type", "default": "impl"},
339 {"name": "links", "type": "list[str]", "default": []},
340 ],
341 metadata={
342 "required_fields": ["title", "type"],
343 "field_default": {
344 "type": "str",
345 },
346 "schema": {
347 "type": "array",
348 "items": {
349 "type": "object",
350 "properties": {
351 "name": {"type": "string"},
352 "type": {
353 "type": "string",
354 "enum": ["str", "list[str]"],
355 "default": "str",
356 },
357 "default": {
358 "anyOf": [
359 {"type": "string"},
360 {"type": "array", "items": {"type": "string"}},
361 ]
362 },
363 },
364 "required": ["name"],
365 "additionalProperties": False,
366 "allOf": [
367 {
368 "if": {"properties": {"type": {"const": "list[str]"}}},
369 "then": {
370 "properties": {
371 "default": {
372 "type": "array",
373 "items": {"type": "string"},
374 }
375 }
376 },
377 },
378 {
379 "if": {"properties": {"type": {"const": "str"}}},
380 "then": {"properties": {"default": {"type": "string"}}},
381 },
382 ],
383 },
384 },
385 },
386 )
387
388 @classmethod
389 def apply_needs_field_default(cls, given_fields: list[FieldConfig]) -> None:
390 field_default = next(
391 _field.metadata["field_default"]
392 for _field in fields(cls)
393 if _field.name == "needs_fields"
394 )
395
396 for _field in given_fields:
397 for _default in field_default:
398 if _default not in _field:
399 _field[_default] = field_default[
400 _default
401 ] # dynamically assign keys
402
403 @classmethod
404 def get_required_fields(cls, name: str) -> list[str] | None:
405 _field = next(_field for _field in fields(cls) if _field.name is name)
406 if _field.metadata:
407 return cast(list[str], _field.metadata["required_fields"])
408 return None
409
410 @classmethod
411 def get_schema(cls, name: str) -> dict[str, Any] | None:
412 _field = next(_field for _field in fields(cls) if _field.name is name)
413 if _field.metadata and "schema" in _field.metadata:
414 return cast(dict[str, Any], _field.metadata["schema"])
415 return None
416
417 def check_schema(self) -> list[str]:
418 errors = []
419 for _field_name in self.field_names():
420 schema = self.get_schema(_field_name)
421 if schema is None:
422 # no schema to check against; `validate` would raise TypeError
423 continue
424 value = getattr(self, _field_name)
425 try:
426 validate(
427 instance=value,
428 schema=schema,
429 ) # validate has no type specified
430 except ValidationError as e:
431 if _field_name == "needs_fields":
432 need_field_name = value[e.path[0]]["name"]
433 errors.append(
434 f"Schema validation error in need_fields '{need_field_name}': {e.message}"
435 )
436 else:
437 errors.append(
438 f"Schema validation error in field '{_field_name}': {e.message}"
439 )
440 return errors
441
442 def check_required_fields(self) -> list[str]:
443 errors = []
444 required_fields = self.get_required_fields("needs_fields")
445 if required_fields is None:
446 errors.append("No required fields specified.")
447 return errors
448 given_field_names = [_field["name"] for _field in self.needs_fields]
449 missing_fields = set(required_fields) - set(given_field_names)
450 if len(missing_fields) != 0:
451 errors.append(f"Missing required fields: {sorted(missing_fields)}")
452
453 return errors
454
455 def check_fields_mutually_exclusive(self) -> list[str]:
456 errors = []
457 needs_field_names = set()
458 for _field in self.needs_fields:
459 if _field["name"] in needs_field_names:
460 errors.append(f"Field '{_field['name']}' is defined multiple times.")
461 needs_field_names.add(_field["name"])
462 return errors
463
464 def check_fields_default_order(self) -> list[str]:
465 errors = []
466 seen_default = False
467 first_default_field = ""
468 for _field in self.needs_fields:
469 has_default = _field.get("default") is not None
470 if has_default and not seen_default:
471 seen_default = True
472 first_default_field = _field["name"]
473 elif not has_default and seen_default:
474 errors.append(
475 f"Field '{_field['name']}' without a default follows "
476 f"field '{first_default_field}' which has a default. "
477 f"Fields without defaults must be defined before fields with defaults."
478 )
479 return errors
480
481 def check_fields_configuration(self) -> list[str]:
482 return (
483 self.check_schema()
484 + self.check_required_fields()
485 + self.check_fields_mutually_exclusive()
486 + self.check_fields_default_order()
487 )
488
489 def get_cnt_required_fields(self) -> int:
490 cnt_required_fields = 0
491 for _field in self.needs_fields:
492 if _field.get("default") is None:
493 cnt_required_fields += 1
494 return cnt_required_fields
495
496 def get_pos_list_str(self) -> list[int]:
497 pos_list_str = []
498 for idx, _field in enumerate(self.needs_fields):
499 if _field["type"] == "list[str]":
500 pos_list_str.append(idx + 1)
501 return pos_list_str
502
503
504class AnalyseSectionConfigType(TypedDict, total=False):
505 """Define typing for loading `analyse` section from the file."""
506
507 get_need_id_refs: bool
508 get_oneline_needs: bool
509 get_multiline_needs: bool
510 outdir: str
511 git_root: str
512 need_id_refs: NeedIdRefsConfigType
513 multiline_needs: MultilineNeedsConfigType
514 oneline_comment_style: OneLineCommentStyleType
515 preprocessor: dict[str, object]
516
517
518class SourceAnalyseConfigType(TypedDict, total=False):
519 """Define typing for its API configuration."""
520
521 src_files: list[Path]
522 src_dir: Path
523 comment_type: CommentType
524 get_need_id_refs: bool
525 get_oneline_needs: bool
526 get_multiline_needs: bool
527 git_root: Path | None
528 need_id_refs_config: NeedIdRefsConfig
529 multiline_needs_config: MultilineNeedsConfig
530 oneline_comment_style: OneLineCommentStyle
531 preprocessor: PreprocessorConfig | None
532
533
534class ProjectsAnalyseConfigType(TypedDict, total=False):
535 projects_config: dict[str, SourceAnalyseConfigType]
536
537
538@dataclass
539class SourceAnalyseConfig:
540 @classmethod
541 def field_names(cls) -> set[str]:
542 return {item.name for item in fields(cls)}
543
544 src_files: list[Path] = field(
545 default_factory=list,
546 metadata={"schema": {"type": "array", "items": {"type": "string"}}},
547 )
548 """A list of source files to be processed."""
549 src_dir: Path = field(
550 default_factory=lambda: Path("./"), metadata={"schema": {"type": "string"}}
551 )
552
553 comment_type: CommentType = field(
554 default=CommentType.cpp, metadata={"schema": {"type": "string"}}
555 )
556 """The type of comment to be processed."""
557
558 get_need_id_refs: bool = field(
559 default=True, metadata={"schema": {"type": "boolean"}}
560 )
561 """Whether to extract need id references from comments"""
562
563 get_oneline_needs: bool = field(
564 default=False, metadata={"schema": {"type": "boolean"}}
565 )
566 """Whether to extract oneline needs from comments"""
567
568 get_multiline_needs: bool = field(
569 default=False, metadata={"schema": {"type": "boolean"}}
570 )
571 """Whether to extract multi-line needs (``@need`` … ``@endneed``) from comments"""
572
573 git_root: Path | None = field(
574 default=None, metadata={"schema": {"type": ["string", "null"]}}
575 )
576 """Explicit path to the Git repository root. If not set, it will be auto-detected
577 by traversing parent directories. Useful for Bazel builds or deeply nested configs."""
578
579 need_id_refs_config: NeedIdRefsConfig = field(default_factory=NeedIdRefsConfig)
580 """Configuration for extracting need id references from comments."""
581
582 multiline_needs_config: MultilineNeedsConfig = field(
583 default_factory=MultilineNeedsConfig
584 )
585 """Configuration for extracting multi-line needs from comments."""
586
587 oneline_comment_style: OneLineCommentStyle = field(
588 default_factory=OneLineCommentStyle
589 )
590 """Configuration for extracting oneline needs from comments."""
591
592 preprocessor: PreprocessorConfig | None = field(default=None)
593 """Opt-in libclang preprocessor engine. None => tree-sitter (default).
594
595 No flat ``metadata["schema"]`` here: this is a nested dataclass, like the
596 sibling ``need_id_refs_config`` / ``multiline_needs_config`` /
597 ``oneline_comment_style`` fields. ``check_schema`` only validates fields that
598 declare a flat schema; giving this field one made it validate the constructed
599 ``PreprocessorConfig`` instance against JSON type ``object`` and fail at
600 ``config-inited``. Its structure is enforced by ``convert_analyse_config``.
601 """
602
603 @classmethod
604 def get_schema(cls, name: str) -> dict[str, Any] | None:
605 _field = next(_field for _field in fields(cls) if _field.name is name)
606 if _field.metadata and "schema" in _field.metadata:
607 return cast(dict[str, Any], _field.metadata["schema"])
608 return None
609
610 def check_schema(self) -> list[str]:
611 errors = []
612 for _field_name in self.field_names():
613 schema = self.get_schema(_field_name)
614 if not schema:
615 continue
616 value = getattr(self, _field_name)
617 if isinstance(value, Path): # adapt to json schema restriction
618 value = str(value)
619 if _field_name == "src_files" and isinstance(
620 value, list
621 ): # adapt to json schema restriction
622 value: list[str] = [
623 str(src_file) for src_file in value
624 ] # only for value adaptation
625 try:
626 validate(instance=value, schema=schema)
627 except ValidationError as e:
628 errors.append(
629 f"Schema validation error in field '{_field_name}': {e.message}"
630 )
631 return errors
632
633 def check_markers_mutually_exclusive(self) -> list[str]:
634 errors = set()
635 markers = set()
636 markers.add(self.oneline_comment_style.start_sequence)
637 markers.add(self.oneline_comment_style.end_sequence)
638 # equality only: the default one-line start ``@`` is a PREFIX of ``@need``, and
639 # what keeps the two apart is that a block's lines are hidden from the one-line
640 # parser, not this check. The two words are checked only when the feature is
641 # on: off, they are no markers of the project.
642 multiline_markers = (
643 (
644 self.multiline_needs_config.start_sequence,
645 self.multiline_needs_config.end_sequence,
646 )
647 if self.get_multiline_needs
648 else ()
649 )
650 for marker in multiline_markers:
651 if marker in markers:
652 errors.add(f"Marker {marker} is defined multiple times")
653 else:
654 markers.add(marker)
655
656 # a reference marker listed twice is an error too, in whichever form (a string,
657 # a table, or one of each): one text cannot name two fields
658 for marker in self.need_id_refs_config.markers:
659 if not isinstance(marker, str):
660 continue # reported by the schema check
661 if marker in markers:
662 errors.add(f"Marker {marker} is defined multiple times")
663 else:
664 markers.add(marker)
665 return list(errors)
666
667 def check_fields_configuration(self) -> list[str]:
668 errors: deque[str] = deque()
669 if self.get_need_id_refs:
670 need_id_refs_errors = self.need_id_refs_config.check_schema()
671 if need_id_refs_errors:
672 errors.appendleft("NeedIdRefs configuration errors:")
673 errors.extend(need_id_refs_errors)
674 if self.get_oneline_needs:
675 oneline_needs_errors = (
676 self.oneline_comment_style.check_fields_configuration()
677 )
678 if oneline_needs_errors:
679 errors.appendleft("OneLineCommentStyle configuration errors:")
680 errors.extend(oneline_needs_errors)
681 if self.get_multiline_needs:
682 multiline_errors = self.multiline_needs_config.check_fields_configuration()
683 if multiline_errors:
684 errors.appendleft("MultilineNeeds configuration errors:")
685 errors.extend(multiline_errors)
686 analyse_errors = self.check_markers_mutually_exclusive() + self.check_schema()
687 if analyse_errors:
688 errors.appendleft("analyse configuration errors:")
689 errors.extend(analyse_errors)
690 return list(errors)
691
692
693# Shared ubCode project file, which other useblocks tools (sphinx-needs,
694# ubCode checker, ...) read as well, so all tools see the same projects.
695DEFAULT_CONFIG_TOML: str = "ubproject.toml"
696
697DEFAULT_REF_URL_FIELD: str = "code_url"
698"""The need field ``@need-ids:`` references are attached to, unless a project's
699``ref_url_field`` names another (``""`` disables the attach), or a marker table names
700the field of its own references."""
701
702#: The table of the TOML file that both readers, the Sphinx extension and the CLI,
703#: take their configuration from.
704CODELINKS_TABLE: str = "codelinks"
705
706
707def load_codelinks_table(path: Path) -> dict[str, object] | None:
708 """Parse *path* and return its ``[codelinks]`` table, through ub-project.
709
710 The values are returned RAW: relative paths are anchored where they are used
711 (the Sphinx extension anchors at ``confdir / Path(config_from_toml).parent``,
712 unresolved), never here.
713
714 :param path: The TOML file.
715 :return: The table, or ``None`` when the file has no ``codelinks`` key.
716 :raises ub_project.ProjectConfigError: If the file cannot be read, is not UTF-8
717 or not valid TOML, or if ``codelinks`` is not a table.
718 :raises RecursionError: For a pathologically nested file, which ``load_toml``
719 does not wrap -- why both callers also catch ``Exception``.
720 """
721 return select_table(load_toml(path), CODELINKS_TABLE, source=path)
722
723
724class CodeLinksProjectConfigType(TypedDict, total=False):
725 """TypedDict defining the configuration structure for individual SrcTrace projects.
726
727 Contains both user-provided configuration:
728 - source_discover
729 - remote_url_pattern
730 - ref_url_field
731 - analyse
732 and runtime-generated configuration objects
733 - source_discover_config
734 - analyse_config
735 """
736
737 source_discover: SourceDiscoverSectionConfigType
738 remote_url_pattern: str
739 ref_url_field: str
740 analyse: AnalyseSectionConfigType
741 source_discover_config: SourceDiscoverConfig
742 analyse_config: SourceAnalyseConfig
743
744
745class CodeLinksConfigType(TypedDict):
746 config_from_toml: str | None
747 set_local_url: bool
748 local_url_field: str
749 set_remote_url: bool
750 remote_url_field: str
751 outdir: Path
752 projects: dict[str, CodeLinksProjectConfigType]
753 debug_measurement: bool
754 debug_filters: bool
755
756
757@dataclass
758class CodeLinksConfig:
759 @classmethod
760 def from_sphinx(cls, sphinx_config: _SphinxConfig) -> "CodeLinksConfig":
761 obj = cls()
762 src_trace_projects = getattr(sphinx_config, "src_trace_projects", None)
763 if isinstance(src_trace_projects, dict):
764 generate_project_configs(src_trace_projects)
765 super().__setattr__(obj, "_sphinx_config", sphinx_config)
766 return obj
767
768 def __getattribute__(self, name: str) -> Any:
769 if name.startswith("__") or name == "_sphinx_config":
770 return super().__getattribute__(name)
771 sphinx_config = (
772 object.__getattribute__(self, "_sphinx_config")
773 if "_sphinx_config" in self.__dict__
774 else None
775 )
776 if sphinx_config:
777 return getattr(
778 super().__getattribute__("_sphinx_config"), f"src_trace_{name}"
779 )
780
781 return object.__getattribute__(self, name)
782
783 def __setattr__(self, name: str, value: Any) -> None:
784 if name == "_sphinx_config" and "src_trace_projects" in value:
785 src_trace_projects: dict[str, CodeLinksProjectConfigType] = value[
786 "src_trace_projects"
787 ]
788 generate_project_configs(src_trace_projects)
789
790 if name.startswith("__") or name == "_sphinx_config":
791 return super().__setattr__(name, value)
792
793 sphinx_config = (
794 object.__getattribute__(self, "_sphinx_config")
795 if "_sphinx_config" in self.__dict__
796 else None
797 )
798
799 if sphinx_config:
800 setattr(
801 super().__getattribute__("_sphinx_config"), f"src_trace_{name}", value
802 )
803
804 if name == "outdir" and isinstance(value, str):
805 # Ensure outdir is a Path object
806 value = Path(value)
807 return object.__setattr__(self, name, value)
808
809 @classmethod
810 def add_config_values(cls, app: Sphinx) -> None:
811 """Add all config values to Sphinx application"""
812 for item in fields(cls):
813 if item.default_factory is not MISSING:
814 default = item.default_factory()
815 elif item.default is not MISSING:
816 default = item.default
817 else:
818 raise Exception(f"Field {item.name} has no default value or factory")
819
820 name = item.name
821 app.add_config_value(
822 f"src_trace_{name}",
823 default,
824 item.metadata["rebuild"],
825 types=item.metadata["types"],
826 )
827
828 @classmethod
829 def field_names(cls) -> set[str]:
830 return {item.name for item in fields(cls)}
831
832 @classmethod
833 def get_schema(cls, name: str) -> dict[str, Any] | None:
834 """Get the schema for a config item."""
835 _field = next(field for field in fields(cls) if field.name is name)
836 if _field.metadata and "schema" in _field.metadata:
837 return _field.metadata["schema"]
838 return None
839
840 config_from_toml: str | None = field(
841 default=DEFAULT_CONFIG_TOML,
842 metadata={
843 "rebuild": "env",
844 "types": (str, type(None)),
845 "schema": {
846 "type": ["string", "null"],
847 "examples": [DEFAULT_CONFIG_TOML, None],
848 },
849 },
850 )
851 """Path to a TOML file to load configuration from.
852
853 Defaults to ``ubproject.toml`` next to :file:`conf.py`. The default is the
854 value ``ubproject.toml`` exactly -- left unset, or written in conf.py as that
855 string: missing, or without a ``[codelinks]`` table, it is silently ignored.
856 Any other value, ``./ubproject.toml`` included, is an explicit file and warns
857 in both cases. A file that exists but cannot be read or parsed warns
858 (``codelinks.config``) either way, as any configured file did at 1.4.0.
859 """
860
861 set_local_url: bool = field(
862 default=False,
863 metadata={
864 "rebuild": "env",
865 "types": (bool,),
866 "schema": {
867 "type": "boolean",
868 },
869 },
870 )
871 """Set the file URL in the extracted need."""
872
873 local_url_field: str = field(
874 default="local-url",
875 metadata={
876 "rebuild": "env",
877 "types": (str,),
878 "schema": {
879 "type": "string",
880 },
881 },
882 )
883 """The field name for the file URL in the extracted need."""
884
885 set_remote_url: bool = field(
886 default=False,
887 metadata={
888 "rebuild": "env",
889 "types": (bool,),
890 "schema": {
891 "type": "boolean",
892 },
893 },
894 )
895 remote_url_field: str = field(
896 default="remote-url",
897 metadata={
898 "rebuild": "env",
899 "types": (str,),
900 "schema": {
901 "type": "string",
902 },
903 },
904 )
905 """The field name for the remote URL in the extracted need."""
906
907 outdir: Path = field(
908 default=Path("output"),
909 metadata={"rebuild": "env", "types": (str), "schema": {"type": "string"}},
910 )
911 """The directory where the generated artifacts and their caches will be stored."""
912
913 projects: dict[str, CodeLinksProjectConfigType] = field(
914 default_factory=dict,
915 metadata={
916 "rebuild": "env",
917 "types": (),
918 "schema": {
919 "type": "object",
920 "additionalProperties": {
921 "type": "object",
922 "properties": {
923 "source_discover": {},
924 "analyse": {},
925 "remote_url_pattern": {},
926 "ref_url_field": {},
927 "source_discover_config": {},
928 "analyse_config": {},
929 },
930 "additionalProperties": False,
931 },
932 },
933 },
934 )
935 """The configuration for the source tracing projects."""
936
937 debug_measurement: bool = field(
938 default=False, metadata={"rebuild": "html", "types": (bool,)}
939 )
940 """If True, log runtime information for various functions."""
941 debug_filters: bool = field(
942 default=False, metadata={"rebuild": "html", "types": (bool,)}
943 )
944 """If True, log filter processing runtime information."""
945
946
947def check_schema(config: CodeLinksConfig) -> list[str]:
948 """Check only first layer's of schema, so that the nested dict is not validated here."""
949 errors = []
950 for _field_name in CodeLinksConfig.field_names():
951 schema = CodeLinksConfig.get_schema(_field_name)
952 if not schema:
953 continue
954 value = getattr(config, _field_name)
955 if isinstance(value, Path): # adapt to json schema restriction
956 value = str(value)
957 try:
958 validate(instance=value, schema=schema)
959 except ValidationError as e:
960 errors.append(
961 f"Schema validation error in filed '{_field_name}': {e.message}"
962 )
963 return errors
964
965
966def check_project_configuration(
967 config: CodeLinksConfig, link_types: Collection[str] = ()
968) -> list[str]:
969 """Check nested project configurations.
970
971 :param config: The configuration.
972 :param link_types: The declared need link types, which no reference field may name
973 (:func:`declared_link_types`; empty where no Sphinx-Needs configuration exists).
974 """
975 errors = []
976
977 for project_name, project_config in config.projects.items():
978 project_errors: list[str] = []
979
980 # validate source_discover config
981 src_discover_config: SourceDiscoverConfig | None = project_config.get(
982 "source_discover_config"
983 )
984 src_discover_errors = []
985 if src_discover_config:
986 src_discover_errors.extend(src_discover_config.check_schema())
987
988 # validate analyse config
989 analyse_config: SourceAnalyseConfig | None = project_config.get(
990 "analyse_config"
991 )
992 analyse_errors = []
993 if analyse_config:
994 analyse_errors = analyse_config.check_fields_configuration()
995
996 # validate src-trace config
997 if config.set_remote_url and "remote_url_pattern" not in project_config:
998 project_errors.append(
999 "remote_url_pattern must be given, as set_remote_url is enabled"
1000 )
1001
1002 if "remote_url_pattern" in project_config and not isinstance(
1003 project_config["remote_url_pattern"], str
1004 ):
1005 project_errors.append("remote_url_pattern must be a string")
1006
1007 ref_url_field = project_config.get("ref_url_field", DEFAULT_REF_URL_FIELD)
1008 if not isinstance(ref_url_field, str):
1009 project_errors.append("ref_url_field must be a string")
1010 # a field is checked where it can be written: URLs on, and ``ref_url_field``
1011 # while a plain-string marker exists, or a marker table's non-empty field
1012 if config.set_local_url or config.set_remote_url:
1013 refs_config = (
1014 analyse_config.need_id_refs_config
1015 if analyse_config
1016 else NeedIdRefsConfig()
1017 )
1018 has_plain_marker = any(
1019 isinstance(marker, str) and marker not in refs_config.fields
1020 for marker in refs_config.markers
1021 )
1022 if (
1023 has_plain_marker
1024 and isinstance(ref_url_field, str)
1025 and (
1026 problem := reference_field_problem(
1027 ref_url_field, config, link_types
1028 )
1029 )
1030 ):
1031 project_errors.append(f"ref_url_field {ref_url_field!r} {problem}")
1032 for marker, field_name in refs_config.fields.items():
1033 if problem := reference_field_problem(field_name, config, link_types):
1034 project_errors.append(
1035 f"need_id_refs marker {marker!r}: field {field_name!r} {problem}"
1036 )
1037
1038 if analyse_errors or src_discover_errors or project_errors:
1039 errors.append(f"Project '{project_name}' has the following errors:")
1040 errors.extend(analyse_errors)
1041 errors.extend(src_discover_errors)
1042 errors.extend(project_errors)
1043
1044 return errors
1045
1046
1047def config_base_dir(confdir: str | Path, config: CodeLinksConfig) -> Path:
1048 """The directory a project's relative paths are anchored at.
1049
1050 The configuration file's directory when ``src_trace_config_from_toml`` names one
1051 (itself anchored at ``confdir``), else ``confdir``: ``src_dir``, ``git_root`` and
1052 the preprocessor paths all resolve against it.
1053 """
1054 base = Path(confdir)
1055 if config.config_from_toml:
1056 base = anchor(Path(config.config_from_toml).parent, base)
1057 return base
1058
1059
1060def locate_src_dir(
1061 confdir: str | Path,
1062 config: CodeLinksConfig,
1063 discover_config: SourceDiscoverConfig,
1064) -> Path:
1065 """A project's source directory, anchored (see :func:`config_base_dir`) and resolved."""
1066 return anchor(discover_config.src_dir, config_base_dir(confdir, config)).resolve()
1067
1068
1069def git_root_problem(git_root: Path, src_dir: Path) -> str | None:
1070 """Why a configured ``git_root`` cannot be a project's git root, or ``None``.
1071
1072 Every source path is relative to the git root, so it must be ``src_dir`` or a
1073 directory above it (#2062). Both paths anchored and resolved by the caller: the
1074 Sphinx extension (``project_analysis.configured_git_root``) and ``codelinks
1075 analyse`` alike, each treating a value with a problem as unset.
1076 """
1077 try:
1078 if not git_root.exists():
1079 return f"git_root {git_root.as_posix()} does not exist"
1080 if not git_root.is_dir():
1081 return f"git_root {git_root.as_posix()} is not a directory"
1082 except OSError:
1083 return f"git_root {git_root.as_posix()} cannot be read"
1084 if not src_dir.is_relative_to(git_root):
1085 return (
1086 f"git_root {git_root.as_posix()} does not contain src_dir "
1087 f"{src_dir.as_posix()}"
1088 )
1089 return None
1090
1091
1092def git_root_warning(project: str, problem: str) -> str:
1093 """The one warning text for a project's ignored ``git_root`` (``codelinks.git_root``)."""
1094 return (
1095 f"project {project!r}: {problem}; it is ignored, and the repository root is "
1096 "detected from src_dir instead"
1097 )
1098
1099
1100def remote_url_pattern_warnings(config: CodeLinksConfig) -> list[str]:
1101 """Why a project's remote URL pattern will not render as one link, if it will not.
1102
1103 The ``remote-url`` value is the pattern filled in, and Sphinx-Needs splits every
1104 string-linked value on ``,`` and ``;`` before turning the parts into links. A
1105 pattern containing either -- gitweb's ``?p=repo.git;a=blob;f={path}`` is the
1106 classic -- therefore renders as several links, none of them right.
1107 """
1108 if not config.set_remote_url or not isinstance(config.projects, dict):
1109 return []
1110 warnings = []
1111 for name, project_config in config.projects.items():
1112 if not isinstance(project_config, dict):
1113 continue
1114 pattern = project_config.get("remote_url_pattern")
1115 if isinstance(pattern, str) and ("," in pattern or ";" in pattern):
1116 warnings.append(
1117 f"Project {name!r}: remote_url_pattern {pattern!r} contains ',' or "
1118 "';'. Sphinx-Needs splits string-linked values on ',' and ';', so this "
1119 "pattern's links will not render as one link."
1120 )
1121 return warnings
1122
1123
1124def _url_fields(config: CodeLinksConfig) -> set[str]:
1125 """The names of the URL fields the extension registers (when switched on)."""
1126 names: set[str] = set()
1127 if config.set_local_url:
1128 names.add(config.local_url_field)
1129 if config.set_remote_url:
1130 names.add(config.remote_url_field)
1131 return names
1132
1133
1134CORE_LINK_TYPES: frozenset[str] = frozenset({"links", "parent_needs"})
1135"""The link types every Sphinx-Needs project has."""
1136
1137
1138def declared_link_types(sphinx_config: _SphinxConfig) -> frozenset[str]:
1139 """The need link types a Sphinx configuration declares.
1140
1141 The core ones, ``needs_links``' keys and ``needs_extra_links``' ``option`` names:
1142 the link types Sphinx-Needs itself checks a field name against. Read from the
1143 configuration values, so it is complete once ``needs_from_toml`` has been loaded
1144 (early in ``config-inited``).
1145 """
1146 names = set(CORE_LINK_TYPES)
1147 links = getattr(sphinx_config, "needs_links", None)
1148 if isinstance(links, dict):
1149 names.update(name for name in links if isinstance(name, str))
1150 extra_links = getattr(sphinx_config, "needs_extra_links", None)
1151 if isinstance(extra_links, list | tuple):
1152 names.update(
1153 link["option"]
1154 for link in extra_links
1155 if isinstance(link, dict) and isinstance(link.get("option"), str)
1156 )
1157 return frozenset(names)
1158
1159
1160def reference_field_problem(
1161 field_name: str, config: CodeLinksConfig, link_types: Collection[str] = ()
1162) -> str | None:
1163 """Why a field cannot hold reference URLs, or ``None``.
1164
1165 The one check of every field references are attached to, ``ref_url_field`` and a
1166 marker table's ``field`` alike. ``""`` (attach nowhere) has no problem.
1167
1168 :param field_name: The field.
1169 :param config: The configuration, for the URL fields.
1170 :param link_types: The declared need link types (:func:`declared_link_types`).
1171 :return: The rest of a sentence naming the field, or ``None``.
1172 """
1173 if not field_name:
1174 return None
1175 if field_name in _url_fields(config):
1176 return "must differ from local_url_field and remote_url_field"
1177 if field_name in link_types:
1178 return "names a need link type, which cannot hold URLs"
1179 if is_back_link_name(field_name, link_types):
1180 return "names the back links of a need link type, which cannot hold URLs"
1181 return None
1182
1183
1184def is_back_link_name(field_name: str, link_types: Collection[str]) -> bool:
1185 """Whether ``field_name`` is the back links of one of ``link_types``
1186 (``<link type>_back``, as Sphinx-Needs names them), core or declared."""
1187 return field_name.endswith("_back") and field_name[: -len("_back")] in link_types
1188
1189
1190def need_id_refs_marker_fields(
1191 config: CodeLinksConfig, project_config: CodeLinksProjectConfigType
1192) -> dict[str, str]:
1193 """Each marker of a project whose references are attached, mapped to its field.
1194
1195 The gate: URLs are on (local or remote) and references are extracted. A marker
1196 given as a table takes its own ``field``; a marker string takes the project's
1197 ``ref_url_field``. A marker whose field is ``""``, not a string, or one of the URL
1198 fields attaches nothing and is left out -- the last two are configuration errors
1199 reported by :func:`check_project_configuration`, as is a field naming a link type.
1200 An empty mapping: the project attaches no reference.
1201 """
1202 if not (config.set_remote_url or config.set_local_url):
1203 return {}
1204 analyse_config = project_config.get("analyse_config")
1205 if analyse_config is None or not analyse_config.get_need_id_refs:
1206 return {}
1207 refs_config = analyse_config.need_id_refs_config
1208 ref_url_field = project_config.get("ref_url_field", DEFAULT_REF_URL_FIELD)
1209 url_fields = _url_fields(config)
1210 marker_fields: dict[str, str] = {}
1211 for marker in refs_config.markers:
1212 if not isinstance(marker, str):
1213 continue
1214 field_name = refs_config.fields.get(marker, ref_url_field)
1215 if isinstance(field_name, str) and field_name and field_name not in url_fields:
1216 marker_fields[marker] = field_name
1217 return marker_fields
1218
1219
1220def need_id_refs_fields(config: CodeLinksConfig) -> dict[str, dict[str, str]]:
1221 """Each project whose references are attached, mapped to its markers' fields
1222 (:func:`need_id_refs_marker_fields`)."""
1223 projects = config.projects
1224 if not isinstance(projects, dict):
1225 return {}
1226 fields_by_project: dict[str, dict[str, str]] = {}
1227 for name, project_config in projects.items():
1228 if not isinstance(project_config, dict):
1229 continue
1230 marker_fields = need_id_refs_marker_fields(config, project_config)
1231 if marker_fields:
1232 fields_by_project[name] = marker_fields
1233 return fields_by_project
1234
1235
1236def need_id_refs_field_names(config: CodeLinksConfig) -> set[str]:
1237 """Every field some project attaches references to: the fields to register."""
1238 return {
1239 field_name
1240 for marker_fields in need_id_refs_fields(config).values()
1241 for field_name in marker_fields.values()
1242 }
1243
1244
1245def check_configuration(
1246 config: CodeLinksConfig, link_types: Collection[str] = ()
1247) -> list[str]:
1248 errors = []
1249 errors.extend(check_schema(config))
1250 errors.extend(check_project_configuration(config, link_types))
1251 return errors
1252
1253
1254def convert_src_discovery_config(
1255 config_dict: SourceDiscoverSectionConfigType | None,
1256) -> SourceDiscoverConfig:
1257 if config_dict:
1258 src_discover_dict = {
1259 key: (Path(value) if key == "src_dir" and isinstance(value, str) else value)
1260 for key, value in config_dict.items()
1261 }
1262 src_discover_config = SourceDiscoverConfig(**src_discover_dict) # ty: ignore[invalid-argument-type]
1263 else:
1264 src_discover_config = SourceDiscoverConfig()
1265
1266 return src_discover_config
1267
1268
1269def _validate_preprocessor_dict(preproc: dict[str, object]) -> None:
1270 """Validate the schema-less ``[preprocessor]`` TOML section.
1271
1272 The section has no ``TypedDict``, so a mistyped scalar would otherwise be
1273 coerced into garbage instead of reported: e.g. ``defines = "X"`` (a bare
1274 string) becomes ``list("X") == ["X"]`` — or worse, ``defines = "cpp17"``
1275 becomes ``["c", "p", "p", "1", "7"]`` → five bogus ``-D`` flags. Fail loud.
1276
1277 :param preproc: the raw ``[preprocessor]`` mapping from TOML.
1278 :raises TypeError: if a key has the wrong type.
1279 """
1280 for key in ("defines", "includes"):
1281 value = preproc.get(key)
1282 if value is not None and (
1283 not isinstance(value, list) or not all(isinstance(x, str) for x in value)
1284 ):
1285 raise TypeError(
1286 f"[preprocessor] {key} must be a list of strings, "
1287 f"got {type(value).__name__}: {value!r}"
1288 )
1289 for key in ("compile_commands", "std"):
1290 value = preproc.get(key)
1291 if value is not None and not isinstance(value, str):
1292 raise TypeError(
1293 f"[preprocessor] {key} must be a string, "
1294 f"got {type(value).__name__}: {value!r}"
1295 )
1296
1297
1298REMOVED_ANALYSE_KEYS: dict[str, str] = {
1299 "get_rst": "get_multiline_needs",
1300 "marked_rst": "[analyse.multiline_needs]",
1301}
1302"""``[analyse]`` keys of the removed marked-rst blocks -> what replaces each."""
1303
1304
1305def check_removed_analyse_keys(config_dict: AnalyseSectionConfigType) -> None:
1306 """Refuse the keys of the removed ``@rst`` blocks with one message naming the cure.
1307
1308 Any other unknown key keeps failing as before, in the dataclass constructor. The one
1309 exception: ``get_rst = false`` alone asks for nothing the blocks did, and
1310 configuration scaffolds wrote it into ``ubproject.toml`` files ubCode reads too, so
1311 it passes here silently -- this runs on every configuration read -- and the build
1312 and the CLI warn once per project (:func:`get_rst_ignored_warning`).
1313
1314 :raises TypeError: through the channel every other configuration error of the
1315 section takes (``typer.BadParameter`` in the CLI, the ``config-inited`` error of
1316 a Sphinx build).
1317 """
1318 found = [key for key in REMOVED_ANALYSE_KEYS if key in config_dict]
1319 if not found:
1320 return
1321 removed = " and ".join(
1322 f"'{key}'" if key == "get_rst" else f"'[analyse.{key}]'" for key in found
1323 )
1324 replacements = " and ".join(REMOVED_ANALYSE_KEYS[key] for key in found)
1325 if _tolerated_get_rst(config_dict):
1326 return
1327 raise TypeError(
1328 f"analyse: {removed} {'is' if len(found) == 1 else 'are'} no longer supported: "
1329 "the marked-rst blocks were replaced by multi-line needs (the @need and "
1330 f"@endneed markers); use {replacements} instead"
1331 )
1332
1333
1334GET_RST_IGNORED = (
1335 "analyse: 'get_rst' is no longer read: the marked-rst blocks were replaced by "
1336 "multi-line needs (the @need and @endneed markers); get_rst = false asks for "
1337 "nothing and can be removed"
1338)
1339"""The warning for a tolerated ``get_rst = false``."""
1340
1341
1342def _tolerated_get_rst(config_dict: Any) -> bool:
1343 """Whether an ``[analyse]`` section's only removed key is ``get_rst = false``."""
1344 return (
1345 isinstance(config_dict, dict)
1346 and config_dict.get("get_rst") is False
1347 and not any(
1348 key in config_dict for key in REMOVED_ANALYSE_KEYS if key != "get_rst"
1349 )
1350 )
1351
1352
1353def get_rst_ignored_warning(name: str, project_config: Any) -> str | None:
1354 """The warning for a project whose ``[analyse]`` section sets ``get_rst = false``
1355 (tolerated, and dropped, by :func:`check_removed_analyse_keys`), else ``None``.
1356
1357 It names the project, as :func:`remote_url_pattern_warnings` does; the rest is
1358 :data:`GET_RST_IGNORED`. Called once per project where a configuration is loaded --
1359 a Sphinx build's ``config-inited``, the CLI before it analyses -- never per read
1360 of it.
1361 """
1362 if isinstance(project_config, dict) and _tolerated_get_rst(
1363 project_config.get("analyse")
1364 ):
1365 return f"Project {name!r}: {GET_RST_IGNORED}"
1366 return None
1367
1368
1369def convert_analyse_config(
1370 config_dict: AnalyseSectionConfigType | None,
1371 src_discover: SourceDiscover | None = None,
1372) -> SourceAnalyseConfig:
1373 analyse_config_dict: SourceAnalyseConfigType = {}
1374 if config_dict:
1375 check_removed_analyse_keys(config_dict)
1376 for k, v in config_dict.items():
1377 if k not in {
1378 "oneline_comment_style",
1379 "need_id_refs",
1380 "multiline_needs",
1381 "preprocessor",
1382 # tolerated (``check_removed_analyse_keys``) and ignored
1383 "get_rst",
1384 }:
1385 # Convert string paths to Path objects
1386 if k in {"src_dir", "git_root"} and isinstance(v, str):
1387 analyse_config_dict[k] = Path(v)
1388 else:
1389 # dynamical assignment
1390 analyse_config_dict[k] = v # ty: ignore[invalid-key]
1391
1392 # Get oneline_comment_style configuration
1393 oneline_comment_style_dict: OneLineCommentStyleType | None = config_dict.get(
1394 "oneline_comment_style"
1395 )
1396 oneline_comment_style: OneLineCommentStyle = (
1397 convert_oneline_comment_style_config(oneline_comment_style_dict)
1398 )
1399
1400 # Get need_id_refs configuration
1401 need_id_refs_config_dict: NeedIdRefsConfigType | None = config_dict.get(
1402 "need_id_refs"
1403 )
1404 need_id_refs_config = convert_need_id_refs_config(need_id_refs_config_dict)
1405
1406 # Get multiline_needs configuration
1407 multiline_needs_config_dict: MultilineNeedsConfigType | None = config_dict.get(
1408 "multiline_needs"
1409 )
1410 multiline_needs_config = convert_multiline_needs_config(
1411 multiline_needs_config_dict
1412 )
1413
1414 analyse_config_dict["need_id_refs_config"] = need_id_refs_config
1415 analyse_config_dict["multiline_needs_config"] = multiline_needs_config
1416 analyse_config_dict["oneline_comment_style"] = oneline_comment_style
1417
1418 preprocessor_dict = config_dict.get("preprocessor")
1419 if preprocessor_dict is not None:
1420 # The preprocessor section has no TypedDict; its values are dynamic
1421 # TOML (typed ``object``), so validate the shapes up front -- a mistyped
1422 # scalar would otherwise coerce into garbage flags.
1423 _validate_preprocessor_dict(preprocessor_dict)
1424 analyse_config_dict["preprocessor"] = PreprocessorConfig(
1425 compile_commands=(
1426 Path(str(preprocessor_dict["compile_commands"]))
1427 if preprocessor_dict.get("compile_commands")
1428 else None
1429 ),
1430 defines=list(preprocessor_dict.get("defines", [])), # ty: ignore[invalid-argument-type]
1431 includes=[Path(str(p)) for p in preprocessor_dict.get("includes", [])], # ty: ignore[not-iterable]
1432 std=str(preprocessor_dict.get("std", DEFAULT_CPP_STD)),
1433 )
1434
1435 if src_discover:
1436 analyse_config_dict["src_files"] = src_discover.source_paths
1437 analyse_config_dict["src_dir"] = src_discover.src_discover_config.src_dir
1438 try:
1439 analyse_config_dict["comment_type"] = CommentType(
1440 src_discover.src_discover_config.comment_type
1441 )
1442 except ValueError:
1443 # If invalid comment_type, keep the string value
1444 # Validation will catch this error later
1445 comment_type_str: str = src_discover.src_discover_config.comment_type
1446 analyse_config_dict["comment_type"] = comment_type_str # ty: ignore[invalid-assignment]
1447
1448 return SourceAnalyseConfig(**analyse_config_dict)
1449
1450
1451def convert_oneline_comment_style_config(
1452 config_dict: OneLineCommentStyleType | None,
1453) -> OneLineCommentStyle:
1454 if config_dict is None:
1455 oneline_comment_style = OneLineCommentStyle()
1456 else:
1457 try:
1458 oneline_comment_style = OneLineCommentStyle(**config_dict)
1459 except TypeError as e:
1460 raise TypeError(f"Invalid oneline comment style configuration: {e}") from e
1461 return oneline_comment_style
1462
1463
1464def convert_need_id_refs_config(
1465 config_dict: NeedIdRefsConfigType | None,
1466) -> NeedIdRefsConfig:
1467 if not config_dict:
1468 need_id_refs_config = NeedIdRefsConfig()
1469 else:
1470 try:
1471 # a marker table is resolved to its text by ``__post_init__``
1472 need_id_refs_config = NeedIdRefsConfig(**config_dict) # ty: ignore[invalid-argument-type]
1473 except TypeError as e:
1474 raise TypeError(f"Invalid need_id_refs configuration: {e}") from e
1475 return need_id_refs_config
1476
1477
1478def convert_multiline_needs_config(
1479 config_dict: MultilineNeedsConfigType | None,
1480) -> MultilineNeedsConfig:
1481 if not config_dict:
1482 return MultilineNeedsConfig()
1483 try:
1484 return MultilineNeedsConfig(**config_dict)
1485 except TypeError as e:
1486 raise TypeError(f"Invalid multiline_needs configuration: {e}") from e
1487
1488
1489def generate_project_configs(
1490 project_configs: dict[str, CodeLinksProjectConfigType],
1491) -> None:
1492 """Generate configs of source discover and analyse from their classes dynamically."""
1493 for project_config in project_configs.values():
1494 # overwrite the config into different types on purpose
1495 # covert dicts to their own classes
1496 src_discover_section: SourceDiscoverSectionConfigType | None = cast(
1497 SourceDiscoverSectionConfigType,
1498 project_config.get("source_discover"),
1499 )
1500 source_discover_config = convert_src_discovery_config(src_discover_section)
1501 project_config["source_discover_config"] = source_discover_config
1502
1503 analyse_section_config: AnalyseSectionConfigType | None = cast(
1504 AnalyseSectionConfigType, project_config.get("analyse")
1505 )
1506 analyse_config = convert_analyse_config(analyse_section_config)
1507 analyse_config.get_oneline_needs = True # force to get oneline_need
1508 # Copy comment_type from source_discover_config to analyse_config
1509 try:
1510 analyse_config.comment_type = CommentType(
1511 source_discover_config.comment_type
1512 )
1513 except ValueError:
1514 # If invalid comment_type, keep the string value
1515 # Validation will catch this error later
1516 analyse_config.comment_type = source_discover_config.comment_type # ty: ignore[invalid-assignment]
1517 project_config["analyse_config"] = analyse_config