Coverage for tests/test_visitor.py: 100.00%

103 statements  

« prev     ^ index     » next       coverage.py v7.6.2, created at 2024-10-12 01:34 +0200

1"""Test visit mechanisms.""" 

2 

3from __future__ import annotations 

4 

5from textwrap import dedent 

6 

7import pytest 

8 

9from griffe import GriffeLoader, temporary_pypackage, temporary_visited_module, temporary_visited_package 

10 

11 

12def test_not_defined_at_runtime() -> None: 

13 """Assert that objects not defined at runtime are not added to wildcards expansions.""" 

14 with temporary_pypackage("package", ["module_a.py", "module_b.py", "module_c.py"]) as tmp_package: 

15 tmp_package.path.joinpath("__init__.py").write_text("from package.module_a import *") 

16 tmp_package.path.joinpath("module_a.py").write_text( 

17 dedent( 

18 """ 

19 import typing 

20 from typing import TYPE_CHECKING 

21 

22 from package.module_b import CONST_B 

23 from package.module_c import CONST_C 

24 

25 if typing.TYPE_CHECKING: # always false 

26 from package.module_b import TYPE_B 

27 if TYPE_CHECKING: # always false 

28 from package.module_c import TYPE_C 

29 """, 

30 ), 

31 ) 

32 tmp_package.path.joinpath("module_b.py").write_text("CONST_B = 'hi'\nTYPE_B = str") 

33 tmp_package.path.joinpath("module_c.py").write_text("CONST_C = 'ho'\nTYPE_C = str") 

34 loader = GriffeLoader(search_paths=[tmp_package.tmpdir]) 

35 package = loader.load(tmp_package.name) 

36 loader.resolve_aliases() 

37 assert "CONST_B" in package.members 

38 assert "CONST_C" in package.members 

39 assert "TYPE_B" not in package.members 

40 assert "TYPE_C" not in package.members 

41 

42 

43@pytest.mark.parametrize( 

44 ("decorator", "labels"), 

45 [ 

46 ("property", ("property",)), 

47 ("staticmethod", ("staticmethod",)), 

48 ("classmethod", ("classmethod",)), 

49 ("functools.cache", ("cached",)), 

50 ("cache", ("cached",)), 

51 ("functools.cached_property", ("cached", "property")), 

52 ("cached_property", ("cached", "property")), 

53 ("functools.lru_cache", ("cached",)), 

54 ("functools.lru_cache(maxsize=8)", ("cached",)), 

55 ("lru_cache", ("cached",)), 

56 ("lru_cache(maxsize=8)", ("cached",)), 

57 ("abc.abstractmethod", ("abstractmethod",)), 

58 ("abstractmethod", ("abstractmethod",)), 

59 ("dataclasses.dataclass", ("dataclass",)), 

60 ("dataclass", ("dataclass",)), 

61 ], 

62) 

63def test_set_function_labels_using_decorators(decorator: str, labels: tuple[str, ...]) -> None: 

64 """Assert decorators are used to set labels on functions. 

65 

66 Parameters: 

67 decorator: A parametrized decorator. 

68 labels: The parametrized tuple of expected labels. 

69 """ 

70 code = f""" 

71 import abc 

72 import dataclasses 

73 import functools 

74 from abc import abstractmethod 

75 from dataclasses import dataclass 

76 from functools import cache, cached_property, lru_cache 

77 

78 class A: 

79 @{decorator} 

80 def f(self): 

81 return 0 

82 """ 

83 with temporary_visited_module(code) as module: 

84 assert module["A.f"].has_labels(*labels) 

85 

86 

87@pytest.mark.parametrize( 

88 ("decorator", "labels"), 

89 [ 

90 ("dataclasses.dataclass", ("dataclass",)), 

91 ("dataclass", ("dataclass",)), 

92 ], 

93) 

94def test_set_class_labels_using_decorators(decorator: str, labels: tuple[str, ...]) -> None: 

95 """Assert decorators are used to set labels on classes. 

96 

97 Parameters: 

98 decorator: A parametrized decorator. 

99 labels: The parametrized tuple of expected labels. 

100 """ 

101 code = f""" 

102 import dataclasses 

103 from dataclasses import dataclass 

104 

105 @{decorator} 

106 class A: ... 

107 """ 

108 with temporary_visited_module(code) as module: 

109 assert module["A"].has_labels(*labels) 

110 

111 

112def test_handle_property_setter_and_deleter() -> None: 

113 """Assert property setters and deleters are supported.""" 

114 code = """ 

115 class A: 

116 def __init__(self): self._thing = 0 

117 

118 @property 

119 def thing(self): return self._thing 

120 

121 @thing.setter 

122 def thing(self, value): self._thing = value 

123 

124 @thing.deleter 

125 def thing(self): del self._thing 

126 """ 

