1from collections import deque
2from dataclasses import MISSING, dataclass, field, fields
3from enum import Enum
4from pathlib import Path
5from typing import Any, Literal, TypedDict, cast
6
7from jsonschema import ValidationError, validate
8from sphinx.application import Sphinx
9from sphinx.config import Config as _SphinxConfig
10
11from sphinx_codelinks.source_discover.config import (
12 CommentType,
13 SourceDiscoverConfig,
14 SourceDiscoverSectionConfigType,
15)
16from sphinx_codelinks.source_discover.source_discover import SourceDiscover
17
18UNIX_NEWLINE = "\n"
19
20
21COMMENT_MARKERS = {
[docs] 22 # @Support C and C++ style comments, IMPL_C_1, impl, [FE_C_SUPPORT, FE_CPP]
23 CommentType.cpp: ["//", "/*"],
[docs] 24 # @Support Python style comments, IMPL_PY_1, impl, [FE_PY]
25 CommentType.python: ["#"],
26 CommentType.cs: ["//", "/*", "///"],
27}
28ESCAPE = "\\"
29
30
31class CommentCategory(str, Enum):
32 comment = "comment"
33 docstring = "expression_statement"
34
35
36class NeedIdRefsConfigType(TypedDict):
37 markers: list[str]
38
39
40@dataclass
41class NeedIdRefsConfig:
42 @classmethod
43 def field_names(cls) -> set[str]:
44 return {item.name for item in fields(cls)}
45
46 markers: list[str] = field(
47 default_factory=lambda: ["@need-ids:"],
48 metadata={"schema": {"type": "array", "items": {"type": "string"}}},
49 )
50 """The markers to extract need ids from"""
51
52 @classmethod
53 def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
54 _field = next(_field for _field in fields(cls) if _field.name is name)
55 if _field.metadata and "schema" in _field.metadata:
56 return cast(dict[str, Any], _field.metadata["schema"]) # type: ignore[explicit-any]
57 return None
58
59 def check_schema(self) -> list[str]:
60 errors = []
61 for _field_name in self.field_names():
62 schema = self.get_schema(_field_name)
63 value = getattr(self, _field_name)
64 try:
65 validate(instance=value, schema=schema) # type: ignore[arg-type] # validate has no type
66 except ValidationError as e:
67 errors.append(
68 f"Schema validation error in field '{_field_name}': {e.message}"
69 )
70 return errors
71
72
73class MarkedRstConfigType(TypedDict):
74 start_sequence: str
75 end_sequence: str
76
77
78@dataclass
79class MarkedRstConfig:
80 @classmethod
81 def field_names(cls) -> set[str]:
82 return {item.name for item in fields(cls)}
83
84 start_sequence: str = field(default="@rst", metadata={"schema": {"type": "string"}})
85 """Chars sequence to indicate the start of the rst text."""
86 end_sequence: str = field(
87 default="@endrst", metadata={"schema": {"type": "string"}}
88 )
89 """Chars sequence to indicate the end of the rst text."""
90
91 @classmethod
92 def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
93 _field = next(_field for _field in fields(cls) if _field.name is name)
94 if _field.metadata and "schema" in _field.metadata:
95 return cast(dict[str, Any], _field.metadata["schema"]) # type: ignore[explicit-any]
96 return None
97
98 def check_schema(self) -> list[str]:
99 errors = []
100 for _field_name in self.field_names():
101 schema = self.get_schema(_field_name)
102 value = getattr(self, _field_name)
103 try:
104 validate(instance=value, schema=schema) # type: ignore[arg-type] # validate has no type
105 except ValidationError as e:
106 errors.append(
107 f"Schema validation error in field '{_field_name}': {e.message}"
108 )
109 return errors
110
111 def check_sequence_mutually_exclusive(self) -> list[str]:
112 errors = []
113 if self.start_sequence == self.end_sequence:
114 errors.append("start_sequence and end_sequence cannot be the same.")
115 return errors
116
117 def check_fields_configuration(self) -> list[str]:
118 return self.check_schema() + self.check_sequence_mutually_exclusive()
119
120
121class FieldConfig(TypedDict, total=False):
122 name: str
123 type: Literal["str", "list[str]"]
124 default: str | list[str] | None
125
126
127class OneLineCommentStyleType(TypedDict):
128 start_sequence: str
129 end_sequence: str
130 field_split_char: str
131 needs_fields: list[FieldConfig]
132
133
134@dataclass
135class OneLineCommentStyle:
136 def __setattr__(self, name: str, value: Any) -> None: # type: ignore[explicit-any]
137 if name == "needs_fields":
138 # apply default to fields
139 self.apply_needs_field_default(value)
140 return super().__setattr__(name, value)
141
142 @classmethod
143 def field_names(cls) -> set[str]:
144 return {item.name for item in fields(cls)}
145
146 start_sequence: str = field(default="@", metadata={"schema": {"type": "string"}})
147 """Chars sequence to indicate the start of the one-line comment."""
148
149 end_sequence: str = field(
150 default=UNIX_NEWLINE, metadata={"schema": {"type": "string"}}
151 )
152 """Chars sequence to indicate the end of the one-line comment."""
153
154 field_split_char: str = field(default=",", metadata={"schema": {"type": "string"}})
155 """Char sequence to split the fields."""
156
157 needs_fields: list[FieldConfig] = field(
158 default_factory=lambda: [
159 {"name": "title"},
160 {"name": "id"},
161 {"name": "type", "default": "impl"},
162 {"name": "links", "type": "list[str]", "default": []},
163 ],
164 metadata={
165 "required_fields": ["title", "type"],
166 "field_default": {
167 "type": "str",
168 },
169 "schema": {
170 "type": "array",
171 "items": {
172 "type": "object",
173 "properties": {
174 "name": {"type": "string"},
175 "type": {
176 "type": "string",
177 "enum": ["str", "list[str]"],
178 "default": "str",
179 },
180 "default": {
181 "anyOf": [
182 {"type": "string"},
183 {"type": "array", "items": {"type": "string"}},
184 ]
185 },
186 },
187 "required": ["name"],
188 "additionalProperties": False,
189 "allOf": [
190 {
191 "if": {"properties": {"type": {"const": "list[str]"}}},
192 "then": {
193 "properties": {
194 "default": {
195 "type": "array",
196 "items": {"type": "string"},
197 }
198 }
199 },
200 },
201 {
202 "if": {"properties": {"type": {"const": "str"}}},
203 "then": {"properties": {"default": {"type": "string"}}},
204 },
205 ],
206 },
207 },
208 },
209 )
210
211 @classmethod
212 def apply_needs_field_default(cls, given_fields: list[FieldConfig]) -> None:
213 field_default = next(
214 _field.metadata["field_default"]
215 for _field in fields(cls)
216 if _field.name == "needs_fields"
217 )
218
219 for _field in given_fields:
220 for _default in field_default:
221 if _default not in _field:
222 _field[_default] = field_default[_default] # type: ignore[literal-required] # dynamically assign keys
223
224 @classmethod
225 def get_required_fields(cls, name: str) -> list[str] | None:
226 _field = next(_field for _field in fields(cls) if _field.name is name)
227 if _field.metadata:
228 return cast(list[str], _field.metadata["required_fields"])
229 return None
230
231 @classmethod
232 def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
233 _field = next(_field for _field in fields(cls) if _field.name is name)
234 if _field.metadata and "schema" in _field.metadata:
235 return cast(dict[str, Any], _field.metadata["schema"]) # type: ignore[explicit-any]
236 return None
237
238 def check_schema(self) -> list[str]:
239 errors = []
240 for _field_name in self.field_names():
241 schema = self.get_schema(_field_name)
242 value = getattr(self, _field_name)
243 try:
244 validate(instance=value, schema=schema) # type: ignore[arg-type] # validate has no type specified
245 except ValidationError as e:
246 if _field_name == "needs_fields":
247 need_field_name = value[e.path[0]]["name"]
248 errors.append(
249 f"Schema validation error in need_fields '{need_field_name}': {e.message}"
250 )
251 else:
252 errors.append(
253 f"Schema validation error in field '{_field_name}': {e.message}"
254 )
255 return errors
256
257 def check_required_fields(self) -> list[str]:
258 errors = []
259 required_fields = self.get_required_fields("needs_fields")
260 if required_fields is None:
261 errors.append("No required fields specified.")
262 return errors
263 given_field_names = [_field["name"] for _field in self.needs_fields]
264 missing_fields = set(required_fields) - set(given_field_names)
265 if len(missing_fields) != 0:
266 errors.append(f"Missing required fields: {sorted(missing_fields)}")
267
268 return errors
269
270 def check_fields_mutually_exclusive(self) -> list[str]:
271 errors = []
272 needs_field_names = set()
273 for _field in self.needs_fields:
274 if _field["name"] in needs_field_names:
275 errors.append(f"Field '{_field['name']}' is defined multiple times.")
276 needs_field_names.add(_field["name"])
277 return errors
278
279 def check_fields_default_order(self) -> list[str]:
280 errors = []
281 seen_default = False
282 first_default_field = ""
283 for _field in self.needs_fields:
284 has_default = _field.get("default") is not None
285 if has_default and not seen_default:
286 seen_default = True
287 first_default_field = _field["name"]
288 elif not has_default and seen_default:
289 errors.append(
290 f"Field '{_field['name']}' without a default follows "
291 f"field '{first_default_field}' which has a default. "
292 f"Fields without defaults must be defined before fields with defaults."
293 )
294 return errors
295
296 def check_fields_configuration(self) -> list[str]:
297 return (
298 self.check_schema()
299 + self.check_required_fields()
300 + self.check_fields_mutually_exclusive()
301 + self.check_fields_default_order()
302 )
303
304 def get_cnt_required_fields(self) -> int:
305 cnt_required_fields = 0
306 for _field in self.needs_fields:
307 if _field.get("default") is None:
308 cnt_required_fields += 1
309 return cnt_required_fields
310
311 def get_pos_list_str(self) -> list[int]:
312 pos_list_str = []
313 for idx, _field in enumerate(self.needs_fields):
314 if _field["type"] == "list[str]":
315 pos_list_str.append(idx + 1)
316 return pos_list_str
317
318
319class AnalyseSectionConfigType(TypedDict, total=False):
320 """Define typing for loading `analyse` section from the file."""
321
322 get_need_id_refs: bool
323 get_oneline_needs: bool
324 get_rst: bool
325 outdir: str
326 git_root: str
327 need_id_refs: NeedIdRefsConfigType
328 marked_rst: MarkedRstConfigType
329 oneline_comment_style: OneLineCommentStyleType
330
331
332class SourceAnalyseConfigType(TypedDict, total=False):
333 """Define typing for its API configuration."""
334
335 src_files: list[Path]
336 src_dir: Path
337 comment_type: CommentType
338 get_need_id_refs: bool
339 get_oneline_needs: bool
340 get_rst: bool
341 git_root: Path | None
342 need_id_refs_config: NeedIdRefsConfig
343 marked_rst_config: MarkedRstConfig
344 oneline_comment_style: OneLineCommentStyle
345
346
347class ProjectsAnalyseConfigType(TypedDict, total=False):
348 projects_config: dict[str, SourceAnalyseConfigType]
349
350
351@dataclass
352class SourceAnalyseConfig:
353 @classmethod
354 def field_names(cls) -> set[str]:
355 return {item.name for item in fields(cls)}
356
357 src_files: list[Path] = field(
358 default_factory=list,
359 metadata={"schema": {"type": "array", "items": {"type": "string"}}},
360 )
361 """A list of source files to be processed."""
362 src_dir: Path = field(
363 default_factory=lambda: Path("./"), metadata={"schema": {"type": "string"}}
364 )
365
366 comment_type: CommentType = field(
367 default=CommentType.cpp, metadata={"schema": {"type": "string"}}
368 )
369 """The type of comment to be processed."""
370
371 get_need_id_refs: bool = field(
372 default=True, metadata={"schema": {"type": "boolean"}}
373 )
374 """Whether to extract need id references from comments"""
375
376 get_oneline_needs: bool = field(
377 default=False, metadata={"schema": {"type": "boolean"}}
378 )
379 """Whether to extract oneline needs from comments"""
380
381 get_rst: bool = field(default=False, metadata={"schema": {"type": "boolean"}})
382 """Whether to extract rst texts from comments"""
383
384 git_root: Path | None = field(
385 default=None, metadata={"schema": {"type": ["string", "null"]}}
386 )
387 """Explicit path to the Git repository root. If not set, it will be auto-detected
388 by traversing parent directories. Useful for Bazel builds or deeply nested configs."""
389
390 need_id_refs_config: NeedIdRefsConfig = field(default_factory=NeedIdRefsConfig)
391 """Configuration for extracting need id references from comments."""
392
393 marked_rst_config: MarkedRstConfig = field(default_factory=MarkedRstConfig)
394 """Configuration for extracting rst texts from comments."""
395
396 oneline_comment_style: OneLineCommentStyle = field(
397 default_factory=OneLineCommentStyle
398 )
399 """Configuration for extracting oneline needs from comments."""
400
401 @classmethod
402 def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
403 _field = next(_field for _field in fields(cls) if _field.name is name)
404 if _field.metadata and "schema" in _field.metadata:
405 return cast(dict[str, Any], _field.metadata["schema"]) # type: ignore[explicit-any]
406 return None
407
408 def check_schema(self) -> list[str]:
409 errors = []
410 for _field_name in self.field_names():
411 schema = self.get_schema(_field_name)
412 if not schema:
413 continue
414 value = getattr(self, _field_name)
415 if isinstance(value, Path): # adapt to json schema restriction
416 value = str(value)
417 if _field_name == "src_files" and isinstance(
418 value, list
419 ): # adapt to json schema restriction
420 value: list[str] = [str(src_file) for src_file in value] # type: ignore[no-redef] # only for value adaptation
421 try:
422 validate(instance=value, schema=schema)
423 except ValidationError as e:
424 errors.append(
425 f"Schema validation error in field '{_field_name}': {e.message}"
426 )
427 return errors
428
429 def check_markers_mutually_exclusive(self) -> list[str]:
430 errors = set()
431 markers = set()
432 markers.add(self.oneline_comment_style.start_sequence)
433 markers.add(self.oneline_comment_style.end_sequence)
434 if self.marked_rst_config.start_sequence in markers:
435 errors.add(
436 f"Marker {self.marked_rst_config.start_sequence} is defined multiple times"
437 )
438 else:
439 markers.add(self.marked_rst_config.start_sequence)
440 if self.marked_rst_config.end_sequence in markers:
441 errors.add(
442 f"Marker {self.marked_rst_config.end_sequence} is defined multiple times"
443 )
444 else:
445 markers.add(self.marked_rst_config.end_sequence)
446
447 for marker in self.need_id_refs_config.markers:
448 if marker in markers:
449 errors.add(f"Marker {marker} is defined multiple times")
450 else:
451 markers.add(marker)
452 return list(errors)
453
454 def check_fields_configuration(self) -> list[str]:
455 errors: deque[str] = deque()
456 if self.get_need_id_refs:
457 need_id_refs_errors = self.need_id_refs_config.check_schema()
458 if need_id_refs_errors:
459 errors.appendleft("NeedIdRefs configuration errors:")
460 errors.extend(need_id_refs_errors)
461 if self.get_oneline_needs:
462 oneline_needs_errors = (
463 self.oneline_comment_style.check_fields_configuration()
464 )
465 if oneline_needs_errors:
466 errors.appendleft("OneLineCommentStyle configuration errors:")
467 errors.extend(oneline_needs_errors)
468 if self.get_rst:
469 marked_rst_errors = self.marked_rst_config.check_fields_configuration()
470 if marked_rst_errors:
471 errors.appendleft("MarkedRst configuration errors:")
472 errors.extend(self.marked_rst_config.check_fields_configuration())
473 analyse_errors = self.check_markers_mutually_exclusive() + self.check_schema()
474 if analyse_errors:
475 errors.appendleft("analyse configuration errors:")
476 errors.extend(analyse_errors)
477 return list(errors)
478
479
480SRC_TRACE_CACHE: str = "src_trace_cache"
481
482
483class SourceTracingLineHref:
484 """Global class for the mapping between source file line numbers and Sphinx documentation links."""
485
486 def __init__(self) -> None:
487 self.mappings: dict[str, dict[int, str]] = {}
488
489
490file_lineno_href = SourceTracingLineHref()
491
492
493class CodeLinksProjectConfigType(TypedDict, total=False):
494 """TypedDict defining the configuration structure for individual SrcTrace projects.
495
496 Contains both user-provided configuration:
497 - source_discover
498 - remote_url_pattern
499 - analyse
500 and runtime-generated configuration objects
501 - source_discover_config
502 - analyse_config
503 """
504
505 source_discover: SourceDiscoverSectionConfigType
506 remote_url_pattern: str
507 analyse: AnalyseSectionConfigType
508 source_discover_config: SourceDiscoverConfig
509 analyse_config: SourceAnalyseConfig
510
511
512class CodeLinksConfigType(TypedDict):
513 config_from_toml: str | None
514 set_local_url: bool
515 local_url_field: str
516 set_remote_url: bool
517 remote_url_field: str
518 outdir: Path
519 projects: dict[str, CodeLinksProjectConfigType]
520 debug_measurement: bool
521 debug_filters: bool
522
523
524@dataclass
525class CodeLinksConfig:
526 @classmethod
527 def from_sphinx(cls, sphinx_config: _SphinxConfig) -> "CodeLinksConfig":
528 obj = cls()
529 super().__setattr__(obj, "_sphinx_config", sphinx_config)
530 return obj
531
532 def __getattribute__(self, name: str) -> Any: # type: ignore[explicit-any]
533 if name.startswith("__") or name == "_sphinx_config":
534 return super().__getattribute__(name)
535 sphinx_config = (
536 object.__getattribute__(self, "_sphinx_config")
537 if "_sphinx_config" in self.__dict__
538 else None
539 )
540 if sphinx_config:
541 return getattr(
542 super().__getattribute__("_sphinx_config"), f"src_trace_{name}"
543 )
544
545 return object.__getattribute__(self, name)
546
547 def __setattr__(self, name: str, value: Any) -> None: # type: ignore[explicit-any]
548 if name == "_sphinx_config" and "src_trace_projects" in value:
549 src_trace_projects: dict[str, CodeLinksProjectConfigType] = value[
550 "src_trace_projects"
551 ]
552 generate_project_configs(src_trace_projects)
553
554 if name.startswith("__") or name == "_sphinx_config":
555 return super().__setattr__(name, value)
556
557 sphinx_config = (
558 object.__getattribute__(self, "_sphinx_config")
559 if "_sphinx_config" in self.__dict__
560 else None
561 )
562
563 if sphinx_config:
564 setattr(
565 super().__getattribute__("_sphinx_config"), f"src_trace_{name}", value
566 )
567
568 if name == "outdir" and isinstance(value, str):
569 # Ensure outdir is a Path object
570 value = Path(value)
571 return object.__setattr__(self, name, value)
572
573 @classmethod
574 def add_config_values(cls, app: Sphinx) -> None:
575 """Add all config values to Sphinx application"""
576 for item in fields(cls):
577 if item.default_factory is not MISSING:
578 default = item.default_factory()
579 elif item.default is not MISSING:
580 default = item.default
581 else:
582 raise Exception(f"Field {item.name} has no default value or factory")
583
584 name = item.name
585 app.add_config_value(
586 f"src_trace_{name}",
587 default,
588 item.metadata["rebuild"],
589 types=item.metadata["types"],
590 )
591
592 @classmethod
593 def field_names(cls) -> set[str]:
594 return {item.name for item in fields(cls)}
595
596 @classmethod
597 def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
598 """Get the schema for a config item."""
599 _field = next(field for field in fields(cls) if field.name is name)
600 if _field.metadata and "schema" in _field.metadata:
601 return _field.metadata["schema"] # type: ignore[no-any-return]
602 return None
603
604 config_from_toml: str | None = field(
605 default=None,
606 metadata={
607 "rebuild": "env",
608 "types": (str, type(None)),
609 "schema": {
610 "type": ["string", "null"],
611 "examples": ["config.toml", None],
612 },
613 },
614 )
615 """Path to a TOML file to load configuration from."""
616
617 set_local_url: bool = field(
618 default=False,
619 metadata={
620 "rebuild": "env",
621 "types": (bool,),
622 "schema": {
623 "type": "boolean",
624 },
625 },
626 )
627 """Set the file URL in the extracted need."""
628
629 local_url_field: str = field(
630 default="local-url",
631 metadata={
632 "rebuild": "env",
633 "types": (str,),
634 "schema": {
635 "type": "string",
636 },
637 },
638 )
639 """The field name for the file URL in the extracted need."""
640
641 set_remote_url: bool = field(
642 default=False,
643 metadata={
644 "rebuild": "env",
645 "types": (bool,),
646 "schema": {
647 "type": "boolean",
648 },
649 },
650 )
651 remote_url_field: str = field(
652 default="remote-url",
653 metadata={
654 "rebuild": "env",
655 "types": (str,),
656 "schema": {
657 "type": "string",
658 },
659 },
660 )
661 """The field name for the remote URL in the extracted need."""
662
663 outdir: Path = field(
664 default=Path("output"),
665 metadata={"rebuild": "env", "types": (str), "schema": {"type": "string"}},
666 )
667 """The directory where the generated artifacts and their caches will be stored."""
668
669 projects: dict[str, CodeLinksProjectConfigType] = field(
670 default_factory=dict,
671 metadata={
672 "rebuild": "env",
673 "types": (),
674 "schema": {
675 "type": "object",
676 "additionalProperties": {
677 "type": "object",
678 "properties": {
679 "source_discover": {},
680 "analyse": {},
681 "remote_url_pattern": {},
682 "source_discover_config": {},
683 "analyse_config": {},
684 },
685 "additionalProperties": False,
686 },
687 },
688 },
689 )
690 """The configuration for the source tracing projects."""
691
692 debug_measurement: bool = field(
693 default=False, metadata={"rebuild": "html", "types": (bool,)}
694 )
695 """If True, log runtime information for various functions."""
696 debug_filters: bool = field(
697 default=False, metadata={"rebuild": "html", "types": (bool,)}
698 )
699 """If True, log filter processing runtime information."""
700
701
702def check_schema(config: CodeLinksConfig) -> list[str]:
703 """Check only first layer's of schema, so that the nested dict is not validated here."""
704 errors = []
705 for _field_name in CodeLinksConfig.field_names():
706 schema = CodeLinksConfig.get_schema(_field_name)
707 if not schema:
708 continue
709 value = getattr(config, _field_name)
710 if isinstance(value, Path): # adapt to json schema restriction
711 value = str(value)
712 try:
713 validate(instance=value, schema=schema)
714 except ValidationError as e:
715 errors.append(
716 f"Schema validation error in filed '{_field_name}': {e.message}"
717 )
718 return errors
719
720
721def check_project_configuration(config: CodeLinksConfig) -> list[str]:
722 """Check nested project configurations"""
723 errors = []
724
725 for project_name, project_config in config.projects.items():
726 project_errors: list[str] = []
727
728 # validate source_discover config
729 src_discover_config: SourceDiscoverConfig | None = project_config.get(
730 "source_discover_config"
731 )
732 src_discover_errors = []
733 if src_discover_config:
734 src_discover_errors.extend(src_discover_config.check_schema())
735
736 # validate analyse config
737 analyse_config: SourceAnalyseConfig | None = project_config.get(
738 "analyse_config"
739 )
740 analyse_errors = []
741 if analyse_config:
742 analyse_errors = analyse_config.check_fields_configuration()
743
744 # validate src-trace config
745 if config.set_remote_url and "remote_url_pattern" not in project_config:
746 project_errors.append(
747 "remote_url_pattern must be given, as set_remote_url is enabled"
748 )
749
750 if "remote_url_pattern" in project_config and not isinstance(
751 project_config["remote_url_pattern"], str
752 ):
753 project_errors.append("remote_url_pattern must be a string")
754
755 if analyse_errors or src_discover_errors or project_errors:
756 errors.append(f"Project '{project_name}' has the following errors:")
757 errors.extend(analyse_errors)
758 errors.extend(src_discover_errors)
759 errors.extend(project_errors)
760
761 return errors
762
763
764def check_configuration(config: CodeLinksConfig) -> list[str]:
765 errors = []
766 errors.extend(check_schema(config))
767 errors.extend(check_project_configuration(config))
768 return errors
769
770
771def convert_src_discovery_config(
772 config_dict: SourceDiscoverSectionConfigType | None,
773) -> SourceDiscoverConfig:
774 if config_dict:
775 src_discover_dict = {
776 key: (Path(value) if key == "src_dir" and isinstance(value, str) else value)
777 for key, value in config_dict.items()
778 }
779 src_discover_config = SourceDiscoverConfig(**src_discover_dict) # type: ignore[arg-type] # mypy is confused by dynamic assignment
780 else:
781 src_discover_config = SourceDiscoverConfig()
782
783 return src_discover_config
784
785
786def convert_analyse_config(
787 config_dict: AnalyseSectionConfigType | None,
788 src_discover: SourceDiscover | None = None,
789) -> SourceAnalyseConfig:
790 analyse_config_dict: SourceAnalyseConfigType = {}
791 if config_dict:
792 for k, v in config_dict.items():
793 if k not in {"online_comment_style", "need_id_refs", "marked_rst"}:
794 # Convert string paths to Path objects
795 if k in {"src_dir", "git_root"} and isinstance(v, str):
796 analyse_config_dict[k] = Path(v) # type: ignore[literal-required]
797 else:
798 analyse_config_dict[k] = v # type: ignore[literal-required] # dynamical assignment
799
800 # Get oneline_comment_style configuration
801 oneline_comment_style_dict: OneLineCommentStyleType | None = config_dict.get(
802 "oneline_comment_style"
803 )
804 oneline_comment_style: OneLineCommentStyle = (
805 convert_oneline_comment_style_config(oneline_comment_style_dict)
806 )
807
808 # Get need_id_refs configuration
809 need_id_refs_config_dict: NeedIdRefsConfigType | None = config_dict.get(
810 "need_id_refs"
811 )
812 need_id_refs_config = convert_need_id_refs_config(need_id_refs_config_dict)
813
814 # Get marked_rst configuration
815 marked_rst_config_dict: MarkedRstConfigType | None = config_dict.get(
816 "marked_rst"
817 )
818 marked_rst_config = convert_marked_rst_config(marked_rst_config_dict)
819
820 analyse_config_dict["need_id_refs_config"] = need_id_refs_config
821 analyse_config_dict["marked_rst_config"] = marked_rst_config
822 analyse_config_dict["oneline_comment_style"] = oneline_comment_style
823
824 if src_discover:
825 analyse_config_dict["src_files"] = src_discover.source_paths
826 analyse_config_dict["src_dir"] = src_discover.src_discover_config.src_dir
827 try:
828 analyse_config_dict["comment_type"] = CommentType(
829 src_discover.src_discover_config.comment_type
830 )
831 except ValueError:
832 # If invalid comment_type, keep the string value
833 # Validation will catch this error later
834 comment_type_str: str = src_discover.src_discover_config.comment_type
835 analyse_config_dict["comment_type"] = comment_type_str # type: ignore[typeddict-item]
836
837 return SourceAnalyseConfig(**analyse_config_dict)
838
839
840def convert_oneline_comment_style_config(
841 config_dict: OneLineCommentStyleType | None,
842) -> OneLineCommentStyle:
843 if config_dict is None:
844 oneline_comment_style = OneLineCommentStyle()
845 else:
846 try:
847 oneline_comment_style = OneLineCommentStyle(**config_dict)
848 except TypeError as e:
849 raise TypeError(f"Invalid oneline comment style configuration: {e}") from e
850 return oneline_comment_style
851
852
853def convert_need_id_refs_config(
854 config_dict: NeedIdRefsConfigType | None,
855) -> NeedIdRefsConfig:
856 if not config_dict:
857 need_id_refs_config = NeedIdRefsConfig()
858 else:
859 try:
860 need_id_refs_config = NeedIdRefsConfig(**config_dict)
861 except TypeError as e:
862 raise TypeError(f"Invalid oneline comment style configuration: {e}") from e
863 return need_id_refs_config
864
865
866def convert_marked_rst_config(
867 config_dict: MarkedRstConfigType | None,
868) -> MarkedRstConfig:
869 if not config_dict:
870 marked_rst_config = MarkedRstConfig()
871 else:
872 try:
873 marked_rst_config = MarkedRstConfig(**config_dict)
874 except TypeError as e:
875 raise TypeError(f"Invalid oneline comment style configuration: {e}") from e
876 return marked_rst_config
877
878
879def generate_project_configs(
880 project_configs: dict[str, CodeLinksProjectConfigType],
881) -> None:
882 """Generate configs of source discover and analyse from their classes dynamically."""
883 for project_config in project_configs.values():
884 # overwrite the config into different types on purpose
885 # covert dicts to their own classes
886 src_discover_section: SourceDiscoverSectionConfigType | None = cast(
887 SourceDiscoverSectionConfigType,
888 project_config.get("source_discover"),
889 )
890 source_discover_config = convert_src_discovery_config(src_discover_section)
891 project_config["source_discover_config"] = source_discover_config
892
893 analyse_section_config: AnalyseSectionConfigType | None = cast(
894 AnalyseSectionConfigType, project_config.get("analyse")
895 )
896 analyse_config = convert_analyse_config(analyse_section_config)
897 analyse_config.get_oneline_needs = True # force to get oneline_need
898 # Copy comment_type from source_discover_config to analyse_config
899 try:
900 analyse_config.comment_type = CommentType(
901 source_discover_config.comment_type
902 )
903 except ValueError:
904 # If invalid comment_type, keep the string value
905 # Validation will catch this error later
906 analyse_config.comment_type = source_discover_config.comment_type # type: ignore[assignment]
907 project_config["analyse_config"] = analyse_config