Coverage for tests / test_finder.py: 99.36%

137 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-11 11:48 +0100

1"""Tests for the `finder` module.""" 

2 

3from __future__ import annotations 

4 

5import os 

6from pathlib import Path 

7from textwrap import dedent 

8 

9import pytest 

10 

11from griffe import Module, ModuleFinder, NamespacePackage, Package, temporary_pypackage 

12from griffe._internal.finder import _handle_editable_module, _handle_pth_file 

13 

14 

15@pytest.mark.parametrize( 

16 ("pypackage", "module", "add_to_search_path", "expected_top_name", "expected_top_path"), 

17 [ 

18 (("a", ["b.py"]), "a/b.py", True, "a", "a/__init__.py"), 

19 (("a", ["b.py"]), "a/b.py", False, "a", "a/__init__.py"), 

20 (("a/b", ["c.py"]), "a/b/c.py", True, "a", "a"), 

21 (("a/b", ["c.py"]), "a/b/c.py", False, "b", "a/b/__init__.py"), 

22 ], 

23) 

24def test_find_module_with_path( 

25 pypackage: tuple[str, list[str]], 

26 module: str, 

27 add_to_search_path: bool, 

28 expected_top_name: str, 

29 expected_top_path: str, 

30) -> None: 

31 """Check that the finder can find modules using strings and Paths. 

32 

33 Parameters: 

34 pypackage: A temporary package (metadata) on the file system (parametrized). 

35 module: The module path to load (parametrized). 

36 add_to_search_path: Whether to add the temporary package parent path to the finder search paths (parametrized). 

37 expected_top_name: Expected top module name (parametrized). 

38 expected_top_path: Expected top module path (parametrized). 

39 """ 

40 with temporary_pypackage(*pypackage) as tmp_package: 

41 finder = ModuleFinder(search_paths=[tmp_package.tmpdir] if add_to_search_path else None) 

42 _, package = finder.find_spec(tmp_package.tmpdir / module) 

43 assert package.name == expected_top_name 

44 if isinstance(package, NamespacePackage): 

45 assert package.path == [tmp_package.tmpdir / expected_top_path] 

46 else: 

47 assert package.path == tmp_package.tmpdir / expected_top_path 

48 

49 

50@pytest.mark.parametrize( 

51 "statement", 

52 [ 

53 "__import__('pkg_resources').declare_namespace(__name__)", 

54 "__path__ = __import__('pkgutil').extend_path(__path__, __name__)", 

55 ], 

56) 

57def test_find_pkg_style_namespace_packages(statement: str) -> None: 

58 """Check that the finder can find pkg-style namespace packages. 

59 

60 Parameters: 

61 statement: The statement in the `__init__` module allowing to mark the package as namespace. 

62 """ 

63 with ( 

64 temporary_pypackage("namespace/package1") as tmp_package1, 

65 temporary_pypackage("namespace/package2") as tmp_package2, 

66 ): 

67 tmp_package1.path.parent.joinpath("__init__.py").write_text(statement, encoding="utf8") 

68 tmp_package2.path.parent.joinpath("__init__.py").write_text(statement, encoding="utf8") 

69 finder = ModuleFinder(search_paths=[tmp_package1.tmpdir, tmp_package2.tmpdir]) 

70 _, package = finder.find_spec("namespace") 

71 assert package.name == "namespace" 

72 assert isinstance(package, NamespacePackage) 

73 assert package.path == [tmp_package1.path.parent, tmp_package2.path.parent] 

74 

75 

76def test_pth_file_handling(tmp_path: Path) -> None: 

77 """Assert .pth files are correctly handled. 

78 

79 Parameters: 

80 tmp_path: Pytest fixture. 

81 """ 

82 pth_file = tmp_path / "hello.pth" 

83 pth_file.write_text( 

84 dedent( 

85 """ 

86 # comment 

87 

88 import thing 

89 import\tthing 

90 /doesnotexist 

91 tests 

92 """, 

93 ), 

94 encoding="utf8", 

95 ) 

96 paths = [sp.path for sp in _handle_pth_file(pth_file)] 

97 assert paths == [Path("tests")] 

98 

99 

100def test_pth_file_handling_with_semi_colon(tmp_path: Path) -> None: 

101 """Assert .pth files are correctly handled. 

102 

103 Parameters: 

104 tmp_path: Pytest fixture. 

105 """ 

106 pth_file = tmp_path / "hello.pth" 

107 pth_file.write_text( 

108 dedent( 

109 """ 

110 # comment 

111 import thing; import\tthing; /doesnotexist; tests 

112 """, 

113 ), 

114 encoding="utf8", 

115 ) 

116 paths = [sp.path for sp in _handle_pth_file(pth_file)] 

117 assert paths == [Path("tests")] 

118 

119 

120@pytest.mark.parametrize("editable_file_name", ["__editables_whatever.py", "_editable_impl_whatever.py"]) 

121def test_editables_file_handling(tmp_path: Path, editable_file_name: str) -> None: 

122 """Assert editable modules by `editables` are handled. 

123 

124 Parameters: 

125 tmp_path: Pytest fixture. 

126 """ 

127 pth_file = tmp_path / editable_file_name 

128 pth_file.write_text("hello\nF.map_module('griffe', 'packages/griffelib/src/griffe/__init__.py')", encoding="utf8") 

129 paths = [sp.path for sp in _handle_editable_module(pth_file)] 

130 assert paths == [Path("packages/griffelib/src")] 

131 

132 

133@pytest.mark.parametrize("annotation", ["", ": dict[str, str]"]) 

134def test_setuptools_file_handling(tmp_path: Path, annotation: str) -> None: 

135 """Assert editable modules by `setuptools` are handled. 

136 

137 Parameters: 

138 tmp_path: Pytest fixture. 

139 annotation: The type annotation of the MAPPING variable. 

140 """ 

141 pth_file = tmp_path / "__editable__whatever.py" 

142 pth_file.write_text(f"hello\nMAPPING{annotation} = {{'griffe': 'src/griffe'}}", encoding="utf8") 

143 paths = [sp.path for sp in _handle_editable_module(pth_file)] 

144 assert paths == [Path("src")] 

145 

146 

147@pytest.mark.parametrize("annotation", ["", ": dict[str, str]"]) 

148def test_setuptools_file_handling_multiple_paths(tmp_path: Path, annotation: str) -> None: 

149 """Assert editable modules by `setuptools` are handled when multiple packages are installed in the same editable. 

150 

151 Parameters: 

152 tmp_path: Pytest fixture. 

153 annotation: The type annotation of the MAPPING variable. 

154 """ 

155 pth_file = tmp_path / "__editable__whatever.py" 

156 pth_file.write_text( 

157 "hello=1\n" 

158 f"MAPPING{annotation} = {{\n'griffe':\n 'src1/griffe', 'briffe':'src2/briffe'}}\n" 

159 "def printer():\n print(hello)", 

160 encoding="utf8", 

161 ) 

162 paths = [sp.path for sp in _handle_editable_module(pth_file)] 

163 assert paths == [Path("src1"), Path("src2")] 

164 

165 

166def test_scikit_build_core_file_handling(tmp_path: Path) -> None: 

167 """Assert editable modules by `scikit-build-core` are handled. 

168 

169 Parameters: 

170 tmp_path: Pytest fixture. 

171 """ 

172 pth_file = tmp_path / "_whatever_editable.py" 

173 pth_file.write_text( 

174 "hello=1\ninstall({'whatever': '/path/to/whatever'}, {'whatever.else': '/else'}, None, False, True)", 

175 encoding="utf8", 

176 ) 

177 # The second dict is not handled: scikit-build-core puts these files 

178 # in a location that Griffe won't be able to discover anyway 

179 # (they don't respect standard package or namespace package layouts, 

180 # and rely on dynamic meta path finder stuff). 

181 paths = [sp.path for sp in _handle_editable_module(pth_file)] 

182 assert paths == [Path("/path/to/whatever")] 

183 

184 

185def test_meson_python_file_handling(tmp_path: Path) -> None: 

186 """Assert editable modules by `meson-python` are handled. 

187 

188 Parameters: 

189 tmp_path: Pytest fixture. 

190 """ 

191 pth_file = tmp_path / "_whatever_editable_loader.py" 

192 pth_file.write_text( 

193 # The path in argument 2 suffixed with src must exist, so we pass `packages/griffelib`. 

194 "hello=1\ninstall({'griffe', 'hello'}, 'packages/griffelib', ['/tmp/ninja'], False)", 

195 encoding="utf8", 

196 ) 

197 search_paths = _handle_editable_module(pth_file) 

198 assert all(sp.always_scan_for == "griffe" for sp in search_paths) 

199 paths = [sp.path for sp in search_paths] 

200 assert paths == [Path("packages/griffelib/src")] 

201 

202 

203@pytest.mark.parametrize( 

204 ("first", "second", "find_stubs", "expect"), 

205 [ 

206 ("package", "stubs", True, "both"), 

207 ("stubs", "package", True, "both"), 

208 ("package", None, True, "package"), 

209 (None, "package", True, "package"), 

210 ("stubs", None, True, "stubs"), 

211 (None, "stubs", True, "stubs"), 

212 (None, None, True, "none"), 

213 ("package", "stubs", False, "package"), 

214 ("stubs", "package", False, "package"), 

215 ("package", None, False, "package"), 

216 (None, "package", False, "package"), 

217 ("stubs", None, False, "none"), 

218 (None, "stubs", False, "none"), 

219 (None, None, False, "none"), 

220 ], 

221) 

