Skip to content

Commit

Permalink
Improve field value parsing by adding NoDecode and ForceDecode an…
Browse files Browse the repository at this point in the history
…notations
  • Loading branch information
hramezani committed Dec 3, 2024
1 parent 0b3e73d commit 5295989
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 0 deletions.
79 changes: 79 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,85 @@ print(Settings().model_dump())
#> {'numbers': [1, 2, 3]}
```

### Disabling JSON parsing

pydatnic-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 by your own, you can annotate the field with `NoDecode`:

```py
import os

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` method 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 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`. This will bypass
the the `enable_decoding` config setting:

```py
import os

from typing_extensions import Annotated

from pydantic_settings import BaseSettings, ForceDecode, SettingsConfigDict


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

numbers: Annotated[list[int], ForceDecode]


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

## 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 @@ -78,6 +78,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 @@ -425,6 +426,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
11 changes: 11 additions & 0 deletions pydantic_settings/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ def import_azure_key_vault() -> None:
ENV_FILE_SENTINEL: DotenvType = Path('')


class NoDecode:
pass


class ForceDecode:
pass


class SettingsError(ValueError):
pass

Expand Down Expand Up @@ -312,6 +320,9 @@ 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']}

0 comments on commit 5295989

Please sign in to comment.