diff --git a/packages/aws-library/src/aws_library/s3/__init__.py b/packages/aws-library/src/aws_library/s3/__init__.py index ea8f6264d604..8a9a85f1279e 100644 --- a/packages/aws-library/src/aws_library/s3/__init__.py +++ b/packages/aws-library/src/aws_library/s3/__init__.py @@ -22,10 +22,10 @@ ) __all__: tuple[str, ...] = ( - "CopiedBytesTransferredCallback", - "MultiPartUploadLinks", "PRESIGNED_LINK_MAX_SIZE", "S3_MAX_FILE_SIZE", + "CopiedBytesTransferredCallback", + "MultiPartUploadLinks", "S3AccessError", "S3BucketInvalidError", "S3DestinationNotEmptyError", @@ -37,8 +37,8 @@ "S3RuntimeError", "S3UploadNotFoundError", "SimcoreS3API", - "UploadedBytesTransferredCallback", "UploadID", + "UploadedBytesTransferredCallback", ) # nopycln: file diff --git a/packages/models-library/src/models_library/api_schemas_webserver/projects.py b/packages/models-library/src/models_library/api_schemas_webserver/projects.py index 6ef501c23114..083628693882 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/projects.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/projects.py @@ -126,7 +126,7 @@ class ProjectGet(OutputSchema): # display name: str - description: str + description: Annotated[str, BeforeValidator(none_to_empty_str_pre_validator)] thumbnail: HttpUrl | Literal[""] type: ProjectType @@ -144,7 +144,7 @@ class ProjectGet(OutputSchema): trashed_at: datetime | None trashed_by: Annotated[ GroupID | None, Field(description="The primary gid of the user who trashed") - ] + ] = None # labeling tags: list[int] @@ -166,10 +166,6 @@ class ProjectGet(OutputSchema): workspace_id: WorkspaceID | None folder_id: FolderID | None - _empty_description = field_validator("description", mode="before")( - none_to_empty_str_pre_validator - ) - @staticmethod def _update_json_schema_extra(schema: JsonDict) -> None: schema.update( diff --git a/packages/models-library/src/models_library/projects.py b/packages/models-library/src/models_library/projects.py index 2092d1fce936..e66c5a54543f 100644 --- a/packages/models-library/src/models_library/projects.py +++ b/packages/models-library/src/models_library/projects.py @@ -10,6 +10,7 @@ from common_library.basic_types import DEFAULT_FACTORY from pydantic import ( BaseModel, + BeforeValidator, ConfigDict, Field, HttpUrl, @@ -85,6 +86,7 @@ class BaseProjectModel(BaseModel): ] description: Annotated[ str, + BeforeValidator(none_to_empty_str_pre_validator), Field( description="longer one-line description about the project", examples=["Dabbling in temporal transitions ..."], @@ -92,6 +94,9 @@ class BaseProjectModel(BaseModel): ] thumbnail: Annotated[ HttpUrl | None, + BeforeValidator( + empty_str_to_none_pre_validator, + ), Field( description="url of the project thumbnail", examples=["https://placeimg.com/171/96/tech/grayscale/?0.jpg"], @@ -104,15 +109,6 @@ class BaseProjectModel(BaseModel): # Pipeline of nodes (SEE projects_nodes.py) workbench: Annotated[NodesDict, Field(description="Project's pipeline")] - # validators - _empty_thumbnail_is_none = field_validator("thumbnail", mode="before")( - empty_str_to_none_pre_validator - ) - - _none_description_is_empty = field_validator("description", mode="before")( - none_to_empty_str_pre_validator - ) - class ProjectAtDB(BaseProjectModel): # Model used to READ from database diff --git a/packages/models-library/src/models_library/projects_nodes.py b/packages/models-library/src/models_library/projects_nodes.py index 24d2e9a79915..1bb91c1ddaac 100644 --- a/packages/models-library/src/models_library/projects_nodes.py +++ b/packages/models-library/src/models_library/projects_nodes.py @@ -177,14 +177,22 @@ class NodeState(BaseModel): model_config = ConfigDict( extra="forbid", - populate_by_name=True, + validate_by_alias=True, + validate_by_name=True, json_schema_extra={ "examples": [ + # example with alias name { "modified": True, "dependencies": [], "currentStatus": "NOT_STARTED", }, + # example with field name + { + "modified": True, + "dependencies": [], + "current_status": "NOT_STARTED", + }, { "modified": True, "dependencies": ["42838344-03de-4ce2-8d93-589a5dcdfd05"], @@ -234,16 +242,19 @@ class Node(BaseModel): Field( ge=0, le=100, - description="the node progress value (deprecated in DB, still used for API only)", - deprecated=True, # <-- Think this is not true, it is still used by the File Picker (frontend nodes) + description="the node progress value", + deprecated=True, # NOTE: still used in the File Picker (frontend nodes) and must be removed first from there before retiring it here + # SEE https://github.com/ITISFoundation/osparc-simcore/issues/8365 ), ] = None - thumbnail: Annotated[ # <-- (DEPRECATED) Can be removed + thumbnail: Annotated[ str | HttpUrl | None, Field( description="url of the latest screenshot of the node", examples=["https://placeimg.com/171/96/tech/grayscale/?0.jpg"], + deprecated=True, + # SEE https://github.com/ITISFoundation/osparc-simcore/issues/8365 ), ] = None @@ -302,22 +313,31 @@ 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 # <-- (DEPRECATED) Can be removed - ) + output_node: Annotated[ + bool | None, + Field( + deprecated=True, + alias="outputNode", + # SEE https://github.com/ITISFoundation/osparc-simcore/issues/8365 + ), + ] = None output_nodes: Annotated[ # <-- (DEPRECATED) Can be removed list[NodeID] | None, Field( description="Used in group-nodes. Node IDs of those connected to the output", alias="outputNodes", + deprecated=True, + # SEE https://github.com/ITISFoundation/osparc-simcore/issues/8365 ), ] = None - parent: Annotated[ # <-- (DEPRECATED) Can be removed + parent: Annotated[ NodeID | None, Field( description="Parent's (group-nodes') node ID s. Used to group", + deprecated=True, + # SEE https://github.com/ITISFoundation/osparc-simcore/issues/8365 ), ] = None @@ -334,6 +354,9 @@ class Node(BaseModel): Field(default_factory=NodeState, description="The node's state object"), ] = DEFAULT_FACTORY + # NOTE: requested_resources should be here! WARNING: this model is used both in database and rest api! + # Model for project_nodes table should NOT be Node but a different one ! + boot_options: Annotated[ dict[EnvVarKey, str] | None, Field( @@ -453,12 +476,14 @@ def _update_json_schema_extra(schema: JsonDict) -> None: model_config = ConfigDict( extra="forbid", - populate_by_name=True, + validate_by_name=True, + validate_by_alias=True, json_schema_extra=_update_json_schema_extra, ) class PartialNode(Node): - key: Annotated[ServiceKey, Field(default=None)] - version: Annotated[ServiceVersion, Field(default=None)] - label: Annotated[str, Field(default=None)] + # NOTE: `type: ignore[assignment]` is needed because mypy gets confused when overriding the types by adding the Union with None + key: ServiceKey | None = None # type: ignore[assignment] + version: ServiceVersion | None = None # type: ignore[assignment] + label: str | None = None # type: ignore[assignment] diff --git a/packages/models-library/src/models_library/projects_nodes_io.py b/packages/models-library/src/models_library/projects_nodes_io.py index 90fdf1412780..d4708582dead 100644 --- a/packages/models-library/src/models_library/projects_nodes_io.py +++ b/packages/models-library/src/models_library/projects_nodes_io.py @@ -30,10 +30,9 @@ UUID_RE, ) -NodeID = UUID - UUIDStr: TypeAlias = Annotated[str, StringConstraints(pattern=UUID_RE)] +NodeID: TypeAlias = UUID NodeIDStr: TypeAlias = UUIDStr LocationID: TypeAlias = int diff --git a/packages/models-library/tests/test_services_types.py b/packages/models-library/tests/test_services_types.py index 206c531a78fd..e3f0c9d472b2 100644 --- a/packages/models-library/tests/test_services_types.py +++ b/packages/models-library/tests/test_services_types.py @@ -1,9 +1,13 @@ import pytest from models_library.projects import ProjectID from models_library.projects_nodes import NodeID -from models_library.services_types import ServiceRunID +from models_library.services_types import ServiceKey, ServiceRunID, ServiceVersion from models_library.users import UserID -from pydantic import PositiveInt +from pydantic import PositiveInt, TypeAdapter +from pytest_simcore.helpers.faker_factories import ( + random_service_key, + random_service_version, +) @pytest.mark.parametrize( @@ -38,3 +42,14 @@ def test_get_resource_tracking_run_id_for_dynamic(): assert isinstance( ServiceRunID.get_resource_tracking_run_id_for_dynamic(), ServiceRunID ) + + +@pytest.mark.parametrize( + "service_key, service_version", + [(random_service_key(), random_service_version()) for _ in range(10)], +) +def test_faker_factory_service_key_and_version_are_in_sync( + service_key: ServiceKey, service_version: ServiceVersion +): + TypeAdapter(ServiceKey).validate_python(service_key) + TypeAdapter(ServiceVersion).validate_python(service_version) diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_projects.py b/packages/postgres-database/src/simcore_postgres_database/utils_projects.py index 577f9441004b..ee6a8a132e89 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_projects.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_projects.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection from .models.projects import projects -from .utils_repos import transaction_context +from .utils_repos import pass_or_acquire_connection, transaction_context class DBBaseProjectError(OsparcErrorMixin, Exception): @@ -22,6 +22,23 @@ class ProjectsRepo: def __init__(self, engine): self.engine = engine + async def exists( + self, + project_uuid: uuid.UUID, + *, + connection: AsyncConnection | None = None, + ) -> bool: + async with pass_or_acquire_connection(self.engine, connection) as conn: + return ( + await conn.scalar( + sa.select(1) + .select_from(projects) + .where(projects.c.uuid == f"{project_uuid}") + .limit(1) + ) + is not None + ) + async def get_project_last_change_date( self, project_uuid: uuid.UUID, diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_projects_nodes.py b/packages/postgres-database/src/simcore_postgres_database/utils_projects_nodes.py index f82481939520..4bb6855b0bff 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_projects_nodes.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_projects_nodes.py @@ -4,19 +4,18 @@ from typing import Annotated, Any import asyncpg.exceptions # type: ignore[import-untyped] -import sqlalchemy import sqlalchemy.exc from common_library.async_tools import maybe_await from common_library.basic_types import DEFAULT_FACTORY from common_library.errors_classes import OsparcErrorMixin from pydantic import BaseModel, ConfigDict, Field -from simcore_postgres_database.utils_aiosqlalchemy import map_db_exception from sqlalchemy.dialects.postgresql import insert as pg_insert from ._protocols import DBConnection from .aiopg_errors import ForeignKeyViolation, UniqueViolation from .models.projects_node_to_pricing_unit import projects_node_to_pricing_unit from .models.projects_nodes import projects_nodes +from .utils_aiosqlalchemy import map_db_exception # @@ -59,6 +58,7 @@ class ProjectNodeCreate(BaseModel): input_access: dict[str, Any] | None = None input_nodes: list[str] | None = None inputs: dict[str, Any] | None = None + inputs_required: list[str] | None = None inputs_units: dict[str, Any] | None = None output_nodes: list[str] | None = None outputs: dict[str, Any] | None = None @@ -103,17 +103,18 @@ async def add( """ if not nodes: return [] + + values = [ + { + "project_uuid": f"{self.project_uuid}", + **node.model_dump(mode="json"), + } + for node in nodes + ] + insert_stmt = ( projects_nodes.insert() - .values( - [ - { - "project_uuid": f"{self.project_uuid}", - **node.model_dump(exclude_unset=True, mode="json"), - } - for node in nodes - ] - ) + .values(values) .returning( *[ c @@ -129,14 +130,17 @@ async def add( rows = await maybe_await(result.fetchall()) assert isinstance(rows, list) # nosec return [ProjectNode.model_validate(r) for r in rows] + except ForeignKeyViolation as exc: # this happens when the project does not exist, as we first check the node exists raise ProjectNodesProjectNotFoundError( project_uuid=self.project_uuid ) from exc + except UniqueViolation as exc: # this happens if the node already exists on creation raise ProjectNodesDuplicateNodeError from exc + except sqlalchemy.exc.IntegrityError as exc: raise map_db_exception( exc, diff --git a/packages/postgres-database/tests/test_models_projects_to_jobs.py b/packages/postgres-database/tests/test_models_projects_to_jobs.py index e2e5cf0476e2..5b6436023d28 100644 --- a/packages/postgres-database/tests/test_models_projects_to_jobs.py +++ b/packages/postgres-database/tests/test_models_projects_to_jobs.py @@ -3,6 +3,7 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments +import json from collections.abc import Iterator import pytest @@ -14,7 +15,7 @@ from faker import Faker from pytest_simcore.helpers import postgres_tools from pytest_simcore.helpers.faker_factories import random_project, random_user -from simcore_postgres_database.models.projects import projects +from simcore_postgres_database.models.projects import ProjectType, projects from simcore_postgres_database.models.projects_to_jobs import projects_to_jobs @@ -97,7 +98,7 @@ def test_populate_projects_to_jobs_during_migration( "Study associated to solver job:" """{ "id": "cd03450c-4c17-4c2c-85fd-0d951d7dcd5a", - "name": "solvers/simcore%2Fservices%2Fcomp%2Fitis%2Fsleeper/releases/2.2.1/jobs/cd03450c-4c2c-85fd-0d951d7dcd5a", + "name": "solvers/simcore%2Fservices%2Fcomp%2Fitis%2Fsleeper/releases/2.2.1/jobs/cd03450c-4c17-4c2c-85fd-0d951d7dcd5a", "inputs_checksum": "015ba4cd5cf00c511a8217deb65c242e3b15dc6ae4b1ecf94982d693887d9e8a", "created_at": "2025-01-27T13:12:58.676564Z" } @@ -120,8 +121,37 @@ def test_populate_projects_to_jobs_during_migration( prj_owner=user_id, ), ] + + client_default_column_values = { + # NOTE: columns with `server_default values` must not be added here + "type": ProjectType.STANDARD.value, + "workbench": {}, + "access_rights": {}, + "published": False, + "hidden": False, + "workspace_id": None, + } + + # NOTE: cannot use `projects` table directly here because it changes + # throughout time for prj in projects_data: - conn.execute(sa.insert(projects).values(prj)) + for key, value in client_default_column_values.items(): + prj.setdefault(key, value) + + for key, value in prj.items(): + if isinstance(value, dict): + prj[key] = json.dumps(value) + + columns = list(prj.keys()) + values_clause = ", ".join(f":{col}" for col in columns) + columns_clause = ", ".join(columns) + stmt = sa.text( + f""" + INSERT INTO projects ({columns_clause}) + VALUES ({values_clause}) + """ # noqa: S608 + ).bindparams(**prj) + conn.execute(stmt) # MIGRATE UPGRADE: this should populate simcore_postgres_database.cli.upgrade.callback("head") diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/assert_checks.py b/packages/pytest-simcore/src/pytest_simcore/helpers/assert_checks.py index fc931cbebd59..afc6cdf15a3f 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/assert_checks.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/assert_checks.py @@ -97,3 +97,17 @@ def _do_assert_error( assert expected_error_code in codes return data, error + + +def assert_equal_ignoring_none(expected: dict, actual: dict): + for key, exp_value in expected.items(): + if exp_value is None: + continue + assert key in actual, f"Missing key {key}" + act_value = actual[key] + if isinstance(exp_value, dict) and isinstance(act_value, dict): + assert_equal_ignoring_none(exp_value, act_value) + else: + assert ( + act_value == exp_value + ), f"Mismatch in {key}: {act_value} != {exp_value}" diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py b/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py index ef006f53105b..405753670914 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py @@ -25,6 +25,15 @@ DEFAULT_FAKER: Final = Faker() +def random_service_key(fake: Faker = DEFAULT_FAKER, *, name: str | None = None) -> str: + suffix = fake.unique.word() if name is None else name + return f"simcore/services/{fake.random_element(['dynamic', 'comp', 'frontend'])}/{suffix.lower()}" + + +def random_service_version(fake: Faker = DEFAULT_FAKER) -> str: + return ".".join([str(fake.pyint(0, 100)) for _ in range(3)]) + + def random_icon_url(fake: Faker): return fake.image_url(width=16, height=16) @@ -187,6 +196,26 @@ def random_project(fake: Faker = DEFAULT_FAKER, **overrides) -> dict[str, Any]: return data +def random_project_node(fake: Faker = DEFAULT_FAKER, **overrides) -> dict[str, Any]: + """Generates random fake data project nodes DATABASE table""" + from simcore_postgres_database.models.projects_nodes import projects_nodes + + fake_name = fake.name() + + data = { + "node_id": fake.uuid4(), + "project_uuid": fake.uuid4(), + "key": random_service_key(fake, name=fake_name), + "version": random_service_version(fake), + "label": fake_name, + } + + assert set(data.keys()).issubset({c.name for c in projects_nodes.columns}) + + data.update(overrides) + return data + + def random_group(fake: Faker = DEFAULT_FAKER, **overrides) -> dict[str, Any]: from simcore_postgres_database.models.groups import groups from simcore_postgres_database.webserver_models import GroupType @@ -483,7 +512,7 @@ def random_service_meta_data( ) -> dict[str, Any]: from simcore_postgres_database.models.services import services_meta_data - _version = ".".join([str(fake.pyint()) for _ in range(3)]) + _version = random_service_version(fake) _name = fake.name() data: dict[str, Any] = { diff --git a/packages/pytest-simcore/tests/test_helpers_asserts_checks.py b/packages/pytest-simcore/tests/test_helpers_asserts_checks.py new file mode 100644 index 000000000000..189d84f9f81e --- /dev/null +++ b/packages/pytest-simcore/tests/test_helpers_asserts_checks.py @@ -0,0 +1,35 @@ +import pytest +from pytest_simcore.helpers.assert_checks import assert_equal_ignoring_none + + +@pytest.mark.parametrize( + "expected, actual", + [ + ({"a": 1, "b": 2}, {"a": 1, "b": 2, "c": 3}), + ({"a": 1, "b": None}, {"a": 1, "b": 42}), + ({"a": {"x": 10, "y": None}}, {"a": {"x": 10, "y": 99}}), + ({"a": {"x": 10, "y": 20}}, {"a": {"x": 10, "y": 20, "z": 30}}), + ({}, {"foo": "bar"}), + ], +) +def test_assert_equal_ignoring_none_passes(expected, actual): + assert_equal_ignoring_none(expected, actual) + + +@pytest.mark.parametrize( + "expected, actual, error_msg", + [ + ({"a": 1, "b": 2}, {"a": 1}, "Missing key b"), + ({"a": 1, "b": 2}, {"a": 1, "b": 3}, "Mismatch in b: 3 != 2"), + ( + {"a": {"x": 10, "y": 20}}, + {"a": {"x": 10, "y": 99}}, + "Mismatch in y: 99 != 20", + ), + ({"a": {"x": 10}}, {"a": {}}, "Missing key x"), + ], +) +def test_assert_equal_ignoring_none_fails(expected, actual, error_msg): + with pytest.raises(AssertionError) as exc_info: + assert_equal_ignoring_none(expected, actual) + assert error_msg in str(exc_info.value) diff --git a/services/api-server/src/simcore_service_api_server/models/domain/projects.py b/services/api-server/src/simcore_service_api_server/models/domain/projects.py index ae74533546b4..287bf985aa41 100644 --- a/services/api-server/src/simcore_service_api_server/models/domain/projects.py +++ b/services/api-server/src/simcore_service_api_server/models/domain/projects.py @@ -4,12 +4,6 @@ from models_library.projects_nodes import InputTypes, Node, OutputTypes from models_library.projects_nodes_io import SimCoreFileLink -assert AccessRights # nosec -assert InputTypes # nosec -assert Node # nosec -assert OutputTypes # nosec -assert SimCoreFileLink # nosec - __all__: tuple[str, ...] = ( "AccessRights", "InputTypes", @@ -17,3 +11,5 @@ "OutputTypes", "SimCoreFileLink", ) + +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index 517cfc3f0115..d704cd3043a6 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -14011,8 +14011,7 @@ components: minimum: 0.0 - type: 'null' title: Progress - description: the node progress value (deprecated in DB, still used for API - only) + description: the node progress value deprecated: true thumbnail: anyOf: @@ -14024,6 +14023,7 @@ components: - type: 'null' title: Thumbnail description: url of the latest screenshot of the node + deprecated: true runHash: anyOf: - type: string @@ -14089,6 +14089,7 @@ components: - type: 'null' title: Outputnodes description: Used in group-nodes. Node IDs of those connected to the output + deprecated: true parent: anyOf: - type: string @@ -14096,6 +14097,7 @@ components: - type: 'null' title: Parent description: Parent's (group-nodes') node ID s. Used to group + deprecated: true position: anyOf: - $ref: '#/components/schemas/Position' @@ -14107,6 +14109,12 @@ components: - $ref: '#/components/schemas/NodeState-Input' - type: 'null' description: The node's state object + required_resources: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Required Resources bootOptions: anyOf: - type: object @@ -14146,8 +14154,7 @@ components: minimum: 0.0 - type: 'null' title: Progress - description: the node progress value (deprecated in DB, still used for API - only) + description: the node progress value deprecated: true thumbnail: anyOf: @@ -14159,6 +14166,7 @@ components: - type: 'null' title: Thumbnail description: url of the latest screenshot of the node + deprecated: true runHash: anyOf: - type: string @@ -14224,6 +14232,7 @@ components: - type: 'null' title: Outputnodes description: Used in group-nodes. Node IDs of those connected to the output + deprecated: true parent: anyOf: - type: string @@ -14231,6 +14240,7 @@ components: - type: 'null' title: Parent description: Parent's (group-nodes') node ID s. Used to group + deprecated: true position: anyOf: - $ref: '#/components/schemas/Position' @@ -14242,6 +14252,12 @@ components: - $ref: '#/components/schemas/NodeState-Output' - type: 'null' description: The node's state object + required_resources: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Required Resources bootOptions: anyOf: - type: object @@ -15953,7 +15969,6 @@ components: - creationDate - lastChangeDate - trashedAt - - trashedBy - tags - dev - workspaceId @@ -16156,7 +16171,6 @@ components: - creationDate - lastChangeDate - trashedAt - - trashedBy - tags - dev - workspaceId diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_models_adapters.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_models_adapters.py new file mode 100644 index 000000000000..f5baf7b776cf --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_models_adapters.py @@ -0,0 +1,70 @@ +""" +Collection of free function adapters between Node-like pydantic models + +- The tricky part here is to deal with alias in Node model which are not present in the DB models + +""" + +from datetime import datetime +from typing import Any +from uuid import UUID + +from models_library.projects_nodes import Node +from simcore_postgres_database.utils_projects_nodes import ( + ProjectNode, + ProjectNodeCreate, +) + + +def node_from_project_node_create(project_node_create: ProjectNodeCreate) -> Node: + """ + Adapter: Converts a ProjectNodeCreate instance to a Node model. + """ + exclude_fields = {"node_id", "required_resources"} + + assert set(ProjectNodeCreate.model_fields).issuperset(exclude_fields) # nosec + + node_data: dict[str, Any] = project_node_create.model_dump( + exclude=exclude_fields, + exclude_none=True, + exclude_unset=True, + ) + return Node.model_validate(node_data, by_name=True) + + +def node_from_project_node(project_node: ProjectNode) -> Node: + """ + Adapter: Converts a ProjectNode instance to a Node model. + """ + exclude_fields = {"node_id", "required_resources", "created", "modified"} + assert set(ProjectNode.model_fields).issuperset(exclude_fields) # nosec + + node_data: dict[str, Any] = project_node.model_dump( + exclude=exclude_fields, + exclude_none=True, + exclude_unset=True, + ) + return Node.model_validate(node_data, by_name=True) + + +def project_node_create_from_node(node: Node, node_id: UUID) -> ProjectNodeCreate: + """ + Adapter: Converts a Node model and node_id to a ProjectNodeCreate instance. + """ + node_data: dict[str, Any] = node.model_dump(by_alias=False, mode="json") + return ProjectNodeCreate(node_id=node_id, **node_data) + + +def project_node_from_node( + node: Node, node_id: UUID, created: datetime, modified: datetime +) -> ProjectNode: + """ + Adapter: Converts a Node model, node_id, created, and modified to a ProjectNode instance. + """ + node_data: dict[str, Any] = node.model_dump(by_alias=False, mode="json") + return ProjectNode( + node_id=node_id, + created=created, + modified=modified, + **node_data, + ) diff --git a/services/web/server/tests/unit/isolated/test_projects__nodes_models_adapters.py b/services/web/server/tests/unit/isolated/test_projects__nodes_models_adapters.py new file mode 100644 index 000000000000..af56734647cb --- /dev/null +++ b/services/web/server/tests/unit/isolated/test_projects__nodes_models_adapters.py @@ -0,0 +1,62 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +from typing import Any +from uuid import UUID + +import pytest +from faker import Faker +from models_library.projects_nodes import Node +from simcore_postgres_database.utils_projects_nodes import ( + ProjectNode, + ProjectNodeCreate, +) +from simcore_service_webserver.projects import _nodes_models_adapters + +_NODE_DOMAIN_MODEL_DICT_EXAMPLES = Node.model_json_schema()["examples"] + + +@pytest.mark.parametrize( + "node_data", + _NODE_DOMAIN_MODEL_DICT_EXAMPLES, + ids=[f"example-{i}" for i in range(len(_NODE_DOMAIN_MODEL_DICT_EXAMPLES))], +) +def test_adapters_between_different_node_models( + node_data: dict[str, Any], faker: Faker +): + # dict -> to Node (from models_library) + node_id = UUID(faker.uuid4()) + node = Node.model_validate(node_data) + + # Node -> ProjectNodeCreate (from simcore_postgres_database) using adapters + project_node_create = _nodes_models_adapters.project_node_create_from_node( + node, node_id + ) + assert isinstance(project_node_create, ProjectNodeCreate) + assert project_node_create.node_id == node_id + + # Node -> ProjectNode (from simcore_postgres_database) using adapters + project_node = _nodes_models_adapters.project_node_from_node( + node, + node_id, + created=faker.date_time(), + modified=faker.date_time(), + ) + + assert isinstance(project_node, ProjectNode) + assert project_node.node_id == node_id + assert project_node.created != project_node.modified + assert project_node_create.node_id == node_id + + # ProjectNodeCreate -> Node (from models_library) using adapters + assert ( + _nodes_models_adapters.node_from_project_node_create(project_node_create) + == node + ) + + # ProjectNode -> Node (from models_library) using adapters + assert _nodes_models_adapters.node_from_project_node(project_node) == node