Coverage for tests / test_visitor.py: 100.00%

178 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-11 11:48 +0100

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 *", encoding="utf8") 

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 encoding="utf8", 

40 ) 

41 tmp_package.path.joinpath("module_b.py").write_text("CONST_B = 'hi'\nTYPE_B = str", encoding="utf8") 

42 tmp_package.path.joinpath("module_c.py").write_text("CONST_C = 'ho'\nTYPE_C = str", encoding="utf8") 

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

44 package = loader.load(tmp_package.name) 

45 loader.resolve_aliases() 

46 assert "CONST_B" in package.members 

47 assert "CONST_C" in package.members 

48 assert "TYPE_B" not in package.members 

49 assert "TYPE_C" not in package.members 

50 

51 

52@pytest.mark.parametrize( 

53 ("decorator", "labels"), 

54 [ 

55 ("property", ("property",)), 

56 ("staticmethod", ("staticmethod",)), 

57 ("classmethod", ("classmethod",)), 

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

59 ("cache", ("cached",)), 

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

61 ("cached_property", ("cached", "property")), 

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

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

64 ("lru_cache", ("cached",)), 

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

66 ("abc.abstractmethod", ("abstractmethod",)), 

67 ("abstractmethod", ("abstractmethod",)), 

68 ("dataclasses.dataclass", ("dataclass",)), 

69 ("dataclass", ("dataclass",)), 

70 ], 

71) 

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

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

74 

75 Parameters: 

76 decorator: A parametrized decorator. 

77 labels: The parametrized tuple of expected labels. 

78 """ 

79 code = f""" 

80 import abc 

81 import dataclasses 

82 import functools 

83 from abc import abstractmethod 

84 from dataclasses import dataclass 

85 from functools import cache, cached_property, lru_cache 

86 

87 class A: 

88 @{decorator} 

89 def f(self): 

90 return 0 

91 """ 

92 with temporary_visited_module(code) as module: 

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

94 

95 

96@pytest.mark.parametrize( 

97 ("decorator", "labels"), 

98 [ 

99 ("dataclasses.dataclass", ("dataclass",)), 

100 ("dataclass", ("dataclass",)), 

101 ], 

102) 

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

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

105 

106 Parameters: 

107 decorator: A parametrized decorator. 

108 labels: The parametrized tuple of expected labels. 

109 """ 

110 code = f""" 

111 import dataclasses 

112 from dataclasses import dataclass 

113 

114 @{decorator} 

115 class A: ... 

116 """ 

117 with temporary_visited_module(code) as module: 

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

119 

120 

121def test_handle_property_setter_and_deleter() -> None: 

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

123 code = """ 

124 class A: 

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

126 

127 @property 

128 def thing(self): return self._thing 

129 

130 @thing.setter 

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

132 

133 @thing.deleter 

134 def thing(self): del self._thing 

135 """ 

136 with temporary_visited_module(code) as module: 

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

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

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

140 

141 

142@pytest.mark.parametrize( 

143 "decorator", 

144 [ 

145 "overload", 

146 "typing.overload", 

147 ], 

148) 

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

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

151 

152 Parameters: 

153 decorator: A parametrized overload decorator. 

154 """ 

155 code = f""" 

156 import typing 

157 from typing import overload 

158 from pathlib import Path 

159 

160 class A: 

161 @{decorator} 

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

163 ... 

164 

165 @{decorator} 

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

167 ... 

168 

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

170 ... 

171 """ 

172 with temporary_visited_module(code) as module: 

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

174 assert len(overloads) == 2 

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

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

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

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

179 

180 

181@pytest.mark.parametrize( 

182 "statements", 

183 [ 

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

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

186 """ 

187 __all__ = ["CONST_INIT"] 

188 __all__ += moda_all + modb_all + modc_all 

189 """, 

190 """ 

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

192 __all__ += modc_all 

193 """, 

194 """ 

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

196 __all__ += [*modc_all] 

197 """, 

198 """ 

199 __all__ = ["CONST_INIT"] 

200 __all__ += moda_all 

201 __all__ += modb_all + modc_all 

202 """, 

203 ], 

204) 

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

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

207 

208 Parameters: 

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

210 """ 

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

212 tmp_package.path.joinpath("moda.py").write_text("CONST_A = 1\n\n__all__ = ['CONST_A']", encoding="utf8") 

213 tmp_package.path.joinpath("modb.py").write_text("CONST_B = 1\n\n__all__ = ['CONST_B']", encoding="utf8") 

214 tmp_package.path.joinpath("modc.py").write_text("CONST_C = 2\n\n__all__ = ['CONST_C']", encoding="utf8") 

215 code = """ 

216 from package.moda import * 

217 from package.moda import __all__ as moda_all 

218 from package.modb import * 

219 from package.modb import __all__ as modb_all 

220 from package.modc import * 

221 from package.modc import __all__ as modc_all 

222 

223 CONST_INIT = 0 

224 """ 

225 tmp_package.path.joinpath("__init__.py").write_text(dedent(code) + dedent(statements), encoding="utf8") 

226 

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

