Skip to content

Commit 5d708b6

Browse files
Merge branch 'is4481/upgrade-libs' into is4481/upgrade-api-server
2 parents cea85f8 + 7021117 commit 5d708b6

File tree

13 files changed

+164
-99
lines changed

13 files changed

+164
-99
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from types import UnionType
2+
from typing import Any, Literal, get_args, get_origin
3+
4+
from pydantic.fields import FieldInfo
5+
6+
7+
def get_type(info: FieldInfo) -> Any:
8+
field_type = info.annotation
9+
if args := get_args(info.annotation):
10+
field_type = next(a for a in args if a != type(None))
11+
return field_type
12+
13+
14+
def is_literal(info: FieldInfo) -> bool:
15+
origin = get_origin(info.annotation)
16+
return origin is Literal
17+
18+
19+
def is_nullable(info: FieldInfo) -> bool:
20+
origin = get_origin(info.annotation) # X | None or Optional[X] will return Union
21+
if origin is UnionType:
22+
return any(x in get_args(info.annotation) for x in (type(None), Any))
23+
return False
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from typing import Any
2+
3+
from models_library.utils.pydantic_fields_extension import get_type
4+
from pydantic import BaseModel, SecretStr
5+
6+
7+
def model_dump_with_secrets(
8+
settings_obj: BaseModel, show_secrets: bool, **pydantic_export_options
9+
) -> dict[str, Any]:
10+
data = settings_obj.model_dump(**pydantic_export_options)
11+
12+
for field_name in settings_obj.model_fields:
13+
if field_name not in data:
14+
continue
15+
16+
field_data = data[field_name]
17+
18+
if isinstance(field_data, SecretStr):
19+
if show_secrets:
20+
data[field_name] = field_data.get_secret_value() # Expose the raw value
21+
else:
22+
data[field_name] = str(field_data)
23+
elif isinstance(field_data, dict):
24+
field_type = get_type(settings_obj.model_fields[field_name])
25+
if issubclass(field_type, BaseModel):
26+
data[field_name] = model_dump_with_secrets(
27+
field_type.model_validate(field_data),
28+
show_secrets,
29+
**pydantic_export_options,
30+
)
31+
32+
return data

