From 562d83e102788ade102fe91993455a7aee2dfa83 Mon Sep 17 00:00:00 2001 From: mahu Date: Mon, 2 Mar 2026 17:00:08 +0100 Subject: [PATCH 01/21] feat: serialize on alias --- docs/index.md | 50 ++++++++++++++++++++++ examples/model_noserialize.py | 24 +++++++++++ examples/model_serialize.py | 23 ++++++++++ src/griffe_pydantic/_internal/common.py | 35 ++++++++++++++- src/griffe_pydantic/_internal/dynamic.py | 34 ++++++++++++++- src/griffe_pydantic/_internal/extension.py | 20 +++++++-- src/griffe_pydantic/_internal/static.py | 32 +++++++++++--- tests/test_extension.py | 46 ++++++++++++++++++++ 8 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 examples/model_noserialize.py create mode 100644 examples/model_serialize.py diff --git a/docs/index.md b/docs/index.md index 52f9a35..418c35b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,3 +50,53 @@ hide: /// +### Serialization Aliases + +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. + +To enable this feature in your documentation configuration, configure the extension as follows: + +```yaml +plugins: + - mkdocstrings: + handlers: + python: + extensions: + - griffe_pydantic: + serialize_by_alias: true +``` + +/// tab | Pydantic model + +```python +--8<-- "examples/model_serialize.py" +``` + +/// + +/// tab | Without alias + +```md exec="true" updatetoc="false" +::: model_noserialize.UserModel + options: + heading_level: 4 + extensions: + - griffe_pydantic: {serialize_by_alias: false} + skip_local_inventory: true +``` + +/// + +/// tab | With alias + +```md exec="true" updatetoc="false" +::: model_serialize.UserModel + options: + heading_level: 4 + extensions: + - griffe_pydantic: {serialize_by_alias: true} + skip_local_inventory: true +``` + +/// + diff --git a/examples/model_noserialize.py b/examples/model_noserialize.py new file mode 100644 index 0000000..2ca0a7e --- /dev/null +++ b/examples/model_noserialize.py @@ -0,0 +1,24 @@ +from pickle import TRUE +from pydantic import BaseModel, ConfigDict, Field + + +class UserModel(BaseModel): + """A user model with serialization aliases. + + When the extension is configured with `serialize_by_alias=True`, fields with + `serialization_alias` will appear under their alias names in the documentation. + """ + + model_config = ConfigDict(frozen=False) + + user_id: int = Field() + """Unique user identifier, serialized as 'id'.""" + + full_name: str = Field(default="Anonymous") + """User's full name, serialized as 'name'.""" + + email_address: str + """User's email address.""" + + is_active: bool = Field(default=TRUE) + """Whether the user is active, serialized as 'active'.""" diff --git a/examples/model_serialize.py b/examples/model_serialize.py new file mode 100644 index 0000000..55b615a --- /dev/null +++ b/examples/model_serialize.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class UserModel(BaseModel): + """A user model with serialization aliases. + + When the extension is configured with `serialize_by_alias=True`, fields with + `serialization_alias` will appear under their alias names in the documentation. + """ + + model_config = ConfigDict(frozen=False) + + user_id: int = Field(serialization_alias="id") + """Unique user identifier, serialized as 'id'.""" + + full_name: str = Field(default="Anonymous", serialization_alias="name") + """User's full name, serialized as 'name'.""" + + email_address: str + """User's email address.""" + + is_active: bool = Field(default=True, serialization_alias="active") + """Whether the user is active, serialized as 'active'.""" diff --git a/src/griffe_pydantic/_internal/common.py b/src/griffe_pydantic/_internal/common.py index ea7ed05..4f0d3f6 100644 --- a/src/griffe_pydantic/_internal/common.py +++ b/src/griffe_pydantic/_internal/common.py @@ -29,7 +29,36 @@ def _model_fields(cls: Class) -> dict[str, Attribute]: - return {name: attr for name, attr in cls.all_members.items() if "pydantic-field" in attr.labels} # ty: ignore[invalid-return-type] + """Get model fields, using serialization_alias when configured. + + Parameters: + cls: The Griffe class representing the Pydantic model. + + Returns: + A dictionary of field name to Attribute, using serialization_alias as keys when appropriate. + """ + fields = {name: attr for name, attr in cls.all_members.items() if "pydantic-field" in attr.labels} + + ext_namespace = cls.extra.get(_self_namespace, {}) + serialize_by_alias = ext_namespace.get("serialize_by_alias", False) + + if not serialize_by_alias: + return fields # ty: ignore[invalid-return-type] + + # Re-key fields with their serialization_alias if present. + # For dynamic analysis, Pydantic fields don't appear as labeled members so we fall back + # to _pydantic_model_fields (populated from model_fields in dynamic._process_class). + pydantic_fields = ext_namespace.get("_pydantic_model_fields", {}) + source = fields or dict.fromkeys(pydantic_fields) + remapped_fields = {} + for name, attr in source.items(): + if attr is not None: + serialization_alias = attr.extra.get(_self_namespace, {}).get("serialization_alias") + else: + field_info = pydantic_fields.get(name) + serialization_alias = getattr(field_info, "serialization_alias", None) if field_info else None + remapped_fields[serialization_alias or name] = attr + return remapped_fields def _model_validators(cls: Class) -> dict[str, Function]: @@ -48,13 +77,15 @@ def _json_schema(model: type[BaseModel]) -> str: return json.dumps(model.model_json_schema(), indent=2) -def _process_class(cls: Class) -> None: +def _process_class(cls: Class, *, serialize_by_alias: bool = False) -> None: """Set metadata on a Pydantic model. Parameters: cls: The Griffe class representing the Pydantic model. + serialize_by_alias: Whether to use serialization_alias as the field name. """ cls.labels.add("pydantic-model") + cls.extra[_self_namespace]["serialize_by_alias"] = serialize_by_alias cls.extra[_self_namespace]["fields"] = partial(_model_fields, cls) cls.extra[_self_namespace]["validators"] = partial(_model_validators, cls) cls.extra[_mkdocstrings_namespace]["template"] = "pydantic_model.html.jinja" diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index 8fb48bf..415e1d6 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -42,6 +42,10 @@ def _process_attribute(obj: Any, attr: Attribute, cls: Class, *, processed: set[ constraints[constraint] = value attr.extra[common._self_namespace]["constraints"] = constraints + # Store serialization_alias if present + if obj.serialization_alias: + attr.extra[common._self_namespace]["serialization_alias"] = obj.serialization_alias + # Populate docstring from the field's `description` argument. if not attr.docstring and (docstring := obj.description): attr.docstring = Docstring(docstring, parent=attr) @@ -56,9 +60,16 @@ def _process_function(obj: Callable, func: Function, cls: Class, *, processed: s common._process_function(func, cls, dec_info.fields) -def _process_class(obj: type, cls: Class, *, processed: set[str], schema: bool = False) -> None: +def _process_class( + obj: type, + cls: Class, + *, + processed: set[str], + schema: bool = False, + serialize_by_alias: bool = False, +) -> None: """Detect and prepare Pydantic models.""" - common._process_class(cls) + common._process_class(cls, serialize_by_alias=serialize_by_alias) if schema: try: 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 = _process_attribute(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type] elif kind is Kind.FUNCTION: _process_function(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type] + + # Also process Pydantic model fields directly from model_fields + # These are FieldInfo objects that may not appear as regular member attributes + if model_fields := getattr(obj, "model_fields", None): + pydantic_fields = {} + for field_name, field_info in model_fields.items(): + pydantic_fields[field_name] = field_info + # If the field has a serialization_alias and serialize_by_alias is enabled, + # we want to use the alias in the output + if hasattr(field_info, "serialization_alias") and field_info.serialization_alias: + from pydantic.fields import FieldInfo # noqa: PLC0415 + + if isinstance(field_info, FieldInfo): + # Create a synthetic field entry with the alias + # Store the FieldInfo in a place where _model_fields can access it + pass + + # Store model fields for later use + cls.extra[common._self_namespace]["_pydantic_model_fields"] = pydantic_fields diff --git a/src/griffe_pydantic/_internal/extension.py b/src/griffe_pydantic/_internal/extension.py index 8f14241..eebb84b 100644 --- a/src/griffe_pydantic/_internal/extension.py +++ b/src/griffe_pydantic/_internal/extension.py @@ -22,14 +22,17 @@ class PydanticExtension(Extension): """Griffe extension for Pydantic.""" - def __init__(self, *, schema: bool = False) -> None: + def __init__(self, *, schema: bool = False, serialize_by_alias: bool = False) -> None: """Initialize the extension. Parameters: schema: Whether to compute and store the JSON schema of models. + serialize_by_alias: Whether to use `serialization_alias` as the field name in documentation. + When enabled, fields with a `serialization_alias` will be keyed by that alias instead of their Python attribute name. """ super().__init__() self._schema = schema + self._serialize_by_alias = serialize_by_alias self._processed: set[str] = set() self._recorded: list[tuple[ObjectNode, Class]] = [] @@ -37,8 +40,19 @@ def on_package(self, *, pkg: Module, **kwargs: Any) -> None: # noqa: ARG002 """Detect models once the whole package is loaded.""" for node, cls in self._recorded: self._processed.add(cls.canonical_path) - dynamic._process_class(node.obj, cls, processed=self._processed, schema=self._schema) - static._process_module(pkg, processed=self._processed, schema=self._schema) + dynamic._process_class( + node.obj, + cls, + processed=self._processed, + schema=self._schema, + serialize_by_alias=self._serialize_by_alias, + ) + static._process_module( + pkg, + processed=self._processed, + schema=self._schema, + serialize_by_alias=self._serialize_by_alias, + ) def on_class_instance(self, *, node: ast.AST | ObjectNode, cls: Class, **kwargs: Any) -> None: # noqa: ARG002 """Detect and prepare Pydantic models.""" diff --git a/src/griffe_pydantic/_internal/static.py b/src/griffe_pydantic/_internal/static.py index 652f7b1..a58e444 100644 --- a/src/griffe_pydantic/_internal/static.py +++ b/src/griffe_pydantic/_internal/static.py @@ -189,9 +189,24 @@ def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str]) -> N attr.labels.discard("instance-attribute") attr.value = kwargs.get("default") - constraints = {kwarg: value for kwarg, value in kwargs.items() if kwarg not in {"default", "description"}} + constraints = { + kwarg: value + for kwarg, value in kwargs.items() + if kwarg not in {"default", "description", "serialization_alias"} + } attr.extra[common._self_namespace]["constraints"] = constraints + # Store serialization_alias if present + if serialization_alias := kwargs.get("serialization_alias"): + if isinstance(serialization_alias, str): + try: + attr.extra[common._self_namespace]["serialization_alias"] = ast.literal_eval(serialization_alias) + except ValueError: + attr.extra[common._self_namespace]["serialization_alias"] = serialization_alias + elif isinstance(serialization_alias, (ExprName, Expr)): + # For now, we can't resolve expressions at static analysis time + _logger.debug(f"Could not resolve serialization_alias expression for field '{attr.path}'") + # Populate docstring from the field's `description` argument. if not attr.docstring and (description_expr := kwargs.get("description")): if description_text := _extract_description(description_expr): @@ -215,7 +230,7 @@ def _process_function(func: Function, cls: Class, *, processed: set[str]) -> Non common._process_function(func, cls, fields) -def _process_class(cls: Class, *, processed: set[str], schema: bool = False) -> None: +def _process_class(cls: Class, *, processed: set[str], schema: bool = False, serialize_by_alias: bool = False) -> None: """Finalize the Pydantic model data.""" if cls.canonical_path in processed: return @@ -225,7 +240,7 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False) -> processed.add(cls.canonical_path) - common._process_class(cls) + common._process_class(cls, serialize_by_alias=serialize_by_alias) if schema: import_path: Path | list[Path] = cls.package.filepath @@ -240,7 +255,9 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False) -> _logger.debug(f"Could not import class {cls.path} for JSON schema") return try: - cls.extra[common._self_namespace]["schema"] = common._json_schema(true_class) + cls.extra[common._self_namespace]["schema"] = common._json_schema( + true_class, + ) except Exception as exc: # noqa: BLE001 # Schema generation can fail and raise Pydantic errors. _logger.debug("Failed to generate schema for %s: %s", cls.path, exc) @@ -252,7 +269,7 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False) -> elif kind is Kind.FUNCTION: _process_function(member, cls, processed=processed) # ty: ignore[invalid-argument-type] elif kind is Kind.CLASS: - _process_class(member, processed=processed, schema=schema) # ty: ignore[invalid-argument-type] + _process_class(member, processed=processed, schema=schema, serialize_by_alias=serialize_by_alias) # ty: ignore[invalid-argument-type] def _process_module( @@ -260,6 +277,7 @@ def _process_module( *, processed: set[str], schema: bool = False, + serialize_by_alias: bool = False, ) -> None: """Handle Pydantic models in a module.""" if mod.canonical_path in processed: @@ -269,9 +287,9 @@ def _process_module( for cls in mod.classes.values(): # Don't process aliases, real classes will be processed at some point anyway. if not cls.is_alias: - _process_class(cls, processed=processed, schema=schema) + _process_class(cls, processed=processed, schema=schema, serialize_by_alias=serialize_by_alias) for submodule in mod.modules.values(): # Same for modules, don't process aliased ones. if not submodule.is_alias: - _process_module(submodule, processed=processed, schema=schema) + _process_module(submodule, processed=processed, schema=schema, serialize_by_alias=serialize_by_alias) diff --git a/tests/test_extension.py b/tests/test_extension.py index 7e8de32..248bd2e 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -387,3 +387,49 @@ class Model(BaseModel): assert package["Model.field1"].docstring is not None assert "This is a multiline description." in package["Model.field1"].docstring.value assert "With multiple lines." in package["Model.field1"].docstring.value + + +def test_serialize_by_alias_disabled_static() -> None: + """Test that without serialize_by_alias, static analysis uses Python attribute names.""" + code = """ + from pydantic import BaseModel, Field + + class Model(BaseModel): + internal_name: str = Field(default="test", serialization_alias="external_name") + regular_field: int = Field(default=42) + """ + with temporary_visited_package( + "package", + modules={"__init__.py": code}, + extensions=Extensions(PydanticExtension(schema=False, serialize_by_alias=False)), + ) as package: + fields = package["Model"].extra["griffe_pydantic"]["fields"]() + assert "internal_name" in fields + assert "regular_field" in fields + assert "external_name" not in fields + + +@pytest.mark.parametrize("analysis", ["static", "dynamic"]) +def test_serialize_by_alias_enabled(analysis: str) -> None: + """Test that serialize_by_alias extension setting uses serialization_alias as the field name.""" + code = """ + from pydantic import BaseModel, Field + + class Model(BaseModel): + internal_name: str = Field(default="test", serialization_alias="external_name") + regular_field: int = Field(default=42) + """ + loader = {"static": temporary_visited_package, "dynamic": temporary_inspected_package}[analysis] + with loader( + "package", + modules={"__init__.py": code}, + extensions=Extensions(PydanticExtension(schema=False, serialize_by_alias=True)), + search_sys_path=analysis == "dynamic", + ) as package: + model = package["Model"] + assert model.labels == {"pydantic-model"} + + fields = model.extra["griffe_pydantic"]["fields"]() + assert "internal_name" not in fields + assert "regular_field" in fields + assert "external_name" in fields From 822815d89c73e9e49143302f840f3b8ccf9ef5cc Mon Sep 17 00:00:00 2001 From: mahu Date: Mon, 2 Mar 2026 17:44:52 +0100 Subject: [PATCH 02/21] test: fix test --- tests/test_extension.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_extension.py b/tests/test_extension.py index 248bd2e..7d9a37b 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -419,13 +419,12 @@ class Model(BaseModel): internal_name: str = Field(default="test", serialization_alias="external_name") regular_field: int = Field(default=42) """ - loader = {"static": temporary_visited_package, "dynamic": temporary_inspected_package}[analysis] - with loader( + with temporary_visited_package( "package", modules={"__init__.py": code}, extensions=Extensions(PydanticExtension(schema=False, serialize_by_alias=True)), - search_sys_path=analysis == "dynamic", ) as package: + model = package["Model"] assert model.labels == {"pydantic-model"} From 1b7ddd8320b60366f89020e5cc7e12428667774a Mon Sep 17 00:00:00 2001 From: mahu Date: Mon, 2 Mar 2026 17:55:29 +0100 Subject: [PATCH 03/21] test: move test --- tests/test_extension.py | 39 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/tests/test_extension.py b/tests/test_extension.py index 7d9a37b..b5fa38e 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -55,6 +55,10 @@ def regular_method(self): class RegularClass(object): regular_attr = 1 + + class AliasClass(BaseModel): + internal_name: str = Field(default="test", serialization_alias="external_name") + regular_field: int = Field(default=42) """ @@ -65,7 +69,7 @@ def test_extension(analysis: str) -> None: with loader( "package", modules={"__init__.py": code}, - extensions=Extensions(PydanticExtension(schema=True)), + extensions=Extensions(PydanticExtension(schema=True, serialize_by_alias=True)), search_sys_path=analysis == "dynamic", ) as package: assert package @@ -82,6 +86,14 @@ def test_extension(analysis: str) -> None: schema = package.classes["ExampleModel"].extra["griffe_pydantic"]["schema"] assert schema.startswith('{\n "description"') + assert "AliasClass" in package.classes + assert package.classes["AliasClass"].labels == {"pydantic-model"} + + fields = package.classes["AliasClass"].extra["griffe_pydantic"]["fields"]() + assert "internal_name" not in fields + assert "regular_field" in fields + assert "external_name" in fields + def test_imported_models() -> None: """Test the extension with imported models.""" @@ -407,28 +419,3 @@ class Model(BaseModel): assert "internal_name" in fields assert "regular_field" in fields assert "external_name" not in fields - - -@pytest.mark.parametrize("analysis", ["static", "dynamic"]) -def test_serialize_by_alias_enabled(analysis: str) -> None: - """Test that serialize_by_alias extension setting uses serialization_alias as the field name.""" - code = """ - from pydantic import BaseModel, Field - - class Model(BaseModel): - internal_name: str = Field(default="test", serialization_alias="external_name") - regular_field: int = Field(default=42) - """ - with temporary_visited_package( - "package", - modules={"__init__.py": code}, - extensions=Extensions(PydanticExtension(schema=False, serialize_by_alias=True)), - ) as package: - - model = package["Model"] - assert model.labels == {"pydantic-model"} - - fields = model.extra["griffe_pydantic"]["fields"]() - assert "internal_name" not in fields - assert "regular_field" in fields - assert "external_name" in fields From 5d7e92d638516f475565938892b7383aa614d015 Mon Sep 17 00:00:00 2001 From: mahu Date: Mon, 2 Mar 2026 18:28:28 +0100 Subject: [PATCH 04/21] fix: set name --- src/griffe_pydantic/_internal/dynamic.py | 1 + src/griffe_pydantic/_internal/static.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index 415e1d6..0c51b3a 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -45,6 +45,7 @@ def _process_attribute(obj: Any, attr: Attribute, cls: Class, *, processed: set[ # Store serialization_alias if present if obj.serialization_alias: attr.extra[common._self_namespace]["serialization_alias"] = obj.serialization_alias + attr.name = obj.serialization_alias # Populate docstring from the field's `description` argument. if not attr.docstring and (docstring := obj.description): diff --git a/src/griffe_pydantic/_internal/static.py b/src/griffe_pydantic/_internal/static.py index a58e444..fe781d9 100644 --- a/src/griffe_pydantic/_internal/static.py +++ b/src/griffe_pydantic/_internal/static.py @@ -203,6 +203,7 @@ def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str]) -> N attr.extra[common._self_namespace]["serialization_alias"] = ast.literal_eval(serialization_alias) except ValueError: attr.extra[common._self_namespace]["serialization_alias"] = serialization_alias + attr.name = attr.extra[common._self_namespace]["serialization_alias"] elif isinstance(serialization_alias, (ExprName, Expr)): # For now, we can't resolve expressions at static analysis time _logger.debug(f"Could not resolve serialization_alias expression for field '{attr.path}'") From b8975215db69852e4634aee2a9134a3aa9d66d99 Mon Sep 17 00:00:00 2001 From: mahu Date: Tue, 3 Mar 2026 08:46:30 +0100 Subject: [PATCH 05/21] bump: pydantic --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index a533e15..05b8cfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ classifiers = [ ] dependencies = [ "griffelib>=2.0", + "pydantic>=2.12.0", ] [project.urls] From 1480ca7c781259152f087aaf8b908af6eee9af42 Mon Sep 17 00:00:00 2001 From: mahu Date: Tue, 3 Mar 2026 08:48:03 +0100 Subject: [PATCH 06/21] deps: remove pydantic --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 05b8cfc..a533e15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ classifiers = [ ] dependencies = [ "griffelib>=2.0", - "pydantic>=2.12.0", ] [project.urls] From a45482bd692fd53186e6f6408d4c9798d67a060a Mon Sep 17 00:00:00 2001 From: mahu Date: Tue, 3 Mar 2026 08:49:53 +0100 Subject: [PATCH 07/21] deps: bump pydantic in ci group --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a533e15..7b0780c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ maintain = [ ci = [ "duty>=1.6", "griffe>=2.0", - "pydantic>=2.10", + "pydantic>=2.12", "pytest>=8.2", "pytest-cov>=5.0", "pytest-randomly>=3.15", From 2c4b3c9fadf9770287201fe6de135e6f5147036a Mon Sep 17 00:00:00 2001 From: mahu Date: Thu, 5 Mar 2026 22:59:12 +0100 Subject: [PATCH 08/21] refactor: use custom template --- examples/model_noserialize.py | 2 +- src/griffe_pydantic/_internal/common.py | 35 +---- src/griffe_pydantic/_internal/dynamic.py | 42 +++--- src/griffe_pydantic/_internal/static.py | 11 +- .../_base/pydantic_attribute_alias.html.jinja | 130 ++++++++++++++++++ .../pydantic_attribute_alias.html.jinja | 1 + 6 files changed, 158 insertions(+), 63 deletions(-) create mode 100644 src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja create mode 100644 src/griffe_pydantic/templates/material/pydantic_attribute_alias.html.jinja diff --git a/examples/model_noserialize.py b/examples/model_noserialize.py index 2ca0a7e..833249d 100644 --- a/examples/model_noserialize.py +++ b/examples/model_noserialize.py @@ -20,5 +20,5 @@ class UserModel(BaseModel): email_address: str """User's email address.""" - is_active: bool = Field(default=TRUE) + is_active: bool = Field(default=True) """Whether the user is active, serialized as 'active'.""" diff --git a/src/griffe_pydantic/_internal/common.py b/src/griffe_pydantic/_internal/common.py index 4f0d3f6..ea7ed05 100644 --- a/src/griffe_pydantic/_internal/common.py +++ b/src/griffe_pydantic/_internal/common.py @@ -29,36 +29,7 @@ def _model_fields(cls: Class) -> dict[str, Attribute]: - """Get model fields, using serialization_alias when configured. - - Parameters: - cls: The Griffe class representing the Pydantic model. - - Returns: - A dictionary of field name to Attribute, using serialization_alias as keys when appropriate. - """ - fields = {name: attr for name, attr in cls.all_members.items() if "pydantic-field" in attr.labels} - - ext_namespace = cls.extra.get(_self_namespace, {}) - serialize_by_alias = ext_namespace.get("serialize_by_alias", False) - - if not serialize_by_alias: - return fields # ty: ignore[invalid-return-type] - - # Re-key fields with their serialization_alias if present. - # For dynamic analysis, Pydantic fields don't appear as labeled members so we fall back - # to _pydantic_model_fields (populated from model_fields in dynamic._process_class). - pydantic_fields = ext_namespace.get("_pydantic_model_fields", {}) - source = fields or dict.fromkeys(pydantic_fields) - remapped_fields = {} - for name, attr in source.items(): - if attr is not None: - serialization_alias = attr.extra.get(_self_namespace, {}).get("serialization_alias") - else: - field_info = pydantic_fields.get(name) - serialization_alias = getattr(field_info, "serialization_alias", None) if field_info else None - remapped_fields[serialization_alias or name] = attr - return remapped_fields + return {name: attr for name, attr in cls.all_members.items() if "pydantic-field" in attr.labels} # ty: ignore[invalid-return-type] def _model_validators(cls: Class) -> dict[str, Function]: @@ -77,15 +48,13 @@ def _json_schema(model: type[BaseModel]) -> str: return json.dumps(model.model_json_schema(), indent=2) -def _process_class(cls: Class, *, serialize_by_alias: bool = False) -> None: +def _process_class(cls: Class) -> None: """Set metadata on a Pydantic model. Parameters: cls: The Griffe class representing the Pydantic model. - serialize_by_alias: Whether to use serialization_alias as the field name. """ cls.labels.add("pydantic-model") - cls.extra[_self_namespace]["serialize_by_alias"] = serialize_by_alias cls.extra[_self_namespace]["fields"] = partial(_model_fields, cls) cls.extra[_self_namespace]["validators"] = partial(_model_validators, cls) cls.extra[_mkdocstrings_namespace]["template"] = "pydantic_model.html.jinja" diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index 0c51b3a..e727c85 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -20,7 +20,14 @@ _logger = get_logger("griffe_pydantic") -def _process_attribute(obj: Any, attr: Attribute, cls: Class, *, processed: set[str]) -> None: +def _process_attribute( + obj: Any, + attr: Attribute, + cls: Class, + *, + processed: set[str], + serialize_by_alias: bool = False, +) -> None: """Handle Pydantic fields.""" from pydantic.fields import FieldInfo # noqa: PLC0415 @@ -43,9 +50,9 @@ def _process_attribute(obj: Any, attr: Attribute, cls: Class, *, processed: set[ attr.extra[common._self_namespace]["constraints"] = constraints # Store serialization_alias if present - if obj.serialization_alias: + if serialize_by_alias and obj.serialization_alias: attr.extra[common._self_namespace]["serialization_alias"] = obj.serialization_alias - attr.name = obj.serialization_alias + attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_attribute_alias.html.jinja" # Populate docstring from the field's `description` argument. if not attr.docstring and (docstring := obj.description): @@ -70,7 +77,7 @@ def _process_class( serialize_by_alias: bool = False, ) -> None: """Detect and prepare Pydantic models.""" - common._process_class(cls, serialize_by_alias=serialize_by_alias) + common._process_class(cls) if schema: try: cls.extra[common._self_namespace]["schema"] = common._json_schema(obj) # ty: ignore[invalid-argument-type] @@ -80,25 +87,12 @@ def _process_class( for member in cls.all_members.values(): kind = member.kind if kind is Kind.ATTRIBUTE: - _process_attribute(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type] + _process_attribute( + getattr(obj, member.name), + member, # ty: ignore[invalid-argument-type] + cls, + processed=processed, + serialize_by_alias=serialize_by_alias, + ) elif kind is Kind.FUNCTION: _process_function(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type] - - # Also process Pydantic model fields directly from model_fields - # These are FieldInfo objects that may not appear as regular member attributes - if model_fields := getattr(obj, "model_fields", None): - pydantic_fields = {} - for field_name, field_info in model_fields.items(): - pydantic_fields[field_name] = field_info - # If the field has a serialization_alias and serialize_by_alias is enabled, - # we want to use the alias in the output - if hasattr(field_info, "serialization_alias") and field_info.serialization_alias: - from pydantic.fields import FieldInfo # noqa: PLC0415 - - if isinstance(field_info, FieldInfo): - # Create a synthetic field entry with the alias - # Store the FieldInfo in a place where _model_fields can access it - pass - - # Store model fields for later use - cls.extra[common._self_namespace]["_pydantic_model_fields"] = pydantic_fields diff --git a/src/griffe_pydantic/_internal/static.py b/src/griffe_pydantic/_internal/static.py index fe781d9..a99d14b 100644 --- a/src/griffe_pydantic/_internal/static.py +++ b/src/griffe_pydantic/_internal/static.py @@ -96,7 +96,7 @@ def _pydantic_validator(func: Function) -> ExprCall | None: return None -def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str]) -> None: +def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str], serialize_by_alias: bool = False) -> None: """Handle Pydantic fields.""" if attr.canonical_path in processed: return @@ -197,13 +197,14 @@ def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str]) -> N attr.extra[common._self_namespace]["constraints"] = constraints # Store serialization_alias if present - if serialization_alias := kwargs.get("serialization_alias"): + if serialize_by_alias and (serialization_alias := kwargs.get("serialization_alias")): if isinstance(serialization_alias, str): try: attr.extra[common._self_namespace]["serialization_alias"] = ast.literal_eval(serialization_alias) except ValueError: attr.extra[common._self_namespace]["serialization_alias"] = serialization_alias - attr.name = attr.extra[common._self_namespace]["serialization_alias"] + # Set the attribute template to the custom template, which will use the serialization_alias instead of the attribute name. + attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_attribute_alias.html.jinja" elif isinstance(serialization_alias, (ExprName, Expr)): # For now, we can't resolve expressions at static analysis time _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 processed.add(cls.canonical_path) - common._process_class(cls, serialize_by_alias=serialize_by_alias) + common._process_class(cls) if schema: import_path: Path | list[Path] = cls.package.filepath @@ -266,7 +267,7 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False, ser for member in cls.all_members.values(): kind = member.kind if kind is Kind.ATTRIBUTE: - _process_attribute(member, cls, processed=processed) # ty: ignore[invalid-argument-type] + _process_attribute(member, cls, processed=processed, serialize_by_alias=serialize_by_alias) # ty: ignore[invalid-argument-type] elif kind is Kind.FUNCTION: _process_function(member, cls, processed=processed) # ty: ignore[invalid-argument-type] elif kind is Kind.CLASS: diff --git a/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja b/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja new file mode 100644 index 0000000..ce98e86 --- /dev/null +++ b/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja @@ -0,0 +1,130 @@ +{#- Template for Python attributes. + +This template renders a Python attribute (or variable). +This can be a module attribute or a class attribute. + +Context: + attribute (griffe.Attribute): The attribute to render. + root (bool): Whether this is the root object, injected with `:::` in a Markdown page. + heading_level (int): The HTML heading level to use. + config (dict): The configuration options. +-#} + +{% block logs scoped %} + {#- Logging block. + + This block can be used to log debug messages, deprecation messages, warnings, etc. + -#} + {{ log.debug("Rendering " + attribute.path) }} +{% endblock logs %} + +
+ {% with obj = attribute, html_id = attribute.path %} + + {% if root %} + {% set show_full_path = config.show_root_full_path %} + {% set root_members = True %} + {% elif root_members %} + {% set show_full_path = config.show_root_members_full_path or config.show_object_full_path %} + {% set root_members = False %} + {% else %} + {% set show_full_path = config.show_object_full_path %} + {% endif %} + + {# {% set attribute_name = attribute.path if show_full_path else attribute.name %} #} + {# TODO some better way to visualize the alias #} + {% set attribute_name = attribute.extra.griffe_pydantic.serialization_alias %} + + {% if not root or config.show_root_heading %} + {% filter heading( + heading_level, + role="data" if attribute.parent.kind.value == "module" else "attr", + id=html_id, + class="doc doc-heading", + toc_label=(' '|safe if config.show_symbol_type_toc else '') + (config.toc_label if config.toc_label and root else attribute.name), + skip_inventory=config.skip_local_inventory, + ) %} + + {% block heading scoped %} + {#- Heading block. + + This block renders the heading for the attribute. + -#} + {% if config.show_symbol_type_heading %}{% endif %} + {% if config.heading and root %} + {{ config.heading }} + {% elif config.separate_signature %} + {{ attribute_name }} + {% else %} + {%+ filter highlight(language="python", inline=True) %} + {{ attribute_name }}{% if attribute.annotation and config.show_signature_annotations %}: {{ attribute.annotation }}{% endif %} + {% if config.show_attribute_values and attribute.value %} = {{ attribute.value }}{% endif %} + {% endfilter %} + {% endif %} + {% endblock heading %} + + {% block labels scoped %} + {#- Labels block. + + This block renders the labels for the attribute. + -#} + {% with labels = attribute.labels %} + {% include "labels.html.jinja" with context %} + {% endwith %} + {% endblock labels %} + + {% endfilter %} + + {% block signature scoped %} + {#- Signature block. + + This block renders the signature for the attribute. + -#} + {% if config.separate_signature %} + {% filter format_attribute(attribute, config.line_length, crossrefs=config.signature_crossrefs, show_value=config.show_attribute_values) %} + {{ attribute.name }} + {% endfilter %} + {% endif %} + {% endblock signature %} + + {% else %} + + {% if config.show_root_toc_entry %} + {% filter heading(heading_level, + role="data" if attribute.parent.kind.value == "module" else "attr", + id=html_id, + toc_label=(' '|safe if config.show_symbol_type_toc else '') + (config.toc_label if config.toc_label and root else attribute_name), + hidden=True, + skip_inventory=config.skip_local_inventory, + ) %} + {% endfilter %} + {% endif %} + {% set heading_level = heading_level - 1 %} + {% endif %} + +
+ {% block contents scoped %} + {#- Contents block. + + This block renders the contents of the attribute. + It contains other blocks that users can override. + Overriding the contents block allows to rearrange the order of the blocks. + -#} + {% block docstring scoped %} + {#- Docstring block. + + This block renders the docstring for the attribute. + -#} + {% with docstring_sections = attribute.docstring.parsed %} + {% include "docstring.html.jinja" with context %} + {% endwith %} + {% endblock docstring %} + + {% if config.backlinks %} + + {% endif %} + {% endblock contents %} +
+ + {% endwith %} +
diff --git a/src/griffe_pydantic/templates/material/pydantic_attribute_alias.html.jinja b/src/griffe_pydantic/templates/material/pydantic_attribute_alias.html.jinja new file mode 100644 index 0000000..f286c06 --- /dev/null +++ b/src/griffe_pydantic/templates/material/pydantic_attribute_alias.html.jinja @@ -0,0 +1 @@ +{% extends "_base/pydantic_attribute_alias.html.jinja" %} From 50b4a86bdd09fa1c0edefef20ca53c17abadb21d Mon Sep 17 00:00:00 2001 From: Mark Shui Hu Date: Mon, 9 Mar 2026 23:48:55 +0100 Subject: [PATCH 09/21] refactor: change option to show_as_alias --- docs/index.md | 22 ++++++++---- examples/model_noserialize.py | 4 +-- examples/model_serialize.py | 10 +++--- src/griffe_pydantic/_internal/dynamic.py | 31 ++++++++++++---- src/griffe_pydantic/_internal/extension.py | 12 +++---- src/griffe_pydantic/_internal/static.py | 36 +++++++++---------- .../_base/pydantic_attribute_alias.html.jinja | 2 +- tests/test_extension.py | 16 ++++----- 8 files changed, 79 insertions(+), 54 deletions(-) diff --git a/docs/index.md b/docs/index.md index 418c35b..2f93534 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,9 +50,9 @@ hide: /// -### Serialization Aliases +### Show as alias -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. +When the extension is configured with `show_as_alias=True`, fields with a `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. To enable this feature in your documentation configuration, configure the extension as follows: @@ -63,9 +63,20 @@ plugins: python: extensions: - griffe_pydantic: - serialize_by_alias: true + show_as_alias: true ``` +Or use the local configuration: + +```markdown +::: model_ext.ExampleModel + options: + extensions: + - griffe_pydantic: + show_as_alias: true +``` + + /// tab | Pydantic model ```python @@ -81,7 +92,7 @@ plugins: options: heading_level: 4 extensions: - - griffe_pydantic: {serialize_by_alias: false} + - griffe_pydantic: {show_as_alias: false} skip_local_inventory: true ``` @@ -94,9 +105,8 @@ plugins: options: heading_level: 4 extensions: - - griffe_pydantic: {serialize_by_alias: true} + - griffe_pydantic: {show_as_alias: true} skip_local_inventory: true ``` /// - diff --git a/examples/model_noserialize.py b/examples/model_noserialize.py index 833249d..4621670 100644 --- a/examples/model_noserialize.py +++ b/examples/model_noserialize.py @@ -5,8 +5,8 @@ class UserModel(BaseModel): """A user model with serialization aliases. - When the extension is configured with `serialize_by_alias=True`, fields with - `serialization_alias` will appear under their alias names in the documentation. + When the extension is configured with `show_as_alias=True`, fields with + `alias` will appear under their alias names in the documentation. """ model_config = ConfigDict(frozen=False) diff --git a/examples/model_serialize.py b/examples/model_serialize.py index 55b615a..d1dd3f0 100644 --- a/examples/model_serialize.py +++ b/examples/model_serialize.py @@ -4,20 +4,20 @@ class UserModel(BaseModel): """A user model with serialization aliases. - When the extension is configured with `serialize_by_alias=True`, fields with - `serialization_alias` will appear under their alias names in the documentation. + When the extension is configured with `show_as_alias=True`, fields with + `alias` will appear under their alias names in the documentation. """ model_config = ConfigDict(frozen=False) - user_id: int = Field(serialization_alias="id") + user_id: int = Field(alias="id") """Unique user identifier, serialized as 'id'.""" - full_name: str = Field(default="Anonymous", serialization_alias="name") + full_name: str = Field(default="Anonymous", alias="name") """User's full name, serialized as 'name'.""" email_address: str """User's email address.""" - is_active: bool = Field(default=True, serialization_alias="active") + is_active: bool = Field(default=True, alias="active") """Whether the user is active, serialized as 'active'.""" diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index e727c85..1157b77 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -26,7 +26,7 @@ def _process_attribute( cls: Class, *, processed: set[str], - serialize_by_alias: bool = False, + show_as_alias: bool = False, ) -> None: """Handle Pydantic fields.""" from pydantic.fields import FieldInfo # noqa: PLC0415 @@ -49,9 +49,9 @@ def _process_attribute( constraints[constraint] = value attr.extra[common._self_namespace]["constraints"] = constraints - # Store serialization_alias if present - if serialize_by_alias and obj.serialization_alias: - attr.extra[common._self_namespace]["serialization_alias"] = obj.serialization_alias + # Store alias if present + if show_as_alias and obj.alias: + attr.extra[common._self_namespace]["alias"] = obj.alias attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_attribute_alias.html.jinja" # Populate docstring from the field's `description` argument. @@ -74,7 +74,7 @@ def _process_class( *, processed: set[str], schema: bool = False, - serialize_by_alias: bool = False, + show_as_alias: bool = False, ) -> None: """Detect and prepare Pydantic models.""" common._process_class(cls) @@ -92,7 +92,26 @@ def _process_class( member, # ty: ignore[invalid-argument-type] cls, processed=processed, - serialize_by_alias=serialize_by_alias, + show_as_alias=show_as_alias, ) elif kind is Kind.FUNCTION: _process_function(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type] + + # Process model fields that may not have been discovered by griffe + if hasattr(obj, "model_fields") and isinstance(obj.model_fields, dict): + for field_name, field_info in obj.model_fields.items(): + if field_name not in cls.all_members: + # Create an Attribute object for this field + attr = Attribute( + name=field_name, + lineno=0, + endlineno=0, + ) + cls.members[field_name] = attr # ty: ignore[invalid-assignment] + _process_attribute( + field_info, + attr, + cls, + processed=processed, + show_as_alias=show_as_alias, + ) diff --git a/src/griffe_pydantic/_internal/extension.py b/src/griffe_pydantic/_internal/extension.py index eebb84b..3b95818 100644 --- a/src/griffe_pydantic/_internal/extension.py +++ b/src/griffe_pydantic/_internal/extension.py @@ -22,17 +22,17 @@ class PydanticExtension(Extension): """Griffe extension for Pydantic.""" - def __init__(self, *, schema: bool = False, serialize_by_alias: bool = False) -> None: + def __init__(self, *, schema: bool = False, show_as_alias: bool = False) -> None: """Initialize the extension. Parameters: schema: Whether to compute and store the JSON schema of models. - serialize_by_alias: Whether to use `serialization_alias` as the field name in documentation. - When enabled, fields with a `serialization_alias` will be keyed by that alias instead of their Python attribute name. + show_as_alias: Whether to use `alias` as the field name in documentation. + When enabled, fields with a `alias` will be keyed by that alias instead of their Python attribute name. """ super().__init__() self._schema = schema - self._serialize_by_alias = serialize_by_alias + self._show_as_alias = show_as_alias self._processed: set[str] = set() self._recorded: list[tuple[ObjectNode, Class]] = [] @@ -45,13 +45,13 @@ def on_package(self, *, pkg: Module, **kwargs: Any) -> None: # noqa: ARG002 cls, processed=self._processed, schema=self._schema, - serialize_by_alias=self._serialize_by_alias, + show_as_alias=self._show_as_alias, ) static._process_module( pkg, processed=self._processed, schema=self._schema, - serialize_by_alias=self._serialize_by_alias, + show_as_alias=self._show_as_alias, ) def on_class_instance(self, *, node: ast.AST | ObjectNode, cls: Class, **kwargs: Any) -> None: # noqa: ARG002 diff --git a/src/griffe_pydantic/_internal/static.py b/src/griffe_pydantic/_internal/static.py index a99d14b..90e8b00 100644 --- a/src/griffe_pydantic/_internal/static.py +++ b/src/griffe_pydantic/_internal/static.py @@ -96,7 +96,7 @@ def _pydantic_validator(func: Function) -> ExprCall | None: return None -def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str], serialize_by_alias: bool = False) -> None: +def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str], show_as_alias: bool = False) -> None: """Handle Pydantic fields.""" if attr.canonical_path in processed: return @@ -189,25 +189,21 @@ def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str], seri attr.labels.discard("instance-attribute") attr.value = kwargs.get("default") - constraints = { - kwarg: value - for kwarg, value in kwargs.items() - if kwarg not in {"default", "description", "serialization_alias"} - } + constraints = {kwarg: value for kwarg, value in kwargs.items() if kwarg not in {"default", "description", "alias"}} attr.extra[common._self_namespace]["constraints"] = constraints - # Store serialization_alias if present - if serialize_by_alias and (serialization_alias := kwargs.get("serialization_alias")): - if isinstance(serialization_alias, str): + # Store alias if present + if show_as_alias and (alias := kwargs.get("alias")): + if isinstance(alias, str): try: - attr.extra[common._self_namespace]["serialization_alias"] = ast.literal_eval(serialization_alias) + attr.extra[common._self_namespace]["alias"] = ast.literal_eval(alias) except ValueError: - attr.extra[common._self_namespace]["serialization_alias"] = serialization_alias - # Set the attribute template to the custom template, which will use the serialization_alias instead of the attribute name. + attr.extra[common._self_namespace]["alias"] = alias + # Set the attribute template to the custom template, which will use the alias instead of the attribute name. attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_attribute_alias.html.jinja" - elif isinstance(serialization_alias, (ExprName, Expr)): + elif isinstance(alias, (ExprName, Expr)): # For now, we can't resolve expressions at static analysis time - _logger.debug(f"Could not resolve serialization_alias expression for field '{attr.path}'") + _logger.debug(f"Could not resolve alias expression for field '{attr.path}'") # Populate docstring from the field's `description` argument. if not attr.docstring and (description_expr := kwargs.get("description")): @@ -232,7 +228,7 @@ def _process_function(func: Function, cls: Class, *, processed: set[str]) -> Non common._process_function(func, cls, fields) -def _process_class(cls: Class, *, processed: set[str], schema: bool = False, serialize_by_alias: bool = False) -> None: +def _process_class(cls: Class, *, processed: set[str], schema: bool = False, show_as_alias: bool = False) -> None: """Finalize the Pydantic model data.""" if cls.canonical_path in processed: return @@ -267,11 +263,11 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False, ser for member in cls.all_members.values(): kind = member.kind if kind is Kind.ATTRIBUTE: - _process_attribute(member, cls, processed=processed, serialize_by_alias=serialize_by_alias) # ty: ignore[invalid-argument-type] + _process_attribute(member, cls, processed=processed, show_as_alias=show_as_alias) # ty: ignore[invalid-argument-type] elif kind is Kind.FUNCTION: _process_function(member, cls, processed=processed) # ty: ignore[invalid-argument-type] elif kind is Kind.CLASS: - _process_class(member, processed=processed, schema=schema, serialize_by_alias=serialize_by_alias) # ty: ignore[invalid-argument-type] + _process_class(member, processed=processed, schema=schema, show_as_alias=show_as_alias) # ty: ignore[invalid-argument-type] def _process_module( @@ -279,7 +275,7 @@ def _process_module( *, processed: set[str], schema: bool = False, - serialize_by_alias: bool = False, + show_as_alias: bool = False, ) -> None: """Handle Pydantic models in a module.""" if mod.canonical_path in processed: @@ -289,9 +285,9 @@ def _process_module( for cls in mod.classes.values(): # Don't process aliases, real classes will be processed at some point anyway. if not cls.is_alias: - _process_class(cls, processed=processed, schema=schema, serialize_by_alias=serialize_by_alias) + _process_class(cls, processed=processed, schema=schema, show_as_alias=show_as_alias) for submodule in mod.modules.values(): # Same for modules, don't process aliased ones. if not submodule.is_alias: - _process_module(submodule, processed=processed, schema=schema, serialize_by_alias=serialize_by_alias) + _process_module(submodule, processed=processed, schema=schema, show_as_alias=show_as_alias) diff --git a/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja b/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja index ce98e86..32f5995 100644 --- a/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja +++ b/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja @@ -33,7 +33,7 @@ Context: {# {% set attribute_name = attribute.path if show_full_path else attribute.name %} #} {# TODO some better way to visualize the alias #} - {% set attribute_name = attribute.extra.griffe_pydantic.serialization_alias %} + {% set attribute_name = attribute.extra.griffe_pydantic.alias %} {% if not root or config.show_root_heading %} {% filter heading( diff --git a/tests/test_extension.py b/tests/test_extension.py index b5fa38e..96a5d67 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -57,7 +57,7 @@ class RegularClass(object): regular_attr = 1 class AliasClass(BaseModel): - internal_name: str = Field(default="test", serialization_alias="external_name") + internal_name: str = Field(default="test", alias="external_name") regular_field: int = Field(default=42) """ @@ -69,7 +69,7 @@ def test_extension(analysis: str) -> None: with loader( "package", modules={"__init__.py": code}, - extensions=Extensions(PydanticExtension(schema=True, serialize_by_alias=True)), + extensions=Extensions(PydanticExtension(schema=True, show_as_alias=True)), search_sys_path=analysis == "dynamic", ) as package: assert package @@ -90,9 +90,9 @@ def test_extension(analysis: str) -> None: assert package.classes["AliasClass"].labels == {"pydantic-model"} fields = package.classes["AliasClass"].extra["griffe_pydantic"]["fields"]() - assert "internal_name" not in fields + assert "internal_name" in fields assert "regular_field" in fields - assert "external_name" in fields + assert "external_name" not in fields def test_imported_models() -> None: @@ -401,19 +401,19 @@ class Model(BaseModel): assert "With multiple lines." in package["Model.field1"].docstring.value -def test_serialize_by_alias_disabled_static() -> None: - """Test that without serialize_by_alias, static analysis uses Python attribute names.""" +def test_show_as_alias_disabled_static() -> None: + """Test that without show_as_alias, static analysis uses Python attribute names.""" code = """ from pydantic import BaseModel, Field class Model(BaseModel): - internal_name: str = Field(default="test", serialization_alias="external_name") + internal_name: str = Field(default="test", alias="external_name") regular_field: int = Field(default=42) """ with temporary_visited_package( "package", modules={"__init__.py": code}, - extensions=Extensions(PydanticExtension(schema=False, serialize_by_alias=False)), + extensions=Extensions(PydanticExtension(schema=False, show_as_alias=False)), ) as package: fields = package["Model"].extra["griffe_pydantic"]["fields"]() assert "internal_name" in fields From 15a5d9eb578907d1ad970c8e3d693eb922487f4f Mon Sep 17 00:00:00 2001 From: watermarkhu Date: Wed, 11 Mar 2026 23:13:02 +0100 Subject: [PATCH 10/21] show alias --- docs/index.md | 10 +- examples/model_noserialize.py | 2 +- examples/model_serialize.py | 2 +- src/griffe_pydantic/_internal/dynamic.py | 10 +- src/griffe_pydantic/_internal/extension.py | 10 +- src/griffe_pydantic/_internal/static.py | 16 +- .../_base/pydantic_attribute_alias.html.jinja | 143 ++---------------- .../material/_base/pydantic_model.html.jinja | 5 +- tests/test_extension.py | 8 +- 9 files changed, 49 insertions(+), 157 deletions(-) diff --git a/docs/index.md b/docs/index.md index 2f93534..3a9d281 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,7 +52,7 @@ hide: ### Show as alias -When the extension is configured with `show_as_alias=True`, fields with a `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. +When the extension is configured with `show_alias=True`, fields with a `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. To enable this feature in your documentation configuration, configure the extension as follows: @@ -63,7 +63,7 @@ plugins: python: extensions: - griffe_pydantic: - show_as_alias: true + show_alias: true ``` Or use the local configuration: @@ -73,7 +73,7 @@ Or use the local configuration: options: extensions: - griffe_pydantic: - show_as_alias: true + show_alias: true ``` @@ -92,7 +92,7 @@ Or use the local configuration: options: heading_level: 4 extensions: - - griffe_pydantic: {show_as_alias: false} + - griffe_pydantic: {show_alias: false} skip_local_inventory: true ``` @@ -105,7 +105,7 @@ Or use the local configuration: options: heading_level: 4 extensions: - - griffe_pydantic: {show_as_alias: true} + - griffe_pydantic: {show_alias: true} skip_local_inventory: true ``` diff --git a/examples/model_noserialize.py b/examples/model_noserialize.py index 4621670..5fce60e 100644 --- a/examples/model_noserialize.py +++ b/examples/model_noserialize.py @@ -5,7 +5,7 @@ class UserModel(BaseModel): """A user model with serialization aliases. - When the extension is configured with `show_as_alias=True`, fields with + When the extension is configured with `show_alias=True`, fields with `alias` will appear under their alias names in the documentation. """ diff --git a/examples/model_serialize.py b/examples/model_serialize.py index d1dd3f0..bec6005 100644 --- a/examples/model_serialize.py +++ b/examples/model_serialize.py @@ -4,7 +4,7 @@ class UserModel(BaseModel): """A user model with serialization aliases. - When the extension is configured with `show_as_alias=True`, fields with + When the extension is configured with `show_alias=True`, fields with `alias` will appear under their alias names in the documentation. """ diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index 1157b77..5454906 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -26,7 +26,7 @@ def _process_attribute( cls: Class, *, processed: set[str], - show_as_alias: bool = False, + show_alias: bool = False, ) -> None: """Handle Pydantic fields.""" from pydantic.fields import FieldInfo # noqa: PLC0415 @@ -50,7 +50,7 @@ def _process_attribute( attr.extra[common._self_namespace]["constraints"] = constraints # Store alias if present - if show_as_alias and obj.alias: + if show_alias and obj.alias: attr.extra[common._self_namespace]["alias"] = obj.alias attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_attribute_alias.html.jinja" @@ -74,7 +74,7 @@ def _process_class( *, processed: set[str], schema: bool = False, - show_as_alias: bool = False, + show_alias: bool = False, ) -> None: """Detect and prepare Pydantic models.""" common._process_class(cls) @@ -92,7 +92,7 @@ def _process_class( member, # ty: ignore[invalid-argument-type] cls, processed=processed, - show_as_alias=show_as_alias, + show_alias=show_alias, ) elif kind is Kind.FUNCTION: _process_function(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type] @@ -113,5 +113,5 @@ def _process_class( attr, cls, processed=processed, - show_as_alias=show_as_alias, + show_alias=show_alias, ) diff --git a/src/griffe_pydantic/_internal/extension.py b/src/griffe_pydantic/_internal/extension.py index 3b95818..ceadaeb 100644 --- a/src/griffe_pydantic/_internal/extension.py +++ b/src/griffe_pydantic/_internal/extension.py @@ -22,17 +22,17 @@ class PydanticExtension(Extension): """Griffe extension for Pydantic.""" - def __init__(self, *, schema: bool = False, show_as_alias: bool = False) -> None: + def __init__(self, *, schema: bool = False, show_alias: bool = False) -> None: """Initialize the extension. Parameters: schema: Whether to compute and store the JSON schema of models. - show_as_alias: Whether to use `alias` as the field name in documentation. + show_alias: Whether to use `alias` as the field name in documentation. When enabled, fields with a `alias` will be keyed by that alias instead of their Python attribute name. """ super().__init__() self._schema = schema - self._show_as_alias = show_as_alias + self._show_alias = show_alias self._processed: set[str] = set() self._recorded: list[tuple[ObjectNode, Class]] = [] @@ -45,13 +45,13 @@ def on_package(self, *, pkg: Module, **kwargs: Any) -> None: # noqa: ARG002 cls, processed=self._processed, schema=self._schema, - show_as_alias=self._show_as_alias, + show_alias=self._show_alias, ) static._process_module( pkg, processed=self._processed, schema=self._schema, - show_as_alias=self._show_as_alias, + show_alias=self._show_alias, ) def on_class_instance(self, *, node: ast.AST | ObjectNode, cls: Class, **kwargs: Any) -> None: # noqa: ARG002 diff --git a/src/griffe_pydantic/_internal/static.py b/src/griffe_pydantic/_internal/static.py index 90e8b00..8bdd638 100644 --- a/src/griffe_pydantic/_internal/static.py +++ b/src/griffe_pydantic/_internal/static.py @@ -96,7 +96,7 @@ def _pydantic_validator(func: Function) -> ExprCall | None: return None -def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str], show_as_alias: bool = False) -> None: +def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str], show_alias: bool = False) -> None: """Handle Pydantic fields.""" if attr.canonical_path in processed: return @@ -193,7 +193,7 @@ def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str], show attr.extra[common._self_namespace]["constraints"] = constraints # Store alias if present - if show_as_alias and (alias := kwargs.get("alias")): + if show_alias and (alias := kwargs.get("alias")): if isinstance(alias, str): try: attr.extra[common._self_namespace]["alias"] = ast.literal_eval(alias) @@ -228,7 +228,7 @@ def _process_function(func: Function, cls: Class, *, processed: set[str]) -> Non common._process_function(func, cls, fields) -def _process_class(cls: Class, *, processed: set[str], schema: bool = False, show_as_alias: bool = False) -> None: +def _process_class(cls: Class, *, processed: set[str], schema: bool = False, show_alias: bool = False) -> None: """Finalize the Pydantic model data.""" if cls.canonical_path in processed: return @@ -263,11 +263,11 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False, sho for member in cls.all_members.values(): kind = member.kind if kind is Kind.ATTRIBUTE: - _process_attribute(member, cls, processed=processed, show_as_alias=show_as_alias) # ty: ignore[invalid-argument-type] + _process_attribute(member, cls, processed=processed, show_alias=show_alias) # ty: ignore[invalid-argument-type] elif kind is Kind.FUNCTION: _process_function(member, cls, processed=processed) # ty: ignore[invalid-argument-type] elif kind is Kind.CLASS: - _process_class(member, processed=processed, schema=schema, show_as_alias=show_as_alias) # ty: ignore[invalid-argument-type] + _process_class(member, processed=processed, schema=schema, show_alias=show_alias) # ty: ignore[invalid-argument-type] def _process_module( @@ -275,7 +275,7 @@ def _process_module( *, processed: set[str], schema: bool = False, - show_as_alias: bool = False, + show_alias: bool = False, ) -> None: """Handle Pydantic models in a module.""" if mod.canonical_path in processed: @@ -285,9 +285,9 @@ def _process_module( for cls in mod.classes.values(): # Don't process aliases, real classes will be processed at some point anyway. if not cls.is_alias: - _process_class(cls, processed=processed, schema=schema, show_as_alias=show_as_alias) + _process_class(cls, processed=processed, schema=schema, show_alias=show_alias) for submodule in mod.modules.values(): # Same for modules, don't process aliased ones. if not submodule.is_alias: - _process_module(submodule, processed=processed, schema=schema, show_as_alias=show_as_alias) + _process_module(submodule, processed=processed, schema=schema, show_alias=show_alias) diff --git a/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja b/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja index 32f5995..9ac01e3 100644 --- a/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja +++ b/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja @@ -1,130 +1,19 @@ -{#- Template for Python attributes. +{% extends "_base/attribute.html.jinja" %} -This template renders a Python attribute (or variable). -This can be a module attribute or a class attribute. +{% block heading %} + {#- Heading block. -Context: - attribute (griffe.Attribute): The attribute to render. - root (bool): Whether this is the root object, injected with `:::` in a Markdown page. - heading_level (int): The HTML heading level to use. - config (dict): The configuration options. --#} - -{% block logs scoped %} - {#- Logging block. - - This block can be used to log debug messages, deprecation messages, warnings, etc. + This block renders the heading for the attribute. -#} - {{ log.debug("Rendering " + attribute.path) }} -{% endblock logs %} - -
- {% with obj = attribute, html_id = attribute.path %} - - {% if root %} - {% set show_full_path = config.show_root_full_path %} - {% set root_members = True %} - {% elif root_members %} - {% set show_full_path = config.show_root_members_full_path or config.show_object_full_path %} - {% set root_members = False %} - {% else %} - {% set show_full_path = config.show_object_full_path %} - {% endif %} - - {# {% set attribute_name = attribute.path if show_full_path else attribute.name %} #} - {# TODO some better way to visualize the alias #} - {% set attribute_name = attribute.extra.griffe_pydantic.alias %} - - {% if not root or config.show_root_heading %} - {% filter heading( - heading_level, - role="data" if attribute.parent.kind.value == "module" else "attr", - id=html_id, - class="doc doc-heading", - toc_label=(' '|safe if config.show_symbol_type_toc else '') + (config.toc_label if config.toc_label and root else attribute.name), - skip_inventory=config.skip_local_inventory, - ) %} - - {% block heading scoped %} - {#- Heading block. - - This block renders the heading for the attribute. - -#} - {% if config.show_symbol_type_heading %}{% endif %} - {% if config.heading and root %} - {{ config.heading }} - {% elif config.separate_signature %} - {{ attribute_name }} - {% else %} - {%+ filter highlight(language="python", inline=True) %} - {{ attribute_name }}{% if attribute.annotation and config.show_signature_annotations %}: {{ attribute.annotation }}{% endif %} - {% if config.show_attribute_values and attribute.value %} = {{ attribute.value }}{% endif %} - {% endfilter %} - {% endif %} - {% endblock heading %} - - {% block labels scoped %} - {#- Labels block. - - This block renders the labels for the attribute. - -#} - {% with labels = attribute.labels %} - {% include "labels.html.jinja" with context %} - {% endwith %} - {% endblock labels %} - - {% endfilter %} - - {% block signature scoped %} - {#- Signature block. - - This block renders the signature for the attribute. - -#} - {% if config.separate_signature %} - {% filter format_attribute(attribute, config.line_length, crossrefs=config.signature_crossrefs, show_value=config.show_attribute_values) %} - {{ attribute.name }} - {% endfilter %} - {% endif %} - {% endblock signature %} - - {% else %} - - {% if config.show_root_toc_entry %} - {% filter heading(heading_level, - role="data" if attribute.parent.kind.value == "module" else "attr", - id=html_id, - toc_label=(' '|safe if config.show_symbol_type_toc else '') + (config.toc_label if config.toc_label and root else attribute_name), - hidden=True, - skip_inventory=config.skip_local_inventory, - ) %} - {% endfilter %} - {% endif %} - {% set heading_level = heading_level - 1 %} - {% endif %} - -
- {% block contents scoped %} - {#- Contents block. - - This block renders the contents of the attribute. - It contains other blocks that users can override. - Overriding the contents block allows to rearrange the order of the blocks. - -#} - {% block docstring scoped %} - {#- Docstring block. - - This block renders the docstring for the attribute. - -#} - {% with docstring_sections = attribute.docstring.parsed %} - {% include "docstring.html.jinja" with context %} - {% endwith %} - {% endblock docstring %} - - {% if config.backlinks %} - - {% endif %} - {% endblock contents %} -
- - {% endwith %} -
+ {% if config.show_symbol_type_heading %}{% endif %} + {% if config.heading and root %} + {{ config.heading }} + {% elif config.separate_signature %} + {{ attribute_name }} ({{ attribute.extra.griffe_pydantic.alias }}) + {% else %} + {%+ filter highlight(language="python", inline=True) %} + {{ attribute_name }}{% if attribute.annotation and config.show_signature_annotations %}: {{ attribute.annotation }}{% endif %} + {% if config.show_attribute_values and attribute.value %} = {{ attribute.value }}{% endif %} + {% endfilter %} + {% endif %} +{% endblock heading %} diff --git a/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja b/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja index 3fd20fc..e53f5fa 100644 --- a/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja +++ b/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja @@ -11,7 +11,7 @@ {% endif %} {% endblock schema %} - + {% block config scoped %} {% if class.extra.griffe_pydantic.config %}

Config:

@@ -31,6 +31,9 @@ {% for name, field in fields.items() %}
  • {{ name }} + {% if field.extra.griffe_pydantic.alias %} + ({{ field.extra.griffe_pydantic.alias }}) + {% endif %} {% with expression = field.annotation %} ({% include "expression.html.jinja" with context %}) {% endwith %} diff --git a/tests/test_extension.py b/tests/test_extension.py index 96a5d67..774ecaf 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -69,7 +69,7 @@ def test_extension(analysis: str) -> None: with loader( "package", modules={"__init__.py": code}, - extensions=Extensions(PydanticExtension(schema=True, show_as_alias=True)), + extensions=Extensions(PydanticExtension(schema=True, show_alias=True)), search_sys_path=analysis == "dynamic", ) as package: assert package @@ -401,8 +401,8 @@ class Model(BaseModel): assert "With multiple lines." in package["Model.field1"].docstring.value -def test_show_as_alias_disabled_static() -> None: - """Test that without show_as_alias, static analysis uses Python attribute names.""" +def test_show_alias_disabled_static() -> None: + """Test that without show_alias, static analysis uses Python attribute names.""" code = """ from pydantic import BaseModel, Field @@ -413,7 +413,7 @@ class Model(BaseModel): with temporary_visited_package( "package", modules={"__init__.py": code}, - extensions=Extensions(PydanticExtension(schema=False, show_as_alias=False)), + extensions=Extensions(PydanticExtension(schema=False, show_alias=False)), ) as package: fields = package["Model"].extra["griffe_pydantic"]["fields"]() assert "internal_name" in fields From 33c5b5e9682bdee8ebccbe67450abe76c187898c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Thu, 12 Mar 2026 12:26:13 +0100 Subject: [PATCH 11/21] fixup! show alias --- docs/index.md | 61 ------------------- examples/model_ext.py | 9 +++ examples/model_noserialize.py | 24 -------- examples/model_serialize.py | 23 ------- src/griffe_pydantic/_internal/dynamic.py | 34 +++-------- src/griffe_pydantic/_internal/extension.py | 20 +----- src/griffe_pydantic/_internal/static.py | 55 +++++++++-------- .../_base/pydantic_attribute_alias.html.jinja | 19 ------ .../material/_base/pydantic_field.html.jinja | 15 +++++ ...s.html.jinja => pydantic_field.html.jinja} | 0 tests/test_extension.py | 22 +------ 11 files changed, 65 insertions(+), 217 deletions(-) delete mode 100644 examples/model_noserialize.py delete mode 100644 examples/model_serialize.py delete mode 100644 src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja create mode 100644 src/griffe_pydantic/templates/material/_base/pydantic_field.html.jinja rename src/griffe_pydantic/templates/material/{pydantic_attribute_alias.html.jinja => pydantic_field.html.jinja} (100%) diff --git a/docs/index.md b/docs/index.md index 3a9d281..8438238 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,64 +49,3 @@ hide: ``` /// - -### Show as alias - -When the extension is configured with `show_alias=True`, fields with a `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. - -To enable this feature in your documentation configuration, configure the extension as follows: - -```yaml -plugins: - - mkdocstrings: - handlers: - python: - extensions: - - griffe_pydantic: - show_alias: true -``` - -Or use the local configuration: - -```markdown -::: model_ext.ExampleModel - options: - extensions: - - griffe_pydantic: - show_alias: true -``` - - -/// tab | Pydantic model - -```python ---8<-- "examples/model_serialize.py" -``` - -/// - -/// tab | Without alias - -```md exec="true" updatetoc="false" -::: model_noserialize.UserModel - options: - heading_level: 4 - extensions: - - griffe_pydantic: {show_alias: false} - skip_local_inventory: true -``` - -/// - -/// tab | With alias - -```md exec="true" updatetoc="false" -::: model_serialize.UserModel - options: - heading_level: 4 - extensions: - - griffe_pydantic: {show_alias: true} - skip_local_inventory: true -``` - -/// diff --git a/examples/model_ext.py b/examples/model_ext.py index 2110554..b4abc7a 100644 --- a/examples/model_ext.py +++ b/examples/model_ext.py @@ -20,6 +20,15 @@ class ExampleModel(BaseModel): default=5, ge=0, le=100, description="Shows constraints within doc string." ) + field_with_alias: int = Field(alias="alias_field") + """Shows the field with its (validation and serialization) alias.""" + + field_with_validation_alias: int = Field(validation_alias="validation_alias_field") + """Shows the field with its validation alias.""" + + field_with_serialization_alias: int = Field(field_with_serialization_aliasalias="serialization_alias_field") + """Shows the field with its serialization alias.""" + @field_validator("field_with_validator_and_alias", "field_without_default", mode="before") @classmethod def check_max_length_ten(cls, v) -> str: diff --git a/examples/model_noserialize.py b/examples/model_noserialize.py deleted file mode 100644 index 5fce60e..0000000 --- a/examples/model_noserialize.py +++ /dev/null @@ -1,24 +0,0 @@ -from pickle import TRUE -from pydantic import BaseModel, ConfigDict, Field - - -class UserModel(BaseModel): - """A user model with serialization aliases. - - When the extension is configured with `show_alias=True`, fields with - `alias` will appear under their alias names in the documentation. - """ - - model_config = ConfigDict(frozen=False) - - user_id: int = Field() - """Unique user identifier, serialized as 'id'.""" - - full_name: str = Field(default="Anonymous") - """User's full name, serialized as 'name'.""" - - email_address: str - """User's email address.""" - - is_active: bool = Field(default=True) - """Whether the user is active, serialized as 'active'.""" diff --git a/examples/model_serialize.py b/examples/model_serialize.py deleted file mode 100644 index bec6005..0000000 --- a/examples/model_serialize.py +++ /dev/null @@ -1,23 +0,0 @@ -from pydantic import BaseModel, ConfigDict, Field - - -class UserModel(BaseModel): - """A user model with serialization aliases. - - When the extension is configured with `show_alias=True`, fields with - `alias` will appear under their alias names in the documentation. - """ - - model_config = ConfigDict(frozen=False) - - user_id: int = Field(alias="id") - """Unique user identifier, serialized as 'id'.""" - - full_name: str = Field(default="Anonymous", alias="name") - """User's full name, serialized as 'name'.""" - - email_address: str - """User's email address.""" - - is_active: bool = Field(default=True, alias="active") - """Whether the user is active, serialized as 'active'.""" diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index 5454906..acead92 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -20,14 +20,7 @@ _logger = get_logger("griffe_pydantic") -def _process_attribute( - obj: Any, - attr: Attribute, - cls: Class, - *, - processed: set[str], - show_alias: bool = False, -) -> None: +def _process_attribute(obj: Any, attr: Attribute, cls: Class, *, processed: set[str]) -> None: """Handle Pydantic fields.""" from pydantic.fields import FieldInfo # noqa: PLC0415 @@ -50,9 +43,10 @@ def _process_attribute( attr.extra[common._self_namespace]["constraints"] = constraints # Store alias if present - if show_alias and obj.alias: - attr.extra[common._self_namespace]["alias"] = obj.alias - attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_attribute_alias.html.jinja" + if obj.alias: + attr.extra[common._self_namespace]["validation_alias"] = obj.alias + attr.extra[common._self_namespace]["serialization_alias"] = obj.alias + attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_field.html.jinja" # Populate docstring from the field's `description` argument. if not attr.docstring and (docstring := obj.description): @@ -68,14 +62,7 @@ def _process_function(obj: Callable, func: Function, cls: Class, *, processed: s common._process_function(func, cls, dec_info.fields) -def _process_class( - obj: type, - cls: Class, - *, - processed: set[str], - schema: bool = False, - show_alias: bool = False, -) -> None: +def _process_class(obj: type, cls: Class, *, processed: set[str], schema: bool = False) -> None: """Detect and prepare Pydantic models.""" common._process_class(cls) if schema: @@ -92,7 +79,6 @@ def _process_class( member, # ty: ignore[invalid-argument-type] cls, processed=processed, - show_alias=show_alias, ) elif kind is Kind.FUNCTION: _process_function(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type] @@ -108,10 +94,4 @@ def _process_class( endlineno=0, ) cls.members[field_name] = attr # ty: ignore[invalid-assignment] - _process_attribute( - field_info, - attr, - cls, - processed=processed, - show_alias=show_alias, - ) + _process_attribute(field_info, attr, cls, processed=processed) diff --git a/src/griffe_pydantic/_internal/extension.py b/src/griffe_pydantic/_internal/extension.py index ceadaeb..8f14241 100644 --- a/src/griffe_pydantic/_internal/extension.py +++ b/src/griffe_pydantic/_internal/extension.py @@ -22,17 +22,14 @@ class PydanticExtension(Extension): """Griffe extension for Pydantic.""" - def __init__(self, *, schema: bool = False, show_alias: bool = False) -> None: + def __init__(self, *, schema: bool = False) -> None: """Initialize the extension. Parameters: schema: Whether to compute and store the JSON schema of models. - show_alias: Whether to use `alias` as the field name in documentation. - When enabled, fields with a `alias` will be keyed by that alias instead of their Python attribute name. """ super().__init__() self._schema = schema - self._show_alias = show_alias self._processed: set[str] = set() self._recorded: list[tuple[ObjectNode, Class]] = [] @@ -40,19 +37,8 @@ def on_package(self, *, pkg: Module, **kwargs: Any) -> None: # noqa: ARG002 """Detect models once the whole package is loaded.""" for node, cls in self._recorded: self._processed.add(cls.canonical_path) - dynamic._process_class( - node.obj, - cls, - processed=self._processed, - schema=self._schema, - show_alias=self._show_alias, - ) - static._process_module( - pkg, - processed=self._processed, - schema=self._schema, - show_alias=self._show_alias, - ) + dynamic._process_class(node.obj, cls, processed=self._processed, schema=self._schema) + static._process_module(pkg, processed=self._processed, schema=self._schema) def on_class_instance(self, *, node: ast.AST | ObjectNode, cls: Class, **kwargs: Any) -> None: # noqa: ARG002 """Detect and prepare Pydantic models.""" diff --git a/src/griffe_pydantic/_internal/static.py b/src/griffe_pydantic/_internal/static.py index 8bdd638..00c24f5 100644 --- a/src/griffe_pydantic/_internal/static.py +++ b/src/griffe_pydantic/_internal/static.py @@ -96,7 +96,21 @@ def _pydantic_validator(func: Function) -> ExprCall | None: return None -def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str], show_alias: bool = False) -> None: +def _literal_or_debug(value: Expr | str | None, /, *, path: str) -> str | None: + if value is None: + return None + if isinstance(value, str): + try: + return ast.literal_eval(value) + except ValueError: + return value + elif isinstance(value, (ExprName, Expr)): + _logger.debug(f"Could not resolve expression '{value}' as a literal for field {path}") + return None + return None + + +def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str]) -> None: """Handle Pydantic fields.""" if attr.canonical_path in processed: return @@ -191,19 +205,16 @@ def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str], show attr.value = kwargs.get("default") constraints = {kwarg: value for kwarg, value in kwargs.items() if kwarg not in {"default", "description", "alias"}} attr.extra[common._self_namespace]["constraints"] = constraints + attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_field.html.jinja" - # Store alias if present - if show_alias and (alias := kwargs.get("alias")): - if isinstance(alias, str): - try: - attr.extra[common._self_namespace]["alias"] = ast.literal_eval(alias) - except ValueError: - attr.extra[common._self_namespace]["alias"] = alias - # Set the attribute template to the custom template, which will use the alias instead of the attribute name. - attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_attribute_alias.html.jinja" - elif isinstance(alias, (ExprName, Expr)): - # For now, we can't resolve expressions at static analysis time - _logger.debug(f"Could not resolve alias expression for field '{attr.path}'") + # Store validation/serialization aliases if defined. + if alias := _literal_or_debug(kwargs.get("alias"), path=attr.path): + attr.extra[common._self_namespace]["validation_alias"] = alias + attr.extra[common._self_namespace]["serialization_alias"] = alias + elif validation_alias := _literal_or_debug(kwargs.get("validation_alias"), path=attr.path): + attr.extra[common._self_namespace]["validation_alias"] = validation_alias + elif serialization_alias := _literal_or_debug(kwargs.get("serialization_alias"), path=attr.path): + attr.extra[common._self_namespace]["serialization_alias"] = serialization_alias # Populate docstring from the field's `description` argument. if not attr.docstring and (description_expr := kwargs.get("description")): @@ -228,7 +239,7 @@ def _process_function(func: Function, cls: Class, *, processed: set[str]) -> Non common._process_function(func, cls, fields) -def _process_class(cls: Class, *, processed: set[str], schema: bool = False, show_alias: bool = False) -> None: +def _process_class(cls: Class, *, processed: set[str], schema: bool = False) -> None: """Finalize the Pydantic model data.""" if cls.canonical_path in processed: return @@ -263,20 +274,14 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False, sho for member in cls.all_members.values(): kind = member.kind if kind is Kind.ATTRIBUTE: - _process_attribute(member, cls, processed=processed, show_alias=show_alias) # ty: ignore[invalid-argument-type] + _process_attribute(member, cls, processed=processed) # ty: ignore[invalid-argument-type] elif kind is Kind.FUNCTION: _process_function(member, cls, processed=processed) # ty: ignore[invalid-argument-type] elif kind is Kind.CLASS: - _process_class(member, processed=processed, schema=schema, show_alias=show_alias) # ty: ignore[invalid-argument-type] + _process_class(member, processed=processed, schema=schema) # ty: ignore[invalid-argument-type] -def _process_module( - mod: Module, - *, - processed: set[str], - schema: bool = False, - show_alias: bool = False, -) -> None: +def _process_module(mod: Module, *, processed: set[str], schema: bool = False) -> None: """Handle Pydantic models in a module.""" if mod.canonical_path in processed: return @@ -285,9 +290,9 @@ def _process_module( for cls in mod.classes.values(): # Don't process aliases, real classes will be processed at some point anyway. if not cls.is_alias: - _process_class(cls, processed=processed, schema=schema, show_alias=show_alias) + _process_class(cls, processed=processed, schema=schema) for submodule in mod.modules.values(): # Same for modules, don't process aliased ones. if not submodule.is_alias: - _process_module(submodule, processed=processed, schema=schema, show_alias=show_alias) + _process_module(submodule, processed=processed, schema=schema) diff --git a/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja b/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja deleted file mode 100644 index 9ac01e3..0000000 --- a/src/griffe_pydantic/templates/material/_base/pydantic_attribute_alias.html.jinja +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "_base/attribute.html.jinja" %} - -{% block heading %} - {#- Heading block. - - This block renders the heading for the attribute. - -#} - {% if config.show_symbol_type_heading %}{% endif %} - {% if config.heading and root %} - {{ config.heading }} - {% elif config.separate_signature %} - {{ attribute_name }} ({{ attribute.extra.griffe_pydantic.alias }}) - {% else %} - {%+ filter highlight(language="python", inline=True) %} - {{ attribute_name }}{% if attribute.annotation and config.show_signature_annotations %}: {{ attribute.annotation }}{% endif %} - {% if config.show_attribute_values and attribute.value %} = {{ attribute.value }}{% endif %} - {% endfilter %} - {% endif %} -{% endblock heading %} diff --git a/src/griffe_pydantic/templates/material/_base/pydantic_field.html.jinja b/src/griffe_pydantic/templates/material/_base/pydantic_field.html.jinja new file mode 100644 index 0000000..bb1849d --- /dev/null +++ b/src/griffe_pydantic/templates/material/_base/pydantic_field.html.jinja @@ -0,0 +1,15 @@ +{% extends "_base/attribute.html.jinja" %} + +{% block contents scoped %} + {% if attribute.extra.griffe_pydantic.validation_alias or attribute.extra.griffe_pydantic.serialization_alias %} +
      + {% if attribute.extra.griffe_pydantic.validation_alias %} +
    • Input / Validation alias: {{ attribute.extra.griffe_pydantic.validation_alias }}
    • + {% endif %} + {% if attribute.extra.griffe_pydantic.serialization_alias %} +
    • Output / Serialization alias: {{ attribute.extra.griffe_pydantic.serialization_alias }}
    • + {% endif %} +
    + {% endif %} + {{ super() }} +{% endblock contents %} diff --git a/src/griffe_pydantic/templates/material/pydantic_attribute_alias.html.jinja b/src/griffe_pydantic/templates/material/pydantic_field.html.jinja similarity index 100% rename from src/griffe_pydantic/templates/material/pydantic_attribute_alias.html.jinja rename to src/griffe_pydantic/templates/material/pydantic_field.html.jinja diff --git a/tests/test_extension.py b/tests/test_extension.py index 774ecaf..dad2ec6 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -69,7 +69,7 @@ def test_extension(analysis: str) -> None: with loader( "package", modules={"__init__.py": code}, - extensions=Extensions(PydanticExtension(schema=True, show_alias=True)), + extensions=Extensions(PydanticExtension(schema=True)), search_sys_path=analysis == "dynamic", ) as package: assert package @@ -399,23 +399,3 @@ class Model(BaseModel): assert package["Model.field1"].docstring is not None assert "This is a multiline description." in package["Model.field1"].docstring.value assert "With multiple lines." in package["Model.field1"].docstring.value - - -def test_show_alias_disabled_static() -> None: - """Test that without show_alias, static analysis uses Python attribute names.""" - code = """ - from pydantic import BaseModel, Field - - class Model(BaseModel): - internal_name: str = Field(default="test", alias="external_name") - regular_field: int = Field(default=42) - """ - with temporary_visited_package( - "package", - modules={"__init__.py": code}, - extensions=Extensions(PydanticExtension(schema=False, show_alias=False)), - ) as package: - fields = package["Model"].extra["griffe_pydantic"]["fields"]() - assert "internal_name" in fields - assert "regular_field" in fields - assert "external_name" not in fields From 86d934d14ece3dc3c6f23c62aaa8169acab50b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Thu, 12 Mar 2026 12:27:14 +0100 Subject: [PATCH 12/21] Update examples/model_ext.py --- examples/model_ext.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/model_ext.py b/examples/model_ext.py index b4abc7a..2b10ba6 100644 --- a/examples/model_ext.py +++ b/examples/model_ext.py @@ -26,7 +26,7 @@ class ExampleModel(BaseModel): field_with_validation_alias: int = Field(validation_alias="validation_alias_field") """Shows the field with its validation alias.""" - field_with_serialization_alias: int = Field(field_with_serialization_aliasalias="serialization_alias_field") + field_with_serialization_alias: int = Field(serialization_alias="serialization_alias_field") """Shows the field with its serialization alias.""" @field_validator("field_with_validator_and_alias", "field_without_default", mode="before") From ecba26ae2e5aff7d2156d2e94323efc690b430a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Thu, 12 Mar 2026 12:28:25 +0100 Subject: [PATCH 13/21] fixup! show alias --- src/griffe_pydantic/_internal/dynamic.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index acead92..7569912 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -41,12 +41,16 @@ def _process_attribute(obj: Any, attr: Attribute, cls: Class, *, processed: set[ if (value := getattr(obj, constraint, None)) is not None: constraints[constraint] = value attr.extra[common._self_namespace]["constraints"] = constraints + attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_field.html.jinja" # Store alias if present if obj.alias: attr.extra[common._self_namespace]["validation_alias"] = obj.alias attr.extra[common._self_namespace]["serialization_alias"] = obj.alias - attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_field.html.jinja" + elif obj.validation_alias: + attr.extra[common._self_namespace]["validation_alias"] = obj.validation_alias + elif obj.serialization_alias: + attr.extra[common._self_namespace]["serialization_alias"] = obj.serialization_alias # Populate docstring from the field's `description` argument. if not attr.docstring and (docstring := obj.description): From 789600c9618524bffaf1fecd3c246a29baadf521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Thu, 12 Mar 2026 12:28:48 +0100 Subject: [PATCH 14/21] fixup! show alias --- src/griffe_pydantic/_internal/dynamic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index 7569912..599d6d5 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -43,7 +43,7 @@ def _process_attribute(obj: Any, attr: Attribute, cls: Class, *, processed: set[ attr.extra[common._self_namespace]["constraints"] = constraints attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_field.html.jinja" - # Store alias if present + # Store validation/serialization aliases if defined. if obj.alias: attr.extra[common._self_namespace]["validation_alias"] = obj.alias attr.extra[common._self_namespace]["serialization_alias"] = obj.alias From 06a8e8f2287cfb09f4a67f8e518ef4f78e24a996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Thu, 12 Mar 2026 12:29:38 +0100 Subject: [PATCH 15/21] fixup! show alias --- src/griffe_pydantic/_internal/dynamic.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index 599d6d5..c0bb167 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -78,12 +78,7 @@ def _process_class(obj: type, cls: Class, *, processed: set[str], schema: bool = for member in cls.all_members.values(): kind = member.kind if kind is Kind.ATTRIBUTE: - _process_attribute( - getattr(obj, member.name), - member, # ty: ignore[invalid-argument-type] - cls, - processed=processed, - ) + _process_attribute(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type] elif kind is Kind.FUNCTION: _process_function(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type] From 4dc1db13bb994fa60cec507e66b92c734a154f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Thu, 12 Mar 2026 12:32:21 +0100 Subject: [PATCH 16/21] fixup! show alias --- src/griffe_pydantic/_internal/dynamic.py | 2 +- src/griffe_pydantic/_internal/static.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index c0bb167..99bf852 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -82,7 +82,7 @@ def _process_class(obj: type, cls: Class, *, processed: set[str], schema: bool = elif kind is Kind.FUNCTION: _process_function(getattr(obj, member.name), member, cls, processed=processed) # ty: ignore[invalid-argument-type] - # Process model fields that may not have been discovered by griffe + # Process model fields that may not have been discovered by Griffe. if hasattr(obj, "model_fields") and isinstance(obj.model_fields, dict): for field_name, field_info in obj.model_fields.items(): if field_name not in cls.all_members: diff --git a/src/griffe_pydantic/_internal/static.py b/src/griffe_pydantic/_internal/static.py index 00c24f5..2d91556 100644 --- a/src/griffe_pydantic/_internal/static.py +++ b/src/griffe_pydantic/_internal/static.py @@ -203,7 +203,11 @@ def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str]) -> N attr.labels.discard("instance-attribute") attr.value = kwargs.get("default") - constraints = {kwarg: value for kwarg, value in kwargs.items() if kwarg not in {"default", "description", "alias"}} + constraints = { + kwarg: value + for kwarg, value in kwargs.items() + if kwarg not in {"default", "description", "alias", "validation_alias", "serialization_alias"} + } attr.extra[common._self_namespace]["constraints"] = constraints attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_field.html.jinja" From f0678e7f9ebd096f707a09ed81ff783c89285b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Thu, 12 Mar 2026 12:37:38 +0100 Subject: [PATCH 17/21] fixup! show alias --- src/griffe_pydantic/_internal/static.py | 11 +++++++---- .../material/_base/pydantic_model.html.jinja | 12 ++++++++++-- .../templates/material/pydantic_field.html.jinja | 2 +- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/griffe_pydantic/_internal/static.py b/src/griffe_pydantic/_internal/static.py index 2d91556..24d3caa 100644 --- a/src/griffe_pydantic/_internal/static.py +++ b/src/griffe_pydantic/_internal/static.py @@ -268,9 +268,7 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False) -> _logger.debug(f"Could not import class {cls.path} for JSON schema") return try: - cls.extra[common._self_namespace]["schema"] = common._json_schema( - true_class, - ) + cls.extra[common._self_namespace]["schema"] = common._json_schema(true_class) except Exception as exc: # noqa: BLE001 # Schema generation can fail and raise Pydantic errors. _logger.debug("Failed to generate schema for %s: %s", cls.path, exc) @@ -285,7 +283,12 @@ def _process_class(cls: Class, *, processed: set[str], schema: bool = False) -> _process_class(member, processed=processed, schema=schema) # ty: ignore[invalid-argument-type] -def _process_module(mod: Module, *, processed: set[str], schema: bool = False) -> None: +def _process_module( + mod: Module, + *, + processed: set[str], + schema: bool = False, +) -> None: """Handle Pydantic models in a module.""" if mod.canonical_path in processed: return diff --git a/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja b/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja index e53f5fa..0f9d8d0 100644 --- a/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja +++ b/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja @@ -31,8 +31,16 @@ {% for name, field in fields.items() %}
  • {{ name }} - {% if field.extra.griffe_pydantic.alias %} - ({{ field.extra.griffe_pydantic.alias }}) + {% if field.extra.griffe_pydantic.validation_alias or field.extra.griffe_pydantic.serialization_alias %} + ( + {%- if field.extra.griffe_pydantic.validation_alias -%} + validation alias: {{ field.extra.griffe_pydantic.validation_alias }} + {% if field.extra.griffe_pydantic.serialization_alias %}, {% endif %} + {%- endif -%} + {%- if field.extra.griffe_pydantic.serialization_alias -%} + serialization alias: {{ field.extra.griffe_pydantic.serialization_alias }} + {%- endif -%} + ) {% endif %} {% with expression = field.annotation %} ({% include "expression.html.jinja" with context %}) diff --git a/src/griffe_pydantic/templates/material/pydantic_field.html.jinja b/src/griffe_pydantic/templates/material/pydantic_field.html.jinja index f286c06..bb228f8 100644 --- a/src/griffe_pydantic/templates/material/pydantic_field.html.jinja +++ b/src/griffe_pydantic/templates/material/pydantic_field.html.jinja @@ -1 +1 @@ -{% extends "_base/pydantic_attribute_alias.html.jinja" %} +{% extends "_base/pydantic_field.html.jinja" %} From 0ac76bbfac555e92994f2a5034ab21255f4bde9d Mon Sep 17 00:00:00 2001 From: watermarkhu Date: Sun, 15 Mar 2026 15:28:28 +0100 Subject: [PATCH 18/21] refactor: show alias if validation==serialization --- .../material/_base/pydantic_field.html.jinja | 14 +++++++++----- .../material/_base/pydantic_model.html.jinja | 18 +++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/griffe_pydantic/templates/material/_base/pydantic_field.html.jinja b/src/griffe_pydantic/templates/material/_base/pydantic_field.html.jinja index bb1849d..cc35e2c 100644 --- a/src/griffe_pydantic/templates/material/_base/pydantic_field.html.jinja +++ b/src/griffe_pydantic/templates/material/_base/pydantic_field.html.jinja @@ -3,11 +3,15 @@ {% block contents scoped %} {% if attribute.extra.griffe_pydantic.validation_alias or attribute.extra.griffe_pydantic.serialization_alias %}
      - {% if attribute.extra.griffe_pydantic.validation_alias %} -
    • Input / Validation alias: {{ attribute.extra.griffe_pydantic.validation_alias }}
    • - {% endif %} - {% if attribute.extra.griffe_pydantic.serialization_alias %} -
    • Output / Serialization alias: {{ attribute.extra.griffe_pydantic.serialization_alias }}
    • + {% if attribute.extra.griffe_pydantic.validation_alias == attribute.extra.griffe_pydantic.serialization_alias %} +
    • Alias: {{ attribute.extra.griffe_pydantic.validation_alias }}
    • + {% else %} + {% if attribute.extra.griffe_pydantic.validation_alias %} +
    • Input / Validation alias: {{ attribute.extra.griffe_pydantic.validation_alias }}
    • + {% endif %} + {% if attribute.extra.griffe_pydantic.serialization_alias %} +
    • Output / Serialization alias: {{ attribute.extra.griffe_pydantic.serialization_alias }}
    • + {% endif %} {% endif %}
    {% endif %} diff --git a/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja b/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja index 0f9d8d0..6ffbf75 100644 --- a/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja +++ b/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja @@ -33,13 +33,17 @@ {{ name }} {% if field.extra.griffe_pydantic.validation_alias or field.extra.griffe_pydantic.serialization_alias %} ( - {%- if field.extra.griffe_pydantic.validation_alias -%} - validation alias: {{ field.extra.griffe_pydantic.validation_alias }} - {% if field.extra.griffe_pydantic.serialization_alias %}, {% endif %} - {%- endif -%} - {%- if field.extra.griffe_pydantic.serialization_alias -%} - serialization alias: {{ field.extra.griffe_pydantic.serialization_alias }} - {%- endif -%} + {% if field.extra.griffe_pydantic.validation_alias == field.extra.griffe_pydantic.serialization_alias %} + alias: {{ field.extra.griffe_pydantic.validation_alias }} + {% else %} + {%- if field.extra.griffe_pydantic.validation_alias -%} + validation alias: {{ field.extra.griffe_pydantic.validation_alias }} + {% if field.extra.griffe_pydantic.serialization_alias %}, {% endif %} + {%- endif -%} + {%- if field.extra.griffe_pydantic.serialization_alias -%} + serialization alias: {{ field.extra.griffe_pydantic.serialization_alias }} + {%- endif -%} + {% endif %} ) {% endif %} {% with expression = field.annotation %} From dc535b17d73fe4209be14ee7b9a6070ba9dc372e Mon Sep 17 00:00:00 2001 From: watermarkhu Date: Mon, 30 Mar 2026 21:33:02 +0200 Subject: [PATCH 19/21] test: ExampleModel fields --- tests/test_extension.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_extension.py b/tests/test_extension.py index dad2ec6..90a7f6d 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -86,6 +86,13 @@ def test_extension(analysis: str) -> None: schema = package.classes["ExampleModel"].extra["griffe_pydantic"]["schema"] assert schema.startswith('{\n "description"') + fields = package.classes["ExampleModel"].extra["griffe_pydantic"]["fields"]() + assert "field_without_default" in fields + assert "field_plain_with_validator" in fields + assert "field_with_validator_and_alias" in fields + assert "field_with_constraints_and_description" in fields + assert "regular_method" not in fields + assert "AliasClass" in package.classes assert package.classes["AliasClass"].labels == {"pydantic-model"} From 4e1a12abdb4dcbbf8686b987c5bd66e4ad1e40a9 Mon Sep 17 00:00:00 2001 From: Mark Shui Hu Date: Sat, 23 May 2026 18:41:52 +0200 Subject: [PATCH 20/21] Update src/griffe_pydantic/_internal/dynamic.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Timothée Mazzucotelli --- src/griffe_pydantic/_internal/dynamic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index 99bf852..a35dd45 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -92,5 +92,5 @@ def _process_class(obj: type, cls: Class, *, processed: set[str], schema: bool = lineno=0, endlineno=0, ) - cls.members[field_name] = attr # ty: ignore[invalid-assignment] + cls.set_member(field_name, attr) _process_attribute(field_info, attr, cls, processed=processed) From 21c7e02f41cac2af489d8b29259aa7045dbb5e61 Mon Sep 17 00:00:00 2001 From: Mark Shui Hu Date: Sat, 23 May 2026 18:42:42 +0200 Subject: [PATCH 21/21] Update src/griffe_pydantic/_internal/dynamic.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Timothée Mazzucotelli --- src/griffe_pydantic/_internal/dynamic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index a35dd45..815ec6f 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -86,7 +86,7 @@ def _process_class(obj: type, cls: Class, *, processed: set[str], schema: bool = if hasattr(obj, "model_fields") and isinstance(obj.model_fields, dict): for field_name, field_info in obj.model_fields.items(): if field_name not in cls.all_members: - # Create an Attribute object for this field + # Create an Attribute object for this field. attr = Attribute( name=field_name, lineno=0,