Skip to content

Commit a6802c9

Browse files
authored
⬆️✅ Fixes settings tests (#6753)
1 parent fa57c9e commit a6802c9

File tree

5 files changed

+163
-46
lines changed

5 files changed

+163
-46
lines changed

packages/models-library/tests/test__pydantic_models.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
77
"""
88

9-
from typing import Union, get_args, get_origin
9+
from typing import Any, Union, get_args, get_origin
1010

1111
import pytest
1212
from models_library.projects_nodes import InputTypes, OutputTypes
@@ -173,3 +173,36 @@ class Func(BaseModel):
173173
print(model.model_dump_json(indent=1))
174174
assert model.input == {"w": 42, "z": False}
175175
assert model.output == [1, 2, 3, None]
176+
177+
178+
def test_nullable_fields_from_pydantic_v1():
179+
# Tests issue found during migration. Pydantic v1 would default to None all nullable fields when they were not **explicitly** set with `...` as required
180+
# SEE https://github.com/ITISFoundation/osparc-simcore/pull/6751
181+
class MyModel(BaseModel):
182+
# pydanticv1 would add a default to fields set as nullable
183+
nullable_required: str | None # <--- This was default to =None in pydantic 1 !!!
184+
nullable_required_with_hyphen: str | None = Field(default=...)
185+
nullable_optional: str | None = None
186+
187+
# but with non-nullable "required" worked both ways
188+
non_nullable_required: int
189+
non_nullable_required_with_hyphen: int = Field(default=...)
190+
non_nullable_optional: int = 42
191+
192+
data: dict[str, Any] = {
193+
"nullable_required_with_hyphen": "foo",
194+
"non_nullable_required_with_hyphen": 1,
195+
"non_nullable_required": 2,
196+
}
197+
198+
with pytest.raises(ValidationError) as err_info:
199+
MyModel.model_validate(data)
200+
201+
assert err_info.value.error_count() == 1
202+
error = err_info.value.errors()[0]
203+
assert error["type"] == "missing"
204+
assert error["loc"] == ("nullable_required",)
205+
206+
data["nullable_required"] = None
207+
model = MyModel.model_validate(data)
208+
assert model.model_dump(exclude_unset=True) == data

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515

1616
_logger = logging.getLogger(__name__)
1717

18-
_DEFAULTS_TO_NONE_MSG: Final[
18+
_AUTO_DEFAULT_FACTORY_RESOLVES_TO_NONE_FSTRING: Final[
1919
str
20-
] = "%s auto_default_from_env unresolved, defaulting to None"
20+
] = "{field_name} auto_default_from_env unresolved, defaulting to None"
2121

2222

2323
class DefaultFromEnvFactoryError(ValueError):
@@ -40,10 +40,10 @@ def _default_factory():
4040
except ValidationError as err:
4141
if is_nullable(info):
4242
# e.g. Optional[PostgresSettings] would warn if defaults to None
43-
_logger.warning(
44-
_DEFAULTS_TO_NONE_MSG,
45-
field_name,
43+
msg = _AUTO_DEFAULT_FACTORY_RESOLVES_TO_NONE_FSTRING.format(
44+
field_name=field_name
4645
)
46+
_logger.warning(msg)
4747
return None
4848
_logger.warning("Validation errors=%s", err.errors())
4949
raise DefaultFromEnvFactoryError(errors=err.errors()) from err
@@ -58,6 +58,9 @@ def _is_auto_default_from_env_enabled(field: FieldInfo) -> bool:
5858
)
5959

6060

61+
ENABLED: Final = {}
62+
63+
6164
class EnvSettingsWithAutoDefaultSource(EnvSettingsSource):
6265
def __init__(
6366
self, settings_cls: type[BaseSettings], env_settings: EnvSettingsSource
@@ -86,7 +89,7 @@ def prepare_field_value(
8689
_is_auto_default_from_env_enabled(field)
8790
and field.default_factory
8891
and field.default is None
89-
and prepared_value == {}
92+
and prepared_value == ENABLED
9093
):
9194
prepared_value = field.default_factory()
9295
return prepared_value

packages/settings-library/tests/test__pydantic_settings.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,13 @@ class Settings(BaseSettings):
5656
@classmethod
5757
def parse_none(cls, v, info: ValidationInfo):
5858
# WARNING: In nullable fields, envs equal to null or none are parsed as None !!
59-
if info.field_name and is_nullable(cls.model_fields[info.field_name]):
60-
if isinstance(v, str) and v.lower() in ("null", "none"):
61-
return None
59+
if (
60+
info.field_name
61+
and is_nullable(cls.model_fields[info.field_name])
62+
and isinstance(v, str)
63+
and v.lower() in ("null", "none")
64+
):
65+
return None
6266
return v
6367

6468

packages/settings-library/tests/test_base.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_envfile
1818
from pytest_simcore.helpers.typing_env import EnvVarsDict
1919
from settings_library.base import (
20-
_DEFAULTS_TO_NONE_MSG,
20+
_AUTO_DEFAULT_FACTORY_RESOLVES_TO_NONE_FSTRING,
2121
BaseCustomSettings,
2222
DefaultFromEnvFactoryError,
2323
)
@@ -223,7 +223,12 @@ class SettingsClass(BaseCustomSettings):
223223

224224
# Defaulting to None also logs a warning
225225
assert logger_warn.call_count == 1
226-
assert _DEFAULTS_TO_NONE_MSG in logger_warn.call_args[0][0]
226+
assert (
227+
_AUTO_DEFAULT_FACTORY_RESOLVES_TO_NONE_FSTRING.format(
228+
field_name="VALUE_NULLABLE_DEFAULT_ENV"
229+
)
230+
in logger_warn.call_args[0][0]
231+
)
227232

228233

229234
def test_auto_default_to_not_none(

services/autoscaling/tests/unit/test_core_settings.py

Lines changed: 106 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@
55

66
import datetime
77
import json
8+
import logging
89
import os
10+
from typing import Final
911

1012
import pytest
1113
from faker import Faker
1214
from pydantic import ValidationError
1315
from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict
16+
from settings_library.base import _AUTO_DEFAULT_FACTORY_RESOLVES_TO_NONE_FSTRING
1417
from simcore_service_autoscaling.core.settings import (
1518
ApplicationSettings,
1619
EC2InstancesSettings,
@@ -141,11 +144,11 @@ def test_EC2_INSTANCES_ALLOWED_TYPES_valid( # noqa: N802
141144
assert settings.AUTOSCALING_EC2_INSTANCES
142145

143146

144-
@pytest.mark.xfail(
145-
reason="disabling till pydantic2 migration is complete see https://github.com/ITISFoundation/osparc-simcore/pull/6705"
146-
)
147147
def test_EC2_INSTANCES_ALLOWED_TYPES_passing_invalid_image_tags( # noqa: N802
148-
app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch, faker: Faker
148+
app_environment: EnvVarsDict,
149+
monkeypatch: pytest.MonkeyPatch,
150+
faker: Faker,
151+
caplog: pytest.LogCaptureFixture,
149152
):
150153
# passing an invalid image tag name will fail
151154
setenvs_from_dict(
@@ -161,8 +164,18 @@ def test_EC2_INSTANCES_ALLOWED_TYPES_passing_invalid_image_tags( # noqa: N802
161164
)
162165
},
163166
)
164-
with pytest.raises(ValidationError):
165-
ApplicationSettings.create_from_envs()
167+
168+
with caplog.at_level(logging.WARNING):
169+
170+
settings = ApplicationSettings.create_from_envs()
171+
assert settings.AUTOSCALING_EC2_INSTANCES is None
172+
173+
assert (
174+
_AUTO_DEFAULT_FACTORY_RESOLVES_TO_NONE_FSTRING.format(
175+
field_name="AUTOSCALING_EC2_INSTANCES"
176+
)
177+
in caplog.text
178+
)
166179

167180

168181
def test_EC2_INSTANCES_ALLOWED_TYPES_passing_valid_image_tags( # noqa: N802
@@ -199,54 +212,98 @@ def test_EC2_INSTANCES_ALLOWED_TYPES_passing_valid_image_tags( # noqa: N802
199212
]
200213

201214

202-
@pytest.mark.xfail(
203-
reason="disabling till pydantic2 migration is complete see https://github.com/ITISFoundation/osparc-simcore/pull/6705"
204-
)
215+
ENABLED_VALUE: Final = "{}"
216+
217+
205218
def test_EC2_INSTANCES_ALLOWED_TYPES_empty_not_allowed( # noqa: N802
206219
app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch
207220
):
208-
assert app_environment["AUTOSCALING_EC2_INSTANCES"] == "{}"
209-
monkeypatch.setenv("EC2_INSTANCES_ALLOWED_TYPES", "{}")
221+
assert (
222+
os.environ["AUTOSCALING_EC2_INSTANCES"] == ENABLED_VALUE
223+
) # parent field in ApplicationSettings
224+
monkeypatch.setenv(
225+
"EC2_INSTANCES_ALLOWED_TYPES", "{}"
226+
) # child field in EC2InstancesSettings
210227

211-
# test child settings
212228
with pytest.raises(ValidationError) as err_info:
229+
# test **child** EC2InstancesSettings
213230
EC2InstancesSettings.create_from_envs()
214231

215232
assert err_info.value.errors()[0]["loc"] == ("EC2_INSTANCES_ALLOWED_TYPES",)
216233

217234

218235
def test_EC2_INSTANCES_ALLOWED_TYPES_empty_not_allowed_with_main_field_env_var( # noqa: N802
219-
app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch
236+
app_environment: EnvVarsDict,
237+
monkeypatch: pytest.MonkeyPatch,
238+
caplog: pytest.LogCaptureFixture,
220239
):
221-
assert os.environ["AUTOSCALING_EC2_INSTANCES"] == "{}"
222-
monkeypatch.setenv("EC2_INSTANCES_ALLOWED_TYPES", "{}")
223-
224-
# now as part of AUTOSCALING_EC2_INSTANCES: EC2InstancesSettings | None
225-
with pytest.raises(ValidationError) as exc_before:
240+
assert (
241+
os.environ["AUTOSCALING_EC2_INSTANCES"] == ENABLED_VALUE
242+
) # parent field in ApplicationSettings
243+
monkeypatch.setenv(
244+
"EC2_INSTANCES_ALLOWED_TYPES", "{}"
245+
) # child field in EC2InstancesSettings
246+
247+
# explicit init of parent -> fails
248+
with pytest.raises(ValidationError) as exc_info:
249+
# NOTE: input captured via InitSettingsSource
226250
ApplicationSettings.create_from_envs(AUTOSCALING_EC2_INSTANCES={})
227251

228-
with pytest.raises(ValidationError) as exc_after:
229-
ApplicationSettings.create_from_envs()
252+
assert exc_info.value.error_count() == 1
253+
error = exc_info.value.errors()[0]
254+
255+
assert error["type"] == "value_error"
256+
assert error["input"] == {}
257+
assert error["loc"] == ("AUTOSCALING_EC2_INSTANCES", "EC2_INSTANCES_ALLOWED_TYPES")
258+
259+
# NOTE: input captured via EnvSettingsWithAutoDefaultSource
260+
# default env factory -> None
261+
with caplog.at_level(logging.WARNING):
230262

231-
assert exc_before.value.errors() == exc_after.value.errors()
263+
settings = ApplicationSettings.create_from_envs()
264+
assert settings.AUTOSCALING_EC2_INSTANCES is None
265+
266+
assert (
267+
_AUTO_DEFAULT_FACTORY_RESOLVES_TO_NONE_FSTRING.format(
268+
field_name="AUTOSCALING_EC2_INSTANCES"
269+
)
270+
in caplog.text
271+
)
232272

233273

234274
def test_EC2_INSTANCES_ALLOWED_TYPES_empty_not_allowed_without_main_field_env_var( # noqa: N802
235-
app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch
275+
app_environment: EnvVarsDict,
276+
monkeypatch: pytest.MonkeyPatch,
277+
caplog: pytest.LogCaptureFixture,
236278
):
237-
monkeypatch.delenv("AUTOSCALING_EC2_INSTANCES")
238-
monkeypatch.setenv("EC2_INSTANCES_ALLOWED_TYPES", "{}")
279+
assert os.environ["AUTOSCALING_EC2_INSTANCES"] == ENABLED_VALUE
280+
monkeypatch.delenv(
281+
"AUTOSCALING_EC2_INSTANCES"
282+
) # parent field in ApplicationSettings
283+
monkeypatch.setenv(
284+
"EC2_INSTANCES_ALLOWED_TYPES", "{}"
285+
) # child field in EC2InstancesSettings
239286

240287
# removing any value for AUTOSCALING_EC2_INSTANCES
241-
settings = ApplicationSettings.create_from_envs()
242-
assert settings.AUTOSCALING_EC2_INSTANCES is None
288+
caplog.clear()
289+
with caplog.at_level(logging.WARNING):
243290

291+
settings = ApplicationSettings.create_from_envs()
292+
assert settings.AUTOSCALING_EC2_INSTANCES is None
244293

245-
@pytest.mark.xfail(
246-
reason="disabling till pydantic2 migration is complete see https://github.com/ITISFoundation/osparc-simcore/pull/6705"
247-
)
248-
def test_invalid_instance_names(
249-
app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch, faker: Faker
294+
assert (
295+
_AUTO_DEFAULT_FACTORY_RESOLVES_TO_NONE_FSTRING.format(
296+
field_name="AUTOSCALING_EC2_INSTANCES"
297+
)
298+
in caplog.text
299+
)
300+
301+
302+
def test_EC2_INSTANCES_ALLOWED_TYPES_invalid_instance_names( # noqa: N802
303+
app_environment: EnvVarsDict,
304+
monkeypatch: pytest.MonkeyPatch,
305+
faker: Faker,
306+
caplog: pytest.LogCaptureFixture,
250307
):
251308
settings = ApplicationSettings.create_from_envs()
252309
assert settings.AUTOSCALING_EC2_INSTANCES
@@ -256,9 +313,24 @@ def test_invalid_instance_names(
256313
monkeypatch,
257314
{
258315
"EC2_INSTANCES_ALLOWED_TYPES": json.dumps(
259-
{faker.pystr(): {"ami_id": faker.pystr(), "pre_pull_images": []}}
316+
{
317+
faker.pystr(): {
318+
"ami_id": faker.pystr(),
319+
"pre_pull_images": [],
320+
}
321+
}
260322
)
261323
},
262324
)
263-
with pytest.raises(ValidationError):
264-
ApplicationSettings.create_from_envs()
325+
caplog.clear()
326+
with caplog.at_level(logging.WARNING):
327+
328+
settings = ApplicationSettings.create_from_envs()
329+
assert settings.AUTOSCALING_EC2_INSTANCES is None
330+
331+
assert (
332+
_AUTO_DEFAULT_FACTORY_RESOLVES_TO_NONE_FSTRING.format(
333+
field_name="AUTOSCALING_EC2_INSTANCES"
334+
)
335+
in caplog.text
336+
)

0 commit comments

Comments
 (0)