Skip to content

Commit 3667aed

Browse files
Improve field value parsing by adding NoDecode and ForceDecode annotations (#492)
Co-authored-by: hyperlint-ai[bot] <154288675+hyperlint-ai[bot]@users.noreply.github.com>
1 parent 2f498fe commit 3667aed

File tree

5 files changed

+188
-0
lines changed

5 files changed

+188
-0
lines changed

docs/index.md

+94
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,100 @@ print(Settings().model_dump())
371371
#> {'numbers': [1, 2, 3]}
372372
```
373373

374+
### Disabling JSON parsing
375+
376+
pydantic-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 in your own validator, you can annotate the field with
378+
[`NoDecode`](../api/pydantic_settings.md#pydantic_settings.NoDecode):
379+
380+
```py
381+
import os
382+
from typing import List
383+
384+
from pydantic import field_validator
385+
from typing_extensions import Annotated
386+
387+
from pydantic_settings import BaseSettings, NoDecode
388+
389+
390+
class Settings(BaseSettings):
391+
numbers: Annotated[List[int], NoDecode] # (1)!
392+
393+
@field_validator('numbers', mode='before')
394+
@classmethod
395+
def decode_numbers(cls, v: str) -> List[int]:
396+
return [int(x) for x in v.split(',')]
397+
398+
399+
os.environ['numbers'] = '1,2,3'
400+
print(Settings().model_dump())
401+
#> {'numbers': [1, 2, 3]}
402+
```
403+
404+
1. The `NoDecode` annotation disables JSON parsing for the `numbers` field. The `decode_numbers` field validator
405+
will be called to parse the value.
406+
407+
You can also disable JSON parsing for all fields by setting the `enable_decoding` config setting to `False`:
408+
409+
```py
410+
import os
411+
from typing import List
412+
413+
from pydantic import field_validator
414+
415+
from pydantic_settings import BaseSettings, SettingsConfigDict
416+
417+
418+
class Settings(BaseSettings):
419+
model_config = SettingsConfigDict(enable_decoding=False)
420+
421+
numbers: List[int]
422+
423+
@field_validator('numbers', mode='before')
424+
@classmethod
425+
def decode_numbers(cls, v: str) -> List[int]:
426+
return [int(x) for x in v.split(',')]
427+
428+
429+
os.environ['numbers'] = '1,2,3'
430+
print(Settings().model_dump())
431+
#> {'numbers': [1, 2, 3]}
432+
```
433+
434+
You can force JSON parsing for a field by annotating it with [`ForceDecode`](../api/pydantic_settings.md#pydantic_settings.ForceDecode).
435+
This will bypass the `enable_decoding` config setting:
436+
437+
```py
438+
import os
439+
from typing import List
440+
441+
from pydantic import field_validator
442+
from typing_extensions import Annotated
443+
444+
from pydantic_settings import BaseSettings, ForceDecode, SettingsConfigDict
445+
446+
447+
class Settings(BaseSettings):
448+
model_config = SettingsConfigDict(enable_decoding=False)
449+
450+
numbers: Annotated[List[int], ForceDecode]
451+
numbers1: List[int] # (1)!
452+
453+
@field_validator('numbers1', mode='before')
454+
@classmethod
455+
def decode_numbers1(cls, v: str) -> List[int]:
456+
return [int(x) for x in v.split(',')]
457+
458+
459+
os.environ['numbers'] = '["1","2","3"]'
460+
os.environ['numbers1'] = '1,2,3'
461+
print(Settings().model_dump())
462+
#> {'numbers': [1, 2, 3], 'numbers1': [1, 2, 3]}
463+
```
464+
465+
1. The `numbers1` field is not annotated with `ForceDecode`, so it will not be parsed as JSON.
466+
and we have to provide a custom validator to parse the value.
467+
374468
## Nested model default partial updates
375469

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

pydantic_settings/__init__.py

+4
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

+2
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class SettingsConfigDict(ConfigDict, total=False):
7979
"""
8080

8181
toml_file: PathType | None
82+
enable_decoding: bool
8283

8384

8485
# Extend `config_keys` by pydantic settings config keys to
@@ -433,6 +434,7 @@ def _settings_build_values(
433434
toml_file=None,
434435
secrets_dir=None,
435436
protected_namespaces=('model_validate', 'model_dump', 'settings_customise_sources'),
437+
enable_decoding=True,
436438
)
437439

438440

pydantic_settings/sources.py

+18
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ def import_azure_key_vault() -> None:
118118
ENV_FILE_SENTINEL: DotenvType = Path('')
119119

120120

121+
class NoDecode:
122+
"""Annotation to prevent decoding of a field value."""
123+
124+
pass
125+
126+
127+
class ForceDecode:
128+
"""Annotation to force decoding of a field value."""
129+
130+
pass
131+
132+
121133
class SettingsError(ValueError):
122134
pass
123135

@@ -312,6 +324,12 @@ def decode_complex_value(self, field_name: str, field: FieldInfo, value: Any) ->
312324
Returns:
313325
The decoded value for further preparation
314326
"""
327+
if field and (
328+
NoDecode in field.metadata
329+
or (self.config.get('enable_decoding') is False and ForceDecode not in field.metadata)
330+
):
331+
return value
332+
315333
return json.loads(value)
316334

317335
@abstractmethod

tests/test_settings.py

+70
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)