Coverage for tests / test_finder.py: 99.36%
137 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 `finder` module."""
3from __future__ import annotations
5import os
6from pathlib import Path
7from textwrap import dedent
9import pytest
11from griffe import Module, ModuleFinder, NamespacePackage, Package, temporary_pypackage
12from griffe._internal.finder import _handle_editable_module, _handle_pth_file
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.
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
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.
60 Parameters:
61 statement: The statement in the `__init__` module allowing to mark the package as namespace.
62 """
63 with (
64 temporary_pypackage("namespace/package1") as tmp_package1,
65 temporary_pypackage("namespace/package2") as tmp_package2,
66 ):
67 tmp_package1.path.parent.joinpath("__init__.py").write_text(statement, encoding="utf8")
68 tmp_package2.path.parent.joinpath("__init__.py").write_text(statement, encoding="utf8")
69 finder = ModuleFinder(search_paths=[tmp_package1.tmpdir, tmp_package2.tmpdir])
70 _, package = finder.find_spec("namespace")
71 assert package.name == "namespace"
72 assert isinstance(package, NamespacePackage)
73 assert package.path == [tmp_package1.path.parent, tmp_package2.path.parent]
76def test_pth_file_handling(tmp_path: Path) -> None:
77 """Assert .pth files are correctly handled.
79 Parameters:
80 tmp_path: Pytest fixture.
81 """
82 pth_file = tmp_path / "hello.pth"
83 pth_file.write_text(
84 dedent(
85 """
86 # comment
88 import thing
89 import\tthing
90 /doesnotexist
91 tests
92 """,
93 ),
94 encoding="utf8",
95 )
96 paths = [sp.path for sp in _handle_pth_file(pth_file)]
97 assert paths == [Path("tests")]
100def test_pth_file_handling_with_semi_colon(tmp_path: Path) -> None:
101 """Assert .pth files are correctly handled.
103 Parameters:
104 tmp_path: Pytest fixture.
105 """
106 pth_file = tmp_path / "hello.pth"
107 pth_file.write_text(
108 dedent(
109 """
110 # comment
111 import thing; import\tthing; /doesnotexist; tests
112 """,
113 ),
114 encoding="utf8",
115 )
116 paths = [sp.path for sp in _handle_pth_file(pth_file)]
117 assert paths == [Path("tests")]
120@pytest.mark.parametrize("editable_file_name", ["__editables_whatever.py", "_editable_impl_whatever.py"])
121def test_editables_file_handling(tmp_path: Path, editable_file_name: str) -> None:
122 """Assert editable modules by `editables` are handled.
124 Parameters:
125 tmp_path: Pytest fixture.
126 """
127 pth_file = tmp_path / editable_file_name
128 pth_file.write_text("hello\nF.map_module('griffe', 'packages/griffelib/src/griffe/__init__.py')", encoding="utf8")
129 paths = [sp.path for sp in _handle_editable_module(pth_file)]
130 assert paths == [Path("packages/griffelib/src")]
133@pytest.mark.parametrize("annotation", ["", ": dict[str, str]"])
134def test_setuptools_file_handling(tmp_path: Path, annotation: str) -> None:
135 """Assert editable modules by `setuptools` are handled.
137 Parameters:
138 tmp_path: Pytest fixture.
139 annotation: The type annotation of the MAPPING variable.
140 """
141 pth_file = tmp_path / "__editable__whatever.py"
142 pth_file.write_text(f"hello\nMAPPING{annotation} = {{'griffe': 'src/griffe'}}", encoding="utf8")
143 paths = [sp.path for sp in _handle_editable_module(pth_file)]
144 assert paths == [Path("src")]
147@pytest.mark.parametrize("annotation", ["", ": dict[str, str]"])
148def test_setuptools_file_handling_multiple_paths(tmp_path: Path, annotation: str) -> None:
149 """Assert editable modules by `setuptools` are handled when multiple packages are installed in the same editable.
151 Parameters:
152 tmp_path: Pytest fixture.
153 annotation: The type annotation of the MAPPING variable.
154 """
155 pth_file = tmp_path / "__editable__whatever.py"
156 pth_file.write_text(
157 "hello=1\n"
158 f"MAPPING{annotation} = {{\n'griffe':\n 'src1/griffe', 'briffe':'src2/briffe'}}\n"
159 "def printer():\n print(hello)",
160 encoding="utf8",
161 )
162 paths = [sp.path for sp in _handle_editable_module(pth_file)]
163 assert paths == [Path("src1"), Path("src2")]
166def test_scikit_build_core_file_handling(tmp_path: Path) -> None:
167 """Assert editable modules by `scikit-build-core` are handled.
169 Parameters:
170 tmp_path: Pytest fixture.
171 """
172 pth_file = tmp_path / "_whatever_editable.py"
173 pth_file.write_text(
174 "hello=1\ninstall({'whatever': '/path/to/whatever'}, {'whatever.else': '/else'}, None, False, True)",
175 encoding="utf8",
176 )
177 # The second dict is not handled: scikit-build-core puts these files
178 # in a location that Griffe won't be able to discover anyway
179 # (they don't respect standard package or namespace package layouts,
180 # and rely on dynamic meta path finder stuff).
181 paths = [sp.path for sp in _handle_editable_module(pth_file)]
182 assert paths == [Path("/path/to/whatever")]
185def test_meson_python_file_handling(tmp_path: Path) -> None:
186 """Assert editable modules by `meson-python` are handled.
188 Parameters:
189 tmp_path: Pytest fixture.
190 """
191 pth_file = tmp_path / "_whatever_editable_loader.py"
192 pth_file.write_text(
193 # The path in argument 2 suffixed with src must exist, so we pass `packages/griffelib`.
194 "hello=1\ninstall({'griffe', 'hello'}, 'packages/griffelib', ['/tmp/ninja'], False)",
195 encoding="utf8",
196 )
197 search_paths = _handle_editable_module(pth_file)
198 assert all(sp.always_scan_for == "griffe" for sp in search_paths)
199 paths = [sp.path for sp in search_paths]
200 assert paths == [Path("packages/griffelib/src")]
203@pytest.mark.parametrize(
204 ("first", "second", "find_stubs", "expect"),
205 [
206 ("package", "stubs", True, "both"),
207 ("stubs", "package", True, "both"),
208 ("package", None, True, "package"),
209 (None, "package", True, "package"),
210 ("stubs", None, True, "stubs"),
211 (None, "stubs", True, "stubs"),
212 (None, None, True, "none"),
213 ("package", "stubs", False, "package"),
214 ("stubs", "package", False, "package"),
215 ("package", None, False, "package"),
216 (None, "package", False, "package"),
217 ("stubs", None, False, "none"),
218 (None, "stubs", False, "none"),
219 (None, None, False, "none"),
220 ],
221)
222def test_finding_stubs_packages(
223 tmp_path: Path,
224 first: str | None,
225 second: str | None,
226 find_stubs: bool,
227 expect: str,
228) -> None:
229 """Find stubs-only packages.
231 Parameters:
232 tmp_path: Pytest fixture.
233 """
234 search_path1 = tmp_path / "sp1"
235 search_path2 = tmp_path / "sp2"
236 search_path1.mkdir()
237 search_path2.mkdir()
239 if first == "package":
240 package = search_path1 / "package"
241 package.mkdir()
242 package.joinpath("__init__.py").touch()
243 elif first == "stubs":
244 stubs = search_path1 / "package-stubs"
245 stubs.mkdir()
246 stubs.joinpath("__init__.pyi").touch()
248 if second == "package":
249 package = search_path2 / "package"
250 package.mkdir()
251 package.joinpath("__init__.py").touch()
252 elif second == "stubs":
253 stubs = search_path2 / "package-stubs"
254 stubs.mkdir()
255 stubs.joinpath("__init__.pyi").touch()
257 finder = ModuleFinder([search_path1, search_path2])
259 if expect == "none":
260 with pytest.raises(ModuleNotFoundError):
261 finder.find_spec("package", try_relative_path=False, find_stubs_package=find_stubs)
262 return
264 name, result = finder.find_spec("package", try_relative_path=False, find_stubs_package=find_stubs)
265 assert name == "package"
267 if expect == "both":
268 assert isinstance(result, Package)
269 assert result.path.suffix == ".py"
270 assert not result.path.parent.name.endswith("-stubs")
271 assert result.stubs
272 assert result.stubs.suffix == ".pyi"
273 assert result.stubs.parent.name.endswith("-stubs")
274 elif expect == "package":
275 assert isinstance(result, Package)
276 assert result.path.suffix == ".py"
277 assert not result.path.parent.name.endswith("-stubs")
278 assert result.stubs is None
279 elif expect == "stubs": 279 ↛ exitline 279 didn't return from function 'test_finding_stubs_packages' because the condition on line 279 was always true
280 assert isinstance(result, Package)
281 assert result.path.suffix == ".pyi"
282 assert result.path.parent.name.endswith("-stubs")
283 assert result.stubs is None
286@pytest.mark.parametrize("namespace_package", [False, True])
287def test_scanning_package_and_module_with_same_names(namespace_package: bool) -> None:
288 """The finder correctly scans package and module having same the name.
290 Parameters:
291 namespace_package: Whether the temporary package is a namespace one.
292 """
293 init = not namespace_package
294 with temporary_pypackage("pkg", ["pkg/mod.py", "mod/mod.py"], init=init, inits=init) as tmp_package:
295 # Here we must make sure that all paths are relative
296 # to correctly assert the finder's behavior,
297 # so we pass `.` and actually enter the temporary directory.
298 path = Path(tmp_package.name)
299 filepath: Path | list[Path] = [path] if namespace_package else path
300 old = Path.cwd()
301 os.chdir(tmp_package.path.parent)
302 try:
303 finder = ModuleFinder(search_paths=[])
304 found = [path for _, path in finder.submodules(Module("pkg", filepath=filepath))]
305 finally:
306 os.chdir(old)
307 check = (
308 path / "pkg/mod.py",
309 path / "mod/mod.py",
310 )
311 for mod in check:
312 assert mod in found
315def test_not_finding_namespace_package_twice() -> None:
316 """Deduplicate paths when finding namespace packages."""
317 with temporary_pypackage("pkg", ["pkg/mod.py", "mod/mod.py"], init=False, inits=False) as tmp_package:
318 old = Path.cwd()
319 os.chdir(tmp_package.tmpdir)
320 try:
321 finder = ModuleFinder(search_paths=[Path(), tmp_package.tmpdir])
322 found = finder.find_package("pkg")
323 finally:
324 os.chdir(old)
325 assert isinstance(found, NamespacePackage)
326 assert len(found.path) == 1