Coverage for tests/test_loader.py: 98.92%

275 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-16 15:54 +0200

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

2 

3from __future__ import annotations 

4 

5import logging 

6from textwrap import dedent 

7from typing import TYPE_CHECKING 

8 

9import pytest 

10 

11from griffe import ( 

12 ExprName, 

13 GriffeLoader, 

14 temporary_inspected_package, 

15 temporary_pyfile, 

16 temporary_pypackage, 

17 temporary_visited_package, 

18) 

19 

20if TYPE_CHECKING: 

21 from pathlib import Path 

22 

23 from griffe import Alias 

24 

25 

26def test_has_docstrings_does_not_try_to_resolve_alias() -> None: 

27 """Assert that checkins presence of docstrings does not trigger alias resolution.""" 

28 with temporary_pyfile("""from abc import abstractmethod""") as (module_name, path): 

29 loader = GriffeLoader(search_paths=[path.parent]) 

30 module = loader.load(module_name) 

31 loader.resolve_aliases() 

32 assert "abstractmethod" in module.members 

33 assert not module.has_docstrings 

34 

35 

36def test_recursive_wildcard_expansion() -> None: 

37 """Assert that wildcards are expanded recursively.""" 

38 with temporary_pypackage("package", ["mod_a/mod_b/mod_c.py"]) as tmp_package: 

39 mod_a_dir = tmp_package.path / "mod_a" 

40 mod_b_dir = mod_a_dir / "mod_b" 

41 mod_a = mod_a_dir / "__init__.py" 

42 mod_b = mod_b_dir / "__init__.py" 

43 mod_c = mod_b_dir / "mod_c.py" 

44 mod_c.write_text("CONST_X = 'X'\nCONST_Y = 'Y'") 

45 mod_b.write_text("from .mod_c import *") 

46 mod_a.write_text("from .mod_b import *") 

47 

48 loader = GriffeLoader(search_paths=[tmp_package.tmpdir]) 

49 package = loader.load(tmp_package.name) 

50 

51 assert "CONST_X" in package["mod_a.mod_b.mod_c"].members 

52 assert "CONST_Y" in package["mod_a.mod_b.mod_c"].members 

53 

54 assert "CONST_X" not in package.members 

55 assert "CONST_Y" not in package.members 

56 

57 loader.expand_wildcards(package) # type: ignore[arg-type] 

58 

59 assert "CONST_X" in package["mod_a"].members 

60 assert "CONST_Y" in package["mod_a"].members 

61 assert "CONST_X" in package["mod_a.mod_b"].members 

62 assert "CONST_Y" in package["mod_a.mod_b"].members 

63 

64 

65def test_dont_shortcut_alias_chain_after_expanding_wildcards() -> None: 

66 """Assert public aliases paths are not resolved to canonical paths when expanding wildcards.""" 

67 with temporary_pypackage("package", ["mod_a.py", "mod_b.py", "mod_c.py"]) as tmp_package: 

68 mod_a = tmp_package.path / "mod_a.py" 

69 mod_b = tmp_package.path / "mod_b.py" 

70 mod_c = tmp_package.path / "mod_c.py" 

71 

72 mod_a.write_text("from package.mod_b import *\nclass Child(Base): ...\n") 

73 mod_b.write_text("from package.mod_c import Base\n__all__ = ['Base']\n") 

74 mod_c.write_text("class Base: ...\n") 

75 

76 loader = GriffeLoader(search_paths=[tmp_package.tmpdir]) 

77 package = loader.load(tmp_package.name) 

78 loader.resolve_aliases() 

79 child = package["mod_a.Child"] 

80 assert child.bases 

81 base = child.bases[0] 

82 assert isinstance(base, ExprName) 

83 assert base.name == "Base" 

84 assert base.canonical_path == "package.mod_b.Base" 

85 

86 

87def test_dont_overwrite_lower_member_when_expanding_wildcard() -> None: 

88 """Check that we don't overwrite a member defined after the import when expanding a wildcard.""" 

