Skip to content

cli_parse_args issue #391

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Sanchoyzer opened this issue Sep 10, 2024 · 7 comments
Closed

cli_parse_args issue #391

Sanchoyzer opened this issue Sep 10, 2024 · 7 comments

Comments

@Sanchoyzer
Copy link

Libs

pydantic==2.9.1
pydantic-settings==2.5.0
pydantic_core==2.23.3
pytest==8.3.3

Code

project
|-- settings.py
|-- tests
    | -- __init__.py
    | -- conftest.py

project/settings.py

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    MY_VAR: str = ""


conf = Settings()

project/tests/conftest.py

from settings import conf

Run pytest

$ pytest --version
pytest 8.3.3

Let's add cli_parse_args

class Settings(BaseSettings):
    model_config = SettingsConfigDict(cli_parse_args=True)
    MY_VAR: str = ""

Run pytest again

$ pytest --version
usage: pytest [-h] [--my_var str]
pytest: error: unrecognized arguments: --version

Summary
It seems that using cli_parse_args overrides all other options, and it doesn't look good

@hramezani
Copy link
Member

@kschwab Could you please take a look?

@kschwab
Copy link
Contributor

kschwab commented Sep 10, 2024

Hi @Sanchoyzer, the above is expected. When cli_parse_args=True, it means the Settings class will parse the CLI whenever it is instantiated. In this case, since conf = Settings() is not protected by __name__ == '__main__' etc., the CLI will be parsed when the module is imported, i.e. from settings import conf.

What is likely misleading is the output, specifically the "pytest" references:

$ pytest --version
usage: pytest [-h] [--my_var str]
pytest: error: unrecognized arguments: --version

In reality, both parsers will run and parse the same CLI args, in this case sys.argv = ['pytest', '--version']. Since Settings class did not set the cli_prog_name config, it uses the default, sys.argv[0], which is "pytest". If we pretend cli_prog_name='Settings', the output would be:

$ pytest --version
usage: Settings [-h] [--my_var str]
Settings: error: unrecognized arguments: --version

Which of course, when Settings parses the CLI it does not have a version field and complains. Hopefully that sorts out the message.

More broadly speaking, the issue you're hitting is running multiple parsers in a single app. Parser A will fail because it doesn't have Parser B args, and Parser B will fail because it doesn't have Parser A args. To share the same CLI args, you'll need to merge the two parsers together. This is covered in the docs under Integrating with Existing Parsers. For your case, I believe the below should work:

import pytest
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(cli_parse_args=True)
    MY_VAR: str = ""

cli_settings = CliSettingsSource(
    Settings,
    root_parser=pytest.Parser(_ispytest=True),
    parse_args_method=pytest.Parser.parse,
    add_argument_method=pytest.Parser.addoption,
    add_argument_group_method=pytest.Parser.getgroup,
    add_parser_method=None,
    add_subparsers_method=None,
    formatter_class=None,
)

conf = Settings(_cli_settings_source=cli_settings(args=True))

You should then see --MY_VAR in pytest --help. I haven't been able to run or verify locally any of the above, but let me know how it works out 👍

@Sanchoyzer
Copy link
Author

Sanchoyzer commented Sep 10, 2024

Hey, @kschwab. Thanks for your answer, it looks really cool. By the way, it might be useful additional information for the documentation

I've tried the solution, and it works for pytest --version / pytest tests, but not for pytest -v tests / pytest --durations=3 tests. Example:

tests/conftest.py:1: in <module>
    from settings import conf
settings.py:22: in <module>
    conf = Settings(_cli_settings_source=cli_settings(args=True))
../.venv/lib/python3.12/site-packages/pydantic_settings/sources.py:1202: in __call__
    return self._load_env_vars(parsed_args=self._parse_args(self.root_parser, args))
E   _pytest.config.exceptions.UsageError: usage: pytest [--MY_VAR str] [file_or_dir ...]
E   pytest: error: unrecognized arguments: -v

