Coverage for tests/test_extension.py: 100.00%

52 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-05 17:55 +0200

1"""Tests for the `extension` module.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from typing import TYPE_CHECKING 

7 

8import pytest 

9from griffe import Extensions, temporary_inspected_package, temporary_visited_package 

10 

11from griffe_pydantic._internal.extension import PydanticExtension 

12 

13if TYPE_CHECKING: 

14 from mkdocstrings_handlers.python.handler import PythonHandler 

15 

16 

17code = """ 

18 from pydantic import field_validator, ConfigDict, BaseModel, Field 

19 

20 

21 class ExampleParentModel(BaseModel): 

22 '''An example parent model.''' 

23 parent_field: str = Field(..., description="Parent field.") 

24 

25 

26 class ExampleModel(ExampleParentModel): 

27 '''An example child model.''' 

28 

29 model_config = ConfigDict(frozen=False) 

30 

31 field_without_default: str 

32 '''Shows the *[Required]* marker in the signature.''' 

33 

34 field_plain_with_validator: int = 100 

35 '''Show standard field with type annotation.''' 

36 

37 field_with_validator_and_alias: str = Field("FooBar", alias="BarFoo", validation_alias="BarFoo") 

38 '''Shows corresponding validator with link/anchor.''' 

39 

40 field_with_constraints_and_description: int = Field( 

41 default=5, ge=0, le=100, description="Shows constraints within doc string." 

42 ) 

43 

44 @field_validator("field_with_validator_and_alias", "field_plain_with_validator", mode="before") 

45 @classmethod 

46 def check_max_length_ten(cls, v): 

47 '''Show corresponding field with link/anchor.''' 

48 if len(v) >= 10: 

49 raise ValueError("No more than 10 characters allowed") 

50 return v 

51 

52 def regular_method(self): 

53 pass 

54 

55 

56 class RegularClass(object): 

57 regular_attr = 1 

58""" 

59 

60 

61@pytest.mark.parametrize("analysis", ["static", "dynamic"]) 

62def test_extension(analysis: str) -> None: 

63 """Test the extension.""" 

64 loader = {"static": temporary_visited_package, "dynamic": temporary_inspected_package}[analysis] 

65 with loader( 

66 "package", 

67 modules={"__init__.py": code}, 

68 extensions=Extensions(PydanticExtension(schema=True)), 

69 search_sys_path=analysis == "dynamic", 

70 ) as package: 

71 assert package 

72 

73 assert "ExampleParentModel" in package.classes 

74 assert package.classes["ExampleParentModel"].labels == {"pydantic-model"} 

75 

76 assert "ExampleModel" in package.classes 

77 assert package.classes["ExampleModel"].labels == {"pydantic-model"} 

78 

79 config = package.classes["ExampleModel"].extra["griffe_pydantic"]["config"] 

80 assert config == {"frozen": False} 

81 

82 schema = package.classes["ExampleModel"].extra["griffe_pydantic"]["schema"] 

83 assert schema.startswith('{\n "description"') 

84 

85 

86def test_imported_models() -> None: 

87 """Test the extension with imported models.""" 

88 with temporary_visited_package( 

89 "package", 

90 modules={ 

91 "__init__.py": "from ._private import MyModel\n\n__all__ = ['MyModel']", 

92 "_private.py": "from pydantic import BaseModel\n\nclass MyModel(BaseModel):\n field1: str\n '''Some field.'''\n", 

93 }, 

94 extensions=Extensions(PydanticExtension(schema=False)), 

95 ) as package: 

96 assert package["MyModel"].labels == {"pydantic-model"} 

97 assert package["MyModel.field1"].labels == {"pydantic-field"} 

98 

99 

100def test_rendering_model_config_using_configdict(python_handler: PythonHandler) -> None: 

101 """Test the extension with model config using ConfigDict.""" 

102 code = """ 

103 from pydantic import BaseModel, ConfigDict, Field 

104 

105 class Model(BaseModel): 

106 usage: str | None = Field( 

107 None, 

108 description="Some description.", 

109 example="Some example.", 

110 ) 