89 with temporary_pypackage("package", ["mod_a.py", "mod_b.py"]) as tmp_package: 

90 mod_a = tmp_package.path / "mod_a.py" 

91 mod_b = tmp_package.path / "mod_b.py" 

92 

93 mod_a.write_text("overwritten = 0\nfrom package.mod_b import *\nnot_overwritten = 0\n") 

94 mod_b.write_text("overwritten = 1\nnot_overwritten = 1\n") 

95 

96 loader = GriffeLoader(search_paths=[tmp_package.tmpdir]) 

97 package = loader.load(tmp_package.name) 

98 loader.resolve_aliases() 

99 assert package["mod_a.overwritten"].value == "1" 

100 assert package["mod_a.not_overwritten"].value == "0" 

101 

102 

103def test_load_data_from_stubs() -> None: 

104 """Check that the loader is able to load data from stubs / `*.pyi` files.""" 

105 with temporary_pypackage("package", ["_rust_notify.pyi"]) as tmp_package: 

106 # code taken from samuelcolvin/watchfiles project 

107 code = ''' 

108 from typing import List, Literal, Optional, Protocol, Set, Tuple, Union 

109 

110 __all__ = ['RustNotify'] 

111 

112 class AbstractEvent(Protocol): 

113 def is_set(self) -> bool: ... 

114 

115 class RustNotify: 

116 """ 

117 Interface to the Rust [notify](https://crates.io/crates/notify) crate which does 

118 the heavy lifting of watching for file changes and grouping them into a single event. 

119 """ 

120 

121 def __init__(self, watch_paths: List[str], debug: bool) -> None: 

122 """ 

123 Create a new RustNotify instance and start a thread to watch for changes. 

124 

125 `FileNotFoundError` is raised if one of the paths does not exist. 

126 

127 Args: 

128 watch_paths: file system paths to watch for changes, can be directories or files 

129 debug: if true, print details about all events to stderr 

130 """ 

131 ''' 

132 tmp_package.path.joinpath("_rust_notify.pyi").write_text(dedent(code)) 

133 tmp_package.path.joinpath("__init__.py").write_text( 

134 "from ._rust_notify import RustNotify\n__all__ = ['RustNotify']", 

135 ) 

136 loader = GriffeLoader(search_paths=[tmp_package.tmpdir]) 

137 package = loader.load(tmp_package.name) 

138 loader.resolve_aliases() 

139 

140 assert "_rust_notify" in package.members 

141 assert "RustNotify" in package.members 

142 assert package["RustNotify"].resolved 

143 

144 

145def test_load_from_both_py_and_pyi_files() -> None: 

146 """Check that the loader is able to merge data loaded from `*.py` and `*.pyi` files.""" 

147 with temporary_pypackage("package", ["mod.py", "mod.pyi"]) as tmp_package: 

148 tmp_package.path.joinpath("mod.py").write_text( 

149 dedent( 

150 """ 

151 CONST = 0 

152 

153 class Class: 

154 class_attr = True 

155 

156 def function1(self, arg1): 

157 pass 

158 

159 def function2(self, arg1=2.2): 

160 pass 

161 """, 

162 ), 

163 ) 

164 tmp_package.path.joinpath("mod.pyi").write_text( 

165 dedent( 

166 """ 

167 from typing import Sequence, overload 

168 

169 CONST: int 

170 

171 class Class: 

172 class_attr: bool 

173 

174 @overload 

175 def function1(self, arg1: str) -> Sequence[str]: ... 

176 @overload 

177 def function1(self, arg1: bytes) -> Sequence[bytes]: ... 

178 

179 def function2(self, arg1: float) -> float: ... 

180 """, 

181 ), 

182 ) 

183 loader = GriffeLoader(search_paths=[tmp_package.tmpdir]) 

184 package = loader.load(tmp_package.name) 

185 loader.resolve_aliases() 

186 

187 assert "mod" in package.members 

188 mod = package["mod"] 

189 assert mod.filepath.suffix == ".py" 

