Skip to content

Commit ad64130

Browse files
committed
Improve field value parsing by adding NoDecode and ForceDecode annotations
1 parent 0b3e73d commit ad64130

File tree

5 files changed

+169
-0
lines changed

5 files changed

+169
-0
lines changed

docs/index.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,85 @@ print(Settings().model_dump())
371371
#> {'numbers': [1, 2, 3]}
372372
```
373373

374+
### Disabling JSON parsing
375+
376+
pydatnic-settings by default parses complex types from environment variables as JSON strings. If you want to disable
377+
this behavior for a field and parse the value by your own, you can annotate the field with `NoDecode`:
378+
379+
```py
380+
import os
381+
382+
from pydantic import field_validator
383+
from typing_extensions import Annotated
384+
385+
from pydantic_settings import BaseSettings, NoDecode
386+
387+
388+
class Settings(BaseSettings):
389+
numbers: Annotated[list[int], NoDecode] # (1)!
390+
391+
@field_validator('numbers', mode='before')
392+
@classmethod
393+
def decode_numbers(cls, v: str) -> list[int]:
394+
return [int(x) for x in v.split(',')]
395+
396+
397+
os.environ['numbers'] = '1,2,3'
398+
print(Settings().model_dump())
399+
#> {'numbers': [1, 2, 3]}
400+
```
401+
402+
1. The `NoDecode` annotation disables JSON parsing for the `numbers` field. The `decode_numbers` field validator
403+
will be called to parse the value.
404+
405+
You can also disable JSON parsing for all fields by setting the `enable_decoding` config setting to `False`:
406+
407+
```py
408+
import os
409+
410+
from pydantic import field_validator
411+
412+
from pydantic_settings import BaseSettings, SettingsConfigDict
413+
414+
415+
class Settings(BaseSettings):
416+
model_config = SettingsConfigDict(enable_decoding=False)
417+
418+
numbers: list[int]
419+
420+
@field_validator('numbers', mode='before')
421+
@classmethod
422+
def decode_numbers(cls, v: str) -> list[int]:
423+
return [int(x) for x in v.split(',')]
424+
425+
426+
os.environ['numbers'] = '1,2,3'
427+
print(Settings().model_dump())
428+
#> {'numbers': [1, 2, 3]}
429+
```
430+
431+
You can force JSON parsing for a field by annotating it with `ForceDecode`. This will bypass
432+
the the `enable_decoding` config setting:
433+
434+
```py
435+
import os
436+
437+
from typing_extensions import Annotated
438+
439+
from pydantic_settings import BaseSettings, ForceDecode, SettingsConfigDict
440+
441+
442+
class Settings(BaseSettings):
443+
model_config = SettingsConfigDict(enable_decoding=False)
444+
445+
numbers: Annotated[list[int], ForceDecode]
446+
447+
448+
os.environ['numbers'] = '["1","2","3"]'
449+
print(Settings().model_dump())
450+
#> {'numbers': [1, 2, 3]}
451+
```
452+
374453
## Nested model default partial updates
375454

376455
By default, Pydantic settings does not allow partial updates to nested model default objects. This behavior can be

pydantic_settings/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
CliSuppress,
1212
DotEnvSettingsSource,
1313
EnvSettingsSource,
14+
ForceDecode,
1415
InitSettingsSource,
1516
JsonConfigSettingsSource,
17+
NoDecode,
1618
PydanticBaseSettingsSource,
1719
PyprojectTomlConfigSettingsSource,
1820
SecretsSettingsSource,
@@ -38,6 +40,8 @@
3840
'CliMutuallyExclusiveGroup',
3941
'InitSettingsSource',
4042
'JsonConfigSettingsSource',
43+
'NoDecode',
44+
'ForceDecode',
4145
'PyprojectTomlConfigSettingsSource',
4246
'PydanticBaseSettingsSource',
4347
'SecretsSettingsSource',

pydantic_settings/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ class SettingsConfigDict(ConfigDict, total=False):
7878
"""
7979

8080
toml_file: PathType | None
81+
enable_decoding: bool
8182

8283

8384
# Extend `config_keys` by pydantic settings config keys to
@@ -425,6 +426,7 @@ def _settings_build_values(
425426
toml_file=None,
426427
secrets_dir=None,
427428
protected_namespaces=('model_validate', 'model_dump', 'settings_customise_sources'),
429+
enable_decoding=True,
428430
)
429431

430432

pydantic_settings/sources.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ def import_azure_key_vault() -> None:
118118
ENV_FILE_SENTINEL: DotenvType = Path('')
119119

120120

121+
class NoDecode:
122+
pass
123+
124+
125+
class ForceDecode:
126+
pass
127+
128+
121129
class SettingsError(ValueError):
122130
pass
123131

@@ -312,6 +320,12 @@ def decode_complex_value(self, field_name: str, field: FieldInfo, value: Any) ->
312320
Returns:
313321
The decoded value for further preparation
314322
"""
323+
if field and (
324+
NoDecode in field.metadata
325+
or (self.config.get('enable_decoding') is False and ForceDecode not in field.metadata)
326+
):
327+
return value
328+
315329
return json.loads(value)
316330

317331
@abstractmethod

tests/test_settings.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import dataclasses
2+
import json
23
import os
34
import pathlib
45
import sys
@@ -26,6 +27,7 @@
2627
SecretStr,
2728
Tag,
2829
ValidationError,
30+
field_validator,
2931
)
3032
from pydantic import (
3133
dataclasses as pydantic_dataclasses,
@@ -37,7 +39,9 @@
3739
BaseSettings,
3840
DotEnvSettingsSource,
3941
EnvSettingsSource,
42+
ForceDecode,
4043
InitSettingsSource,
44+
NoDecode,
4145
PydanticBaseSettingsSource,
4246
SecretsSettingsSource,
4347
SettingsConfigDict,
@@ -2873,3 +2877,69 @@ class Settings(BaseSettings):
28732877
s = Settings()
28742878
assert s.foo.get_secret_value() == 123
28752879
assert s.bar.get_secret_value() == PostgresDsn('postgres://user:password@localhost/dbname')
2880+
2881+
2882+
def test_field_annotated_no_decode(env):
2883+
class Settings(BaseSettings):
2884+
a: List[str] # this field will be decoded because of default `enable_decoding=True`
2885+
b: Annotated[List[str], NoDecode]
2886+
2887+
# decode the value here. the field value won't be decoded because of NoDecode
2888+
@field_validator('b', mode='before')
2889+
@classmethod
2890+
def decode_b(cls, v: str) -> List[str]:
2891+
return json.loads(v)
2892+
2893+
env.set('a', '["one", "two"]')
2894+
env.set('b', '["1", "2"]')
2895+
2896+
s = Settings()
2897+
assert s.model_dump() == {'a': ['one', 'two'], 'b': ['1', '2']}
2898+
2899+
2900+
def test_field_annotated_no_decode_and_disable_decoding(env):
2901+
class Settings(BaseSettings):
2902+
model_config = SettingsConfigDict(enable_decoding=False)
2903+
2904+
a: Annotated[List[str], NoDecode]
2905+
2906+
# decode the value here. the field value won't be decoded because of NoDecode
2907+
@field_validator('a', mode='before')
2908+
@classmethod
2909+
def decode_b(cls, v: str) -> list[str]:
2910+
return json.loads(v)
2911+
2912+
env.set('a', '["one", "two"]')
2913+
2914+
s = Settings()
2915+
assert s.model_dump() == {'a': ['one', 'two']}
2916+
2917+
2918+
def test_field_annotated_disable_decoding(env):
2919+
class Settings(BaseSettings):
2920+
model_config = SettingsConfigDict(enable_decoding=False)
2921+
2922+
a: List[str]
2923+
2924+
# decode the value here. the field value won't be decoded because of `enable_decoding=False`
2925+
@field_validator('a', mode='before')
2926+
@classmethod
2927+
def decode_b(cls, v: str) -> List[str]:
2928+
return json.loads(v)
2929+
2930+
env.set('a', '["one", "two"]')
2931+
2932+
s = Settings()
2933+
assert s.model_dump() == {'a': ['one', 'two']}
2934+
2935+
2936+
def test_field_annotated_force_decode_disable_decoding(env):
2937+
class Settings(BaseSettings):
2938+
model_config = SettingsConfigDict(enable_decoding=False)
2939+
2940+
a: Annotated[List[str], ForceDecode]
2941+
2942+
env.set('a', '["one", "two"]')
2943+
2944+
s = Settings()
2945+
assert s.model_dump() == {'a': ['one', 'two']}

0 commit comments

Comments
 (0)