Skip to content

Commit 11ade46

Browse files
authored
Merge branch 'master' into mai/docker-compose-refs
2 parents e8bd4e3 + 7f199cb commit 11ade46

File tree

7 files changed

+138
-33
lines changed

7 files changed

+138
-33
lines changed

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1+
"""
2+
3+
# Batch Operations Rationale:
4+
5+
Please preserve the following behaviors when implementing batch operations:
6+
7+
| Case | Behavior | Justification |
8+
| -------------- | ------------------------------------------ | --------------------------- |
9+
| Empty `names` | `400 Bad Request` | Invalid input |
10+
| Some missing | `200 OK`, with `missing` field | Partial success |
11+
| Duplicates | Silently deduplicate | Idempotent, client-friendly |
12+
| Response order | Preserve request order (excluding missing) | Deterministic, ergonomic |
13+
14+
15+
- `BatchGet` is semantically distinct from `List`.
16+
- `List` means “give me everything you have, maybe filtered.”
17+
- `BatchGet` means “give me these specific known resources.”
18+
- Passing an empty list means you’re not actually identifying anything to fetch — so it’s a client error (bad request), not a legitimate “empty result.”
19+
- This aligns with the principle: If the request parameters are syntactically valid but semantically meaningless, return 400 Bad Request.
20+
21+
# References:
22+
- https://google.aip.dev/130
23+
- https://google.aip.dev/231
24+
"""
25+
126
from typing import Annotated, Generic, TypeVar
227

328
from common_library.basic_types import DEFAULT_FACTORY

