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

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

21 

22if TYPE_CHECKING: 

23 from collections.abc import Iterator, Mapping, Sequence 

24 

25 from _griffe.collections import ModulesCollection 

26 from _griffe.enumerations import Parser 

27 from _griffe.extensions.base import Extensions 

28 

29_TMPDIR_PREFIX = "griffe_" 

30 

31 

32@dataclass 

33class TmpPackage: 

34 """A temporary package. 

35 

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

37 They will be resolved to absolute paths after initialization. 

38 """ 

39 

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

46 

47 def __post_init__(self) -> None: 

48 self.tmpdir = self.tmpdir.resolve() 

49 self.path = self.path.resolve() 

50 

51 

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. 

55 

56 Parameters: 

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

58 module_name: The name of the temporary module. 

59 

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 

68 

69 

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. 

79 

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. 

91 

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) 

117 

118 

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. 

138 

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. 

162 

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 ) 

182 

183 

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. 

203 

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. 

227 

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

253 

254 

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. 

268 

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. 

278 

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 

299 

300 

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. 

315 

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. 

326 

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

351 

352 

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

354 """Link objects together, vertically. 

355 

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. 

359 

360 Raises: 

361 ValueError: When no objects are provided. 

362 

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 

374 

375 

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

377 """Link objects together, horizontally. 

378 

379 Parameters: 

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

381 

382 Raises: 

383 ValueError: When no objects are provided. 

384 

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 

394 

395 

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

397 """Link objects together, vertically. 

398 

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. 

403 

404 Raises: 

405 ValueError: When no objects are provided. 

406 

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]