228 package = loader.load(tmp_package.name) 

229 loader.resolve_aliases() 

230 

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

232 

233 

234def test_dont_crash_on_nested_functions_in_init() -> None: 

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

236 with temporary_visited_module( 

237 """ 

238 class C: 

239 def __init__(self): 

240 def pl(i: int): 

241 return i + 1 

242 """, 

243 ) as module: 

244 assert module 

245 

246 

247def test_get_correct_docstring_starting_line_number() -> None: 

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

249 with temporary_visited_module( 

250 """ 

251 ''' 

252 Module docstring. 

253 ''' 

254 class C: 

255 ''' 

256 Class docstring. 

257 ''' 

258 def method(self): 

259 ''' 

260 Method docstring. 

261 ''' 

262 """, 

263 ) as module: 

264 assert module.docstring.lineno == 2 # ty:ignore[possibly-missing-attribute] 

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

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

267 

268 

269def test_visit_properties_as_attributes() -> None: 

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

271 with temporary_visited_module( 

272 """ 

273 from functools import cached_property 

274 

275 class C: 

276 @property 

277 def prop(self) -> bool: 

278 return True 

279 @cached_property 

280 def cached_prop(self) -> int: 

281 return 0 

282 """, 

283 ) as module: 

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

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

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

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

288 

289 

290def test_forward_docstrings() -> None: 

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

292 

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

294 """ 

295 with temporary_visited_module( 

296 ''' 

297 class C: 

298 attr: int 

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

300 

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

302 self.attr = attr 

303 ''', 

304 ) as module: 

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

306 

307 

308def test_classvar_annotations() -> None: 

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

310 with temporary_visited_module( 

311 """ 

312 from typing import ClassVar 

313 

314 class C: 

315 w: ClassVar[str] = "foo" 

316 x: ClassVar[int] 

317 y: str 

318 z: int = 5 

319 

320 def __init__(self) -> None: 

321 self.a: ClassVar[float] 

322 self.y = "" 

323 self.b: bytes 

324 """, 

325 ) as module: 

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

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

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

329 

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

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

332 

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

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

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

336 

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

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

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

340 

341 # This is syntactically valid, but semantically invalid 

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

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

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

345 

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

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

348 

349 

350def test_visiting_if_statement_in_class_for_type_guards() -> None: 

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

352 with temporary_visited_module( 

353 """ 

354 class A: 

355 if something("string1 string2"): 

356 class B: 

357 pass 

358 """, 

359 ) as module: 

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

361 

362 

363def test_visiting_relative_imports_triggering_cyclic_aliases() -> None: 

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

365 with temporary_visited_package( 

366 "pkg", 

367 { 

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

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

370 "b.py": "", 

371 }, 

372 ) as pkg: 

373 assert "a" not in pkg.imports 

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

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

376 

377 

378def test_parse_attributes_in__all__() -> None: 

379 """Parse attributes in `__all__`.""" 

380 with temporary_visited_package( 

381 "package", 

382 { 

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

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

385 }, 

386 ) as package: 

387 assert "hello" in package.exports # ty:ignore[unsupported-operator] 

388 

389 

390def test_parse_deep_attributes_in__all__() -> None: 

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

392 with temporary_visited_package( 

393 "package", 

394 { 

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

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

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

398 }, 

399 ) as package: 

400 assert "hello" in package.exports # ty:ignore[unsupported-operator] 

401 

402 

403# YORE: EOL 3.12: Remove block. 

404# YORE: EOL 3.11: Remove line. 

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

406def test_parse_pep695_generics_without_defaults() -> None: 

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

408 with temporary_visited_module( 

409 """ 

410 class Class[X: Exception]: pass 

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

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

413 """, 

414 ) as module: 

415 class_ = module["Class"] 

416 assert class_.is_class 

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

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

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

420 assert not class_.type_parameters[0].constraints 

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

422 

423 func = module["func"] 

424 assert func.is_function 

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

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

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

428 assert not func.type_parameters[0].constraints 

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

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

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

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

433 assert not func.type_parameters[1].constraints 

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

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

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

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

438 assert not func.type_parameters[2].constraints 

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

440 

441 type_alias = module["TA"] 

442 assert type_alias.is_type_alias 

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

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

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

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

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

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

449 assert isinstance(type_alias.value, Expr) 

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

451 

452 

453# YORE: EOL 3.12: Remove line. 

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

455def test_parse_pep695_generics() -> None: 

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

457 with temporary_visited_module( 

458 """ 

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

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

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

462 """, 

463 ) as module: 

464 class_ = module["Class"] 

465 assert class_.is_class 

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

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

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

469 assert not class_.type_parameters[0].constraints 

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

471 

472 func = module["func"] 

473 assert func.is_function 

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

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

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

477 assert not func.type_parameters[0].constraints 

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

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

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

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

482 assert not func.type_parameters[1].constraints 

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

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

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

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

487 assert not func.type_parameters[2].constraints 

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

489 

490 type_alias = module["TA"] 

491 assert type_alias.is_type_alias 

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

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

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

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

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

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

498 assert isinstance(type_alias.value, Expr) 

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