services/catalog/src/simcore_service_catalog/service/catalog_services.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,7 @@ async def batch_get_user_services(
703703
704704
Raises:
705705
CatalogItemNotFoundError: When no services are found at all
706+
ValidationError: if the ids are empty
706707
"""
707708
unique_service_identifiers = _BatchIdsValidator.validate_python(ids)
708709

services/dask-sidecar/src/simcore_service_dask_sidecar/utils/files.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,9 @@ async def pull_file_from_remote(
166166
s3_settings: S3Settings | None,
167167
) -> None:
168168
assert src_url.path # nosec
169+
src_location_str = f"{src_url.path.strip('/')} on {src_url.host}:{src_url.port}"
169170
await log_publishing_cb(
170-
f"Downloading '{src_url}' into local file '{dst_path}'...",
171+
f"Downloading '{src_location_str}' into local file '{dst_path}'...",
171172
logging.INFO,
172173
)
173174
if not dst_path.parent.exists():
@@ -196,11 +197,11 @@ async def pull_file_from_remote(
196197
TypeAdapter(FileUrl).validate_python(f"{download_dst_path.as_uri()}"),
197198
src_storage_cfg=cast(dict[str, Any], storage_kwargs),
198199
log_publishing_cb=log_publishing_cb,
199-
text_prefix=f"Downloading '{src_url.path.strip('/')}':",
200+
text_prefix=f"Downloading '{src_location_str}':",
200201
)
201202

202203
await log_publishing_cb(
203-
f"Download of '{src_url}' into local file '{download_dst_path}' complete.",
204+
f"Download of '{src_location_str}' into local file '{download_dst_path}' complete.",
204205
logging.INFO,
205206
)
206207

services/web/server/src/simcore_service_webserver/catalog/_service.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
ServiceOutputGet,
1111
ServiceOutputKey,
1212
)
13+
from models_library.batch_operations import create_batch_ids_validator
1314
from models_library.products import ProductName
1415
from models_library.rest_pagination import (
1516
PageLimitInt,
@@ -25,6 +26,7 @@
2526
from models_library.users import UserID
2627
from models_library.utils.fastapi_encoders import jsonable_encoder
2728
from pint import UnitRegistry
29+
from pydantic import ValidationError
2830
from servicelib.rabbitmq._errors import RPCServerError
2931
from servicelib.rabbitmq.rpc_interfaces.catalog import services as catalog_rpc
3032
from servicelib.rabbitmq.rpc_interfaces.catalog.errors import (
@@ -94,6 +96,11 @@ async def list_latest_services(
9496
return page_data, page.meta
9597

9698

99+
_BatchServicesIdsValidator = create_batch_ids_validator(
100+
tuple[ServiceKey, ServiceVersion]
101+
)
102+
103+
97104
async def batch_get_my_services(
98105
app: web.Application,
99106
*,
@@ -102,12 +109,17 @@ async def batch_get_my_services(
102109
services_ids: list[tuple[ServiceKey, ServiceVersion]],
103110
) -> MyServicesBatchGetResult:
104111
try:
112+
ids = _BatchServicesIdsValidator.validate_python(services_ids)
113+
except ValidationError as err:
114+
msg = f"Invalid 'service_ids' parameter:\n{err}"
115+
raise ValueError(msg) from err
105116

117+
try:
106118
return await catalog_rpc.batch_get_my_services(
107119
get_rabbitmq_rpc_client(app),
108120
user_id=user_id,
109121
product_name=product_name,
110-
ids=services_ids,
122+
ids=ids,
111123
)
112124

113125
except RPCServerError as err:

services/web/server/src/simcore_service_webserver/projects/_controller/nodes_rest.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -546,28 +546,32 @@ async def get_project_services(request: web.Request) -> web.Response:
546546
)
547547
)
548548

549-
batch_got = await catalog_service.batch_get_my_services(
550-
request.app,
551-
product_name=req_ctx.product_name,
552-
user_id=req_ctx.user_id,
553-
services_ids=services_in_project,
554-
)
549+
services = []
550+
missing = None
551+
552+
if services_in_project:
553+
batch_got = await catalog_service.batch_get_my_services(
554+
request.app,
555+
product_name=req_ctx.product_name,
556+
user_id=req_ctx.user_id,
557+
services_ids=services_in_project,
558+
)
559+
services = [
560+
NodeServiceGet.model_validate(sv, from_attributes=True)
561+
for sv in batch_got.found_items
562+
]
563+
missing = (
564+
[
565+
ServiceKeyVersion(key=k, version=v)
566+
for k, v in batch_got.missing_identifiers
567+
]
568+
if batch_got.missing_identifiers
569+
else None
570+
)
555571

556572
return envelope_json_response(
557573
ProjectNodeServicesGet(
558-
project_uuid=path_params.project_id,
559-
services=[
560-
NodeServiceGet.model_validate(sv, from_attributes=True)
561-
for sv in batch_got.found_items
562-
],
563-
missing=(
564-
[
565-
ServiceKeyVersion(key=k, version=v)
566-
for k, v in batch_got.missing_identifiers
567-
]
568-
if batch_got.missing_identifiers
569-
else None
570-
),
574+
project_uuid=path_params.project_id, services=services, missing=missing
571575
)
572576
)
573577

services/web/server/src/simcore_service_webserver/projects/_projects_nodes_repository.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import logging
22

33
import sqlalchemy as sa
4-
54
from aiohttp import web
65
from models_library.projects import ProjectID
76
from models_library.projects_nodes import Node, PartialNode
@@ -10,8 +9,8 @@
109
from simcore_postgres_database.webserver_models import projects_nodes
1110
from sqlalchemy.ext.asyncio import AsyncConnection
1211

13-
from .exceptions import NodeNotFoundError
1412
from ..db.plugin import get_asyncpg_engine
13+
from .exceptions import NodeNotFoundError
1514

1615
_logger = logging.getLogger(__name__)
1716

@@ -44,21 +43,18 @@ async def get(
4443
node_id: NodeID,
4544
) -> Node:
4645
async with transaction_context(get_asyncpg_engine(app), connection) as conn:
47-
get_stmt = sa.select(
48-
*_SELECTION_PROJECTS_NODES_DB_ARGS
49-
).where(
46+
get_stmt = sa.select(*_SELECTION_PROJECTS_NODES_DB_ARGS).where(
5047
(projects_nodes.c.project_uuid == f"{project_id}")
5148
& (projects_nodes.c.node_id == f"{node_id}")
5249
)
5350

54-
result = await conn.stream(get_stmt)
51+
result = await conn.execute(get_stmt)
5552
assert result # nosec
5653

57-
row = await result.first()
54+
row = result.one_or_none()
5855
if row is None:
5956
raise NodeNotFoundError(
60-
project_uuid=f"{project_id}",
61-
node_uuid=f"{node_id}"
57+
project_uuid=f"{project_id}", node_uuid=f"{node_id}"
6258
)
6359
assert row # nosec
6460
return Node.model_validate(row, from_attributes=True)
@@ -75,7 +71,7 @@ async def update(
7571
values = partial_node.model_dump(mode="json", exclude_unset=True)
7672

7773
async with transaction_context(get_asyncpg_engine(app), connection) as conn:
78-
await conn.stream(
74+
await conn.execute(
7975
projects_nodes.update()
8076
.values(**values)
8177
.where(

services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__services_access.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
# pylint: disable=unused-variable
55
# type: ignore
66

7+
from collections.abc import AsyncIterator
78
from copy import deepcopy
89
from http import HTTPStatus
10+
from pathlib import Path
911
from typing import Any
1012

1113
import pytest
@@ -20,6 +22,7 @@
2022
from models_library.services_history import ServiceRelease
2123
from pytest_mock import MockerFixture
2224
from pytest_simcore.helpers.assert_checks import assert_status
25+
from pytest_simcore.helpers.webserver_projects import NewProject
2326
from pytest_simcore.helpers.webserver_users import UserInfoDict
2427
from servicelib.aiohttp import status
2528
from servicelib.rabbitmq import RPCServerError
@@ -590,3 +593,66 @@ async def test_get_project_services_service_unavailable(
590593

591594
assert error
592595
assert not data
596+
597+
598+
@pytest.fixture
599+
async def empty_project(
600+
client,
601+
fake_project: dict,
602+
logged_user: dict,
603+
tests_data_dir: Path,
604+
osparc_product_name: str,
605+
) -> AsyncIterator[dict]:
606+
fake_project["prjOwner"] = logged_user["name"]
607+
fake_project["workbench"] = {}
608+
async with NewProject(
609+
fake_project,
610+
client.app,
611+
user_id=logged_user["id"],
612+
product_name=osparc_product_name,
613+
tests_data_dir=tests_data_dir,
614+
) as project:
615+
yield project
616+
617+
618+
@pytest.mark.parametrize("user_role", [UserRole.USER])
619+
async def test_get_project_services_empty_project(
620+
client: TestClient,
621+
empty_project: ProjectDict,
622+
mocker: MockerFixture,
623+
):
624+
# NOTE: Tests bug described in https://github.com/ITISFoundation/osparc-simcore/pull/8501
625+
assert empty_project["workbench"] == {}
626+
627+
# MOCK CATALOG TO RAISE IF CALLED (it should not be called)
628+
from simcore_service_webserver.catalog._service import catalog_rpc # noqa: PLC0415
629+
630+
mocker.patch.object(
631+
catalog_rpc,
632+
"batch_get_my_services",
633+
spec=True,
634+
side_effect=ValueError(
635+
"Bad request: cannot batch-get an empty list of name-ids"
636+
),
637+
)
638+
639+
assert client.app
640+
641+
# ACT
642+
project_id = empty_project["uuid"]
643+
644+
expected_url = client.app.router["get_project_services"].url_for(
645+
project_id=project_id
646+
)
647+
assert URL(f"/v0/projects/{project_id}/nodes/-/services") == expected_url
648+
649+
resp = await client.get(f"/v0/projects/{project_id}/nodes/-/services")
650+
651+
# ASSERT
652+
data, _ = await assert_status(resp, status.HTTP_200_OK)
653+
654+
assert data == {
655+
"projectUuid": project_id,
656+
"services": [],
657+
"missing": None,
658+
}

0 commit comments

Comments
 (0)