packages/service-library/tests/deferred_tasks/test_deferred_tasks.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import psutil
1717
import pytest
1818
from aiohttp.test_utils import unused_port
19+
from models_library.utils.json_serialization import json_dumps
20+
from models_library.utils.serialization import model_dump_with_secrets
1921
from pydantic import NonNegativeFloat, NonNegativeInt
2022
from pytest_mock import MockerFixture
2123
from servicelib import redis as servicelib_redis
@@ -24,7 +26,6 @@
2426
from servicelib.sequences_utils import partition_gen
2527
from settings_library.rabbit import RabbitSettings
2628
from settings_library.redis import RedisSettings
27-
from settings_library.utils_encoders import create_json_encoder_wo_secrets
2829
from tenacity.asyncio import AsyncRetrying
2930
from tenacity.retry import retry_if_exception_type
3031
from tenacity.stop import stop_after_delay
@@ -123,7 +124,6 @@ async def _tcp_command(
123124

124125
def _get_serialization_options() -> dict[str, Any]:
125126
return {
126-
"encoder": create_json_encoder_wo_secrets(RabbitSettings),
127127
"exclude_defaults": True,
128128
"exclude_none": True,
129129
"exclude_unset": True,
@@ -158,8 +158,20 @@ async def start(self) -> None:
158158
response = await _tcp_command(
159159
"init-context",
160160
{
161-
"rabbit": self.rabbit_service.model_dump_json(**_get_serialization_options()),
162-
"redis": self.redis_service.model_dump_json(**_get_serialization_options()),
161+
"rabbit": json_dumps(
162+
model_dump_with_secrets(
163+
self.rabbit_service,
164+
show_secrets=True,
165+
**_get_serialization_options(),
166+
)
167+
),
168+
"redis": json_dumps(
169+
model_dump_with_secrets(
170+
self.redis_service,
171+
show_secrets=True,
172+
**_get_serialization_options(),
173+
)
174+
),
163175
"max-workers": self.max_workers,
164176
},
165177
port=self.remote_process.port,

packages/settings-library/requirements/ci.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
# installs this repo's packages
1414
pytest-simcore @ ../pytest-simcore
15+
simcore-models-library @ ../models-library
1516

1617
# current module
1718
simcore-settings-library @ .

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

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import logging
22
from functools import cached_property
3-
from types import UnionType
4-
from typing import Any, Final, Literal, get_args, get_origin
3+
from typing import Any, Final, get_origin
54

5+
from models_library.utils.pydantic_fields_extension import (
6+
get_type,
7+
is_literal,
8+
is_nullable,
9+
)
610
from pydantic import ValidationInfo, field_validator
711
from pydantic.fields import FieldInfo
812
from pydantic_core import PydanticUndefined, ValidationError
@@ -21,38 +25,19 @@ def __init__(self, errors):
2125
self.errors = errors
2226

2327

24-
def _allows_none(info: FieldInfo) -> bool:
25-
origin = get_origin(info.annotation) # X | None or Optional[X] will return Union
26-
if origin is UnionType:
27-
return any(x in get_args(info.annotation) for x in (type(None), Any))
28-
return False
29-
30-
31-
def _get_type(info: FieldInfo) -> Any:
32-
field_type = info.annotation
33-
if args := get_args(info.annotation):
34-
field_type = next(a for a in args if a != type(None))
35-
return field_type
36-
37-
38-
def _is_literal(info: FieldInfo) -> bool:
39-
origin = get_origin(info.annotation)
40-
return origin is Literal
41-
42-
4328
def _create_settings_from_env(field_name: str, info: FieldInfo):
4429
# NOTE: Cannot pass only field.type_ because @prepare_field (when this function is called)
4530
# this value is still not resolved (field.type_ at that moment has a weak_ref).
4631
# Therefore we keep the entire 'field' but MUST be treated here as read-only
4732

4833
def _default_factory():
4934
"""Creates default from sub-settings or None (if nullable)"""
50-
field_settings_cls = _get_type(info)
35+
field_settings_cls = get_type(info)
5136
try:
5237
return field_settings_cls()
5338

5439
except ValidationError as err:
55-
if _allows_none(info):
40+
if is_nullable(info):
5641
# e.g. Optional[PostgresSettings] would warn if defaults to None
5742
_logger.warning(
5843
_DEFAULTS_TO_NONE_MSG,
@@ -80,9 +65,9 @@ def _parse_none(cls, v, info: ValidationInfo):
8065
# WARNING: In nullable fields, envs equal to null or none are parsed as None !!
8166
if (
8267
info.field_name
83-
and _allows_none(cls.model_fields[info.field_name])
68+
and is_nullable(cls.model_fields[info.field_name])
8469
and isinstance(v, str)
85-
and v.lower() in ("null", "none")
70+
and v.lower() in ("none",)
8671
):
8772
return None
8873
return v
@@ -93,6 +78,7 @@ def _parse_none(cls, v, info: ValidationInfo):
9378
frozen=True,
9479
validate_default=True,
9580
ignored_types=(cached_property,),
81+
env_parse_none_str="null",
9682
)
9783

9884
@classmethod
@@ -106,13 +92,13 @@ def __pydantic_init_subclass__(cls, **kwargs: Any):
10692
"auto_default_from_env", False
10793
)
10894
)
109-
field_type = _get_type(field)
95+
field_type = get_type(field)
11096

11197
# Avoids issubclass raising TypeError. SEE test_issubclass_type_error_with_pydantic_models
11298
is_not_composed = (
11399
get_origin(field_type) is None
114100
) # is not composed as dict[str, Any] or Generic[Base]
115-
is_not_literal = not _is_literal(field)
101+
is_not_literal = not is_literal(field)
116102

117103
if (
118104
is_not_literal

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@
88

99
from typing import Annotated, TypeAlias
1010

11-
from pydantic import Field, StringConstraints, TypeAdapter
11+
from pydantic import BeforeValidator, Field, StringConstraints, TypeAdapter
1212

1313
from .base import BaseCustomSettings
1414

15-
1615
# Based on https://countrycode.org/
17-
CountryCodeStr: TypeAlias = Annotated[str, StringConstraints(strip_whitespace=True, pattern=r"^\d{1,4}")]
16+
CountryCodeStr: TypeAlias = Annotated[
17+
str,
18+
BeforeValidator(str),
19+
StringConstraints(strip_whitespace=True, pattern=r"^\d{1,4}"),
20+
]
1821

1922

2023
class TwilioSettings(BaseCustomSettings):

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

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
import os
34
from collections.abc import Callable
@@ -6,12 +7,12 @@
67

78
import rich
89
import typer
10+
from models_library.utils.serialization import model_dump_with_secrets
911
from pydantic import ValidationError
1012
from pydantic_settings import BaseSettings
1113

1214
from ._constants import HEADER_STR
1315
from .base import BaseCustomSettings
14-
from .utils_encoders import create_json_encoder_wo_secrets
1516

1617

1718
def print_as_envfile(
@@ -25,8 +26,9 @@ def print_as_envfile(
2526
exclude_unset = pydantic_export_options.get("exclude_unset", False)
2627

2728
for name, field in settings_obj.model_fields.items():
28-
auto_default_from_env = field.json_schema_extra is not None and field.json_schema_extra.get(
29-
"auto_default_from_env", False # type: ignore[union-attr]
29+
auto_default_from_env = (
30+
field.json_schema_extra is not None
31+
and field.json_schema_extra.get("auto_default_from_env", False)
3032
)
3133

3234
value = getattr(settings_obj, name)
@@ -39,7 +41,11 @@ def print_as_envfile(
3941

4042
if isinstance(value, BaseSettings):
4143
if compact:
42-
value = f"'{value.model_dump_json(**pydantic_export_options)}'" # flat
44+
value = json.dumps(
45+
model_dump_with_secrets(
46+
value, show_secrets=show_secrets, **pydantic_export_options
47+
)
48+
) # flat
4349
else:
4450
if verbose:
4551
typer.echo(f"\n# --- {name} --- ")
@@ -61,9 +67,16 @@ def print_as_envfile(
6167
typer.echo(f"{name}={value}")
6268

6369

64-
def print_as_json(settings_obj, *, compact=False, **pydantic_export_options):
70+
def print_as_json(
71+
settings_obj, *, compact=False, show_secrets, **pydantic_export_options
72+
):
6573
typer.echo(
66-
settings_obj.model_dump_json(indent=None if compact else 2, **pydantic_export_options)
74+
json.dumps(
75+
model_dump_with_secrets(
76+
settings_obj, show_secrets=show_secrets, **pydantic_export_options
77+
),
78+
indent=None if compact else 2,
79+
)
6780
)
6881

6982

@@ -127,14 +140,14 @@ def settings(
127140
raise
128141

129142
pydantic_export_options: dict[str, Any] = {"exclude_unset": exclude_unset}
130-
if show_secrets:
131-
# NOTE: this option is for json-only
132-
pydantic_export_options["encoder"] = create_json_encoder_wo_secrets(
133-
settings_cls
134-
)
135143

136144
if as_json:
137-
print_as_json(settings_obj, compact=compact, **pydantic_export_options)
145+
print_as_json(
146+
settings_obj,
147+
compact=compact,
148+
show_secrets=show_secrets,
149+
**pydantic_export_options,
150+
)
138151
else:
139152
print_as_envfile(
140153
settings_obj,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def _compose_url(
115115

116116
# post process parts dict
117117
kwargs = {}
118-
for k, v in parts.items():
118+
for k, v in parts.items(): # type: ignore[assignment]
119119
if isinstance(v, SecretStr):
120120
value = v.get_secret_value()
121121
else:

0 commit comments

Comments
 (0)