It seems that the pytest parser replaced its own arguments instead of adding new (or something like that)

@kschwab
Copy link
Contributor

kschwab commented Sep 11, 2024

Hey @Sanchoyzer, I think you have to use the hooks with pytest to get it to work properly. Try the following in your conftest.py:

import pytest
from settings import Settings
from pydantic_settings import CliSettingsSource

_cli_settings: CliSettingsSource
def pytest_addoption(parser):
    global _cli_settings
    _cli_settings = CliSettingsSource(
        Settings,
        root_parser=parser,
        parse_args_method=pytest.Parser.parse,
        add_argument_method=pytest.Parser.addoption,
        add_argument_group_method=pytest.Parser.getgroup,
        add_parser_method=None,
        add_subparsers_method=None,
        formatter_class=None,
    )

conf: Settings
def pytest_configure(config):
    global conf
    conf = Settings(_cli_settings_source=_cli_settings(args=config.args))

And then in your settings.py:

from pydantic import Field
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    MY_STR: str = Field("it's me", description='This is my str var')
    MY_INT: int = Field(23, description='This is my int var')

Locally, I get:

$ pytest --help | grep MY_
  --MY_STR=str          This is my str var (default: it's me)
  --MY_INT=int          This is my int var (default: 23)

and pytest -v tests etc. appear to work as expected. I agree on updating the docs with perhaps a pytest specific example. Outside of argparse, it is likely next most popular use case for external integration. However, I am not an expert in pytest so would look towards community here for providing solution that aligns with pytest "best practices" 😄

@Sanchoyzer
Copy link
Author

Hey, @kschwab . Thanks for your message. Your code works as expected, but I'm afraid that it isn't so optimal to create the conf var in conftest.py

The thing is that we usually have the settings file, like my_project/settings.py:

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(cli_parse_args=True)
    MY_VAR: str = ""

conf = Settings()

and we use it in many files for business logic, like my_project/views/users.py:

from settings import conf

def handler(request):
    if request.params.get('my_var') != conf.MY_VAR:
        ...

or in tests, like my_project/tests/tests_users.py:

from settings import conf

@pytest.fixture(scope='session', autouse=True)
def settings():
    conf.MY_VAR = 'qwerty'

that's why the conf var need to be created in one place (settings.py, not conftest.py) and will be imported in others

@kschwab
Copy link
Contributor

kschwab commented Sep 11, 2024

I see. This is really more of an app specific issue. i.e., in the business logic case, Settings should parse the CLI by itself. In the pytest case, Settings should integrate with the pytest parser. I think you can still accomplish both, but conf = Settings() in settings.py has to distinguish between the two cases. e.g., something like the below would work:

In settings.py:

import sys
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(cli_parse_args=True)
    MY_VAR: str = ""

conf = Settings() if 'pytest' not in sys.argv[0] else None

In conftest.py:

import pytest
import settings
from pydantic_settings import CliSettingsSource

_cli_settings: CliSettingsSource
def pytest_addoption(parser):
    global _cli_settings
    _cli_settings = CliSettingsSource(
        settings.Settings,
        root_parser=parser,
        parse_args_method=pytest.Parser.parse,
        add_argument_method=pytest.Parser.addoption,
        add_argument_group_method=pytest.Parser.getgroup,
        add_parser_method=None,
        add_subparsers_method=None,
        formatter_class=None,
    )

def pytest_configure(config):
    settings.conf = settings.Settings(_cli_settings_source=_cli_settings(args=config.args))

Of course, if the intent is to not to integrate with the pytest CLI (i.e. Settings should not parse the CLI when running pytest) you could just make the CLI parsing dependent on whether pytest is running or not in settings.py:

import sys
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(cli_parse_args='pytest' not in sys.argv[0])
    MY_VAR: str = ""

conf = Settings()

@Sanchoyzer
Copy link
Author

Hey, @kschwab , thanks a lot, this awesome solution is what I need:

SettingsConfigDict(cli_parse_args='pytest' not in sys.argv[0])

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants