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

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, Iterator, Mapping, Sequence 

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 _griffe.collections import ModulesCollection 

24 from _griffe.enumerations import Parser 

25 from _griffe.extensions.base import Extensions 

26 

27_TMPDIR_PREFIX = "griffe_" 

28 

29 

30@dataclass 

31class TmpPackage: 

32 """A temporary package. 

33 

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

35 They will be resolved to absolute paths after initialization. 

36 """ 

37 

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

44 

45 def __post_init__(self) -> None: 

46 self.tmpdir = self.tmpdir.resolve() 

47 self.path = self.path.resolve() 

48 

49 

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. 

53 

54 Parameters: 

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

56 module_name: The name of the temporary module. 

57 

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 

66 

67 

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. 

77 

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. 

89 

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) 

115 

116 

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. 

136 

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. 

160 

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 ) 

180 

181 

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. 

201 

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. 

225 

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

251 

252 

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. 

266 

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. 

276 

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 

297 

298 

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. 

313 

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. 

324 

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

349 

350 

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

352 """Link objects together, vertically. 

353 

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. 

357 

358 Raises: 

359 ValueError: When no objects are provided. 

360 

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 

372 

373 

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

375 """Link objects together, horizontally. 

376 

377 Parameters: 

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

379 

380 Raises: 

381 ValueError: When no objects are provided. 

382 

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 

392 

393 

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

395 """Link objects together, vertically. 

396 

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. 

401 

402 Raises: 

403 ValueError: When no objects are provided. 

404 

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] 

416 

417 modules[-1]._filepath = filepath 

418 return vtree(*modules, return_leaf=return_leaf) # type: ignore[return-value]