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