Coverage for tests/test_project_scan.py: 100%

71 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-03 00:00 +0000

1import pathlib 

2import typing 

3 

4import pytest 

5 

6from archdocs.main import ArchitectureParserAndRenderer, SettingsForArchdocs 

7from tests.rendered_diagram import KUBERNETES_VARIANTS_ROOT, WITHOUT_MANIFESTS, render_diagram 

8 

9 

10_OWN_SOURCE: typing.Final = """import redis 

11 

12 

13cache_client = redis.Redis(host="localhost") 

14""" 

15_VENDORED_SOURCE: typing.Final = """import celery 

16import uvicorn 

17 

18 

19app = celery.Celery(__name__) 

20""" 

21_APPLICATION_SOURCE: typing.Final = "import fastapi\n\napp = fastapi.FastAPI()\n" 

22_CHART_RELATIVE_PATH: typing.Final = "deploy/mychart" 

23_SOURCES_RELATIVE_PATH: typing.Final = "src" 

24_NEIGHBOUR_CHART_VALUES: typing.Final = """replicaCount: 4 

25 

26ingress: 

27 enabled: true 

28 hosts: 

29 - host: neighbour.example.com 

30""" 

31_RAW_INGRESS_MANIFEST: typing.Final = """apiVersion: networking.k8s.io/v1 

32kind: Ingress 

33metadata: 

34 name: mychart 

35spec: 

36 rules: 

37 - host: from-templates.example.com 

38""" 

39_DECOY_CHART_VALUES: typing.Final = """replicaCount: 9 

40 

41ingress: 

42 enabled: true 

43 hosts: 

44 - host: decoy.example.com 

45""" 

46 

47 

48def _build_charted_project( 

49 project_path: pathlib.Path, 

50 /, 

51 *, 

52 chart_path: pathlib.Path | None = None, 

53 chart_values: str = _NEIGHBOUR_CHART_VALUES, 

54) -> pathlib.Path: 

55 source_dir: typing.Final = project_path / _SOURCES_RELATIVE_PATH 

56 source_dir.mkdir(parents=True) 

57 (source_dir / "main.py").write_text(_APPLICATION_SOURCE) 

58 chart_dir: typing.Final = project_path / _CHART_RELATIVE_PATH if chart_path is None else chart_path 

59 chart_dir.mkdir(parents=True) 

60 (chart_dir / "Chart.yaml").write_text("apiVersion: v2\nname: mychart\n") 

61 (chart_dir / "values.yaml").write_text(chart_values) 

62 return source_dir 

63 

64 

65# The last case is the mirror one: a service living in a `build` directory must not skip itself. 

66@pytest.mark.parametrize( 

67 ("project_subpath", "vendored_relative_path"), 

68 [ 

69 (".", ".venv/lib/python3.12/site-packages/celery"), 

70 (".", "venv/celery"), 

71 (".", "node_modules/celery"), 

72 (".", "build/lib/celery"), 

73 ("build/dist/myservice", ".venv/celery"), 

74 ], 

75) 

76def test_dependencies_stay_out_of_the_diagram( 

77 tmp_path: pathlib.Path, 

78 project_subpath: str, 

79 vendored_relative_path: str, 

80) -> None: 

81 project_path: typing.Final = tmp_path / project_subpath 

82 project_path.mkdir(parents=True, exist_ok=True) 

83 (project_path / "service.py").write_text(_OWN_SOURCE) 

84 vendored_dir: typing.Final = project_path / vendored_relative_path 

85 vendored_dir.mkdir(parents=True) 

86 (vendored_dir / "vendored.py").write_text(_VENDORED_SOURCE) 

87 

88 rendered_diagram: typing.Final = render_diagram( 

89 SettingsForArchdocs( 

90 root_dir=project_path, 

91 service_name="vendor-svc", 

92 kubernetes_dir=WITHOUT_MANIFESTS, 

93 ), 

94 ) 

95 

96 assert 'redisdb["redis"]' in rendered_diagram 

97 assert "celery" not in rendered_diagram 

98 assert "uvicorn" not in rendered_diagram 

99 

100 

101@pytest.mark.parametrize("sources_subpath", [".", "one"]) 

102def test_manifests_are_found_above_the_sources(tmp_path: pathlib.Path, sources_subpath: str) -> None: 

103 rendered_diagram: typing.Final = render_diagram( 

104 SettingsForArchdocs( 

105 root_dir=_build_charted_project(tmp_path / sources_subpath, chart_path=tmp_path / _CHART_RELATIVE_PATH), 

106 service_name="above-svc", 

107 ), 

108 ) 

