Files
pydantic/tests/test_settings.py
T
Mark Trifonov be246701c5 Nested env (#3159)
* Environment names for complex types #2304

* nested env disabled by default

* cleanup

* nested env settings: simplified and mypy fixes

* nested env settings: config, test, doc

* nested env settings: changes file

* nested env settings: cleanup

* Apply suggestions from code review

Co-authored-by: Samuel Colvin <samcolvin@gmail.com>

* Apply suggested changes from code review

* lint fix

* changes from code review

* simplify explosing env vars

* linting

Co-authored-by: Mark Trifonov <>
Co-authored-by: Samuel Colvin <samcolvin@gmail.com>
Co-authored-by: Samuel Colvin <s@muelcolvin.com>
2021-12-18 20:56:14 +00:00

1090 lines
29 KiB
Python

import os
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple, Union
import pytest
from pydantic import BaseModel, BaseSettings, Field, HttpUrl, NoneStr, SecretStr, ValidationError, dataclasses
from pydantic.env_settings import (
EnvSettingsSource,
InitSettingsSource,
SecretsSettingsSource,
SettingsError,
SettingsSourceCallable,
read_env_file,
)
try:
import dotenv
except ImportError:
dotenv = None
class SimpleSettings(BaseSettings):
apple: str
def test_sub_env(env):
env.set('apple', 'hello')
s = SimpleSettings()
assert s.apple == 'hello'
def test_sub_env_override(env):
env.set('apple', 'hello')
s = SimpleSettings(apple='goodbye')
assert s.apple == 'goodbye'
def test_sub_env_missing():
with pytest.raises(ValidationError) as exc_info:
SimpleSettings()
assert exc_info.value.errors() == [{'loc': ('apple',), 'msg': 'field required', 'type': 'value_error.missing'}]
def test_other_setting():
with pytest.raises(ValidationError):
SimpleSettings(apple='a', foobar=42)
def test_with_prefix(env):
class Settings(BaseSettings):
apple: str
class Config:
env_prefix = 'foobar_'
with pytest.raises(ValidationError):
Settings()
env.set('foobar_apple', 'has_prefix')
s = Settings()
assert s.apple == 'has_prefix'
def test_nested_env_with_basemodel(env):
class TopValue(BaseModel):
apple: str
banana: str
class Settings(BaseSettings):
top: TopValue
with pytest.raises(ValidationError):
Settings()
env.set('top', '{"banana": "secret_value"}')
s = Settings(top={'apple': 'value'})
assert s.top == {'apple': 'value', 'banana': 'secret_value'}
def test_merge_dict(env):
class Settings(BaseSettings):
top: Dict[str, str]
with pytest.raises(ValidationError):
Settings()
env.set('top', '{"banana": "secret_value"}')
s = Settings(top={'apple': 'value'})
assert s.top == {'apple': 'value', 'banana': 'secret_value'}
def test_nested_env_delimiter(env):
class SubSubValue(BaseSettings):
v6: str
class SubValue(BaseSettings):
v4: str
v5: int
sub_sub: SubSubValue
class TopValue(BaseSettings):
v1: str
v2: str
v3: str
sub: SubValue
class Cfg(BaseSettings):
v0: str
v0_union: Union[SubValue, int]
top: TopValue
class Config:
env_nested_delimiter = '__'
env.set('top', '{"v1": "json-1", "v2": "json-2", "sub": {"v5": "xx"}}')
env.set('top__sub__v5', '5')
env.set('v0', '0')
env.set('top__v2', '2')
env.set('top__v3', '3')
env.set('v0_union', '0')
env.set('top__sub__sub_sub__v6', '6')
env.set('top__sub__v4', '4')
cfg = Cfg()
assert cfg.dict() == {
'v0': '0',
'v0_union': 0,
'top': {
'v1': 'json-1',
'v2': '2',
'v3': '3',
'sub': {'v4': '4', 'v5': 5, 'sub_sub': {'v6': '6'}},
},
}
def test_nested_env_delimiter_complex_required(env):
class Cfg(BaseSettings):
v: str = 'default'
class Config:
env_nested_delimiter = '__'
env.set('v__x', 'x')
env.set('v__y', 'y')
cfg = Cfg()
assert cfg.dict() == {'v': 'default'}
def test_nested_env_delimiter_aliases(env):
class SubModel(BaseSettings):
v1: str
v2: str
class Cfg(BaseSettings):
sub_model: SubModel
class Config:
fields = {'sub_model': {'env': ['foo', 'bar']}}
env_nested_delimiter = '__'
env.set('foo__v1', '-1-')
env.set('bar__v2', '-2-')
assert Cfg().dict() == {'sub_model': {'v1': '-1-', 'v2': '-2-'}}
class DateModel(BaseModel):
pips: bool = False
class ComplexSettings(BaseSettings):
apples: List[str] = []
bananas: Set[int] = set()
carrots: dict = {}
date: DateModel = DateModel()
def test_list(env):
env.set('apples', '["russet", "granny smith"]')
s = ComplexSettings()
assert s.apples == ['russet', 'granny smith']
assert s.date.pips is False
def test_set_dict_model(env):
env.set('bananas', '[1, 2, 3, 3]')
env.set('CARROTS', '{"a": null, "b": 4}')
env.set('daTE', '{"pips": true}')
s = ComplexSettings()
assert s.bananas == {1, 2, 3}
assert s.carrots == {'a': None, 'b': 4}
assert s.date.pips is True
def test_invalid_json(env):
env.set('apples', '["russet", "granny smith",]')
with pytest.raises(SettingsError, match='error parsing JSON for "apples"'):
ComplexSettings()
def test_required_sub_model(env):
class Settings(BaseSettings):
foobar: DateModel
with pytest.raises(ValidationError):
Settings()
env.set('FOOBAR', '{"pips": "TRUE"}')
s = Settings()
assert s.foobar.pips is True
def test_non_class(env):
class Settings(BaseSettings):
foobar: NoneStr
env.set('FOOBAR', 'xxx')
s = Settings()
assert s.foobar == 'xxx'
def test_env_str(env):
class Settings(BaseSettings):
apple: str = ...
class Config:
fields = {'apple': {'env': 'BOOM'}}
env.set('BOOM', 'hello')
assert Settings().apple == 'hello'
def test_env_list(env):
class Settings(BaseSettings):
foobar: str
class Config:
fields = {'foobar': {'env': ['different1', 'different2']}}
env.set('different1', 'value 1')
env.set('different2', 'value 2')
s = Settings()
assert s.foobar == 'value 1'
def test_env_list_field(env):
class Settings(BaseSettings):
foobar: str = Field(..., env='foobar_env_name')
env.set('FOOBAR_ENV_NAME', 'env value')
s = Settings()
assert s.foobar == 'env value'
def test_env_list_last(env):
class Settings(BaseSettings):
foobar: str
class Config:
fields = {'foobar': {'env': ['different2']}}
env.set('different1', 'value 1')
env.set('different2', 'value 2')
s = Settings()
assert s.foobar == 'value 2'
assert Settings(foobar='abc').foobar == 'abc'
def test_env_inheritance(env):
class SettingsParent(BaseSettings):
foobar: str = 'parent default'
class Config:
fields = {'foobar': {'env': 'different'}}
class SettingsChild(SettingsParent):
foobar: str = 'child default'
assert SettingsParent().foobar == 'parent default'
assert SettingsParent(foobar='abc').foobar == 'abc'
assert SettingsChild().foobar == 'child default'
assert SettingsChild(foobar='abc').foobar == 'abc'
env.set('different', 'env value')
assert SettingsParent().foobar == 'env value'
assert SettingsParent(foobar='abc').foobar == 'abc'
assert SettingsChild().foobar == 'env value'
assert SettingsChild(foobar='abc').foobar == 'abc'
def test_env_inheritance_field(env):
class SettingsParent(BaseSettings):
foobar: str = Field('parent default', env='foobar_env')
class SettingsChild(SettingsParent):
foobar: str = 'child default'
assert SettingsParent().foobar == 'parent default'
assert SettingsParent(foobar='abc').foobar == 'abc'
assert SettingsChild().foobar == 'child default'
assert SettingsChild(foobar='abc').foobar == 'abc'
env.set('foobar_env', 'env value')
assert SettingsParent().foobar == 'env value'
assert SettingsParent(foobar='abc').foobar == 'abc'
assert SettingsChild().foobar == 'child default'
assert SettingsChild(foobar='abc').foobar == 'abc'
def test_env_prefix_inheritance_config(env):
env.set('foobar', 'foobar')
env.set('prefix_foobar', 'prefix_foobar')
env.set('foobar_parent_from_field', 'foobar_parent_from_field')
env.set('foobar_child_from_field', 'foobar_child_from_field')
env.set('foobar_parent_from_config', 'foobar_parent_from_config')
env.set('foobar_child_from_config', 'foobar_child_from_config')
# . Child prefix does not override explicit parent field config
class Parent(BaseSettings):
foobar: str = Field(None, env='foobar_parent_from_field')
class Child(Parent):
class Config:
env_prefix = 'prefix_'
assert Child().foobar == 'foobar_parent_from_field'
# c. Child prefix does not override explicit parent class config
class Parent(BaseSettings):
foobar: str = None
class Config:
fields = {
'foobar': {'env': ['foobar_parent_from_config']},
}
class Child(Parent):
class Config:
env_prefix = 'prefix_'
assert Child().foobar == 'foobar_parent_from_config'
# d. Child prefix overrides parent with implicit config
class Parent(BaseSettings):
foobar: str = None
class Child(Parent):
class Config:
env_prefix = 'prefix_'
assert Child().foobar == 'prefix_foobar'
def test_env_inheritance_config(env):
env.set('foobar', 'foobar')
env.set('prefix_foobar', 'prefix_foobar')
env.set('foobar_parent_from_field', 'foobar_parent_from_field')
env.set('foobar_child_from_field', 'foobar_child_from_field')
env.set('foobar_parent_from_config', 'foobar_parent_from_config')
env.set('foobar_child_from_config', 'foobar_child_from_config')
# a. Child class config overrides prefix and parent field config
class Parent(BaseSettings):
foobar: str = Field(None, env='foobar_parent_from_field')
class Child(Parent):
class Config:
env_prefix = 'prefix_'
fields = {
'foobar': {'env': ['foobar_child_from_config']},
}
assert Child().foobar == 'foobar_child_from_config'
# b. Child class config overrides prefix and parent class config
class Parent(BaseSettings):
foobar: str = None
class Config:
fields = {
'foobar': {'env': ['foobar_parent_from_config']},
}
class Child(Parent):
class Config:
env_prefix = 'prefix_'
fields = {
'foobar': {'env': ['foobar_child_from_config']},
}
assert Child().foobar == 'foobar_child_from_config'
# . Child class config overrides prefix and parent with implicit config
class Parent(BaseSettings):
foobar: Optional[str]
class Child(Parent):
class Config:
env_prefix = 'prefix_'
fields = {
'foobar': {'env': ['foobar_child_from_field']},
}
assert Child().foobar == 'foobar_child_from_field'
def test_env_invalid(env):
with pytest.raises(TypeError, match=r'invalid field env: 123 \(int\); should be string, list or set'):
class Settings(BaseSettings):
foobar: str
class Config:
fields = {'foobar': {'env': 123}}
def test_env_field(env):
with pytest.raises(TypeError, match=r'invalid field env: 123 \(int\); should be string, list or set'):
class Settings(BaseSettings):
foobar: str = Field(..., env=123)
def test_aliases_warning(env):
with pytest.warns(FutureWarning, match='aliases are no longer used by BaseSettings'):
class Settings(BaseSettings):
foobar: str = 'default value'
class Config:
fields = {'foobar': 'foobar_alias'}
assert Settings().foobar == 'default value'
env.set('foobar_alias', 'xxx')
assert Settings().foobar == 'default value'
assert Settings(foobar_alias='42').foobar == '42'
def test_aliases_no_warning(env):
class Settings(BaseSettings):
foobar: str = 'default value'
class Config:
fields = {'foobar': {'alias': 'foobar_alias', 'env': 'foobar_env'}}
assert Settings().foobar == 'default value'
assert Settings(foobar_alias='42').foobar == '42'
env.set('foobar_alias', 'xxx')
assert Settings().foobar == 'default value'
env.set('foobar_env', 'xxx')
assert Settings().foobar == 'xxx'
assert Settings(foobar_alias='42').foobar == '42'
def test_case_sensitive(monkeypatch):
class Settings(BaseSettings):
foo: str
class Config:
case_sensitive = True
# Need to patch os.environ to get build to work on Windows, where os.environ is case insensitive
monkeypatch.setattr(os, 'environ', value={'Foo': 'foo'})
with pytest.raises(ValidationError) as exc_info:
Settings()
assert exc_info.value.errors() == [{'loc': ('foo',), 'msg': 'field required', 'type': 'value_error.missing'}]
def test_nested_dataclass(env):
@dataclasses.dataclass
class MyDataclass:
foo: int
bar: str
class Settings(BaseSettings):
n: MyDataclass
env.set('N', '[123, "bar value"]')
s = Settings()
assert isinstance(s.n, MyDataclass)
assert s.n.foo == 123
assert s.n.bar == 'bar value'
def test_env_takes_precedence(env):
class Settings(BaseSettings):
foo: int
bar: str
class Config:
@classmethod
def customise_sources(
cls,
init_settings: SettingsSourceCallable,
env_settings: SettingsSourceCallable,
file_secret_settings: SettingsSourceCallable,
) -> Tuple[SettingsSourceCallable, ...]:
return env_settings, init_settings
env.set('BAR', 'env setting')
s = Settings(foo='123', bar='argument')
assert s.foo == 123
assert s.bar == 'env setting'
def test_config_file_settings_nornir(env):
"""
See https://github.com/samuelcolvin/pydantic/pull/341#issuecomment-450378771
"""
def nornir_settings_source(settings: BaseSettings) -> Dict[str, Any]:
return {'param_a': 'config a', 'param_b': 'config b', 'param_c': 'config c'}
class Settings(BaseSettings):
param_a: str
param_b: str
param_c: str
class Config:
@classmethod
def customise_sources(
cls,
init_settings: SettingsSourceCallable,
env_settings: SettingsSourceCallable,
file_secret_settings: SettingsSourceCallable,
) -> Tuple[SettingsSourceCallable, ...]:
return env_settings, init_settings, nornir_settings_source
env.set('PARAM_C', 'env setting c')
s = Settings(param_b='argument b', param_c='argument c')
assert s.param_a == 'config a'
assert s.param_b == 'argument b'
assert s.param_c == 'env setting c'
def test_env_union_with_complex_subfields_parses_json(env):
class A(BaseSettings):
a: str
class B(BaseSettings):
b: int
class Settings(BaseSettings):
content: Union[A, B, int]
env.set('content', '{"a": "test"}')
s = Settings()
assert s.content == A(a='test')
def test_env_union_with_complex_subfields_parses_plain_if_json_fails(env):
class A(BaseSettings):
a: str
class B(BaseSettings):
b: int
class Settings(BaseSettings):
content: Union[A, B, datetime]
env.set('content', '2020-07-05T00:00:00Z')
s = Settings()
assert s.content == datetime(2020, 7, 5, 0, 0, tzinfo=timezone.utc)
def test_env_union_without_complex_subfields_does_not_parse_json(env):
class Settings(BaseSettings):
content: Union[datetime, str]
env.set('content', '2020-07-05T00:00:00Z')
s = Settings()
assert s.content == datetime(2020, 7, 5, 0, 0, tzinfo=timezone.utc)
test_env_file = """\
# this is a comment
A=good string
# another one, followed by whitespace
b='better string'
c="best string"
"""
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_env_file_config(env, tmp_path):
p = tmp_path / '.env'
p.write_text(test_env_file)
class Settings(BaseSettings):
a: str
b: str
c: str
class Config:
env_file = p
env.set('A', 'overridden var')
s = Settings()
assert s.a == 'overridden var'
assert s.b == 'better string'
assert s.c == 'best string'
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_env_file_config_case_sensitive(tmp_path):
p = tmp_path / '.env'
p.write_text(test_env_file)
class Settings(BaseSettings):
a: str
b: str
c: str
class Config:
env_file = p
case_sensitive = True
with pytest.raises(ValidationError) as exc_info:
Settings()
assert exc_info.value.errors() == [{'loc': ('a',), 'msg': 'field required', 'type': 'value_error.missing'}]
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_env_file_export(env, tmp_path):
p = tmp_path / '.env'
p.write_text(
"""\
export A='good string'
export B=better-string
export C="best string"
"""
)
class Settings(BaseSettings):
a: str
b: str
c: str
class Config:
env_file = p
env.set('A', 'overridden var')
s = Settings()
assert s.a == 'overridden var'
assert s.b == 'better-string'
assert s.c == 'best string'
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_env_file_config_custom_encoding(tmp_path):
p = tmp_path / '.env'
p.write_text('pika=p!±@', encoding='latin-1')
class Settings(BaseSettings):
pika: str
class Config:
env_file = p
env_file_encoding = 'latin-1'
s = Settings()
assert s.pika == 'p!±@'
@pytest.fixture
def home_tmp():
tmp_filename = f'{uuid.uuid4()}.env'
home_tmp_path = Path.home() / tmp_filename
yield home_tmp_path, tmp_filename
home_tmp_path.unlink()
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_env_file_home_directory(home_tmp):
home_tmp_path, tmp_filename = home_tmp
home_tmp_path.write_text('pika=baz')
class Settings(BaseSettings):
pika: str
class Config:
env_file = f'~/{tmp_filename}'
assert Settings().pika == 'baz'
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_env_file_none(tmp_path):
p = tmp_path / '.env'
p.write_text('a')
class Settings(BaseSettings):
a: str = 'xxx'
s = Settings(_env_file=p)
assert s.a == 'xxx'
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_env_file_override_file(tmp_path):
p1 = tmp_path / '.env'
p1.write_text(test_env_file)
p2 = tmp_path / '.env.prod'
p2.write_text('A="new string"')
class Settings(BaseSettings):
a: str
class Config:
env_file = str(p1)
s = Settings(_env_file=p2)
assert s.a == 'new string'
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_env_file_override_none(tmp_path):
p = tmp_path / '.env'
p.write_text(test_env_file)
class Settings(BaseSettings):
a: str = None
class Config:
env_file = p
s = Settings(_env_file=None)
assert s.a is None
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_env_file_not_a_file(env):
class Settings(BaseSettings):
a: str = None
env.set('A', 'ignore non-file')
s = Settings(_env_file='tests/')
assert s.a == 'ignore non-file'
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_read_env_file_cast_sensitive(tmp_path):
p = tmp_path / '.env'
p.write_text('a="test"\nB=123')
assert read_env_file(p) == {'a': 'test', 'b': '123'}
assert read_env_file(p, case_sensitive=True) == {'a': 'test', 'B': '123'}
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_read_env_file_syntax_wrong(tmp_path):
p = tmp_path / '.env'
p.write_text('NOT_AN_ASSIGNMENT')
assert read_env_file(p, case_sensitive=True) == {'NOT_AN_ASSIGNMENT': None}
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_env_file_example(tmp_path):
p = tmp_path / '.env'
p.write_text(
"""\
# ignore comment
ENVIRONMENT="production"
REDIS_ADDRESS=localhost:6379
MEANING_OF_LIFE=42
MY_VAR='Hello world'
"""
)
class Settings(BaseSettings):
environment: str
redis_address: str
meaning_of_life: int
my_var: str
s = Settings(_env_file=str(p))
assert s.dict() == {
'environment': 'production',
'redis_address': 'localhost:6379',
'meaning_of_life': 42,
'my_var': 'Hello world',
}
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_env_file_custom_encoding(tmp_path):
p = tmp_path / '.env'
p.write_text('pika=p!±@', encoding='latin-1')
class Settings(BaseSettings):
pika: str
with pytest.raises(UnicodeDecodeError):
Settings(_env_file=str(p))
s = Settings(_env_file=str(p), _env_file_encoding='latin-1')
assert s.dict() == {'pika': 'p!±@'}
@pytest.mark.skipif(dotenv, reason='python-dotenv is installed')
def test_dotenv_not_installed(tmp_path):
p = tmp_path / '.env'
p.write_text('a=b')
class Settings(BaseSettings):
a: str
with pytest.raises(ImportError, match=r'^python-dotenv is not installed, run `pip install pydantic\[dotenv\]`$'):
Settings(_env_file=p)
def test_alias_set(env):
class Settings(BaseSettings):
foo: str = 'default foo'
bar: str = 'bar default'
class Config:
fields = {'foo': {'env': 'foo_env'}}
assert Settings.__fields__['bar'].name == 'bar'
assert Settings.__fields__['bar'].alias == 'bar'
assert Settings.__fields__['foo'].name == 'foo'
assert Settings.__fields__['foo'].alias == 'foo'
class SubSettings(Settings):
spam: str = 'spam default'
assert SubSettings.__fields__['bar'].name == 'bar'
assert SubSettings.__fields__['bar'].alias == 'bar'
assert SubSettings.__fields__['foo'].name == 'foo'
assert SubSettings.__fields__['foo'].alias == 'foo'
assert SubSettings().dict() == {'foo': 'default foo', 'bar': 'bar default', 'spam': 'spam default'}
env.set('foo_env', 'fff')
assert SubSettings().dict() == {'foo': 'fff', 'bar': 'bar default', 'spam': 'spam default'}
env.set('bar', 'bbb')
assert SubSettings().dict() == {'foo': 'fff', 'bar': 'bbb', 'spam': 'spam default'}
env.set('spam', 'sss')
assert SubSettings().dict() == {'foo': 'fff', 'bar': 'bbb', 'spam': 'sss'}
def test_prefix_on_parent(env):
class MyBaseSettings(BaseSettings):
var: str = 'old'
class MySubSettings(MyBaseSettings):
class Config:
env_prefix = 'PREFIX_'
assert MyBaseSettings().dict() == {'var': 'old'}
assert MySubSettings().dict() == {'var': 'old'}
env.set('PREFIX_VAR', 'new')
assert MyBaseSettings().dict() == {'var': 'old'}
assert MySubSettings().dict() == {'var': 'new'}
def test_frozenset(env):
class Settings(BaseSettings):
foo: str = 'default foo'
class Config:
fields = {'foo': {'env': frozenset(['foo_a', 'foo_b'])}}
assert Settings.__fields__['foo'].field_info.extra['env_names'] == frozenset({'foo_a', 'foo_b'})
assert Settings().dict() == {'foo': 'default foo'}
env.set('foo_a', 'x')
assert Settings().dict() == {'foo': 'x'}
def test_secrets_path(tmp_path):
p = tmp_path / 'foo'
p.write_text('foo_secret_value_str')
class Settings(BaseSettings):
foo: str
class Config:
secrets_dir = tmp_path
assert Settings().dict() == {'foo': 'foo_secret_value_str'}
def test_secrets_path_url(tmp_path):
(tmp_path / 'foo').write_text('http://www.example.com')
(tmp_path / 'bar').write_text('snap')
class Settings(BaseSettings):
foo: HttpUrl
bar: SecretStr
class Config:
secrets_dir = tmp_path
assert Settings().dict() == {'foo': 'http://www.example.com', 'bar': SecretStr('snap')}
def test_secrets_path_json(tmp_path):
p = tmp_path / 'foo'
p.write_text('{"a": "b"}')
class Settings(BaseSettings):
foo: Dict[str, str]
class Config:
secrets_dir = tmp_path
assert Settings().dict() == {'foo': {'a': 'b'}}
def test_secrets_path_invalid_json(tmp_path):
p = tmp_path / 'foo'
p.write_text('{"a": "b"')
class Settings(BaseSettings):
foo: Dict[str, str]
class Config:
secrets_dir = tmp_path
with pytest.raises(SettingsError, match='error parsing JSON for "foo"'):
Settings()
def test_secrets_missing(tmp_path):
class Settings(BaseSettings):
foo: str
class Config:
secrets_dir = tmp_path
with pytest.raises(ValidationError) as exc_info:
Settings()
assert exc_info.value.errors() == [{'loc': ('foo',), 'msg': 'field required', 'type': 'value_error.missing'}]
def test_secrets_invalid_secrets_dir(tmp_path):
p1 = tmp_path / 'foo'
p1.write_text('foo_secret_value_str')
class Settings(BaseSettings):
foo: str
class Config:
secrets_dir = p1
with pytest.raises(SettingsError, match='secrets_dir must reference a directory, not a file'):
Settings()
@pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex')
def test_secrets_missing_location(tmp_path):
class Settings(BaseSettings):
class Config:
secrets_dir = tmp_path / 'does_not_exist'
with pytest.warns(UserWarning, match=f'directory "{tmp_path}/does_not_exist" does not exist'):
Settings()
@pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex')
def test_secrets_file_is_a_directory(tmp_path):
p1 = tmp_path / 'foo'
p1.mkdir()
class Settings(BaseSettings):
foo: Optional[str]
class Config:
secrets_dir = tmp_path
with pytest.warns(UserWarning, match=f'attempted to load secret file "{tmp_path}/foo" but found a directory inste'):
Settings()
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_secrets_dotenv_precedence(tmp_path):
s = tmp_path / 'foo'
s.write_text('foo_secret_value_str')
e = tmp_path / '.env'
e.write_text('foo=foo_env_value_str')
class Settings(BaseSettings):
foo: str
class Config:
secrets_dir = tmp_path
assert Settings(_env_file=e).dict() == {'foo': 'foo_env_value_str'}
def test_external_settings_sources_precedence(env):
def external_source_0(settings: BaseSettings) -> Dict[str, str]:
return {'apple': 'value 0', 'banana': 'value 2'}
def external_source_1(settings: BaseSettings) -> Dict[str, str]:
return {'apple': 'value 1', 'raspberry': 'value 3'}
class Settings(BaseSettings):
apple: str
banana: str
raspberry: str
class Config:
@classmethod
def customise_sources(
cls,
init_settings: SettingsSourceCallable,
env_settings: SettingsSourceCallable,
file_secret_settings: SettingsSourceCallable,
) -> Tuple[SettingsSourceCallable, ...]:
return init_settings, env_settings, file_secret_settings, external_source_0, external_source_1
env.set('banana', 'value 1')
assert Settings().dict() == {'apple': 'value 0', 'banana': 'value 1', 'raspberry': 'value 3'}
def test_external_settings_sources_filter_env_vars():
vault_storage = {'user:password': {'apple': 'value 0', 'banana': 'value 2'}}
class VaultSettingsSource:
def __init__(self, user: str, password: str):
self.user = user
self.password = password
def __call__(self, settings: BaseSettings) -> Dict[str, str]:
vault_vars = vault_storage[f'{self.user}:{self.password}']
return {
field.alias: vault_vars[field.name]
for field in settings.__fields__.values()
if field.name in vault_vars
}
class Settings(BaseSettings):
apple: str
banana: str
class Config:
@classmethod
def customise_sources(
cls,
init_settings: SettingsSourceCallable,
env_settings: SettingsSourceCallable,
file_secret_settings: SettingsSourceCallable,
) -> Tuple[SettingsSourceCallable, ...]:
return (
init_settings,
env_settings,
file_secret_settings,
VaultSettingsSource(user='user', password='password'),
)
assert Settings().dict() == {'apple': 'value 0', 'banana': 'value 2'}
def test_customise_sources_empty():
class Settings(BaseSettings):
apple: str = 'default'
banana: str = 'default'
class Config:
@classmethod
def customise_sources(cls, *args, **kwargs):
return ()
assert Settings().dict() == {'apple': 'default', 'banana': 'default'}
assert Settings(apple='xxx').dict() == {'apple': 'default', 'banana': 'default'}
def test_builtins_settings_source_repr():
assert (
repr(InitSettingsSource(init_kwargs={'apple': 'value 0', 'banana': 'value 1'}))
== "InitSettingsSource(init_kwargs={'apple': 'value 0', 'banana': 'value 1'})"
)
assert (
repr(EnvSettingsSource(env_file='.env', env_file_encoding='utf-8'))
== "EnvSettingsSource(env_file='.env', env_file_encoding='utf-8', env_nested_delimiter=None)"
)
assert repr(SecretsSettingsSource(secrets_dir='/secrets')) == "SecretsSettingsSource(secrets_dir='/secrets')"