Skip to content

Commit 8977f28

Browse files
Merge branch 'master' into is5854/upgrade-to-py311
2 parents d1af34b + 5cd7d13 commit 8977f28

File tree

35 files changed

+804
-244
lines changed

35 files changed

+804
-244
lines changed

.env-devel

+1
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ WEBSERVER_DIAGNOSTICS={}
318318
WEBSERVER_EMAIL={}
319319
WEBSERVER_EXPORTER={}
320320
WEBSERVER_FRONTEND={}
321+
WEBSERVER_FOLDERS=1
321322
WEBSERVER_GARBAGE_COLLECTOR=null
322323
WEBSERVER_GROUPS=1
323324
WEBSERVER_GUNICORN_CMD_ARGS=--timeout=180

api/specs/web-server/_folders.py

+4-5
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
FolderGet,
1616
PutFolderBodyParams,
1717
)
18-
from models_library.api_schemas_webserver.wallets import WalletGet
1918
from models_library.generics import Envelope
2019
from models_library.rest_pagination import PageQueryParameters
2120
from pydantic import Json
@@ -39,7 +38,7 @@
3938

4039
@router.post(
4140
"/folders",
42-
response_model=Envelope[WalletGet],
41+
response_model=Envelope[FolderGet],
4342
status_code=status.HTTP_201_CREATED,
4443
)
4544
async def create_folder(_body: CreateFolderBodyParams):
@@ -55,10 +54,10 @@ async def list_folders(
5554
order_by: Annotated[
5655
Json,
5756
Query(
58-
description="Order by field (name|description) and direction (asc|desc). The default sorting order is ascending.",
57+
description="Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending.",
5958
example='{"field": "name", "direction": "desc"}',
6059
),
61-
] = '{"field": "name", "direction": "desc"}',
60+
] = '{"field": "modified_at", "direction": "desc"}',
6261
):
6362
...
6463

