Coverage for tests / test_loader.py: 100.00%

290 statements  

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

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'", encoding="utf8") 

45 mod_b.write_text("from .mod_c import *", encoding="utf8") 

46 mod_a.write_text("from .mod_b import *", encoding="utf8") 

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) # ty:ignore[invalid-argument-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", encoding="utf8") 

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

74 mod_c.write_text("class Base: ...\n", encoding="utf8") 

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", encoding="utf8") 

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

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), encoding="utf8") 

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

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

135 encoding="utf8", 

136 ) 

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

138 package = loader.load(tmp_package.name) 

139 loader.resolve_aliases() 

140 

141 assert "_rust_notify" in package.members 

142 assert "RustNotify" in package.members 

143 assert package["RustNotify"].resolved 

144 

145 

146def test_load_from_both_py_and_pyi_files() -> None: 

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

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

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

150 dedent( 

151 """ 

152 CONST = 0 

153 

154 class Class: 

155 class_attr = True 

156 

157 def function1(self, arg1): 

158 pass 

159 

160 def function2(self, arg1=2.2): 

161 pass 

162 """, 

163 ), 

164 encoding="utf8", 

165 ) 

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

167 dedent( 

168 """ 

169 from typing import Sequence, overload 

170 

171 CONST: int 

172 

173 class Class: 

174 class_attr: bool 

175 

176 @overload 

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

178 @overload 

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

180 

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

182 """, 

183 ), 

184 encoding="utf8", 

185 ) 

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

187 package = loader.load(tmp_package.name) 

188 loader.resolve_aliases() 

189 

190 assert "mod" in package.members 

191 mod = package["mod"] 

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

193 

194 assert "CONST" in mod.members 

195 const = mod["CONST"] 

196 assert const.value == "0" 

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

198 

199 assert "Class" in mod.members 

200 class_ = mod["Class"] 

201 

202 assert "class_attr" in class_.members 

203 class_attr = class_["class_attr"] 

204 assert class_attr.value == "True" 

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

206 

207 assert "function1" in class_.members 

208 function1 = class_["function1"] 

209 assert len(function1.overloads) == 2 

210 

211 assert "function2" in class_.members 

212 function2 = class_["function2"] 

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

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

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

216 

217 

218def test_overwrite_module_with_attribute() -> None: 

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

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

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

222 tmp_package.path.joinpath("__init__.py").write_text("from package.mod import *", encoding="utf8") 

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

224 loader.load(tmp_package.name) 

225 loader.resolve_aliases() 

226 

227 

228def test_load_package_from_both_py_and_pyi_files() -> None: 

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

230 

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

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

233 """ 

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

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

236 tmp_package.path.joinpath("__init__.pyi").write_text("def f(x: int) -> str: ...", encoding="utf8") 

237 

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

239 package = loader.load(tmp_package.name) 

240 assert "f" in package.members 

241 

242 

243def test_load_single_module_from_both_py_and_pyi_files() -> None: 

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

245 

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

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

248 """ 

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

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

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

252 tmp_folder.path.joinpath("mod.pyi").write_text("def f(x: int) -> str: ...", encoding="utf8") 

253 

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

255 package = loader.load("mod") 

256 assert "f" in package.members 

257 

258 

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

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

261 

262 Parameters: 

263 caplog: Pytest fixture to capture logs. 

264 """ 

265 item_name = "XXX" 

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

267 tmp_folder.path.joinpath("__init__.py").write_text( 

268 f"from .mod import {item_name}\n__all__ = [{item_name}]", 

269 encoding="utf8", 

270 ) 

271 tmp_folder.path.joinpath("mod.py").write_text(f"class {item_name}: ...", encoding="utf8") 

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

273 loader.expand_exports(loader.load("package")) # ty:ignore[invalid-argument-type] 

274 assert any(item_name in record.message and record.levelname == "WARNING" for record in caplog.records) 

275 

276 

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

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

279 

280 Parameters: 

281 caplog: Pytest fixture to capture logs. 

282 """ 

