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
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-05 17:55 +0200
1from __future__ import annotations
3import ast
4import sys
5from typing import TYPE_CHECKING
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)
23from griffe_pydantic._internal import common
25if TYPE_CHECKING:
26 from pathlib import Path
29_logger = get_logger("griffe_pydantic")
32def _inherits_pydantic(cls: Class) -> bool:
33 """Tell whether a class inherits from a Pydantic model.
35 Parameters:
36 cls: A Griffe class.
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
47 return any(_inherits_pydantic(parent_class) for parent_class in cls.mro())
50def _pydantic_validator(func: Function) -> ExprCall | None:
51 """Return a function's `pydantic.field_validator` decorator if it exists.
53 Parameters:
54 func: A Griffe function.
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
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)
74 # Properties are not fields.
75 if "property" in attr.labels:
76 return
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
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 }
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]
97 elif attr.value is not None:
98 kwargs["default"] = attr.value
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
113 attr.labels.add("pydantic-field")
114 attr.labels.discard("class-attribute")
115 attr.labels.discard("instance-attribute")
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
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")
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)
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
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)
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
149 if not _inherits_pydantic(cls):
150 return
152 processed.add(cls.canonical_path)
154 common._process_class(cls)
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)
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]
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)
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)
196 for submodule in mod.modules.values():
197 _process_module(submodule, processed=processed, schema=schema)