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
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-11 11:48 +0100
1"""Tests for the `loader` module."""
3from __future__ import annotations
5import logging
6from textwrap import dedent
7from typing import TYPE_CHECKING
9import pytest
11from griffe import (
12 ExprName,
13 GriffeLoader,
14 temporary_inspected_package,
15 temporary_pyfile,
16 temporary_pypackage,
17 temporary_visited_package,
18)
20if TYPE_CHECKING:
21 from pathlib import Path
23 from griffe import Alias
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
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")
48 loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
49 package = loader.load(tmp_package.name)
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
54 assert "CONST_X" not in package.members
55 assert "CONST_Y" not in package.members
57 loader.expand_wildcards(package) # ty:ignore[invalid-argument-type]
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
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"
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")
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"
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"
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")
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"
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
110 __all__ = ['RustNotify']
112 class AbstractEvent(Protocol):
113 def is_set(self) -> bool: ...
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 """
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.
125 `FileNotFoundError` is raised if one of the paths does not exist.
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()
141 assert "_rust_notify" in package.members
142 assert "RustNotify" in package.members
143 assert package["RustNotify"].resolved
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
154 class Class:
155 class_attr = True
157 def function1(self, arg1):
158 pass
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
171 CONST: int
173 class Class:
174 class_attr: bool
176 @overload
177 def function1(self, arg1: str) -> Sequence[str]: ...
178 @overload
179 def function1(self, arg1: bytes) -> Sequence[bytes]: ...
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()
190 assert "mod" in package.members
191 mod = package["mod"]
192 assert mod.filepath.suffix == ".py"
194 assert "CONST" in mod.members
195 const = mod["CONST"]
196 assert const.value == "0"
197 assert const.annotation.name == "int"
199 assert "Class" in mod.members
200 class_ = mod["Class"]
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"
207 assert "function1" in class_.members
208 function1 = class_["function1"]
209 assert len(function1.overloads) == 2
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"
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()
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.
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")
238 loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
239 package = loader.load(tmp_package.name)
240 assert "f" in package.members
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.
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")
254 loader = GriffeLoader(search_paths=[tmp_folder.path])
255 package = loader.load("mod")
256 assert "f" in package.members
259def test_unsupported_item_in_all(caplog: pytest.LogCaptureFixture) -> None:
260 """Check that unsupported items in `__all__` log a warning.
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)
277def test_skip_modules_with_dots_in_filename(caplog: pytest.LogCaptureFixture) -> None:
278 """Check that modules with dots in their filenames are skipped.
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)
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
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)
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
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
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
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
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)
342 a_package = loader.load("a")
343 assert "b" in a_package.members
345 b_package = a_package.members["b"]
346 assert "c" in b_package.members
348 c_package = b_package.members["c"]
349 assert "d" in c_package.members
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
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
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
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.
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")
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")
405 # Exposing stubs first, to make sure order doesn't matter.
406 loader = GriffeLoader(search_paths=[stubs_parent, package_parent])
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
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.
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
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.
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
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
480def test_relying_on_modules_path_attribute(monkeypatch: pytest.MonkeyPatch) -> None:
481 """Load a package that relies on the `__path__` attribute of a module."""
483 def raise_module_not_found_error(*args, **kwargs) -> None: # noqa: ARG001,ANN002,ANN003
484 raise ModuleNotFoundError
486 loader = GriffeLoader()
487 monkeypatch.setattr(loader.finder, "find_spec", raise_module_not_found_error)
488 assert loader.load("griffe")
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
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
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.
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()