Coverage for tests/test_finder.py: 99.36%
137 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 `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 (
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)
68 tmp_package2.path.parent.joinpath("__init__.py").write_text(statement)
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 )
95 paths = [sp.path for sp in _handle_pth_file(pth_file)]
96 assert paths == [Path("tests")]
99def test_pth_file_handling_with_semi_colon(tmp_path: Path) -> None:
100 """Assert .pth files are correctly handled.
102 Parameters:
103 tmp_path: Pytest fixture.
104 """
105 pth_file = tmp_path / "hello.pth"
106 pth_file.write_text(
107 dedent(
108 """
109 # comment
110 import thing; import\tthing; /doesnotexist; tests
111 """,
112 ),
113 )
114 paths = [sp.path for sp in _handle_pth_file(pth_file)]
115 assert paths == [Path("tests")]
118@pytest.mark.parametrize("editable_file_name", ["__editables_whatever.py", "_editable_impl_whatever.py"])
119def test_editables_file_handling(tmp_path: Path, editable_file_name: str) -> None:
120 """Assert editable modules by `editables` are handled.
122 Parameters:
123 tmp_path: Pytest fixture.
124 """
125 pth_file = tmp_path / editable_file_name
126 pth_file.write_text("hello\nF.map_module('griffe', 'src/griffe/__init__.py')")
127 paths = [sp.path for sp in _handle_editable_module(pth_file)]
128 assert paths == [Path("src")]
131@pytest.mark.parametrize("annotation", ["", ": dict[str, str]"])
132def test_setuptools_file_handling(tmp_path: Path, annotation: str) -> None:
133 """Assert editable modules by `setuptools` are handled.
135 Parameters:
136 tmp_path: Pytest fixture.
137 annotation: The type annotation of the MAPPING variable.
138 """
139 pth_file = tmp_path / "__editable__whatever.py"
140 pth_file.write_text(f"hello\nMAPPING{annotation} = { 'griffe': 'src/griffe'} ")
141 paths = [sp.path for sp in _handle_editable_module(pth_file)]
142 assert paths == [Path("src")]
145@pytest.mark.parametrize("annotation", ["", ": dict[str, str]"])
146def test_setuptools_file_handling_multiple_paths(tmp_path: Path, annotation: str) -> None:
147 """Assert editable modules by `setuptools` are handled when multiple packages are installed in the same editable.
149 Parameters:
150 tmp_path: Pytest fixture.
151 annotation: The type annotation of the MAPPING variable.
152 """
153 pth_file = tmp_path / "__editable__whatever.py"
154 pth_file.write_text(
155 "hello=1\n"
156 f"MAPPING{annotation} = { \n'griffe':\n 'src1/griffe', 'briffe':'src2/briffe'} \n"
157 "def printer():\n print(hello)",
158 )
159 paths = [sp.path for sp in _handle_editable_module(pth_file)]
160 assert paths == [Path("src1"), Path("src2")]
163def test_scikit_build_core_file_handling(tmp_path: Path) -> None:
164 """Assert editable modules by `scikit-build-core` are handled.
166 Parameters:
167 tmp_path: Pytest fixture.
168 """
169 pth_file = tmp_path / "_whatever_editable.py"
170 pth_file.write_text(
171 "hello=1\ninstall({'whatever': '/path/to/whatever'}, {'whatever.else': '/else'}, None, False, True)",
172 )
173 # the second dict is not handled: scikit-build-core puts these files
174 # in a location that Griffe won't be able to discover anyway
175 # (they don't respect standard package or namespace package layouts,
176 # and rely on dynamic meta path finder stuff)
177 paths = [sp.path for sp in _handle_editable_module(pth_file)]
178 assert paths == [Path("/path/to/whatever")]
181def test_meson_python_file_handling(tmp_path: Path) -> None:
182 """Assert editable modules by `meson-python` are handled.
184 Parameters:
185 tmp_path: Pytest fixture.
186 """
187 pth_file = tmp_path / "_whatever_editable_loader.py"
188 pth_file.write_text(
189 # the path in argument 2 suffixed with src must exist, so we pass '.'
190 "hello=1\ninstall({'griffe', 'hello'}, '.', ['/tmp/ninja'], False)",
191 )
192 search_paths = _handle_editable_module(pth_file)
193 assert all(sp.always_scan_for in {"griffe", "_griffe"} for sp in search_paths)
194 paths = [sp.path for sp in search_paths]
195 assert paths == [Path("src")]
198@pytest.mark.parametrize(
199 ("first", "second", "find_stubs", "expect"),
200 [
201 ("package", "stubs", True, "both"),
202 ("stubs", "package", True, "both"),
203 ("package", None, True, "package"),
204 (None, "package", True, "package"),
205 ("stubs", None, True, "stubs"),
206 (None, "stubs", True, "stubs"),
207 (None, None, True, "none"),
208 ("package", "stubs", False, "package"),
209 ("stubs", "package", False, "package"),
210 ("package", None, False, "package"),
211 (None, "package", False, "package"),
212 ("stubs", None, False, "none"),
213 (None, "stubs", False, "none"),
214 (None, None, False, "none"),
215 ],
216)
217def test_finding_stubs_packages(
218 tmp_path: Path,
219 first: str | None,
220 second: str | None,
221 find_stubs: bool,
222 expect: str,
223) -> None:
224 """Find stubs-only packages.
226 Parameters:
227 tmp_path: Pytest fixture.
228 """
229 search_path1 = tmp_path / "sp1"
230 search_path2 = tmp_path / "sp2"
231 search_path1.mkdir()
232 search_path2.mkdir()
234 if first == "package":
235 package = search_path1 / "package"
236 package.mkdir()
237 package.joinpath("__init__.py").touch()
238 elif first == "stubs":
239 stubs = search_path1 / "package-stubs"
240 stubs.mkdir()
241 stubs.joinpath("__init__.pyi").touch()
243 if second == "package":
244 package = search_path2 / "package"
245 package.mkdir()
246 package.joinpath("__init__.py").touch()
247 elif second == "stubs":
248 stubs = search_path2 / "package-stubs"
249 stubs.mkdir()
250 stubs.joinpath("__init__.pyi").touch()
252 finder = ModuleFinder([search_path1, search_path2])
254 if expect == "none":
255 with pytest.raises(ModuleNotFoundError):
256 finder.find_spec("package", try_relative_path=False, find_stubs_package=find_stubs)
257 return
259 name, result = finder.find_spec("package", try_relative_path=False, find_stubs_package=find_stubs)
260 assert name == "package"
262 if expect == "both":
263 assert isinstance(result, Package)
264 assert result.path.suffix == ".py"
265 assert not result.path.parent.name.endswith("-stubs")
266 assert result.stubs
267 assert result.stubs.suffix == ".pyi"
268 assert result.stubs.parent.name.endswith("-stubs")
269 elif expect == "package":
270 assert isinstance(result, Package)
271 assert result.path.suffix == ".py"
272 assert not result.path.parent.name.endswith("-stubs")
273 assert result.stubs is None
274 elif expect == "stubs": 274 ↛ exitline 274 didn't return from function 'test_finding_stubs_packages' because the condition on line 274 was always true
275 assert isinstance(result, Package)
276 assert result.path.suffix == ".pyi"
277 assert result.path.parent.name.endswith("-stubs")
278 assert result.stubs is None
281@pytest.mark.parametrize("namespace_package", [False, True])
282def test_scanning_package_and_module_with_same_names(namespace_package: bool) -> None:
283 """The finder correctly scans package and module having same the name.
285 Parameters:
286 namespace_package: Whether the temporary package is a namespace one.
287 """
288 init = not namespace_package
289 with temporary_pypackage("pkg", ["pkg/mod.py", "mod/mod.py"], init=init, inits=init) as tmp_package:
290 # Here we must make sure that all paths are relative
291 # to correctly assert the finder's behavior,
292 # so we pass `.` and actually enter the temporary directory.
293 path = Path(tmp_package.name)
294 filepath: Path | list[Path] = [path] if namespace_package else path
295 old = Path.cwd()
296 os.chdir(tmp_package.path.parent)
297 try:
298 finder = ModuleFinder(search_paths=[])
299 found = [path for _, path in finder.submodules(Module("pkg", filepath=filepath))]
300 finally:
301 os.chdir(old)
302 check = (
303 path / "pkg/mod.py",
304 path / "mod/mod.py",
305 )
306 for mod in check:
307 assert mod in found
310def test_not_finding_namespace_package_twice() -> None:
311 """Deduplicate paths when finding namespace packages."""
312 with temporary_pypackage("pkg", ["pkg/mod.py", "mod/mod.py"], init=False, inits=False) as tmp_package:
313 old = Path.cwd()
314 os.chdir(tmp_package.tmpdir)
315 try:
316 finder = ModuleFinder(search_paths=[Path(), tmp_package.tmpdir])
317 found = finder.find_package("pkg")
318 finally:
319 os.chdir(old)
320 assert isinstance(found, NamespacePackage)
321 assert len(found.path) == 1