190 

191 assert "CONST" in mod.members 

192 const = mod["CONST"] 

193 assert const.value == "0" 

194 assert const.annotation.name == "int" 

195 

196 assert "Class" in mod.members 

197 class_ = mod["Class"] 

198 

199 assert "class_attr" in class_.members 

200 class_attr = class_["class_attr"] 

201 assert class_attr.value == "True" 

202 assert class_attr.annotation.name == "bool" 

203 

204 assert "function1" in class_.members 

205 function1 = class_["function1"] 

206 assert len(function1.overloads) == 2 

207 

208 assert "function2" in class_.members 

209 function2 = class_["function2"] 

210 assert function2.returns.name == "float" 

211 assert function2.parameters["arg1"].annotation.name == "float" 

212 assert function2.parameters["arg1"].default == "2.2" 

213 

214 

215def test_overwrite_module_with_attribute() -> None: 

216 """Check we are able to overwrite a module with an attribute.""" 

217 with temporary_pypackage("package", ["mod.py"]) as tmp_package: 

218 tmp_package.path.joinpath("mod.py").write_text("mod: list = [0, 1, 2]") 

219 tmp_package.path.joinpath("__init__.py").write_text("from package.mod import *") 

220 loader = GriffeLoader(search_paths=[tmp_package.tmpdir]) 

221 loader.load(tmp_package.name) 

222 loader.resolve_aliases() 

223 

224 

225def test_load_package_from_both_py_and_pyi_files() -> None: 

226 """Check that the loader is able to merge a package loaded from `*.py` and `*.pyi` files. 

227 

228 This is a special case of the previous test: where the package itself has a top level 

229 `__init__.pyi` (not so uncommon). 

230 """ 

231 with temporary_pypackage("package", ["__init__.py", "__init__.pyi"]) as tmp_package: 

232 tmp_package.path.joinpath("__init__.py").write_text("globals()['f'] = lambda x: str(x)") 

233 tmp_package.path.joinpath("__init__.pyi").write_text("def f(x: int) -> str: ...") 

234 

235 loader = GriffeLoader(search_paths=[tmp_package.tmpdir]) 

236 package = loader.load(tmp_package.name) 

237 assert "f" in package.members 

238 

239 

240def test_load_single_module_from_both_py_and_pyi_files() -> None: 

241 """Check that the loader is able to merge a single-module package loaded from `*.py` and `*.pyi` files. 

242 

243 This is a special case of the previous test: where the package is a single module 

244 distribution that also drops a `.pyi` file in site-packages. 

245 """ 

246 with temporary_pypackage("just_a_folder", ["mod.py", "mod.pyi"]) as tmp_folder: 

247 tmp_folder.path.joinpath("__init__.py").unlink() 

248 tmp_folder.path.joinpath("mod.py").write_text("globals()['f'] = lambda x: str(x)") 

249 tmp_folder.path.joinpath("mod.pyi").write_text("def f(x: int) -> str: ...") 

250 

251 loader = GriffeLoader(search_paths=[tmp_folder.path]) 

252 package = loader.load("mod") 

253 assert "f" in package.members 

254 

255 

256def test_unsupported_item_in_all(caplog: pytest.LogCaptureFixture) -> None: 

257 """Check that unsupported items in `__all__` log a warning. 

258 

259 Parameters: 

260 caplog: Pytest fixture to capture logs. 

261 """ 

262 item_name = "XXX" 

263 with temporary_pypackage("package", ["mod.py"]) as tmp_folder: 

264 tmp_folder.path.joinpath("__init__.py").write_text(f"from .mod import {item_name}\n__all__ = [{item_name}]") 

265 tmp_folder.path.joinpath("mod.py").write_text(f"class {item_name}: ...") 

266 loader = GriffeLoader(search_paths=[tmp_folder.tmpdir]) 

267 loader.expand_exports(loader.load("package")) # type: ignore[arg-type] 

