diff --git a/services/api-server/src/simcore_service_api_server/_service_function_jobs_task_client.py b/services/api-server/src/simcore_service_api_server/_service_function_jobs_task_client.py index e49f7855a1ca..80a4af8763ac 100644 --- a/services/api-server/src/simcore_service_api_server/_service_function_jobs_task_client.py +++ b/services/api-server/src/simcore_service_api_server/_service_function_jobs_task_client.py @@ -41,12 +41,13 @@ from ._service_functions import FunctionService from ._service_jobs import JobService from .api.dependencies.authentication import Identity -from .exceptions.function_errors import ( - FunctionJobCacheNotFoundError, +from .exceptions.backend_errors import ( + SolverJobOutputRequestButNotSucceededError, + StudyJobOutputRequestButNotSucceededError, ) +from .exceptions.function_errors import FunctionJobCacheNotFoundError from .models.api_resources import JobLinks from .models.domain.celery_models import ApiServerOwnerMetadata -from .models.schemas.functions import FunctionJobCreationTaskStatus from .models.schemas.jobs import JobInputs, JobPricingSpecification from .services_http.webserver import AuthSession from .services_rpc.storage import StorageService @@ -260,7 +261,7 @@ async def get_cached_function_job( raise FunctionJobCacheNotFoundError - async def function_job_outputs( + async def function_job_outputs( # noqa: PLR0911 # pylint: disable=too-many-return-statements self, *, function: RegisteredFunction, @@ -283,30 +284,36 @@ async def function_job_outputs( ): if function_job.project_job_id is None: return None - new_outputs = dict( - ( - await self._job_service.get_study_job_outputs( - study_id=function.project_id, - job_id=function_job.project_job_id, - ) - ).results - ) + try: + new_outputs = dict( + ( + await self._job_service.get_study_job_outputs( + study_id=function.project_id, + job_id=function_job.project_job_id, + ) + ).results + ) + except StudyJobOutputRequestButNotSucceededError: + return None elif ( function.function_class == FunctionClass.SOLVER and function_job.function_class == FunctionClass.SOLVER ): if function_job.solver_job_id is None: return None - new_outputs = dict( - ( - await self._job_service.get_solver_job_outputs( - solver_key=function.solver_key, - version=function.solver_version, - job_id=function_job.solver_job_id, - async_pg_engine=self._async_pg_engine, - ) - ).results - ) + try: + new_outputs = dict( + ( + await self._job_service.get_solver_job_outputs( + solver_key=function.solver_key, + version=function.solver_version, + job_id=function_job.solver_job_id, + async_pg_engine=self._async_pg_engine, + ) + ).results + ) + except SolverJobOutputRequestButNotSucceededError: + return None else: raise UnsupportedFunctionClassError(function_class=function.function_class) diff --git a/services/api-server/src/simcore_service_api_server/_service_jobs.py b/services/api-server/src/simcore_service_api_server/_service_jobs.py index c01eac1fa23a..d1182aa545d6 100644 --- a/services/api-server/src/simcore_service_api_server/_service_jobs.py +++ b/services/api-server/src/simcore_service_api_server/_service_jobs.py @@ -20,6 +20,7 @@ from models_library.projects import ProjectID from models_library.projects_nodes import InputID, InputTypes from models_library.projects_nodes_io import BaseFileLink, NodeID +from models_library.projects_state import RunningState from models_library.rest_pagination import PageMetaInfoLimitOffset, PageOffsetInt from models_library.rpc.webserver.projects import ProjectJobRpcGet from models_library.rpc_pagination import PageLimitInt @@ -29,7 +30,11 @@ from sqlalchemy.ext.asyncio import AsyncEngine from ._service_solvers import SolverService -from .exceptions.backend_errors import JobAssetsMissingError +from .exceptions.backend_errors import ( + JobAssetsMissingError, + SolverJobOutputRequestButNotSucceededError, + StudyJobOutputRequestButNotSucceededError, +) from .exceptions.custom_errors import ( InsufficientCreditsError, MissingWalletError, @@ -308,6 +313,15 @@ async def get_solver_job_outputs( job_name = compose_solver_job_resource_name(solver_key, version, job_id) _logger.debug("Get Job '%s' outputs", job_name) + job_status = await self.inspect_solver_job( + solver_key=solver_key, version=version, job_id=job_id + ) + + if job_status.state != RunningState.SUCCESS: + raise SolverJobOutputRequestButNotSucceededError( + job_id=job_id, state=job_status.state + ) + project_marked_as_job = await self.get_job( job_id=job_id, job_parent_resource_name=Solver.compose_resource_name( @@ -379,9 +393,16 @@ async def get_study_job_outputs( job_name = compose_study_job_resource_name(study_id, job_id) _logger.debug("Getting Job Outputs for '%s'", job_name) + job_status = await self.inspect_study_job(job_id=job_id) + + if job_status.state != RunningState.SUCCESS: + raise StudyJobOutputRequestButNotSucceededError( + job_id=job_id, state=job_status.state + ) project_outputs = await self._web_rest_client.get_project_outputs( project_id=job_id ) + return await create_job_outputs_from_project_outputs( job_id, project_outputs, self.user_id, self._storage_rest_client ) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/backend_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/backend_errors.py index 322911ed9e4f..26e74e6d8200 100644 --- a/services/api-server/src/simcore_service_api_server/exceptions/backend_errors.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/backend_errors.py @@ -151,3 +151,13 @@ class JobAssetsMissingError(BaseBackEndError): class CeleryTaskNotFoundError(BaseBackEndError): msg_template = "Task {task_uuid} not found" status_code = status.HTTP_404_NOT_FOUND + + +class SolverJobOutputRequestButNotSucceededError(BaseBackEndError): + msg_template = "Solver job '{job_id}' not succeeded, when output is requested. Current state: {state}" + status_code = status.HTTP_409_CONFLICT + + +class StudyJobOutputRequestButNotSucceededError(BaseBackEndError): + msg_template = "Study job '{job_id}' not succeeded, when output is requested. Current state: {state}" + status_code = status.HTTP_409_CONFLICT diff --git a/services/api-server/tests/unit/api_functions/conftest.py b/services/api-server/tests/unit/api_functions/conftest.py index 4b0ab91b7216..c676288c5aed 100644 --- a/services/api-server/tests/unit/api_functions/conftest.py +++ b/services/api-server/tests/unit/api_functions/conftest.py @@ -38,6 +38,9 @@ from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.typing_mock import HandlerMockFactory +from servicelib.rabbitmq.rpc_interfaces.webserver.v1.functions import FunctionsRpcApi +from servicelib.rabbitmq.rpc_interfaces.webserver.v1.projects import ProjectsRpcApi +from simcore_service_api_server.api.dependencies import services @pytest.fixture @@ -62,8 +65,6 @@ async def mock_dependency_get_celery_task_manager( def _new(app: FastAPI): return None - from simcore_service_api_server.api.dependencies import services - return mocker.patch.object(services, services.get_task_manager.__name__, _new) @@ -243,9 +244,6 @@ def _create( exception: Exception | None = None, side_effect: Callable | None = None, ) -> MockType: - from servicelib.rabbitmq.rpc_interfaces.webserver.v1.functions import ( - FunctionsRpcApi, - ) assert exception is None or side_effect is None @@ -272,9 +270,6 @@ def _create( exception: Exception | None = None, side_effect: Callable | None = None, ) -> MockType: - from servicelib.rabbitmq.rpc_interfaces.webserver.v1.projects import ( - ProjectsRpcApi, - ) assert exception is None or side_effect is None @@ -286,25 +281,3 @@ def _create( ) return _create - - -@pytest.fixture() -def mock_method_in_jobs_service( - mocked_app_rpc_dependencies: None, - mocker: MockerFixture, -) -> Callable[[str, Any, Exception | None], MockType]: - def _create( - method_name: str = "", - return_value: Any = None, - exception: Exception | None = None, - ) -> MockType: - from simcore_service_api_server._service_jobs import JobService - - return mocker.patch.object( - JobService, - method_name, - return_value=return_value, - side_effect=exception, - ) - - return _create 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 b5728ad2aa51..9a4fa3a55ac9 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 @@ -3,6 +3,9 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments +import datetime +import uuid +from collections.abc import Callable from pathlib import Path from pprint import pprint from typing import Any @@ -14,7 +17,8 @@ import pytest from faker import Faker from fastapi import FastAPI -from models_library.services import ServiceMetaDataPublished +from models_library.projects import ProjectID +from models_library.projects_state import RunningState from models_library.utils.fastapi_encoders import jsonable_encoder from pydantic import AnyUrl, HttpUrl, TypeAdapter from pytest_mock import MockType @@ -129,23 +133,8 @@ def mocked_directorv2_rest_api( def test_download_presigned_link( presigned_download_link: AnyUrl, tmp_path: Path, project_id: str, node_id: str ): - """Cheks that the generation of presigned_download_link works as expected""" + """Checks that the generation of presigned_download_link works as expected""" r = httpx.get(f"{presigned_download_link}") - ## pprint(dict(r.headers)) - # r.headers looks like: - # { - # 'access-control-allow-origin': '*', - # 'connection': 'close', - # 'content-length': '491', - # 'content-md5': 'HoY5Kfgqb9VSdS44CYBxnA==', - # 'content-type': 'binary/octet-stream', - # 'date': 'Thu, 19 May 2022 22:16:48 GMT', - # 'etag': '"1e863929f82a6fd552752e380980719c"', - # 'last-modified': 'Thu, 19 May 2022 22:16:48 GMT', - # 'server': 'Werkzeug/2.1.2 Python/3.9.12', - # 'x-amz-version-id': 'null', - # 'x-amzn-requestid': 'WMAPXWFR2G4EJRVYBNJDRHTCXJ7NBRMDN7QQNHTQ5RYAQ34ZZNAL' - # } assert r.status_code == status.HTTP_200_OK expected_fname = f"{project_id}-{node_id}.log" @@ -196,6 +185,55 @@ async def test_solver_logs( pprint(dict(resp.headers)) # noqa: T203 +@pytest.mark.parametrize( + "job_outputs, project_id, job_state, expected_output, expected_status_code, expected_error_message", + [ + ( + None, + uuid.uuid4(), + RunningState.STARTED, + None, + status.HTTP_409_CONFLICT, + "not succeeded, when output is requested", + ), + ], +) +async def test_solver_job_outputs( + client: httpx.AsyncClient, + auth: httpx.BasicAuth, + job_outputs: dict[str, Any] | None, + project_id: ProjectID, + job_state: RunningState, + expected_output: dict[str, Any] | None, + mock_method_in_jobs_service: Callable[[str, Any], MockType], + expected_status_code: int, + expected_error_message: str | None, + solver_key: str, + solver_version: str, +) -> None: + + job_status = JobStatus( + state=job_state, + job_id=project_id, + submitted_at=datetime.datetime.now(tz=datetime.UTC), + started_at=datetime.datetime.now(tz=datetime.UTC), + stopped_at=datetime.datetime.now(tz=datetime.UTC), + progress=0, + ) + mock_method_in_jobs_service("inspect_solver_job", job_status) + + response = await client.get( + f"{API_VTAG}/solvers/{solver_key}/releases/{solver_version}/jobs/{project_id}/outputs", + auth=auth, + ) + assert response.status_code == expected_status_code + data = response.json() + if expected_error_message: + assert "not succeeded, when output is requested" in data["errors"][0] + if expected_output: + assert data == expected_output + + @pytest.mark.acceptance_test( "New feature https://github.com/ITISFoundation/osparc-simcore/issues/3940" ) @@ -311,12 +349,6 @@ async def test_run_solver_job( "owner", } == set(oas["components"]["schemas"]["ServiceGet"]["required"]) - example = next( - e - for e in ServiceMetaDataPublished.model_json_schema()["examples"] - if "boot-options" in e - ) - # --------------------------------------------------------------------------------------------------------- resp = await client.get(f"/{API_VTAG}/meta") diff --git a/services/api-server/tests/unit/api_studies/test_api_routes_studies_jobs.py b/services/api-server/tests/unit/api_studies/test_api_routes_studies_jobs.py index 84b300cf07ae..1a0e6eebb801 100644 --- a/services/api-server/tests/unit/api_studies/test_api_routes_studies_jobs.py +++ b/services/api-server/tests/unit/api_studies/test_api_routes_studies_jobs.py @@ -4,7 +4,10 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable +import datetime import json +import uuid +from collections.abc import Callable from pathlib import Path from typing import Any, Final from uuid import UUID @@ -14,6 +17,7 @@ import respx from faker import Faker from fastapi import status +from models_library.projects_state import RunningState from pytest_mock import MockType from pytest_simcore.helpers.httpx_calls_capture_models import ( CreateRespxMockCallback, @@ -278,22 +282,53 @@ def _default_side_effect( assert _default_side_effect.post_called +@pytest.mark.parametrize( + "project_id, job_state, expected_status_code, expected_error_message", + [ + ( + uuid.uuid4(), + RunningState.STARTED, + status.HTTP_409_CONFLICT, + "not succeeded, when output is requested", + ), + ( + uuid.uuid4(), + RunningState.SUCCESS, + status.HTTP_200_OK, + None, + ), + ], +) async def test_get_study_job_outputs( client: httpx.AsyncClient, fake_study_id: UUID, auth: httpx.BasicAuth, + project_id: UUID, + job_state: RunningState, + expected_status_code: int, + expected_error_message: str | None, mocked_webserver_rest_api_base: respx.MockRouter, mocked_webserver_rpc_api: dict[str, MockType], + mock_method_in_jobs_service: Callable[[str, Any], MockType], ): - job_id = "cfe9a77a-f71e-11ee-8fca-0242ac140008" + + job_status = JobStatus( + state=job_state, + job_id=project_id, + submitted_at=datetime.datetime.now(tz=datetime.UTC), + started_at=datetime.datetime.now(tz=datetime.UTC), + stopped_at=datetime.datetime.now(tz=datetime.UTC), + progress=0, + ) + mock_method_in_jobs_service("inspect_study_job", job_status) capture = { - "name": "GET /projects/cfe9a77a-f71e-11ee-8fca-0242ac140008/outputs", - "description": "", + "name": f"GET /projects/{project_id}/outputs", + "description": f"", "method": "GET", "host": "webserver", "path": { - "path": "/v0/projects/{project_id}/outputs", + "path": f"/v0/projects/{project_id}/outputs", "path_parameters": [ { "in": "path", @@ -321,21 +356,24 @@ async def test_get_study_job_outputs( } mocked_webserver_rest_api_base.get( - path=capture["path"]["path"].format(project_id=job_id) + path=capture["path"]["path"].format(project_id=project_id) ).respond( status_code=capture["status_code"], json=capture["response_body"], ) response = await client.post( - f"{API_VTAG}/studies/{fake_study_id}/jobs/{job_id}/outputs", + f"{API_VTAG}/studies/{fake_study_id}/jobs/{project_id}/outputs", auth=auth, ) - assert response.status_code == status.HTTP_200_OK - job_outputs = JobOutputs(**response.json()) + assert response.status_code == expected_status_code + if expected_error_message: + assert expected_error_message in response.text + else: + job_outputs = JobOutputs(**response.json()) - assert str(job_outputs.job_id) == job_id - assert job_outputs.results == {} + assert str(job_outputs.job_id) == str(project_id) + assert job_outputs.results == {} async def test_get_job_logs( @@ -373,10 +411,21 @@ async def test_get_study_outputs( mocked_directorv2_rest_api_base: respx.MockRouter, auth: httpx.BasicAuth, project_tests_dir: Path, + mock_method_in_jobs_service: Callable[[str, Any], MockType], ): _study_id = "e9f34992-436c-11ef-a15d-0242ac14000c" + job_status = JobStatus( + state=RunningState.SUCCESS, + job_id=uuid.UUID(_study_id), + submitted_at=datetime.datetime.now(tz=datetime.UTC), + started_at=datetime.datetime.now(tz=datetime.UTC), + stopped_at=datetime.datetime.now(tz=datetime.UTC), + progress=0, + ) + mock_method_in_jobs_service("inspect_study_job", job_status) + create_respx_mock_from_capture( respx_mocks=[ mocked_directorv2_rest_api_base, diff --git a/services/api-server/tests/unit/conftest.py b/services/api-server/tests/unit/conftest.py index 2ddba69b5858..b41f6b14f568 100644 --- a/services/api-server/tests/unit/conftest.py +++ b/services/api-server/tests/unit/conftest.py @@ -62,6 +62,7 @@ from requests.auth import HTTPBasicAuth from respx import MockRouter from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient +from simcore_service_api_server._service_jobs import JobService from simcore_service_api_server.api.dependencies.authentication import Identity from simcore_service_api_server.core.application import create_app from simcore_service_api_server.core.settings import ApplicationSettings @@ -185,7 +186,7 @@ async def client( yield httpx_async_client -## MOCKED Repositories -------------------------------------------------- +# MOCKED Repositories -------------------------------------------------- @pytest.fixture @@ -227,7 +228,7 @@ def auth( return HTTPBasicAuth(user_api_key, user_api_secret) -## MOCKED S3 service -------------------------------------------------- +# MOCKED S3 service -------------------------------------------------- @pytest.fixture @@ -255,7 +256,7 @@ def mocked_s3_server_url() -> Iterator[HttpUrl]: print(f"<-- stopped mock S3 server on {endpoint_url}") -## MOCKED res/web APIs from simcore services ------------------------------------------ +# MOCKED res/web APIs from simcore services ------------------------------------------ @pytest.fixture @@ -1034,3 +1035,24 @@ def openapi_dev_specs(project_slug_dir: Path) -> dict[str, Any]: ) assert openapi_file.is_file() return json.loads(openapi_file.read_text()) + + +@pytest.fixture() +def mock_method_in_jobs_service( + mocked_app_rpc_dependencies: None, + mocker: MockerFixture, +) -> Callable[[str, Any, Exception | None], MockType]: + def _create( + method_name: str = "", + return_value: Any = None, + exception: Exception | None = None, + ) -> MockType: + + return mocker.patch.object( + JobService, + method_name, + return_value=return_value, + side_effect=exception, + ) + + return _create diff --git a/services/api-server/tests/unit/test_api_solver_jobs.py b/services/api-server/tests/unit/test_api_solver_jobs.py index 8a9b7797c6fe..06244173857a 100644 --- a/services/api-server/tests/unit/test_api_solver_jobs.py +++ b/services/api-server/tests/unit/test_api_solver_jobs.py @@ -3,6 +3,9 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments +import datetime as datetime_main +import uuid +from collections.abc import Callable from datetime import datetime from decimal import Decimal from pathlib import Path @@ -17,6 +20,7 @@ from httpx import AsyncClient from models_library.generics import Envelope from models_library.projects_nodes import Node +from models_library.projects_state import RunningState from models_library.rpc.webserver.projects import ProjectJobRpcGet from pydantic import TypeAdapter from pytest_mock import MockType @@ -460,6 +464,7 @@ async def test_get_solver_job_outputs( project_tests_dir: Path, sufficient_credits: bool, expected_status_code: int, + mock_method_in_jobs_service: Callable[[str, Any], MockType], ): def _sf( request: httpx.Request, @@ -486,18 +491,35 @@ def _wallet_side_effect( envelope.data = wallet return jsonable_encoder(envelope) + _job_id: Final[str] = "1eefc09b-5d08-4022-bc18-33dedbbd7d0f" + + job_status = JobStatus( + state=RunningState.SUCCESS, + job_id=uuid.UUID(_job_id), + submitted_at=datetime.now(tz=datetime_main.UTC), + started_at=datetime.now(tz=datetime_main.UTC), + stopped_at=datetime.now(tz=datetime_main.UTC), + progress=0, + ) + mock_method_in_jobs_service("inspect_solver_job", job_status) + create_respx_mock_from_capture( respx_mocks=[ mocked_webserver_rest_api_base, mocked_storage_rest_api_base, ], capture_path=project_tests_dir / "mocks" / "get_solver_outputs.json", - side_effects_callbacks=[_sf, _sf, _sf, _wallet_side_effect, _sf], + side_effects_callbacks=[ + _sf, + _sf, + _sf, + _wallet_side_effect, + _sf, + ], ) _solver_key: Final[str] = "simcore/services/comp/isolve" _version: Final[str] = "2.1.24" - _job_id: Final[str] = "1eefc09b-5d08-4022-bc18-33dedbbd7d0f" response = await client.get( f"{API_VTAG}/solvers/{_solver_key}/releases/{_version}/jobs/{_job_id}/outputs", auth=auth, @@ -534,6 +556,7 @@ async def test_get_solver_job_outputs_assets_deleted( create_respx_mock_from_capture: CreateRespxMockCallback, auth: httpx.BasicAuth, project_tests_dir: Path, + mock_method_in_jobs_service: Callable[[str, Any], MockType], ): def _sf( request: httpx.Request, @@ -542,18 +565,28 @@ def _sf( ) -> Any: return capture.response_body + _job_id: Final[str] = "1eefc09b-5d08-4022-bc18-33dedbbd7d0f" + + job_status = JobStatus( + state=RunningState.SUCCESS, + job_id=uuid.UUID(_job_id), + submitted_at=datetime.now(tz=datetime_main.UTC), + started_at=datetime.now(tz=datetime_main.UTC), + stopped_at=datetime.now(tz=datetime_main.UTC), + progress=0, + ) + mock_method_in_jobs_service("inspect_solver_job", job_status) create_respx_mock_from_capture( respx_mocks=[ mocked_webserver_rest_api_base, mocked_storage_rest_api_base, ], capture_path=project_tests_dir / "mocks" / "get_solver_outputs.json", - side_effects_callbacks=[_sf, _sf, _sf, _sf, _sf], + side_effects_callbacks=[_sf, _sf, _sf, _sf, _sf], # type: ignore ) _solver_key: Final[str] = "simcore/services/comp/isolve" _version: Final[str] = "2.1.24" - _job_id: Final[str] = "1eefc09b-5d08-4022-bc18-33dedbbd7d0f" response = await client.get( f"{API_VTAG}/solvers/{_solver_key}/releases/{_version}/jobs/{_job_id}/outputs", auth=auth, diff --git a/tests/public-api/test_solvers_jobs_api.py b/tests/public-api/test_solvers_jobs_api.py index ddd6445f11eb..036ffd1ba101 100644 --- a/tests/public-api/test_solvers_jobs_api.py +++ b/tests/public-api/test_solvers_jobs_api.py @@ -1,8 +1,9 @@ """ - NOTE: All tests in this module run against the same simcore deployed stack. Which means that the results in one - might affect the others. E.g. files uploaded in one test can be listed in rext +NOTE: All tests in this module run against the same simcore deployed stack. Which means that the results in one +might affect the others. E.g. files uploaded in one test can be listed in rext """ + # pylint: disable=protected-access # pylint: disable=redefined-outer-name # pylint: disable=too-many-arguments @@ -236,6 +237,11 @@ def test_run_job( # FIXME: assert status.started_at < status.stopped_at assert status.submitted_at < status.stopped_at + if expected_outcome != "SUCCESS": + with pytest.raises(osparc.ApiException): + outputs = solvers_api.get_job_outputs(solver.id, solver.version, job.id) + return + # check solver outputs outputs: osparc.JobOutputs = solvers_api.get_job_outputs( solver.id, solver.version, job.id @@ -244,37 +250,20 @@ def test_run_job( assert outputs.job_id == job.id assert len(outputs.results) == 2 - # 'outputs': {'output_1': {'description': 'Integer is generated in range [1-9]', - # 'displayOrder': 1, - # 'fileToKeyMap': {'single_number.txt': 'output_1'}, - # 'label': 'File containing one random integer', - # 'type': 'data:text/plain'}, - # 'output_2': {'description': 'Interval is generated in range ' - # '[1-9]', - # 'displayOrder': 2, - # 'label': 'Random sleep interval', - # 'type': 'integer', - # 'unit': 'second'}}, - output_file = outputs.results["output_1"] number = outputs.results["output_2"] assert status.state == expected_outcome - if expected_outcome == "SUCCESS": - assert isinstance(output_file, osparc.File) - assert isinstance(number, float) - - # output file exists - assert files_api.get_file(output_file.id) == output_file + assert isinstance(output_file, osparc.File) + assert isinstance(number, float) - # can download and open - download_path: str = files_api.download_file(file_id=output_file.id) - assert float(Path(download_path).read_text()), "contains a random number" + # output file exists + assert files_api.get_file(output_file.id) == output_file - else: - # one of them is not finished - assert output_file is None or number is None + # can download and open + download_path: str = files_api.download_file(file_id=output_file.id) + assert float(Path(download_path).read_text()), "contains a random number" # download log (Added in on API version 0.4.0 / client version 0.5.0 ) if osparc_VERSION >= (0, 5, 0):