Coverage for tests/test_loader.py: 100.00%
268 statements
« prev ^ index » next coverage.py v7.6.2, created at 2024-10-12 01:34 +0200
« prev ^ index » next coverage.py v7.6.2, created at 2024-10-12 01:34 +0200
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'")
45 mod_b.write_text("from .mod_c import *")
46 mod_a.write_text("from .mod_b import *")
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) # type: ignore[arg-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")
73 mod_b.write_text("from package.mod_c import Base\n__all__ = ['Base']\n")
74 mod_c.write_text("class Base: ...\n")
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")
94 mod_b.write_text("overwritten = 1\nnot_overwritten = 1\n")
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))
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()
140 assert "_rust_notify" in package.members
141 assert "RustNotify" in package.members
142 assert package["RustNotify"].resolved
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
153 class Class:
154 class_attr = True
156 def function1(self, arg1):
157 pass
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
169 CONST: int
171 class Class:
172 class_attr: bool
174 @overload
175 def function1(self, arg1: str) -> Sequence[str]: ...
176 @overload
177 def function1(self, arg1: bytes) -> Sequence[bytes]: ...
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()
187 assert "mod" in package.members
188 mod = package["mod"]
189 assert mod.filepath.suffix == ".py"
191 assert "CONST" in mod.members
192 const = mod["CONST"]
193 assert const.value == "0"
194 assert const.annotation.name == "int"
196 assert "Class" in mod.members
197 class_ = mod["Class"]
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"
204 assert "function1" in class_.members
205 function1 = class_["function1"]
206 assert len(function1.overloads) == 2
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"
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()
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.
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: ...")
235 loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
236 package = loader.load(tmp_package.name)
237 assert "f" in package.members
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.
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: ...")
251 loader = GriffeLoader(search_paths=[tmp_folder.path])
252 package = loader.load("mod")
253 assert "f" in package.members
256def test_unsupported_item_in_all(caplog: pytest.LogCaptureFixture) -> None:
257 """Check that unsupported items in `__all__` log a warning.
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)
271def test_skip_modules_with_dots_in_filename(caplog: pytest.LogCaptureFixture) -> None:
272 """Check that modules with dots in their filenames are skipped.
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)
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
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)
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
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
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
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
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)
336 a_package = loader.load("a")
337 assert "b" in a_package.members
339 b_package = a_package.members["b"]
340 assert "c" in b_package.members
342 c_package = b_package.members["c"]
343 assert "d" in c_package.members
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
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
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
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.
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")
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")
399 # Exposing stubs first, to make sure order doesn't matter.
400 loader = GriffeLoader(search_paths=[stubs_parent, package_parent])
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
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.
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
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.
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
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 (
465 temporary_visited_package("static_pkg", modules) as static_package,
466 temporary_inspected_package("dynamic_pkg", modules) as dynamic_package,
467 ):
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
474def test_relying_on_modules_path_attribute(monkeypatch: pytest.MonkeyPatch) -> None:
475 """Load a package that relies on the `__path__` attribute of a module."""
477 def raise_module_not_found_error(*args, **kwargs) -> None: # noqa: ARG001,ANN002,ANN003
478 raise ModuleNotFoundError
480 loader = GriffeLoader()
481 monkeypatch.setattr(loader.finder, "find_spec", raise_module_not_found_error)
482 assert loader.load("griffe")
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") # type: ignore[assignment]
490 assert alias.is_alias
491 assert not alias.resolved