Skip to content

Commit bee16a3

Browse files
authored
♻️ Enhances BaseCustomSettings in settings_library (ITISFoundation#2750)
1 parent 5696d9f commit bee16a3

File tree

33 files changed

+1368
-314
lines changed

33 files changed

+1368
-314
lines changed

packages/pytest-simcore/src/pytest_simcore/docker_compose.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
FIXTURE_CONFIG_OPS_SERVICES_SELECTION,
2727
)
2828
from .helpers.constants import HEADER_STR
29+
from .helpers.typing_env import EnvVarsDict
2930
from .helpers.utils_docker import get_ip, run_docker_compose_config, save_docker_infos
30-
from .helpers.utils_environs import EnvVarsDict
3131

3232

3333
@pytest.fixture(scope="session")

packages/pytest-simcore/src/pytest_simcore/docker_swarm.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
from tenacity.wait import wait_fixed
2323

2424
from .helpers.constants import HEADER_STR, MINUTE
25+
from .helpers.typing_env import EnvVarsDict
2526
from .helpers.utils_dict import copy_from_dict
2627
from .helpers.utils_docker import get_ip
27-
from .helpers.utils_environs import EnvVarsDict
2828

2929
log = logging.getLogger(__name__)
3030

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Dict, Optional
2+
3+
EnvVarsDict = Dict[str, Optional[str]]
4+
#
5+
# NOTE: that this means that env vars do not require a value. If that happens a None is assigned
6+
# For instance, a valid env file is
7+
#
8+
# NAME=foo
9+
# INDEX=33
10+
# ONLY_NAME=
11+
#
12+
# will return env: EnvVarsDict = {"NAME": "foo", "INDEX": 33, "ONLY_NAME": None}
13+
#

