Skip to content
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

Fix #1458 - Allow for custom parsing of environment variables via parse_env_var in Config object #4406

Merged
merged 13 commits into from Aug 22, 2022
1 change: 1 addition & 0 deletions changes/4406-acmiyaguchi.md
@@ -0,0 +1 @@
Allow for custom parsing of environment variables via `parse_env_var` in `Config`.
24 changes: 24 additions & 0 deletions docs/examples/settings_with_custom_parsing.py
@@ -0,0 +1,24 @@
# output-json
samuelcolvin marked this conversation as resolved.
Show resolved Hide resolved
import os
from typing import Any, List

from pydantic import BaseSettings


def parse_list(s: str) -> List[int]:
return [int(x.strip()) for x in s.split(',')]


class Settings(BaseSettings):
numbers: List[int]

class Config:
@classmethod
def parse_env_var(cls, field_name: str, raw_val: str) -> Any:
if field_name == 'numbers':
return parse_list(raw_val)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return parse_list(raw_val)
return [int(x) for x in s.split(',')]

then remove parse_list above, I think this is just as easy to read and more compact.

I don't think you need the strip since int(' 123 ') == 123

return cls.json_loads(raw_val)


os.environ['numbers'] = '1,2,3'
print(Settings().dict())
11 changes: 8 additions & 3 deletions docs/usage/settings.md
Expand Up @@ -87,7 +87,7 @@ export SUB_MODEL__DEEP__V4=v4
You could load a settings module thus:
{!.tmp_examples/settings_nested_env.md!}

`env_nested_delimiter` can be configured via the `Config` class as shown above, or via the
`env_nested_delimiter` can be configured via the `Config` class as shown above, or via the
`_env_nested_delimiter` keyword argument on instantiation.

JSON is only parsed in top-level fields, if you need to parse JSON in sub-models, you will need to implement
Expand All @@ -96,6 +96,11 @@ validators on those models.
Nested environment variables take precedence over the top-level environment variable JSON
(e.g. in the example above, `SUB_MODEL__V2` trumps `SUB_MODEL`).

You may also populate a complex type by providing your own parsing function to
the `parse_env_var` classmethod in the Config object.

{!.tmp_examples/settings_with_custom_parsing.md!}

## Dotenv (.env) support

