Coverage for tests/test_finder.py: 99.36%

137 statements  

« prev     ^ index     » next       coverage.py v7.6.2, created at 2024-10-12 01:34 +0200

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.finder import _handle_editable_module, _handle_pth_file 

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

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) 

68 tmp_package2.path.parent.joinpath("__init__.py").write_text(statement) 

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 ) 

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

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

97 

98 

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

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

101 

102 Parameters: 

103 tmp_path: Pytest fixture. 

104 """ 

105 pth_file = tmp_path / "hello.pth" 

106 pth_file.write_text( 

107 dedent( 

108 """ 

109 # comment 

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

111 """, 

112 ), 

113 ) 

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

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

116 

117 

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

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

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

121 

122 Parameters: 

123 tmp_path: Pytest fixture. 

124 """ 

125 pth_file = tmp_path / editable_file_name 

126 pth_file.write_text("hello\nF.map_module('griffe', 'src/griffe/__init__.py')") 

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

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

129 

130 

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

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

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

134 

135 Parameters: 

136 tmp_path: Pytest fixture. 

137 annotation: The type annotation of the MAPPING variable. 

138 """ 

139 pth_file = tmp_path / "__editable__whatever.py" 

140 pth_file.write_text(f"hello\nMAPPING{annotation} = { 'griffe': 'src/griffe'} ") 

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

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

143 

144 

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

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

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

148 

149 Parameters: 

150 tmp_path: Pytest fixture. 

151 annotation: The type annotation of the MAPPING variable. 

152 """ 

153 pth_file = tmp_path / "__editable__whatever.py" 

154 pth_file.write_text( 

155 "hello=1\n" 

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

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

158 ) 

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

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

161 

162 

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

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

165 

166 Parameters: 

167 tmp_path: Pytest fixture. 

168 """ 

169 pth_file = tmp_path / "_whatever_editable.py" 

170 pth_file.write_text( 

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

172 ) 

173 # the second dict is not handled: scikit-build-core puts these files 

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

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

176 # and rely on dynamic meta path finder stuff) 

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

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

179 

180 

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

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

183 

184 Parameters: 

185 tmp_path: Pytest fixture. 

186 """ 

187 pth_file = tmp_path / "_whatever_editable_loader.py" 

188 pth_file.write_text( 

189 # the path in argument 2 suffixed with src must exist, so we pass '.' 

190 "hello=1\ninstall({'griffe', 'hello'}, '.', ['/tmp/ninja'], False)", 

191 ) 

192 search_paths = _handle_editable_module(pth_file) 

193 assert all(sp.always_scan_for in {"griffe", "_griffe"} for sp in search_paths) 

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

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

196 

197 

198@pytest.mark.parametrize( 

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

200 [ 

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

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

203 ("package", None, True, "package"), 

204 (None, "package", True, "package"), 

205 ("stubs", None, True, "stubs"), 

206 (None, "stubs", True, "stubs"), 

207 (None, None, True, "none"), 

208 ("package", "stubs", False, "package"), 

209 ("stubs", "package", False, "package"), 

210 ("package", None, False, "package"), 

211 (None, "package", False, "package"), 

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

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

214 (None, None, False, "none"), 

215 ], 

216) 

217def test_finding_stubs_packages( 

218 tmp_path: Path, 

219 first: str | None, 

220 second: str | None, 

221 find_stubs: bool, 

222 expect: str, 

223) -> None: 

224 """Find stubs-only packages. 

225 

226 Parameters: 

227 tmp_path: Pytest fixture. 

228 """ 

229 search_path1 = tmp_path / "sp1" 

230 search_path2 = tmp_path / "sp2" 

231 search_path1.mkdir() 

232 search_path2.mkdir() 

233 

234 if first == "package": 

235 package = search_path1 / "package" 

236 package.mkdir() 

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

238 elif first == "stubs": 

239 stubs = search_path1 / "package-stubs" 

240 stubs.mkdir() 

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

242 

243 if second == "package": 

244 package = search_path2 / "package" 

245 package.mkdir() 

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

247 elif second == "stubs": 

248 stubs = search_path2 / "package-stubs" 

249 stubs.mkdir() 

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

251 

252 finder = ModuleFinder([search_path1, search_path2]) 

253 

254 if expect == "none": 

255 with pytest.raises(ModuleNotFoundError): 

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

257 return 

258 

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

260 assert name == "package" 

261 

262 if expect == "both": 

263 assert isinstance(result, Package) 

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

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

266 assert result.stubs 

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

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

269 elif expect == "package": 

270 assert isinstance(result, Package) 

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

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

273 assert result.stubs is None 

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

275 assert isinstance(result, Package) 

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

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

278 assert result.stubs is None 

279 

280 

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

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

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

284 

285 Parameters: 

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

287 """ 

288 init = not namespace_package 

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

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

291 # to correctly assert the finder's behavior, 

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

293 path = Path(tmp_package.name) 

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

295 old = Path.cwd() 

296 os.chdir(tmp_package.path.parent) 

297 try: 

298 finder = ModuleFinder(search_paths=[]) 

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

300 finally: 

301 os.chdir(old) 

302 check = ( 

303 path / "pkg/mod.py", 

304 path / "mod/mod.py", 

305 ) 

306 for mod in check: 

307 assert mod in found 

308 

309 

310def test_not_finding_namespace_package_twice() -> None: 

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

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

313 old = Path.cwd() 

314 os.chdir(tmp_package.tmpdir) 

315 try: 

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

317 found = finder.find_package("pkg") 

318 finally: 

319 os.chdir(old) 

320 assert isinstance(found, NamespacePackage) 

321 assert len(found.path) == 1