111 model_config = ConfigDict( 

112 json_schema_extra={ 

113 "example": { 

114 "usage": "Some usage.", 

115 "limitations": "Some limitations.", 

116 "billing": "Some value.", 

117 "notice_period": "Some value.", 

118 } 

119 } 

120 ) 

121 """ 

122 with temporary_visited_package( 

123 "package", 

124 modules={"__init__.py": code}, 

125 extensions=Extensions(PydanticExtension(schema=False)), 

126 ) as package: 

127 python_handler.render(package["Model"], python_handler.get_options({})) # Assert no errors. 

128 

129 

130def test_not_crashing_on_dynamic_field_description(caplog: pytest.LogCaptureFixture) -> None: 

131 """Test the extension with dynamic field description.""" 

132 code = """ 

133 import pydantic 

134 

135 desc = "xyz" 

136 

137 class TestModel(pydantic.BaseModel): 

138 abc: str = pydantic.Field(description=desc) 

139 """ 

140 with ( 

141 caplog.at_level(logging.DEBUG), 

142 temporary_visited_package( 

143 "package", 

144 modules={"__init__.py": code}, 

145 extensions=Extensions(PydanticExtension(schema=False)), 

146 ), 

147 ): 

148 assert any( 

149 record.levelname == "DEBUG" and "field 'package.TestModel.abc' as literal" in record.message 

150 for record in caplog.records 

151 ) 

152 

153 

154def test_ignore_classvars() -> None: 

155 """Test the extension ignores class variables.""" 

156 code = """ 

157 from pydantic import BaseModel 

158 from typing import ClassVar 

159 

160 class Model(BaseModel): 

161 field: str 

162 class_var: ClassVar[int] = 1 

163 """ 

164 with temporary_visited_package( 

165 "package", 

166 modules={"__init__.py": code}, 

167 extensions=Extensions(PydanticExtension(schema=False)), 

168 ) as package: 

169 assert "pydantic-field" not in package["Model.class_var"].labels 

170 assert "class-attribute" in package["Model.class_var"].labels 

171 

172 

173def test_wildcard_field_validator() -> None: 

174 """Test field validator that works on all fields.""" 

175 code = """ 

176 from pydantic import BaseModel, field_validator 

177 

178 class Schema(BaseModel): 

179 a: int 

180 b: int 

181 

182 @field_validator('*', mode='before') 

183 @classmethod 

184 def set_if_none(cls, v: Any, info): 

185 ... 

186 """ 

187 with temporary_visited_package( 

188 "package", 

189 modules={"__init__.py": code}, 

190 extensions=Extensions(PydanticExtension(schema=False)), 

191 ) as package: 

192 validator = package["Schema.set_if_none"] 

193 assert validator.labels == {"pydantic-validator"} 

194 assert validator in package["Schema.a"].extra["griffe_pydantic"]["validators"] 

195 assert validator in package["Schema.b"].extra["griffe_pydantic"]["validators"] 

196 

197 

198def test_ignoring_properties() -> None: 

199 """Properties are not fields and must be ignored.""" 

200 code = """ 

201 from pydantic import BaseModel, field 

202 

203 class Base(BaseModel): 

204 @property 

205 def a(self) -> int: 

206 return 0 

207 

208 class Model(Base): 

209 b: int = field(default=1) 

210 """ 

211 with temporary_visited_package( 

212 "package", 

213 modules={"__init__.py": code}, 

214 extensions=Extensions(PydanticExtension(schema=False)), 

215 ) as package: 

216 assert "pydantic-field" not in package["Model.a"].labels 

217 

218 

219def test_process_non_model_base_class_fields() -> None: 

220 """Fields in a non-model base class must be processed.""" 

221 code = """ 

222 from pydantic import BaseModel, field 

223 

224 class A: 

225 a: int = 0 

226 

227 class B(BaseModel, A): 

228 b: int = 1 

229 """ 

230 with temporary_visited_package( 

231 "package", 

232 modules={"__init__.py": code}, 

233 extensions=Extensions(PydanticExtension(schema=False)), 

234 ) as package: 

235 assert "pydantic-field" in package["B.a"].labels