109 

110 assert 'above_svc{"above-svc (replicas 4)"}' in rendered_diagram 

111 assert "HTTP neighbour.example.com" in rendered_diagram 

112 

113 

114def test_chart_is_found_by_its_templates(tmp_path: pathlib.Path) -> None: 

115 templates_dir: typing.Final = tmp_path / _CHART_RELATIVE_PATH / "templates" 

116 templates_dir.mkdir(parents=True) 

117 (templates_dir / "ingress.yaml").write_text(_RAW_INGRESS_MANIFEST) 

118 

119 rendered_diagram: typing.Final = render_diagram( 

120 SettingsForArchdocs(root_dir=tmp_path, service_name="templates-svc"), 

121 ) 

122 

123 assert "HTTP from-templates.example.com" in rendered_diagram 

124 

125 

126# The decoy chart is what the working directory would offer a relative path. 

127@pytest.mark.parametrize( 

128 ("kubernetes_dir", "expected_part", "forbidden_part"), 

129 [ 

130 (KUBERNETES_VARIANTS_ROOT / "loadbalancer", "LoadBalancer", "neighbour.example.com"), 

131 ("there-is-no-such-chart", 'config_svc{"config-svc"}', "neighbour.example.com"), 

132 (_CHART_RELATIVE_PATH, "HTTP neighbour.example.com", "decoy.example.com"), 

133 ], 

134) 

135def test_configured_dir_wins_over_the_search( 

136 tmp_path: pathlib.Path, 

137 monkeypatch: pytest.MonkeyPatch, 

138 kubernetes_dir: str | pathlib.Path, 

139 expected_part: str, 

140 forbidden_part: str, 

141) -> None: 

142 decoy_project_path: typing.Final = tmp_path / "elsewhere" 

143 _build_charted_project(decoy_project_path, chart_values=_DECOY_CHART_VALUES) 

144 monkeypatch.chdir(decoy_project_path) 

145 

146 rendered_diagram: typing.Final = render_diagram( 

147 SettingsForArchdocs( 

148 root_dir=_build_charted_project(tmp_path / "project"), 

149 service_name="config-svc", 

150 kubernetes_dir=kubernetes_dir, 

151 ), 

152 ) 

153 

154 assert expected_part in rendered_diagram 

155 assert forbidden_part not in rendered_diagram 

156 

157 

158# A mounted route keeps one engine alive, and a rescan on every request would walk the whole 

159# tree again: sources edited under a running process wait for a restart, see the playground. 

160def test_sources_are_scanned_once_per_engine(tmp_path: pathlib.Path) -> None: 

161 (tmp_path / "service.py").write_text(_OWN_SOURCE) 

162 architecture_engine: typing.Final = ArchitectureParserAndRenderer( 

163 local_settings=SettingsForArchdocs( 

164 root_dir=tmp_path, 

165 service_name="cached-svc", 

166 kubernetes_dir=WITHOUT_MANIFESTS, 

167 ), 

168 ) 

169 first_diagram: typing.Final = architecture_engine.render_architecture_diagram() 

170 

171 (tmp_path / "service.py").write_text(_APPLICATION_SOURCE) 

172 second_diagram: typing.Final = architecture_engine.render_architecture_diagram() 

173 

174 assert second_diagram == first_diagram 

175 assert 'redisdb["redis"]' in second_diagram 

176 assert "REST" not in second_diagram 

177 

178 

179@pytest.mark.parametrize(("project_subpath", "repository_marker"), [("project", ".git"), ("one/two/three", "")]) 

180def test_far_away_manifests_are_ignored( 

181 tmp_path: pathlib.Path, 

182 project_subpath: str, 

183 repository_marker: str, 

184) -> None: 

185 project_path: typing.Final = tmp_path / project_subpath 

186 source_dir: typing.Final = _build_charted_project( 

187 project_path, 

188 chart_path=tmp_path / _CHART_RELATIVE_PATH, 

189 chart_values=_DECOY_CHART_VALUES, 

190 ) 

191 if repository_marker: 

192 (project_path / repository_marker).mkdir() 

193 

194 rendered_diagram: typing.Final = render_diagram( 

195 SettingsForArchdocs(root_dir=source_dir, service_name="far-svc"), 

196 ) 

197 

198 assert 'far_svc{"far-svc"}' in rendered_diagram 

199 assert "decoy.example.com" not in rendered_diagram