Coverage for src/griffe/_internal/tests.py: 91.55%
114 statements
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-11 13:44 +0200
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-11 13:44 +0200
1# This module contains helpers. They simplify programmatic use of Griffe,
2# for example to load data from strings or to create temporary packages.
3# They are particularly useful for our own tests suite.
5from __future__ import annotations
7import sys
8import tempfile
9from contextlib import contextmanager
10from dataclasses import dataclass
11from importlib import invalidate_caches
12from pathlib import Path
13from textwrap import dedent
14from typing import TYPE_CHECKING, Any
16from griffe._internal.agents.inspector import inspect
17from griffe._internal.agents.visitor import visit
18from griffe._internal.collections import LinesCollection
19from griffe._internal.loader import load
20from griffe._internal.models import Module, Object
22if TYPE_CHECKING:
23 from collections.abc import Iterator, Mapping, Sequence
25 from griffe._internal.collections import ModulesCollection
26 from griffe._internal.docstrings.parsers import DocstringStyle
27 from griffe._internal.enumerations import Parser
28 from griffe._internal.extensions.base import Extensions
30_TMPDIR_PREFIX = "griffe_"
33@dataclass
34class TmpPackage:
35 """A temporary package.
37 The `tmpdir` and `path` parameters can be passed as relative path.
38 They will be resolved to absolute paths after initialization.
39 """
41 tmpdir: Path
42 """The temporary directory containing the package."""
43 name: str
44 """The package name, as to dynamically import it."""
45 path: Path
46 """The package path."""
48 def __post_init__(self) -> None:
49 self.tmpdir = self.tmpdir.resolve()
50 self.path = self.path.resolve()
53@contextmanager
54def temporary_pyfile(code: str, *, module_name: str = "module") -> Iterator[tuple[str, Path]]:
55 """Create a Python file containing the given code in a temporary directory.
57 Parameters:
58 code: The code to write to the temporary file.
59 module_name: The name of the temporary module.
61 Yields:
62 module_name: The module name, as to dynamically import it.
63 module_path: The module path.
64 """
65 with tempfile.TemporaryDirectory(prefix=_TMPDIR_PREFIX) as tmpdir:
66 tmpfile = Path(tmpdir) / f"{module_name}.py"
67 tmpfile.write_text(dedent(code))
68 yield module_name, tmpfile
71@contextmanager
72def temporary_pypackage(
73 package: str,
74 modules: Sequence[str] | Mapping[str, str] | None = None,
75 *,
76 init: bool = True,
77 inits: bool = True,
78) -> Iterator[TmpPackage]:
79 """Create a package containing the given modules in a temporary directory.
81 Parameters:
82 package: The package name. Example: `"a"` gives
83 a package named `a`, while `"a/b"` gives a namespace package
84 named `a` with a package inside named `b`.
85 If `init` is false, then `b` is also a namespace package.
86 modules: Additional modules to create in the package.
87 If a list, simply touch the files: `["b.py", "c/d.py", "e/f"]`.
88 If a dict, keys are the file names and values their contents:
89 `{"b.py": "b = 1", "c/d.py": "print('hey from c')"}`.
90 init: Whether to create an `__init__` module in the top package.
91 inits: Whether to create `__init__` modules in subpackages.
93 Yields:
94 A temporary package.
95 """
96 modules = modules or {}
97 if isinstance(modules, list):
98 modules = dict.fromkeys(modules, "")
99 mkdir_kwargs = {"parents": True, "exist_ok": True}
100 with tempfile.TemporaryDirectory(prefix=_TMPDIR_PREFIX) as tmpdir:
101 tmpdirpath = Path(tmpdir)
102 package_name = ".".join(Path(package).parts)
103 package_path = tmpdirpath / package
104 package_path.mkdir(**mkdir_kwargs)
105 if init:
106 package_path.joinpath("__init__.py").touch()
107 for module_name, module_contents in modules.items(): # type: ignore[union-attr]
108 current_path = package_path
109 for part in Path(module_name).parts:
110 if part.endswith((".py", ".pyi")):
111 current_path.joinpath(part).write_text(dedent(module_contents))
112 else:
113 current_path /= part
114 current_path.mkdir(**mkdir_kwargs)
115 if inits:
116 current_path.joinpath("__init__.py").touch()
117 yield TmpPackage(tmpdirpath, package_name, package_path)
120@contextmanager
121def temporary_visited_package(
122 package: str,
123 modules: Sequence[str] | Mapping[str, str] | None = None,
124 *,
125 init: bool = True,
126 inits: bool = True,
127 extensions: Extensions | None = None,
128 docstring_parser: DocstringStyle | Parser | None = None,
129 docstring_options: dict[str, Any] | None = None,
130 lines_collection: LinesCollection | None = None,
131 modules_collection: ModulesCollection | None = None,
132 allow_inspection: bool = False,
133 store_source: bool = True,
134 resolve_aliases: bool = False,
135 resolve_external: bool | None = None,
136 resolve_implicit: bool = False,
137 search_sys_path: bool = False,
138) -> Iterator[Module]:
139 """Create and visit a temporary package.
141 Parameters:
142 package: The package name. Example: `"a"` gives
143 a package named `a`, while `"a/b"` gives a namespace package
144 named `a` with a package inside named `b`.
145 If `init` is false, then `b` is also a namespace package.
146 modules: Additional modules to create in the package.
147 If a list, simply touch the files: `["b.py", "c/d.py", "e/f"]`.
148 If a dict, keys are the file names and values their contents:
149 `{"b.py": "b = 1", "c/d.py": "print('hey from c')"}`.
150 init: Whether to create an `__init__` module in the top package.
151 inits: Whether to create `__init__` modules in subpackages.
152 extensions: The extensions to use.
153 docstring_parser: The docstring parser to use. By default, no parsing is done.
154 docstring_options: Additional docstring parsing options.
155 lines_collection: A collection of source code lines.
156 modules_collection: A collection of modules.
157 allow_inspection: Whether to allow inspecting modules when visiting them is not possible.
158 store_source: Whether to store code source in the lines collection.
159 resolve_aliases: Whether to resolve aliases.
160 resolve_external: Whether to try to load unspecified modules to resolve aliases.
161 Default value (`None`) means to load external modules only if they are the private sibling
162 or the origin module (for example when `ast` imports from `_ast`).
163 resolve_implicit: When false, only try to resolve an alias if it is explicitly exported.
164 search_sys_path: Whether to search the system paths for the package.
166 Yields:
167 A module.
168 """
169 search_paths = sys.path if search_sys_path else []
170 with temporary_pypackage(package, modules, init=init, inits=inits) as tmp_package:
171 yield load( # type: ignore[misc]
172 tmp_package.name,
173 search_paths=[tmp_package.tmpdir, *search_paths],
174 extensions=extensions,
175 docstring_parser=docstring_parser,
176 docstring_options=docstring_options,
177 lines_collection=lines_collection,
178 modules_collection=modules_collection,
179 allow_inspection=allow_inspection,
180 store_source=store_source,
181 resolve_aliases=resolve_aliases,
182 resolve_external=resolve_external,
183 resolve_implicit=resolve_implicit,
184 force_inspection=False,
185 )
188@contextmanager
189def temporary_inspected_package(
190 package: str,
191 modules: Sequence[str] | Mapping[str, str] | None = None,
192 *,
193 init: bool = True,
194 inits: bool = True,
195 extensions: Extensions | None = None,
196 docstring_parser: DocstringStyle | Parser | None = None,
197 docstring_options: dict[str, Any] | None = None,
198 lines_collection: LinesCollection | None = None,
199 modules_collection: ModulesCollection | None = None,
200 allow_inspection: bool = True,
201 store_source: bool = True,
202 resolve_aliases: bool = False,
203 resolve_external: bool | None = None,
204 resolve_implicit: bool = False,
205 search_sys_path: bool = False,
206) -> Iterator[Module]:
207 """Create and inspect a temporary package.
209 Parameters:
210 package: The package name. Example: `"a"` gives
211 a package named `a`, while `"a/b"` gives a namespace package
212 named `a` with a package inside named `b`.
213 If `init` is false, then `b` is also a namespace package.
214 modules: Additional modules to create in the package.
215 If a list, simply touch the files: `["b.py", "c/d.py", "e/f"]`.
216 If a dict, keys are the file names and values their contents:
217 `{"b.py": "b = 1", "c/d.py": "print('hey from c')"}`.
218 init: Whether to create an `__init__` module in the top package.
219 inits: Whether to create `__init__` modules in subpackages.
220 extensions: The extensions to use.
221 docstring_parser: The docstring parser to use. By default, no parsing is done.
222 docstring_options: Additional docstring parsing options.
223 lines_collection: A collection of source code lines.
224 modules_collection: A collection of modules.
225 allow_inspection: Whether to allow inspecting modules.
226 store_source: Whether to store code source in the lines collection.
227 resolve_aliases: Whether to resolve aliases.
228 resolve_external: Whether to try to load unspecified modules to resolve aliases.
229 Default value (`None`) means to load external modules only if they are the private sibling
230 or the origin module (for example when `ast` imports from `_ast`).
231 resolve_implicit: When false, only try to resolve an alias if it is explicitly exported.
232 search_sys_path: Whether to search the system paths for the package.
234 Yields:
235 A module.
236 """
237 search_paths = sys.path if search_sys_path else []
238 with temporary_pypackage(package, modules, init=init, inits=inits) as tmp_package:
239 try:
240 yield load( # type: ignore[misc]
241 tmp_package.name,
242 search_paths=[tmp_package.tmpdir, *search_paths],
243 extensions=extensions,
244 docstring_parser=docstring_parser,
245 docstring_options=docstring_options,
246 lines_collection=lines_collection,
247 modules_collection=modules_collection,
248 allow_inspection=allow_inspection,
249 store_source=store_source,
250 resolve_aliases=resolve_aliases,
251 resolve_external=resolve_external,
252 resolve_implicit=resolve_implicit,
253 force_inspection=True,
254 )
255 finally:
256 for name in tuple(sys.modules.keys()):
257 if name == package or name.startswith(f"{package}."):
258 sys.modules.pop(name, None)
259 invalidate_caches()
262@contextmanager
263def temporary_visited_module(
264 code: str,
265 *,
266 module_name: str = "module",
267 extensions: Extensions | None = None,
268 parent: Module | None = None,
269 docstring_parser: DocstringStyle | Parser | None = None,
270 docstring_options: dict[str, Any] | None = None,
271 lines_collection: LinesCollection | None = None,
272 modules_collection: ModulesCollection | None = None,
273) -> Iterator[Module]:
274 """Create and visit a temporary module with the given code.
276 Parameters:
277 code: The code of the module.
278 module_name: The name of the temporary module.
279 extensions: The extensions to use when visiting the AST.
280 parent: The optional parent of this module.
281 docstring_parser: The docstring parser to use. By default, no parsing is done.
282 docstring_options: Additional docstring parsing options.
283 lines_collection: A collection of source code lines.
284 modules_collection: A collection of modules.
286 Yields:
287 The visited module.
288 """
289 code = dedent(code)
290 with temporary_pyfile(code, module_name=module_name) as (_, path):
291 lines_collection = lines_collection or LinesCollection()
292 lines_collection[path] = code.splitlines()
293 module = visit(
294 module_name,
295 filepath=path,
296 code=code,
297 extensions=extensions,
298 parent=parent,
299 docstring_parser=docstring_parser,
300 docstring_options=docstring_options,
301 lines_collection=lines_collection,
302 modules_collection=modules_collection,
303 )
304 module.modules_collection[module_name] = module
305 yield module
308@contextmanager
309def temporary_inspected_module(
310 code: str,
311 *,
312 module_name: str = "module",
313 import_paths: list[Path] | None = None,
314 extensions: Extensions | None = None,
315 parent: Module | None = None,
316 docstring_parser: DocstringStyle | Parser | None = None,
317 docstring_options: dict[str, Any] | None = None,
318 lines_collection: LinesCollection | None = None,
319 modules_collection: ModulesCollection | None = None,
320) -> Iterator[Module]:
321 """Create and inspect a temporary module with the given code.
323 Parameters:
324 code: The code of the module.
325 module_name: The name of the temporary module.
326 import_paths: Paths to import the module from.
327 extensions: The extensions to use when visiting the AST.
328 parent: The optional parent of this module.
329 docstring_parser: The docstring parser to use. By default, no parsing is done.
330 docstring_options: Additional docstring parsing options.
331 lines_collection: A collection of source code lines.
332 modules_collection: A collection of modules.
334 Yields:
335 The inspected module.
336 """
337 with temporary_pyfile(code, module_name=module_name) as (_, path):
338 lines_collection = lines_collection or LinesCollection()
339 lines_collection[path] = code.splitlines()
340 try:
341 module = inspect(
342 module_name,
343 filepath=path,
344 import_paths=import_paths,
345 extensions=extensions,
346 parent=parent,
347 docstring_parser=docstring_parser,
348 docstring_options=docstring_options,
349 lines_collection=lines_collection,
350 modules_collection=modules_collection,
351 )
352 module.modules_collection[module_name] = module
353 yield module
354 finally:
355 if module_name in sys.modules:
356 del sys.modules[module_name]
357 invalidate_caches()
360def vtree(*objects: Object, return_leaf: bool = False) -> Object:
361 """Link objects together, vertically.
363 Parameters:
364 *objects: A sequence of objects. The first one is at the top of the tree.
365 return_leaf: Whether to return the leaf instead of the root.
367 Raises:
368 ValueError: When no objects are provided.
370 Returns:
371 The top or leaf object.
372 """
373 if not objects: 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true
374 raise ValueError("At least one object must be provided")
375 top = objects[0]
376 leaf = top
377 for obj in objects[1:]:
378 leaf.set_member(obj.name, obj)
379 leaf = obj
380 return leaf if return_leaf else top
383def htree(*objects: Object) -> Object:
384 """Link objects together, horizontally.
386 Parameters:
387 *objects: A sequence of objects. All objects starting at the second become members of the first.
389 Raises:
390 ValueError: When no objects are provided.
392 Returns:
393 The first given object, with all the other objects as members of it.
394 """
395 if not objects:
396 raise ValueError("At least one object must be provided")
397 top = objects[0]
398 for obj in objects[1:]:
399 top.set_member(obj.name, obj)
400 return top
403def module_vtree(path: str, *, leaf_package: bool = True, return_leaf: bool = False) -> Module:
404 """Link objects together, vertically.
406 Parameters:
407 path: The complete module path, like `"a.b.c.d"`.
408 leaf_package: Whether the deepest module should also be a package.
409 return_leaf: Whether to return the leaf instead of the root.
411 Raises:
412 ValueError: When no objects are provided.
414 Returns:
415 The top or leaf module.
416 """
417 parts = path.split(".")
418 modules = [Module(name, filepath=Path(*parts[:index], "__init__.py")) for index, name in enumerate(parts)]
419 if not leaf_package:
420 filepath = modules[-1].filepath.with_stem(parts[-1]) # type: ignore[union-attr]
421 modules[-1]._filepath = filepath
422 return vtree(*modules, return_leaf=return_leaf) # type: ignore[return-value]