Skip to content

Commit 6c98b8f

Browse files
committed
Merge branch 'master' into add_funccoll_filter
2 parents e22c9f9 + 8362d80 commit 6c98b8f

File tree

8 files changed

+187
-40
lines changed

8 files changed

+187
-40
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Prompt
2+
3+
```
4+
Please convert all pydantic model fields that use `Field()` with default values to use the Annotated pattern instead.
5+
Follow these guidelines:
6+
7+
1. Move default values outside of `Field()` like this: `field_name: Annotated[field_type, Field(description="")] = default_value`.
8+
2. Keep all other parameters like validation_alias and descriptions inside `Field()`.
9+
3. For fields using default_factory, keep that parameter as is in the `Field()` constructor, but set the default value outside to DEFAULT_FACTORY from common_library.basic_types. Example: `field_name: Annotated[dict_type, Field(default_factory=dict)] = DEFAULT_FACTORY`.
10+
4. Add the import: `from common_library.basic_types import DEFAULT_FACTORY` if it's not already present.
11+
5. If `Field()` has no parameters (empty), don't use Annotated at all. Just use: `field_name: field_type = default_value`.
12+
6. Leave any model validations, `model_config` settings, and `field_validators` untouched.
13+
```
14+
## Examples
15+
16+
### Before:
17+
18+
```python
19+
from pydantic import BaseModel, Field
20+
21+
class UserModel(BaseModel):
22+
name: str = Field(default="Anonymous", description="User's display name")
23+
age: int = Field(default=18, ge=0, lt=120)
24+
tags: list[str] = Field(default_factory=list, description="User tags")
25+
metadata: dict[str, str] = Field(default_factory=dict)
26+
is_active: bool = Field(default=True)
27+
```
28+
29+
- **After**
30+
31+
```python
32+
from typing import Annotated
33+
from pydantic import BaseModel, Field
34+
from common_library.basic_types import DEFAULT_FACTORY
35+
36+
class UserModel(BaseModel):
37+
name: Annotated[str, Field(description="User's display name")] = "Anonymous"
38+
age: Annotated[int, Field(ge=0, lt=120)] = 18
39+
tags: Annotated[list[str], Field(default_factory=list, description="User tags")] = DEFAULT_FACTORY
40+
metadata: Annotated[dict[str, str], Field(default_factory=dict)] = DEFAULT_FACTORY
41+
is_active: bool = True
42+
```
43+
44+
## Another Example with Complex Fields
45+
46+
### Before:
47+
48+
```python
49+
from pydantic import BaseModel, Field, field_validator
50+
from datetime import datetime
51+
52+
class ProjectModel(BaseModel):
53+
id: str = Field(default_factory=uuid.uuid4, description="Unique project identifier")
54+
name: str = Field(default="Untitled Project", min_length=3, max_length=50)
55+
created_at: datetime = Field(default_factory=datetime.now)
56+
config: dict = Field(default={"version": "1.0", "theme": "default"})
57+
58+
@field_validator("name")
59+
def validate_name(cls, v):
60+
if v.isdigit():
61+
raise ValueError("Name cannot be only digits")
62+
return v
63+
```
64+
65+
### After:
66+
67+
```python
68+
from typing import Annotated
69+
from pydantic import BaseModel, Field, field_validator
70+
from datetime import datetime
71+
from common_library.basic_types import DEFAULT_FACTORY
72+
73+
class ProjectModel(BaseModel):
74+
id: Annotated[str, Field(default_factory=uuid.uuid4, description="Unique project identifier")] = DEFAULT_FACTORY
75+
name: Annotated[str, Field(min_length=3, max_length=50)] = "Untitled Project"
76+
created_at: Annotated[datetime, Field(default_factory=datetime.now)] = DEFAULT_FACTORY
77+
config: dict = {"version": "1.0", "theme": "default"}
78+
79+
@field_validator("name")
80+
def validate_name(cls, v):
81+
if v.isdigit():
82+
raise ValueError("Name cannot be only digits")
83+
return v
84+
```