268 assert any(item_name in record.message and record.levelname == "WARNING" for record in caplog.records) 268 ↛ exitline 268 didn't finish the generator expression on line 268

269 

270 

271def test_skip_modules_with_dots_in_filename(caplog: pytest.LogCaptureFixture) -> None: 

272 """Check that modules with dots in their filenames are skipped. 

273 

274 Parameters: 

275 caplog: Pytest fixture to capture logs. 

276 """ 

277 caplog.set_level(logging.DEBUG) 

278 with temporary_pypackage("package", ["gunicorn.conf.py"]) as tmp_folder: 

279 loader = GriffeLoader(search_paths=[tmp_folder.tmpdir]) 

280 loader.load("package") 

281 assert any("gunicorn.conf.py" in record.message and record.levelname == "DEBUG" for record in caplog.records) 281 ↛ exitline 281 didn't finish the generator expression on line 281

282 

283 

284def test_nested_namespace_packages() -> None: 

285 """Load a deeply nested namespace package.""" 

286 with temporary_pypackage("a/b/c/d", ["mod.py"]) as tmp_folder: 

287 loader = GriffeLoader(search_paths=[tmp_folder.tmpdir]) 

288 a_package = loader.load("a") 

289 assert "b" in a_package.members 

290 b_package = a_package.members["b"] 

291 assert "c" in b_package.members 

292 c_package = b_package.members["c"] 

293 assert "d" in c_package.members 

294 d_package = c_package.members["d"] 

295 assert "mod" in d_package.members 

296 

297 

298def test_multiple_nested_namespace_packages() -> None: 

299 """Load a deeply nested namespace package appearing in several places.""" 

300 with temporary_pypackage("a/b/c/d", ["mod1.py"], init=False) as tmp_ns1: # noqa: SIM117 

301 with temporary_pypackage("a/b/c/d", ["mod2.py"], init=False) as tmp_ns2: 

302 with temporary_pypackage("a/b/c/d", ["mod3.py"], init=False) as tmp_ns3: 

303 tmp_namespace_pkgs = [tmp_ns.tmpdir for tmp_ns in (tmp_ns1, tmp_ns2, tmp_ns3)] 

304 loader = GriffeLoader(search_paths=tmp_namespace_pkgs) 

305 

306 a_package = loader.load("a") 

307 for tmp_ns in tmp_namespace_pkgs: 

308 assert tmp_ns.joinpath("a") in a_package.filepath # type: ignore[operator] 

309 assert "b" in a_package.members 

310 

311 b_package = a_package.members["b"] 

312 for tmp_ns in tmp_namespace_pkgs: 

313 assert tmp_ns.joinpath("a/b") in b_package.filepath # type: ignore[operator] 

314 assert "c" in b_package.members 

315 

316 c_package = b_package.members["c"] 

317 for tmp_ns in tmp_namespace_pkgs: 

318 assert tmp_ns.joinpath("a/b/c") in c_package.filepath # type: ignore[operator] 

319 assert "d" in c_package.members 

320 

321 d_package = c_package.members["d"] 

322 for tmp_ns in tmp_namespace_pkgs: 

323 assert tmp_ns.joinpath("a/b/c/d") in d_package.filepath # type: ignore[operator] 

324 assert "mod1" in d_package.members 

325 assert "mod2" in d_package.members 

326 assert "mod3" in d_package.members 

327 

328 

329def test_stop_at_first_package_inside_namespace_package() -> None: 

330 """Stop loading similar paths once we found a non-namespace package.""" 

331 with temporary_pypackage("a/b/c/d", ["mod1.py"], init=True) as tmp_ns1: # noqa: SIM117 

332 with temporary_pypackage("a/b/c/d", ["mod2.py"], init=True) as tmp_ns2: 

333 tmp_namespace_pkgs = [tmp_ns.tmpdir for tmp_ns in (tmp_ns1, tmp_ns2)] 

334 loader = GriffeLoader(search_paths=tmp_namespace_pkgs) 

335 

336 a_package = loader.load("a") 

337 assert "b" in a_package.members 

