Coverage for src/griffe_pydantic/static.py: 85.81%

92 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-18 01:11 +0100

1"""Griffe extension for Pydantic.""" 

2 

3from __future__ import annotations 

4 

5import ast 

6import sys 

7from typing import TYPE_CHECKING 

8 

9from griffe import ( 

10 Alias, 

11 Attribute, 

12 Class, 

13 Docstring, 

14 Expr, 

15 ExprCall, 

16 ExprKeyword, 

17 ExprName, 

18 Function, 

19 Module, 

20 dynamic_import, 

21 get_logger, 

22) 

23 

24from griffe_pydantic import common 

25 

26if TYPE_CHECKING: 

27 from pathlib import Path 

28 

29 

30logger = get_logger(__name__) 

31 

32 

33def inherits_pydantic(cls: Class) -> bool: 

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

35 

36 Parameters: 

37 cls: A Griffe class. 

38 

39 Returns: 

40 True/False. 

41 """ 

42 for base in cls.bases: 

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

44 base = base.canonical_path # noqa: PLW2901 

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

46 return True 

47 

48 return any(inherits_pydantic(parent_class) for parent_class in cls.mro()) 

49 

50 

51def pydantic_validator(func: Function) -> ExprCall | None: 

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

53 

54 Parameters: 

55 func: A Griffe function. 

56 

57 Returns: 

58 A decorator value (Griffe expression). 

59 """ 

60 for decorator in func.decorators: 

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

62 "pydantic.field_validator", 

63 "pydantic.model_validator", 

64 }: 

65 return decorator.value 

66 return None 

67 

68 

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

70 """Handle Pydantic fields.""" 

71 if attr.canonical_path in processed: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true

72 return 

73 processed.add(attr.canonical_path) 

74 

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

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

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

78 return 

79 

80 kwargs = {} 

81 if isinstance(attr.value, ExprCall): 

82 kwargs = { 

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

84 } 

85 

86 if ( 

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

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

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

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

91 ): 

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

93 

94 elif attr.value is not None: 

95 kwargs["default"] = attr.value 

96 

97 if attr.name == "model_config": 

98 cls.extra[common.self_namespace]["config"] = kwargs 

99 return 

100 

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

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

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

104 

105 attr.value = kwargs.get("default", None) 

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

107 attr.extra[common.self_namespace]["constraints"] = constraints 

108 

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

110 if not attr.docstring and (docstring := kwargs.get("description", None)): 

111 try: 

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

113 except ValueError: 

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

115 

116 

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

118 """Handle Pydantic field validators.""" 

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

120 return 

121 processed.add(func.canonical_path) 

122 

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

124 logger.warning(f"cannot yet process {func}") 

125 return 

126 

127 if decorator := pydantic_validator(func): 

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

129 common.process_function(func, cls, fields) 

130 

131 

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

133 """Finalize the Pydantic model data.""" 

134 if cls.canonical_path in processed: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true

135 return 

136 

137 if not inherits_pydantic(cls): 

138 return 

139 

140 processed.add(cls.canonical_path) 

141 

142 common.process_class(cls) 

143 

144 if schema: 

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

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

147 import_path = import_path[-1] 

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

149 import_path = import_path.parent 

150 import_path = import_path.parent 

151 try: 

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

153 except ImportError: 

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

155 return 

156 cls.extra[common.self_namespace]["schema"] = common.json_schema(true_class) 

157 

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

159 if isinstance(member, Attribute): 

160 process_attribute(member, cls, processed=processed) 

161 elif isinstance(member, Function): 

162 process_function(member, cls, processed=processed) 

163 elif isinstance(member, Class): 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 process_class(member, processed=processed, schema=schema) 

165 

166 

167def process_module( 

168 mod: Module, 

169 *, 

170 processed: set[str], 

171 schema: bool = False, 

172) -> None: 

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

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

175 return 

176 processed.add(mod.canonical_path) 

177 

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

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

180 if not cls.is_alias: 

181 process_class(cls, processed=processed, schema=schema) 

182 

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

184 process_module(submodule, processed=processed, schema=schema)