Coverage for src/_griffe/tests.py: 93.06%
115 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# 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, Iterator, Mapping, Sequence
16from _griffe.agents.inspector import inspect
17from _griffe.agents.visitor import visit
18from _griffe.collections import LinesCollection
19from _griffe.loader import load
20from _griffe.models import Module, Object
22if TYPE_CHECKING:
23 from _griffe.collections import ModulesCollection
24 from _griffe.enumerations import Parser
25 from _griffe.extensions.base import Extensions
27_TMPDIR_PREFIX = "griffe_"
30@dataclass
31class TmpPackage:
32 """A temporary package.
34 The `tmpdir` and `path` parameters can be passed as relative path.
35 They will be resolved to absolute paths after initialization.
36 """
38 tmpdir: Path
39 """The temporary directory containing the package."""
40 name: str
41 """The package name, as to dynamically import it."""
42 path: Path
43 """The package path."""
45 def __post_init__(self) -> None:
46 self.tmpdir = self.tmpdir.resolve()
47 self.path = self.path.resolve()
50@contextmanager
51def temporary_pyfile(code: str, *, module_name: str = "module") -> Iterator[tuple[str, Path]]:
52 """Create a Python file containing the given code in a temporary directory.
54 Parameters:
55 code: The code to write to the temporary file.
56 module_name: The name of the temporary module.
58 Yields:
59 module_name: The module name, as to dynamically import it.
60 module_path: The module path.
61 """
62 with tempfile.TemporaryDirectory(prefix=_TMPDIR_PREFIX) as tmpdir:
63 tmpfile = Path(tmpdir) / f"{module_name}.py"
64 tmpfile.write_text(dedent(code))
65 yield module_name, tmpfile
68@contextmanager
69def temporary_pypackage(
70 package: str,
71 modules: Sequence[str] | Mapping[str, str] | None = None,
72 *,
73 init: bool = True,
74 inits: bool = True,
75) -> Iterator[TmpPackage]:
76 """Create a package containing the given modules in a temporary directory.
78 Parameters:
79 package: The package name. Example: `"a"` gives
80 a package named `a`, while `"a/b"` gives a namespace package
81 named `a` with a package inside named `b`.
82 If `init` is false, then `b` is also a namespace package.
83 modules: Additional modules to create in the package.
84 If a list, simply touch the files: `["b.py", "c/d.py", "e/f"]`.
85 If a dict, keys are the file names and values their contents:
86 `{"b.py": "b = 1", "c/d.py": "print('hey from c')"}`.
87 init: Whether to create an `__init__` module in the top package.
88 inits: Whether to create `__init__` modules in subpackages.
90 Yields:
91 A temporary package.
92 """
93 modules = modules or {}
94 if isinstance(modules, list):
95 modules = {mod: "" for mod in modules}
96 mkdir_kwargs = {"parents": True, "exist_ok": True}
97 with tempfile.TemporaryDirectory(prefix=_TMPDIR_PREFIX) as tmpdir:
98 tmpdirpath = Path(tmpdir)
99 package_name = ".".join(Path(package).parts)
100 package_path = tmpdirpath / package
101 package_path.mkdir(**mkdir_kwargs)
102 if init:
103 package_path.joinpath("__init__.py").touch()
104 for module_name, module_contents in modules.items(): # type: ignore[union-attr]
105 current_path = package_path
106 for part in Path(module_name).parts:
107 if part.endswith((".py", ".pyi")):
108 current_path.joinpath(part).write_text(dedent(module_contents))
109 else:
110 current_path /= part
111 current_path.mkdir(**mkdir_kwargs)
112 if inits:
113 current_path.joinpath("__init__.py").touch()
114 yield TmpPackage(tmpdirpath, package_name, package_path)
117@contextmanager
118def temporary_visited_package(
119 package: str,
120 modules: Sequence[str] | Mapping[str, str] | None = None,
121 *,
122 init: bool = True,
123 inits: bool = True,
124 extensions: Extensions | None = None,
125 docstring_parser: Parser | None = None,
126 docstring_options: dict[str, Any] | None = None,
127 lines_collection: LinesCollection | None = None,
128 modules_collection: ModulesCollection | None = None,
129 allow_inspection: bool = False,
130 store_source: bool = True,
131 resolve_aliases: bool = False,
132 resolve_external: bool | None = None,
133 resolve_implicit: bool = False,
134) -> Iterator[Module]:
135 """Create and visit a temporary package.
137 Parameters:
138 package: The package name. Example: `"a"` gives
139 a package named `a`, while `"a/b"` gives a namespace package
140 named `a` with a package inside named `b`.
141 If `init` is false, then `b` is also a namespace package.
142 modules: Additional modules to create in the package.
143 If a list, simply touch the files: `["b.py", "c/d.py", "e/f"]`.
144 If a dict, keys are the file names and values their contents:
145 `{"b.py": "b = 1", "c/d.py": "print('hey from c')"}`.
146 init: Whether to create an `__init__` module in the top package.
147 inits: Whether to create `__init__` modules in subpackages.
148 extensions: The extensions to use.
149 docstring_parser: The docstring parser to use. By default, no parsing is done.
150 docstring_options: Additional docstring parsing options.
151 lines_collection: A collection of source code lines.
152 modules_collection: A collection of modules.
153 allow_inspection: Whether to allow inspecting modules when visiting them is not possible.
154 store_source: Whether to store code source in the lines collection.
155 resolve_aliases: Whether to resolve aliases.
156 resolve_external: Whether to try to load unspecified modules to resolve aliases.
157 Default value (`None`) means to load external modules only if they are the private sibling
158 or the origin module (for example when `ast` imports from `_ast`).
159 resolve_implicit: When false, only try to resolve an alias if it is explicitly exported.
161 Yields:
162 A module.
163 """
164 with temporary_pypackage(package, modules, init=init, inits=inits) as tmp_package:
165 yield load( # type: ignore[misc]
166 tmp_package.name,
167 search_paths=[tmp_package.tmpdir],
168 extensions=extensions,
169 docstring_parser=docstring_parser,
170 docstring_options=docstring_options,
171 lines_collection=lines_collection,
172 modules_collection=modules_collection,
173 allow_inspection=allow_inspection,
174 store_source=store_source,
175 resolve_aliases=resolve_aliases,
176 resolve_external=resolve_external,
177 resolve_implicit=resolve_implicit,
178 force_inspection=False,
179 )
182@contextmanager
183def temporary_inspected_package(
184 package: str,
185 modules: Sequence[str] | Mapping[str, str] | None = None,
186 *,
187 init: bool = True,
188 inits: bool = True,
189 extensions: Extensions | None = None,
190 docstring_parser: Parser | None = None,
191 docstring_options: dict[str, Any] | None = None,
192 lines_collection: LinesCollection | None = None,
193 modules_collection: ModulesCollection | None = None,
194 allow_inspection: bool = True,
195 store_source: bool = True,
196 resolve_aliases: bool = False,
197 resolve_external: bool | None = None,
198 resolve_implicit: bool = False,
199) -> Iterator[Module]:
200 """Create and inspect a temporary package.
202 Parameters:
203 package: The package name. Example: `"a"` gives
204 a package named `a`, while `"a/b"` gives a namespace package
205 named `a` with a package inside named `b`.
206 If `init` is false, then `b` is also a namespace package.
207 modules: Additional modules to create in the package.
208 If a list, simply touch the files: `["b.py", "c/d.py", "e/f"]`.
209 If a dict, keys are the file names and values their contents:
210 `{"b.py": "b = 1", "c/d.py": "print('hey from c')"}`.
211 init: Whether to create an `__init__` module in the top package.
212 inits: Whether to create `__init__` modules in subpackages.
213 extensions: The extensions to use.
214 docstring_parser: The docstring parser to use. By default, no parsing is done.
215 docstring_options: Additional docstring parsing options.
216 lines_collection: A collection of source code lines.
217 modules_collection: A collection of modules.
218 allow_inspection: Whether to allow inspecting modules.
219 store_source: Whether to store code source in the lines collection.
220 resolve_aliases: Whether to resolve aliases.
221 resolve_external: Whether to try to load unspecified modules to resolve aliases.
222 Default value (`None`) means to load external modules only if they are the private sibling
223 or the origin module (for example when `ast` imports from `_ast`).
224 resolve_implicit: When false, only try to resolve an alias if it is explicitly exported.
226 Yields:
227 A module.
228 """
229 with temporary_pypackage(package, modules, init=init, inits=inits) as tmp_package:
230 try:
231 yield load( # type: ignore[misc]
232 tmp_package.name,
233 search_paths=[tmp_package.tmpdir],
234 extensions=extensions,
235 docstring_parser=docstring_parser,
236 docstring_options=docstring_options,
237 lines_collection=lines_collection,
238 modules_collection=modules_collection,
239 allow_inspection=allow_inspection,
240 store_source=store_source,
241 resolve_aliases=resolve_aliases,
242 resolve_external=resolve_external,
243 resolve_implicit=resolve_implicit,
244 force_inspection=True,
245 )
246 finally:
247 for name in tuple(sys.modules.keys()):
248 if name == package or name.startswith(f"{package}."):
249 sys.modules.pop(name, None)
250 invalidate_caches()
253@contextmanager
254def temporary_visited_module(
255 code: str,
256 *,
257 module_name: str = "module",
258 extensions: Extensions | None = None,
259 parent: Module | None = None,
260 docstring_parser: Parser | None = None,
261 docstring_options: dict[str, Any] | None = None,
262 lines_collection: LinesCollection | None = None,
263 modules_collection: ModulesCollection | None = None,
264) -> Iterator[Module]:
265 """Create and visit a temporary module with the given code.
267 Parameters:
268 code: The code of the module.
269 module_name: The name of the temporary module.
270 extensions: The extensions to use when visiting the AST.
271 parent: The optional parent of this module.
272 docstring_parser: The docstring parser to use. By default, no parsing is done.
273 docstring_options: Additional docstring parsing options.
274 lines_collection: A collection of source code lines.
275 modules_collection: A collection of modules.
277 Yields:
278 The visited module.
279 """
280 code = dedent(code)
281 with temporary_pyfile(code, module_name=module_name) as (_, path):
282 lines_collection = lines_collection or LinesCollection()
283 lines_collection[path] = code.splitlines()
284 module = visit(
285 module_name,
286 filepath=path,
287 code=code,
288 extensions=extensions,
289 parent=parent,
290 docstring_parser=docstring_parser,
291 docstring_options=docstring_options,
292 lines_collection=lines_collection,
293 modules_collection=modules_collection,
294 )
295 module.modules_collection[module_name] = module
296 yield module
299@contextmanager
300def temporary_inspected_module(
301 code: str,
302 *,
303 module_name: str = "module",
304 import_paths: list[Path] | None = None,
305 extensions: Extensions | None = None,
306 parent: Module | None = None,
307 docstring_parser: Parser | None = None,
308 docstring_options: dict[str, Any] | None = None,
309 lines_collection: LinesCollection | None = None,
310 modules_collection: ModulesCollection | None = None,
311) -> Iterator[Module]:
312 """Create and inspect a temporary module with the given code.
314 Parameters:
315 code: The code of the module.
316 module_name: The name of the temporary module.
317 import_paths: Paths to import the module from.
318 extensions: The extensions to use when visiting the AST.
319 parent: The optional parent of this module.
320 docstring_parser: The docstring parser to use. By default, no parsing is done.
321 docstring_options: Additional docstring parsing options.
322 lines_collection: A collection of source code lines.
323 modules_collection: A collection of modules.
325 Yields:
326 The inspected module.
327 """
328 with temporary_pyfile(code, module_name=module_name) as (_, path):
329 lines_collection = lines_collection or LinesCollection()
330 lines_collection[path] = code.splitlines()
331 try:
332 module = inspect(
333 module_name,
334 filepath=path,
335 import_paths=import_paths,
336 extensions=extensions,
337 parent=parent,
338 docstring_parser=docstring_parser,
339 docstring_options=docstring_options,
340 lines_collection=lines_collection,
341 modules_collection=modules_collection,
342 )
343 module.modules_collection[module_name] = module
344 yield module
345 finally:
346 if module_name in sys.modules:
347 del sys.modules[module_name]
348 invalidate_caches()
351def vtree(*objects: Object, return_leaf: bool = False) -> Object:
352 """Link objects together, vertically.
354 Parameters:
355 *objects: A sequence of objects. The first one is at the top of the tree.
356 return_leaf: Whether to return the leaf instead of the root.
358 Raises:
359 ValueError: When no objects are provided.
361 Returns:
362 The top or leaf object.
363 """
364 if not objects: 364 ↛ 365line 364 didn't jump to line 365 because the condition on line 364 was never true
365 raise ValueError("At least one object must be provided")
366 top = objects[0]
367 leaf = top
368 for obj in objects[1:]:
369 leaf.set_member(obj.name, obj)
370 leaf = obj
371 return leaf if return_leaf else top
374def htree(*objects: Object) -> Object:
375 """Link objects together, horizontally.
377 Parameters:
378 *objects: A sequence of objects. All objects starting at the second become members of the first.
380 Raises:
381 ValueError: When no objects are provided.
383 Returns:
384 The first given object, with all the other objects as members of it.
385 """
386 if not objects:
387 raise ValueError("At least one object must be provided")
388 top = objects[0]
389 for obj in objects[1:]:
390 top.set_member(obj.name, obj)
391 return top
394def module_vtree(path: str, *, leaf_package: bool = True, return_leaf: bool = False) -> Module:
395 """Link objects together, vertically.
397 Parameters:
398 path: The complete module path, like `"a.b.c.d"`.
399 leaf_package: Whether the deepest module should also be a package.
400 return_leaf: Whether to return the leaf instead of the root.
402 Raises:
403 ValueError: When no objects are provided.
405 Returns:
406 The top or leaf module.
407 """
408 parts = path.split(".")
409 modules = [Module(name, filepath=Path(*parts[:index], "__init__.py")) for index, name in enumerate(parts)]
410 if not leaf_package:
411 # YORE: EOL 3.8: Replace block with line 2.
412 try:
413 filepath = modules[-1].filepath.with_stem(parts[-1]) # type: ignore[union-attr]
414 except AttributeError:
415 filepath = modules[-1].filepath.with_name(f"{parts[-1]}.py") # type: ignore[union-attr]
417 modules[-1]._filepath = filepath
418 return vtree(*modules, return_leaf=return_leaf) # type: ignore[return-value]