338 

339 b_package = a_package.members["b"] 

340 assert "c" in b_package.members 

341 

342 c_package = b_package.members["c"] 

343 assert "d" in c_package.members 

344 

345 d_package = c_package.members["d"] 

346 assert d_package.is_subpackage 

347 assert d_package.filepath == tmp_ns1.tmpdir.joinpath("a/b/c/d/__init__.py") 

348 assert "mod1" in d_package.members 

349 assert "mod2" not in d_package.members 

350 

351 

352def test_load_builtin_modules() -> None: 

353 """Assert builtin/compiled modules can be loaded.""" 

354 loader = GriffeLoader() 

355 loader.load("_ast") 

356 loader.load("_collections") 

357 loader.load("_json") 

358 assert "_ast" in loader.modules_collection 

359 assert "_collections" in loader.modules_collection 

360 assert "_json" in loader.modules_collection 

361 

362 

363def test_resolve_aliases_of_builtin_modules() -> None: 

364 """Assert builtin/compiled modules can be loaded.""" 

365 loader = GriffeLoader() 

366 loader.load("io") 

367 loader.load("_io") 

368 unresolved, _ = loader.resolve_aliases(external=True, implicit=True, max_iterations=1) 

369 io_unresolved = {un for un in unresolved if un.startswith(("io", "_io"))} 

370 assert len(io_unresolved) < 5 

371 

372 

373@pytest.mark.parametrize("namespace", [False, True]) 

374def test_loading_stubs_only_packages(tmp_path: Path, namespace: bool) -> None: 

375 """Test loading and merging of stubs-only packages. 

376 

377 Parameters: 

378 tmp_path: Pytest fixture. 

379 namespace: Whether the package and stubs are namespace packages. 

380 """ 

381 # Create package. 

382 package_parent = tmp_path / "pkg_parent" 

383 package_parent.mkdir() 

384 package = package_parent / "package" 

385 package.mkdir() 

386 if not namespace: 

387 package.joinpath("__init__.py").write_text("a: int = 0") 

388 package.joinpath("module.py").write_text("a: int = 0") 

389 

390 # Create stubs. 

391 stubs_parent = tmp_path / "stubs_parent" 

392 stubs_parent.mkdir() 

393 stubs = stubs_parent / "package-stubs" 

394 stubs.mkdir() 

395 if not namespace: 

396 stubs.joinpath("__init__.pyi").write_text("b: int") 

397 stubs.joinpath("module.pyi").write_text("b: int") 

398 

399 # Exposing stubs first, to make sure order doesn't matter. 

400 loader = GriffeLoader(search_paths=[stubs_parent, package_parent]) 

401 

402 # Loading package and stubs, checking their contents. 

403 top_module = loader.load("package", try_relative_path=False, find_stubs_package=True) 

404 if not namespace: 

405 assert "a" in top_module.members 

406 assert "b" in top_module.members 

407 assert "a" in top_module["module"].members 

408 assert "b" in top_module["module"].members 

409 

410 

411@pytest.mark.parametrize( 

412 "init", 

413 [ 

414 "from package.thing import thing", 

415 "thing = False", 

416 ], 

417) 

418def test_submodule_shadowing_member(init: str, caplog: pytest.LogCaptureFixture) -> None: 

419 """Warn when a submodule shadows a member of the same name. 

420 

421 Parameters: 

422 init: Contents of the top-level init module. 

423 """ 

424 caplog.set_level(logging.DEBUG) 

425 with temporary_visited_package( 

426 "package", 

427 {"__init__.py": init, "thing.py": "thing = True"}, 

428 init=True, 

429 ): 

430 assert "shadowing" in caplog.text 

431 

432 

433@pytest.mark.parametrize("wildcard", [True, False]) 

434@pytest.mark.parametrize(("external", "foo_is_resolved"), [(None, True), (True, True), (False, False)]) 

435def test_side_loading_sibling_private_module(wildcard: bool, external: bool | None, foo_is_resolved: bool) -> None: 