@@ -83,7 +82,7 @@ async def replace_folder(
8382

8483
@router.delete(
8584
"/folders/{folder_id}",
86-
response_model=Envelope[FolderGet],
85+
status_code=status.HTTP_204_NO_CONTENT,
8786
)
8887
async def delete_folder(_path: Annotated[FoldersPathParams, Depends()]):
8988
...

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

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from datetime import datetime
2+
from typing import NamedTuple
23

34
from models_library.basic_types import IDStr
45
from models_library.folders import FolderID
56
from models_library.projects_access import AccessRights
67
from models_library.users import GroupID
7-
from pydantic import Extra
8+
from models_library.utils.common_validators import null_or_none_str_to_none_validator
9+
from pydantic import Extra, PositiveInt, validator
810

911
from ._base import InputSchema, OutputSchema
1012

@@ -21,6 +23,11 @@ class FolderGet(OutputSchema):
2123
access_rights: dict[GroupID, AccessRights]
2224

2325

26+
class FolderGetPage(NamedTuple):
27+
items: list[FolderGet]
28+
total: PositiveInt
29+
30+
2431
class CreateFolderBodyParams(InputSchema):
2532
name: IDStr
2633
description: str
@@ -29,6 +36,10 @@ class CreateFolderBodyParams(InputSchema):
2936
class Config:
3037
extra = Extra.forbid
3138

39+
_null_or_none_str_to_none_validator = validator(
40+
"parent_folder_id", allow_reuse=True, pre=True
41+
)(null_or_none_str_to_none_validator)
42+
3243

3344
class PutFolderBodyParams(InputSchema):
3445
name: IDStr

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

+9
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
from pydantic.errors import PydanticErrorMixin
22

33

4+
class _DefaultDict(dict):
5+
def __missing__(self, key):
6+
return f"'{key}=?'"
7+
8+
49
class OsparcErrorMixin(PydanticErrorMixin):
510
def __new__(cls, *args, **kwargs):
611
if not hasattr(cls, "code"):
712
cls.code = cls._get_full_class_name()
813
return super().__new__(cls, *args, **kwargs)
914

15+
def __str__(self) -> str:
16+
# NOTE: safe. Does not raise KeyError
17+
return self.msg_template.format_map(_DefaultDict(**self.__dict__))
18+
1019
@classmethod
1120
def _get_full_class_name(cls) -> str:
1221
relevant_classes = [

packages/models-library/src/models_library/utils/common_validators.py

+6
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,9 @@ def ensure_unique_dict_values_validator(dict_data: dict) -> dict:
6363
msg = f"Dictionary values must be unique, provided: {dict_data}"
6464
raise ValueError(msg)
6565
return dict_data
66+
67+
68+
def null_or_none_str_to_none_validator(value: Any):
69+
if isinstance(value, str) and value.lower() in ("null", "none"):
70+
return None
71+
return value

packages/models-library/tests/test_errors_classes.py

+14
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import pytest
1212
from models_library.errors_classes import OsparcErrorMixin
13+
from pydantic.errors import PydanticErrorMixin
1314

1415

1516
def test_get_full_class_name():
@@ -134,3 +135,16 @@ class MyError(OsparcErrorMixin, ValueError):
134135

135136
error = MyError(**ctx)
136137
assert str(error) == expected
138+
139+
140+
def test_missing_keys_in_msg_template_does_not_raise():
141+
class MyErrorBefore(PydanticErrorMixin, ValueError):
142+
msg_template = "{value} and {missing}"
143+
144+
with pytest.raises(KeyError, match="missing"):
145+
str(MyErrorBefore(value=42))
146+
147+
class MyErrorAfter(OsparcErrorMixin, ValueError):
148+
msg_template = "{value} and {missing}"
149+
150+
assert str(MyErrorAfter(value=42)) == "42 and 'missing=?'"

packages/models-library/tests/test_utils_common_validators.py

+28
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
create_enums_pre_validator,
66
empty_str_to_none_pre_validator,
77
none_to_empty_str_pre_validator,
8+
null_or_none_str_to_none_validator,
89
)
910
from pydantic import BaseModel, ValidationError, validator
1011

@@ -59,3 +60,30 @@ class Model(BaseModel):
5960

6061
model = Model.parse_obj({"message": ""})
6162
assert model == Model.parse_obj({"message": None})
63+
64+
65+
def test_null_or_none_str_to_none_validator():
66+
class Model(BaseModel):
67+
message: str | None
68+
69+
_null_or_none_str_to_none_validator = validator(
70+
"message", allow_reuse=True, pre=True
71+
)(null_or_none_str_to_none_validator)
72+
73+
model = Model.parse_obj({"message": "none"})
74+
assert model == Model.parse_obj({"message": None})
75+
76+
model = Model.parse_obj({"message": "null"})
77+
assert model == Model.parse_obj({"message": None})
78+
79+
model = Model.parse_obj({"message": "NoNe"})
80+
assert model == Model.parse_obj({"message": None})
81+
82+
model = Model.parse_obj({"message": "NuLl"})
83+
assert model == Model.parse_obj({"message": None})
84+
85+
model = Model.parse_obj({"message": None})
86+
assert model == Model.parse_obj({"message": None})
87+
88+
model = Model.parse_obj({"message": ""})
89+
assert model == Model.parse_obj({"message": ""})

packages/postgres-database/src/simcore_postgres_database/utils_folders.py

+32-7
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from datetime import datetime
66
from enum import Enum
77
from functools import reduce
8-
from typing import Any, ClassVar, Final, TypeAlias
8+
from typing import Any, ClassVar, Final, TypeAlias, cast
99

1010
import sqlalchemy as sa
1111
from aiopg.sa.connection import SAConnection
@@ -20,12 +20,14 @@
2020
parse_obj_as,
2121
)
2222
from pydantic.errors import PydanticErrorMixin
23+
from simcore_postgres_database.utils_ordering import OrderByDict
2324
from sqlalchemy.dialects import postgresql
2425
from sqlalchemy.sql.elements import ColumnElement
2526
from sqlalchemy.sql.selectable import ScalarSelect
2627