127 with temporary_visited_module(code) as module: 

128 assert module["A.thing"].has_labels("property", "writable", "deletable") 

129 assert module["A.thing"].setter.is_function 

130 assert module["A.thing"].deleter.is_function 

131 

132 

133@pytest.mark.parametrize( 

134 "decorator", 

135 [ 

136 "overload", 

137 "typing.overload", 

138 ], 

139) 

140def test_handle_typing_overaload(decorator: str) -> None: 

141 """Assert `typing.overload` is supported. 

142 

143 Parameters: 

144 decorator: A parametrized overload decorator. 

145 """ 

146 code = f""" 

147 import typing 

148 from typing import overload 

149 from pathlib import Path 

150 

151 class A: 

152 @{decorator} 

153 def absolute(self, path: str) -> str: 

154 ... 

155 

156 @{decorator} 

157 def absolute(self, path: Path) -> Path: 

158 ... 

159 

160 def absolute(self, path: str | Path) -> str | Path: 

161 ... 

162 """ 

163 with temporary_visited_module(code) as module: 

164 overloads = module["A.absolute"].overloads 

165 assert len(overloads) == 2 

166 assert overloads[0].parameters["path"].annotation.name == "str" 

167 assert overloads[1].parameters["path"].annotation.name == "Path" 

168 assert overloads[0].returns.name == "str" 

169 assert overloads[1].returns.name == "Path" 

170 

171 

172@pytest.mark.parametrize( 

173 "statements", 

174 [ 

175 """__all__ = moda_all + modb_all + modc_all + ["CONST_INIT"]""", 

176 """__all__ = ["CONST_INIT", *moda_all, *modb_all, *modc_all]""", 

177 """ 

178 __all__ = ["CONST_INIT"] 

179 __all__ += moda_all + modb_all + modc_all 

180 """, 

181 """ 

182 __all__ = moda_all + modb_all + modc_all 

183 __all__ += ["CONST_INIT"] 

184 """, 

185 """ 

186 __all__ = ["CONST_INIT"] 

187 __all__ += moda_all 

188 __all__ += modb_all + modc_all 

189 """, 

190 ], 

191) 

192def test_parse_complex__all__assignments(statements: str) -> None: 

193 """Check our ability to expand exports based on `__all__` [augmented] assignments. 

194 

195 Parameters: 

196 statements: Parametrized text containing `__all__` [augmented] assignments. 

197 """ 

198 with temporary_pypackage("package", ["moda.py", "modb.py", "modc.py"]) as tmp_package: 

199 tmp_package.path.joinpath("moda.py").write_text("CONST_A = 1\n\n__all__ = ['CONST_A']") 

200 tmp_package.path.joinpath("modb.py").write_text("CONST_B = 1\n\n__all__ = ['CONST_B']") 

201 tmp_package.path.joinpath("modc.py").write_text("CONST_C = 2\n\n__all__ = ['CONST_C']") 

202 code = """ 

203 from package.moda import * 

204 from package.moda import __all__ as moda_all 

205 from package.modb import * 

206 from package.modb import __all__ as modb_all 

207 from package.modc import * 

208 from package.modc import __all__ as modc_all 

209 

210 CONST_INIT = 0 

211 """ 

212 tmp_package.path.joinpath("__init__.py").write_text(dedent(code) + dedent(statements)) 

213 

214 loader = GriffeLoader(search_paths=[tmp_package.tmpdir]) 

215 package = loader.load(tmp_package.name) 

216 loader.resolve_aliases() 

217 

218 assert package.exports == {"CONST_INIT", "CONST_A", "CONST_B", "CONST_C"} 

219 

220 

221def test_dont_crash_on_nested_functions_in_init() -> None: 

222 """Assert we don't crash when visiting a nested function in `__init__` methods.""" 

223 with temporary_visited_module( 

224 """ 

225 class C: 

226 def __init__(self): 

227 def pl(i: int): 

228 return i + 1 

229 """, 

230 ) as module: 

231 assert module 

232 

233 

234def test_get_correct_docstring_starting_line_number() -> None: 

235 """Assert we get the correct line numbers for docstring.""" 

236 with temporary_visited_module( 

237 """ 

238 ''' 

239 Module docstring. 

240 ''' 

241 class C: 

242 ''' 

243 Class docstring. 

244 ''' 

245 def method(self): 

246 ''' 

247 Method docstring. 

248 ''' 

249 """, 

250 ) as module: 

251 assert module.docstring.lineno == 2 # type: ignore[union-attr] 

252 assert module["C"].docstring.lineno == 6 

253 assert module["C.method"].docstring.lineno == 10 

254 

255 

