Coverage for src/griffe_pydantic/_internal/static.py: 86.67%

103 statements  

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

1from __future__ import annotations 

2 

3import ast 

4import sys 

5from typing import TYPE_CHECKING 

6 

7from griffe import ( 

8 Alias, 

9 Attribute, 

10 Class, 

11 Docstring, 

12 Expr, 

13 ExprCall, 

14 ExprKeyword, 

15 ExprName, 

16 Function, 

17 Kind, 

18 Module, 

19 dynamic_import, 

20 get_logger, 

21) 

22 

23from griffe_pydantic._internal import common 

24 

25if TYPE_CHECKING: 

26 from pathlib import Path 

27 

28 

29_logger = get_logger("griffe_pydantic") 

30 

31 

32def _inherits_pydantic(cls: Class) -> bool: 

33 """Tell whether a class inherits from a Pydantic model. 

34 

35 Parameters: 

36 cls: A Griffe class. 

37 

38 Returns: 

39 True/False. 

40 """ 

41 for base in cls.bases: 

42 if isinstance(base, (ExprName, Expr)): 42 ↛ 44line 42 didn't jump to line 44 because the condition on line 42 was always true

43 base = base.canonical_path # noqa: PLW2901 

44 if base in {"pydantic.BaseModel", "pydantic.main.BaseModel"}: 

45 return True 

46 

47 return any(_inherits_pydantic(parent_class) for parent_class in cls.mro()) 

48 

49 

50def _pydantic_validator(func: Function) -> ExprCall | None: 

51 """Return a function's `pydantic.field_validator` decorator if it exists. 

52 

53 Parameters: 

54 func: A Griffe function. 

55 

56 Returns: 

57 A decorator value (Griffe expression). 

58 """ 

59 for decorator in func.decorators: 

60 if isinstance(decorator.value, ExprCall) and decorator.callable_path in { 60 ↛ 59line 60 didn't jump to line 59 because the condition on line 60 was always true

61 "pydantic.field_validator", 

62 "pydantic.model_validator", 

63 }: 

64 return decorator.value 

65 return None 

66 

67 

68def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str]) -> None: 

69 """Handle Pydantic fields.""" 

70 if attr.canonical_path in processed: 

71 return 

72 processed.add(attr.canonical_path) 

73 

74 # Properties are not fields. 

75 if "property" in attr.labels: 

76 return 

77 

78 # Presence of `class-attribute` label and absence of `instance-attribute` label 

79 # indicates that the attribute is annotated with `ClassVar` and should be ignored. 

80 if "class-attribute" in attr.labels and "instance-attribute" not in attr.labels: 

81 return 

82 

83 kwargs = {} 

84 if isinstance(attr.value, ExprCall): 

85 kwargs = { 

86 argument.name: argument.value for argument in attr.value.arguments if isinstance(argument, ExprKeyword) 

87 } 

88 

89 if ( 

90 attr.value.function.canonical_path == "pydantic.Field" 

91 and len(attr.value.arguments) >= 1 

92 and not isinstance(attr.value.arguments[0], ExprKeyword) 

93 and attr.value.arguments[0] != "..." # handle Field(...), i.e. no default 

94 ): 

95 kwargs["default"] = attr.value.arguments[0] 

96 

97 elif attr.value is not None: 

98 kwargs["default"] = attr.value 

99 

100 if attr.name == "model_config": 

101 config = {} 

102 for key, value in kwargs.items(): 

103 if isinstance(value, str): 

104 try: 

105 config[key] = ast.literal_eval(value) 

106 except ValueError: 

107 config[key] = value 

108 else: 

109 config[key] = value 

110 cls.extra[common._self_namespace]["config"] = config 

111 return 

112 

113 attr.labels.add("pydantic-field") 

114 attr.labels.discard("class-attribute") 

115 attr.labels.discard("instance-attribute") 

116 

117 attr.value = kwargs.get("default") 

118 constraints = {kwarg: value for kwarg, value in kwargs.items() if kwarg not in {"default", "description"}} 

119 attr.extra[common._self_namespace]["constraints"] = constraints 

120 

121 # Populate docstring from the field's `description` argument. 

122 if not attr.docstring and (docstring := kwargs.get("description")): 

123 try: 

124 attr.docstring = Docstring(ast.literal_eval(docstring), parent=attr) # type: ignore[arg-type] 

125 except ValueError: 

126 _logger.debug(f"Could not parse description of field '{attr.path}' as literal, skipping") 

127 

128 

129def _process_function(func: Function, cls: Class, *, processed: set[str]) -> None: 

130 """Handle Pydantic field validators.""" 

131 if func.canonical_path in processed: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true

132 return 

133 processed.add(func.canonical_path) 

134 

135 if isinstance(func, Alias): 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true

136 _logger.debug(f"Cannot yet process {func}") 

137 return 

138 

139 if decorator := _pydantic_validator(func): 

140 fields = [ast.literal_eval(field) for field in decorator.arguments if isinstance(field, str)] 

141 common._process_function(func, cls, fields) 

142 

143 

144def _process_class(cls: Class, *, processed: set[str], schema: bool = False) -> None: 

145 """Finalize the Pydantic model data.""" 

146 if cls.canonical_path in processed: 

147 return 

148 

149 if not _inherits_pydantic(cls): 

150 return 

151 

152 processed.add(cls.canonical_path) 

153 

154 common._process_class(cls) 

155 

156 if schema: 

157 import_path: Path | list[Path] = cls.package.filepath 

158 if isinstance(import_path, list): 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true

159 import_path = import_path[-1] 

160 if import_path.name == "__init__.py": 160 ↛ 162line 160 didn't jump to line 162 because the condition on line 160 was always true

161 import_path = import_path.parent 

162 import_path = import_path.parent 

163 try: 

164 true_class = dynamic_import(cls.path, import_paths=[import_path, *sys.path]) 

165 except ImportError: 

166 _logger.debug(f"Could not import class {cls.path} for JSON schema") 

167 return 

168 cls.extra[common._self_namespace]["schema"] = common._json_schema(true_class) 

169 

170 for member in cls.all_members.values(): 

171 kind = member.kind 

172 if kind is Kind.ATTRIBUTE: 

173 _process_attribute(member, cls, processed=processed) # type: ignore[arg-type] 

174 elif kind is Kind.FUNCTION: 174 ↛ 176line 174 didn't jump to line 176 because the condition on line 174 was always true

175 _process_function(member, cls, processed=processed) # type: ignore[arg-type] 

176 elif kind is Kind.CLASS: 

177 _process_class(member, processed=processed, schema=schema) # type: ignore[arg-type] 

178 

179 

180def _process_module( 

181 mod: Module, 

182 *, 

183 processed: set[str], 

184 schema: bool = False, 

185) -> None: 

186 """Handle Pydantic models in a module.""" 

187 if mod.canonical_path in processed: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true

188 return 

189 processed.add(mod.canonical_path) 

190 

191 for cls in mod.classes.values(): 

192 # Don't process aliases, real classes will be processed at some point anyway. 

193 if not cls.is_alias: 

194 _process_class(cls, processed=processed, schema=schema) 

195 

196 for submodule in mod.modules.values(): 

197 _process_module(submodule, processed=processed, schema=schema)