Coverage for tests / unit / tools / core / test_tool_registry.py: 100%

128 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Unit tests for ToolRegistry manifest loading and query methods.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import shutil 

7from pathlib import Path 

8 

9import pytest 

10from assertpy import assert_that 

11 

12from lintro.tools.core.tool_registry import ( 

13 ManifestTool, 

14 ToolRegistry, 

15) 

16 

17# --------------------------------------------------------------------------- 

18# Fixtures 

19# --------------------------------------------------------------------------- 

20 

21_FIXTURE_DIR = Path(__file__).parent / "fixtures" 

22 

23 

24@pytest.fixture() 

25def v2_manifest_path(tmp_path: Path) -> Path: 

26 """Copy the v2 manifest fixture to a temp directory and return its path. 

27 

28 Args: 

29 tmp_path: Pytest built-in temporary directory. 

30 

31 Returns: 

32 Path to the copied manifest.json. 

33 """ 

34 src = _FIXTURE_DIR / "test_manifest.json" 

35 dest = tmp_path / "manifest.json" 

36 shutil.copy2(src, dest) 

37 return dest 

38 

39 

40@pytest.fixture() 

41def registry(v2_manifest_path: Path) -> ToolRegistry: 

42 """Load a ToolRegistry from the v2 test manifest. 

43 

44 Args: 

45 v2_manifest_path: Path to the test manifest. 

46 

47 Returns: 

48 ToolRegistry loaded from the test manifest. 

49 """ 

50 return ToolRegistry._load_from_path(v2_manifest_path) 

51 

52 

53# =========================================================================== 

54# Manifest loading 

55# =========================================================================== 

56 

57 

58def test_load_v2_manifest(registry: ToolRegistry) -> None: 

59 """Loading a v2 manifest populates tools, profiles, and language_map.""" 

60 assert_that(len(registry)).is_equal_to(6) 

61 assert_that(registry.profile_names).is_length(4) 

62 assert_that(registry.language_map).contains_key("python", "docker", "security") 

63 

64 

65def test_load_v1_compat(tmp_path: Path) -> None: 

66 """A v1 manifest without top-level version_command falls back to install block.""" 

67 manifest = { 

68 "version": 1, 

69 "tools": [ 

70 { 

71 "name": "old-tool", 

72 "version": "1.0.0", 

73 "install": { 

74 "type": "pip", 

75 "version_command": ["old-tool", "-V"], 

76 }, 

77 }, 

78 ], 

79 } 

80 path = tmp_path / "manifest.json" 

81 path.write_text(json.dumps(manifest), encoding="utf-8") 

82 

83 reg = ToolRegistry._load_from_path(path) 

84 tool = reg.get("old-tool") 

85 assert_that(tool.version_command).is_equal_to(("old-tool", "-V")) 

86 assert_that(tool.category).is_equal_to("bundled") 

87 

88 

89def test_load_missing_name_skips_tool(tmp_path: Path) -> None: 

90 """A tool entry without a 'name' field is silently skipped.""" 

91 manifest = { 

92 "version": 2, 

93 "tools": [ 

94 {"version": "1.0.0", "install": {"type": "pip"}}, 

95 { 

96 "name": "good", 

97 "version": "1.0.0", 

98 "install": {"type": "pip"}, 

99 }, 

100 ], 

101 } 

102 path = tmp_path / "manifest.json" 

103 path.write_text(json.dumps(manifest), encoding="utf-8") 

104 

105 reg = ToolRegistry._load_from_path(path) 

106 assert_that(len(reg)).is_equal_to(1) 

107 assert_that("good" in reg).is_true() 

108 

109 

110def test_load_missing_version_skips_tool(tmp_path: Path) -> None: 

111 """A tool entry without a 'version' field is silently skipped.""" 

112 manifest = { 

113 "version": 2, 

114 "tools": [ 

115 {"name": "no-ver", "install": {"type": "pip"}}, 

116 { 

117 "name": "good", 

118 "version": "2.0.0", 

119 "install": {"type": "pip"}, 

120 }, 

121 ], 

122 } 

123 path = tmp_path / "manifest.json" 

124 path.write_text(json.dumps(manifest), encoding="utf-8") 

125 

126 reg = ToolRegistry._load_from_path(path) 

