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

Improve field value parsing by adding NoDecode and ForceDecode annotations #492

Merged
merged 4 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
94 changes: 94 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,100 @@ print(Settings().model_dump())
#> {'numbers': [1, 2, 3]}
```

### Disabling JSON parsing

pydantic-settings by default parses complex types from environment variables as JSON strings. If you want to disable
this behavior for a field and parse the value in your own validator, you can annotate the field with
[`NoDecode`](../api/pydantic_settings.md#pydantic_settings.NoDecode):

```py
import os
from typing import List

from pydantic import field_validator
from typing_extensions import Annotated

from pydantic_settings import BaseSettings, NoDecode


class Settings(BaseSettings):
numbers: Annotated[List[int], NoDecode] # (1)!

@field_validator('numbers', mode='before')
@classmethod
def decode_numbers(cls, v: str) -> List[int]:
return [int(x) for x in v.split(',')]


os.environ['numbers'] = '1,2,3'
print(Settings().model_dump())
#> {'numbers': [1, 2, 3]}
```

1. The `NoDecode` annotation disables JSON parsing for the `numbers` field. The `decode_numbers` field validator
will be called to parse the value.

You can also disable JSON parsing for all fields by setting the `enable_decoding` config setting to `False`:

```py
import os
from typing import List

from pydantic import field_validator

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
model_config = SettingsConfigDict(enable_decoding=False)

numbers: List[int]

@field_validator('numbers', mode='before')
@classmethod
def decode_numbers(cls, v: str) -> List[int]:
return [int(x) for x in v.split(',')]


os.environ['numbers'] = '1,2,3'
print(Settings().model_dump())
#> {'numbers': [1, 2, 3]}
```

You can force JSON parsing for a field by annotating it with [`ForceDecode`](../api/pydantic_settings.md#pydantic_settings.ForceDecode).
This will bypass the `enable_decoding` config setting:

```py
import os
from typing import List

from pydantic import field_validator
from typing_extensions import Annotated

from pydantic_settings import BaseSettings, ForceDecode, SettingsConfigDict


class Settings(BaseSettings):
model_config = SettingsConfigDict(enable_decoding=False)

hramezani marked this conversation as resolved.
Show resolved Hide resolved
numbers: Annotated[List[int], ForceDecode]
numbers1: List[int] # (1)!

@field_validator('numbers1', mode='before')
@classmethod
def decode_numbers1(cls, v: str) -> List[int]:
return [int(x) for x in v.split(',')]


