Skip to content

Commit 2c4b3c9

Browse files
committed
refactor: use custom template
1 parent a45482b commit 2c4b3c9

6 files changed

Lines changed: 158 additions & 63 deletions

File tree

examples/model_noserialize.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@ class UserModel(BaseModel):
2020
email_address: str
2121
"""User's email address."""
2222

23-
is_active: bool = Field(default=TRUE)
23+
is_active: bool = Field(default=True)
2424
"""Whether the user is active, serialized as 'active'."""

src/griffe_pydantic/_internal/common.py

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,36 +29,7 @@
2929

3030

3131
def _model_fields(cls: Class) -> dict[str, Attribute]:
32-
"""Get model fields, using serialization_alias when configured.
33-
34-
Parameters:
35-
cls: The Griffe class representing the Pydantic model.
36-
37-
Returns:
38-
A dictionary of field name to Attribute, using serialization_alias as keys when appropriate.
39-
"""
40-
fields = {name: attr for name, attr in cls.all_members.items() if "pydantic-field" in attr.labels}
41-
42-
ext_namespace = cls.extra.get(_self_namespace, {})
43-
serialize_by_alias = ext_namespace.get("serialize_by_alias", False)
44-
45-
if not serialize_by_alias:
46-
return fields # ty: ignore[invalid-return-type]
47-
48-
# Re-key fields with their serialization_alias if present.
49-
# For dynamic analysis, Pydantic fields don't appear as labeled members so we fall back
50-
# to _pydantic_model_fields (populated from model_fields in dynamic._process_class).
51-
pydantic_fields = ext_namespace.get("_pydantic_model_fields", {})
52-
source = fields or dict.fromkeys(pydantic_fields)
53-
remapped_fields = {}
54-
for name, attr in source.items():
55-
if attr is not None:
56-
serialization_alias = attr.extra.get(_self_namespace, {}).get("serialization_alias")
57-
else:
58-
field_info = pydantic_fields.get(name)
59-
serialization_alias = getattr(field_info, "serialization_alias", None) if field_info else None
60-
remapped_fields[serialization_alias or name] = attr
61-
return remapped_fields
32+
return {name: attr for name, attr in cls.all_members.items() if "pydantic-field" in attr.labels} # ty: ignore[invalid-return-type]
6233

6334

6435
def _model_validators(cls: Class) -> dict[str, Function]:
@@ -77,15 +48,13 @@ def _json_schema(model: type[BaseModel]) -> str:
7748
return json.dumps(model.model_json_schema(), indent=2)
7849

7950

80-
def _process_class(cls: Class, *, serialize_by_alias: bool = False) -> None:
51+
def _process_class(cls: Class) -> None:
8152
"""Set metadata on a Pydantic model.
8253
8354
Parameters:
8455
cls: The Griffe class representing the Pydantic model.
85-
serialize_by_alias: Whether to use serialization_alias as the field name.
8656
"""
8757
cls.labels.add("pydantic-model")
88-
cls.extra[_self_namespace]["serialize_by_alias"] = serialize_by_alias
8958
cls.extra[_self_namespace]["fields"] = partial(_model_fields, cls)
9059
cls.extra[_self_namespace]["validators"] = partial(_model_validators, cls)
9160
cls.extra[_mkdocstrings_namespace]["template"] = "pydantic_model.html.jinja"

src/griffe_pydantic/_internal/dynamic.py

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@
2020
_logger = get_logger("griffe_pydantic")
2121

2222

23-
def _process_attribute(obj: Any, attr: Attribute, cls: Class, *, processed: set[str]) -> None:
23+
def _process_attribute(
24+
obj: Any,
25+
attr: Attribute,
26+
cls: Class,
27+
*,
28+
processed: set[str],
29+
serialize_by_alias: bool = False,
30+
) -> None:
2431
"""Handle Pydantic fields."""
2532
from pydantic.fields import FieldInfo # noqa: PLC0415
2633

