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