436 """Automatically load `_a` when `a` (wildcard) imports from it. 

437 

438 Parameters: 

439 wildcard: Whether the import is a wildcard import. 

440 external: Value for the `external` parameter when resolving aliases. 

441 foo_is_resolved: Whether the `foo` alias should be resolved. 

442 """ 

443 with temporary_pypackage("_a", {"__init__.py": "def foo():\n '''Docstring.'''"}) as pkg_a: # noqa: SIM117 

444 with temporary_pypackage("a", {"__init__.py": f"from _a import {'*' if wildcard else 'foo'}"}) as pkg_a_private: 

445 loader = GriffeLoader(search_paths=[pkg_a.tmpdir, pkg_a_private.tmpdir]) 

446 package = loader.load("a") 

447 loader.resolve_aliases(external=external, implicit=True) 

448 if foo_is_resolved: 

449 assert "foo" in package.members 

450 assert package["foo"].is_alias 

451 assert package["foo"].resolved 

452 assert package["foo"].docstring.value == "Docstring." 

453 elif wildcard: 

454 assert "foo" not in package.members 

455 else: 

456 assert "foo" in package.members 

457 assert package["foo"].is_alias 

458 assert not package["foo"].resolved 

459 

460 

461def test_forcing_inspection() -> None: 

462 """Load a package with forced dynamic analysis.""" 

463 modules = {"__init__.py": "a = 0", "mod.py": "b = 1"} 

464 with temporary_visited_package("static_pkg", modules) as static_package, temporary_inspected_package( 

465 "dynamic_pkg", 

466 modules, 

467 ) as dynamic_package: 

468 for name in static_package.members: 

469 assert name in dynamic_package.members 

470 for name in static_package["mod"].members: 

471 assert name in dynamic_package["mod"].members 

472 

473 

474def test_relying_on_modules_path_attribute(monkeypatch: pytest.MonkeyPatch) -> None: 

475 """Load a package that relies on the `__path__` attribute of a module.""" 

476 

477 def raise_module_not_found_error(*args, **kwargs) -> None: # noqa: ARG001,ANN002,ANN003 

478 raise ModuleNotFoundError 

479 

480 loader = GriffeLoader() 

481 monkeypatch.setattr(loader.finder, "find_spec", raise_module_not_found_error) 

482 assert loader.load("griffe") 

483 

484 

485def test_not_calling_package_loaded_hook_on_something_else_than_package() -> None: 

486 """Always call the `on_package_loaded` hook on a package, not any other object.""" 

487 with temporary_pypackage("pkg", {"__init__.py": "from typing import List as L"}) as pkg: 

488 loader = GriffeLoader(search_paths=[pkg.tmpdir]) 

489 alias: Alias = loader.load("pkg.L") 

490 assert alias.is_alias 

491 assert not alias.resolved 

492 

493 

494@pytest.mark.parametrize("dynamic", [True, False]) 

495def test_warning_on_objects_from_non_submodules_being_exported( 

496 caplog: pytest.LogCaptureFixture, 

497 dynamic: bool, 

498) -> None: 

499 """Warn when objects from non-submodules are exported.""" 

500 temporary_package = temporary_inspected_package if dynamic else temporary_visited_package 

501 with caplog.at_level(logging.DEBUG, logger="griffe"), temporary_package( 

502 "pkg", 

503 { 

504 "__init__.py": "from typing import List\nfrom pkg import moda, modb\n__all__ = ['List']", 

505 "moda.py": "class Thing: ...", 

506 "modb.py": "from pkg.moda import Thing\n\n__all__ = ['Thing']", 

507 }, 

508 resolve_aliases=True, 

509 ): 

510 messages = [record.message for record in caplog.records] 

511 assert any("Name `List` exported by module" in msg for msg in messages) 511 ↛ exitline 511 didn't finish the generator expression on line 511

512 assert any("Name `Thing` exported by module" in msg for msg in messages) 512 ↛ exitline 512 didn't finish the generator expression on line 512