Skip to content

Commit d03ed5c

Browse files
committed
refactor(schema): generate field descriptions as docstrings
1 parent 0e2adcb commit d03ed5c

2 files changed

Lines changed: 4096 additions & 3393 deletions

File tree

scripts/gen_schema.py

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ def generate_schema() -> None:
286286
"black",
287287
"isort",
288288
"--use-annotated",
289+
"--use-field-description",
289290
"--snake-case-field",
290291
]
291292

@@ -511,6 +512,7 @@ def postprocess_generated_schema(output_path: Path) -> list[str]:
511512
_ProcessingStep("apply default overrides", _apply_default_overrides),
512513
_ProcessingStep("restore required nullable fields", _restore_required_nullable_fields),
513514
_ProcessingStep("ensure custom BaseModel", _ensure_custom_base_model),
515+
_ProcessingStep("enable RootModel attribute docstrings", _enable_root_model_attribute_docstrings),
514516
_ProcessingStep("inject field validators", _inject_field_validators),
515517
_ProcessingStep("inject deserialize defaults", _inject_deserialize_defaults),
516518
_ProcessingStep("inject schema aliases", _inject_schema_aliases),
@@ -675,7 +677,7 @@ def _ensure_custom_base_model(content: str) -> str:
675677
lines[idx] = "from pydantic import " + ", ".join(new_imports)
676678
to_insert = textwrap.dedent("""\
677679
class BaseModel(_BaseModel):
678-
model_config = ConfigDict(populate_by_name=True)
680+
model_config = ConfigDict(populate_by_name=True, use_attribute_docstrings=True)
679681
680682
def __getattr__(self, item: str) -> Any:
681683
if item.lower() != item:
@@ -691,6 +693,24 @@ def __getattr__(self, item: str) -> Any:
691693
return "\n".join(lines) + "\n"
692694

693695

696+
def _enable_root_model_attribute_docstrings(content: str) -> str:
697+
lines = content.splitlines(keepends=True)
698+
tree = ast.parse(content)
699+
insertion_points = [
700+
node.body[0].lineno - 1
701+
for node in tree.body
702+
if isinstance(node, ast.ClassDef)
703+
and node.body
704+
and any(
705+
isinstance(base, ast.Subscript) and isinstance(base.value, ast.Name) and base.value.id == "RootModel"
706+
for base in node.bases
707+
)
708+
]
709+
for line_index in reversed(insertion_points):
710+
lines.insert(line_index, " model_config = ConfigDict(use_attribute_docstrings=True)\n\n")
711+
return "".join(lines)
712+
713+
694714
def _ensure_pydantic_import(content: str, name: str) -> str:
695715
"""Add *name* to the ``from pydantic import ...`` line if not already present."""
696716
lines = content.splitlines()
@@ -945,8 +965,14 @@ def _restore_required_nullable_fields(content: str, schema: dict[str, Any] | Non
945965
def restore_block(match: re.Match[str], _field_names: list[str] = field_names) -> str:
946966
header, block = match.group(1), match.group(2)
947967
for field_name in _field_names:
948-
field_pattern = re.compile(rf"(\n\s+{re.escape(field_name)}:\s+Annotated\[[\s\S]*?\n\s+\]\s*)=\s*None")
949-
block = field_pattern.sub(r"\1", block, count=1)
968+
field_patterns = (
969+
re.compile(rf"(\n\s+{re.escape(field_name)}:[^\n]*?)\s*=\s*None(?=\n)"),
970+
re.compile(rf"(\n\s+{re.escape(field_name)}:[^\n]*\[\s*\n[\s\S]*?\n\s+\]\s*)=\s*None"),
971+
)
972+
for field_pattern in field_patterns:
973+
block, count = field_pattern.subn(r"\1", block, count=1)
974+
if count:
975+
break
950976
return header + block
951977

952978
content = class_pattern.sub(restore_block, content, count=1)
@@ -955,18 +981,14 @@ def restore_block(match: re.Match[str], _field_names: list[str] = field_names) -
955981

956982
def _apply_field_overrides(content: str) -> str:
957983
for class_name, field_name, new_type, optional in FIELD_TYPE_OVERRIDES:
958-
if optional:
959-
pattern = re.compile(
960-
rf"(class {class_name}\(BaseModel\):.*?\n\s+{field_name}:\s+Annotated\[\s*)Optional\[str],",
961-
re.DOTALL,
962-
)
963-
content, count = pattern.subn(rf"\1Optional[{new_type}],", content)
964-
else:
965-
pattern = re.compile(
966-
rf"(class {class_name}\(BaseModel\):.*?\n\s+{field_name}:\s+Annotated\[\s*)str,",
967-
re.DOTALL,
968-
)
969-
content, count = pattern.subn(rf"\1{new_type},", content)
984+
old_type = "Optional[str]" if optional else "str"
985+
replacement_type = f"Optional[{new_type}]" if optional else new_type
986+
pattern = re.compile(
987+
rf"(class {re.escape(class_name)}\(BaseModel\):.*?\n\s+{re.escape(field_name)}:\s+"
988+
rf"(?:Annotated\[\s*)?){re.escape(old_type)}(?=\s*(?:,|=|\n))",
989+
re.DOTALL,
990+
)
991+
content, count = pattern.subn(rf"\g<1>{replacement_type}", content, count=1)
970992
if count == 0:
971993
print(
972994
f"Warning: failed to apply type override for {class_name}.{field_name} -> {new_type}",
@@ -992,7 +1014,8 @@ def replace_block(
9921014
field_patterns: tuple[tuple[re.Pattern[str], Callable[[re.Match[str]], str]], ...] = (
9931015
(
9941016
re.compile(
995-
rf"(\n\s+{_field_name}:.*?\]\s*=\s*)([\s\S]*?)(?=\n\s{{4}}[A-Za-z_]|$)",
1017+
rf"(\n\s+{_field_name}:.*?\]\s*=\s*)([\s\S]*?)"
1018+
rf"(?=\n\s{{4}}(?:[A-Za-z_][A-Za-z0-9_]*\s*:|[rRuUbBfF]*(?:'''|\"\"\"))|$)",
9961019
re.DOTALL,
9971020
),
9981021
lambda m, _rep=_replacement: m.group(1) + _rep,

0 commit comments

Comments
 (0)