!!! note
Expand Down Expand Up @@ -178,7 +183,7 @@ see [python-dotenv's documentation](https://saurabh-kumar.com/python-dotenv/#usa

Placing secret values in files is a common pattern to provide sensitive configuration to an application.

A secret file follows the same principal as a dotenv file except it only contains a single value and the file name
A secret file follows the same principal as a dotenv file except it only contains a single value and the file name
is used as the key. A secret file will look like the following:

`/var/run/database_password`:
Expand Down Expand Up @@ -231,7 +236,7 @@ class Settings(BaseSettings):
secrets_dir = '/run/secrets'
```
!!! note
By default Docker uses `/run/secrets` as the target mount point. If you want to use a different location, change
By default Docker uses `/run/secrets` as the target mount point. If you want to use a different location, change
`Config.secrets_dir` accordingly.

Then, create your secret via the Docker CLI
Expand Down
12 changes: 8 additions & 4 deletions pydantic/env_settings.py
Expand Up @@ -126,6 +126,10 @@ def customise_sources(
) -> Tuple[SettingsSourceCallable, ...]:
return init_settings, env_settings, file_secret_settings

@classmethod
def parse_env_var(cls, field_name: str, raw_val: str) -> Any:
return cls.json_loads(raw_val)

# populated by the metaclass using the Config class defined above, annotated here to help IDEs only
__config__: ClassVar[Type[Config]]

Expand Down Expand Up @@ -190,10 +194,10 @@ def __call__(self, settings: BaseSettings) -> Dict[str, Any]: # noqa C901
else:
# field is complex and there's a value, decode that as JSON, then add explode_env_vars
try:
env_val = settings.__config__.json_loads(env_val)
env_val = settings.__config__.parse_env_var(field.name, env_val)
samuelcolvin marked this conversation as resolved.
Show resolved Hide resolved
except ValueError as e:
if not allow_json_failure:
raise SettingsError(f'error parsing JSON for "{env_name}"') from e
raise SettingsError(f'error parsing envvar "{env_name}"') from e
samuelcolvin marked this conversation as resolved.
Show resolved Hide resolved

if isinstance(env_val, dict):
d[field.alias] = deep_update(env_val, self.explode_env_vars(field, env_vars))
Expand Down Expand Up @@ -299,9 +303,9 @@ def __call__(self, settings: BaseSettings) -> Dict[str, Any]:
secret_value = path.read_text().strip()
if field.is_complex():
try:
secret_value = settings.__config__.json_loads(secret_value)
secret_value = settings.__config__.parse_env_var(field.name, secret_value)
except ValueError as e:
raise SettingsError(f'error parsing JSON for "{env_name}"') from e
raise SettingsError(f'error parsing envvar "{env_name}"') from e
samuelcolvin marked this conversation as resolved.
Show resolved Hide resolved

secrets[field.alias] = secret_value
else:
Expand Down
69 changes: 66 additions & 3 deletions tests/test_settings.py
Expand Up @@ -3,7 +3,7 @@
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple, Union
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union

import pytest

Expand Down Expand Up @@ -221,7 +221,7 @@ def test_set_dict_model(env):

def test_invalid_json(env):
env.set('apples', '["russet", "granny smith",]')
with pytest.raises(SettingsError, match='error parsing JSON for "apples"'):
with pytest.raises(SettingsError, match='error parsing envvar "apples"'):
ComplexSettings()


Expand Down Expand Up @@ -1054,7 +1054,7 @@ class Settings(BaseSettings):
class Config:
secrets_dir = tmp_path

with pytest.raises(SettingsError, match='error parsing JSON for "foo"'):
with pytest.raises(SettingsError, match='error parsing envvar "foo"'):
Settings()


Expand Down Expand Up @@ -1215,3 +1215,66 @@ def test_builtins_settings_source_repr():
== "EnvSettingsSource(env_file='.env', env_file_encoding='utf-8', env_nested_delimiter=None)"
)
assert repr(SecretsSettingsSource(secrets_dir='/secrets')) == "SecretsSettingsSource(secrets_dir='/secrets')"


def _parse_custom_dict(value: str) -> Callable[[str], Dict[int, str]]:
"""A custom parsing function passed into env parsing test."""
res = {}
for part in value.split(','):
k, v = part.split('=')
res[int(k)] = v
return res


def test_env_setting_source_custom_env_parse(env):
class Settings(BaseSettings):
top: Dict[int, str]

class Config:
@classmethod
def parse_env_var(cls, field_name: str, raw_val: str):
if field_name == 'top':
return _parse_custom_dict(raw_val)
return cls.json_loads(raw_val)

with pytest.raises(ValidationError):
Settings()
env.set('top', '1=apple,2=banana')
s = Settings()
assert s.top == {1: 'apple', 2: 'banana'}


def test_env_settings_source_custom_env_parse_is_bad(env):
class Settings(BaseSettings):
top: Dict[int, str]

class Config:
@classmethod
def parse_env_var(cls, field_name: str, raw_val: str):
if field_name == 'top':
return int(raw_val)
return cls.json_loads(raw_val)

env.set('top', '1=apple,2=banana')
with pytest.raises(SettingsError, match='error parsing envvar "top"'):
Settings()


def test_secret_settings_source_custom_env_parse(tmp_path):
p = tmp_path / 'top'
p.write_text('1=apple,2=banana')

class Settings(BaseSettings):
top: Dict[int, str] = Field(env_parse=_parse_custom_dict)
samuelcolvin marked this conversation as resolved.
Show resolved Hide resolved

class Config:
secrets_dir = tmp_path

@classmethod
def parse_env_var(cls, field_name: str, raw_val: str):
if field_name == 'top':
return _parse_custom_dict(raw_val)
return cls.json_loads(raw_val)

s = Settings()
assert s.top == {1: 'apple', 2: 'banana'}