diff --git a/docs/index.md b/docs/index.md index 52f9a35..8438238 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,4 +49,3 @@ hide: ``` /// - diff --git a/examples/model_ext.py b/examples/model_ext.py index 2110554..2b10ba6 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(serialization_alias="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/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", diff --git a/src/griffe_pydantic/_internal/dynamic.py b/src/griffe_pydantic/_internal/dynamic.py index 8fb48bf..815ec6f 100644 --- a/src/griffe_pydantic/_internal/dynamic.py +++ b/src/griffe_pydantic/_internal/dynamic.py @@ -41,6 +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 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 + 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): @@ -71,3 +81,16 @@ 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] + + # 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.set_member(field_name, attr) + _process_attribute(field_info, attr, cls, processed=processed) diff --git a/src/griffe_pydantic/_internal/static.py b/src/griffe_pydantic/_internal/static.py index 652f7b1..24d3caa 100644 --- a/src/griffe_pydantic/_internal/static.py +++ b/src/griffe_pydantic/_internal/static.py @@ -96,6 +96,20 @@ def _pydantic_validator(func: Function) -> ExprCall | None: return 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: @@ -189,8 +203,22 @@ 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", "alias", "validation_alias", "serialization_alias"} + } attr.extra[common._self_namespace]["constraints"] = constraints + attr.extra[common._mkdocstrings_namespace]["template"] = "pydantic_field.html.jinja" + + # 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")): 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..cc35e2c --- /dev/null +++ b/src/griffe_pydantic/templates/material/_base/pydantic_field.html.jinja @@ -0,0 +1,19 @@ +{% extends "_base/attribute.html.jinja" %} + +{% block contents scoped %} + {% if attribute.extra.griffe_pydantic.validation_alias or attribute.extra.griffe_pydantic.serialization_alias %} + + {% endif %} + {{ super() }} +{% endblock contents %} 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..6ffbf75 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,21 @@ {% for name, field in fields.items() %}
  • {{ name }} + {% if field.extra.griffe_pydantic.validation_alias or field.extra.griffe_pydantic.serialization_alias %} + ( + {% 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 %} ({% include "expression.html.jinja" with context %}) {% endwith %} diff --git a/src/griffe_pydantic/templates/material/pydantic_field.html.jinja b/src/griffe_pydantic/templates/material/pydantic_field.html.jinja new file mode 100644 index 0000000..bb228f8 --- /dev/null +++ b/src/griffe_pydantic/templates/material/pydantic_field.html.jinja @@ -0,0 +1 @@ +{% extends "_base/pydantic_field.html.jinja" %} diff --git a/tests/test_extension.py b/tests/test_extension.py index 7e8de32..90a7f6d 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", alias="external_name") + regular_field: int = Field(default=42) """ @@ -82,6 +86,21 @@ 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"} + + fields = package.classes["AliasClass"].extra["griffe_pydantic"]["fields"]() + assert "internal_name" in fields + assert "regular_field" in fields + assert "external_name" not in fields + def test_imported_models() -> None: """Test the extension with imported models."""