Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upgrade pydantic #6258

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions packages/pytest-simcore/src/pytest_simcore/pydantic_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,21 +82,19 @@ def _is_model_cls(obj) -> bool:
for model_name, model_cls in inspect.getmembers(module, _is_model_cls):
assert model_name # nosec
if (
(config_cls := model_cls.Config)
and inspect.isclass(config_cls)
and is_strict_inner(model_cls, config_cls)
and (schema_extra := getattr(config_cls, "schema_extra", {}))
and isinstance(schema_extra, dict)
(config_dict := model_cls.model_config)
and (json_schema_extra := config_dict.get("json_schema_extra", {}))
and isinstance(json_schema_extra, dict)
):
if "example" in schema_extra:
if "example" in json_schema_extra:
yield ModelExample(
model_cls=model_cls,
example_name="example",
example_data=schema_extra["example"],
example_data=json_schema_extra["example"],
)

elif "examples" in schema_extra:
for index, example in enumerate(schema_extra["examples"]):
elif "examples" in json_schema_extra:
for index, example in enumerate(json_schema_extra["examples"]):
yield ModelExample(
model_cls=model_cls,
example_name=f"examples_{index}",
Expand Down
4 changes: 2 additions & 2 deletions packages/settings-library/requirements/_base.in
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
#
--constraint ../../../requirements/constraints.txt

pydantic>=1.9

pydantic
pydantic-settings

# extra
rich
Expand Down
12 changes: 11 additions & 1 deletion packages/settings-library/requirements/_base.txt
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
annotated-types==0.7.0
# via pydantic
click==8.1.7
# via typer
markdown-it-py==3.0.0
# via rich
mdurl==0.1.2
# via markdown-it-py
pydantic==1.10.17
pydantic==2.8.2
# via
# -c requirements/../../../requirements/constraints.txt
# -r requirements/_base.in
# pydantic-settings
pydantic-core==2.20.1
# via pydantic
pydantic-settings==2.4.0
# via -r requirements/_base.in
pygments==2.18.0
# via rich
python-dotenv==1.0.1
# via pydantic-settings
rich==13.7.1
# via
# -r requirements/_base.in
Expand All @@ -21,4 +30,5 @@ typer==0.12.4
typing-extensions==4.12.2
# via
# pydantic
# pydantic-core
# typer
10 changes: 3 additions & 7 deletions packages/settings-library/requirements/_test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ coverage==7.6.1
# via
# -r requirements/_test.in
# pytest-cov
exceptiongroup==1.2.2
# via pytest
faker==27.0.0
# via -r requirements/_test.in
iniconfig==2.0.0
Expand Down Expand Up @@ -34,12 +32,10 @@ pytest-sugar==1.0.0
python-dateutil==2.9.0.post0
# via faker
python-dotenv==1.0.1
# via -r requirements/_test.in
# via
# -c requirements/_base.txt
# -r requirements/_test.in
six==1.16.0
# via python-dateutil
termcolor==2.4.0
# via pytest-sugar
tomli==2.0.1
# via
# coverage
# pytest
10 changes: 0 additions & 10 deletions packages/settings-library/requirements/_tools.txt
Original file line number Diff line number Diff line change
Expand Up @@ -67,21 +67,11 @@ ruff==0.6.1
# via -r requirements/../../../requirements/devenv.txt
setuptools==73.0.1
# via pip-tools
tomli==2.0.1
# via
# -c requirements/_test.txt
# black
# build
# mypy
# pip-tools
# pylint
tomlkit==0.13.2
# via pylint
typing-extensions==4.12.2
# via
# -c requirements/_base.txt
# astroid
# black
# mypy
virtualenv==20.26.3
# via pre-commit
Expand Down
148 changes: 74 additions & 74 deletions packages/settings-library/src/settings_library/base.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
from functools import cached_property
import logging
from collections.abc import Sequence
from functools import cached_property
from typing import Final, get_args, get_origin

from pydantic import (
BaseConfig,
BaseSettings,
ConfigError,
Extra,
ValidationError,
validator,
)
from pydantic.error_wrappers import ErrorList, ErrorWrapper
from pydantic.fields import ModelField, Undefined
from pydantic.typing import is_literal_type
#from functools import cached_property
from typing import Final

from pydantic import ConfigDict, ValidationError, ValidationInfo, field_validator
#from pydantic.error_wrappers import ErrorList, ErrorWrapper
#from pydantic.typing import is_literal_type
from pydantic_settings import BaseSettings

_logger = logging.getLogger(__name__)

Expand All @@ -22,11 +16,11 @@
] = "%s auto_default_from_env unresolved, defaulting to None"


