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

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. 

4 

5from __future__ import annotations 

6 

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 

15 

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 

21 

22if TYPE_CHECKING: 

23 from collections.abc import Iterator, Mapping, Sequence 

24 

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 

29 

30_TMPDIR_PREFIX = "griffe_" 

31 

32 

33@dataclass 

34class TmpPackage: 

35 """A temporary package. 

36 

37 The `tmpdir` and `path` parameters can be passed as relative path. 

38 They will be resolved to absolute paths after initialization. 

39 """ 

40 

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.""" 

47 

48 def __post_init__(self) -> None: 

49 self.tmpdir = self.tmpdir.resolve() 

50 self.path = self.path.resolve() 

51 

52 

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. 

56 

57 Parameters: 

58 code: The code to write to the temporary file. 

59 module_name: The name of the temporary module. 

60 

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 

69 

70 

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. 

80 

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. 

92 

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) 

118 

119 

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. 

140 

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. 

165 

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 ) 

186 

187 

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. 

208 

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. 

233 

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() 

260 

261 

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. 

275 

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. 

285 

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 

306 

307 

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. 

322 

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. 

333 

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() 

358 

359 

360def vtree(*objects: Object, return_leaf: bool = False) -> Object: 

361 """Link objects together, vertically. 

362 

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. 

366 

367 Raises: 

368 ValueError: When no objects are provided. 

369 

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 

381 

382 

383def htree(*objects: Object) -> Object: 

384 """Link objects together, horizontally. 

385 

386 Parameters: 

387 *objects: A sequence of objects. All objects starting at the second become members of the first. 

388 

389 Raises: 

390 ValueError: When no objects are provided. 

391 

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 

401 

402 

403def module_vtree(path: str, *, leaf_package: bool = True, return_leaf: bool = False) -> Module: 

404 """Link objects together, vertically. 

405 

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. 

410 

411 Raises: 

412 ValueError: When no objects are provided. 

413 

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]