Coverage for tests/test_visitor.py: 100.00%

178 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-08-11 13:44 +0200

1"""Test visit mechanisms.""" 

2 

3from __future__ import annotations 

4 

5import sys 

6from textwrap import dedent 

7 

8import pytest 

9 

10from griffe import ( 

11 Expr, 

12 GriffeLoader, 

13 TypeParameterKind, 

14 temporary_pypackage, 

15 temporary_visited_module, 

16 temporary_visited_package, 

17) 

18 

19 

20def test_not_defined_at_runtime() -> None: 

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

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

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

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

25 dedent( 

26 """ 

27 import typing 

28 from typing import TYPE_CHECKING 

29 

30 from package.module_b import CONST_B 

31 from package.module_c import CONST_C 

32 

33 if typing.TYPE_CHECKING: # Always false. 

34 from package.module_b import TYPE_B 

35 if TYPE_CHECKING: # Always false. 

36 from package.module_c import TYPE_C 

37 """, 

38 ), 

39 ) 

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

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

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

43 package = loader.load(tmp_package.name) 

44 loader.resolve_aliases() 

45 assert "CONST_B" in package.members 

46 assert "CONST_C" in package.members 

47 assert "TYPE_B" not in package.members 

48 assert "TYPE_C" not in package.members 

49 

50 

51@pytest.mark.parametrize( 

52 ("decorator", "labels"), 

53 [ 

54 ("property", ("property",)), 

55 ("staticmethod", ("staticmethod",)), 

56 ("classmethod", ("classmethod",)), 

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

58 ("cache", ("cached",)), 

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

60 ("cached_property", ("cached", "property")), 

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

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

63 ("lru_cache", ("cached",)), 

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

65 ("abc.abstractmethod", ("abstractmethod",)), 

66 ("abstractmethod", ("abstractmethod",)), 

67 ("dataclasses.dataclass", ("dataclass",)), 

68 ("dataclass", ("dataclass",)), 

69 ], 

70) 

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

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

73 

74 Parameters: 

75 decorator: A parametrized decorator. 

76 labels: The parametrized tuple of expected labels. 

77 """ 

78 code = f""" 

79 import abc 

80 import dataclasses 

81 import functools 

82 from abc import abstractmethod 

83 from dataclasses import dataclass 

84 from functools import cache, cached_property, lru_cache 

85 

86 class A: 

87 @{decorator} 

88 def f(self): 

89 return 0 

90 """ 

91 with temporary_visited_module(code) as module: 

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

93 

94 

95@pytest.mark.parametrize( 

96 ("decorator", "labels"), 

97 [ 

98 ("dataclasses.dataclass", ("dataclass",)), 

99 ("dataclass", ("dataclass",)), 

100 ], 

101) 

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

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

104 

105 Parameters: 

106 decorator: A parametrized decorator. 

107 labels: The parametrized tuple of expected labels. 

108 """ 

109 code = f""" 

110 import dataclasses 

111 from dataclasses import dataclass 

112 

113 @{decorator} 

114 class A: ... 

115 """ 

116 with temporary_visited_module(code) as module: 

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

118 

119 

120def test_handle_property_setter_and_deleter() -> None: 

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

122 code = """ 

123 class A: 

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

125 

126 @property 

127 def thing(self): return self._thing 

128 

129 @thing.setter 

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

131 

132 @thing.deleter 

133 def thing(self): del self._thing 

134 """ 

135 with temporary_visited_module(code) as module: 

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

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

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

139 

140 

141@pytest.mark.parametrize( 

142 "decorator", 

143 [ 

144 "overload", 

145 "typing.overload", 

146 ], 

147) 

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

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

150 

151 Parameters: 

152 decorator: A parametrized overload decorator. 

153 """ 

154 code = f""" 

155 import typing 

156 from typing import overload 

157 from pathlib import Path 

158 

159 class A: 

160 @{decorator} 

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

162 ... 

163 

164 @{decorator} 

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

166 ... 

167 

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

169 ... 

170 """ 

171 with temporary_visited_module(code) as module: 

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

173 assert len(overloads) == 2 

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

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

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

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

178 

179 

180@pytest.mark.parametrize( 

181 "statements", 

182 [ 

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

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

185 """ 

186 __all__ = ["CONST_INIT"] 

187 __all__ += moda_all + modb_all + modc_all 

188 """, 

189 """ 

190 __all__ = ["CONST_INIT"] + moda_all + modb_all 

