Skip to content

Commit ab6c0dc

Browse files
feat: Support dedent/cleandoc for Field description
Issue-50: #50 PR-52: #52
1 parent 9491921 commit ab6c0dc

File tree

2 files changed

+124
-4
lines changed

2 files changed

+124
-4
lines changed

src/griffe_pydantic/_internal/static.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,35 @@
3131
_logger = get_logger("griffe_pydantic")
3232

3333

34+
def _extract_description(description: Expr | str) -> str | None:
35+
"""Extract a description value from a Field argument.
36+
37+
Handles plain string literals as well as calls to textwrap.dedent() and inspect.cleandoc().
38+
39+
Parameters:
40+
description: The description expression from a Field call.
41+
42+
Returns:
43+
The extracted description string, or None if it cannot be extracted.
44+
"""
45+
# If it's a call to dedent() or cleandoc(), extract the first argument.
46+
if (
47+
isinstance(description, ExprCall)
48+
and description.function.canonical_path in ("textwrap.dedent", "inspect.cleandoc")
49+
and description.arguments
50+
):
51+
description = description.arguments[0]
52+
53+
# For plain strings, just evaluate them.
54+
if isinstance(description, str):
55+
try:
56+
return ast.literal_eval(description)
57+
except ValueError:
58+
pass
59+
60+
return None
61+
62+
3463
def _inherits_pydantic(cls: Class) -> bool:
3564
"""Tell whether a class inherits from a Pydantic model.
3665
@@ -164,10 +193,10 @@ def _process_attribute(attr: Attribute, cls: Class, *, processed: set[str]) -> N
164193
attr.extra[common._self_namespace]["constraints"] = constraints
165194

166195
# Populate docstring from the field's `description` argument.
167-
if not attr.docstring and (docstring := kwargs.get("description")):
168-
try:
169-
attr.docstring = Docstring(ast.literal_eval(docstring), parent=attr) # ty: ignore[invalid-argument-type]
170-
except ValueError:
196+
if not attr.docstring and (description_expr := kwargs.get("description")):
197+
if description_text := _extract_description(description_expr):
198+
attr.docstring = Docstring(description_text, parent=attr)
199+
else:
171200
_logger.debug(f"Could not parse description of field '{attr.path}' as literal, skipping")
172201

173202

tests/test_extension.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,3 +296,94 @@ class Model(BaseModel):
296296
) as package:
297297
assert "pydantic-field" in package["Model.field"].labels
298298
assert "pydantic-field" not in package["Model._private"].labels
299+
300+
301+
def test_field_description_with_dedent() -> None:
302+
"""Test that field descriptions wrapped in textwrap.dedent() are extracted."""
303+
code = """
304+
from textwrap import dedent
305+
from pydantic import BaseModel, Field
306+
307+
class Model(BaseModel):
308+
field1: int = Field(
309+
description=dedent('''
310+
This is a multiline description.
311+
With multiple lines.
312+
''')
313+
)
314+
field2: str = Field(default="test", description=dedent("Single line dedented."))
315+
"""
316+
with temporary_visited_package(
317+
"package",
318+
modules={"__init__.py": code},
319+
extensions=Extensions(PydanticExtension(schema=False)),
320+
) as package:
321+
assert package["Model.field1"].is_attribute
322+
assert "pydantic-field" in package["Model.field1"].labels
323+
assert package["Model.field1"].docstring is not None
324+
assert "This is a multiline description." in package["Model.field1"].docstring.value
325+
assert "With multiple lines." in package["Model.field1"].docstring.value
326+
327+
assert package["Model.field2"].is_attribute
328+
assert "pydantic-field" in package["Model.field2"].labels
329+
assert package["Model.field2"].docstring is not None
330+
assert package["Model.field2"].docstring.value == "Single line dedented."
331+
332+
333+
def test_field_description_with_cleandoc() -> None:
334+
"""Test that field descriptions wrapped in inspect.cleandoc() are extracted."""
335+
code = """
336+
from inspect import cleandoc
337+
from pydantic import BaseModel, Field
338+
339+
class Model(BaseModel):
340+
field1: int = Field(
341+
description=cleandoc('''
342+
This is a multiline description.
343+
With multiple lines.
344+
''')
345+
)
346+
field2: str = Field(default="test", description=cleandoc("Single line cleandoc."))
347+
"""
348+
with temporary_visited_package(
349+
"package",
350+
modules={"__init__.py": code},
351+
extensions=Extensions(PydanticExtension(schema=False)),
352+
) as package:
353+
assert package["Model.field1"].is_attribute
354+
assert "pydantic-field" in package["Model.field1"].labels
355+
assert package["Model.field1"].docstring is not None
356+
assert "This is a multiline description." in package["Model.field1"].docstring.value
357+
assert "With multiple lines." in package["Model.field1"].docstring.value
358+
359+
assert package["Model.field2"].is_attribute
360+
assert "pydantic-field" in package["Model.field2"].labels
361+
assert package["Model.field2"].docstring is not None
362+
assert package["Model.field2"].docstring.value == "Single line cleandoc."
363+
364+
365+
def test_field_description_with_annotated_and_dedent() -> None:
366+
"""Test that field descriptions with Annotated and dedent() are extracted."""
367+
code = """
368+
from textwrap import dedent
369+
from pydantic import BaseModel, Field
370+
from typing import Annotated
371+
372+
class Model(BaseModel):
373+
field1: Annotated[int, Field(
374+
description=dedent('''
375+
This is a multiline description.
376+
With multiple lines.
377+
''')
378+
)]
379+
"""
380+
with temporary_visited_package(
381+
"package",
382+
modules={"__init__.py": code},
383+
extensions=Extensions(PydanticExtension(schema=False)),
384+
) as package:
385+
assert package["Model.field1"].is_attribute
386+
assert "pydantic-field" in package["Model.field1"].labels
387+
assert package["Model.field1"].docstring is not None
388+
assert "This is a multiline description." in package["Model.field1"].docstring.value
389+
assert "With multiple lines." in package["Model.field1"].docstring.value

0 commit comments

Comments
 (0)