Skip to content

Commit 562d83e

Browse files
committed
feat: serialize on alias
1 parent 9df8760 commit 562d83e

8 files changed

Lines changed: 250 additions & 14 deletions

File tree

docs/index.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,53 @@ hide:
5050

5151
///
5252

53+
### Serialization Aliases
54+
55+
When the extension is configured with `serialize_by_alias=True`, fields with a `serialization_alias` will appear under their alias names in the documentation. This is useful for APIs where the serialized output uses different field names than the Python attribute names. See [Pydantic's alias documentation](https://docs.pydantic.dev/latest/concepts/alias/) for more information.
56+
57+
To enable this feature in your documentation configuration, configure the extension as follows:
58+
59+
```yaml
60+
plugins:
61+
- mkdocstrings:
62+
handlers:
63+
python:
64+
extensions:
65+
- griffe_pydantic:
66+
serialize_by_alias: true
67+
```
68+
69+
/// tab | Pydantic model
70+
71+
```python
72+
--8<-- "examples/model_serialize.py"
73+
```
74+
75+
///
76+
77+
/// tab | Without alias
78+
79+
```md exec="true" updatetoc="false"
80+
::: model_noserialize.UserModel
81+
options:
82+
heading_level: 4
83+
extensions:
84+
- griffe_pydantic: {serialize_by_alias: false}
85+
skip_local_inventory: true
86+
```
87+
88+
///
89+
90+
/// tab | With alias
91+
92+
```md exec="true" updatetoc="false"
93+
::: model_serialize.UserModel
94+
options:
95+
heading_level: 4
96+
extensions:
97+
- griffe_pydantic: {serialize_by_alias: true}
98+
skip_local_inventory: true
99+
```
100+
101+
///
102+

examples/model_noserialize.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from pickle import TRUE
2+
from pydantic import BaseModel, ConfigDict, Field
3+
4+
5+
class UserModel(BaseModel):
6+
"""A user model with serialization aliases.
7+
8+
When the extension is configured with `serialize_by_alias=True`, fields with
9+
`serialization_alias` will appear under their alias names in the documentation.
10+
"""
11+
12+
model_config = ConfigDict(frozen=False)
13+
14+
user_id: int = Field()
15+
"""Unique user identifier, serialized as 'id'."""
16+
17+
full_name: str = Field(default="Anonymous")
18+
"""User's full name, serialized as 'name'."""
19+
20+
email_address: str
21+
"""User's email address."""
22+
23+
is_active: bool = Field(default=TRUE)
24+
"""Whether the user is active, serialized as 'active'."""

examples/model_serialize.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from pydantic import BaseModel, ConfigDict, Field
2+
3+
4+
class UserModel(BaseModel):
5+
"""A user model with serialization aliases.
6+
7+
When the extension is configured with `serialize_by_alias=True`, fields with
8+
`serialization_alias` will appear under their alias names in the documentation.
9+
"""
10+
11+
model_config = ConfigDict(frozen=False)
12+
13+
user_id: int = Field(serialization_alias="id")
14+
"""Unique user identifier, serialized as 'id'."""
15+
16+
full_name: str = Field(default="Anonymous", serialization_alias="name")
17+
"""User's full name, serialized as 'name'."""
18+
19+
email_address: str
20+
"""User's email address."""
21+
22+
is_active: bool = Field(default=True, serialization_alias="active")
23+
"""Whether the user is active, serialized as 'active'."""

src/griffe_pydantic/_internal/common.py

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

3030

3131
def _model_fields(cls: Class) -> dict[str, Attribute]:
32-
return {name: attr for name, attr in cls.all_members.items() if "pydantic-field" in attr.labels} # ty: ignore[invalid-return-type]
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
3362

3463

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

5079

51-
def _process_class(cls: Class) -> None:
80+
def _process_class(cls: Class, *, serialize_by_alias: bool = False) -> None:
5281
"""Set metadata on a Pydantic model.
5382
5483
Parameters:
5584
cls: The Griffe class representing the Pydantic model.
85+
serialize_by_alias: Whether to use serialization_alias as the field name.
5686
"""
5787
cls.labels.add("pydantic-model")
88+
cls.extra[_self_namespace]["serialize_by_alias"] = serialize_by_alias
5889
cls.extra[_self_namespace]["fields"] = partial(_model_fields, cls)
5990
cls.extra[_self_namespace]["validators"] = partial(_model_validators, cls)
6091
cls.extra[_mkdocstrings_namespace]["template"] = "pydantic_model.html.jinja"

src/griffe_pydantic/_internal/dynamic.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ def _process_attribute(obj: Any, attr: Attribute, cls: Class, *, processed: set[
4242
constraints[constraint] = value
4343
attr.extra[common._self_namespace]["constraints"] = constraints
4444

45+
# Store serialization_alias if present
46+
if obj.serialization_alias:
47+
attr.extra[common._self_namespace]["serialization_alias"] = obj.serialization_alias
48+
4549
# Populate docstring from the field's `description` argument.
4650
if not attr.docstring and (docstring := obj.description):
4751
attr.docstring = Docstring(docstring, parent=attr)
@@ -56,9 +60,16 @@ def _process_function(obj: Callable, func: Function, cls: Class, *, processed: s
5660
common._process_function(func, cls, dec_info.fields)
5761

5862

59-
def _process_class(obj: type, cls: Class, *, processed: set[str], schema: bool = False) -> None:
63+
def _process_class(
64+
obj: type,
65+
cls: Class,
66+
*,
67+
processed: set[str],
68+
schema: bool = False,
69+
serialize_by_alias: bool = False,
70+
) -> None:
6071
"""Detect and prepare Pydantic models."""
61-
common._process_class(cls)
72+
common._process_class(cls, serialize_by_alias=serialize_by_alias)
6273
if schema:
6374
try:
6475
cls.extra[common._self_namespace]["schema"] = common._json_schema(obj) # ty: ignore[invalid-argument-type]
@@ -71,3 +82,22 @@ def _process_class(obj: type, cls: Class, *, processed: set[str], schema: bool =
7182
_process_attribute(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type]
7283
elif kind is Kind.FUNCTION:
7384
_process_function(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type]
85+
86+
# Also process Pydantic model fields directly from model_fields
87+
# These are FieldInfo objects that may not appear as regular member attributes
88+
if model_fields := getattr(obj, "model_fields", None):
89+
pydantic_fields = {}
90+
for field_name, field_info in model_fields.items():
91+
pydantic_fields[field_name] = field_info
92+
# If the field has a serialization_alias and serialize_by_alias is enabled,
93+
# we want to use the alias in the output
94+
if hasattr(field_info, "serialization_alias") and field_info.serialization_alias:
95+
from pydantic.fields import FieldInfo # noqa: PLC0415
96+
97+
if isinstance(field_info, FieldInfo):
98+
# Create a synthetic field entry with the alias
99+
# Store the FieldInfo in a place where _model_fields can access it
100+
pass
101+
102+
# Store model fields for later use
103+
cls.extra[common._self_namespace]["_pydantic_model_fields"] = pydantic_fields

src/griffe_pydantic/_internal/extension.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,37 @@
2222
class PydanticExtension(Extension):
2323
"""Griffe extension for Pydantic."""
2424

25-
def __init__(self, *, schema: bool = False) -> None:
25+
def __init__(self, *, schema: bool = False, serialize_by_alias: bool = False) -> None:
2626
"""Initialize the extension.
2727
2828
Parameters:
2929
schema: Whether to compute and store the JSON schema of models.
30+
serialize_by_alias: Whether to use `serialization_alias` as the field name in documentation.
31+
When enabled, fields with a `serialization_alias` will be keyed by that alias instead of their Python attribute name.
3032
"""
3133
super().__init__()
3234
self._schema = schema
35+
self._serialize_by_alias = serialize_by_alias
3336
self._processed: set[str] = set()
3437
self._recorded: list[tuple[ObjectNode, Class]] = []
3538

3639
def on_package(self, *, pkg: Module, **kwargs: Any) -> None: # noqa: ARG002
3740
"""Detect models once the whole package is loaded."""
3841
for node, cls in self._recorded:
3942
self._processed.add(cls.canonical_path)
40-
dynamic._process_class(node.obj, cls, processed=self._processed, schema=self._schema)
41-
static._process_module(pkg, processed=self._processed, schema=self._schema)
43+
dynamic._process_class(
44+
node.obj,
45+
cls,
46+
processed=self._processed,
47+
schema=self._schema,
48+
serialize_by_alias=self._serialize_by_alias,
49+
)
50+
static._process_module(
51+
pkg,
52+
processed=self._processed,
53+
schema=self._schema,
54+
serialize_by_alias=self._serialize_by_alias,
55+
)
4256

4357
def on_class_instance(self, *, node: ast.AST | ObjectNode, cls: Class, **kwargs: Any) -> None: # noqa: ARG002
4458
"""Detect and prepare Pydantic models."""

src/griffe_pydantic/_internal/static.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,24 @@ def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str]) -> N
189189
attr.labels.discard("instance-attribute")
190190

191191
attr.value = kwargs.get("default")
192-
constraints = {kwarg: value for kwarg, value in kwargs.items() if kwarg not in {"default", "description"}}
192+
constraints = {
193+
kwarg: value
194+
for kwarg, value in kwargs.items()
195+
if kwarg not in {"default", "description", "serialization_alias"}
196+
}
193197
attr.extra[common._self_namespace]["constraints"] = constraints
194198

199+
# Store serialization_alias if present
200+
if serialization_alias := kwargs.get("serialization_alias"):
201+
if isinstance(serialization_alias, str):
202+
try:
203+
attr.extra[common._self_namespace]["serialization_alias"] = ast.literal_eval(serialization_alias)
204+
except ValueError:
205+
attr.extra[common._self_namespace]["serialization_alias"] = serialization_alias
206+
elif isinstance(serialization_alias, (ExprName, Expr)):
207+
# For now, we can't resolve expressions at static analysis time
208+
_logger.debug(f"Could not resolve serialization_alias expression for field '{attr.path}'")
209+
195210
# Populate docstring from the field's `description` argument.
196211
if not attr.docstring and (description_expr := kwargs.get("description")):
197212
if description_text := _extract_description(description_expr):
@@ -215,7 +230,7 @@ def _process_function(func: Function, cls: Class, *, processed: set[str]) -> Non
215230
common._process_function(func, cls, fields)
216231

217232

218-
def _process_class(cls: Class, *, processed: set[str], schema: bool = False) -> None:
233+
def _process_class(cls: Class, *, processed: set[str], schema: bool = False, serialize_by_alias: bool = False) -> None:
219234
"""Finalize the Pydantic model data."""
220235
if cls.canonical_path in processed:
221236
return
@@ -225,7 +240,7 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False) ->
225240

226241
processed.add(cls.canonical_path)
227242

228-
common._process_class(cls)
243+
common._process_class(cls, serialize_by_alias=serialize_by_alias)
229244

230245
if schema:
231246
import_path: Path | list[Path] = cls.package.filepath
@@ -240,7 +255,9 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False) ->
240255
_logger.debug(f"Could not import class {cls.path} for JSON schema")
241256
return
242257
try:
243-
cls.extra[common._self_namespace]["schema"] = common._json_schema(true_class)
258+
cls.extra[common._self_namespace]["schema"] = common._json_schema(
259+
true_class,
260+
)
244261
except Exception as exc: # noqa: BLE001
245262
# Schema generation can fail and raise Pydantic errors.
246263
_logger.debug("Failed to generate schema for %s: %s", cls.path, exc)
@@ -252,14 +269,15 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False) ->
252269
elif kind is Kind.FUNCTION:
253270
_process_function(member, cls, processed=processed) # ty: ignore[invalid-argument-type]
254271
elif kind is Kind.CLASS:
255-
_process_class(member, processed=processed, schema=schema) # ty: ignore[invalid-argument-type]
272+
_process_class(member, processed=processed, schema=schema, serialize_by_alias=serialize_by_alias) # ty: ignore[invalid-argument-type]
256273