191 __all__ += modc_all 

192 """, 

193 """ 

194 __all__ = ["CONST_INIT"] + moda_all + modb_all 

195 __all__ += [*modc_all] 

196 """, 

197 """ 

198 __all__ = ["CONST_INIT"] 

199 __all__ += moda_all 

200 __all__ += modb_all + modc_all 

201 """, 

202 ], 

203) 

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

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

206 

207 Parameters: 

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

209 """ 

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

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

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

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

214 code = """ 

215 from package.moda import * 

216 from package.moda import __all__ as moda_all 

217 from package.modb import * 

218 from package.modb import __all__ as modb_all 

219 from package.modc import * 

220 from package.modc import __all__ as modc_all 

221 

222 CONST_INIT = 0 

223 """ 

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

225 

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

227 package = loader.load(tmp_package.name) 

228 loader.resolve_aliases() 

229 

230 assert package.exports == ["CONST_INIT", "CONST_A", "CONST_B", "CONST_C"] 

231 

232 

233def test_dont_crash_on_nested_functions_in_init() -> None: 

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

235 with temporary_visited_module( 

236 """ 

237 class C: 

238 def __init__(self): 

239 def pl(i: int): 

240 return i + 1 

241 """, 

242 ) as module: 

243 assert module 

244 

245 

246def test_get_correct_docstring_starting_line_number() -> None: 

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

248 with temporary_visited_module( 

249 """ 

250 ''' 

251 Module docstring. 

252 ''' 

253 class C: 

254 ''' 

255 Class docstring. 

256 ''' 

257 def method(self): 

258 ''' 

259 Method docstring. 

260 ''' 

261 """, 

262 ) as module: 

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

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

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

266 

267 

268def test_visit_properties_as_attributes() -> None: 

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

270 with temporary_visited_module( 

271 """ 

272 from functools import cached_property 

273 

274 class C: 

275 @property 

276 def prop(self) -> bool: 

277 return True 

278 @cached_property 

279 def cached_prop(self) -> int: 

280 return 0 

281 """, 

282 ) as module: 

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

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

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

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

287 

288 

289def test_forward_docstrings() -> None: 

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

291 

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

293 """ 

294 with temporary_visited_module( 

295 ''' 

296 class C: 

297 attr: int 

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

299 

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

301 self.attr = attr 

302 ''', 

303 ) as module: 

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

305 

306 

307def test_classvar_annotations() -> None: 

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

309 with temporary_visited_module( 

310 """ 

311 from typing import ClassVar 

312 

313 class C: 

314 w: ClassVar[str] = "foo" 

315 x: ClassVar[int] 

316 y: str 

317 z: int = 5 

318 

319 def __init__(self) -> None: 

320 self.a: ClassVar[float] 

321 self.y = "" 

322 self.b: bytes 

323 """, 

324 ) as module: 

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

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

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

328 

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

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

331 

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

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

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

335 

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

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

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

339 

340 # This is syntactically valid, but semantically invalid 

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

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

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

344 

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

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

347 

348 

349def test_visiting_if_statement_in_class_for_type_guards() -> None: 

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

351 with temporary_visited_module( 

352 """ 

353 class A: 

354 if something("string1 string2"): 

355 class B: 

356 pass 

357 """, 

358 ) as module: 

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

360 

361 

362def test_visiting_relative_imports_triggering_cyclic_aliases() -> None: 

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

364 with temporary_visited_package( 

365 "pkg", 

366 { 

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

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

369 "b.py": "", 

370 }, 

371 ) as pkg: 

372 assert "a" not in pkg.imports 

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

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

375 

376 

377def test_parse_attributes_in__all__() -> None: 

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

379 with temporary_visited_package( 

380 "package", 

381 { 

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

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

384 }, 

385 ) as package: 

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

387 

388 

389def test_parse_deep_attributes_in__all__() -> None: 

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

391 with temporary_visited_package( 

392 "package", 

393 { 

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

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

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

397 }, 

398 ) as package: 

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

400 

401 

402# YORE: EOL 3.12: Remove block. 

403# YORE: EOL 3.11: Remove line. 

404@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics") 

405def test_parse_pep695_generics_without_defaults() -> None: 

406 """Assert PEP 695 generics are correctly inspected.""" 

407 with temporary_visited_module( 

408 """ 

409 class Class[X: Exception]: pass 

410 def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass 

411 type TA[T: (int, str)] = dict[str, T] 