packages/models-library/src/models_library/basic_types.py

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from re import Pattern
44
from typing import Annotated, ClassVar, Final, TypeAlias
55

6+
import annotated_types
67
from common_library.basic_types import BootModeEnum, BuildTargetEnum, LogLevel
78
from pydantic import Field, HttpUrl, PositiveInt, StringConstraints
89
from pydantic_core import core_schema
@@ -13,15 +14,16 @@
1314
SIMPLE_VERSION_RE,
1415
UUID_RE,
1516
)
17+
from .utils.common_validators import trim_string_before
1618

1719
assert issubclass(LogLevel, Enum) # nosec
1820
assert issubclass(BootModeEnum, Enum) # nosec
1921
assert issubclass(BuildTargetEnum, Enum) # nosec
2022

2123
__all__: tuple[str, ...] = (
22-
"LogLevel",
2324
"BootModeEnum",
2425
"BuildTargetEnum",
26+
"LogLevel",
2527
)
2628

2729

@@ -70,12 +72,31 @@
7072
UUIDStr: TypeAlias = Annotated[str, StringConstraints(pattern=UUID_RE)]
7173

7274

75+
SafeQueryStr: TypeAlias = Annotated[
76+
str,
77+
StringConstraints(
78+
max_length=512, # Reasonable limit for query parameters to avoid overflows
79+
strip_whitespace=True,
80+
),
81+
annotated_types.doc(
82+
"""
83+
A string that is safe to use in URLs and query parameters.
84+
""",
85+
),
86+
]
87+
88+
7389
# non-empty bounded string used as identifier
7490
# e.g. "123" or "name_123" or "fa327c73-52d8-462a-9267-84eeaf0f90e3" but NOT ""
7591
_ELLIPSIS_CHAR: Final[str] = "..."
7692

7793

7894
class ConstrainedStr(str):
95+
"""Emulates pydantic's v1 constrained types
96+
97+
DEPRECATED: Use instead Annotated[str, StringConstraints(...)]
98+
"""
99+
79100
pattern: str | Pattern[str] | None = None
80101
min_length: int | None = None
81102
max_length: int | None = None
@@ -102,6 +123,11 @@ def __get_pydantic_core_schema__(cls, _source_type, _handler):
102123

103124

104125
class IDStr(ConstrainedStr):
126+
"""Non-empty bounded string used as identifier
127+
128+
DEPRECATED: Use instead Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)]
129+
"""
130+
105131
strip_whitespace = True
106132
min_length = 1
107133
max_length = 100
@@ -125,21 +151,36 @@ def concatenate(*args: "IDStr", link_char: str = " ") -> "IDStr":
125151
return IDStr(result)
126152

127153

128-
class ShortTruncatedStr(ConstrainedStr):
129-
# NOTE: Use to input e.g. titles or display names
130-
# A truncated string:
131-
# - Strips whitespaces and truncate strings that exceed the specified characters limit (curtail_length).
132-
# - Ensures that the **input** data length to the API is controlled and prevents exceeding large inputs silently, i.e. without raising errors.
133-
# SEE https://github.com/ITISFoundation/osparc-simcore/pull/5989#discussion_r1650506583
134-
strip_whitespace = True
135-
curtail_length = 600
136-
154+
_SHORT_TRUNCATED_STR_MAX_LENGTH: Final[int] = 600
155+
ShortTruncatedStr: TypeAlias = Annotated[
156+
str,
157+
StringConstraints(strip_whitespace=True),
158+
trim_string_before(max_length=_SHORT_TRUNCATED_STR_MAX_LENGTH),
159+
annotated_types.doc(
160+
"""
161+
A truncated string used to input e.g. titles or display names.
162+
Strips whitespaces and truncate strings that exceed the specified characters limit (curtail_length).
163+
Ensures that the **input** data length to the API is controlled and prevents exceeding large inputs silently,
164+
i.e. without raising errors.
165+
"""
166+
# SEE https://github.com/ITISFoundation/osparc-simcore/pull/5989#discussion_r1650506583
167+
),
168+
]
137169

