Skip to content

Commit 00148b0

Browse files
committed
Fixes deprecation of popuplate_by_name
populate_by_name usage is not recommended in v2.11+ and will be deprecated in v3. Instead, you should use the validate_by_name configuration setting. https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
1 parent 80cfef3 commit 00148b0

File tree

10 files changed

+202
-77
lines changed

10 files changed

+202
-77
lines changed

packages/models-library/src/models_library/api_schemas_webserver/_base.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Base model classes for schemas in OpenAPI specs (OAS) for this service
2+
Base model classes for schemas in OpenAPI specs (OAS) for this service
33
44
"""
55

@@ -29,9 +29,8 @@ class InputSchemaWithoutCamelCase(BaseModel):
2929
)
3030

3131

32-
class InputSchema(BaseModel):
32+
class InputSchema(InputSchemaWithoutCamelCase):
3333
model_config = ConfigDict(
34-
**InputSchemaWithoutCamelCase.model_config,
3534
alias_generator=snake_to_camel,
3635
)
3736

packages/models-library/tests/test__pydantic_models.py

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
1-
""" This test suite does not intend to re-test pydantic but rather
1+
"""This test suite does not intend to re-test pydantic but rather
22
check some "corner cases" or critical setups with pydantic model such that:
33
44
- we can ensure a given behaviour is preserved through updates
55
- document/clarify some concept
66
77
"""
88

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

1111
import pytest
1212
from common_library.json_serialization import json_dumps
1313
from models_library.projects_nodes import InputTypes, OutputTypes
1414
from models_library.projects_nodes_io import SimCoreFileLink
15-
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
15+
from models_library.utils.change_case import snake_to_camel
16+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
1617
from pydantic.types import Json
1718
from pydantic.version import version_short
1819

@@ -120,7 +121,7 @@ class Func(BaseModel):
120121
{"$ref": "#/$defs/DatCoreFileLink"},
121122
{"$ref": "#/$defs/DownloadLink"},
122123
{"type": "array", "items": {}},
123-
{"type": "object"},
124+
{"type": "object", "additionalProperties": True},
124125
],
125126
}
126127

