Coverage for tests / test_nodes.py: 100.00%

61 statements  

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

1"""Test nodes utilities.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import sys 

7from ast import PyCF_ONLY_AST 

8 

9import pytest 

10 

11from griffe import Expr, ExprName, module_vtree, relative_to_absolute, temporary_visited_module 

12 

13syntax_examples = [ 

14 # Operations. 

15 "b + c", 

16 "b - c", 

17 "b * c", 

18 "b / c", 

19 "b // c", 

20 "b ** c", 

21 "b ^ c", 

22 "b & c", 

23 "b | c", 

24 "b @ c", 

25 "b % c", 

26 "b >> c", 

27 "b << c", 

28 # Unary operations. 

29 "+b", 

30 "-b", 

31 "~b", 

32 # Comparisons. 

33 "b == c", 

34 "b >= c", 

35 "b > c", 

36 "b <= c", 

37 "b < c", 

38 "b != c", 

39 # Boolean logic. 

40 "b and c", 

41 "b or c", 

42 "not b", 

43 # Identify. 

44 "b is c", 

45 "b is not c", 

46 # Membership. 

47 "b in c", 

48 "b not in c", 

49 # Calls. 

50 "call()", 

51 "call(something)", 

52 "call(something=something)", 

53 # Strings. 

54 "f'a {round(key, 2)} {z}'", 

55 # YORE: EOL 3.13: Replace line with `"t'a {round(key, 2)} {z}'",`. 

56 *(["t'a {round(key, 2)} {z}'"] if sys.version_info >= (3, 14) else []), 

57 # Slices. 

58 "o[x]", 

59 "o[x, y]", 

60 "o[x:y]", 

61 "o[x:y, z]", 

62 "o[x, y(z)]", 

63 # Walrus operator. 

64 "a if (a := b) else c", 

65 # Starred. 

66 "a(*b, **c)", 

67 # Structs. 

68 "(a, b, c)", 

69 "{a, b, c}", 

70 "{a: b, c: d}", 

71 "[a, b, c]", 

72 # Yields. 

73 "yield", 

74 "yield a", 

75 "yield from a", 

76 # Lambdas. 

77 "lambda a: a", 

78 "lambda a, b: a", 

79 "lambda *a, **b: a", 

80 "lambda a, b=0: a", 

81 "lambda a, /, b, c: a", 

82 "lambda a, *, b, c: a", 

83 "lambda a, /, b, *, c: a", 

84] 

85 

86 

87@pytest.mark.parametrize( 

88 ("code", "path", "is_package", "expected"), 

89 [ 

90 ("from . import b", "a", False, "a.b"), 

91 ("from . import b", "a", True, "a.b"), 

92 ("from . import c", "a.b", False, "a.c"), 

93 ("from . import c", "a.b", True, "a.b.c"), 

94 ("from . import d", "a.b.c", False, "a.b.d"), 

95 ("from .c import d", "a", False, "a.c.d"), 

96 ("from .c import d", "a.b", False, "a.c.d"), 

97 ("from .b import c", "a.b", True, "a.b.b.c"), 

98 ("from .. import e", "a.c.d.i", False, "a.c.e"), 

99 ("from ..d import e", "a.c.d.i", False, "a.c.d.e"), 

100 ("from ... import f", "a.c.d.i", False, "a.f"), 

101 ("from ...b import f", "a.c.d.i", False, "a.b.f"), 

102 ("from ...c.d import e", "a.c.d.i", False, "a.c.d.e"), 

103 ("from .c import *", "a", False, "a.c.*"), 

104 ("from .c import *", "a.b", False, "a.c.*"), 

105 ("from .b import *", "a.b", True, "a.b.b.*"), 

106 ("from .. import *", "a.c.d.i", False, "a.c.*"), 

107 ("from ..d import *", "a.c.d.i", False, "a.c.d.*"), 

108 ("from ... import *", "a.c.d.i", False, "a.*"), 

109 ("from ...b import *", "a.c.d.i", False, "a.b.*"), 

110 ("from ...c.d import *", "a.c.d.i", False, "a.c.d.*"), 

111 ], 

112) 

113def test_relative_to_absolute_imports(code: str, path: str, is_package: bool, expected: str) -> None: 

114 """Check if relative imports are correctly converted to absolute ones. 

115 

116 Parameters: 

117 code: The parametrized module code. 

118 path: The parametrized module path. 

119 is_package: Whether the module is a package (or subpackage) (parametrized). 

120 expected: The parametrized expected absolute path. 

121 """ 

122 node = compile(code, mode="exec", filename="<>", flags=PyCF_ONLY_AST).body[0] # ty:ignore[unresolved-attribute] 

123 module = module_vtree(path, leaf_package=is_package, return_leaf=True) 

124 for name in node.names: 

125 assert relative_to_absolute(node, name, module) == expected 

126 

127 

128def test_multipart_imports() -> None: 

129 """Assert that a multipart path like `a.b.c` imported as `x` points to the right target.""" 

130 with temporary_visited_module( 

131 """ 

132 import pkg.b.c 

133 import pkg.b.c as alias 