@@ -43,9 +50,9 @@ def _process_attribute(obj: Any, attr: Attribute, cls: Class, *, processed: set[
4350
attr.extra[common._self_namespace]["constraints"] = constraints
4451

4552
# Store serialization_alias if present
46-
if obj.serialization_alias:
53+
if serialize_by_alias and obj.serialization_alias:
4754
attr.extra[common._self_namespace]["serialization_alias"] = obj.serialization_alias
48-
attr.name = obj.serialization_alias
55+
attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_attribute_alias.html.jinja"
4956

5057
# Populate docstring from the field's `description` argument.
5158
if not attr.docstring and (docstring := obj.description):
@@ -70,7 +77,7 @@ def _process_class(
7077
serialize_by_alias: bool = False,
7178
) -> None:
7279
"""Detect and prepare Pydantic models."""
73-
common._process_class(cls, serialize_by_alias=serialize_by_alias)
80+
common._process_class(cls)
7481
if schema:
7582
try:
7683
cls.extra[common._self_namespace]["schema"] = common._json_schema(obj) # ty: ignore[invalid-argument-type]
@@ -80,25 +87,12 @@ def _process_class(
8087
for member in cls.all_members.values():
8188
kind = member.kind
8289
if kind is Kind.ATTRIBUTE:
83-
_process_attribute(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type]
90+
_process_attribute(
91+
getattr(obj, member.name),
92+
member, # ty: ignore[invalid-argument-type]
93+
cls,
94+
processed=processed,
95+
serialize_by_alias=serialize_by_alias,
96+
)
8497
elif kind is Kind.FUNCTION:
8598
_process_function(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type]
86-
87-
# Also process Pydantic model fields directly from model_fields
88-
# These are FieldInfo objects that may not appear as regular member attributes
89-
if model_fields := getattr(obj, "model_fields", None):
90-
pydantic_fields = {}
91-
for field_name, field_info in model_fields.items():
92-
pydantic_fields[field_name] = field_info
93-
# If the field has a serialization_alias and serialize_by_alias is enabled,
94-
# we want to use the alias in the output
95-
if hasattr(field_info, "serialization_alias") and field_info.serialization_alias:
96-
from pydantic.fields import FieldInfo # noqa: PLC0415
97-
98-
if isinstance(field_info, FieldInfo):
99-
# Create a synthetic field entry with the alias
100-
# Store the FieldInfo in a place where _model_fields can access it
101-
pass
102-
103-
# Store model fields for later use
104-
cls.extra[common._self_namespace]["_pydantic_model_fields"] = pydantic_fields

src/griffe_pydantic/_internal/static.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def _pydantic_validator(func: Function) -> ExprCall | None:
9696
return None
9797

9898

99-
def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str]) -> None:
99+
def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str], serialize_by_alias: bool = False) -> None:
100100
"""Handle Pydantic fields."""
101101
if attr.canonical_path in processed:
102102
return
@@ -197,13 +197,14 @@ def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str]) -> N
197197
attr.extra[common._self_namespace]["constraints"] = constraints
198198

199199
# Store serialization_alias if present
200-
if serialization_alias := kwargs.get("serialization_alias"):
200+
if serialize_by_alias and (serialization_alias := kwargs.get("serialization_alias")):
201201
if isinstance(serialization_alias, str):
202202
try:
203203
attr.extra[common._self_namespace]["serialization_alias"] = ast.literal_eval(serialization_alias)
204204
except ValueError:
205205
attr.extra[common._self_namespace]["serialization_alias"] = serialization_alias
206-
attr.name = attr.extra[common._self_namespace]["serialization_alias"]
206+
# Set the attribute template to the custom template, which will use the serialization_alias instead of the attribute name.
207+
attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_attribute_alias.html.jinja"
207208
elif isinstance(serialization_alias, (ExprName, Expr)):
208209
# For now, we can't resolve expressions at static analysis time
209210
_logger.debug(f"Could not resolve serialization_alias expression for field '{attr.path}'")
@@ -241,7 +242,7 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False, ser
241242

242243
processed.add(cls.canonical_path)
243244

244-
common._process_class(cls, serialize_by_alias=serialize_by_alias)
245+
common._process_class(cls)
245246

