Skip to content

Can't read Pydantic Settings from stdin #296

New issue

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

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

Already on GitHub? # to your account

Closed
WarpedPixel opened this issue May 30, 2024 · 3 comments · Fixed by #318
Closed

Can't read Pydantic Settings from stdin #296

WarpedPixel opened this issue May 30, 2024 · 3 comments · Fixed by #318
Assignees

Comments

@WarpedPixel
Copy link
Contributor

Requirement

I need to read Settings from stdin. This is not our primary scenario but something we need for tooling, for security reasons (secrets we don't want on disk). Our tooling reads settings to process them, and ideally they should be piped to our app to leave no traces behind, even temporarily.

What we tried

Settings pass filenames only into the public API. And using the stdin filename '/dev/stdin' doesn't work (on Mac) as the reads don't block until data is available (it simply returns an empty file).

Because we couldn't find a clean way to do this, I think it is an issue that should be addressed. Perhaps there is a different way to do it that we missed, please let me know.

Possible Fixes

The best fix would be to simply support streams everywhere in the API, instead of just filenames.

It would also be fine to support special filenames '-' or '/dev/stdin' as special cases that are internally replaced by stream=sys.stdin. Or to allow a SettingsSource to provide a stream instead of file names.

In our case we hacked a third solution deriving a class from DotEnvSettingsSource because we need most of that functionality (just need to replace the source in some cases). But because read_env_file is a global function instead of a method, we cannot simply override that and had to copy a few other methods from the implementation. Moving that into the DotEnvSettingsSource class would allow a clean override of just read_env_file that could handle stdin reading. This is probably the minimal solution that cleanly resolves this requirement, without changing anything for the mainstream usage of Pydantic settings.

@hramezani
Copy link
Member

Thanks @WarpedPixel for reporting this.

RIght now, pydanantic-settings doesn't have a settings source that can read values from a stream.

In our case we hacked a third solution deriving a class from DotEnvSettingsSource because we need most of that functionality (just need to replace the source in some cases). But because read_env_file is a global function instead of a method, we cannot simply override that and had to copy a few other methods from the implementation. Moving that into the DotEnvSettingsSource class would allow a clean override of just read_env_file that could handle stdin reading. This is probably the minimal solution that cleanly resolves this requirement, without changing anything for the mainstream usage of Pydantic settings.

Yeah, it is a function and we can't move it to DotEnvSettingsSource because it will introduce a breaking change and we don't want to break people's code. You can override DotEnvSettingsSource._read_env_files, this method uses read_env_file to load the file and updates dotenv_vars dict.

@WarpedPixel
Copy link
Contributor Author

Perhaps there is a better way to do this, but it turned into 75 lines of mostly duplicated code that will certainly be fragile and difficult to maintain. The below is hacky because a class could be written to handle both filenames and streams (or named streams at least) without changing the signatures of anything, but it passes my tests.

Code to override read_env_file

# this runs against the current version of pydantic_settings 

import os
import sys
from pathlib import Path
from typing import Mapping

from dotenv import dotenv_values
from pydantic_settings import BaseSettings, DotEnvSettingsSource
from pydantic_settings.sources import ENV_FILE_SENTINEL, DotenvType, parse_env_vars
from pydantic_settings.sources import read_env_file as original_read_env_file


class StdinSettingsSource(DotEnvSettingsSource):
    def __init__(
        self,
        settings_cls: type[BaseSettings],
        env_file: DotenvType | None = ENV_FILE_SENTINEL,
        env_file_encoding: str | None = None,
        case_sensitive: bool | None = None,
        env_prefix: str | None = None,
        env_nested_delimiter: str | None = None,
        env_ignore_empty: bool | None = None,
        env_parse_none_str: str | None = None,
    ) -> None:
        super().__init__(
            settings_cls, env_file, env_file_encoding, case_sensitive, env_prefix, env_nested_delimiter, env_ignore_empty, env_parse_none_str
        )

    @staticmethod
    def read_env_file(
        file_path: Path,
        *,
        encoding: str | None = None,
        case_sensitive: bool = False,
        ignore_empty: bool = False,
        parse_none_str: str | None = None,
    ) -> Mapping[str, str | None]:
        file_vars: dict[str, str | None] = dotenv_values(stream=sys.stdin, encoding=encoding or 'utf8')
        return parse_env_vars(file_vars, case_sensitive, ignore_empty, parse_none_str)

    # copy from pydantic_settings.sources.DotEnvSettingsSource. NEEDED because we can't override read_env_file
    def _read_env_files(self) -> Mapping[str, str | None]:
        env_files = ['/dev/stdin']
        # env_files = self.env_file
        # if env_files is None:
        #     return {}

        if isinstance(env_files, (str, os.PathLike)):
            env_files = [env_files]

        dotenv_vars: dict[str, str | None] = {}
        for env_file in env_files:
            env_path = Path(env_file).expanduser()
            if env_path.is_file():
                dotenv_vars.update(
                    original_read_env_file(
                        env_path,
                        encoding=self.env_file_encoding,
                        case_sensitive=self.case_sensitive,
                        ignore_empty=self.env_ignore_empty,
                        parse_none_str=self.env_parse_none_str,
                    )
                )
            elif env_path == Path('/dev/stdin'):
                dotenv_vars.update(
                    self.read_env_file(
                        env_path,
                        encoding=self.env_file_encoding,
                        case_sensitive=self.case_sensitive,
                        ignore_empty=self.env_ignore_empty,
                        parse_none_str=self.env_parse_none_str,
                    )
                )

        return dotenv_vars

It seems like moving read_env_file to a method shouldn't break anything. It is not referenced anywhere else in pydantic_settings. And if we really want to handle the case where people import that function you could continue to expose it with a deprecation warning. Something like

import warnings

def read_env_file(
    file_path: Path,
    *,
    encoding: str | None = None,
    case_sensitive: bool = False,
    ignore_empty: bool = False,
    parse_none_str: str | None = None,
) -> Mapping[str, str | None]:
    warnings.warn("read_env_file is deprecated and will be removed in the next version, use DotEnvSettingsSource.read_env_file instead", DeprecationWarning)
    file_vars: dict[str, str | None] = dotenv_values(file_path, encoding=encoding or 'utf8')
    return parse_env_vars(file_vars, case_sensitive, ignore_empty, parse_none_str)

This would allow the subclassing of DotEnvSettingsSource to become something much simpler:

# this version would work if we made the change to bring read_env_file as a method of DotEnvSettingsSource
class SimpleStdinSettingsSource(DotEnvSettingsSource):
    # override read_env_file to also read from stdin
    def read_env_file(
        self,
        file_path: Path,
    ) -> Mapping[str, str | None]:
        if file_path == Path('/dev/stdin'):
            file_vars: dict[str, str | None] = dotenv_values(stream=sys.stdin, encoding=self.env_file_encoding or 'utf8')
        else:
            file_vars: dict[str, str | None] = dotenv_values(file_path, encoding=self.env_file_encoding or 'utf8')
        return parse_env_vars(file_vars, self.case_sensitive, self.env_ignore_empty, self.env_parse_none_str)

If I can be of help I would work on a clean PR for this.

@hramezani
Copy link
Member

@WarpedPixel let's move it into the DotEnvSettingsSource and add a deprecation warning

WarpedPixel pushed a commit to WarpedPixel/pydantic-settings that referenced this issue Jun 20, 2024
Fixes pydantic#296
Adds a method _read_env_file to DotEnvSettingsSource that can be easily overridden.
This fix keeps a global function so it can continue to be tested, but adds an underscore to the name.
The plain read_env_file is marked as deprecated.
WarpedPixel pushed a commit to WarpedPixel/pydantic-settings that referenced this issue Jun 25, 2024
Fixes pydantic#296
Added test and moved bulk of dotenv code into DotEnvSettingsSource so future deprecation will be cleaner
WarpedPixel pushed a commit to WarpedPixel/pydantic-settings that referenced this issue Jun 25, 2024
Fixes pydantic#296
Adds a method _read_env_file to DotEnvSettingsSource that can be easily overridden.
This fix keeps a global function so it can continue to be tested, but adds an underscore to the name.
The plain read_env_file is marked as deprecated.
WarpedPixel pushed a commit to WarpedPixel/pydantic-settings that referenced this issue Jun 25, 2024
Fixes pydantic#296
Added test and moved bulk of dotenv code into DotEnvSettingsSource so future deprecation will be cleaner
WarpedPixel pushed a commit to WarpedPixel/pydantic-settings that referenced this issue Jul 1, 2024
WarpedPixel pushed a commit to WarpedPixel/pydantic-settings that referenced this issue Jul 1, 2024
Fixes pydantic#296
Adds a method _read_env_file to DotEnvSettingsSource that can be easily overridden.
This fix keeps a global function so it can continue to be tested, but adds an underscore to the name.
The plain read_env_file is marked as deprecated.
WarpedPixel pushed a commit to WarpedPixel/pydantic-settings that referenced this issue Jul 1, 2024
Fixes pydantic#296
Added test and moved bulk of dotenv code into DotEnvSettingsSource so future deprecation will be cleaner
WarpedPixel pushed a commit to WarpedPixel/pydantic-settings that referenced this issue Jul 1, 2024
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants