diff --git a/packages/models-library/src/models_library/projects.py b/packages/models-library/src/models_library/projects.py index 5a5b9fe7409c..4d594026d1b2 100644 --- a/packages/models-library/src/models_library/projects.py +++ b/packages/models-library/src/models_library/projects.py @@ -113,8 +113,8 @@ class ProjectAtDB(BaseProjectModel): published: Annotated[ bool | None, - Field(default=False, description="Defines if a study is available publicly"), - ] + Field(description="Defines if a study is available publicly"), + ] = False @field_validator("project_type", mode="before") @classmethod diff --git a/packages/models-library/src/models_library/projects_nodes.py b/packages/models-library/src/models_library/projects_nodes.py index 81ffd16c1650..66683369f357 100644 --- a/packages/models-library/src/models_library/projects_nodes.py +++ b/packages/models-library/src/models_library/projects_nodes.py @@ -1,5 +1,5 @@ """ - Models Node as a central element in a project's pipeline +Models Node as a central element in a project's pipeline """ from typing import Annotated, Any, TypeAlias, Union @@ -17,6 +17,7 @@ StringConstraints, field_validator, ) +from pydantic.config import JsonDict from .basic_types import EnvVarKey, KeyIDStr from .projects_access import AccessEnum @@ -71,26 +72,41 @@ class NodeState(BaseModel): - modified: bool = Field( - default=True, description="true if the node's outputs need to be re-computed" - ) - dependencies: set[NodeID] = Field( - default_factory=set, - description="contains the node inputs dependencies if they need to be computed first", - ) - current_status: RunningState = Field( - default=RunningState.NOT_STARTED, - description="the node's current state", - alias="currentStatus", - ) - progress: float | None = Field( - default=0, - ge=0.0, - le=1.0, - description="current progress of the task if available (None if not started or not a computational task)", - ) + modified: Annotated[ + bool, + Field( + description="true if the node's outputs need to be re-computed", + ), + ] = True + + dependencies: Annotated[ + set[NodeID], + Field( + default_factory=set, + description="contains the node inputs dependencies if they need to be computed first", + ), + ] = DEFAULT_FACTORY + + current_status: Annotated[ + RunningState, + Field( + description="the node's current state", + alias="currentStatus", + ), + ] = RunningState.NOT_STARTED + + progress: Annotated[ + float | None, + Field( + ge=0.0, + le=1.0, + description="current progress of the task if available (None if not started or not a computational task)", + ), + ] = 0 + model_config = ConfigDict( extra="forbid", + populate_by_name=True, json_schema_extra={ "examples": [ { @@ -113,24 +129,35 @@ class NodeState(BaseModel): ) +def _convert_old_enum_name(v) -> RunningState: + if v == "FAILURE": + return RunningState.FAILED + return RunningState(v) + + class Node(BaseModel): - key: ServiceKey = Field( - ..., - description="distinctive name for the node based on the docker registry path", - examples=[ - "simcore/services/comp/itis/sleeper", - "simcore/services/dynamic/3dviewer", - "simcore/services/frontend/file-picker", - ], - ) - version: ServiceVersion = Field( - ..., - description="semantic version number of the node", - examples=["1.0.0", "0.0.1"], - ) - label: str = Field( - ..., description="The short name of the node", examples=["JupyterLab"] - ) + key: Annotated[ + ServiceKey, + Field( + description="distinctive name for the node based on the docker registry path", + examples=[ + "simcore/services/comp/itis/sleeper", + "simcore/services/dynamic/3dviewer", + "simcore/services/frontend/file-picker", + ], + ), + ] + version: Annotated[ + ServiceVersion, + Field( + description="semantic version number of the node", + examples=["1.0.0", "0.0.1"], + ), + ] + label: Annotated[ + str, + Field(description="The short name of the node", examples=["JupyterLab"]), + ] progress: Annotated[ float | None, Field( @@ -204,9 +231,9 @@ class Node(BaseModel): Field(default_factory=dict, description="values of output properties"), ] = DEFAULT_FACTORY - output_node: Annotated[ - bool | None, Field(deprecated=True, alias="outputNode") - ] = None + output_node: Annotated[bool | None, Field(deprecated=True, alias="outputNode")] = ( + None + ) output_nodes: Annotated[ list[NodeID] | None, @@ -255,24 +282,109 @@ def _convert_empty_str_to_none(cls, v): return None return v - @classmethod - def _convert_old_enum_name(cls, v) -> RunningState: - if v == "FAILURE": - return RunningState.FAILED - return RunningState(v) - @field_validator("state", mode="before") @classmethod def _convert_from_enum(cls, v): if isinstance(v, str): + # the old version of state was a enum of RunningState - running_state_value = cls._convert_old_enum_name(v) - return NodeState(currentStatus=running_state_value) + running_state_value = _convert_old_enum_name(v) + return NodeState(current_status=running_state_value) return v + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "examples": [ + # Minimal example with only required fields + { + "key": "simcore/services/comp/no_ports", + "version": "1.0.0", + "label": "Sleep", + }, + # Complete example with optional fields + { + "key": "simcore/services/comp/only_inputs", + "version": "1.0.0", + "label": "Only INputs", + "inputs": { + "input_1": 1, + "input_2": 2, + "input_3": 3, + }, + }, + # Complete example with optional fields + { + "key": "simcore/services/comp/only_outputs", + "version": "1.0.0", + "label": "Only Outputs", + "outputs": { + "output_1": 1, + "output_2": 2, + "output_3": 3, + }, + }, + # Example with all possible input and output types + { + "key": "simcore/services/comp/itis/all-types", + "version": "1.0.0", + "label": "All Types Demo", + "inputs": { + "boolean_input": True, + "integer_input": 42, + "float_input": 3.14159, + "string_input": "text value", + "json_input": {"key": "value", "nested": {"data": 123}}, + "port_link_input": { + "nodeUuid": "f2700a54-adcf-45d4-9881-01ec30fd75a2", + "output": "out_1", + }, + "simcore_file_link": { + "store": "simcore.s3", + "path": "123e4567-e89b-12d3-a456-426614174000/test.csv", + }, + "datcore_file_link": { + "store": "datcore", + "dataset": "N:dataset:123", + "path": "path/to/file.txt", + }, + "download_link": { + "downloadLink": "https://example.com/downloadable/file.txt" + }, + "array_input": [1, 2, 3, 4, 5], + "object_input": {"name": "test", "value": 42}, + }, + "outputs": { + "boolean_output": False, + "integer_output": 100, + "float_output": 2.71828, + "string_output": "result text", + "json_output": {"status": "success", "data": [1, 2, 3]}, + "simcore_file_output": { + "store": "simcore.s3", + "path": "987e6543-e21b-12d3-a456-426614174000/result.csv", + }, + "datcore_file_output": { + "store": "datcore", + "dataset": "N:dataset:456", + "path": "results/output.txt", + }, + "download_link_output": { + "downloadLink": "https://example.com/results/download.txt" + }, + "array_output": ["a", "b", "c", "d"], + "object_output": {"status": "complete", "count": 42}, + }, + }, + ], + } + ) + model_config = ConfigDict( extra="forbid", populate_by_name=True, + json_schema_extra=_update_json_schema_extra, ) diff --git a/packages/models-library/src/models_library/rpc/webserver/projects.py b/packages/models-library/src/models_library/rpc/webserver/projects.py index b44e63451953..010c1c41d84f 100644 --- a/packages/models-library/src/models_library/rpc/webserver/projects.py +++ b/packages/models-library/src/models_library/rpc/webserver/projects.py @@ -1,12 +1,15 @@ from datetime import datetime from typing import Annotated, TypeAlias +from uuid import uuid4 -from models_library.projects import ProjectID +from models_library.projects import NodesDict, ProjectID +from models_library.projects_nodes import Node from models_library.rpc_pagination import PageRpc from pydantic import BaseModel, ConfigDict, Field +from pydantic.config import JsonDict -class ProjectRpcGet(BaseModel): +class ProjectJobRpcGet(BaseModel): """ Minimal information about a project that (for now) will fullfill the needs of the api-server. Specifically, the fields needed in @@ -23,19 +26,44 @@ class ProjectRpcGet(BaseModel): ] description: str + workbench: NodesDict + # timestamps - creation_date: datetime - last_change_date: datetime + creation_at: datetime + modified_at: datetime + + # Specific to jobs + job_parent_resource_name: str + + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + nodes_examples = Node.model_json_schema()["examples"] + schema.update( + { + "examples": [ + { + "uuid": "12345678-1234-5678-1234-123456789012", + "name": "My project", + "description": "My project description", + "workbench": {f"{uuid4()}": n for n in nodes_examples[2:3]}, + "creation_at": "2023-01-01T00:00:00Z", + "modified_at": "2023-01-01T00:00:00Z", + "job_parent_resource_name": "solvers/foo/release/1.2.3", + }, + ] + } + ) model_config = ConfigDict( extra="forbid", populate_by_name=True, + json_schema_extra=_update_json_schema_extra, ) -PageRpcProjectRpcGet: TypeAlias = PageRpc[ +PageRpcProjectJobRpcGet: TypeAlias = PageRpc[ # WARNING: keep this definition in models_library and not in the RPC interface # otherwise the metaclass PageRpc[*] will create *different* classes in server/client side # and will fail to serialize/deserialize these parameters when transmitted/received - ProjectRpcGet + ProjectJobRpcGet ] diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/catalog_rpc_server.py b/packages/pytest-simcore/src/pytest_simcore/helpers/catalog_rpc_server.py index 17a69908f8ae..727aff1cf777 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/catalog_rpc_server.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/catalog_rpc_server.py @@ -9,11 +9,9 @@ LatestServiceGet, ServiceGetV2, ServiceListFilters, + ServiceUpdateV2, ) from models_library.api_schemas_catalog.services_ports import ServicePortGet -from models_library.api_schemas_webserver.catalog import ( - CatalogServiceUpdate, -) from models_library.products import ProductName from models_library.rest_pagination import PageOffsetInt from models_library.rpc_pagination import PageLimitInt, PageRpc @@ -25,15 +23,17 @@ ) from models_library.services_types import ServiceKey, ServiceVersion from models_library.users import UserID -from pydantic import NonNegativeInt, TypeAdapter +from pydantic import NonNegativeInt, TypeAdapter, validate_call +from pytest_mock import MockType from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient class CatalogRpcSideEffects: # pylint: disable=no-self-use + @validate_call(config={"arbitrary_types_allowed": True}) async def list_services_paginated( self, - rpc_client: RabbitMQRPCClient, + rpc_client: RabbitMQRPCClient | MockType, *, product_name: ProductName, user_id: UserID, @@ -58,9 +58,10 @@ async def list_services_paginated( offset=offset, ) + @validate_call(config={"arbitrary_types_allowed": True}) async def get_service( self, - rpc_client: RabbitMQRPCClient, + rpc_client: RabbitMQRPCClient | MockType, *, product_name: ProductName, user_id: UserID, @@ -87,16 +88,17 @@ async def get_service( return got + @validate_call(config={"arbitrary_types_allowed": True}) async def update_service( self, - rpc_client: RabbitMQRPCClient, + rpc_client: RabbitMQRPCClient | MockType, *, product_name: ProductName, user_id: UserID, service_key: ServiceKey, service_version: ServiceVersion, - update: CatalogServiceUpdate, - ): + update: ServiceUpdateV2, + ) -> ServiceGetV2: assert rpc_client assert product_name assert user_id @@ -108,9 +110,10 @@ async def update_service( got.key = service_key return got.model_copy(update=update.model_dump(exclude_unset=True)) + @validate_call(config={"arbitrary_types_allowed": True}) async def list_my_service_history_paginated( self, - rpc_client: RabbitMQRPCClient, + rpc_client: RabbitMQRPCClient | MockType, *, product_name: ProductName, user_id: UserID, @@ -138,9 +141,10 @@ async def list_my_service_history_paginated( offset=offset, ) + @validate_call(config={"arbitrary_types_allowed": True}) async def get_service_ports( self, - rpc_client: RabbitMQRPCClient, + rpc_client: RabbitMQRPCClient | MockType, *, product_name: ProductName, user_id: UserID, diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_rpc_server.py b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_rpc_server.py index bf21eefef1b9..5bdbb2336b25 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_rpc_server.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_rpc_server.py @@ -7,9 +7,16 @@ from models_library.products import ProductName from models_library.projects import ProjectID +from models_library.rest_pagination import PageOffsetInt +from models_library.rpc.webserver.projects import PageRpcProjectJobRpcGet +from models_library.rpc_pagination import ( + DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, + PageLimitInt, +) from models_library.users import UserID from pydantic import TypeAdapter, validate_call -from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient +from pytest_mock import MockType +from servicelib.rabbitmq import RabbitMQRPCClient class WebserverRpcSideEffects: @@ -18,7 +25,7 @@ class WebserverRpcSideEffects: @validate_call(config={"arbitrary_types_allowed": True}) async def mark_project_as_job( self, - rpc_client: RabbitMQRPCClient, + rpc_client: RabbitMQRPCClient | MockType, *, product_name: ProductName, user_id: UserID, @@ -35,3 +42,32 @@ async def mark_project_as_job( assert user_id TypeAdapter(ProjectID).validate_python(project_uuid) + + @validate_call(config={"arbitrary_types_allowed": True}) + async def list_projects_marked_as_jobs( + self, + rpc_client: RabbitMQRPCClient | MockType, + *, + product_name: ProductName, + user_id: UserID, + # pagination + offset: PageOffsetInt = 0, + limit: PageLimitInt = DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, + # filters + job_parent_resource_name_filter: str | None = None, + ) -> PageRpcProjectJobRpcGet: + assert rpc_client + assert product_name + assert user_id + + if job_parent_resource_name_filter: + assert not job_parent_resource_name_filter.startswith("/") + + items = PageRpcProjectJobRpcGet.model_json_schema()["examples"] + + return PageRpcProjectJobRpcGet.create( + items[offset, : offset + limit], + total=len(items), + limit=limit, + offset=offset, + ) diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/projects.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/projects.py index b234fe0aca05..ccfaff4028cc 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/projects.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/projects.py @@ -6,7 +6,7 @@ from models_library.projects import ProjectID from models_library.rabbitmq_basic_types import RPCMethodName from models_library.rest_pagination import PageOffsetInt -from models_library.rpc.webserver.projects import PageRpcProjectRpcGet +from models_library.rpc.webserver.projects import PageRpcProjectJobRpcGet from models_library.rpc_pagination import ( DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, PageLimitInt, @@ -53,7 +53,7 @@ async def list_projects_marked_as_jobs( limit: PageLimitInt = DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, # filters job_parent_resource_name_filter: str | None = None, -) -> PageRpcProjectRpcGet: +) -> PageRpcProjectJobRpcGet: result = await rpc_client.request( WEBSERVER_RPC_NAMESPACE, TypeAdapter(RPCMethodName).validate_python("list_projects_marked_as_jobs"), @@ -63,5 +63,5 @@ async def list_projects_marked_as_jobs( limit=limit, job_parent_resource_name_filter=job_parent_resource_name_filter, ) - assert TypeAdapter(PageRpcProjectRpcGet).validate_python(result) # nosec - return cast(PageRpcProjectRpcGet, result) + assert TypeAdapter(PageRpcProjectJobRpcGet).validate_python(result) # nosec + return cast(PageRpcProjectJobRpcGet, result) diff --git a/services/api-server/tests/unit/_with_db/test_product.py b/services/api-server/tests/unit/_with_db/test_product.py index 5c83ec557fa4..0bca5b028011 100644 --- a/services/api-server/tests/unit/_with_db/test_product.py +++ b/services/api-server/tests/unit/_with_db/test_product.py @@ -80,7 +80,7 @@ def _check_key_product_compatibility(request: httpx.Request, **kwargs): async def test_product_catalog( client: httpx.AsyncClient, - mocked_rpc_catalog_service_api: dict[str, MockType], + mocked_catalog_rpc_api: dict[str, MockType], create_fake_api_keys: Callable[[PositiveInt], AsyncGenerator[ApiKeyInDB, None]], ) -> None: assert client @@ -94,4 +94,4 @@ async def test_product_catalog( auth=httpx.BasicAuth(key.api_key, key.api_secret), ) - assert mocked_rpc_catalog_service_api["get_service"].called + assert mocked_catalog_rpc_api["get_service"].called diff --git a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs.py b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs.py index fa8160ce87ae..c5d5bc98b688 100644 --- a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs.py +++ b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs.py @@ -204,7 +204,7 @@ async def test_run_solver_job( client: httpx.AsyncClient, directorv2_service_openapi_specs: dict[str, Any], catalog_service_openapi_specs: dict[str, Any], - mocked_rpc_catalog_service_api: dict[str, MockType], + mocked_catalog_rpc_api: dict[str, MockType], mocked_directorv2_service_api: MockRouter, mocked_webserver_rest_api: MockRouter, mocked_webserver_rpc_api: dict[str, MockType], diff --git a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_delete.py b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_delete.py index 23966e15bd1e..899a18553dcb 100644 --- a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_delete.py +++ b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_delete.py @@ -125,7 +125,7 @@ async def test_create_and_delete_solver_job( client: httpx.AsyncClient, solver_key: str, solver_version: str, - mocked_rpc_catalog_service_api: dict[str, MockType], + mocked_catalog_rpc_api: dict[str, MockType], mocked_backend_services_apis_for_create_and_delete_solver_job: MockedBackendApiDict, ): # create Job @@ -155,7 +155,7 @@ async def test_create_and_delete_solver_job( assert mock_webserver_router assert mock_webserver_router["delete_project"].called - get_service = mocked_rpc_catalog_service_api["get_service"] + get_service = mocked_catalog_rpc_api["get_service"] assert get_service assert get_service.called @@ -175,7 +175,7 @@ async def test_create_job( solver_key: str, solver_version: str, mocked_backend_services_apis_for_create_and_delete_solver_job: MockedBackendApiDict, - mocked_rpc_catalog_service_api: dict[str, MockType], + mocked_catalog_rpc_api: dict[str, MockType], hidden: bool, parent_project_id: UUID | None, parent_node_id: UUID | None, diff --git a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_metadata.py b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_metadata.py index 419492287633..5d9147e7120f 100644 --- a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_metadata.py +++ b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_metadata.py @@ -39,7 +39,7 @@ def _as_path_regex(initial_path: str): def mocked_backend( mocked_webserver_rest_api: MockRouter, mocked_webserver_rpc_api: dict[str, MockType], - mocked_rpc_catalog_service_api: dict[str, MockType], + mocked_catalog_rpc_api: dict[str, MockType], project_tests_dir: Path, ) -> MockedBackendApiDict: mock_name = "for_test_get_and_update_job_metadata.json" diff --git a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_read.py b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_read.py index 7f3be4e216f8..36f028455254 100644 --- a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_read.py +++ b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_read.py @@ -25,7 +25,7 @@ class MockBackendRouters(NamedTuple): @pytest.fixture def mocked_backend( mocked_webserver_rest_api_base: MockRouter, - mocked_rpc_catalog_service_api: dict[str, MockType], + mocked_catalog_rpc_api: dict[str, MockType], project_tests_dir: Path, ) -> MockBackendRouters: mock_name = "on_list_jobs.json" @@ -47,7 +47,7 @@ def mocked_backend( return MockBackendRouters( webserver=mocked_webserver_rest_api_base, - catalog=mocked_rpc_catalog_service_api, + catalog=mocked_catalog_rpc_api, ) diff --git a/services/api-server/tests/unit/conftest.py b/services/api-server/tests/unit/conftest.py index 3c1b33864a8b..3a5bd0e81cb4 100644 --- a/services/api-server/tests/unit/conftest.py +++ b/services/api-server/tests/unit/conftest.py @@ -31,6 +31,7 @@ from models_library.api_schemas_webserver.projects import ProjectGet from models_library.app_diagnostics import AppStatusCheck from models_library.generics import Envelope +from models_library.products import ProductName from models_library.projects import ProjectID from models_library.projects_nodes_io import BaseFileLink, SimcoreS3FileID from models_library.users import UserID @@ -55,55 +56,8 @@ @pytest.fixture -def mocked_rpc_catalog_service_api( - app: FastAPI, mocker: MockerFixture -) -> Iterable[dict[str, MockType]]: - """ - Mocks the RPC catalog service API for testing purposes. - """ - - class MockRabbitMQRPCClient: - pass - - def get_mock_rabbitmq_rpc_client(): - return MockRabbitMQRPCClient() - - app.dependency_overrides[get_rabbitmq_rpc_client] = get_mock_rabbitmq_rpc_client - side_effects = CatalogRpcSideEffects() - - yield { - "list_services_paginated": mocker.patch.object( - catalog_rpc, - "list_services_paginated", - autospec=True, - side_effect=side_effects.list_services_paginated, - ), - "get_service": mocker.patch.object( - catalog_rpc, - "get_service", - autospec=True, - side_effect=side_effects.get_service, - ), - "update_service": mocker.patch.object( - catalog_rpc, - "update_service", - autospec=True, - side_effect=side_effects.update_service, - ), - "list_my_service_history_paginated": mocker.patch.object( - catalog_rpc, - "list_my_service_history_paginated", - autospec=True, - side_effect=side_effects.list_my_service_history_paginated, - ), - "get_service_ports": mocker.patch.object( - catalog_rpc, - "get_service_ports", - autospec=True, - side_effect=side_effects.get_service_ports, - ), - } - app.dependency_overrides.pop(get_rabbitmq_rpc_client) +def product_name() -> ProductName: + return "osparc" @pytest.fixture @@ -278,7 +232,7 @@ def mocked_s3_server_url() -> Iterator[HttpUrl]: print(f"<-- stopped mock S3 server on {endpoint_url}") -## MOCKED stack services -------------------------------------------------- +## MOCKED res/web APIs from simcore services ------------------------------------------ @pytest.fixture @@ -417,6 +371,11 @@ def mocked_webserver_rpc_api( "mark_project_as_job", side_effects.mark_project_as_job, ), + "list_projects_marked_as_jobs": mocker.patch.object( + projects_rpc, + "list_projects_marked_as_jobs", + side_effects.list_projects_marked_as_jobs, + ), } @@ -513,6 +472,60 @@ def mocked_catalog_rest_api_base( yield respx_mock +@pytest.fixture +def mocked_catalog_rpc_api( + app: FastAPI, mocker: MockerFixture +) -> Iterable[dict[str, MockType]]: + """ + Mocks the RPC catalog service API for testing purposes. + """ + + def get_mock_rabbitmq_rpc_client(): + return MagicMock() + + app.dependency_overrides[get_rabbitmq_rpc_client] = get_mock_rabbitmq_rpc_client + side_effects = CatalogRpcSideEffects() + + yield { + "list_services_paginated": mocker.patch.object( + catalog_rpc, + "list_services_paginated", + autospec=True, + side_effect=side_effects.list_services_paginated, + ), + "get_service": mocker.patch.object( + catalog_rpc, + "get_service", + autospec=True, + side_effect=side_effects.get_service, + ), + "update_service": mocker.patch.object( + catalog_rpc, + "update_service", + autospec=True, + side_effect=side_effects.update_service, + ), + "list_my_service_history_paginated": mocker.patch.object( + catalog_rpc, + "list_my_service_history_paginated", + autospec=True, + side_effect=side_effects.list_my_service_history_paginated, + ), + "get_service_ports": mocker.patch.object( + catalog_rpc, + "get_service_ports", + autospec=True, + side_effect=side_effects.get_service_ports, + ), + } + app.dependency_overrides.pop(get_rabbitmq_rpc_client) + + +# +# Other Mocks +# + + @pytest.fixture def mocked_solver_job_outputs(mocker) -> None: result: dict[str, ResultsTypes] = {} diff --git a/services/api-server/tests/unit/test_api_programs.py b/services/api-server/tests/unit/test_api_programs.py index a0ad30eb1d12..f087757e0993 100644 --- a/services/api-server/tests/unit/test_api_programs.py +++ b/services/api-server/tests/unit/test_api_programs.py @@ -24,7 +24,7 @@ async def test_get_program_release( auth: httpx.BasicAuth, client: AsyncClient, - mocked_rpc_catalog_service_api: dict[str, MockType], + mocked_catalog_rpc_api: dict[str, MockType], mocker: MockerFixture, user_id: UserID, ): @@ -48,7 +48,7 @@ async def test_create_program_job( auth: httpx.BasicAuth, client: AsyncClient, mocked_webserver_rest_api_base, - mocked_rpc_catalog_service_api: dict[str, MockType], + mocked_catalog_rpc_api: dict[str, MockType], create_respx_mock_from_capture: CreateRespxMockCallback, mocker: MockerFixture, user_id: UserID, diff --git a/services/api-server/tests/unit/test_api_solvers.py b/services/api-server/tests/unit/test_api_solvers.py index e7ac79d0cc13..0f76b520e01f 100644 --- a/services/api-server/tests/unit/test_api_solvers.py +++ b/services/api-server/tests/unit/test_api_solvers.py @@ -60,7 +60,7 @@ async def test_get_solver_pricing_plan( ) async def test_get_latest_solver_release( client: AsyncClient, - mocked_rpc_catalog_service_api, + mocked_catalog_rpc_api, auth: httpx.BasicAuth, solver_key: str, expected_status_code: int, diff --git a/services/api-server/tests/unit/test_services_catalog.py b/services/api-server/tests/unit/test_services_catalog.py index 35e6e94455bf..7dc2d6f22f86 100644 --- a/services/api-server/tests/unit/test_services_catalog.py +++ b/services/api-server/tests/unit/test_services_catalog.py @@ -4,7 +4,6 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable -import pytest from fastapi import FastAPI from models_library.api_schemas_catalog.services import LatestServiceGet, ServiceGetV2 from models_library.products import ProductName @@ -16,11 +15,6 @@ from simcore_service_api_server.services_rpc.catalog import CatalogService -@pytest.fixture -def product_name() -> ProductName: - return "osparc" - - def to_solver( service: LatestServiceGet | ServiceGetV2, href_self: HttpUrl | None = None ) -> Solver: @@ -40,7 +34,7 @@ async def test_catalog_service_read_solvers( product_name: ProductName, user_id: UserID, mocker: MockerFixture, - mocked_rpc_catalog_service_api: dict[str, MockType], + mocked_catalog_rpc_api: dict[str, MockType], ): catalog_service = CatalogService(client=mocker.MagicMock()) @@ -90,9 +84,7 @@ async def test_catalog_service_read_solvers( assert any(port.kind == "output" for port in ports), "Should contain output ports" # checks calls to rpc - mocked_rpc_catalog_service_api["list_services_paginated"].assert_called_once() - mocked_rpc_catalog_service_api[ - "list_my_service_history_paginated" - ].assert_called_once() - mocked_rpc_catalog_service_api["get_service"].assert_called_once() - mocked_rpc_catalog_service_api["get_service_ports"].assert_called_once() + mocked_catalog_rpc_api["list_services_paginated"].assert_called_once() + mocked_catalog_rpc_api["list_my_service_history_paginated"].assert_called_once() + mocked_catalog_rpc_api["get_service"].assert_called_once() + mocked_catalog_rpc_api["get_service_ports"].assert_called_once() diff --git a/services/director-v2/src/simcore_service_director_v2/utils/dags.py b/services/director-v2/src/simcore_service_director_v2/utils/dags.py index 2b8593fce072..a1ae47622786 100644 --- a/services/director-v2/src/simcore_service_director_v2/utils/dags.py +++ b/services/director-v2/src/simcore_service_director_v2/utils/dags.py @@ -246,7 +246,7 @@ async def compute_pipeline_details( node_id: NodeState( modified=node_data.get(kNODE_MODIFIED_STATE, False), dependencies=node_data.get(kNODE_DEPENDENCIES_TO_COMPUTE, set()), - currentStatus=node_id_to_comp_task[node_id].state, + current_status=node_id_to_comp_task[node_id].state, progress=( node_id_to_comp_task[node_id].progress if node_id_to_comp_task[node_id].progress is not None diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rpc.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rpc.py index 80b560c207ac..d3bd67d0bbfb 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rpc.py +++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rpc.py @@ -3,7 +3,10 @@ from models_library.products import ProductName from models_library.projects import ProjectID from models_library.rest_pagination import PageLimitInt, PageOffsetInt -from models_library.rpc.webserver.projects import PageRpcProjectRpcGet, ProjectRpcGet +from models_library.rpc.webserver.projects import ( + PageRpcProjectJobRpcGet, + ProjectJobRpcGet, +) from models_library.users import UserID from pydantic import ValidationError, validate_call from servicelib.rabbitmq import RPCRouter @@ -64,7 +67,7 @@ async def list_projects_marked_as_jobs( limit: PageLimitInt, # filters job_parent_resource_name_filter: str | None, -) -> PageRpcProjectRpcGet: +) -> PageRpcProjectJobRpcGet: total, projects = await _jobs_service.list_my_projects_marked_as_jobs( app, @@ -76,17 +79,19 @@ async def list_projects_marked_as_jobs( ) job_projects = [ - ProjectRpcGet( + ProjectJobRpcGet( uuid=project.uuid, name=project.name, description=project.description, - creation_date=project.creation_date, - last_change_date=project.last_change_date, + workbench=project.workbench, + creation_at=project.creation_date, + modified_at=project.last_change_date, + job_parent_resource_name=project.job_parent_resource_name, ) for project in projects ] - page: PageRpcProjectRpcGet = PageRpcProjectRpcGet.create( + page: PageRpcProjectJobRpcGet = PageRpcProjectJobRpcGet.create( job_projects, total=total, limit=limit, diff --git a/services/web/server/src/simcore_service_webserver/projects/_jobs_repository.py b/services/web/server/src/simcore_service_webserver/projects/_jobs_repository.py index 2aeb8ad7ca42..5981f5fe7b0f 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_jobs_repository.py +++ b/services/web/server/src/simcore_service_webserver/projects/_jobs_repository.py @@ -113,6 +113,7 @@ async def list_projects_marked_as_jobs( list_query = ( sa.select( *_PROJECT_DB_COLS, + projects.c.workbench, base_query.c.job_parent_resource_name, ) .select_from( diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py index ca3e03978a4f..b7a7d484c165 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py @@ -1024,7 +1024,7 @@ async def update_project_node_state( project_id=project_id, node_id=node_id, partial_node=PartialNode.model_construct( - state=NodeState(currentStatus=RunningState(new_state)) + state=NodeState(current_status=RunningState(new_state)) ), ) diff --git a/services/web/server/src/simcore_service_webserver/projects/models.py b/services/web/server/src/simcore_service_webserver/projects/models.py index a20a009c8295..6eabb26d0e59 100644 --- a/services/web/server/src/simcore_service_webserver/projects/models.py +++ b/services/web/server/src/simcore_service_webserver/projects/models.py @@ -8,7 +8,7 @@ from models_library.api_schemas_webserver.projects_ui import StudyUI from models_library.folders import FolderID from models_library.groups import GroupID -from models_library.projects import ClassifierID, ProjectID +from models_library.projects import ClassifierID, NodesDict, ProjectID from models_library.users import UserID from models_library.utils.common_validators import ( empty_str_to_none_pre_validator, @@ -72,6 +72,8 @@ class ProjectDBGet(BaseModel): class ProjectJobDBGet(ProjectDBGet): + workbench: NodesDict + job_parent_resource_name: str diff --git a/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py b/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py index 881282abe6fe..7bce1dda8840 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py +++ b/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py @@ -45,7 +45,7 @@ def app_environment( @pytest.fixture -def mocked_rpc_catalog_service_api(mocker: MockerFixture) -> dict[str, MockType]: +def mocked_catalog_rpc_api(mocker: MockerFixture) -> dict[str, MockType]: side_effects = CatalogRpcSideEffects() @@ -75,7 +75,7 @@ def mocked_rpc_catalog_service_api(mocker: MockerFixture) -> dict[str, MockType] async def test_list_services_latest( client: TestClient, logged_user: UserInfoDict, - mocked_rpc_catalog_service_api: dict[str, MockType], + mocked_catalog_rpc_api: dict[str, MockType], ): assert client.app assert client.app.router @@ -93,7 +93,7 @@ async def test_list_services_latest( assert model.data assert len(model.data) == model.meta.count - assert mocked_rpc_catalog_service_api["list_services_paginated"].call_count == 1 + assert mocked_catalog_rpc_api["list_services_paginated"].call_count == 1 @pytest.mark.parametrize( @@ -293,7 +293,7 @@ async def test_get_compatible_outputs_given_target_inptuts( async def test_get_and_patch_service( client: TestClient, logged_user: UserInfoDict, - mocked_rpc_catalog_service_api: dict[str, MockType], + mocked_catalog_rpc_api: dict[str, MockType], faker: Faker, ): assert client.app @@ -317,8 +317,8 @@ async def test_get_and_patch_service( assert model.key == service_key assert model.version == service_version - assert mocked_rpc_catalog_service_api["get_service"].call_count == 1 - assert not mocked_rpc_catalog_service_api["update_service"].called + assert mocked_catalog_rpc_api["get_service"].call_count == 1 + assert not mocked_catalog_rpc_api["update_service"].called # PATCH update = CatalogServiceUpdate( @@ -348,8 +348,8 @@ async def test_get_and_patch_service( assert model.version_display == update.version_display assert model.access_rights == update.access_rights - assert mocked_rpc_catalog_service_api["get_service"].call_count == 1 - assert mocked_rpc_catalog_service_api["update_service"].call_count == 1 + assert mocked_catalog_rpc_api["get_service"].call_count == 1 + assert mocked_catalog_rpc_api["update_service"].call_count == 1 @pytest.mark.xfail(reason="service tags entrypoints under development") @@ -360,7 +360,7 @@ async def test_get_and_patch_service( async def test_tags_in_services( client: TestClient, logged_user: UserInfoDict, - mocked_rpc_catalog_service_api: dict[str, MockType], + mocked_catalog_rpc_api: dict[str, MockType], ): assert client.app assert client.app.router diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects__jobs_service.py b/services/web/server/tests/unit/with_dbs/02/test_projects__jobs_service.py index acb93b0555dc..c33274ca609c 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects__jobs_service.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects__jobs_service.py @@ -81,6 +81,7 @@ async def test_user_can_see_marked_project( client: TestClient, osparc_product_name: ProductName, project_job_fixture: ProjectJobFixture, + user_project: ProjectDict, ): assert client.app total_count, result = await list_my_projects_marked_as_jobs( @@ -97,6 +98,34 @@ async def test_user_can_see_marked_project( project.job_parent_resource_name == project_job_fixture.job_parent_resource_name ) + # Verify workbench structure is maintained + assert project.workbench is not None + assert isinstance(project.workbench, dict) + + # Compare with original user_project + assert len(project.workbench) == len(user_project["workbench"]) + + # Check that all node IDs from original project are present + for node_id in user_project["workbench"]: + assert node_id in project.workbench + + # Check some properties of the nodes + original_node = user_project["workbench"][node_id] + project_node = project.workbench[node_id] + + assert project_node.key == original_node["key"] + assert project_node.version == original_node["version"] + assert project_node.label == original_node["label"] + + # Check inputs/outputs if they exist + if "inputs" in original_node: + assert project_node.inputs is not None + assert len(project_node.inputs) == len(original_node["inputs"]) + + if "outputs" in original_node: + assert project_node.outputs is not None + assert len(project_node.outputs) == len(original_node["outputs"]) + async def test_other_user_cannot_see_marked_project( client: TestClient, diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_rpc.py b/services/web/server/tests/unit/with_dbs/02/test_projects_rpc.py index 69dceae2f7a6..b18067e53831 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_rpc.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_rpc.py @@ -12,7 +12,10 @@ from common_library.users_enums import UserRole from models_library.products import ProductName from models_library.projects import ProjectID -from models_library.rpc.webserver.projects import PageRpcProjectRpcGet, ProjectRpcGet +from models_library.rpc.webserver.projects import ( + PageRpcProjectJobRpcGet, + ProjectJobRpcGet, +) from pydantic import ValidationError from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict @@ -107,7 +110,7 @@ async def test_rpc_client_list_my_projects_marked_as_jobs( ) # List projects marked as jobs - page: PageRpcProjectRpcGet = await projects_rpc.list_projects_marked_as_jobs( + page: PageRpcProjectJobRpcGet = await projects_rpc.list_projects_marked_as_jobs( rpc_client=rpc_client, product_name=product_name, user_id=user_id, @@ -116,8 +119,14 @@ async def test_rpc_client_list_my_projects_marked_as_jobs( assert page.meta.total == 1 assert page.meta.offset == 0 - assert isinstance(page.data[0], ProjectRpcGet) - assert page.data[0].uuid == project_uuid + assert isinstance(page.data[0], ProjectJobRpcGet) + + project_job = page.data[0] + assert project_job.uuid == project_uuid + assert project_job.name == user_project["name"] + assert project_job.description == user_project["description"] + + assert set(project_job.workbench.keys()) == set(user_project["workbench"].keys()) @pytest.fixture