pydantic / pydantic-settings

Settings management using pydantic
https://docs.pydantic.dev/latest/usage/pydantic_settings/
MIT License
502 stars 47 forks source link

CLI help interacts badly with strings containing `'%'` #304

Closed scottstanie closed 4 weeks ago

scottstanie commented 1 month ago

The new CLI parsing is awesome, but I've stumbled across an annoying issue relating to argparse:

Stripping down the demo to one string field, the default can't have a % in it or argparse errors with ValueError: unsupported format character...

import sys

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class M(BaseSettings):
    model_config = SettingsConfigDict(cli_parse_args=True)

    date_fmt1: str = Field(
        "%Y%m%d", description="String date format for `datetime.strptime`"
    )

sys.argv = ["pydantic-settings-bug.py", "--help"]

print(M().model_dump())
Traceback (most recent call last):
  File "/Users/staniewi/repos/dolphin/pydantic-settings-bug.py", line 17, in <module>
    print(M().model_dump())
          ^^^
  File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/site-packages/pydantic_settings/main.py", line 141, in __init__
    **__pydantic_self__._settings_build_values(
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
  File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 543, in _format_action
    help_text = self._expand_help(action)
                ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 640, in _expand_help
    return self._get_help_string(action) % params
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~
ValueError: unsupported format character 'Y' (0x59) at index 54

Full traceback:

```python-traceback $ python pydantic-settings-bug.py Traceback (most recent call last): File "/Users/staniewi/repos/dolphin/pydantic-settings-bug.py", line 17, in print(M().model_dump()) ^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/site-packages/pydantic_settings/main.py", line 141, in __init__ **__pydantic_self__._settings_build_values( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/site-packages/pydantic_settings/main.py", line 260, in _settings_build_values CliSettingsSource( File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/site-packages/pydantic_settings/sources.py", line 919, in __init__ self._load_env_vars(parsed_args=self._parse_args(self.root_parser, cli_parse_args)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/site-packages/pydantic_settings/sources.py", line 1201, in parse_args_insensitive_method return parser_method(root_parser, insensitive_args, namespace) # type: ignore ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 1869, in parse_args args, argv = self.parse_known_args(args, namespace) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 1902, in parse_known_args namespace, args = self._parse_known_args(args, namespace) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 2114, in _parse_known_args start_index = consume_optional(start_index) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 2054, in consume_optional take_action(action, args, option_string) File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 1978, in take_action action(self, namespace, argument_values, option_string) File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 1119, in __call__ parser.print_help() File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 2601, in print_help self._print_message(self.format_help(), file) ^^^^^^^^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 2585, in format_help return formatter.format_help() ^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 286, in format_help help = self._root_section.format_help() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 217, in format_help item_help = join([func(*args) for func, args in self.items]) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 217, in item_help = join([func(*args) for func, args in self.items]) ^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 217, in format_help item_help = join([func(*args) for func, args in self.items]) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 217, in item_help = join([func(*args) for func, args in self.items]) ^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 543, in _format_action help_text = self._expand_help(action) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/staniewi/miniconda3/envs/mapping-311/lib/python3.11/argparse.py", line 640, in _expand_help return self._get_help_string(action) % params ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~ ValueError: unsupported format character 'Y' (0x59) at index 54 ```

Versions:

In [5]: import pydantic, pydantic_core, pydantic_settings

In [6]: pydantic.__version__, pydantic_core.__version__, pydantic_settings.__version__
Out[6]: ('2.5.2', '2.18.4', '2.3.1')
scottstanie commented 1 month ago

Since this is only an issue because argparse uses the %-style formatting, I have a one line fix here. I'm not sure if there are other opinions about more general fixes though.


In [5]: %run pydantic-settings-bug.py --date-fmt1
usage: pydantic-settings.bug.py [-h] [--date_fmt1 str]

options:
  -h, --help       show this help message and exit
  --date_fmt1 str  (default: %Y%m%d)
hramezani commented 1 month ago

Thanks @scottstanie for reporting this.

@kschwab could you please take a look?

kschwab commented 1 month ago

Thanks @scottstanie for the find and proposed fix 👍🏾

I would make one small change to keep it specific to argparse, that way we avoid any potential issues with other external parsers that do not need the replacement:

return _help.replace('%', '%%') if issubclass(type(self._root_parser), ArgumentParser) else _help
hramezani commented 3 weeks ago

the fix has been released in new pydantic-settings 2.3.2