os.environ['numbers'] = '["1","2","3"]'
os.environ['numbers1'] = '1,2,3'
print(Settings().model_dump())
#> {'numbers': [1, 2, 3], 'numbers1': [1, 2, 3]}
```

1. The `numbers1` field is not annotated with `ForceDecode`, so it will not be parsed as JSON.
and we have to provide a custom validator to parse the value.

## Nested model default partial updates

By default, Pydantic settings does not allow partial updates to nested model default objects. This behavior can be
Expand Down
4 changes: 4 additions & 0 deletions pydantic_settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
CliSuppress,
DotEnvSettingsSource,
EnvSettingsSource,
ForceDecode,
InitSettingsSource,
JsonConfigSettingsSource,
NoDecode,
PydanticBaseSettingsSource,
PyprojectTomlConfigSettingsSource,
SecretsSettingsSource,
Expand All @@ -38,6 +40,8 @@
'CliMutuallyExclusiveGroup',
'InitSettingsSource',
'JsonConfigSettingsSource',
'NoDecode',
'ForceDecode',
'PyprojectTomlConfigSettingsSource',
'PydanticBaseSettingsSource',
'SecretsSettingsSource',
Expand Down
2 changes: 2 additions & 0 deletions pydantic_settings/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class SettingsConfigDict(ConfigDict, total=False):
"""

toml_file: PathType | None
enable_decoding: bool


# Extend `config_keys` by pydantic settings config keys to
Expand Down Expand Up @@ -433,6 +434,7 @@ def _settings_build_values(
toml_file=None,
secrets_dir=None,
protected_namespaces=('model_validate', 'model_dump', 'settings_customise_sources'),
enable_decoding=True,
)


Expand Down
18 changes: 18 additions & 0 deletions pydantic_settings/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ def import_azure_key_vault() -> None:
ENV_FILE_SENTINEL: DotenvType = Path('')


class NoDecode:
hramezani marked this conversation as resolved.
Show resolved Hide resolved
"""Annotation to prevent decoding of a field value."""

pass


class ForceDecode:
hramezani marked this conversation as resolved.
Show resolved Hide resolved
"""Annotation to force decoding of a field value."""

pass


class SettingsError(ValueError):
pass

Expand Down Expand Up @@ -312,6 +324,12 @@ def decode_complex_value(self, field_name: str, field: FieldInfo, value: Any) ->
Returns:
The decoded value for further preparation
"""
if field and (
NoDecode in field.metadata
or (self.config.get('enable_decoding') is False and ForceDecode not in field.metadata)
):
return value

return json.loads(value)

@abstractmethod
Expand Down
70 changes: 70 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import dataclasses
import json
import os
import pathlib
import sys
Expand Down Expand Up @@ -26,6 +27,7 @@
SecretStr,
Tag,
ValidationError,
field_validator,
)
from pydantic import (
dataclasses as pydantic_dataclasses,
Expand All @@ -37,7 +39,9 @@
BaseSettings,
DotEnvSettingsSource,
EnvSettingsSource,
ForceDecode,
InitSettingsSource,
NoDecode,
PydanticBaseSettingsSource,
SecretsSettingsSource,
SettingsConfigDict,
Expand Down Expand Up @@ -2873,3 +2877,69 @@ class Settings(BaseSettings):
s = Settings()
assert s.foo.get_secret_value() == 123
assert s.bar.get_secret_value() == PostgresDsn('postgres://user:password@localhost/dbname')


def test_field_annotated_no_decode(env):
class Settings(BaseSettings):
a: List[str] # this field will be decoded because of default `enable_decoding=True`
b: Annotated[List[str], NoDecode]

# decode the value here. the field value won't be decoded because of NoDecode
@field_validator('b', mode='before')
@classmethod
def decode_b(cls, v: str) -> List[str]:
return json.loads(v)

env.set('a', '["one", "two"]')
env.set('b', '["1", "2"]')

s = Settings()
assert s.model_dump() == {'a': ['one', 'two'], 'b': ['1', '2']}


def test_field_annotated_no_decode_and_disable_decoding(env):
class Settings(BaseSettings):
model_config = SettingsConfigDict(enable_decoding=False)

a: Annotated[List[str], NoDecode]

# decode the value here. the field value won't be decoded because of NoDecode
@field_validator('a', mode='before')
@classmethod
def decode_b(cls, v: str) -> List[str]:
return json.loads(v)

env.set('a', '["one", "two"]')

s = Settings()
assert s.model_dump() == {'a': ['one', 'two']}


def test_field_annotated_disable_decoding(env):
class Settings(BaseSettings):
model_config = SettingsConfigDict(enable_decoding=False)

a: List[str]

# decode the value here. the field value won't be decoded because of `enable_decoding=False`
@field_validator('a', mode='before')
@classmethod
def decode_b(cls, v: str) -> List[str]:
return json.loads(v)

env.set('a', '["one", "two"]')

s = Settings()
assert s.model_dump() == {'a': ['one', 'two']}


def test_field_annotated_force_decode_disable_decoding(env):
class Settings(BaseSettings):
model_config = SettingsConfigDict(enable_decoding=False)

a: Annotated[List[str], ForceDecode]

env.set('a', '["one", "two"]')

s = Settings()
assert s.model_dump() == {'a': ['one', 'two']}
Loading