127 assert_that(len(reg)).is_equal_to(1) 

128 assert_that("no-ver" in reg).is_false() 

129 

130 

131def test_load_invalid_manifest_version(tmp_path: Path) -> None: 

132 """A non-integer manifest version raises ValueError.""" 

133 manifest = {"version": "not_int", "tools": []} 

134 path = tmp_path / "manifest.json" 

135 path.write_text(json.dumps(manifest), encoding="utf-8") 

136 

137 assert_that(ToolRegistry._load_from_path).raises(ValueError).when_called_with( 

138 path, 

139 ).is_equal_to("manifest 'version' must be an integer, got 'not_int'") 

140 

141 

142def test_parse_tool_entry_invalid_version_command(tmp_path: Path) -> None: 

143 """A non-list version_command is treated as an empty list.""" 

144 manifest = { 

145 "version": 2, 

146 "tools": [ 

147 { 

148 "name": "bad-cmd", 

149 "version": "1.0.0", 

150 "install": {"type": "pip"}, 

151 "version_command": "not-a-list", 

152 }, 

153 ], 

154 } 

155 path = tmp_path / "manifest.json" 

156 path.write_text(json.dumps(manifest), encoding="utf-8") 

157 

158 reg = ToolRegistry._load_from_path(path) 

159 tool = reg.get("bad-cmd") 

160 assert_that(tool.version_command).is_equal_to(()) 

161 

162 

163# =========================================================================== 

164# all_tools 

165# =========================================================================== 

166 

167 

168def test_all_tools_excludes_dev_by_default(registry: ToolRegistry) -> None: 

169 """Dev-tier tools are excluded when include_dev is False (default).""" 

170 tools = registry.all_tools() 

171 names = [t.name for t in tools] 

172 assert_that(names).does_not_contain("dev-tool") 

173 

174 

175def test_all_tools_includes_dev(registry: ToolRegistry) -> None: 

176 """Dev-tier tools are included when include_dev is True.""" 

177 tools = registry.all_tools(include_dev=True) 

178 names = [t.name for t in tools] 

179 assert_that(names).contains("dev-tool") 

180 

181 

182def test_all_tools_sorted_by_name(registry: ToolRegistry) -> None: 

183 """Returned tools are sorted alphabetically by name.""" 

184 tools = registry.all_tools(include_dev=True) 

185 names = [t.name for t in tools] 

186 assert_that(names).is_equal_to(sorted(names)) 

187 

188 

189# =========================================================================== 

190# tools_for_languages 

191# =========================================================================== 

192 

193 

194def test_tools_for_languages_single(registry: ToolRegistry) -> None: 

195 """Passing ['python'] returns python-mapped tools plus security.""" 

196 tools = registry.tools_for_languages(["python"]) 

197 names = [t.name for t in tools] 

198 assert_that(names).contains("ruff", "mypy", "gitleaks") 

199 

200 

201def test_tools_for_languages_multiple(registry: ToolRegistry) -> None: 

202 """Passing multiple languages returns the union of their tool sets.""" 

203 tools = registry.tools_for_languages(["python", "docker"]) 

204 names = [t.name for t in tools] 

205 assert_that(names).contains("ruff", "mypy", "hadolint", "gitleaks") 

206 

207 

208def test_tools_for_languages_always_includes_security( 

209 registry: ToolRegistry, 

210) -> None: 

211 """Security tools are always included regardless of language list.""" 

212 tools = registry.tools_for_languages(["docker"]) 

213 names = [t.name for t in tools] 

214 assert_that(names).contains("gitleaks") 

215 

216 

217def test_tools_for_languages_unknown_lang(registry: ToolRegistry) -> None: 

218 """An unknown language returns only security tools.""" 

219 tools = registry.tools_for_languages(["cobol"]) 

220 names = [t.name for t in tools] 

221 assert_that(names).is_equal_to(["gitleaks"]) 

222 

223 

224# =========================================================================== 

225# tools_for_profile 

226# =========================================================================== 

227 

228 

229def test_tools_for_profile_explicit(registry: ToolRegistry) -> None: 

230 """The 'minimal' explicit profile returns exactly the listed tools.""" 

231 tools = registry.tools_for_profile("minimal") 