134 """, 

135 ) as module: 

136 pkg = module["pkg"] 

137 alias = module["alias"] 

138 assert pkg.target_path == "pkg" 

139 assert alias.target_path == "pkg.b.c" 

140 

141 

142@pytest.mark.parametrize( 

143 "expression", 

144 [ 

145 "A", 

146 "A.B", 

147 "A[B]", 

148 "A.B[C.D]", 

149 "~A", 

150 "A | B", 

151 "A[[B, C], D]", 

152 "A(b=c, d=1)", 

153 "A[-1, +2.3]", 

154 "A[B, C.D(e='syntax error')]", 

155 ], 

156) 

157def test_building_annotations_from_nodes(expression: str) -> None: 

158 """Test building annotations from AST nodes. 

159 

160 Parameters: 

161 expression: An expression (parametrized). 

162 """ 

163 class_defs = "\n\n".join(f"class {letter}: ..." for letter in "ABCD") 

164 with temporary_visited_module(f"{class_defs}\n\nx: {expression}\ny: {expression} = 0") as module: 

165 assert "x" in module.members 

166 assert "y" in module.members 

167 assert str(module["x"].annotation) == expression 

168 assert str(module["y"].annotation) == expression 

169 

170 

171@pytest.mark.parametrize("code", syntax_examples) 

172def test_building_expressions_from_nodes(code: str) -> None: 

173 """Test building annotations from AST nodes. 

174 

175 Parameters: 

176 code: An expression (parametrized). 

177 """ 

178 with temporary_visited_module(f"__z__ = {code}") as module: 

179 assert "__z__" in module.members 

180 

181 # Make space after comma non-significant. 

182 value = str(module["__z__"].value).replace(", ", ",") 

183 assert value == code.replace(", ", ",") 

184 

185 

186@pytest.mark.parametrize( 

187 ("code", "has_name"), 

188 [ 

189 ("import typing\nclass A: ...\na: typing.Literal['A']", False), 

190 ("from typing import Literal\nclass A: ...\na: Literal['A']", False), 

191 ("import typing_extensions\nclass A: ...\na: typing.Literal['A']", False), 

192 ("from typing_extensions import Literal\nclass A: ...\na: Literal['A']", False), 

193 ("from mod import A\na: 'A'", True), 

194 ("from mod import A\na: list['A']", True), 

195 ], 

196) 

197def test_forward_references(code: str, has_name: bool) -> None: 

198 """Check that we support forward references (type names as strings). 

199 

200 Parameters: 

201 code: Parametrized code. 

202 has_name: Whether the annotation should contain a Name rather than a string. 

203 """ 

204 with temporary_visited_module(code) as module: 

205 annotation = list(module["a"].annotation.iterate(flat=True)) 

206 if has_name: 

207 assert any(isinstance(item, ExprName) and item.name == "A" for item in annotation) 

208 assert all(not (isinstance(item, str) and item == "A") for item in annotation) 

209 else: 

210 assert "'A'" in annotation 

211 assert all(not (isinstance(item, ExprName) and item.name == "A") for item in annotation) 

212 

213 

214@pytest.mark.parametrize( 

215 "default", 

216 [ 

217 "1", 

218 "'test_string'", 

219 "dict(key=1)", 

220 "{'key': 1}", 

221 "DEFAULT_VALUE", 

222 "None", 

223 ], 

224) 

225def test_default_value_from_nodes(default: str) -> None: 

226 """Test getting default value from AST nodes. 

227 

228 Parameters: 

229 default: A default value (parametrized). 

230 """ 

231 module_defs = f"def f(x={default}):\n return x" 

232 with temporary_visited_module(module_defs) as module: 

233 assert "f" in module.members 

234 params = module.members["f"].parameters # ty:ignore[possibly-missing-attribute] 

235 assert len(params) == 1 

236 assert str(params[0].default) == default 

237 

238 

239# https://github.com/mkdocstrings/griffe/issues/159 

240def test_parsing_complex_string_annotations() -> None: 

241 """Test parsing of complex, stringified annotations.""" 

242 with temporary_visited_module( 

243 """ 

244 class ArgsKwargs: 

245 def __init__(self, args: 'tuple[Any, ...]', kwargs: 'dict[str, Any] | None' = None) -> None: 

246 ... 

247 

248 @property 

249 def args(self) -> 'tuple[Any, ...]': 

250 ... 

251 

252 @property 

253 def kwargs(self) -> 'dict[str, Any] | None': 

254 ... 

255 """, 

256 ) as module: 

257 init_args_annotation = module["ArgsKwargs.__init__"].parameters["args"].annotation 

258 assert isinstance(init_args_annotation, Expr) 

259 assert init_args_annotation.is_tuple 

260 kwargs_return_annotation = module["ArgsKwargs.kwargs"].annotation 

261 assert isinstance(kwargs_return_annotation, Expr) 

262 

263 

264def test_parsing_dynamic_base_classes(caplog: pytest.LogCaptureFixture) -> None: 

265 """Assert parsing dynamic base classes does not trigger errors. 

266 

267 Parameters: 

268 caplog: Pytest fixture to capture logs. 

269 """ 

270 with ( 

271 caplog.at_level(logging.ERROR), 

272 temporary_visited_module( 

273 """ 

274 from collections import namedtuple 

275 class Thing(namedtuple('Thing', 'attr1 attr2')): 

276 ... 

277 """, 

278 ), 

279 ): 

280 pass 

281 assert not caplog.records