256def test_visit_properties_as_attributes() -> None: 

257 """Assert properties are created as attributes and not functions.""" 

258 with temporary_visited_module( 

259 """ 

260 from functools import cached_property 

261 

262 class C: 

263 @property 

264 def prop(self) -> bool: 

265 return True 

266 @cached_property 

267 def cached_prop(self) -> int: 

268 return 0 

269 """, 

270 ) as module: 

271 assert module["C.prop"].is_attribute 

272 assert "property" in module["C.prop"].labels 

273 assert module["C.cached_prop"].is_attribute 

274 assert "cached" in module["C.cached_prop"].labels 

275 

276 

277def test_forward_docstrings() -> None: 

278 """Assert docstrings of class attributes are forwarded to instance assignments. 

279 

280 This is a regression test for https://github.com/mkdocstrings/griffe/issues/128. 

281 """ 

282 with temporary_visited_module( 

283 ''' 

284 class C: 

285 attr: int 

286 """This is a non-empty docstring.""" 

287 

288 def __init__(self, attr: int) -> None: 

289 self.attr = attr 

290 ''', 

291 ) as module: 

292 assert module["C.attr"].docstring 

293 

294 

295def test_classvar_annotations() -> None: 

296 """Assert class variable and instance variable annotations are correctly parsed and merged.""" 

297 with temporary_visited_module( 

298 """ 

299 from typing import ClassVar 

300 

301 class C: 

302 w: ClassVar[str] = "foo" 

303 x: ClassVar[int] 

304 y: str 

305 z: int = 5 

306 

307 def __init__(self) -> None: 

308 self.a: ClassVar[float] 

309 self.y = "" 

310 self.b: bytes 

311 """, 

312 ) as module: 

313 assert module["C.w"].annotation.canonical_path == "str" 

314 assert module["C.w"].labels == {"class-attribute"} 

315 assert module["C.w"].value == "'foo'" 

316 

317 assert module["C.x"].annotation.canonical_path == "int" 

318 assert module["C.x"].labels == {"class-attribute"} 

319 

320 assert module["C.y"].annotation.canonical_path == "str" 

321 assert module["C.y"].labels == {"instance-attribute"} 

322 assert module["C.y"].value == "''" 

323 

324 assert module["C.z"].annotation.canonical_path == "int" 

325 assert module["C.z"].labels == {"class-attribute", "instance-attribute"} 

326 assert module["C.z"].value == "5" 

327 

328 # This is syntactically valid, but semantically invalid 

329 assert module["C.a"].annotation.canonical_path == "typing.ClassVar" 

330 assert module["C.a"].annotation.slice.canonical_path == "float" 

331 assert module["C.a"].labels == {"instance-attribute"} 

332 

333 assert module["C.b"].annotation.canonical_path == "bytes" 

334 assert module["C.b"].labels == {"instance-attribute"} 

335 

336 

337def test_visiting_if_statement_in_class_for_type_guards() -> None: 

338 """Don't fail on various if statements when checking for type-guards.""" 

339 with temporary_visited_module( 

340 """ 

341 class A: 

342 if something("string1 string2"): 

343 class B: 

344 pass 

345 """, 

346 ) as module: 

347 assert module["A.B"].runtime 

348 

349 

350def test_visiting_relative_imports_triggering_cyclic_aliases() -> None: 

351 """Skip specific imports to avoid cyclic aliases.""" 

352 with temporary_visited_package( 

353 "pkg", 

354 { 

355 "__init__.py": "from . import a", 

356 "a.py": "from . import b", 

357 "b.py": "", 

358 }, 

359 ) as pkg: 

360 assert "a" not in pkg.imports 

361 assert "b" in pkg["a"].imports 

362 assert pkg["a"].imports["b"] == "pkg.b" 

363 

364 

365def test_parse_attributes_in__all__() -> None: 

366 """Parse attributes in `__all__`.""" 

367 with temporary_visited_package( 

368 "package", 

369 { 

370 "__init__.py": "from package import module\n__all__ = module.__all__", 

371 "module.py": "def hello(): ...\n__all__ = ['hello']", 

372 }, 

373 ) as package: 

374 assert "hello" in package.exports # type: ignore[operator] 

375 

376 

377def test_parse_deep_attributes_in__all__() -> None: 

378 """Parse deep attributes in `__all__`.""" 

379 with temporary_visited_package( 

380 "package", 

381 { 

382 "__init__.py": "from package import subpackage\n__all__ = subpackage.module.__all__", 

383 "subpackage/__init__.py": "from package.subpackage import module", 

384 "subpackage/module.py": "def hello(): ...\n__all__ = ['hello']", 

385 }, 

386 ) as package: 

387 assert "hello" in package.exports # type: ignore[operator]