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
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-18 01:11 +0100
1"""Griffe extension for Pydantic."""
3from __future__ import annotations
5import ast
6import sys
7from typing import TYPE_CHECKING
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)
24from griffe_pydantic import common
26if TYPE_CHECKING:
27 from pathlib import Path
30logger = get_logger(__name__)
33def inherits_pydantic(cls: Class) -> bool:
34 """Tell whether a class inherits from a Pydantic model.
36 Parameters:
37 cls: A Griffe class.
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
48 return any(inherits_pydantic(parent_class) for parent_class in cls.mro())
51def pydantic_validator(func: Function) -> ExprCall | None:
52 """Return a function's `pydantic.field_validator` decorator if it exists.
54 Parameters:
55 func: A Griffe function.
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
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)
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
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 }
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]
94 elif attr.value is not None:
95 kwargs["default"] = attr.value
97 if attr.name == "model_config":
98 cls.extra[common.self_namespace]["config"] = kwargs
99 return
101 attr.labels.add("pydantic-field")
102 attr.labels.discard("class-attribute")
103 attr.labels.discard("instance-attribute")
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
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")
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)
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
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)
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
137 if not inherits_pydantic(cls):
138 return
140 processed.add(cls.canonical_path)
142 common.process_class(cls)
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)
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)
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)
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)
183 for submodule in mod.modules.values():
184 process_module(submodule, processed=processed, schema=schema)