packages/pytest-simcore/src/pytest_simcore/helpers/utils_environs.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22
33
"""
44
import re
5+
import warnings
56
from copy import deepcopy
67
from pathlib import Path
7-
from typing import Dict, List, Union
8+
from typing import Dict, List
89

910
import yaml
1011

11-
EnvVarsDict = Dict[str, Union[str, None]]
12+
from .typing_env import EnvVarsDict
1213

1314
VARIABLE_SUBSTITUTION = re.compile(r"\$\{(\w+)(?:(:{0,1}[-?]{0,1})(.*))?\}$")
1415

16+
warnings.warn(
17+
f"{__name__} is deprecated, use instead pytest_simcore.helpers.utils_envs",
18+
DeprecationWarning,
19+
)
20+
1521

1622
def _load_env(file_handler) -> Dict:
1723
"""Deserializes an environment file like .env-devel and
@@ -36,7 +42,7 @@ def eval_environs_in_docker_compose(
3642
docker_compose_dir: Path,
3743
host_environ: Dict = None,
3844
*,
39-
use_env_devel=True
45+
use_env_devel=True,
4046
):
4147
"""Resolves environments in docker compose and sets them under 'environment' section
4248
@@ -56,7 +62,7 @@ def replace_environs_in_docker_compose_service(
5662
docker_compose_dir: Path,
5763
host_environ: Dict = None,
5864
*,
59-
use_env_devel=True
65+
use_env_devel=True,
6066
):
6167
"""Resolves environments in docker-compose's service section,
6268
drops any reference to env_file and sets all
@@ -107,7 +113,7 @@ def eval_service_environ(
107113
host_environ: Dict = None,
108114
image_environ: Dict = None,
109115
*,
110-
use_env_devel=True
116+
use_env_devel=True,
111117
) -> EnvVarsDict:
112118
"""Deduces a service environment with it runs in a stack from confirmation
113119
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import os
2+
from io import StringIO
3+
4+
from _pytest.monkeypatch import MonkeyPatch
5+
from dotenv import dotenv_values
6+
7+
from .typing_env import EnvVarsDict
8+
9+
10+
def setenvs_as_envfile(monkeypatch: MonkeyPatch, envfile_text: str) -> EnvVarsDict:
11+
envs = dotenv_values(stream=StringIO(envfile_text))
12+
for key, value in envs.items():
13+
monkeypatch.setenv(key, str(value))
14+
15+
assert all(env in os.environ for env in envs)
16+
return envs
17+
18+
19+
def delenvs_as_envfile(
20+
monkeypatch: MonkeyPatch, envfile_text: str, raising: bool
21+
) -> EnvVarsDict:
22+
envs = dotenv_values(stream=StringIO(envfile_text))
23+
for key in envs.keys():
24+
monkeypatch.delenv(key, raising=raising)
25+
26+
assert all(env not in os.environ for env in envs)
27+
return envs

packages/pytest-simcore/src/pytest_simcore/simcore_services.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
from yarl import URL
2020

2121
from .helpers.constants import MINUTE
22+
from .helpers.typing_env import EnvVarsDict
2223
from .helpers.utils_docker import get_ip, get_service_published_port
23-
from .helpers.utils_environs import EnvVarsDict
2424

2525
log = logging.getLogger(__name__)
2626

packages/settings-library/requirements/_base.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#
44
--constraint ../../../requirements/constraints.txt
55

6-
pydantic
6+
pydantic>=1.9
77

88

99
# extra
Lines changed: 92 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,110 @@
1-
import logging
2-
import os
31
from functools import cached_property
4-
from typing import List, Tuple, Type
2+
from typing import Sequence, get_args
53

6-
from pydantic import BaseSettings, Extra, SecretStr, ValidationError
4+
from pydantic import BaseConfig, BaseSettings, Extra, ValidationError, validator
5+
from pydantic.error_wrappers import ErrorList, ErrorWrapper
6+
from pydantic.fields import ModelField, Undefined
7+
from pydantic.types import SecretStr
78

8-
logger = logging.getLogger(__name__)
9+
10+
class DefaultFromEnvFactoryError(ValidationError):
11+
...
12+
13+
14+
def create_settings_from_env(field: ModelField):
15+
# NOTE: Cannot pass only field.type_ because @prepare_field (when this function is called)
16+
# this value is still not resolved (field.type_ at that moment has a weak_ref).
17+
# Therefore we keep the entire 'field' but MUST be treated here as read-only
18+
19+
def _default_factory():
20+
"""Creates default from sub-settings or None (if nullable)"""
21+
field_settings_cls = field.type_
22+
try:
23+
return field_settings_cls()
24+
25+
except ValidationError as err:
26+
if field.allow_none:
27+
return None
28+
29+
def _prepend_field_name(ee: ErrorList):
30+
if isinstance(ee, ErrorWrapper):
31+
return ErrorWrapper(ee.exc, (field.name,) + ee.loc_tuple())
32+
assert isinstance(ee, Sequence) # nosec
33+
return [_prepend_field_name(e) for e in ee]
34+
35+
raise DefaultFromEnvFactoryError(
36+
errors=_prepend_field_name(err.raw_errors), # type: ignore
37+
model=err.model,
38+
# FIXME: model = shall be the parent settings?? but I dont find how retrieve it from the field
39+
) from err
40+
41+
return _default_factory
942

1043

1144
class BaseCustomSettings(BaseSettings):
12-
class Config:
13-
# SEE https://pydantic-docs.helpmanual.io/usage/model_config/
14-
case_sensitive = False
45+
"""
46+
- Customized configuration for all settings
47+
- If a field is a BaseCustomSettings subclass, it allows creating a default from env vars setting the Field
48+
option 'auto_default_from_env=True'.
49+
50+
SEE tests for details.
51+
"""
52+
53+
@validator("*", pre=True)
54+
@classmethod
55+
def parse_none(cls, v, field: ModelField):
56+
# WARNING: In nullable fields, envs equal to null or none are parsed as None !!
57+
if field.allow_none:
58+
if isinstance(v, str) and v.lower() in ("null", "none"):
59+
return None
60+
return v
61+
62+
class Config(BaseConfig):
63+
case_sensitive = True # All must be capitalized
1564
extra = Extra.forbid
1665
allow_mutation = False
1766
frozen = True
1867
validate_all = True
1968
json_encoders = {SecretStr: lambda v: v.get_secret_value()}
2069
keep_untouched = (cached_property,)
2170

22-
@classmethod
23-
def _set_defaults_with_default_constructors(
24-
cls, default_fields: List[Tuple[str, Type["BaseCustomSettings"]]]
25-
):
26-
# This function can set defaults on fields that are BaseSettings as well
27-
# It is used in control construction of defaults.
28-
# Pydantic offers a defaults_factory but it is executed upon creation of the Settings **class**
29-
# which is too early for our purpose. Instead, we want to create the defaults just
30-
# before the settings instance is constructed
31-
32-
assert issubclass(cls, BaseCustomSettings) # nosec
33-
34-
# Builds defaults at this point
35-
for name, default_cls in default_fields:
36-
try:
37-
default = default_cls.create_from_envs()
38-
field_obj = cls.__fields__[name]
39-
field_obj.default = default
40-
field_obj.field_info.default = default
41-
field_obj.required = False
42-
except ValidationError as e:
43-
logger.error(
44-
(
45-
"Could not validate '%s', field '%s' "
46-
"contains errors, see below:\n%s"
47-
"\n======ENV_VARS=====\n%s"
48-
"\n==================="
49-
),
50-
cls.__name__,
51-
default_cls.__name__,
52-
str(e),
53-
"\n".join(f"{k}={v}" for k, v in os.environ.items()),
71+
@classmethod
72+
def prepare_field(cls, field: ModelField) -> None:
73+
super().prepare_field(field)
74+
75+
auto_default_from_env = field.field_info.extra.get(
76+
"auto_default_from_env", False
77+
)
78+
79+
field_type = field.type_
80+
if args := get_args(field_type):
81+
field_type = next(a for a in args if a != type(None))
82+
83+
if issubclass(field_type, BaseCustomSettings):
84+
85+
if auto_default_from_env:
86+
87+
assert field.field_info.default is Undefined
88+
assert field.field_info.default_factory is None
89+
90+
field.default_factory = create_settings_from_env(field)
91+
# Having a default value, makes this field automatically optional
92+
field.required = False
93+
94+
elif issubclass(field_type, BaseSettings):
95+
raise ValueError(
96+
f"{cls}.{field.name} of type {field_type} must inherit from BaseCustomSettings"
5497
)
55-
raise e
5698

57-
@classmethod
58-
def create_from_envs(cls):
59-
"""Constructs settings instance capturing envs (even for defaults) at this call moment"""
60-
61-
# captures envs here to build defaults for BaseCustomSettings sub-settings
62-
default_fields = []
63-
for name, field in cls.__fields__.items():
64-
if issubclass(field.type_, BaseCustomSettings):
65-
default_fields.append((name, field.type_))
66-
elif issubclass(field.type_, BaseSettings):
99+
elif auto_default_from_env:
67100
raise ValueError(
68-
f"{name} field class {field.type_} must inherit from BaseCustomSettings"
101+
"auto_default_from_env=True can only be used in BaseCustomSettings subclasses"
102+
f"but field {cls}.{field.name} is {field_type} "
69103
)
70-
cls._set_defaults_with_default_constructors(default_fields)
71104

72-
# builds instance
73-
obj = cls()
74-
return obj
105+
@classmethod
106+
def create_from_envs(cls, **overrides):
107+
# Kept for legacy
108+
# Optional to use to make the code more readable
109+
# More explicit and pylance seems to get less confused
110+
return cls(**overrides)

packages/settings-library/src/settings_library/email.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Optional
2+
13
from pydantic.fields import Field
24
from pydantic.types import SecretStr
35

@@ -13,6 +15,6 @@ class SMTPSettings(BaseCustomSettings):
1315
SMTP_HOST: str
1416
SMTP_PORT: PortInt
1517

16-
SMTP_TLS_ENABLED: bool = Field(description="Enables Secure Mode")
17-
SMTP_USERNAME: str
18-
SMTP_PASSWORD: SecretStr
18+
SMTP_TLS_ENABLED: bool = Field(False, description="Enables Secure Mode")
19+
SMTP_USERNAME: Optional[str]
20+
SMTP_PASSWORD: Optional[SecretStr]

packages/settings-library/src/settings_library/postgres.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,17 @@ def dsn_with_query(self) -> str:
6666
{"application_name": self.POSTGRES_CLIENT_NAME}
6767
)
6868
return dsn
69+
70+
class Config(BaseCustomSettings.Config):
71+
schema_extra = {
72+
"examples": [
73+
# minimal
74+
{
75+
"POSTGRES_HOST": "localhost",
76+
"POSTGRES_PORT": "5432",
77+
"POSTGRES_USER": "usr",
78+
"POSTGRES_PASSWORD": "secret",
79+
"POSTGRES_DB": "db",
80+
}
81+
],
82+
}

0 commit comments

Comments
 (0)