@@ -154,7 +155,7 @@ class Func(BaseModel):
154155
MINIMAL = 2 # <--- index of the example with the minimum required fields
155156
assert SimCoreFileLink in get_args(OutputTypes)
156157
example = SimCoreFileLink.model_validate(
157-
SimCoreFileLink.model_config["json_schema_extra"]["examples"][MINIMAL]
158+
SimCoreFileLink.model_json_schema()["examples"][MINIMAL]
158159
)
159160
model = Func.model_validate(
160161
{
@@ -183,7 +184,9 @@ def test_nullable_fields_from_pydantic_v1():
183184
# SEE https://github.com/ITISFoundation/osparc-simcore/pull/6751
184185
class MyModel(BaseModel):
185186
# pydanticv1 would add a default to fields set as nullable
186-
nullable_required: str | None # <--- This was default to =None in pydantic 1 !!!
187+
nullable_required: (
188+
str | None
189+
) # <--- This was default to =None in pydantic 1 !!!
187190
nullable_required_with_hyphen: str | None = Field(default=...)
188191
nullable_optional: str | None = None
189192

@@ -209,3 +212,112 @@ class MyModel(BaseModel):
209212
data["nullable_required"] = None
210213
model = MyModel.model_validate(data)
211214
assert model.model_dump(exclude_unset=True) == data
215+
216+
217+
# BELOW some tests related to depreacated `populate_by_name` in pydantic v2.11+ !!
218+
#
219+
# https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
220+
#
221+
# `populate_by_name` usage is not recommended in v2.11+ and will be deprecated in v3. Instead, you should use the validate_by_name configuration setting.
222+
# When validate_by_name=True and validate_by_alias=True, this is strictly equivalent to the previous behavior of populate_by_name=True.
223+
# In v2.11, we also introduced a validate_by_alias setting that introduces more fine grained control for validation behavior.
224+
# Here's how you might go about using the new settings to achieve the same behavior:
225+
#
226+
227+
228+
@pytest.mark.parametrize("extra", ["ignore", "allow", "forbid"])
229+
@pytest.mark.parametrize(
230+
"validate_by_alias, validate_by_name",
231+
[
232+
# NOTE: (False, False) is not allowed: at least one has to be True!
233+
# SEE https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.validate_by_alias
234+
(False, True),
235+
(True, False),
236+
(True, True),
237+
],
238+
)
239+
def test_model_config_validate_by_alias_and_name(
240+
validate_by_alias: bool,
241+
validate_by_name: bool,
242+
extra: Literal["ignore", "allow", "forbid"],
243+
):
244+
class TestModel(BaseModel):
245+
snake_case: str | None = None
246+
247+
model_config = ConfigDict(
248+
validate_by_alias=validate_by_alias,
249+
validate_by_name=validate_by_name,
250+
extra=extra,
251+
alias_generator=snake_to_camel,
252+
)
253+
254+
assert TestModel.model_config.get("populate_by_name") is None
255+
assert TestModel.model_config.get("validate_by_alias") is validate_by_alias
256+
assert TestModel.model_config.get("validate_by_name") is validate_by_name
257+
assert TestModel.model_config.get("extra") == extra
258+
259+
if validate_by_alias is False:
260+
261+
if extra == "forbid":
262+
with pytest.raises(ValidationError):
263+
TestModel.model_validate({"snakeCase": "foo"})
264+
265+
elif extra == "ignore":
266+
model = TestModel.model_validate({"snakeCase": "foo"})
267+
assert model.snake_case is None
268+
assert model.model_dump() == {"snake_case": None}
269+
270+
elif extra == "allow":
271+
model = TestModel.model_validate({"snakeCase": "foo"})
272+
assert model.snake_case is None
273+
assert model.model_dump() == {"snake_case": None, "snakeCase": "foo"}
274+
275+
else:
276+
assert TestModel.model_validate({"snakeCase": "foo"}).snake_case == "foo"
277+
278+
if validate_by_name is False:
279+
if extra == "forbid":
280+
with pytest.raises(ValidationError):
281+
TestModel.model_validate({"snake_case": "foo"})
282+
283+
elif extra == "ignore":
284+
model = TestModel.model_validate({"snake_case": "foo"})
285+
assert model.snake_case is None
286+
assert model.model_dump() == {"snake_case": None}
287+
288+
elif extra == "allow":
289+
model = TestModel.model_validate({"snake_case": "foo"})
290+
assert model.snake_case is None
291+
assert model.model_dump() == {"snake_case": "foo"}
292+
else:
293+
assert TestModel.model_validate({"snake_case": "foo"}).snake_case == "foo"
294+
295+
296+
@pytest.mark.parametrize("populate_by_name", [True, False])
297+
def test_model_config_populate_by_name(populate_by_name: bool):
298+
# SEE https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
299+
class TestModel(BaseModel):
300+
snake_case: str | None = None
301+
302+
model_config = ConfigDict(
303+
populate_by_name=populate_by_name,
304+
extra="forbid", # easier to check the effect of populate_by_name!
305+
alias_generator=snake_to_camel,
306+
)
307+
308+
# checks how they are set
309+
assert TestModel.model_config.get("populate_by_name") is populate_by_name
310+
assert TestModel.model_config.get("extra") == "forbid"
311+
312+
# NOTE how defaults work with populate_by_name!!
313+
assert TestModel.model_config.get("validate_by_name") == populate_by_name
314+
assert TestModel.model_config.get("validate_by_alias") is True # Default
315+
316+
# validate_by_alias BEHAVIUOR defaults to True
317+
TestModel.model_validate({"snakeCase": "foo"})
318+
319+
if populate_by_name:
320+
assert TestModel.model_validate({"snake_case": "foo"}).snake_case == "foo"
321+
else:
322+
with pytest.raises(ValidationError):
323+
TestModel.model_validate({"snake_case": "foo"})

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

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
from functools import cached_property
3-
from typing import Any, Final, get_origin
3+
from typing import Any, Final
44

55
from common_library.pydantic_fields_extension import get_type, is_literal, is_nullable
66
from pydantic import ValidationInfo, field_validator
@@ -15,9 +15,9 @@
1515

1616
_logger = logging.getLogger(__name__)
1717

18-
_AUTO_DEFAULT_FACTORY_RESOLVES_TO_NONE_FSTRING: Final[
19-
str
20-
] = "{field_name} auto_default_from_env unresolved, defaulting to None"
18+
_AUTO_DEFAULT_FACTORY_RESOLVES_TO_NONE_FSTRING: Final[str] = (
19+
"{field_name} auto_default_from_env unresolved, defaulting to None"
20+
)
2121

2222

2323
class DefaultFromEnvFactoryError(ValueError):
@@ -119,11 +119,13 @@ def _parse_none(cls, v, info: ValidationInfo):
119119

120120
model_config = SettingsConfigDict(
121121
case_sensitive=True, # All must be capitalized
122-
extra="forbid",
122+
env_parse_none_str="null",
123+
extra="ignore",
123124
frozen=True,
124-
validate_default=True,
125125
ignored_types=(cached_property,),
126-
env_parse_none_str="null",
126+
validate_by_alias=True,
127+
validate_by_name=True,
128+
validate_default=True,
127129
)
128130

129131
@classmethod
@@ -133,28 +135,15 @@ def __pydantic_init_subclass__(cls, **kwargs: Any):
133135
for name, field in cls.model_fields.items():
134136
auto_default_from_env = _is_auto_default_from_env_enabled(field)
135137
field_type = get_type(field)
136-
137-
# Avoids issubclass raising TypeError. SEE test_issubclass_type_error_with_pydantic_models
138-
is_not_composed = (
139-
get_origin(field_type) is None
140-
) # is not composed as dict[str, Any] or Generic[Base]
141138
is_not_literal = not is_literal(field)
142139

143-
if (
144-
is_not_literal
145-
and is_not_composed
146-
and issubclass(field_type, BaseCustomSettings)
147-
):
140+
if is_not_literal and issubclass(field_type, BaseCustomSettings):
148141
if auto_default_from_env:
149142
# Builds a default factory `Field(default_factory=create_settings_from_env(field))`
150143
field.default_factory = _create_settings_from_env(name, field)
151144
field.default = None
152145

153-
elif (
154-
is_not_literal
155-
and is_not_composed
156-
and issubclass(field_type, BaseSettings)
157-
):
146+
elif is_not_literal and issubclass(field_type, BaseSettings):
158147
msg = f"{cls}.{name} of type {field_type} must inherit from BaseCustomSettings"
159148
raise ValueError(msg)
160149

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

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
ValidationInfo,
1111
field_validator,
1212
)
13+
from pydantic.config import JsonDict
1314
from pydantic_settings import SettingsConfigDict
1415

1516
from .base import BaseCustomSettings
@@ -41,7 +42,6 @@ class PostgresSettings(BaseCustomSettings):
4142
Field(
4243
description="Name of the application connecting the postgres database, will default to use the host hostname (hostname on linux)",
4344
validation_alias=AliasChoices(
44-
"POSTGRES_CLIENT_NAME",
4545
# This is useful when running inside a docker container, then the hostname is set each client gets a different name
4646
"HOST",
4747
"HOSTNAME",
@@ -103,17 +103,34 @@ def _update_query(self, uri: str) -> str:
103103
return urlunparse(parsed_uri._replace(query=updated_query))
104104
return uri
105105

106-
model_config = SettingsConfigDict(
107-
json_schema_extra={
108-
"examples": [
109-
# minimal required
110-
{
111-
"POSTGRES_HOST": "localhost",
112-
"POSTGRES_PORT": "5432",
113-
"POSTGRES_USER": "usr",
114-
"POSTGRES_PASSWORD": "secret",
115-
"POSTGRES_DB": "db",
116-
}
117-
],
118-
}
119-
)
106+
@staticmethod
107+
def _update_json_schema_extra(schema: JsonDict) -> None:
108+
schema.update(
109+
{
110+
"examples": [
111+
# minimal required
112+
{
113+
"POSTGRES_HOST": "localhost",
114+
"POSTGRES_PORT": "5432",
115+
"POSTGRES_USER": "usr",
116+
"POSTGRES_PASSWORD": "secret",
117+
"POSTGRES_DB": "db",
118+
},
119+
# full example
120+
{
121+
"POSTGRES_HOST": "localhost",
122+
"POSTGRES_PORT": "5432",
123+
"POSTGRES_USER": "usr",
124+
"POSTGRES_PASSWORD": "secret",
125+
"POSTGRES_DB": "db",
126+
"POSTGRES_MINSIZE": 1,
127+
"POSTGRES_MAXSIZE": 50,
128+
"POSTGRES_CLIENT_NAME": "my_app", # first-choice
129+
"HOST": "should be ignored",
130+
"HOST_NAME": "should be ignored",
131+
},
132+
],
133+
}
134+
)
135+
136+
model_config = SettingsConfigDict(json_schema_extra=_update_json_schema_extra)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ def print_as_envfile(
2626
**pydantic_export_options,
2727
):
2828
exclude_unset = pydantic_export_options.get("exclude_unset", False)
29+
settings_cls = settings_obj.__class__
2930

30-
for name, field in settings_obj.model_fields.items():
31+
for name, field in settings_cls.model_fields.items():
3132
auto_default_from_env = (
3233
field.json_schema_extra is not None
3334
and field.json_schema_extra.get("auto_default_from_env", False)

packages/settings-library/tests/test__models_examples.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ def test_all_settings_library_models_config_examples(
1717
model_cls: type[BaseModel], example_name: str, example_data: Any
1818
):
1919

20+
assert (
21+
model_cls.model_config.get("validate_by_alias") is True
22+
), f"validate_by_alias must be enabled in {model_cls}"
23+
assert (
24+
model_cls.model_config.get("validate_by_name") is True
25+
), f"validate_by_name must be enabled in {model_cls}"
26+
2027
assert_validation_model(
2128
model_cls, example_name=example_name, example_data=example_data
2229
)

packages/settings-library/tests/test_base.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -335,28 +335,34 @@ class SettingsClassExt(SettingsClass):
335335
}
336336

337337

338-
def test_issubclass_type_error_with_pydantic_models():
339-
# There is a problem
340-
#
341-
# TypeError: issubclass() arg 1 must be a class
342-
#
343-
# SEE https://github.com/pydantic/pydantic/issues/545
344-
#
345-
# >> issubclass(dict, BaseSettings)
346-
# False
347-
# >> issubclass(dict[str, str], BaseSettings)
348-
# Traceback (most recent call last):
349-
# File "<string>", line 1, in <module>
350-
# File "/home/crespo/.pyenv/versions/3.10.13/lib/python3.10/abc.py", line 123, in __subclasscheck__
351-
# return _abc_subclasscheck(cls, subclass)
352-
# TypeError: issubclass() arg 1 must be a class
353-
#
338+
def test_fixed_issubclass_type_error_with_pydantic_models():
354339

355340
assert not issubclass(dict, BaseSettings)
356-
357-
# NOTE: this should be fixed by pydantic at some point. When this happens, this test will fail
358-
with pytest.raises(TypeError):
359-
issubclass(dict[str, str], BaseSettings)
341+
assert not issubclass(
342+
# FIXED with
343+
#
344+
# pydantic 2.11.7
345+
# pydantic_core 2.33.2
346+
# pydantic-extra-types 2.10.5
347+
# pydantic-settings 2.7.0
348+
#
349+
#
350+
# TypeError: issubclass() arg 1 must be a class
351+
#
352+
# SEE https://github.com/pydantic/pydantic/issues/545
353+
#
354+
# >> issubclass(dict, BaseSettings)
355+
# False
356+
# >> issubclass(dict[str, str], BaseSettings)
357+
# Traceback (most recent call last):
358+
# File "<string>", line 1, in <module>
359+
# File "/home/crespo/.pyenv/versions/3.10.13/lib/python3.10/abc.py", line 123, in __subclasscheck__
360+
# return _abc_subclasscheck(cls, subclass)
361+
# TypeError: issubclass() arg 1 must be a class
362+
#
363+
dict[str, str],
364+
BaseSettings,
365+
)
360366

361367
# here reproduces the problem with our settings that ANE and PC had
362368
class SettingsClassThatFailed(BaseCustomSettings):

0 commit comments

Comments
 (0)