412 """, 

413 ) as module: 

414 class_ = module["Class"] 

415 assert class_.is_class 

416 assert class_.type_parameters[0].name == "X" 

417 assert class_.type_parameters[0].kind == TypeParameterKind.type_var 

418 assert class_.type_parameters[0].bound.name == "Exception" 

419 assert not class_.type_parameters[0].constraints 

420 assert class_.type_parameters[0].default is None 

421 

422 func = module["func"] 

423 assert func.is_function 

424 assert func.type_parameters[0].name == "P" 

425 assert func.type_parameters[0].kind == TypeParameterKind.param_spec 

426 assert func.type_parameters[0].bound is None 

427 assert not func.type_parameters[0].constraints 

428 assert func.type_parameters[0].default is None 

429 assert func.type_parameters[1].name == "T" 

430 assert func.type_parameters[1].kind == TypeParameterKind.type_var 

431 assert func.type_parameters[1].bound is None 

432 assert not func.type_parameters[1].constraints 

433 assert func.type_parameters[1].default is None 

434 assert func.type_parameters[2].name == "R" 

435 assert func.type_parameters[2].kind == TypeParameterKind.type_var_tuple 

436 assert func.type_parameters[2].bound is None 

437 assert not func.type_parameters[2].constraints 

438 assert func.type_parameters[2].default is None 

439 

440 type_alias = module["TA"] 

441 assert type_alias.is_type_alias 

442 assert type_alias.type_parameters[0].name == "T" 

443 assert type_alias.type_parameters[0].kind == TypeParameterKind.type_var 

444 assert type_alias.type_parameters[0].bound is None 

445 assert type_alias.type_parameters[0].constraints[0].name == "int" 

446 assert type_alias.type_parameters[0].constraints[1].name == "str" 

447 assert type_alias.type_parameters[0].default is None 

448 assert isinstance(type_alias.value, Expr) 

449 assert str(type_alias.value) == "dict[str, T]" 

450 

451 

452# YORE: EOL 3.12: Remove line. 

453@pytest.mark.skipif(sys.version_info < (3, 13), reason="Python less than 3.13 does not have defaults in PEP 695 generics") # fmt: skip 

454def test_parse_pep695_generics() -> None: 

455 """Assert PEP 695 generics are correctly parsed.""" 

456 with temporary_visited_module( 

457 """ 

458 class Class[X: Exception = OSError]: pass 

459 def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass 

460 type TA[T: (int, str) = str] = dict[str, T] 

461 """, 

462 ) as module: 

463 class_ = module["Class"] 

464 assert class_.is_class 

465 assert class_.type_parameters[0].name == "X" 

466 assert class_.type_parameters[0].kind == TypeParameterKind.type_var 

467 assert class_.type_parameters[0].bound.name == "Exception" 

468 assert not class_.type_parameters[0].constraints 

469 assert class_.type_parameters[0].default.name == "OSError" 

470 

471 func = module["func"] 

472 assert func.is_function 

473 assert func.type_parameters[0].name == "P" 

474 assert func.type_parameters[0].kind == TypeParameterKind.param_spec 

475 assert func.type_parameters[0].bound is None 

476 assert not func.type_parameters[0].constraints 

477 assert func.type_parameters[0].default is None 

478 assert func.type_parameters[1].name == "T" 

479 assert func.type_parameters[1].kind == TypeParameterKind.type_var 

480 assert func.type_parameters[1].bound is None 

481 assert not func.type_parameters[1].constraints 

482 assert func.type_parameters[1].default is None 

483 assert func.type_parameters[2].name == "R" 

484 assert func.type_parameters[2].kind == TypeParameterKind.type_var_tuple 

485 assert func.type_parameters[2].bound is None 

486 assert not func.type_parameters[2].constraints 

487 assert func.type_parameters[2].default is None 

488 

489 type_alias = module["TA"] 

490 assert type_alias.is_type_alias 

491 assert type_alias.type_parameters[0].name == "T" 

492 assert type_alias.type_parameters[0].kind == TypeParameterKind.type_var 

493 assert type_alias.type_parameters[0].bound is None 

494 assert type_alias.type_parameters[0].constraints[0].name == "int" 

495 assert type_alias.type_parameters[0].constraints[1].name == "str" 

496 assert type_alias.type_parameters[0].default.name == "str" 

497 assert isinstance(type_alias.value, Expr) 

498 assert str(type_alias.value) == "dict[str, T]"