138-
class LongTruncatedStr(ConstrainedStr):
139-
# NOTE: Use to input e.g. descriptions or summaries
140-
# Analogous to ShortTruncatedStr
141-
strip_whitespace = True
142-
curtail_length = 65536 # same as github descripton
170+
_LONG_TRUNCATED_STR_MAX_LENGTH: Final[int] = 65536 # same as github description
171+
LongTruncatedStr: TypeAlias = Annotated[
172+
str,
173+
StringConstraints(strip_whitespace=True),
174+
trim_string_before(max_length=_LONG_TRUNCATED_STR_MAX_LENGTH),
175+
annotated_types.doc(
176+
"""
177+
A truncated string used to input e.g. descriptions or summaries.
178+
Strips whitespaces and truncate strings that exceed the specified characters limit (curtail_length).
179+
Ensures that the **input** data length to the API is controlled and prevents exceeding large inputs silently,
180+
i.e. without raising errors.
181+
"""
182+
),
183+
]
143184

144185

145186
# auto-incremented primary-key IDs

packages/models-library/src/models_library/projects.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@
1111
from models_library.basic_types import ConstrainedStr
1212
from models_library.folders import FolderID
1313
from models_library.workspaces import WorkspaceID
14-
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
14+
from pydantic import (
15+
BaseModel,
16+
ConfigDict,
17+
Field,
18+
HttpUrl,
19+
StringConstraints,
20+
field_validator,
21+
)
1522

1623
from .basic_regex import DATE_RE, UUID_RE_BASE
1724
from .emails import LowerCaseEmailStr
@@ -35,8 +42,7 @@
3542
_DATETIME_FORMAT: Final[str] = "%Y-%m-%dT%H:%M:%S.%fZ"
3643

3744

38-
class ProjectIDStr(ConstrainedStr):
39-
pattern = UUID_RE_BASE
45+
ProjectIDStr: TypeAlias = Annotated[str, StringConstraints(pattern=UUID_RE_BASE)]
4046

4147

4248
class DateTimeStr(ConstrainedStr):

packages/models-library/src/models_library/rabbitmq_basic_types.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from typing import Final
1+
from typing import Annotated, Final, TypeAlias
22

33
from models_library.basic_types import ConstrainedStr
4-
from pydantic import TypeAdapter
4+
from pydantic import StringConstraints, TypeAdapter
55

66
REGEX_RABBIT_QUEUE_ALLOWED_SYMBOLS: Final[str] = r"^[\w\-\.]*$"
77

@@ -21,7 +21,9 @@ def from_entries(cls, entries: dict[str, str]) -> "RPCNamespace":
2121
return TypeAdapter(cls).validate_python(composed_string)
2222

2323

24-
class RPCMethodName(ConstrainedStr):
25-
pattern = REGEX_RABBIT_QUEUE_ALLOWED_SYMBOLS
26-
min_length: int = 1
27-
max_length: int = 252
24+
RPCMethodName: TypeAlias = Annotated[
25+
str,
26+
StringConstraints(
27+
pattern=REGEX_RABBIT_QUEUE_ALLOWED_SYMBOLS, min_length=1, max_length=252
28+
),
29+
]