246247
if schema:
247248
import_path: Path | list[Path] = cls.package.filepath
@@ -266,7 +267,7 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False, ser
266267
for member in cls.all_members.values():
267268
kind = member.kind
268269
if kind is Kind.ATTRIBUTE:
269-
_process_attribute(member, cls, processed=processed) # ty: ignore[invalid-argument-type]
270+
_process_attribute(member, cls, processed=processed, serialize_by_alias=serialize_by_alias) # ty: ignore[invalid-argument-type]
270271
elif kind is Kind.FUNCTION:
271272
_process_function(member, cls, processed=processed) # ty: ignore[invalid-argument-type]
272273
elif kind is Kind.CLASS:
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
{#- Template for Python attributes.
2+
3+
This template renders a Python attribute (or variable).
4+
This can be a module attribute or a class attribute.
5+
6+
Context:
7+
attribute (griffe.Attribute): The attribute to render.
8+
root (bool): Whether this is the root object, injected with `:::` in a Markdown page.
9+
heading_level (int): The HTML heading level to use.
10+
config (dict): The configuration options.
11+
-#}
12+
13+
{% block logs scoped %}
14+
{#- Logging block.
15+
16+
This block can be used to log debug messages, deprecation messages, warnings, etc.
17+
-#}
18+
{{ log.debug("Rendering " + attribute.path) }}
19+
{% endblock logs %}
20+
21+
<div class="doc doc-object doc-attribute">
22+
{% with obj = attribute, html_id = attribute.path %}
23+
24+
{% if root %}
25+
{% set show_full_path = config.show_root_full_path %}
26+
{% set root_members = True %}
27+
{% elif root_members %}
28+
{% set show_full_path = config.show_root_members_full_path or config.show_object_full_path %}
29+
{% set root_members = False %}
30+
{% else %}
31+
{% set show_full_path = config.show_object_full_path %}
32+
{% endif %}
33+
34+
{# {% set attribute_name = attribute.path if show_full_path else attribute.name %} #}
35+
{# TODO some better way to visualize the alias #}
36+
{% set attribute_name = attribute.extra.griffe_pydantic.serialization_alias %}
37+
38+
{% if not root or config.show_root_heading %}
39+
{% filter heading(
40+
heading_level,
41+
role="data" if attribute.parent.kind.value == "module" else "attr",
42+
id=html_id,
43+
class="doc doc-heading",
44+
toc_label=('<code class="doc-symbol doc-symbol-toc doc-symbol-attribute"></code>&nbsp;'|safe if config.show_symbol_type_toc else '') + (config.toc_label if config.toc_label and root else attribute.name),
45+
skip_inventory=config.skip_local_inventory,
46+
) %}
47+
48+
{% block heading scoped %}
49+
{#- Heading block.
50+
51+
This block renders the heading for the attribute.
52+
-#}
53+
{% if config.show_symbol_type_heading %}<code class="doc-symbol doc-symbol-heading doc-symbol-attribute"></code>{% endif %}
54+
{% if config.heading and root %}
55+
{{ config.heading }}
56+
{% elif config.separate_signature %}
57+
<span class="doc doc-object-name doc-attribute-name">{{ attribute_name }}</span>
58+
{% else %}
59+
{%+ filter highlight(language="python", inline=True) %}
60+
{{ attribute_name }}{% if attribute.annotation and config.show_signature_annotations %}: {{ attribute.annotation }}{% endif %}
61+
{% if config.show_attribute_values and attribute.value %} = {{ attribute.value }}{% endif %}
62+
{% endfilter %}
63+
{% endif %}
64+
{% endblock heading %}
65+
66+
{% block labels scoped %}
67+
{#- Labels block.
68+
69+
This block renders the labels for the attribute.
70+
-#}
71+
{% with labels = attribute.labels %}
72+
{% include "labels.html.jinja" with context %}
73+
{% endwith %}
74+
{% endblock labels %}
75+
76+
{% endfilter %}
77+
78+
{% block signature scoped %}
79+
{#- Signature block.
80+
81+
This block renders the signature for the attribute.
82+
-#}
83+
{% if config.separate_signature %}
84+
{% filter format_attribute(attribute, config.line_length, crossrefs=config.signature_crossrefs, show_value=config.show_attribute_values) %}
85+
{{ attribute.name }}
86+
{% endfilter %}
87+
{% endif %}
88+
{% endblock signature %}
89+
90+
{% else %}
91+
92+
{% if config.show_root_toc_entry %}
93+
{% filter heading(heading_level,
94+
role="data" if attribute.parent.kind.value == "module" else "attr",
95+
id=html_id,
96+
toc_label=('<code class="doc-symbol doc-symbol-toc doc-symbol-attribute"></code>&nbsp;'|safe if config.show_symbol_type_toc else '') + (config.toc_label if config.toc_label and root else attribute_name),
97+
hidden=True,
98+
skip_inventory=config.skip_local_inventory,
99+
) %}
100+
{% endfilter %}
101+
{% endif %}
102+
{% set heading_level = heading_level - 1 %}
103+
{% endif %}
104+
105+
<div class="doc doc-contents {% if root %}first{% endif %}">
106+
{% block contents scoped %}
107+
{#- Contents block.
108+
109+
This block renders the contents of the attribute.
110+
It contains other blocks that users can override.
111+
Overriding the contents block allows to rearrange the order of the blocks.
112+
-#}
113+
{% block docstring scoped %}
114+
{#- Docstring block.
115+
116+
This block renders the docstring for the attribute.
117+
-#}
118+
{% with docstring_sections = attribute.docstring.parsed %}
119+
{% include "docstring.html.jinja" with context %}
120+
{% endwith %}
121+
{% endblock docstring %}
122+
123+
{% if config.backlinks %}
124+
<backlinks identifier="{{ html_id }}" handler="python" />
125+
{% endif %}
126+
{% endblock contents %}
127+
</div>
128+
129+
{% endwith %}
130+
</div>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{% extends "_base/pydantic_attribute_alias.html.jinja" %}

0 commit comments

Comments
 (0)