257274

258275
def _process_module(
259276
mod: Module,
260277
*,
261278
processed: set[str],
262279
schema: bool = False,
280+
serialize_by_alias: bool = False,
263281
) -> None:
264282
"""Handle Pydantic models in a module."""
265283
if mod.canonical_path in processed:
@@ -269,9 +287,9 @@ def _process_module(
269287
for cls in mod.classes.values():
270288
# Don't process aliases, real classes will be processed at some point anyway.
271289
if not cls.is_alias:
272-
_process_class(cls, processed=processed, schema=schema)
290+
_process_class(cls, processed=processed, schema=schema, serialize_by_alias=serialize_by_alias)
273291

274292
for submodule in mod.modules.values():
275293
# Same for modules, don't process aliased ones.
276294
if not submodule.is_alias:
277-
_process_module(submodule, processed=processed, schema=schema)
295+
_process_module(submodule, processed=processed, schema=schema, serialize_by_alias=serialize_by_alias)

tests/test_extension.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,3 +387,49 @@ class Model(BaseModel):
387387
assert package["Model.field1"].docstring is not None
388388
assert "This is a multiline description." in package["Model.field1"].docstring.value
389389
assert "With multiple lines." in package["Model.field1"].docstring.value
390+
391+
392+
def test_serialize_by_alias_disabled_static() -> None:
393+
"""Test that without serialize_by_alias, static analysis uses Python attribute names."""
394+
code = """
395+
from pydantic import BaseModel, Field
396+
397+
class Model(BaseModel):
398+
internal_name: str = Field(default="test", serialization_alias="external_name")
399+
regular_field: int = Field(default=42)
400+
"""
401+
with temporary_visited_package(
402+
"package",
403+
modules={"__init__.py": code},
404+
extensions=Extensions(PydanticExtension(schema=False, serialize_by_alias=False)),
405+
) as package:
406+
fields = package["Model"].extra["griffe_pydantic"]["fields"]()
407+
assert "internal_name" in fields
408+
assert "regular_field" in fields
409+
assert "external_name" not in fields
410+
411+
412+
@pytest.mark.parametrize("analysis", ["static", "dynamic"])
413+
def test_serialize_by_alias_enabled(analysis: str) -> None:
414+
"""Test that serialize_by_alias extension setting uses serialization_alias as the field name."""
415+
code = """
416+
from pydantic import BaseModel, Field
417+
418+
class Model(BaseModel):
419+
internal_name: str = Field(default="test", serialization_alias="external_name")
420+
regular_field: int = Field(default=42)
421+
"""
422+
loader = {"static": temporary_visited_package, "dynamic": temporary_inspected_package}[analysis]
423+
with loader(
424+
"package",
425+
modules={"__init__.py": code},
426+
extensions=Extensions(PydanticExtension(schema=False, serialize_by_alias=True)),
427+
search_sys_path=analysis == "dynamic",
428+
) as package:
429+
model = package["Model"]
430+
assert model.labels == {"pydantic-model"}
431+
432+
fields = model.extra["griffe_pydantic"]["fields"]()
433+
assert "internal_name" not in fields
434+
assert "regular_field" in fields
435+
assert "external_name" in fields

0 commit comments

Comments
 (0)