packages/models-library/tests/test_basic_types.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44
from models_library.basic_types import (
5+
_SHORT_TRUNCATED_STR_MAX_LENGTH,
56
EnvVarKey,
67
IDStr,
78
MD5Str,
@@ -76,16 +77,28 @@ def test_string_identifier_constraint_type():
7677

7778

7879
def test_short_truncated_string():
80+
curtail_length = _SHORT_TRUNCATED_STR_MAX_LENGTH
7981
assert (
80-
TypeAdapter(ShortTruncatedStr).validate_python(
81-
"X" * ShortTruncatedStr.curtail_length
82-
)
83-
== "X" * ShortTruncatedStr.curtail_length
84-
)
82+
TypeAdapter(ShortTruncatedStr).validate_python("X" * curtail_length)
83+
== "X" * curtail_length
84+
), "Max length string should remain intact"
8585

8686
assert (
87-
TypeAdapter(ShortTruncatedStr).validate_python(
88-
"X" * (ShortTruncatedStr.curtail_length + 1)
89-
)
90-
== "X" * ShortTruncatedStr.curtail_length
91-
)
87+
TypeAdapter(ShortTruncatedStr).validate_python("X" * (curtail_length + 1))
88+
== "X" * curtail_length
89+
), "Overlong string should be truncated exactly to max length"
90+
91+
assert (
92+
TypeAdapter(ShortTruncatedStr).validate_python("X" * (curtail_length + 100))
93+
== "X" * curtail_length
94+
), "Much longer string should still truncate to exact max length"
95+
96+
# below limit
97+
assert TypeAdapter(ShortTruncatedStr).validate_python(
98+
"X" * (curtail_length - 1)
99+
) == "X" * (curtail_length - 1), "Under-length string should not be modified"
100+
101+
# spaces are trimmed
102+
assert (
103+
TypeAdapter(ShortTruncatedStr).validate_python(" " * (curtail_length + 1)) == ""
104+
), "Only-whitespace string should become empty string"

packages/models-library/tests/test_projects.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
import pytest
99
from faker import Faker
10-
from models_library.api_schemas_webserver.projects import LongTruncatedStr, ProjectPatch
10+
from models_library.api_schemas_webserver.projects import ProjectPatch
11+
from models_library.basic_types import _LONG_TRUNCATED_STR_MAX_LENGTH
1112
from models_library.projects import Project
1213

1314

@@ -47,8 +48,7 @@ def test_project_with_thumbnail_as_empty_string(minimal_project: dict[str, Any])
4748

4849
def test_project_patch_truncates_description():
4950
# NOTE: checks https://github.com/ITISFoundation/osparc-simcore/issues/5988
50-
assert LongTruncatedStr.curtail_length
51-
len_truncated = int(LongTruncatedStr.curtail_length)
51+
len_truncated = _LONG_TRUNCATED_STR_MAX_LENGTH
5252

5353
long_description = "X" * (len_truncated + 10)
5454
assert len(long_description) > len_truncated

services/api-server/src/simcore_service_api_server/api/dependencies/models_schemas_solvers_filters.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Annotated
22

33
from fastapi import Query
4+
from models_library.basic_types import SafeQueryStr
45

56
from ...models.schemas.solvers_filters import SolversListFilters
67
from ._utils import _get_query_params
@@ -9,11 +10,11 @@
910
def get_solvers_filters(
1011
# pylint: disable=unsubscriptable-object
1112
solver_id: Annotated[
12-
str | None,
13+
SafeQueryStr | None,
1314
Query(**_get_query_params(SolversListFilters.model_fields["solver_id"])),
1415
] = None,
1516
version_display: Annotated[
16-
str | None,
17+
SafeQueryStr | None,
1718
Query(**_get_query_params(SolversListFilters.model_fields["version_display"])),
1819
] = None,
1920
) -> SolversListFilters:

services/api-server/src/simcore_service_api_server/api/routes/studies_jobs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ async def create_study_job(
163163

164164
await webserver_api.patch_project(
165165
project_id=job.id,
166-
patch_params=ProjectPatch(name=job.name), # type: ignore[arg-type]
166+
patch_params=ProjectPatch(name=job.name),
167167
)
168168

169169
await wb_api_rpc.mark_project_as_job(

0 commit comments

Comments
 (0)