Coverage for tests/test_finder.py: 99.50%

137 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-15 16:47 +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 temporary_pypackage("namespace/package1") as tmp_package1, temporary_pypackage( 

64 "namespace/package2", 

65 ) as tmp_package2: 

66 tmp_package1.path.parent.joinpath("__init__.py").write_text(statement) 

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

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

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

70 assert package.name == "namespace" 

71 assert isinstance(package, NamespacePackage) 

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

73 

74 

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

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

77 

78 Parameters: 

79 tmp_path: Pytest fixture. 

80 """ 

81 pth_file = tmp_path / "hello.pth" 

82 pth_file.write_text( 

83 dedent( 

84 """ 

85 # comment 

86 

87 import thing 

88 import\tthing 

89 /doesnotexist 

90 tests 

91 """, 

92 ), 

93 ) 

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

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

96 

97 

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

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

100 

101 Parameters: 

102 tmp_path: Pytest fixture. 

103 """ 

104 pth_file = tmp_path / "hello.pth" 

105 pth_file.write_text( 

106 dedent( 

107 """ 

108 # comment 

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

110 """, 

111 ), 

112 ) 

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

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

115 

116 

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

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

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

120 

121 Parameters: 

122 tmp_path: Pytest fixture. 

123 """ 

124 pth_file = tmp_path / editable_file_name 

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

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

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

128 

129 

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

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

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

133 

134 Parameters: 

135 tmp_path: Pytest fixture. 

136 annotation: The type annotation of the MAPPING variable. 

137 """ 

138 pth_file = tmp_path / "__editable__whatever.py" 

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

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

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

142 

143 

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

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

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

147 

148 Parameters: 

149 tmp_path: Pytest fixture. 

150 annotation: The type annotation of the MAPPING variable. 

151 """ 

152 pth_file = tmp_path / "__editable__whatever.py" 

153 pth_file.write_text( 

154 "hello=1\n" 

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

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

157 ) 

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

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

160 

161 

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

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

164 

165 Parameters: 

166 tmp_path: Pytest fixture. 

167 """ 

168 pth_file = tmp_path / "_whatever_editable.py" 

169 pth_file.write_text( 

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

171 ) 

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

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

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

175 # and rely on dynamic meta path finder stuff) 

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

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

178 

179 

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

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

182 

183 Parameters: 

184 tmp_path: Pytest fixture. 

185 """ 

186 pth_file = tmp_path / "_whatever_editable_loader.py" 

187 pth_file.write_text( 

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

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

190 ) 

191 search_paths = _handle_editable_module(pth_file) 

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

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

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

195 

196 

197@pytest.mark.parametrize( 

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

199 [ 

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

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

202 ("package", None, True, "package"), 

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

204 ("stubs", None, True, "stubs"), 

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

206 (None, None, True, "none"), 

207 ("package", "stubs", False, "package"), 

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

209 ("package", None, False, "package"), 

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

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

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

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

214 ], 

215) 

216def test_finding_stubs_packages( 

217 tmp_path: Path, 

218 first: str | None, 

219 second: str | None, 

220 find_stubs: bool, 

221 expect: str, 

222) -> None: 

223 """Find stubs-only packages. 

224 

225 Parameters: 

226 tmp_path: Pytest fixture. 

227 """ 

228 search_path1 = tmp_path / "sp1" 

229 search_path2 = tmp_path / "sp2" 

230 search_path1.mkdir() 

231 search_path2.mkdir() 

232 

233 if first == "package": 

234 package = search_path1 / "package" 

235 package.mkdir() 

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

237 elif first == "stubs": 

238 stubs = search_path1 / "package-stubs" 

239 stubs.mkdir() 

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

241 

242 if second == "package": 

243 package = search_path2 / "package" 

244 package.mkdir() 

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

246 elif second == "stubs": 

247 stubs = search_path2 / "package-stubs" 

248 stubs.mkdir() 

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

250 

251 finder = ModuleFinder([search_path1, search_path2]) 

252 

253 if expect == "none": 

254 with pytest.raises(ModuleNotFoundError): 

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

256 return 

257 

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

259 assert name == "package" 

260 

261 if expect == "both": 

262 assert isinstance(result, Package) 

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

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

265 assert result.stubs 

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

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

268 elif expect == "package": 

269 assert isinstance(result, Package) 

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

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

272 assert result.stubs is None 

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

274 assert isinstance(result, Package) 

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

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

277 assert result.stubs is None 

278 

279 

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

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

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

283 

284 Parameters: 

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

286 """ 

287 init = not namespace_package 

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

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

290 # to correctly assert the finder's behavior, 

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

292 path = Path(tmp_package.name) 

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

294 old = os.getcwd() 

295 os.chdir(tmp_package.path.parent) 

296 try: 

297 finder = ModuleFinder(search_paths=[]) 

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

299 finally: 

300 os.chdir(old) 

301 check = ( 

302 path / "pkg/mod.py", 

303 path / "mod/mod.py", 

304 ) 

305 for mod in check: 

306 assert mod in found 

307 

308 

309def test_not_finding_namespace_package_twice() -> None: 

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

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

312 old = os.getcwd() 

313 os.chdir(tmp_package.tmpdir) 

314 try: 

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

316 found = finder.find_package("pkg") 

317 finally: 

318 os.chdir(old) 

319 assert isinstance(found, NamespacePackage) 

320 assert len(found.path) == 1