Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions pydantic_settings/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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)

Expand Down
24 changes: 18 additions & 6 deletions pydantic_settings/sources/providers/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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']
Expand All @@ -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:
Expand All @@ -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
Expand Down
74 changes: 74 additions & 0 deletions tests/test_source_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
RootModel,
Tag,
ValidationError,
field_serializer,
field_validator,
model_validator,
)
Expand Down Expand Up @@ -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'
Loading