232 names = [t.name for t in tools] 

233 assert_that(names).is_equal_to(["mypy", "ruff"]) 

234 

235 

236def test_tools_for_profile_auto_detect_with_langs( 

237 registry: ToolRegistry, 

238) -> None: 

239 """The 'recommended' profile with detected_langs delegates to tools_for_languages.""" 

240 tools = registry.tools_for_profile("recommended", detected_langs=["python"]) 

241 names = [t.name for t in tools] 

242 assert_that(names).contains("ruff", "mypy", "gitleaks") 

243 

244 

245def test_tools_for_profile_auto_detect_no_langs_falls_back_to_minimal( 

246 registry: ToolRegistry, 

247) -> None: 

248 """The 'recommended' profile without detected languages falls back to 'minimal'.""" 

249 tools = registry.tools_for_profile("recommended") 

250 names = [t.name for t in tools] 

251 assert_that(names).is_equal_to(["mypy", "ruff"]) 

252 

253 

254def test_tools_for_profile_all(registry: ToolRegistry) -> None: 

255 """The 'complete' profile returns every tool, including dev.""" 

256 tools = registry.tools_for_profile("complete") 

257 names = [t.name for t in tools] 

258 assert_that(names).contains("dev-tool") 

259 assert_that(len(names)).is_equal_to(6) 

260 

261 

262def test_tools_for_profile_filter_excludes_formatters( 

263 registry: ToolRegistry, 

264) -> None: 

265 """The 'ci' profile excludes tools whose tags are a subset of ['formatter']. 

266 

267 'ruff' has tags ['linter', 'formatter'] so it is kept. 

268 'black' has tags ['formatter'] which is a subset — excluded. 

269 """ 

270 tools = registry.tools_for_profile( 

271 "ci", 

272 detected_langs=["python"], 

273 ) 

274 names = [t.name for t in tools] 

275 assert_that(names).contains("ruff") 

276 assert_that(names).does_not_contain("black") 

277 

278 

279def test_tools_for_profile_unknown_raises(registry: ToolRegistry) -> None: 

280 """An unknown profile name raises KeyError.""" 

281 assert_that(registry.tools_for_profile).raises(KeyError).when_called_with( 

282 "nonexistent", 

283 ) 

284 

285 

286# =========================================================================== 

287# Query methods 

288# =========================================================================== 

289 

290 

291def test_get_existing_tool(registry: ToolRegistry) -> None: 

292 """get() returns the ManifestTool for a known tool name.""" 

293 tool = registry.get("ruff") 

294 assert_that(tool).is_instance_of(ManifestTool) 

295 assert_that(tool.name).is_equal_to("ruff") 

296 assert_that(tool.version).is_equal_to("0.14.0") 

297 

298 

299def test_get_missing_tool_raises_key_error(registry: ToolRegistry) -> None: 

300 """get() raises KeyError for an unknown tool name.""" 

301 assert_that(registry.get).raises(KeyError).when_called_with("nope") 

302 

303 

304def test_get_or_none_existing(registry: ToolRegistry) -> None: 

305 """get_or_none() returns the ManifestTool when the tool exists.""" 

306 tool = registry.get_or_none("mypy") 

307 assert_that(tool).is_not_none() 

308 # narrow Optional for mypy — assertpy does not perform type narrowing 

309 assert tool is not None # noqa: S101 

310 assert_that(tool.name).is_equal_to("mypy") 

311 

312 

313def test_get_or_none_missing(registry: ToolRegistry) -> None: 

314 """get_or_none() returns None when the tool does not exist.""" 

315 tool = registry.get_or_none("nope") 

316 assert_that(tool).is_none() 

317 

318 

319def test_contains_true(registry: ToolRegistry) -> None: 

320 """__contains__ returns True for a registered tool.""" 

321 assert_that("ruff" in registry).is_true() 

322 

323 

324def test_contains_false(registry: ToolRegistry) -> None: 

325 """__contains__ returns False for an unregistered tool.""" 

326 assert_that("nope" in registry).is_false() 

327 

328 

329def test_len(registry: ToolRegistry) -> None: 

330 """__len__ returns the total number of tools in the registry.""" 

331 assert_that(len(registry)).is_equal_to(6)