class DefaultFromEnvFactoryError(ValidationError):
class DefaultFromEnvFactoryError(ValueError):
...


def create_settings_from_env(field: ModelField):
def create_settings_from_env(field):
# NOTE: Cannot pass only field.type_ because @prepare_field (when this function is called)
# this value is still not resolved (field.type_ at that moment has a weak_ref).
# Therefore we keep the entire 'field' but MUST be treated here as read-only
Expand All @@ -46,9 +40,9 @@ def _default_factory():
)
return None

def _prepend_field_name(ee: ErrorList):
if isinstance(ee, ErrorWrapper):
return ErrorWrapper(ee.exc, (field.name, *ee.loc_tuple()))
def _prepend_field_name(ee):
if isinstance(ee):
return ValidationError(ee.exc, (field.name, *ee.loc_tuple()))
assert isinstance(ee, Sequence) # nosec
return [_prepend_field_name(e) for e in ee]

Expand All @@ -70,66 +64,72 @@ class BaseCustomSettings(BaseSettings):
SEE tests for details.
"""

@validator("*", pre=True)
model_config = ConfigDict(
case_sensitive = True, # All must be capitalized
extra = "forbid",
allow_mutation = False,
frozen = True,
validate_all = True,
keep_untouched = (cached_property,)
)

@field_validator("*", mode="before")
@classmethod
def parse_none(cls, v, field: ModelField):
def parse_none(cls, v):
# WARNING: In nullable fields, envs equal to null or none are parsed as None !!
if field.allow_none and isinstance(v, str) and v.lower() in ("null", "none"):
if isinstance(v, str) and v.lower() in ("null", "none"):
return None
return v

class Config(BaseConfig):
case_sensitive = True # All must be capitalized
extra = Extra.forbid
allow_mutation = False
frozen = True
validate_all = True
keep_untouched = (cached_property,)

@classmethod
def prepare_field(cls, field: ModelField) -> None:
super().prepare_field(field)

auto_default_from_env = field.field_info.extra.get(
"auto_default_from_env", False
)

field_type = field.type_
if args := get_args(field_type):
field_type = next(a for a in args if a != type(None))

# Avoids issubclass raising TypeError. SEE test_issubclass_type_error_with_pydantic_models
is_not_composed = (
get_origin(field_type) is None
) # is not composed as dict[str, Any] or Generic[Base]
# avoid literals raising TypeError
is_not_literal = is_literal_type(field.type_) is False

if (
is_not_literal
and is_not_composed
and issubclass(field_type, BaseCustomSettings)
):
if auto_default_from_env:
assert field.field_info.default is Undefined
assert field.field_info.default_factory is None

# Transform it into something like `Field(default_factory=create_settings_from_env(field))`
field.default_factory = create_settings_from_env(field)
field.default = None
field.required = False # has a default now

elif (
is_not_literal
and is_not_composed
and issubclass(field_type, BaseSettings)
):
msg = f"{cls}.{field.name} of type {field_type} must inherit from BaseCustomSettings"
raise ConfigError(msg)

elif auto_default_from_env:
msg = f"auto_default_from_env=True can only be used in BaseCustomSettings subclassesbut field {cls}.{field.name} is {field_type} "
raise ConfigError(msg)
# # TODO[pydantic]: The `Config` class inherits from another class, please create the `model_config` manually.
# # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information.
# class Config(BaseConfig):


# @classmethod
# def prepare_field(cls, field: ModelField) -> None:
# super().prepare_field(field)

# auto_default_from_env = field.field_info.extra.get(
# "auto_default_from_env", False
# )

# field_type = field.type_
# if args := get_args(field_type):
# field_type = next(a for a in args if a != type(None))

# # Avoids issubclass raising TypeError. SEE test_issubclass_type_error_with_pydantic_models
# is_not_composed = (
# get_origin(field_type) is None
# ) # is not composed as dict[str, Any] or Generic[Base]
# # avoid literals raising TypeError
# is_not_literal = is_literal_type(field.type_) is False

# if (
# is_not_literal
# and is_not_composed
# and issubclass(field_type, BaseCustomSettings)
# ):
# if auto_default_from_env:
# assert field.field_info.default is Undefined
# assert field.field_info.default_factory is None

# # Transform it into something like `Field(default_factory=create_settings_from_env(field))`
# field.default_factory = create_settings_from_env(field)
# field.default = None
# field.required = False # has a default now

# elif (
# is_not_literal
# and is_not_composed
# and issubclass(field_type, BaseSettings)
# ):
# msg = f"{cls}.{field.name} of type {field_type} must inherit from BaseCustomSettings"
# raise ConfigError(msg)

# elif auto_default_from_env:
# msg = f"auto_default_from_env=True can only be used in BaseCustomSettings subclassesbut field {cls}.{field.name} is {field_type} "
# raise ConfigError(msg)

@classmethod
def create_from_envs(cls, **overrides):
Expand Down
18 changes: 7 additions & 11 deletions packages/settings-library/src/settings_library/basic_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,15 @@
import re
from enum import Enum

from pydantic import ConstrainedInt, ConstrainedStr

from pydantic import Field, StringConstraints
from typing_extensions import Annotated

# port number range
class PortInt(ConstrainedInt):
gt = 0
lt = 65535
PortInt = Annotated[int, Field(gt=0, lt=65535)]


# e.g. 'v5'
class VersionTag(ConstrainedStr):
regex = re.compile(r"^v\d$")
VersionTag = Annotated[str, StringConstraints(pattern=re.compile(r"^v\d$"))]


class LogLevel(str, Enum):
Expand Down Expand Up @@ -55,7 +52,6 @@ class BuildTargetEnum(str, Enum):

# non-empty bounded string used as identifier
# e.g. "123" or "name_123" or "fa327c73-52d8-462a-9267-84eeaf0f90e3" but NOT ""
class IDStr(ConstrainedStr):
strip_whitespace = True
min_length = 1
max_length = 50
IDStr = Annotated[
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)
]
4 changes: 2 additions & 2 deletions packages/settings-library/src/settings_library/catalog.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from functools import cached_property

from pydantic import parse_obj_as
from pydantic import TypeAdapter
from settings_library.base import BaseCustomSettings
from settings_library.basic_types import PortInt, VersionTag
from settings_library.utils_service import (
Expand All @@ -13,7 +13,7 @@
class CatalogSettings(BaseCustomSettings, MixinServiceSettings):
CATALOG_HOST: str = "catalog"
CATALOG_PORT: PortInt = DEFAULT_FASTAPI_PORT
CATALOG_VTAG: VersionTag = parse_obj_as(VersionTag, "v0")
CATALOG_VTAG: VersionTag = TypeAdapter(VersionTag).validate_strings("v0")

@cached_property
def api_base_url(self) -> str:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from pydantic import ByteSize, NonNegativeInt, validator
from pydantic.tools import parse_raw_as
from pydantic import ByteSize, NonNegativeInt, TypeAdapter, field_validator
from settings_library.base import BaseCustomSettings

from ._constants import GB
Expand All @@ -10,19 +9,20 @@

class ComputationalServices(BaseCustomSettings):
DEFAULT_MAX_NANO_CPUS: NonNegativeInt = _DEFAULT_MAX_NANO_CPUS_VALUE
DEFAULT_MAX_MEMORY: ByteSize = parse_raw_as(
ByteSize, f"{_DEFAULT_MAX_MEMORY_VALUE}"
DEFAULT_MAX_MEMORY: ByteSize = TypeAdapter(ByteSize).validate_strings(
f"{_DEFAULT_MAX_MEMORY_VALUE}"
)

DEFAULT_RUNTIME_TIMEOUT: NonNegativeInt = 0

@validator("DEFAULT_MAX_NANO_CPUS", pre=True)
@field_validator("DEFAULT_MAX_NANO_CPUS", mode="before")
@classmethod
def _set_default_cpus_if_negative(cls, v):
if v is None or v == "" or int(v) <= 0:
v = _DEFAULT_MAX_NANO_CPUS_VALUE
return v

@validator("DEFAULT_MAX_MEMORY", pre=True)
@field_validator("DEFAULT_MAX_MEMORY", mode="before")
@classmethod
def _set_default_memory_if_negative(cls, v):
if v is None or v == "" or int(v) <= 0:
Expand Down
Loading
Loading