283 caplog.set_level(logging.DEBUG) 

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

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

286 loader.load("package") 

287 assert any("gunicorn.conf.py" in record.message and record.levelname == "DEBUG" for record in caplog.records) 

288 

289 

290def test_nested_namespace_packages() -> None: 

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

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

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

294 a_package = loader.load("a") 

295 assert "b" in a_package.members 

296 b_package = a_package.members["b"] 

297 assert "c" in b_package.members 

298 c_package = b_package.members["c"] 

299 assert "d" in c_package.members 

300 d_package = c_package.members["d"] 

301 assert "mod" in d_package.members 

302 

303 

304def test_multiple_nested_namespace_packages() -> None: 

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

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

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

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

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

310 loader = GriffeLoader(search_paths=tmp_namespace_pkgs) 

311 

312 a_package = loader.load("a") 

313 for tmp_ns in tmp_namespace_pkgs: 

314 assert tmp_ns.joinpath("a") in a_package.filepath # ty:ignore[unsupported-operator] 

315 assert "b" in a_package.members 

316 

317 b_package = a_package.members["b"] 

318 for tmp_ns in tmp_namespace_pkgs: 

319 assert tmp_ns.joinpath("a/b") in b_package.filepath # ty:ignore[unsupported-operator] 

320 assert "c" in b_package.members 

321 

322 c_package = b_package.members["c"] 

323 for tmp_ns in tmp_namespace_pkgs: 

324 assert tmp_ns.joinpath("a/b/c") in c_package.filepath # ty:ignore[unsupported-operator] 

325 assert "d" in c_package.members 

326 

327 d_package = c_package.members["d"] 

328 for tmp_ns in tmp_namespace_pkgs: 

329 assert tmp_ns.joinpath("a/b/c/d") in d_package.filepath # ty:ignore[unsupported-operator] 

330 assert "mod1" in d_package.members 

331 assert "mod2" in d_package.members 

332 assert "mod3" in d_package.members 

333 

334 

335def test_stop_at_first_package_inside_namespace_package() -> None: 

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

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

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

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

340 loader = GriffeLoader(search_paths=tmp_namespace_pkgs) 

341 

342 a_package = loader.load("a") 

343 assert "b" in a_package.members 

344 

345 b_package = a_package.members["b"] 

346 assert "c" in b_package.members 

347 

348 c_package = b_package.members["c"] 

349 assert "d" in c_package.members 

350 

351 d_package = c_package.members["d"] 

352 assert d_package.is_subpackage 

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

354 assert "mod1" in d_package.members 

355 assert "mod2" not in d_package.members 

356 

357 

358def test_load_builtin_modules() -> None: 

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

360 loader = GriffeLoader() 

361 loader.load("_ast") 

362 loader.load("_collections") 

363 loader.load("_operator") 

364 assert "_ast" in loader.modules_collection 

365 assert "_collections" in loader.modules_collection 

366 assert "_operator" in loader.modules_collection 

367 

368 

369def test_resolve_aliases_of_builtin_modules() -> None: 

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

371 loader = GriffeLoader() 

372 loader.load("io") 

373 loader.load("_io") 

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

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

376 assert len(io_unresolved) < 5 

377 

378 

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

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

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

382 

383 Parameters: 

384 tmp_path: Pytest fixture. 

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

386 """ 

387 # Create package. 

388 package_parent = tmp_path / "pkg_parent" 

389 package_parent.mkdir() 

390 package = package_parent / "package" 

391 package.mkdir() 

392 if not namespace: 

393 package.joinpath("__init__.py").write_text("a: int = 0", encoding="utf8") 

394 package.joinpath("module.py").write_text("a: int = 0", encoding="utf8") 

395 

396 # Create stubs. 

397 stubs_parent = tmp_path / "stubs_parent" 

398 stubs_parent.mkdir() 

399 stubs = stubs_parent / "package-stubs" 

400 stubs.mkdir() 

401 if not namespace: 

402 stubs.joinpath("__init__.pyi").write_text("b: int", encoding="utf8") 

403 stubs.joinpath("module.pyi").write_text("b: int", encoding="utf8") 

404 

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

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

407 

408 # Loading package and stubs, checking their contents. 

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

410 if not namespace: 

411 assert "a" in top_module.members 

412 assert "b" in top_module.members 

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

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

415 

416 

417@pytest.mark.parametrize( 

418 "init", 

419 [ 

420 "from package.thing import thing", 

421 "thing = False", 

422 ], 

423) 

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

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

426 

427 Parameters: 

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

429 """ 

