Coverage for archdocs/main.py: 100%
48 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-09 14:41 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-09 14:41 +0000
1import dataclasses
2import functools
3import pathlib
4import typing
5from concurrent import futures
7from archdocs import diagram_model, mapping, mermaid_syntax, settings
10@typing.final
11@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
12class SettingsForArchdocs:
13 root_dir: str | pathlib.Path
14 service_name: str
15 kubernetes_dir: str | pathlib.Path | None = None
16 # Both assets come from public CDNs by default; a network that cannot reach them serves its
17 # own copy of mermaid and drops the font, which falls back to the system sans-serif.
18 mermaid_script_url: str = settings.DEFAULT_MERMAID_SCRIPT_URL
19 font_stylesheet_url: str | None = settings.DEFAULT_FONT_STYLESHEET_URL
22type _ParsedManifests = tuple[tuple[mapping.ManifestFeatureFunctions[typing.Any], typing.Any], ...]
25def _read_source_text(one_source_file: pathlib.Path, /) -> str:
26 # The scanned tree is somebody's whole project, not a curated corpus: a source in a legacy
27 # encoding, a symlink into nowhere or a file the process may not open costs that one file and
28 # nothing else. Without this the page answers 500 instead of drawing the rest of the service.
29 try:
30 return one_source_file.read_text(errors="ignore")
31 except OSError:
32 return ""
35def _parse_every_manifest(
36 root_path: pathlib.Path,
37 configured_manifest_dir: str | pathlib.Path | None,
38 /,
39) -> _ParsedManifests:
40 return tuple(
41 (
42 one_manifest_functions,
43 one_manifest_functions.parse_manifests(
44 one_manifest_functions.read_source(root_path, configured_manifest_dir),
45 ),
46 )
47 for one_manifest_functions in mapping.MAPPING_OF_MANIFEST_PARSERS_AND_RENDERERS.values()
48 )
51def _render_manifest_annotations(all_parsed_manifests: _ParsedManifests, /) -> tuple[str, ...]:
52 return tuple(
53 one_annotation
54 for one_manifest_functions, one_parsed_manifest in all_parsed_manifests
55 for one_annotation in one_manifest_functions.render_node_annotations(one_parsed_manifest)
56 )
59def _render_manifest_edges(
60 service_node: diagram_model.DiagramNode,
61 all_parsed_manifests: _ParsedManifests,
62 /,
63) -> tuple[diagram_model.DiagramEdge, ...]:
64 return tuple(
65 one_edge
66 for one_manifest_functions, one_parsed_manifest in all_parsed_manifests
67 for one_edge in one_manifest_functions.render_edges(service_node, one_parsed_manifest)
68 )
71@typing.final
72@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
73class ArchitectureParserAndRenderer:
74 local_settings: SettingsForArchdocs
75 # The cache is state, not identity: leaving it out of `__eq__` keeps the frozen dataclass
76 # hashable, which a list field would otherwise take away.
77 _rendered_diagram_cache: list[str] = dataclasses.field(default_factory=list, compare=False)
79 def render_architecture_diagram(self) -> str:
80 # Not `functools.cache`: on a method it keeps the instance alive for the process
81 # lifetime, which is exactly what ruff B019 forbids.
82 if self._rendered_diagram_cache:
83 return self._rendered_diagram_cache[0]
84 rendered_diagram: typing.Final = self._render_every_diagram_line()
85 self._rendered_diagram_cache.append(rendered_diagram)
86 return rendered_diagram
88 def _render_every_diagram_line(self) -> str:
89 root_path: typing.Final = pathlib.Path(self.local_settings.root_dir).resolve()
90 all_parsed_manifests: typing.Final = _parse_every_manifest(root_path, self.local_settings.kubernetes_dir)
91 service_node: typing.Final = diagram_model.build_service_node(
92 self.local_settings.service_name,
93 _render_manifest_annotations(all_parsed_manifests),
94 )
95 all_edges: typing.Final = (
96 *_render_manifest_edges(service_node, all_parsed_manifests),
97 *self._render_source_edges(service_node, root_path),
98 )
99 return mermaid_syntax.MermaidDiagram(service_node=service_node, all_edges=all_edges).render_every_line()
101 def _render_source_edges(
102 self,
103 service_node: diagram_model.DiagramNode,
104 root_path: pathlib.Path,
105 /,
106 ) -> tuple[diagram_model.DiagramEdge, ...]:
107 py_files: typing.Final = sorted(
108 one_source_file
109 for one_source_file in root_path.rglob(settings.FILES_SEARCH_PATTERN)
110 if settings.SKIPPED_DIR_NAMES.isdisjoint(one_source_file.relative_to(root_path).parts)
111 )
112 with futures.ThreadPoolExecutor(max_workers=settings.MAX_WORKERS) as executor:
113 all_file_edges: typing.Final = executor.map(
114 functools.partial(self._render_one_file_edges, service_node),
115 py_files,
116 )
117 return tuple(one_edge for one_file_edges in all_file_edges for one_edge in one_file_edges)
119 def _render_one_file_edges(
120 self,
121 service_node: diagram_model.DiagramNode,
122 one_source_file: pathlib.Path,
123 /,
124 ) -> tuple[diagram_model.DiagramEdge, ...]:
125 raw_file_source: typing.Final = _read_source_text(one_source_file)
126 return tuple(
127 one_edge
128 for one_feature_functions in mapping.MAPPING_OF_PARSERS_AND_RENDERERS.values()
129 for one_edge in one_feature_functions.render_edges(
130 service_node,
131 one_feature_functions.parse_source(raw_file_source),
132 )
133 )