diff --git a/docs/index.md b/docs/index.md index 3fda68e7..633e8f81 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1315,6 +1315,31 @@ print(CliApp.serialize(settings, dict_style='env')) """ ``` +To use [Pydantic field serializers](https://docs.pydantic.dev/latest/concepts/serialization/#field-serializers) during CLI serialization, pass `use_serializers=True`. This is opt-in (defaults to `False`) to preserve existing behavior. + +```py +from pydantic import BaseModel, field_serializer + +from pydantic_settings import CliApp + + +class Settings(BaseModel): + count: int + + @field_serializer('count') + def double_count(self, v: int) -> int: + return v * 2 + + +settings = Settings(count=3) + +print(CliApp.serialize(settings)) +#> ['--count', '3'] + +print(CliApp.serialize(settings, use_serializers=True)) +#> ['--count', '6'] +``` + ### Mutually Exclusive Groups CLI mutually exclusive groups can be created by inheriting from the `CliMutuallyExclusiveGroup` class. diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 2d236ddb..b8cad94b 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -807,6 +807,7 @@ def serialize( list_style: Literal['json', 'argparse', 'lazy'] = 'json', dict_style: Literal['json', 'env'] = 'json', positionals_first: bool = False, + use_serializers: bool = False, ) -> list[str]: """ Serializes the CLI arguments for a Pydantic data model. @@ -837,6 +838,8 @@ def serialize( Example: `--config host=localhost --config port=5432` positionals_first: Controls whether positional arguments should be serialized first compared to optional arguments. Defaults to `False`. + use_serializers: Controls whether [pydantic serializers](https://docs.pydantic.dev/latest/concepts/serialization) + should be used. Defaults to `False`. Returns: The serialized CLI arguments for the data model. @@ -848,6 +851,7 @@ def serialize( list_style=list_style, dict_style=dict_style, positionals_first=positionals_first, + use_serializers=use_serializers, ) return CliSettingsSource._flatten_serialized_args(serialized_args, positionals_first) diff --git a/pydantic_settings/sources/providers/cli.py b/pydantic_settings/sources/providers/cli.py index 25cdb8fa..c658323f 100644 --- a/pydantic_settings/sources/providers/cli.py +++ b/pydantic_settings/sources/providers/cli.py @@ -1523,18 +1523,27 @@ def _flatten_serialized_args( else serialized_args['positional'] + serialized_args['optional'] ) + serialized_args['subcommand'] - def _serialized_args( + def _serialized_args( # noqa: C901 self, model: PydanticModel, list_style: Literal['json', 'argparse', 'lazy'] = 'json', dict_style: Literal['json', 'env'] = 'json', positionals_first: bool = False, + use_serializers: bool = False, _is_submodel: bool = False, ) -> dict[str, list[str]]: alias_path_only_defaults: dict[str, Any] = {} optional_args: list[str | list[Any] | dict[str, Any]] = [] positional_args: list[str | list[Any] | dict[str, Any]] = [] subcommand_args: list[str] = [] + dumped_model: dict[str, Any] = {} + if use_serializers: + if is_model_class(type(model)) and hasattr(model, 'model_dump'): + dumped_model = model.model_dump(mode='json') + elif is_pydantic_dataclass(type(model)): + dumped_model = TypeAdapter(type(model)).dump_python(model, mode='json') + else: + raise ValueError(f'unsupported type: {type(model)}') for field_name, field_info in _get_model_fields(type(model) if _is_submodel else self.settings_cls).items(): model_default = getattr(model, field_name) if field_info.default == model_default: @@ -1549,6 +1558,7 @@ def _serialized_args( list_style=list_style, dict_style=dict_style, positionals_first=positionals_first, + use_serializers=use_serializers, _is_submodel=True, ) subcommand_args += self._flatten_serialized_args(sub_args, positionals_first) @@ -1559,6 +1569,7 @@ def _serialized_args( list_style=list_style, dict_style=dict_style, positionals_first=positionals_first, + use_serializers=use_serializers, _is_submodel=True, ) optional_args += sub_args['optional'] @@ -1568,8 +1579,9 @@ def _serialized_args( matched = re.match(r'(-*)(.+)', arg.preferred_arg_name) flag_chars, arg_name = matched.groups() if matched else ('', '') + dumped_field = dumped_model[field_name] if use_serializers else model_default value: str | list[Any] | dict[str, Any] = ( - json.dumps(model_default) if isinstance(model_default, (dict, list, set)) else str(model_default) + json.dumps(dumped_field) if isinstance(dumped_field, (dict, list, set)) else str(dumped_field) ) if arg.is_alias_path_only: @@ -1579,16 +1591,16 @@ def _serialized_args( value = self._update_alias_path_only_default(arg_name, value, field_info, alias_path_only_defaults) if _CliPositionalArg in field_info.metadata: - for value in model_default if isinstance(model_default, list) else [model_default]: + for value in dumped_field if isinstance(dumped_field, list) else [dumped_field]: value = json.dumps(value) if isinstance(value, (dict, list, set)) else str(value) positional_args.append(value) continue - # Note: prepend 'no-' for boolean optional action flag if model_default value is False and flag is not a short option - if arg.kwargs.get('action') == BooleanOptionalAction and model_default is False and flag_chars == '--': + # Note: prepend 'no-' for boolean optional action flag if dumped_field value is False and flag is not a short option + if arg.kwargs.get('action') == BooleanOptionalAction and dumped_field is False and flag_chars == '--': flag_chars += 'no-' - for value in self._coerce_value_styles(model_default, value, list_style=list_style, dict_style=dict_style): + for value in self._coerce_value_styles(dumped_field, value, list_style=list_style, dict_style=dict_style): optional_args.append(f'{flag_chars}{arg_name}') # If implicit bool flag, do not add a value diff --git a/tests/test_source_cli.py b/tests/test_source_cli.py index c21d44b9..c3aedcee 100644 --- a/tests/test_source_cli.py +++ b/tests/test_source_cli.py @@ -23,6 +23,7 @@ RootModel, Tag, ValidationError, + field_serializer, field_validator, model_validator, ) @@ -3773,3 +3774,76 @@ class Root(BaseSettings, cli_prog_name='example.py'): -x int (default: 1) """ ) + + +def test_cli_serialize_use_serializers_field_serializer(): + """use_serializers=True applies field_serializer transforms.""" + + class Cfg(BaseModel): + value: int + + @field_serializer('value') + def double_value(self, v: int) -> int: + return v * 2 + + cfg = Cfg(value=3) + + # Default (False): raw Python value is used + assert CliApp.serialize(cfg) == ['--value', '3'] + + # With use_serializers=True: serializer doubles the value + assert CliApp.serialize(cfg, use_serializers=True) == ['--value', '6'] + + +def test_cli_serialize_use_serializers_default_false(): + """use_serializers defaults to False, preserving existing behavior.""" + + class Cfg(BaseModel): + name: str + + @field_serializer('name') + def upper_name(self, v: str) -> str: + return v.upper() + + cfg = Cfg(name='hello') + assert CliApp.serialize(cfg) == ['--name', 'hello'] + assert CliApp.serialize(cfg, use_serializers=False) == ['--name', 'hello'] + + +def test_cli_serialize_use_serializers_pydantic_dataclass(): + """use_serializers=True works with pydantic dataclasses.""" + from pydantic import dataclasses as pydantic_dataclasses + + @pydantic_dataclasses.dataclass + class Cfg: + count: int + + @field_serializer('count') + def negate(self, v: int) -> int: + return -v + + cfg = Cfg(count=5) + assert CliApp.serialize(cfg, use_serializers=True) == ['--count', '-5'] + + +def test_cli_serialize_use_serializers_nested(): + """use_serializers=True is propagated through nested subfields.""" + + class Inner(BaseModel): + x: int + + @field_serializer('x') + def triple(self, v: int) -> int: + return v * 3 + + class Outer(BaseModel): + inner: Inner + y: int + + cfg = Outer(inner=Inner(x=2), y=4) + serialized = CliApp.serialize(cfg, use_serializers=True) + # inner.x should be tripled; y has no serializer so unchanged + assert '--inner.x' in serialized + assert serialized[serialized.index('--inner.x') + 1] == '6' + y_flag = next(f for f in serialized if f.lstrip('-') == 'y') + assert serialized[serialized.index(y_flag) + 1] == '4'