2728
from .models.folders import folders, folders_access_rights, folders_to_projects
2829
from .models.groups import GroupType, groups
30+
from .utils_ordering import OrderDirection
2931

3032
_ProductName: TypeAlias = str
3133
_ProjectID: TypeAlias = uuid.UUID
@@ -986,8 +988,11 @@ async def folder_list(
986988
*,
987989
offset: NonNegativeInt,
988990
limit: NonNegativeInt,
991+
order_by: OrderByDict = OrderByDict(
992+
field="modified", direction=OrderDirection.DESC
993+
),
989994
_required_permissions=_requires(_BasePermissions.LIST_FOLDERS), # noqa: B008
990-
) -> list[FolderEntry]:
995+
) -> tuple[int, list[FolderEntry]]:
991996
"""
992997
Raises:
993998
FolderNotFoundError
@@ -1015,7 +1020,7 @@ async def folder_list(
10151020
access_via_gid = resolved_access_rights.gid
10161021
access_via_folder_id = resolved_access_rights.folder_id
10171022

1018-
query = (
1023+
base_query = (
10191024
sa.select(
10201025
folders,
10211026
folders_access_rights,
@@ -1047,14 +1052,30 @@ async def folder_list(
10471052
if folder_id is None
10481053
else True
10491054
)
1050-
.offset(offset)
1051-
.limit(limit)
1055+
.where(folders.c.product_name == product_name)
10521056
)
10531057

1054-
async for entry in connection.execute(query):
1058+
# Select total count from base_query
1059+
subquery = base_query.subquery()
1060+
count_query = sa.select(sa.func.count()).select_from(subquery)
1061+
count_result = await connection.execute(count_query)
1062+
total_count = await count_result.scalar()
1063+
1064+
# Ordering and pagination
1065+
if order_by["direction"] == OrderDirection.ASC:
1066+
list_query = base_query.order_by(
1067+
sa.asc(getattr(folders.c, order_by["field"]))
1068+
)
1069+
else:
1070+
list_query = base_query.order_by(
1071+
sa.desc(getattr(folders.c, order_by["field"]))
1072+
)
1073+
list_query = list_query.offset(offset).limit(limit)
1074+
1075+
async for entry in connection.execute(list_query):
10551076
results.append(FolderEntry.from_orm(entry)) # noqa: PERF401s
10561077

1057-
return results
1078+
return cast(int, total_count), results
10581079

10591080

10601081
async def folder_get(
@@ -1101,6 +1122,7 @@ async def folder_get(
11011122
if folder_id is None
11021123
else True
11031124
)
1125+
.where(folders.c.product_name == product_name)
11041126
)
11051127

11061128
query_result: RowProxy | None = await (
@@ -1113,3 +1135,6 @@ async def folder_get(
11131135
)
11141136

11151137
return FolderEntry.from_orm(query_result)
1138+
1139+
1140+
__all__ = ["OrderByDict"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from enum import Enum
2+
from typing import TypedDict
3+
4+
5+
class OrderDirection(str, Enum):
6+
ASC = "asc"
7+
DESC = "desc"
8+
9+
10+
class OrderByDict(TypedDict):
11+
field: str
12+
direction: OrderDirection
13+
14+
15+
# Example usage
16+
order_by_example: OrderByDict = {
17+
"field": "example_field",
18+
"direction": OrderDirection.ASC,
19+
}

packages/postgres-database/tests/test_utils_folders.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1633,9 +1633,10 @@ async def _list_folder_as(
16331633
limit: NonNegativeInt = ALL_IN_ONE_PAGE_LIMIT,
16341634
) -> list[FolderEntry]:
16351635

1636-
return await folder_list(
1636+
total_count, folders_db = await folder_list(
16371637
connection, default_product_name, folder_id, gid, offset=offset, limit=limit
16381638
)
1639+
return folders_db
16391640

16401641

16411642
async def test_folder_list(

packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/catalog/services.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ async def _call(
5454
user_id=user_id,
5555
limit=limit,
5656
offset=offset,
57-
timeout_s=4 * RPC_REQUEST_DEFAULT_TIMEOUT_S,
57+
timeout_s=10 * RPC_REQUEST_DEFAULT_TIMEOUT_S,
5858
)
5959

6060
result = await _call(

services/api-server/src/simcore_service_api_server/exceptions/service_errors_utils.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,14 @@ def _get_http_exception_kwargs(
5858
service_name: str,
5959
service_error: httpx.HTTPStatusError,
6060
http_status_map: HttpStatusMap,
61-
**detail_kwargs: Any,
61+
**exception_ctx: Any,
6262
):
6363
detail: str = ""
6464
headers: dict[str, str] = {}
6565

6666
if exception_type := http_status_map.get(service_error.response.status_code):
67-
raise exception_type(**detail_kwargs)
67+
raise exception_type(**exception_ctx)
68+
6869
if service_error.response.status_code in {
6970
status.HTTP_429_TOO_MANY_REQUESTS,
7071
status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -92,9 +93,8 @@ def _get_http_exception_kwargs(
9293
def service_exception_handler(
9394
service_name: str,
9495
http_status_map: HttpStatusMap,
95-
**endpoint_kwargs,
96+
**context,
9697
):
97-
#
9898
status_code: int
9999
detail: str
100100
headers: dict[str, str] = {}
@@ -115,7 +115,7 @@ def service_exception_handler(
115115
except httpx.HTTPStatusError as exc:
116116

117117
status_code, detail, headers = _get_http_exception_kwargs(
118-
service_name, exc, http_status_map=http_status_map, **endpoint_kwargs
118+
service_name, exc, http_status_map=http_status_map, **context
119119
)
120120
raise HTTPException(
121121
status_code=status_code, detail=detail, headers=headers
@@ -145,7 +145,7 @@ def _assert_correct_kwargs(func: Callable, status_map: HttpStatusMap):
145145
for name, param in signature(func).parameters.items()
146146
if param.kind == param.KEYWORD_ONLY
147147
}
148-
for _, exc_type in status_map.items():
148+
for exc_type in status_map.values():
149149
_exception_inputs = exc_type.named_fields()
150150
assert _exception_inputs.issubset(
151151
_required_kwargs

services/api-server/src/simcore_service_api_server/services/director_v2.py

-3
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,6 @@ async def get_computation_logs(
192192

193193

194194
def setup(app: FastAPI, settings: DirectorV2Settings) -> None:
195-
if not settings:
196-
settings = DirectorV2Settings()
197-
198195
setup_client_instance(
199196
app,
200197
DirectorV2Api,

services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_logs.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import logging
1010
from collections.abc import Iterable
1111
from pprint import pprint
12-
from typing import AsyncIterable, Final
12+
from typing import Final
1313

1414
import httpx
1515
import pytest
@@ -128,14 +128,14 @@ async def test_log_streaming(
128128
@pytest.fixture
129129
async def mock_job_not_found(
130130
mocked_directorv2_service_api_base: MockRouter,
131-
) -> AsyncIterable[MockRouter]:
131+
) -> MockRouter:
132132
def _get_computation(request: httpx.Request, **kwargs) -> httpx.Response:
133133
return httpx.Response(status_code=status.HTTP_404_NOT_FOUND)
134134

135135
mocked_directorv2_service_api_base.get(
136136
path__regex=r"/v2/computations/(?P<project_id>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"
137137
).mock(side_effect=_get_computation)
138-
yield mocked_directorv2_service_api_base
138+
return mocked_directorv2_service_api_base
139139

140140

141141
async def test_logstreaming_job_not_found_exception(

0 commit comments

Comments
 (0)