Coverage for tests/test_project_scan.py: 100%
111 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 22:03 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 22:03 +0000
1import pathlib
2import types
3import typing
5import pytest
7from archdocs.main import ArchitectureParserAndRenderer, SettingsForArchdocs
8from tests import diagram_rendering
11_OWN_SOURCE: typing.Final = """import redis
14cache_client = redis.Redis(host="localhost")
15"""
16_VENDORED_SOURCE: typing.Final = """import celery
17import uvicorn
20app = celery.Celery(__name__)
21"""
22_APPLICATION_SOURCE: typing.Final = "import fastapi\n\napp = fastapi.FastAPI()\n"
23_CHART_RELATIVE_PATH: typing.Final = "deploy/mychart"
24_SOURCES_RELATIVE_PATH: typing.Final = "src"
25_NEIGHBOUR_CHART_VALUES: typing.Final = """replicaCount: 4
27ingress:
28 enabled: true
29 hosts:
30 - host: neighbour.example.com
31"""
32_RAW_INGRESS_MANIFEST: typing.Final = """apiVersion: networking.k8s.io/v1
33kind: Ingress
34metadata:
35 name: mychart
36spec:
37 tls:
38 - secretName: from-templates-tls
39 rules:
40 - host: from-templates.example.com
41"""
42_DECOY_CHART_VALUES: typing.Final = """replicaCount: 9
44ingress:
45 enabled: true
46 hosts:
47 - host: decoy.example.com
48"""
49_SUBCHART_VALUES: typing.Final = "replicaCount: 9\n"
50_NEIGHBOUR_HOST_EDGE: typing.Final = "HTTP neighbour.example.com"
53def _write_legacy_encoded_source(project_path: pathlib.Path, /) -> None:
54 (project_path / "legacy.py").write_bytes("HEADING = 'café'\n".encode("latin-1"))
57def _write_dangling_symlink(project_path: pathlib.Path, /) -> None:
58 (project_path / "removed.py").symlink_to(project_path / "never-existed.py")
61def _write_legacy_encoded_manifest(chart_path: pathlib.Path, /) -> None:
62 (chart_path / "legacy.yaml").write_bytes(
63 "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: café\n".encode("latin-1"),
64 )
67def _write_dangling_manifest_symlinks(chart_path: pathlib.Path, /) -> None:
68 (chart_path / "broken.yaml").symlink_to(chart_path / "never-existed.yaml")
69 (chart_path.parent / "values.yaml").symlink_to(chart_path.parent / "never-existed-values.yaml")
72_ALL_UNREADABLE_SOURCES: typing.Final = types.MappingProxyType(
73 {
74 "legacy encoding": _write_legacy_encoded_source,
75 "dangling symlink": _write_dangling_symlink,
76 },
77)
78_ALL_UNREADABLE_MANIFESTS: typing.Final = types.MappingProxyType(
79 {
80 "legacy encoding": _write_legacy_encoded_manifest,
81 "dangling symlink": _write_dangling_manifest_symlinks,
82 },
83)
86def _build_charted_project(
87 project_path: pathlib.Path,
88 /,
89 *,
90 chart_path: pathlib.Path | None = None,
91 chart_values: str = _NEIGHBOUR_CHART_VALUES,
92) -> pathlib.Path:
93 source_dir: typing.Final = project_path / _SOURCES_RELATIVE_PATH
94 source_dir.mkdir(parents=True)
95 (source_dir / "main.py").write_text(_APPLICATION_SOURCE)
96 chart_dir: typing.Final = project_path / _CHART_RELATIVE_PATH if chart_path is None else chart_path
97 chart_dir.mkdir(parents=True)
98 (chart_dir / "Chart.yaml").write_text("apiVersion: v2\nname: mychart\n")
99 (chart_dir / "values.yaml").write_text(chart_values)
100 return source_dir
103# The last case is the mirror one: a service living in a `build` directory must not skip itself.
104@pytest.mark.parametrize(
105 ("project_subpath", "vendored_relative_path"),
106 [
107 (".", ".venv/lib/python3.12/site-packages/celery"),
108 (".", "venv/celery"),
109 (".", "node_modules/celery"),
110 (".", "build/lib/celery"),
111 ("build/dist/myservice", ".venv/celery"),
112 ],
113)
114def test_dependencies_stay_out_of_the_diagram(
115 tmp_path: pathlib.Path,
116 project_subpath: str,
117 vendored_relative_path: str,
118) -> None:
119 project_path: typing.Final = tmp_path / project_subpath
120 project_path.mkdir(parents=True, exist_ok=True)
121 (project_path / "service.py").write_text(_OWN_SOURCE)
122 vendored_dir: typing.Final = project_path / vendored_relative_path
123 vendored_dir.mkdir(parents=True)
124 (vendored_dir / "vendored.py").write_text(_VENDORED_SOURCE)
126 rendered_diagram: typing.Final = diagram_rendering.render_diagram(
127 SettingsForArchdocs(
128 root_dir=project_path,
129 service_name="vendor-svc",
130 kubernetes_dir=diagram_rendering.WITHOUT_MANIFESTS,
131 ),
132 )
134 assert 'redisdb["redis"]' in rendered_diagram
135 assert "celery" not in rendered_diagram
136 assert "uvicorn" not in rendered_diagram
139# The scanned tree is the user's whole project: one file the process cannot decode or open used
140# to raise out of the thread pool and answer the route with 500 instead of the rest of the service.
141@pytest.mark.parametrize("break_one_source", _ALL_UNREADABLE_SOURCES.values(), ids=_ALL_UNREADABLE_SOURCES)
142def test_unreadable_source_costs_only_itself(
143 tmp_path: pathlib.Path,
144 break_one_source: typing.Callable[[pathlib.Path], None],
145) -> None:
146 (tmp_path / "cache.py").write_text(_OWN_SOURCE)
147 break_one_source(tmp_path)
149 rendered_diagram: typing.Final = diagram_rendering.render_diagram(
150 SettingsForArchdocs(
151 root_dir=tmp_path,
152 service_name="unreadable-svc",
153 kubernetes_dir=diagram_rendering.WITHOUT_MANIFESTS,
154 ),
155 )
157 assert 'redisdb["redis"]' in rendered_diagram
160@pytest.mark.parametrize("sources_subpath", [".", "one"])
161def test_manifests_are_found_above_the_sources(tmp_path: pathlib.Path, sources_subpath: str) -> None:
162 rendered_diagram: typing.Final = diagram_rendering.render_diagram(
163 SettingsForArchdocs(
164 root_dir=_build_charted_project(tmp_path / sources_subpath, chart_path=tmp_path / _CHART_RELATIVE_PATH),
165 service_name="above-svc",
166 ),
167 )
169 assert 'above_svc{"above-svc (replicas 4)"}' in rendered_diagram
170 assert _NEIGHBOUR_HOST_EDGE in rendered_diagram
173def test_chart_is_found_by_its_templates(tmp_path: pathlib.Path) -> None:
174 templates_dir: typing.Final = tmp_path / _CHART_RELATIVE_PATH / "templates"
175 templates_dir.mkdir(parents=True)
176 (templates_dir / "ingress.yaml").write_text(_RAW_INGRESS_MANIFEST)
178 rendered_diagram: typing.Final = diagram_rendering.render_diagram(
179 SettingsForArchdocs(root_dir=tmp_path, service_name="templates-svc"),
180 )
182 assert "HTTPS from-templates.example.com" in rendered_diagram
185# The decoy chart is what the working directory would offer a relative path.
186@pytest.mark.parametrize(
187 ("kubernetes_dir", "expected_part", "forbidden_part"),
188 [
189 (diagram_rendering.KUBERNETES_VARIANTS_ROOT / "loadbalancer", "LoadBalancer", "neighbour.example.com"),
190 ("there-is-no-such-chart", 'config_svc{"config-svc"}', "neighbour.example.com"),
191 (_CHART_RELATIVE_PATH, _NEIGHBOUR_HOST_EDGE, "decoy.example.com"),
192 ],
193)
194def test_configured_dir_wins_over_the_search(
195 tmp_path: pathlib.Path,
196 monkeypatch: pytest.MonkeyPatch,
197 kubernetes_dir: str | pathlib.Path,
198 expected_part: str,
199 forbidden_part: str,
200) -> None:
201 decoy_project_path: typing.Final = tmp_path / "elsewhere"
202 _build_charted_project(decoy_project_path, chart_values=_DECOY_CHART_VALUES)
203 monkeypatch.chdir(decoy_project_path)
205 rendered_diagram: typing.Final = diagram_rendering.render_diagram(
206 SettingsForArchdocs(
207 root_dir=_build_charted_project(tmp_path / "project"),
208 service_name="config-svc",
209 kubernetes_dir=kubernetes_dir,
210 ),
211 )
213 assert expected_part in rendered_diagram
214 assert forbidden_part not in rendered_diagram
217# A typo in root_dir is the emptiest possible project, not an error page: the service node
218# still has to appear, alone.
219def test_missing_root_dir_draws_the_service_alone(tmp_path: pathlib.Path) -> None:
220 rendered_diagram: typing.Final = diagram_rendering.render_diagram(
221 SettingsForArchdocs(
222 root_dir=tmp_path / "never-created",
223 service_name="missing-svc",
224 kubernetes_dir=diagram_rendering.WITHOUT_MANIFESTS,
225 ),
226 )
228 assert 'missing_svc{"missing-svc"}' in rendered_diagram
229 assert " --> " not in rendered_diagram
232# Manifests are hunted through the same foreign tree as the sources: a dangling symlink or a
233# manifest in a legacy encoding next to the chart used to raise out of the walk and answer the
234# route with 500 instead of the chart.
235@pytest.mark.parametrize("break_one_manifest", _ALL_UNREADABLE_MANIFESTS.values(), ids=_ALL_UNREADABLE_MANIFESTS)
236def test_unreadable_manifest_costs_only_itself(
237 tmp_path: pathlib.Path,
238 break_one_manifest: typing.Callable[[pathlib.Path], None],
239) -> None:
240 source_dir: typing.Final = _build_charted_project(tmp_path)
241 break_one_manifest(tmp_path / _CHART_RELATIVE_PATH)
243 rendered_diagram: typing.Final = diagram_rendering.render_diagram(
244 SettingsForArchdocs(root_dir=source_dir, service_name="broken-chart-svc"),
245 )
247 assert 'broken_chart_svc{"broken-chart-svc (replicas 4)"}' in rendered_diagram
248 assert _NEIGHBOUR_HOST_EDGE in rendered_diagram
251# Helm reads a subchart's values under the ones of the chart that pulls it in, so the chart the
252# diagram is drawn from is the outer one — even though `charts/` sorts before `values.yaml`.
253def test_subchart_values_lose_to_the_chart(tmp_path: pathlib.Path) -> None:
254 source_dir: typing.Final = _build_charted_project(tmp_path)
255 subchart_dir: typing.Final = tmp_path / _CHART_RELATIVE_PATH / "charts" / "redis"
256 subchart_dir.mkdir(parents=True)
257 (subchart_dir / "Chart.yaml").write_text("apiVersion: v2\nname: redis\n")
258 (subchart_dir / "values.yaml").write_text(_SUBCHART_VALUES)
260 rendered_diagram: typing.Final = diagram_rendering.render_diagram(
261 SettingsForArchdocs(root_dir=source_dir, service_name="subchart-svc"),
262 )
264 assert 'subchart_svc{"subchart-svc (replicas 4)"}' in rendered_diagram
265 assert _NEIGHBOUR_HOST_EDGE in rendered_diagram
268# A mounted route keeps one engine alive, and a rescan on every request would walk the whole
269# tree again: sources edited under a running process wait for a restart, see the playground.
270def test_sources_are_scanned_once_per_engine(tmp_path: pathlib.Path) -> None:
271 (tmp_path / "service.py").write_text(_OWN_SOURCE)
272 architecture_engine: typing.Final = ArchitectureParserAndRenderer(
273 local_settings=SettingsForArchdocs(
274 root_dir=tmp_path,
275 service_name="cached-svc",
276 kubernetes_dir=diagram_rendering.WITHOUT_MANIFESTS,
277 ),
278 )
279 first_diagram: typing.Final = architecture_engine.render_architecture_diagram()
281 (tmp_path / "service.py").write_text(_APPLICATION_SOURCE)
282 second_diagram: typing.Final = architecture_engine.render_architecture_diagram()
284 assert second_diagram == first_diagram
285 assert 'redisdb["redis"]' in second_diagram
286 assert "REST" not in second_diagram
289@pytest.mark.parametrize(("project_subpath", "repository_marker"), [("project", ".git"), ("one/two/three", "")])
290def test_far_away_manifests_are_ignored(
291 tmp_path: pathlib.Path,
292 project_subpath: str,
293 repository_marker: str,
294) -> None:
295 project_path: typing.Final = tmp_path / project_subpath
296 source_dir: typing.Final = _build_charted_project(
297 project_path,
298 chart_path=tmp_path / _CHART_RELATIVE_PATH,
299 chart_values=_DECOY_CHART_VALUES,
300 )
301 if repository_marker:
302 (project_path / repository_marker).mkdir()
304 rendered_diagram: typing.Final = diagram_rendering.render_diagram(
305 SettingsForArchdocs(root_dir=source_dir, service_name="far-svc"),
306 )
308 assert 'far_svc{"far-svc"}' in rendered_diagram
309 assert "decoy.example.com" not in rendered_diagram