diff --git a/docs/cli-reference/field-customization.md b/docs/cli-reference/field-customization.md index 1c9dbc872..041685965 100644 --- a/docs/cli-reference/field-customization.md +++ b/docs/cli-reference/field-customization.md @@ -27,6 +27,7 @@ | [`--use-inline-field-description`](#use-inline-field-description) | Add field descriptions as inline comments. | | [`--use-schema-description`](#use-schema-description) | Use schema description as class docstring. | | [`--use-serialization-alias`](#use-serialization-alias) | Use serialization_alias instead of alias for field aliasing ... | +| [`--use-single-line-docstring`](#use-single-line-docstring) | Emit short docstrings on a single line. | | [`--use-title-as-name`](#use-title-as-name) | Use schema title as the generated class name. | --- @@ -3527,6 +3528,84 @@ serializing to the original JSON property name. --- +## `--use-single-line-docstring` {#use-single-line-docstring} + +Emit short docstrings on a single line. + +The `--use-single-line-docstring` flag formats docstrings that fit on one line +as compact single-line docstrings while keeping the historical multi-line +format as the default. + +**Related:** [`--use-field-description`](field-customization.md#use-field-description), [`--use-schema-description`](field-customization.md#use-schema-description) + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --use-field-description --use-single-line-docstring # (1)! + ``` + + 1. :material-arrow-left: `--use-single-line-docstring` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "firstName": { + "type": "string", + "description": "The person's first name." + }, + "lastName": { + "type": ["string", "null"], + "description": "The person's last name." + }, + "age": { + "description": "Age in years which must be equal to or greater than zero.", + "type": "integer", + "minimum": 0 + }, + "friends": { + "type": "array" + }, + "comment": { + "type": "null" + } + } + } + ``` + + **Output:** + + ```python + # generated by datamodel-codegen: + # filename: person.json + # timestamp: 2019-07-26T00:00:00+00:00 + + from __future__ import annotations + + from typing import Any + + from pydantic import BaseModel, conint + + + class Person(BaseModel): + firstName: str | None = None + """The person's first name.""" + lastName: str | None = None + """The person's last name.""" + age: conint(ge=0) | None = None + """Age in years which must be equal to or greater than zero.""" + friends: list[Any] | None = None + comment: None = None + ``` + +--- + ## `--use-title-as-name` {#use-title-as-name} Use schema title as the generated class name. diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index d481179dc..8d9a9d951 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -10,7 +10,7 @@ This documentation is auto-generated from test cases. |----------|---------|-------------| | 📁 [Base Options](base-options.md) | 10 | Input/output configuration | | 🔧 [Typing Customization](typing-customization.md) | 29 | Type annotation and import behavior | -| 🏷️ [Field Customization](field-customization.md) | 24 | Field naming and docstring behavior | +| 🏷️ [Field Customization](field-customization.md) | 25 | Field naming and docstring behavior | | 🏗️ [Model Customization](model-customization.md) | 39 | Model generation behavior | | 🎨 [Template Customization](template-customization.md) | 21 | Output formatting and custom rendering | | 📘 [OpenAPI-only Options](openapi-only-options.md) | 7 | OpenAPI-specific features | @@ -212,6 +212,7 @@ This documentation is auto-generated from test cases. - [`--use-schema-description`](field-customization.md#use-schema-description) - [`--use-serialization-alias`](field-customization.md#use-serialization-alias) - [`--use-serialize-as-any`](model-customization.md#use-serialize-as-any) +- [`--use-single-line-docstring`](field-customization.md#use-single-line-docstring) - [`--use-specialized-enum`](typing-customization.md#use-specialized-enum) - [`--use-standard-collections`](typing-customization.md#use-standard-collections) - [`--use-standard-primitive-types`](typing-customization.md#use-standard-primitive-types) diff --git a/docs/cli-reference/quick-reference.md b/docs/cli-reference/quick-reference.md index 95acfb3d6..64c41f6e2 100644 --- a/docs/cli-reference/quick-reference.md +++ b/docs/cli-reference/quick-reference.md @@ -88,6 +88,7 @@ datamodel-codegen [OPTIONS] | [`--use-inline-field-description`](field-customization.md#use-inline-field-description) | Add field descriptions as inline comments. | | [`--use-schema-description`](field-customization.md#use-schema-description) | Use schema description as class docstring. | | [`--use-serialization-alias`](field-customization.md#use-serialization-alias) | Use serialization_alias instead of alias for field aliasing (Pydantic v2 only). | +| [`--use-single-line-docstring`](field-customization.md#use-single-line-docstring) | Emit short docstrings on a single line. | | [`--use-title-as-name`](field-customization.md#use-title-as-name) | Use schema title as the generated class name. | ### 🏗️ Model Customization @@ -352,6 +353,7 @@ All options sorted alphabetically: - [`--use-schema-description`](field-customization.md#use-schema-description) - Use schema description as class docstring. - [`--use-serialization-alias`](field-customization.md#use-serialization-alias) - Use serialization_alias instead of alias for field aliasing ... - [`--use-serialize-as-any`](model-customization.md#use-serialize-as-any) - Wrap fields with subtypes in Pydantic's SerializeAsAny. +- [`--use-single-line-docstring`](field-customization.md#use-single-line-docstring) - Emit short docstrings on a single line. - [`--use-specialized-enum`](typing-customization.md#use-specialized-enum) - Generate StrEnum/IntEnum for string/integer enums (Python 3.... - [`--use-standard-collections`](typing-customization.md#use-standard-collections) - Use built-in dict/list instead of typing.Dict/List. - [`--use-standard-primitive-types`](typing-customization.md#use-standard-primitive-types) - Use Python standard library types for string formats instead... diff --git a/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index 131782028..11ec3ef5a 100644 --- a/src/datamodel_code_generator/__init__.py +++ b/src/datamodel_code_generator/__init__.py @@ -727,6 +727,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: "dataclass_arguments": dataclass_arguments, "defer_formatting": defer_formatting, "use_type_checking_imports": config.use_type_checking_imports, + "use_single_line_docstring": config.use_single_line_docstring, "enum_field_as_literal": ( config.enum_field_as_literal if config.enum_field_as_literal is not None diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index e7225ea13..98e5f7bd0 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -453,6 +453,7 @@ def validate_class_name_affix_scope(cls, v: str | ClassNameAffixScope | None) -> use_field_description_example: bool = False use_attribute_docstrings: bool = False use_inline_field_description: bool = False + use_single_line_docstring: bool = False use_default_kwarg: bool = False reuse_model: bool = False reuse_scope: ReuseScope = ReuseScope.Module @@ -906,6 +907,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 use_field_description_example=config.use_field_description_example, use_attribute_docstrings=config.use_attribute_docstrings, use_inline_field_description=config.use_inline_field_description, + use_single_line_docstring=config.use_single_line_docstring, use_default_kwarg=config.use_default_kwarg, reuse_model=config.reuse_model, reuse_scope=config.reuse_scope, diff --git a/src/datamodel_code_generator/_types/generate_config_dict.py b/src/datamodel_code_generator/_types/generate_config_dict.py index c0c278d10..f95200b1b 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -78,6 +78,7 @@ class GenerateConfigDict(TypedDict, closed=True): use_field_description_example: NotRequired[bool] use_attribute_docstrings: NotRequired[bool] use_inline_field_description: NotRequired[bool] + use_single_line_docstring: NotRequired[bool] use_default_kwarg: NotRequired[bool] reuse_model: NotRequired[bool] reuse_scope: NotRequired[ReuseScope] diff --git a/src/datamodel_code_generator/_types/parser_config_dicts.py b/src/datamodel_code_generator/_types/parser_config_dicts.py index c99912474..1665e4f0c 100644 --- a/src/datamodel_code_generator/_types/parser_config_dicts.py +++ b/src/datamodel_code_generator/_types/parser_config_dicts.py @@ -79,6 +79,7 @@ class ParserConfigDict(TypedDict): use_field_description_example: NotRequired[bool] use_attribute_docstrings: NotRequired[bool] use_inline_field_description: NotRequired[bool] + use_single_line_docstring: NotRequired[bool] use_default_kwarg: NotRequired[bool] reuse_model: NotRequired[bool] reuse_scope: NotRequired[ReuseScope | None] diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index 7c8f7a7a0..1bd3f5cc3 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -853,6 +853,12 @@ def start_section(self, heading: str | None) -> None: action="store_true", default=None, ) +field_options.add_argument( + "--use-single-line-docstring", + help="Use single-line docstrings when the content fits on one line", + action="store_true", + default=None, +) field_options.add_argument( "--union-mode", help="Union mode for only pydantic v2 field", diff --git a/src/datamodel_code_generator/cli_options.py b/src/datamodel_code_generator/cli_options.py index ad64860d3..267f28f95 100644 --- a/src/datamodel_code_generator/cli_options.py +++ b/src/datamodel_code_generator/cli_options.py @@ -156,6 +156,7 @@ class CLIOptionMeta: "--use-inline-field-description": CLIOptionMeta( name="--use-inline-field-description", category=OptionCategory.FIELD ), + "--use-single-line-docstring": CLIOptionMeta(name="--use-single-line-docstring", category=OptionCategory.FIELD), "--field-constraints": CLIOptionMeta(name="--field-constraints", category=OptionCategory.FIELD), "--field-extra-keys": CLIOptionMeta(name="--field-extra-keys", category=OptionCategory.FIELD), "--field-extra-keys-without-x-prefix": CLIOptionMeta( diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index 4bd1ed9a3..5b82c4459 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -103,6 +103,7 @@ class GenerateConfig(BaseModel): use_field_description_example: bool = False use_attribute_docstrings: bool = False use_inline_field_description: bool = False + use_single_line_docstring: bool = False use_default_kwarg: bool = False reuse_model: bool = False reuse_scope: ReuseScope = ReuseScope.Module @@ -242,6 +243,7 @@ class ParserConfig(BaseModel): use_field_description_example: bool = False use_attribute_docstrings: bool = False use_inline_field_description: bool = False + use_single_line_docstring: bool = False use_default_kwarg: bool = False reuse_model: bool = False reuse_scope: ReuseScope | None = None diff --git a/src/datamodel_code_generator/model/base.py b/src/datamodel_code_generator/model/base.py index 02eadbd61..a33233aca 100644 --- a/src/datamodel_code_generator/model/base.py +++ b/src/datamodel_code_generator/model/base.py @@ -66,6 +66,68 @@ def escape_docstring(value: str | None) -> str | None: return value.replace("\\", "\\\\").replace('"""', '\\"\\"\\"') +def _ends_with_unescaped_quote(value: str) -> bool: + """Return whether *value* ends with a double quote that is not escaped.""" + if not value.endswith('"'): + return False + + backslash_count = 0 + for char in reversed(value[:-1]): + if char != "\\": + break + backslash_count += 1 + return backslash_count % 2 == 0 + + +def format_docstring(value: str | None, indent_spaces: int = 0, *, use_single_line_docstring: bool = False) -> str: + """Format *value* as a docstring as per PEP 257. + + PEP 257 recommends that docstrings that can fit on one line should be formatted on a + single line, for consistency and readability. When use_single_line_docstring is + false, docstrings retain the historical multi-line formatting. It is assumed that + the opening triple-quotes are indented appropriately in the template. If it's a + multi-line docstring, each line including the closing triple-quotes will be indented + as per indent_spaces. + + Args: + value: docstring text + indent_spaces: Spaces to indent for all lines after the opening triple-quotes + use_single_line_docstring: Use one-line docstrings when possible + + Returns: + Empty string when `value` is falsy; otherwise the docstring block. + """ + if value is None or not value.strip(): + return "" + + escaped = escape_docstring(value) or "" + + if use_single_line_docstring and "\n" not in value and "\r" not in value: + if _ends_with_unescaped_quote(escaped): + escaped = f'{escaped[:-1]}\\"' + + return f'"""{escaped}"""' + + indent = max(indent_spaces, 0) * " " + if indent: + escaped = "\n".join(f"{indent}{line}" if line else "" for line in escaped.split("\n")) + return f'"""\n{escaped}\n{indent}"""' + return f'"""\n{escaped}\n"""' + + +class _RenderedDataModelField: + """Proxy a field with a pre-rendered docstring for built-in templates.""" + + __slots__ = ("_field", "docstring") + + def __init__(self, field: DataModelFieldBase, docstring: str) -> None: + self._field = field + self.docstring = docstring + + def __getattr__(self, name: str) -> Any: + return getattr(self._field, name) + + ALL_MODEL: str = "#all#" GENERIC_BASE_CLASS_PATH: str = "#/__datamodel_code_generator__/generic_base_class__" GENERIC_BASE_CLASS_NAME: str = "__generic_base_class__" @@ -358,6 +420,7 @@ def inline_field_docstring(self) -> str | None: if description is not None and "\n" not in description: escaped = escape_docstring(description) return f'"""{escaped}"""' + return None @property @@ -453,7 +516,8 @@ def _get_environment(template_subdir: Path, custom_template_dir: Path | None) -> loader=loader, autoescape=select_autoescape(["html", "xml"]), ) - env.filters["escape_docstring"] = escape_docstring + env.filters["escape_docstring"] = escape_docstring # For old custom templates + env.filters["format_docstring"] = format_docstring return env @@ -486,7 +550,8 @@ def _get_environment_with_absolute_path(absolute_template_dir: Path, builtin_sub loader=ChoiceLoader(loaders), autoescape=select_autoescape(["html", "xml"]), ) - env.filters["escape_docstring"] = escape_docstring + env.filters["escape_docstring"] = escape_docstring # For old custom templates + env.filters["format_docstring"] = format_docstring return env @@ -598,6 +663,9 @@ class DataModel(TemplateBase, Nullable, ABC): # noqa: PLR0904 SUPPORTS_FIELD_RENAMING: ClassVar[bool] = False SUPPORTS_KW_ONLY: ClassVar[bool] = False REQUIRES_RUNTIME_IMPORTS_WITH_RUFF_CHECK: ClassVar[bool] = False + DOCSTRING_INDENT: ClassVar[int] = 4 + FIELD_DOCSTRING_INDENT: ClassVar[int] = 4 + FORMAT_DESCRIPTION_AS_DOCSTRING: ClassVar[bool] = True has_forward_reference: bool = False def __init__( # noqa: PLR0913 @@ -902,18 +970,46 @@ def set_reference_path(self, new_path: str) -> None: def render(self, *, class_name: str | None = None) -> str: """Render the model to a string using the template.""" + use_custom_template = self.template_file_path.is_absolute() return self._render( class_name=class_name or self.class_name, - fields=self.fields, + fields=self.fields if use_custom_template else self.rendered_fields, decorators=self.decorators, base_class=self.base_class, methods=self.methods, - description=self.description, + description=self.description + if use_custom_template or not self.FORMAT_DESCRIPTION_AS_DOCSTRING + else self.rendered_description, dataclass_arguments=self.dataclass_arguments, path=self.path, **self.extra_template_data, ) + @property + def use_single_line_docstring(self) -> bool: + """Whether single-line docstring formatting is enabled for this model.""" + return bool(self.extra_template_data.get("use_single_line_docstring")) + + def _format_docstring(self, value: str | None, indent_spaces: int) -> str: + return format_docstring( + value, + indent_spaces, + use_single_line_docstring=self.use_single_line_docstring, + ) + + @property + def rendered_description(self) -> str: + """Return the model description as a generated docstring literal.""" + return self._format_docstring(self.description, self.DOCSTRING_INDENT) + + @property + def rendered_fields(self) -> list[DataModelFieldBase | _RenderedDataModelField]: + """Return fields with docstrings prepared for built-in templates.""" + return [ + _RenderedDataModelField(field, self._format_docstring(field.docstring, self.FIELD_DOCSTRING_INDENT)) + for field in self.fields + ] + _rebuild_namespace = {"Union": Union, "DataModelFieldBase": DataModelFieldBase, "DataType": DataType} DataType.model_rebuild(_types_namespace=_rebuild_namespace) diff --git a/src/datamodel_code_generator/model/pydantic_v2/root_model_type_alias.py b/src/datamodel_code_generator/model/pydantic_v2/root_model_type_alias.py index 5674f69c4..97a5560c5 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/root_model_type_alias.py +++ b/src/datamodel_code_generator/model/pydantic_v2/root_model_type_alias.py @@ -28,4 +28,6 @@ class RootModelTypeAlias(RootModel): TEMPLATE_FILE_PATH: ClassVar[str] = "pydantic_v2/RootModelTypeAlias.jinja2" IS_ALIAS: ClassVar[bool] = True + DOCSTRING_INDENT: ClassVar[int] = 0 + FIELD_DOCSTRING_INDENT: ClassVar[int] = 0 DEFAULT_IMPORTS: ClassVar[tuple[Import, ...]] = (IMPORT_ROOT_MODEL,) diff --git a/src/datamodel_code_generator/model/template/Enum.jinja2 b/src/datamodel_code_generator/model/template/Enum.jinja2 index 09a8e2207..7da31a3a5 100644 --- a/src/datamodel_code_generator/model/template/Enum.jinja2 +++ b/src/datamodel_code_generator/model/template/Enum.jinja2 @@ -3,16 +3,12 @@ {% endfor -%} class {{ class_name }}({{ base_class }}): {%- if description %} - """ - {{ description | escape_docstring | indent(4) }} - """ + {{ description }} {%- endif %} {%- for field in fields %} {{ field.name }} = {{ field.default }} {%- if field.docstring %} - """ - {{ field.docstring | escape_docstring | indent(4) }} - """ + {{ field.docstring }} {%- if field.use_inline_field_description and not loop.last %} {% endif %} diff --git a/src/datamodel_code_generator/model/template/ScalarTypeAliasAnnotation.jinja2 b/src/datamodel_code_generator/model/template/ScalarTypeAliasAnnotation.jinja2 index a214eb596..adb65e94a 100644 --- a/src/datamodel_code_generator/model/template/ScalarTypeAliasAnnotation.jinja2 +++ b/src/datamodel_code_generator/model/template/ScalarTypeAliasAnnotation.jinja2 @@ -1,6 +1,4 @@ {{ class_name }}: TypeAlias = {{ py_type }} {%- if description %} -""" {{ description }} -""" {%- endif %} diff --git a/src/datamodel_code_generator/model/template/ScalarTypeAliasType.jinja2 b/src/datamodel_code_generator/model/template/ScalarTypeAliasType.jinja2 index ce30cf157..b319aa87a 100644 --- a/src/datamodel_code_generator/model/template/ScalarTypeAliasType.jinja2 +++ b/src/datamodel_code_generator/model/template/ScalarTypeAliasType.jinja2 @@ -1,6 +1,4 @@ {{ class_name }} = TypeAliasType("{{ class_name }}", {{ py_type }}) {%- if description %} -""" {{ description }} -""" {%- endif %} diff --git a/src/datamodel_code_generator/model/template/ScalarTypeStatement.jinja2 b/src/datamodel_code_generator/model/template/ScalarTypeStatement.jinja2 index 3299ab6d8..81d26151f 100644 --- a/src/datamodel_code_generator/model/template/ScalarTypeStatement.jinja2 +++ b/src/datamodel_code_generator/model/template/ScalarTypeStatement.jinja2 @@ -1,6 +1,4 @@ type {{ class_name }} = {{ py_type }} {%- if description %} -""" {{ description }} -""" -{%- endif %} \ No newline at end of file +{%- endif %} diff --git a/src/datamodel_code_generator/model/template/TypeAliasAnnotation.jinja2 b/src/datamodel_code_generator/model/template/TypeAliasAnnotation.jinja2 index 85265d402..151828768 100644 --- a/src/datamodel_code_generator/model/template/TypeAliasAnnotation.jinja2 +++ b/src/datamodel_code_generator/model/template/TypeAliasAnnotation.jinja2 @@ -11,19 +11,13 @@ Annotated[{{ _field.type_hint }}, {{ _field.field }}] {%- if fields %} {{ class_name }}: TypeAlias = {{ get_type_annotation(fields[0]) }}{% if comment is defined %} # {{ comment }}{% endif %} {%- if description %} -""" -{{ description | escape_docstring | indent(0) }} -""" +{{ description }} {%- elif fields[0].docstring %} -""" -{{ fields[0].docstring | escape_docstring | indent(0) }} -""" +{{ fields[0].docstring }} {%- endif %} {%- else %} {{ class_name }}: TypeAlias = {{ base_class }}{% if comment is defined %} # {{ comment }}{% endif %} {%- if description %} -""" -{{ description | escape_docstring | indent(0) }} -""" +{{ description }} {%- endif %} {%- endif %} diff --git a/src/datamodel_code_generator/model/template/TypeAliasType.jinja2 b/src/datamodel_code_generator/model/template/TypeAliasType.jinja2 index bd19b2a3f..111dca508 100644 --- a/src/datamodel_code_generator/model/template/TypeAliasType.jinja2 +++ b/src/datamodel_code_generator/model/template/TypeAliasType.jinja2 @@ -11,19 +11,13 @@ Annotated[{{ _field.type_hint }}, {{ _field.field }}] {%- if fields %} {{ class_name }} = TypeAliasType("{{ class_name }}", {{ get_type_annotation(fields[0]) }}){% if comment is defined %} # {{ comment }}{% endif %} {%- if description %} -""" -{{ description | escape_docstring | indent(0) }} -""" +{{ description }} {%- elif fields[0].docstring %} -""" -{{ fields[0].docstring | escape_docstring | indent(0) }} -""" +{{ fields[0].docstring }} {%- endif %} {%- else %} {{ class_name }} = TypeAliasType("{{ class_name }}", {{ base_class }}){% if comment is defined %} # {{ comment }}{% endif %} {%- if description %} -""" -{{ description | escape_docstring | indent(0) }} -""" +{{ description }} {%- endif %} {%- endif %} diff --git a/src/datamodel_code_generator/model/template/TypeStatement.jinja2 b/src/datamodel_code_generator/model/template/TypeStatement.jinja2 index ce03c241b..f1ea7d675 100644 --- a/src/datamodel_code_generator/model/template/TypeStatement.jinja2 +++ b/src/datamodel_code_generator/model/template/TypeStatement.jinja2 @@ -11,19 +11,13 @@ Annotated[{{ _field.type_hint }}, {{ _field.field }}] {%- if fields %} type {{ class_name }} = {{ get_type_annotation(fields[0]) }}{% if comment is defined %} # {{ comment }}{% endif %} {%- if description %} -""" -{{ description | escape_docstring | indent(0) }} -""" +{{ description }} {%- elif fields[0].docstring %} -""" -{{ fields[0].docstring | escape_docstring | indent(0) }} -""" +{{ fields[0].docstring }} {%- endif %} {%- else %} type {{ class_name }} = {{ base_class }}{% if comment is defined %} # {{ comment }}{% endif %} {%- if description %} -""" -{{ description | escape_docstring | indent(0) }} -""" +{{ description }} +{%- endif %} {%- endif %} -{%- endif %} \ No newline at end of file diff --git a/src/datamodel_code_generator/model/template/TypedDictClass.jinja2 b/src/datamodel_code_generator/model/template/TypedDictClass.jinja2 index 7087b924c..f0c76ae82 100644 --- a/src/datamodel_code_generator/model/template/TypedDictClass.jinja2 +++ b/src/datamodel_code_generator/model/template/TypedDictClass.jinja2 @@ -1,8 +1,6 @@ class {{ class_name }}({{ base_class }}): {%- if description %} - """ - {{ description | escape_docstring | indent(4) }} - """ + {{ description }} {%- endif %} {%- if not fields and not description %} pass @@ -10,9 +8,7 @@ class {{ class_name }}({{ base_class }}): {%- for field in fields %} {{ field.name }}: {{ field.type_hint }} {%- if field.docstring %} - """ - {{ field.docstring | escape_docstring | indent(4) }} - """ + {{ field.docstring }} {%- if field.use_inline_field_description and not loop.last %} {% endif %} diff --git a/src/datamodel_code_generator/model/template/TypedDictFunction.jinja2 b/src/datamodel_code_generator/model/template/TypedDictFunction.jinja2 index 6ced50efd..82545f4e0 100644 --- a/src/datamodel_code_generator/model/template/TypedDictFunction.jinja2 +++ b/src/datamodel_code_generator/model/template/TypedDictFunction.jinja2 @@ -1,15 +1,11 @@ {%- if description %} -""" -{{ description | escape_docstring | indent(4) }} -""" + {{ description }} {%- endif %} {{ class_name }} = TypedDict('{{ class_name }}', { {%- for field in all_fields %} '{{ field.key }}': {{ field.type_hint }}, {%- if field.docstring %} - """ - {{ field.docstring | escape_docstring | indent(4) }} - """ + {{ field.docstring }} {%- if field.use_inline_field_description and not loop.last %} {% endif %} diff --git a/src/datamodel_code_generator/model/template/dataclass.jinja2 b/src/datamodel_code_generator/model/template/dataclass.jinja2 index 46b9ddab6..0460dcaa2 100644 --- a/src/datamodel_code_generator/model/template/dataclass.jinja2 +++ b/src/datamodel_code_generator/model/template/dataclass.jinja2 @@ -18,9 +18,7 @@ class {{ class_name }}({{ base_class }}): class {{ class_name }}: {%- endif %} {%- if description %} - """ - {{ description | escape_docstring | indent(4) }} - """ + {{ description }} {%- endif %} {%- if not fields and not description %} pass @@ -35,9 +33,7 @@ class {{ class_name }}: {%- endif -%} {%- endif %} {%- if field.docstring %} - """ - {{ field.docstring | escape_docstring | indent(4) }} - """ + {{ field.docstring }} {%- if field.use_inline_field_description and not loop.last %} {% endif %} diff --git a/src/datamodel_code_generator/model/template/msgspec.jinja2 b/src/datamodel_code_generator/model/template/msgspec.jinja2 index 0b712b297..f1765b13f 100644 --- a/src/datamodel_code_generator/model/template/msgspec.jinja2 +++ b/src/datamodel_code_generator/model/template/msgspec.jinja2 @@ -9,9 +9,7 @@ class {{ class_name }}({{ base_class }}{%- for key, value in (base_class_kwargs| class {{ class_name }}: {%- endif %} {%- if description %} - """ - {{ description | escape_docstring | indent(4) }} - """ + {{ description }} {%- endif %} {%- set ns = namespace(has_rendered_field=false) -%} {%- for field in fields -%} @@ -37,9 +35,7 @@ class {{ class_name }}: {%- if not field.extras.get('is_classvar') and field.docstring %} - """ - {{ field.docstring | escape_docstring | indent(4) }} - """ + {{ field.docstring }} {%- if field.use_inline_field_description and not loop.last %} {% endif %} diff --git a/src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 b/src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 index 0d812ff60..ae102e07b 100644 --- a/src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 +++ b/src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 @@ -3,9 +3,7 @@ {% endfor -%} class {{ class_name }}({{ base_class }}):{% if comment is defined %} # {{ comment }}{% endif %} {%- if description %} - """ - {{ description | escape_docstring | indent(4) }} - """ + {{ description }} {%- endif %} {%- if not fields and not description and not config and not class_body_lines %} pass @@ -32,9 +30,7 @@ class {{ class_name }}({{ base_class }}):{% if comment is defined %} # {{ comme {%- endif -%} {%- endif %} {%- if field.docstring %} - """ - {{ field.docstring | escape_docstring | indent(4) }} - """ + {{ field.docstring }} {%- if field.use_inline_field_description and not loop.last %} {% endif %} diff --git a/src/datamodel_code_generator/model/template/pydantic_v2/RootModel.jinja2 b/src/datamodel_code_generator/model/template/pydantic_v2/RootModel.jinja2 index af5de33b7..446e8d122 100644 --- a/src/datamodel_code_generator/model/template/pydantic_v2/RootModel.jinja2 +++ b/src/datamodel_code_generator/model/template/pydantic_v2/RootModel.jinja2 @@ -18,9 +18,7 @@ {%- set use_base_type = config and config.regex_engine -%} class {{ class_name }}({{ base_class }}{%- if fields -%}[{{get_type_hint(fields, use_base_type)}}]{%- endif -%}):{% if comment is defined %} # {{ comment }}{% endif %} {%- if description %} - """ - {{ description | escape_docstring | indent(4) }} - """ + {{ description }} {%- endif %} {%- if config %} {%- filter indent(4) %} @@ -47,9 +45,7 @@ class {{ class_name }}({{ base_class }}{%- if fields -%}[{{get_type_hint(fields, {%- endif -%} {%- endif %} {%- if field.docstring %} - """ - {{ field.docstring | escape_docstring | indent(4) }} - """ + {{ field.docstring }} {%- elif field.inline_field_docstring %} {{ field.inline_field_docstring }} diff --git a/src/datamodel_code_generator/model/template/pydantic_v2/RootModelTypeAlias.jinja2 b/src/datamodel_code_generator/model/template/pydantic_v2/RootModelTypeAlias.jinja2 index 401d83f23..95be3b71f 100644 --- a/src/datamodel_code_generator/model/template/pydantic_v2/RootModelTypeAlias.jinja2 +++ b/src/datamodel_code_generator/model/template/pydantic_v2/RootModelTypeAlias.jinja2 @@ -12,11 +12,7 @@ {%- set use_base_type = config and config.regex_engine -%} {{ class_name }} = RootModel[{{get_type_hint(fields, use_base_type)}}]{% if comment is defined %} # {{ comment }}{% endif %} {%- if description %} -""" {{ description }} -""" {%- elif fields and fields[0].docstring %} -""" {{ fields[0].docstring }} -""" {%- endif %} diff --git a/src/datamodel_code_generator/model/template/pydantic_v2/dataclass.jinja2 b/src/datamodel_code_generator/model/template/pydantic_v2/dataclass.jinja2 index 00b12e628..2ce858bf8 100644 --- a/src/datamodel_code_generator/model/template/pydantic_v2/dataclass.jinja2 +++ b/src/datamodel_code_generator/model/template/pydantic_v2/dataclass.jinja2 @@ -25,9 +25,7 @@ class {{ class_name }}({{ base_class }}): class {{ class_name }}: {%- endif %} {%- if description %} - """ - {{ description | escape_docstring | indent(4) }} - """ + {{ description }} {%- endif %} {%- if not fields and not description %} pass @@ -46,9 +44,7 @@ class {{ class_name }}: {%- endif -%} {%- endif %} {%- if field.docstring %} - """ - {{ field.docstring | escape_docstring | indent(4) }} - """ + {{ field.docstring }} {%- if field.use_inline_field_description and not loop.last %} {% endif %} diff --git a/src/datamodel_code_generator/model/type_alias.py b/src/datamodel_code_generator/model/type_alias.py index 319b127cf..ff7e10360 100644 --- a/src/datamodel_code_generator/model/type_alias.py +++ b/src/datamodel_code_generator/model/type_alias.py @@ -23,6 +23,8 @@ class TypeAliasBase(DataModel): IS_ALIAS: ClassVar[bool] = True SUPPORTS_GENERIC_BASE_CLASS: ClassVar[bool] = False + DOCSTRING_INDENT: ClassVar[int] = 0 + FIELD_DOCSTRING_INDENT: ClassVar[int] = 0 @property def imports(self) -> tuple[Import, ...]: diff --git a/src/datamodel_code_generator/model/typed_dict.py b/src/datamodel_code_generator/model/typed_dict.py index afe1a7dd9..fb687163c 100644 --- a/src/datamodel_code_generator/model/typed_dict.py +++ b/src/datamodel_code_generator/model/typed_dict.py @@ -164,13 +164,14 @@ def all_fields(self) -> Iterator[DataModelFieldBase]: def render(self, *, class_name: str | None = None) -> str: """Render TypedDict class with appropriate syntax.""" + use_custom_template = self.template_file_path.is_absolute() return self._render( class_name=class_name or self.class_name, - fields=self.fields, + fields=self.fields if use_custom_template else self.rendered_fields, decorators=self.decorators, base_class=self.base_class, methods=self.methods, - description=self.description, + description=self.description if use_custom_template else self.rendered_description, is_functional_syntax=self.is_functional_syntax, all_fields=self.all_fields, **self.extra_template_data, diff --git a/src/datamodel_code_generator/model/union.py b/src/datamodel_code_generator/model/union.py index 1375c90fb..f2fabcdbf 100644 --- a/src/datamodel_code_generator/model/union.py +++ b/src/datamodel_code_generator/model/union.py @@ -26,6 +26,8 @@ class _DataTypeUnionBase(DataModel): """Base class for GraphQL union types with shared __init__ logic.""" + FORMAT_DESCRIPTION_AS_DOCSTRING: ClassVar[bool] = False + def __init__( # noqa: PLR0913 self, *, diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index ada2399b3..cc51863a0 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -985,6 +985,7 @@ def __init__( # noqa: PLR0912, PLR0915 self.use_field_description: bool = config.use_field_description self.use_field_description_example: bool = config.use_field_description_example self.use_inline_field_description: bool = config.use_inline_field_description + self.use_single_line_docstring: bool = config.use_single_line_docstring self.use_default_kwarg: bool = config.use_default_kwarg self.reuse_model: bool = config.reuse_model self.reuse_scope: ReuseScope | None = config.reuse_scope @@ -1068,6 +1069,8 @@ def __init__( # noqa: PLR0912, PLR0915 self.generic_base_class_config["use_attribute_docstrings"] = True else: self.extra_template_data[ALL_MODEL]["use_attribute_docstrings"] = True + if config.use_single_line_docstring: + self.extra_template_data[ALL_MODEL]["use_single_line_docstring"] = True if config.target_pydantic_version: if config.use_generic_base_class: diff --git a/src/datamodel_code_generator/prompt_data.py b/src/datamodel_code_generator/prompt_data.py index 21d442f9f..3dd5527f7 100644 --- a/src/datamodel_code_generator/prompt_data.py +++ b/src/datamodel_code_generator/prompt_data.py @@ -130,6 +130,7 @@ "--use-generic-base-class": "Generate a shared base class with model configuration to avoid repetition (DRY).", "--use-generic-container-types": "Use generic container types (Sequence, Mapping) for type hinting.", "--use-inline-field-description": "Add field descriptions as inline comments.", + "--use-single-line-docstring": "Use single-line docstrings when the content fits on one line.", "--use-non-positive-negative-number-constrained-types": "Use NonPositive/NonNegative types for number constraints.", "--use-one-literal-as-default": "Use single literal value as default when enum has only one option.", "--use-operation-id-as-name": "Use OpenAPI operationId as the generated function/class name.", diff --git a/tests/data/expected/main/help/color.txt b/tests/data/expected/main/help/color.txt index f34ce56d9..20ef08006 100644 --- a/tests/data/expected/main/help/color.txt +++ b/tests/data/expected/main/help/color.txt @@ -256,6 +256,9 @@ For detailed usage, see: https://datamodel-code-generator.koxudaxi.dev aliasing (Pydantic v2 only). This allows setting values using the Pythonic field name while serializing to the original name. + --use-single-line-docstring + Use single-line docstrings when the content fits on + one line Model customization: --all-exports-collision-strategy {error,minimal-prefix,full-prefix} diff --git a/tests/data/expected/main/help/no_color.txt b/tests/data/expected/main/help/no_color.txt index d31bbb398..1aa862595 100644 --- a/tests/data/expected/main/help/no_color.txt +++ b/tests/data/expected/main/help/no_color.txt @@ -256,6 +256,9 @@ Field customization: aliasing (Pydantic v2 only). This allows setting values using the Pythonic field name while serializing to the original name. + --use-single-line-docstring + Use single-line docstrings when the content fits on + one line Model customization: --all-exports-collision-strategy {error,minimal-prefix,full-prefix} diff --git a/tests/data/expected/main/input_model/config_class.py b/tests/data/expected/main/input_model/config_class.py index 02437ce39..308ad584d 100644 --- a/tests/data/expected/main/input_model/config_class.py +++ b/tests/data/expected/main/input_model/config_class.py @@ -155,6 +155,7 @@ class GenerateConfig(TypedDict, closed=True): use_field_description_example: NotRequired[bool] use_attribute_docstrings: NotRequired[bool] use_inline_field_description: NotRequired[bool] + use_single_line_docstring: NotRequired[bool] use_default_kwarg: NotRequired[bool] reuse_model: NotRequired[bool] reuse_scope: NotRequired[ReuseScope] diff --git a/tests/data/expected/main/use_single_line_docstring.py b/tests/data/expected/main/use_single_line_docstring.py new file mode 100644 index 000000000..13760c888 --- /dev/null +++ b/tests/data/expected/main/use_single_line_docstring.py @@ -0,0 +1,20 @@ +# generated by datamodel-codegen: +# filename: person.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, conint + + +class Person(BaseModel): + firstName: str | None = None + """The person's first name.""" + lastName: str | None = None + """The person's last name.""" + age: conint(ge=0) | None = None + """Age in years which must be equal to or greater than zero.""" + friends: list[Any] | None = None + comment: None = None diff --git a/tests/data/expected/main_kr/generate_prompt/basic.txt b/tests/data/expected/main_kr/generate_prompt/basic.txt index 90ebc7f90..509d6e3ee 100644 --- a/tests/data/expected/main_kr/generate_prompt/basic.txt +++ b/tests/data/expected/main_kr/generate_prompt/basic.txt @@ -75,6 +75,7 @@ - `--use-inline-field-description`: Add field descriptions as inline comments. - `--use-schema-description`: Use schema description as class docstring. - `--use-serialization-alias`: Use serialization_alias instead of alias for field aliasing (Pydantic v2 only). +- `--use-single-line-docstring`: Use single-line docstrings when the content fits on one line. - `--use-title-as-name`: Use schema title as the generated class name. ### Model Customization @@ -432,6 +433,9 @@ For detailed usage, see: https://datamodel-code-generator.koxudaxi.dev aliasing (Pydantic v2 only). This allows setting values using the Pythonic field name while serializing to the original name. + --use-single-line-docstring + Use single-line docstrings when the content fits on + one line Model customization: --all-exports-collision-strategy {error,minimal-prefix,full-prefix} diff --git a/tests/data/expected/main_kr/generate_prompt/with_list_options.txt b/tests/data/expected/main_kr/generate_prompt/with_list_options.txt index 820550c3a..f4f09a4a8 100644 --- a/tests/data/expected/main_kr/generate_prompt/with_list_options.txt +++ b/tests/data/expected/main_kr/generate_prompt/with_list_options.txt @@ -75,6 +75,7 @@ - `--use-inline-field-description`: Add field descriptions as inline comments. - `--use-schema-description`: Use schema description as class docstring. - `--use-serialization-alias`: Use serialization_alias instead of alias for field aliasing (Pydantic v2 only). +- `--use-single-line-docstring`: Use single-line docstrings when the content fits on one line. - `--use-title-as-name`: Use schema title as the generated class name. ### Model Customization @@ -432,6 +433,9 @@ For detailed usage, see: https://datamodel-code-generator.koxudaxi.dev aliasing (Pydantic v2 only). This allows setting values using the Pythonic field name while serializing to the original name. + --use-single-line-docstring + Use single-line docstrings when the content fits on + one line Model customization: --all-exports-collision-strategy {error,minimal-prefix,full-prefix} diff --git a/tests/data/expected/main_kr/generate_prompt/with_options.txt b/tests/data/expected/main_kr/generate_prompt/with_options.txt index 01f4ffb63..d34f82f54 100644 --- a/tests/data/expected/main_kr/generate_prompt/with_options.txt +++ b/tests/data/expected/main_kr/generate_prompt/with_options.txt @@ -81,6 +81,7 @@ What other options should I use? - `--use-inline-field-description`: Add field descriptions as inline comments. - `--use-schema-description`: Use schema description as class docstring. - `--use-serialization-alias`: Use serialization_alias instead of alias for field aliasing (Pydantic v2 only). +- `--use-single-line-docstring`: Use single-line docstrings when the content fits on one line. - `--use-title-as-name`: Use schema title as the generated class name. ### Model Customization @@ -438,6 +439,9 @@ For detailed usage, see: https://datamodel-code-generator.koxudaxi.dev aliasing (Pydantic v2 only). This allows setting values using the Pythonic field name while serializing to the original name. + --use-single-line-docstring + Use single-line docstrings when the content fits on + one line Model customization: --all-exports-collision-strategy {error,minimal-prefix,full-prefix} diff --git a/tests/data/expected/main_kr/generate_prompt/with_question.txt b/tests/data/expected/main_kr/generate_prompt/with_question.txt index d6285a325..1cb9f1148 100644 --- a/tests/data/expected/main_kr/generate_prompt/with_question.txt +++ b/tests/data/expected/main_kr/generate_prompt/with_question.txt @@ -79,6 +79,7 @@ How do I convert enums to Literal types? - `--use-inline-field-description`: Add field descriptions as inline comments. - `--use-schema-description`: Use schema description as class docstring. - `--use-serialization-alias`: Use serialization_alias instead of alias for field aliasing (Pydantic v2 only). +- `--use-single-line-docstring`: Use single-line docstrings when the content fits on one line. - `--use-title-as-name`: Use schema title as the generated class name. ### Model Customization @@ -436,6 +437,9 @@ For detailed usage, see: https://datamodel-code-generator.koxudaxi.dev aliasing (Pydantic v2 only). This allows setting values using the Pythonic field name while serializing to the original name. + --use-single-line-docstring + Use single-line docstrings when the content fits on + one line Model customization: --all-exports-collision-strategy {error,minimal-prefix,full-prefix} diff --git a/tests/data/expected/main_kr/help_shows_new_options.txt b/tests/data/expected/main_kr/help_shows_new_options.txt index f34ce56d9..20ef08006 100644 --- a/tests/data/expected/main_kr/help_shows_new_options.txt +++ b/tests/data/expected/main_kr/help_shows_new_options.txt @@ -256,6 +256,9 @@ For detailed usage, see: https://datamodel-code-generator.koxudaxi.dev aliasing (Pydantic v2 only). This allows setting values using the Pythonic field name while serializing to the original name. + --use-single-line-docstring + Use single-line docstrings when the content fits on + one line Model customization: --all-exports-collision-strategy {error,minimal-prefix,full-prefix} diff --git a/tests/data/templates_schema_id/pydantic_v2/BaseModel.jinja2 b/tests/data/templates_schema_id/pydantic_v2/BaseModel.jinja2 index 30f897675..b6c635dc4 100644 --- a/tests/data/templates_schema_id/pydantic_v2/BaseModel.jinja2 +++ b/tests/data/templates_schema_id/pydantic_v2/BaseModel.jinja2 @@ -11,9 +11,7 @@ an alias for Bar: every pydantic model class consumes considerable memory. #} {% endfor -%} class {{ class_name }}({{ base_class }}):{% if comment is defined %} # {{ comment }}{% endif %} {%- if description %} - """ - {{ description | escape_docstring | indent(4) }} - """ + {{ description | format_docstring(4, use_single_line_docstring=use_single_line_docstring) }} {%- endif %} {%- if schema_id is defined and schema_id %} __schema_id__ = "{{ schema_id }}" @@ -43,9 +41,7 @@ class {{ class_name }}({{ base_class }}):{% if comment is defined %} # {{ comme {%- endif -%} {%- endif %} {%- if field.docstring %} - """ - {{ field.docstring | escape_docstring | indent(4) }} - """ + {{ field.docstring | format_docstring(4, use_single_line_docstring=use_single_line_docstring) }} {%- if field.use_inline_field_description and not loop.last %} {% endif %} diff --git a/tests/main/test_main_general.py b/tests/main/test_main_general.py index 3d848b5b6..b293f95dd 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -1930,6 +1930,60 @@ def test_generate_returns_string_with_dataclass() -> None: assert_output(result, EXPECTED_MAIN_PATH / "generate_returns_string_with_dataclass.py") +@pytest.mark.allow_direct_assert +def test_generate_uses_multiline_docstring_by_default() -> None: + """Test that schema descriptions keep the historical multi-line docstring format.""" + json_schema = '{"title": "Person", "description": "Person model", "type": "object", "properties": {}}' + result = generate( + json_schema, + input_file_type=InputFileType.JsonSchema, + input_filename="schema.json", + use_schema_description=True, + disable_timestamp=True, + ) + assert isinstance(result, str) + assert 'class Person(BaseModel):\n """\n Person model\n """' in result + + +@pytest.mark.allow_direct_assert +def test_generate_uses_single_line_docstring_when_enabled() -> None: + """Test that schema descriptions use one-line docstrings when requested.""" + json_schema = '{"title": "Person", "description": "Person model", "type": "object", "properties": {}}' + result = generate( + json_schema, + input_file_type=InputFileType.JsonSchema, + input_filename="schema.json", + use_schema_description=True, + use_single_line_docstring=True, + disable_timestamp=True, + ) + assert isinstance(result, str) + assert 'class Person(BaseModel):\n """Person model"""' in result + + +@pytest.mark.cli_doc( + options=["--use-single-line-docstring"], + option_description="""Emit short docstrings on a single line. + +The `--use-single-line-docstring` flag formats docstrings that fit on one line +as compact single-line docstrings while keeping the historical multi-line +format as the default.""", + input_schema="jsonschema/person.json", + cli_args=["--use-field-description", "--use-single-line-docstring"], + golden_output="main/use_single_line_docstring.py", + related_options=["--use-schema-description", "--use-field-description"], +) +def test_main_use_single_line_docstring(output_file: Path) -> None: + """Emit short docstrings on a single line.""" + run_main_and_assert( + input_path=DATA_PATH / "jsonschema" / "person.json", + output_path=output_file, + assert_func=assert_file_content, + expected_file="use_single_line_docstring.py", + extra_args=["--use-field-description", "--use-single-line-docstring"], + ) + + def test_generate_returns_none_when_output_path_provided(tmp_path: Path) -> None: """Test that generate() returns None when output path is provided.""" json_schema = '{"type": "object", "properties": {"name": {"type": "string"}}}' diff --git a/tests/main/test_public_api_signature_baseline.py b/tests/main/test_public_api_signature_baseline.py index b5511a070..46c36e47b 100644 --- a/tests/main/test_public_api_signature_baseline.py +++ b/tests/main/test_public_api_signature_baseline.py @@ -93,6 +93,7 @@ def _baseline_generate( use_field_description_example: bool = False, use_attribute_docstrings: bool = False, use_inline_field_description: bool = False, + use_single_line_docstring: bool = False, use_default_kwarg: bool = False, reuse_model: bool = False, reuse_scope: ReuseScope = ReuseScope.Module, @@ -234,6 +235,7 @@ def __init__( use_field_description_example: bool = False, use_attribute_docstrings: bool = False, use_inline_field_description: bool = False, + use_single_line_docstring: bool = False, use_default_kwarg: bool = False, reuse_model: bool = False, reuse_scope: ReuseScope | None = None, diff --git a/tests/model/test_base.py b/tests/model/test_base.py index 2a0fbfe1e..86474b3e5 100644 --- a/tests/model/test_base.py +++ b/tests/model/test_base.py @@ -13,6 +13,7 @@ DataModelFieldBase, TemplateBase, escape_docstring, + format_docstring, get_module_path, sanitize_module_name, ) @@ -396,6 +397,37 @@ def test_escape_docstring(input_value: str | None, expected: str | None) -> None assert escape_docstring(input_value) == expected +def test_format_docstring_uses_multiline_format_by_default() -> None: + """Test format_docstring preserves historical multi-line formatting by default.""" + assert format_docstring("Description", 4) == '"""\n Description\n """' + + +@pytest.mark.parametrize("empty_value", [None, "", " "]) +def test_format_docstring_returns_empty_string_for_empty_values(empty_value: str | None) -> None: + """Test format_docstring returns an empty string for empty values.""" + assert not format_docstring(empty_value, 4) + + +def test_format_docstring_uses_single_line_when_enabled() -> None: + """Test format_docstring emits one-line docstrings when enabled.""" + assert format_docstring("Description", 4, use_single_line_docstring=True) == '"""Description"""' + + +def test_format_docstring_escapes_trailing_quote_without_changing_docstring() -> None: + """Test one-line docstrings ending with a quote preserve their value.""" + assert format_docstring('Description"', 4, use_single_line_docstring=True) == r'"""Description\""""' + + +def test_format_docstring_escapes_single_quote_docstring() -> None: + """Test a docstring consisting only of a quote is escaped.""" + assert format_docstring('"', 4, use_single_line_docstring=True) == r'"""\""""' + + +def test_format_docstring_keeps_escaped_triple_quotes_without_extra_escape() -> None: + """Test escaped triple quotes at the end are not double-escaped.""" + assert format_docstring('Description """', 4, use_single_line_docstring=True) == r'"""Description \"\"\""""' + + def test_inline_field_docstring_escapes_special_chars() -> None: """Test inline_field_docstring property escapes special characters.""" field = DataModelFieldBase(