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