222def test_finding_stubs_packages( 

223 tmp_path: Path, 

224 first: str | None, 

225 second: str | None, 

226 find_stubs: bool, 

227 expect: str, 

228) -> None: 

229 """Find stubs-only packages. 

230 

231 Parameters: 

232 tmp_path: Pytest fixture. 

233 """ 

234 search_path1 = tmp_path / "sp1" 

235 search_path2 = tmp_path / "sp2" 

236 search_path1.mkdir() 

237 search_path2.mkdir() 

238 

239 if first == "package": 

240 package = search_path1 / "package" 

241 package.mkdir() 

242 package.joinpath("__init__.py").touch() 

243 elif first == "stubs": 

244 stubs = search_path1 / "package-stubs" 

245 stubs.mkdir() 

246 stubs.joinpath("__init__.pyi").touch() 

247 

248 if second == "package": 

249 package = search_path2 / "package" 

250 package.mkdir() 

251 package.joinpath("__init__.py").touch() 

252 elif second == "stubs": 

253 stubs = search_path2 / "package-stubs" 

254 stubs.mkdir() 

255 stubs.joinpath("__init__.pyi").touch() 

256 

257 finder = ModuleFinder([search_path1, search_path2]) 

258 

259 if expect == "none": 

260 with pytest.raises(ModuleNotFoundError): 

261 finder.find_spec("package", try_relative_path=False, find_stubs_package=find_stubs) 

262 return 

263 

264 name, result = finder.find_spec("package", try_relative_path=False, find_stubs_package=find_stubs) 

265 assert name == "package" 

266 

267 if expect == "both": 

268 assert isinstance(result, Package) 

269 assert result.path.suffix == ".py" 

270 assert not result.path.parent.name.endswith("-stubs") 

271 assert result.stubs 

272 assert result.stubs.suffix == ".pyi" 

273 assert result.stubs.parent.name.endswith("-stubs") 

274 elif expect == "package": 

275 assert isinstance(result, Package) 

276 assert result.path.suffix == ".py" 

277 assert not result.path.parent.name.endswith("-stubs") 

278 assert result.stubs is None 

279 elif expect == "stubs": 279 ↛ exitline 279 didn't return from function 'test_finding_stubs_packages' because the condition on line 279 was always true

280 assert isinstance(result, Package) 

281 assert result.path.suffix == ".pyi" 

282 assert result.path.parent.name.endswith("-stubs") 

283 assert result.stubs is None 

284 

285 

286@pytest.mark.parametrize("namespace_package", [False, True]) 

287def test_scanning_package_and_module_with_same_names(namespace_package: bool) -> None: 

288 """The finder correctly scans package and module having same the name. 

289 

290 Parameters: 

291 namespace_package: Whether the temporary package is a namespace one. 

292 """ 

293 init = not namespace_package 

294 with temporary_pypackage("pkg", ["pkg/mod.py", "mod/mod.py"], init=init, inits=init) as tmp_package: 

295 # Here we must make sure that all paths are relative 

296 # to correctly assert the finder's behavior, 

297 # so we pass `.` and actually enter the temporary directory. 

298 path = Path(tmp_package.name) 

299 filepath: Path | list[Path] = [path] if namespace_package else path 

300 old = Path.cwd() 

301 os.chdir(tmp_package.path.parent) 

302 try: 

303 finder = ModuleFinder(search_paths=[]) 

304 found = [path for _, path in finder.submodules(Module("pkg", filepath=filepath))] 

305 finally: 

306 os.chdir(old) 

307 check = ( 

308 path / "pkg/mod.py", 

309 path / "mod/mod.py", 

310 ) 

311 for mod in check: 

312 assert mod in found 

313 

314 

315def test_not_finding_namespace_package_twice() -> None: 

316 """Deduplicate paths when finding namespace packages.""" 

317 with temporary_pypackage("pkg", ["pkg/mod.py", "mod/mod.py"], init=False, inits=False) as tmp_package: 

318 old = Path.cwd() 

319 os.chdir(tmp_package.tmpdir) 

320 try: 

321 finder = ModuleFinder(search_paths=[Path(), tmp_package.tmpdir]) 

322 found = finder.find_package("pkg") 

323 finally: 

324 os.chdir(old) 

325 assert isinstance(found, NamespacePackage) 

326 assert len(found.path) == 1