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