430 caplog.set_level(logging.DEBUG) 

431 with temporary_visited_package( 

432 "package", 

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

434 init=True, 

435 ): 

436 assert "shadowing" in caplog.text 

437 

438 

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

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

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

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

443 

444 Parameters: 

445 wildcard: Whether the import is a wildcard import. 

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

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

448 """ 

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

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

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

452 package = loader.load("a") 

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

454 if foo_is_resolved: 

455 assert "foo" in package.members 

456 assert package["foo"].is_alias 

457 assert package["foo"].resolved 

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

459 elif wildcard: 

460 assert "foo" not in package.members 

461 else: 

462 assert "foo" in package.members 

463 assert package["foo"].is_alias 

464 assert not package["foo"].resolved 

465 

466 

467def test_forcing_inspection() -> None: 

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

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

470 with ( 

471 temporary_visited_package("static_pkg", modules) as static_package, 

472 temporary_inspected_package("dynamic_pkg", modules) as dynamic_package, 

473 ): 

474 for name in static_package.members: 

475 assert name in dynamic_package.members 

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

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

478 

479 

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

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

482 

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

484 raise ModuleNotFoundError 

485 

486 loader = GriffeLoader() 

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

488 assert loader.load("griffe") 

489 

490 

491def test_not_calling_package_hook_on_something_else_than_package() -> None: 

492 """Always call the `on_package` hook on a package, not any other object.""" 

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

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

495 alias: Alias = loader.load("pkg.L") # ty:ignore[invalid-assignment] 

496 assert alias.is_alias 

497 assert not alias.resolved 

498 

499 

500def test_not_overriding_module_with_alias_from_wildcard_import() -> None: 

501 """Do not override a submodule with an imported object with the same name.""" 

502 with temporary_visited_package( 

503 "pkg", 

504 { 

505 "__init__.py": "", 

506 "a/__init__.py": "from .m import *", 

507 "a/m.py": "def m(): pass", 

508 "b/__init__.py": "from .m import *", 

509 "b/m.py": "from pkg.a.m import m", 

510 }, 

511 resolve_aliases=True, 

512 ) as pkg: 

513 assert pkg["a.m"].is_module 

514 assert pkg["a.m.m"].is_function 

515 assert pkg["b.m"].is_module 

516 assert pkg["b.m.m"].is_alias 

517 

518 

519def test_loading_utf8_with_bom_files(tmp_path: Path) -> None: 

520 """Check that the loader can handle UTF-8 files with BOM.""" 

521 pkg = tmp_path / "pkg" 

522 pkg.mkdir() 

523 init_file = pkg / "__init__.py" 

524 init_file.write_text("\ufeff# This is a UTF-8 file with BOM\n\ndef func() -> int: ...", encoding="utf-8") 

525 loader = GriffeLoader(search_paths=[tmp_path]) 

526 package = loader.load("pkg") 

527 assert "func" in package.members # Just checking all went well, no SyntaxError exceptions raised. 

528 

529 

530def test_deferred_finder(tmp_path: Path) -> None: 

531 """Check that the deferred finder works as expected.""" 

532 ns = tmp_path / "ns" 

533 l1 = GriffeLoader(search_paths=[tmp_path]) 

534 ns.mkdir(exist_ok=False) 

535 l2 = GriffeLoader(search_paths=[tmp_path]) 

536 l1_result = l1.load("ns") 

537 l2_result = l2.load("ns") 

538 assert l1_result.as_dict() == l2_result.as_dict()