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 b03d29c67005..fe43da30f158 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py @@ -629,3 +629,65 @@ def random_service_consume_filetype( data.update(overrides) return data + + +def random_group_classifier( + *, + gid: int, + fake: Faker = DEFAULT_FAKER, + **overrides, +) -> dict[str, Any]: + from simcore_postgres_database.models.classifiers import group_classifiers + + data = { + "gid": gid, + "bundle": { + "vcs_ref": fake.lexify(text="???????"), + "vcs_url": "https://organization.classifiers.git", + "build_date": "2021-01-20T15:19:30Z", + "classifiers": { + "project::dak": { + "url": None, + "logo": None, + "aliases": [], + "related": [], + "markdown": "", + "released": None, + "classifier": "project::dak", + "created_by": fake.user_name(), + "github_url": None, + "display_name": "DAK", + "wikipedia_url": None, + "short_description": None, + }, + "organization::zmt": { + "url": "https://zmt.swiss/", + "logo": None, + "aliases": ["Zurich MedTech AG"], + "related": [], + "markdown": fake.text(), + "released": None, + "classifier": "organization::zmt", + "created_by": fake.user_name(), + "github_url": None, + "display_name": "ZMT", + "wikipedia_url": None, + "short_description": "ZMT is a member of Zurich43", + }, + }, + "collections": { + "jupyterlab-math": { + "items": ["crespo/osparc-demo"], + "markdown": fake.text(), + "created_by": fake.user_name(), + "display_name": "jupyterlab-math", + } + }, + }, + "uses_scicrunch": False, + } + + assert set(data.keys()).issubset({c.name for c in group_classifiers.columns}) + + data.update(overrides) + return data diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/scrunch_citations.py b/packages/pytest-simcore/src/pytest_simcore/helpers/scrunch_citations.py index ea6e0287ccce..1b2ff9e55c08 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/scrunch_citations.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/scrunch_citations.py @@ -1,10 +1,10 @@ # Citations according to https://scicrunch.org/resources """ - NOTES: +NOTES: - - scicrunch API ONLY recognizes RRIDs from SciCrunch registry of tools (i.e. with prefix "SCR") - - scicrunch web search handles ALL RRIDs (see below example of citations from other) - - scicrunch API does NOT uses 'RRID:' prefix in rrid request parameters +- scicrunch API ONLY recognizes RRIDs from SciCrunch registry of tools (i.e. with prefix "SCR") +- scicrunch web search handles ALL RRIDs (see below example of citations from other) +- scicrunch API does NOT uses 'RRID:' prefix in rrid request parameters """ @@ -13,8 +13,12 @@ def split_citations(citations: list[str]) -> list[tuple[str, str]]: def _split(citation: str) -> tuple[str, str]: + assert citation.startswith("("), f"Got {citation=}" + assert citation.endswith(")"), f"Got {citation=}" + # make sure there is a comma before RRID: so we can split if "," not in citation: citation = citation.replace("(", "(,") + name, rrid = re.match(r"^\((.*),\s*RRID:(.+)\)$", citation).groups() return name, rrid diff --git a/services/web/server/src/simcore_service_webserver/exporter/_formatter/_sds.py b/services/web/server/src/simcore_service_webserver/exporter/_formatter/_sds.py index 806e33b5df59..081ab82549b2 100644 --- a/services/web/server/src/simcore_service_webserver/exporter/_formatter/_sds.py +++ b/services/web/server/src/simcore_service_webserver/exporter/_formatter/_sds.py @@ -11,7 +11,7 @@ from ...projects._projects_service import get_project_for_user from ...projects.exceptions import BaseProjectError from ...projects.models import ProjectDict -from ...scicrunch.db import ResearchResourceRepository +from ...scicrunch.scicrunch_service import SCICRUNCH_SERVICE_APPKEY from ..exceptions import SDSException from .template_json import write_template_json from .xlsx.code_description import ( @@ -70,10 +70,10 @@ async def _add_rrid_entries( ) -> None: rrid_entires: deque[RRIDEntry] = deque() - repo = ResearchResourceRepository(app) + service = app[SCICRUNCH_SERVICE_APPKEY] classifiers = project_data["classifiers"] for classifier in classifiers: - scicrunch_resource = await repo.get(rrid=classifier) + scicrunch_resource = await service.get_resource_atdb(rrid=classifier) if scicrunch_resource is None: continue diff --git a/services/web/server/src/simcore_service_webserver/exporter/plugin.py b/services/web/server/src/simcore_service_webserver/exporter/plugin.py index 30f9ec6407f3..99977626f00f 100644 --- a/services/web/server/src/simcore_service_webserver/exporter/plugin.py +++ b/services/web/server/src/simcore_service_webserver/exporter/plugin.py @@ -1,6 +1,7 @@ import logging from aiohttp import web +from simcore_service_webserver.scicrunch.plugin import setup_scicrunch from ..application_setup import ModuleCategory, app_setup_func from . import _handlers @@ -15,6 +16,7 @@ logger=_logger, ) def setup_exporter(app: web.Application) -> bool: + setup_scicrunch(app) # Rest-API routes: maps handlers with routes tags with "viewer" based on OAS operation_id app.router.add_routes(_handlers.routes) diff --git a/services/web/server/src/simcore_service_webserver/groups/_classifiers_repository.py b/services/web/server/src/simcore_service_webserver/groups/_classifiers_repository.py new file mode 100644 index 000000000000..54a2a04c1291 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/groups/_classifiers_repository.py @@ -0,0 +1,44 @@ +import logging +from typing import Any, cast + +import sqlalchemy as sa +from simcore_postgres_database.models.classifiers import group_classifiers +from simcore_postgres_database.utils_repos import pass_or_acquire_connection +from sqlalchemy.engine import Row +from sqlalchemy.ext.asyncio import AsyncConnection + +from ..db.base_repository import BaseRepository + +_logger = logging.getLogger(__name__) + + +class GroupClassifierRepository(BaseRepository): + + async def _get_bundle( + self, gid: int, connection: AsyncConnection | None = None + ) -> Row | None: + async with pass_or_acquire_connection(self.engine, connection) as conn: + result = await conn.execute( + sa.select(group_classifiers.c.bundle).where( + group_classifiers.c.gid == gid + ) + ) + return result.one_or_none() + + async def get_classifiers_from_bundle(self, gid: int) -> dict[str, Any] | None: + bundle_row = await self._get_bundle(gid) + if bundle_row: + return cast(dict[str, Any], bundle_row.bundle) + return None + + async def group_uses_scicrunch( + self, gid: int, connection: AsyncConnection | None = None + ) -> bool: + async with pass_or_acquire_connection(self.engine, connection) as conn: + result = await conn.execute( + sa.select(group_classifiers.c.uses_scicrunch).where( + group_classifiers.c.gid == gid + ) + ) + row = result.one_or_none() + return bool(row.uses_scicrunch if row else False) diff --git a/services/web/server/src/simcore_service_webserver/groups/_classifiers_rest.py b/services/web/server/src/simcore_service_webserver/groups/_classifiers_rest.py index e9113e5b6666..d96531098b79 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_classifiers_rest.py +++ b/services/web/server/src/simcore_service_webserver/groups/_classifiers_rest.py @@ -8,13 +8,11 @@ from .._meta import API_VTAG from ..login.decorators import login_required -from ..scicrunch.db import ResearchResourceRepository -from ..scicrunch.errors import ScicrunchError -from ..scicrunch.models import ResearchResource, ResourceHit -from ..scicrunch.service_client import SciCrunch +from ..scicrunch.models import ResourceHit +from ..scicrunch.scicrunch_service import SCICRUNCH_SERVICE_APPKEY from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response -from ._classifiers_service import GroupClassifierRepository, build_rrids_tree_view +from ._classifiers_service import GroupClassifiersService from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.schemas import GroupsClassifiersQuery, GroupsPathParams @@ -29,23 +27,15 @@ @permission_required("groups.*") @handle_plugin_requests_exceptions async def get_group_classifiers(request: web.Request): - try: - path_params = parse_request_path_parameters_as(GroupsPathParams, request) - query_params: GroupsClassifiersQuery = parse_request_query_parameters_as( - GroupsClassifiersQuery, request - ) - - repo = GroupClassifierRepository(request.app) - if not await repo.group_uses_scicrunch(path_params.gid): - bundle = await repo.get_classifiers_from_bundle(path_params.gid) - return envelope_json_response(bundle) - - # otherwise, build dynamic tree with RRIDs - view = await build_rrids_tree_view( - request.app, tree_view_mode=query_params.tree_view - ) - except ScicrunchError: - view = {} + path_params = parse_request_path_parameters_as(GroupsPathParams, request) + query_params: GroupsClassifiersQuery = parse_request_query_parameters_as( + GroupsClassifiersQuery, request + ) + + service = GroupClassifiersService(request.app) + view = await service.get_group_classifiers( + path_params.gid, tree_view_mode=query_params.tree_view + ) return envelope_json_response(view) @@ -59,15 +49,9 @@ async def get_group_classifiers(request: web.Request): @handle_plugin_requests_exceptions async def get_scicrunch_resource(request: web.Request): rrid = request.match_info["rrid"] - rrid = SciCrunch.validate_identifier(rrid) - # check if in database first - repo = ResearchResourceRepository(request.app) - resource: ResearchResource | None = await repo.get_resource(rrid) - if not resource: - # otherwise, request to scicrunch service - scicrunch = SciCrunch.get_instance(request.app) - resource = await scicrunch.get_resource_fields(rrid) + service = request.app[SCICRUNCH_SERVICE_APPKEY] + resource = await service.get_or_fetch_research_resource(rrid) return envelope_json_response(resource.model_dump()) @@ -82,16 +66,8 @@ async def get_scicrunch_resource(request: web.Request): async def add_scicrunch_resource(request: web.Request): rrid = request.match_info["rrid"] - # check if exists - repo = ResearchResourceRepository(request.app) - resource: ResearchResource | None = await repo.get_resource(rrid) - if not resource: - # then request scicrunch service - scicrunch = SciCrunch.get_instance(request.app) - resource = await scicrunch.get_resource_fields(rrid) - - # insert new or if exists, then update - await repo.upsert(resource) + service = request.app[SCICRUNCH_SERVICE_APPKEY] + resource = await service.create_research_resource(rrid) return envelope_json_response(resource.model_dump()) @@ -106,7 +82,7 @@ async def add_scicrunch_resource(request: web.Request): async def search_scicrunch_resources(request: web.Request): guess_name = str(request.query["guess_name"]).strip() - scicrunch = SciCrunch.get_instance(request.app) - hits: list[ResourceHit] = await scicrunch.search_resource(guess_name) + service = request.app[SCICRUNCH_SERVICE_APPKEY] + hits: list[ResourceHit] = await service.search_research_resources(guess_name) return envelope_json_response([hit.model_dump() for hit in hits]) diff --git a/services/web/server/src/simcore_service_webserver/groups/_classifiers_service.py b/services/web/server/src/simcore_service_webserver/groups/_classifiers_service.py index 1e1155613e25..2f2eca7ea3d6 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_classifiers_service.py +++ b/services/web/server/src/simcore_service_webserver/groups/_classifiers_service.py @@ -11,9 +11,7 @@ import logging from typing import Annotated, Any, Final, Literal, TypeAlias -import sqlalchemy as sa from aiohttp import web -from aiopg.sa.result import RowProxy from common_library.logging.logging_errors import create_troubleshooting_log_kwargs from pydantic import ( BaseModel, @@ -24,11 +22,10 @@ ValidationError, field_validator, ) -from simcore_postgres_database.models.classifiers import group_classifiers -from ..db.plugin import get_database_engine_legacy -from ..scicrunch.db import ResearchResourceRepository -from ..scicrunch.service_client import SciCrunch +from ..scicrunch.errors import ScicrunchError +from ..scicrunch.scicrunch_service import SCICRUNCH_SERVICE_APPKEY +from ._classifiers_repository import GroupClassifierRepository _logger = logging.getLogger(__name__) MAX_SIZE_SHORT_MSG: Final[int] = 100 @@ -50,11 +47,13 @@ class ClassifierItem(BaseModel): ) display_name: str short_description: str | None - url: HttpUrl | None = Field( - None, - description="Link to more information", - examples=["https://scicrunch.org/resources/Any/search?q=osparc&l=osparc"], - ) + url: Annotated[ + HttpUrl | None, + Field( + description="Link to more information", + examples=["https://scicrunch.org/resources/Any/search?q=osparc&l=osparc"], + ), + ] = None @field_validator("short_description", mode="before") @classmethod @@ -73,87 +72,76 @@ class Classifiers(BaseModel): classifiers: dict[TreePath, ClassifierItem] -# DATABASE -------- +# SERVICE LAYER -------- -class GroupClassifierRepository: +class GroupClassifiersService: def __init__(self, app: web.Application): - self.engine = get_database_engine_legacy(app) - - async def _get_bundle(self, gid: int) -> RowProxy | None: - async with self.engine.acquire() as conn: - bundle: RowProxy | None = await conn.scalar( - sa.select(group_classifiers.c.bundle).where( - group_classifiers.c.gid == gid - ) - ) - return bundle + self.app = app + self._repo = GroupClassifierRepository.create_from_app(app) + + async def get_group_classifiers( + self, gid: int, tree_view_mode: Literal["std"] = "std" + ) -> dict[str, Any]: + """Get classifiers for a group, either from bundle or dynamic tree view.""" + if not await self._repo.group_uses_scicrunch(gid): + bundle = await self._repo.get_classifiers_from_bundle(gid) + if bundle: + try: + # truncate bundle to what is needed and drop the rest + return Classifiers(**bundle).model_dump( + exclude_unset=True, exclude_none=True + ) + except ValidationError as err: + _logger.exception( + **create_troubleshooting_log_kwargs( + f"DB corrupt data in 'groups_classifiers' table. Invalid classifier for gid={gid}. Returning empty bundle.", + error=err, + error_context={ + "gid": gid, + "bundle": bundle, + }, + ) + ) + return {} - async def get_classifiers_from_bundle(self, gid: int) -> dict[str, Any]: - bundle = await self._get_bundle(gid) - if bundle: + # otherwise, build dynamic tree with RRIDs + try: + return await self._build_rrids_tree_view(tree_view_mode=tree_view_mode) + except ScicrunchError: + # Return empty view on any error (including ScicrunchError) + return {} + + async def _build_rrids_tree_view( + self, tree_view_mode: Literal["std"] = "std" + ) -> dict[str, Any]: + if tree_view_mode != "std": + msg = "Currently only 'std' option for the classifiers tree view is implemented" + raise NotImplementedError(msg) + + service = self.app[SCICRUNCH_SERVICE_APPKEY] + + flat_tree_view: dict[TreePath, ClassifierItem] = {} + for resource in await service.list_research_resources(): try: - # truncate bundle to what is needed and drop the rest - return Classifiers(**bundle).model_dump( - exclude_unset=True, exclude_none=True - ) - except ValidationError as err: - _logger.exception( - **create_troubleshooting_log_kwargs( - f"DB corrupt data in 'groups_classifiers' table. Invalid classifier for gid={gid}. Returning empty bundle.", - error=err, - error_context={ - "gid": gid, - "bundle": bundle, - }, - ) + validated_item = ClassifierItem( + classifier=resource.rrid, + display_name=resource.name.title(), + short_description=resource.description, + url=service.get_resolver_web_url(resource.rrid), ) - return {} - async def group_uses_scicrunch(self, gid: int) -> bool: - async with self.engine.acquire() as conn: - value: RowProxy | None = await conn.scalar( - sa.select(group_classifiers.c.uses_scicrunch).where( - group_classifiers.c.gid == gid + node = TypeAdapter(TreePath).validate_python( + validated_item.display_name.replace(":", " ") ) - ) - return bool(value) - - -# HELPERS FOR API HANDLERS -------------- + flat_tree_view[node] = validated_item + except ValidationError as err: + _logger.warning( + "Cannot convert RRID into a classifier item. Skipping. Details: %s", + err, + ) -async def build_rrids_tree_view( - app: web.Application, tree_view_mode: Literal["std"] = "std" -) -> dict[str, Any]: - if tree_view_mode != "std": - raise web.HTTPNotImplemented( - text="Currently only 'std' option for the classifiers tree view is implemented" + return Classifiers.model_construct(classifiers=flat_tree_view).model_dump( + exclude_unset=True ) - - scicrunch = SciCrunch.get_instance(app) - repo = ResearchResourceRepository(app) - - flat_tree_view: dict[TreePath, ClassifierItem] = {} - for resource in await repo.list_resources(): - try: - validated_item = ClassifierItem( - classifier=resource.rrid, - display_name=resource.name.title(), - short_description=resource.description, - url=scicrunch.get_resolver_web_url(resource.rrid), - ) - - node = TypeAdapter(TreePath).validate_python( - validated_item.display_name.replace(":", " ") - ) - flat_tree_view[node] = validated_item - - except ValidationError as err: - _logger.warning( - "Cannot convert RRID into a classifier item. Skipping. Details: %s", err - ) - - return Classifiers.model_construct(classifiers=flat_tree_view).model_dump( - exclude_unset=True - ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_repository.py b/services/web/server/src/simcore_service_webserver/groups/_groups_repository.py index ede591b76589..765935c85e5c 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_repository.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_repository.py @@ -17,7 +17,6 @@ StandardGroupUpdate, ) from models_library.users import UserID, UserNameID -from simcore_postgres_database.aiopg_errors import UniqueViolation from simcore_postgres_database.models.users import users from simcore_postgres_database.utils_products import get_or_create_product_group from simcore_postgres_database.utils_repos import ( @@ -28,6 +27,7 @@ from sqlalchemy import and_ from sqlalchemy.dialects.postgresql import insert from sqlalchemy.engine.row import Row +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncConnection from ..db.models import groups, user_to_groups, users @@ -789,7 +789,7 @@ async def add_new_user_in_group( uid=new_user_id, gid=group_id, access_rights=user_access_rights ) ) - except UniqueViolation as exc: + except IntegrityError as exc: raise UserAlreadyInGroupError( uid=new_user_id, gid=group_id, diff --git a/services/web/server/src/simcore_service_webserver/groups/plugin.py b/services/web/server/src/simcore_service_webserver/groups/plugin.py index 1ac79222337a..5654cb0c39a8 100644 --- a/services/web/server/src/simcore_service_webserver/groups/plugin.py +++ b/services/web/server/src/simcore_service_webserver/groups/plugin.py @@ -1,6 +1,7 @@ import logging from aiohttp import web +from simcore_service_webserver.scicrunch.plugin import setup_scicrunch from ..application_keys import APP_SETTINGS_APPKEY from ..application_setup import ModuleCategory, app_setup_func @@ -22,6 +23,7 @@ def setup_groups(app: web.Application): # plugin dependencies setup_products(app) + setup_scicrunch(app) app.router.add_routes(_groups_rest.routes) app.router.add_routes(_classifiers_rest.routes) diff --git a/services/web/server/src/simcore_service_webserver/scicrunch/service_client.py b/services/web/server/src/simcore_service_webserver/scicrunch/_client.py similarity index 95% rename from services/web/server/src/simcore_service_webserver/scicrunch/service_client.py rename to services/web/server/src/simcore_service_webserver/scicrunch/_client.py index 3f9f4fe2653b..6354194a1f2a 100644 --- a/services/web/server/src/simcore_service_webserver/scicrunch/service_client.py +++ b/services/web/server/src/simcore_service_webserver/scicrunch/_client.py @@ -61,15 +61,15 @@ def acquire_instance( cls, app: web.Application, settings: SciCrunchSettings ) -> "SciCrunch": """Returns single instance for the application and stores it""" - obj: SciCrunch | None = app.get(_SCICRUNCH_APPKEY) + obj: SciCrunch | None = app.get(SCICRUNCH_CLIENT_APPKEY) if obj is None: session = get_client_session(app) - app[_SCICRUNCH_APPKEY] = obj = cls(session, settings) + app[SCICRUNCH_CLIENT_APPKEY] = obj = cls(session, settings) return obj @classmethod def get_instance(cls, app: web.Application) -> "SciCrunch": - obj: SciCrunch | None = app.get(_SCICRUNCH_APPKEY) + obj: SciCrunch | None = app.get(SCICRUNCH_CLIENT_APPKEY) if obj is None: raise ScicrunchConfigError( details="Services on scicrunch.org are currently disabled" @@ -174,4 +174,4 @@ async def search_resource(self, name_as: str) -> list[ResourceHit]: return hits.root -_SCICRUNCH_APPKEY: Final = web.AppKey(SciCrunch.__name__, SciCrunch) +SCICRUNCH_CLIENT_APPKEY: Final = web.AppKey(SciCrunch.__name__, SciCrunch) diff --git a/services/web/server/src/simcore_service_webserver/scicrunch/_repository.py b/services/web/server/src/simcore_service_webserver/scicrunch/_repository.py new file mode 100644 index 000000000000..74fdc41ab7d7 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/scicrunch/_repository.py @@ -0,0 +1,65 @@ +""" +Repository for scicrunch_resources table operations using asyncpg +""" + +import logging +from typing import Any + +import sqlalchemy as sa +from simcore_postgres_database.models.scicrunch_resources import scicrunch_resources +from simcore_postgres_database.utils_repos import ( + pass_or_acquire_connection, + transaction_context, +) +from sqlalchemy.dialects.postgresql import insert as sa_pg_insert +from sqlalchemy.engine import Row +from sqlalchemy.ext.asyncio import AsyncConnection + +from ..db.base_repository import BaseRepository + +_logger = logging.getLogger(__name__) + + +class ScicrunchResourcesRepository(BaseRepository): + """Repository for managing scicrunch_resources operations.""" + + async def list_all_resources( + self, connection: AsyncConnection | None = None + ) -> list[Row]: + """List all research resources with basic fields.""" + async with pass_or_acquire_connection(self.engine, connection) as conn: + stmt = sa.select( + scicrunch_resources.c.rrid, + scicrunch_resources.c.name, + scicrunch_resources.c.description, + ) + result = await conn.execute(stmt) + return result.fetchall() + + async def get_resource_by_rrid( + self, rrid: str, connection: AsyncConnection | None = None + ) -> Row | None: + """Get a research resource by RRID.""" + async with pass_or_acquire_connection(self.engine, connection) as conn: + stmt = sa.select(scicrunch_resources).where( + scicrunch_resources.c.rrid == rrid + ) + result = await conn.execute(stmt) + return result.one_or_none() + + async def upsert_resource( + self, resource_data: dict[str, Any], connection: AsyncConnection | None = None + ) -> Row: + """Insert or update a research resource.""" + async with transaction_context(self.engine, connection) as conn: + stmt = ( + sa_pg_insert(scicrunch_resources) + .values(resource_data) + .on_conflict_do_update( + index_elements=[scicrunch_resources.c.rrid], + set_=resource_data, + ) + .returning(*scicrunch_resources.c) + ) + result = await conn.execute(stmt) + return result.one() diff --git a/services/web/server/src/simcore_service_webserver/scicrunch/_service.py b/services/web/server/src/simcore_service_webserver/scicrunch/_service.py new file mode 100644 index 000000000000..8f75767509b8 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/scicrunch/_service.py @@ -0,0 +1,137 @@ +""" +Service layer for scicrunch research resources operations +""" + +import logging + +from common_library.logging.logging_errors import create_troubleshooting_log_kwargs +from pydantic import HttpUrl, ValidationError + +from ._client import SciCrunch +from ._repository import ScicrunchResourcesRepository +from .models import ResearchResource, ResearchResourceAtdB, ResourceHit + +_logger = logging.getLogger(__name__) + + +class ScicrunchResourcesService: + """Service layer handling business logic for scicrunch resources. + + - Research Resources operations (RRID = Research Resource ID) + """ + + def __init__(self, repo: ScicrunchResourcesRepository, client: SciCrunch): + self._repo = repo + # client to interact with scicrunch.org service + self._client = client + + async def list_research_resources(self) -> list[ResearchResource]: + """List all research resources as domain models.""" + rows = await self._repo.list_all_resources() + if not rows: + return [] + + resources = [] + for row in rows: + try: + resource_data = dict(row) + resource = ResearchResource.model_validate(resource_data) + resources.append(resource) + except ValidationError as err: + _logger.warning( + **create_troubleshooting_log_kwargs( + f"Invalid data for resource {row.rrid}", + error=err, + error_context={"row_data": dict(row)}, + ) + ) + continue + + return resources + + async def get_research_resource(self, rrid: str) -> ResearchResource | None: + """Get resource as domain model.""" + resource_atdb = await self.get_resource_atdb(rrid) + if not resource_atdb: + return None + + try: + return ResearchResource.model_validate(resource_atdb.model_dump()) + except ValidationError as err: + _logger.exception( + **create_troubleshooting_log_kwargs( + f"Failed to convert resource {rrid} to domain model", + error=err, + error_context={ + "rrid": rrid, + "resource_data": resource_atdb.model_dump(), + }, + ) + ) + return None + + async def get_or_fetch_research_resource(self, rrid: str) -> ResearchResource: + """Get resource from database first, fetch from SciCrunch API if not found.""" + # Validate the RRID format first + validated_rrid = SciCrunch.validate_identifier(rrid) + + # Check if in database first + resource = await self.get_research_resource(validated_rrid) + if resource: + return resource + + # Otherwise, request from scicrunch service + return await self._client.get_resource_fields(validated_rrid) + + async def get_resource_atdb(self, rrid: str) -> ResearchResourceAtdB | None: + """Get resource with all database fields.""" + row = await self._repo.get_resource_by_rrid(rrid) + if not row: + return None + + try: + return ResearchResourceAtdB.model_validate(dict(row)) + except ValidationError as err: + _logger.exception( + **create_troubleshooting_log_kwargs( + f"Invalid data for resource {rrid}", + error=err, + error_context={"rrid": rrid, "row_data": dict(row)}, + ) + ) + return None + + async def search_research_resources(self, guess_name: str) -> list[ResourceHit]: + """Search for research resources using SciCrunch API.""" + guess_name = guess_name.strip() + if not guess_name: + return [] + + return await self._client.search_resource(guess_name) + + async def create_research_resource(self, rrid: str) -> ResearchResource: + """Add a research resource by RRID, fetching from SciCrunch if not in database.""" + # Check if exists in database first + resource = await self.get_research_resource(rrid) + if resource: + return resource + + # If not found, request from scicrunch service + resource = await self._client.get_resource_fields(rrid) + + # Insert new or update if exists + return await self.upsert_research_resource(resource) + + async def upsert_research_resource( + self, resource: ResearchResource + ) -> ResearchResource: + """Create or update a research resource.""" + values = resource.model_dump(exclude_unset=True) + row = await self._repo.upsert_resource(values) + return ResearchResource.model_validate(dict(row)) + + # HELPERS -- + + def get_resolver_web_url(self, rrid: str) -> HttpUrl: + """Get the resolver web URL for a given RRID.""" + return self._client.get_resolver_web_url(rrid) diff --git a/services/web/server/src/simcore_service_webserver/scicrunch/db.py b/services/web/server/src/simcore_service_webserver/scicrunch/db.py deleted file mode 100644 index c99c82f2aed7..000000000000 --- a/services/web/server/src/simcore_service_webserver/scicrunch/db.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Access to postgres database scicrunch_resources table where USED rrids get stored -""" - -import logging - -import sqlalchemy as sa -from aiohttp import web -from aiopg.sa.result import ResultProxy, RowProxy -from simcore_postgres_database.models.scicrunch_resources import scicrunch_resources -from sqlalchemy.dialects.postgresql import insert as sa_pg_insert - -from ..db.plugin import get_database_engine_legacy -from .models import ResearchResource, ResearchResourceAtdB - -logger = logging.getLogger(__name__) - - -class ResearchResourceRepository: - """Hides interaction with scicrunch_resources pg tables - - acquires & releases connection **per call** - - uses aiopg[sa] - - implements CRUD on rrids - """ - - # WARNING: interfaces to both ResarchResource and ResearchResourceAtDB - - def __init__(self, app: web.Application): - self._engine = get_database_engine_legacy(app) - - async def list_resources(self) -> list[ResearchResource]: - async with self._engine.acquire() as conn: - stmt = sa.select( - [ - scicrunch_resources.c.rrid, - scicrunch_resources.c.name, - scicrunch_resources.c.description, - ] - ) - res: ResultProxy = await conn.execute(stmt) - rows: list[RowProxy] = await res.fetchall() - return ( - [ResearchResource.model_validate(row) for row in rows] if rows else [] - ) - - async def get(self, rrid: str) -> ResearchResourceAtdB | None: - async with self._engine.acquire() as conn: - stmt = sa.select(scicrunch_resources).where( - scicrunch_resources.c.rrid == rrid - ) - rows = await conn.execute(stmt) - row = await rows.fetchone() - return ResearchResourceAtdB(**row) if row else None - - async def get_resource(self, rrid: str) -> ResearchResource | None: - resource: ResearchResourceAtdB | None = await self.get(rrid) - if resource: - return ResearchResource(**resource.model_dump()) - return resource - - async def upsert(self, resource: ResearchResource): - async with self._engine.acquire() as conn: - values = resource.model_dump(exclude_unset=True) - - stmt = ( - sa_pg_insert(scicrunch_resources) - .values(values) - .on_conflict_do_update( - index_elements=[ - scicrunch_resources.c.rrid, - ], - set_=values, - ) - ) - await conn.execute(stmt) diff --git a/services/web/server/src/simcore_service_webserver/scicrunch/models.py b/services/web/server/src/simcore_service_webserver/scicrunch/models.py index 2140f88ea335..ab5bc79485c8 100644 --- a/services/web/server/src/simcore_service_webserver/scicrunch/models.py +++ b/services/web/server/src/simcore_service_webserver/scicrunch/models.py @@ -1,12 +1,14 @@ """ - Domain models at every interface: scicrunch API, pg database and webserver API +Domain models at every interface: scicrunch API, pg database and webserver API """ import logging import re from datetime import datetime +from functools import partial +from typing import Annotated -from pydantic import field_validator, ConfigDict, BaseModel, Field +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field logger = logging.getLogger(__name__) @@ -49,25 +51,29 @@ def normalize_rrid_tags(rrid_tag: str, *, with_prefix: bool = True) -> str: class ResourceHit(BaseModel): - rrid: str = Field(..., alias="rid") + rrid: Annotated[str, Field(alias="rid")] name: str # webserver API models ----------------------------------------- class ResearchResource(BaseModel): - rrid: str = Field( - ..., - description="Unique identifier used as classifier, i.e. to tag studies and services", - pattern=STRICT_RRID_PATTERN, - ) + rrid: Annotated[ + str, + BeforeValidator( + partial(normalize_rrid_tags, with_prefix=True), + ), + Field( + description="Unique identifier used as classifier, i.e. to tag studies and services", + pattern=STRICT_RRID_PATTERN, + ), + ] name: str description: str - @field_validator("rrid", mode="before") - @classmethod - def format_rrid(cls, v): - return normalize_rrid_tags(v, with_prefix=True) - model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True) + model_config = ConfigDict( + from_attributes=True, + str_strip_whitespace=True, + ) # postgres_database.scicrunch_resources ORM -------------------- diff --git a/services/web/server/src/simcore_service_webserver/scicrunch/plugin.py b/services/web/server/src/simcore_service_webserver/scicrunch/plugin.py index ef82e673c30d..78bdf8f44252 100644 --- a/services/web/server/src/simcore_service_webserver/scicrunch/plugin.py +++ b/services/web/server/src/simcore_service_webserver/scicrunch/plugin.py @@ -1,13 +1,13 @@ -""" -Notice that this is used as a submodule of groups'a app module -""" - import logging from aiohttp import web +from simcore_service_webserver.db.plugin import setup_db +from simcore_service_webserver.scicrunch._repository import ScicrunchResourcesRepository from ..application_setup import ModuleCategory, app_setup_func -from .service_client import SciCrunch +from ._client import SciCrunch +from ._service import ScicrunchResourcesService +from .scicrunch_service import SCICRUNCH_SERVICE_APPKEY from .settings import get_plugin_settings _logger = logging.getLogger(__name__) @@ -15,8 +15,23 @@ async def _on_startup(app: web.Application): settings = get_plugin_settings(app) - api = SciCrunch.acquire_instance(app, settings) - assert api == SciCrunch.get_instance(app) # nosec + + # 1. scicrunch http client + client = SciCrunch.acquire_instance(app, settings) + assert client == SciCrunch.get_instance(app) # nosec + + # 2. scicrunch repository (uses app[ENGINE_DB_CLIENT_APPKEY]) + repo = ScicrunchResourcesRepository.create_from_app(app) + assert repo # nosec + + # 3. scicrunch resources service + service = ScicrunchResourcesService( + repo=repo, + client=client, + ) + + # store service in app + app[SCICRUNCH_SERVICE_APPKEY] = service @app_setup_func( @@ -28,4 +43,6 @@ async def _on_startup(app: web.Application): def setup_scicrunch(app: web.Application): assert get_plugin_settings(app) # nosec + setup_db(app) # needs engine + app.on_startup.append(_on_startup) diff --git a/services/web/server/src/simcore_service_webserver/scicrunch/scicrunch_service.py b/services/web/server/src/simcore_service_webserver/scicrunch/scicrunch_service.py new file mode 100644 index 000000000000..c22f6fa16da5 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/scicrunch/scicrunch_service.py @@ -0,0 +1,13 @@ +from typing import Final + +from aiohttp import web + +from ._service import ScicrunchResourcesService + +SCICRUNCH_SERVICE_APPKEY: Final = web.AppKey( + ScicrunchResourcesService.__name__, ScicrunchResourcesService +) + + +__all__: tuple[str, ...] = ("SCICRUNCH_SERVICE_APPKEY",) +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/scicrunch/settings.py b/services/web/server/src/simcore_service_webserver/scicrunch/settings.py index 4a88fd3d1dc6..6b04f70be780 100644 --- a/services/web/server/src/simcore_service_webserver/scicrunch/settings.py +++ b/services/web/server/src/simcore_service_webserver/scicrunch/settings.py @@ -1,3 +1,5 @@ +from typing import Annotated + from aiohttp import web from pydantic import Field, HttpUrl, SecretStr, TypeAdapter from settings_library.base import BaseCustomSettings @@ -10,21 +12,17 @@ class SciCrunchSettings(BaseCustomSettings): - SCICRUNCH_API_BASE_URL: HttpUrl = Field( - default=TypeAdapter(HttpUrl).validate_python(f"{SCICRUNCH_DEFAULT_URL}/api/1"), - description="Base url to scicrunch API's entrypoint", - ) + SCICRUNCH_API_BASE_URL: Annotated[ + HttpUrl, Field(description="Base url to scicrunch API's entrypoint") + ] = TypeAdapter(HttpUrl).validate_python(f"{SCICRUNCH_DEFAULT_URL}/api/1") # NOTE: Login in https://scicrunch.org and get API Key under My Account -> API Keys # WARNING: this needs to be setup in osparc-ops before deploying SCICRUNCH_API_KEY: SecretStr - SCICRUNCH_RESOLVER_BASE_URL: HttpUrl = Field( - default=TypeAdapter(HttpUrl).validate_python( - f"{SCICRUNCH_DEFAULT_URL}/resolver" - ), - description="Base url to scicrunch resolver entrypoint", - ) + SCICRUNCH_RESOLVER_BASE_URL: Annotated[ + HttpUrl, Field(description="Base url to scicrunch resolver entrypoint") + ] = TypeAdapter(HttpUrl).validate_python(f"{SCICRUNCH_DEFAULT_URL}/resolver") def get_plugin_settings(app: web.Application) -> SciCrunchSettings: diff --git a/services/web/server/tests/integration/01/scicrunch/test_scicrunch__resolver.py b/services/web/server/tests/integration/01/scicrunch/test_scicrunch__resolver.py index 99683e08cff8..47845898e2d4 100644 --- a/services/web/server/tests/integration/01/scicrunch/test_scicrunch__resolver.py +++ b/services/web/server/tests/integration/01/scicrunch/test_scicrunch__resolver.py @@ -16,9 +16,6 @@ from simcore_service_webserver.scicrunch.settings import SciCrunchSettings -@pytest.mark.skip( - "requires a fix see case https://github.com/ITISFoundation/osparc-simcore/issues/5160" -) @pytest.mark.parametrize( "name,rrid", TOOL_CITATIONS + ANTIBODY_CITATIONS + PLAMID_CITATIONS + ORGANISM_CITATIONS, @@ -56,6 +53,7 @@ async def test_scicrunch_resolves_all_valid_rrids( ) else: # proper_citation includes both 'name' and 'rrid' but in different formats! + # AND has been changing in time. BELOW are some of the formats found! # # NOTE: why CELL_LINE_CITATIONS are removed from test parametrization ? @@ -64,13 +62,17 @@ async def test_scicrunch_resolves_all_valid_rrids( # sometimes (BCRJ Cat# 0226, RRID:CVCL_0033) appears as first hit instead # of the reference in CELL_LINE_CITATIONS # + valid_formats = ( + f"({name}, RRID:{rrid})", + f"{name}, RRID:{rrid}", # without parentheses + f"({name},RRID:{rrid})", + f"{name} (RRID:{rrid})", + ) + resolved_citations = [ + resolved.proper_citation for resolved in resolved_items + ] assert any( - resolved.proper_citation - in ( - f"({name}, RRID:{rrid})", - f"({name},RRID:{rrid})", - f"{name} (RRID:{rrid})", - ) - for resolved in resolved_items - ) + proper_citation in valid_formats + for proper_citation in resolved_citations + ), f"No proper_citation found with both {name=} and {rrid=}: {resolved_citations=} not in {valid_formats=}" diff --git a/services/web/server/tests/integration/01/test_exporter_requests_handlers.py b/services/web/server/tests/integration/01/test_exporter_requests_handlers.py index b471f6883c85..161b81ccebfd 100644 --- a/services/web/server/tests/integration/01/test_exporter_requests_handlers.py +++ b/services/web/server/tests/integration/01/test_exporter_requests_handlers.py @@ -14,6 +14,7 @@ import pytest import redis.asyncio as aioredis from aiohttp.test_utils import TestClient +from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.webserver_login import LoggedUser, UserInfoDict from pytest_simcore.helpers.webserver_projects import ( create_project, @@ -152,6 +153,7 @@ async def client( redis_client: aioredis.Redis, rabbit_service: RabbitSettings, simcore_services_ready: None, + monkeypatch: pytest.MonkeyPatch, ) -> Iterable[TestClient]: # test config & env vars ---------------------- cfg = deepcopy(app_config) @@ -163,11 +165,20 @@ async def client( monkeypatch_setenv_from_app_config(cfg) + monkeypatch.delenv("WEBSERVER_SCICRUNCH", raising=False) + setenvs_from_dict( + monkeypatch, {"SCICRUNCH_API_KEY": "REPLACE_ME_with_valid_api_key"} + ) + # app setup ---------------------------------- app = create_safe_application(cfg) # activates only security+restAPI sub-modules - assert setup_settings(app) + app_settings = setup_settings(app) + + assert app_settings.WEBSERVER_SCICRUNCH is not None + assert app_settings.WEBSERVER_RABBITMQ is not None + assert app_settings.WEBSERVER_EXPORTER is not None assert ( exporter_settings.get_plugin_settings(app) is not None ), "Should capture defaults" diff --git a/services/web/server/tests/unit/isolated/scicrunch/test_scicrunch_service_client.py b/services/web/server/tests/unit/isolated/scicrunch/test_scicrunch_service_client.py index d32013c33885..6d6884f7ff35 100644 --- a/services/web/server/tests/unit/isolated/scicrunch/test_scicrunch_service_client.py +++ b/services/web/server/tests/unit/isolated/scicrunch/test_scicrunch_service_client.py @@ -14,16 +14,19 @@ import pytest from aiohttp import web -from aioresponses import aioresponses as AioResponsesMock # noqa: N812 +from aioresponses import aioresponses as AioResponsesMock +from pytest_mock import MockerFixture # noqa: N812 from pytest_simcore.helpers.typing_env import EnvVarsDict from servicelib.aiohttp.application import create_safe_application from servicelib.aiohttp.client_session import get_client_session from simcore_service_webserver.application_settings import setup_settings -from simcore_service_webserver.scicrunch.plugin import setup_scicrunch -from simcore_service_webserver.scicrunch.service_client import ( +from simcore_service_webserver.scicrunch._client import ( ResearchResource, SciCrunch, ) +from simcore_service_webserver.scicrunch.plugin import ( + setup_scicrunch, +) @pytest.fixture @@ -103,14 +106,28 @@ async def mock_scicrunch_service_resolver( async def app( mock_env_devel_environment: EnvVarsDict, aiohttp_server: Callable, + mocker: MockerFixture, ) -> web.Application: app_ = create_safe_application() + # mock access to db in this test-suite + mock_setup_db = mocker.patch( + "simcore_service_webserver.scicrunch.plugin.setup_db", + return_value=True, + ) + + get_async_engine = mocker.patch( + "simcore_service_webserver.db.base_repository._asyncpg.get_async_engine", + ) + setup_settings(app_) setup_scicrunch(app_) + assert mock_setup_db.called + assert not get_async_engine.called server = await aiohttp_server(app_) assert server.app == app_ + assert get_async_engine.called return server.app diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py deleted file mode 100644 index c77f1335015a..000000000000 --- a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py +++ /dev/null @@ -1,56 +0,0 @@ -# pylint: disable=redefined-outer-name -# pylint: disable=unused-argument -# pylint: disable=unused-variable -# pylint: disable=too-many-arguments - - -import pytest -import sqlalchemy as sa -from servicelib.common_aiopg_utils import DataSourceName, create_pg_engine -from simcore_service_webserver.constants import APP_AIOPG_ENGINE_KEY -from simcore_service_webserver.groups._classifiers_service import ( - GroupClassifierRepository, -) -from sqlalchemy.sql import text - - -@pytest.fixture -def inject_tables(postgres_db: sa.engine.Engine): - stmt = text( - """\ - INSERT INTO "group_classifiers" ("id", "bundle", "created", "modified", "gid", "uses_scicrunch") VALUES - (2, '{"vcs_ref": "asdfasdf", "vcs_url": "https://foo.classifiers.git", "build_date": "2021-01-20T15:19:30Z", "classifiers": {"project::dak": {"url": null, "logo": null, "aliases": [], "related": [], "markdown": "", "released": null, "classifier": "project::dak", "created_by": "Nicolas Chavannes", "github_url": null, "display_name": "DAK", "wikipedia_url": null, "short_description": null}, "organization::zmt": {"url": "https://zmt.swiss/", "logo": null, "aliases": ["Zurich MedTech AG"], "related": [], "markdown": "Zurich MedTech AG (ZMT) offers tools and best practices for targeted life sciences applications to simulate, analyze, and predict complex and dynamic biological processes and interactions. ZMT is a member of Zurich43", "released": null, "classifier": "organization::zmt", "created_by": "crespo", "github_url": null, "display_name": "ZMT", "wikipedia_url": null, "short_description": "ZMT is a member of Zurich43"}}, "collections": {"jupyterlab-math": {"items": ["crespo/osparc-demo"], "markdown": "Curated collection of repositories with examples of notebooks to run in jupyter-python-octave-math service", "created_by": "crespo", "display_name": "jupyterlab-math"}}}', '2021-03-04 23:17:43.373258', '2021-03-04 23:17:43.373258', 1, '0'); - """ - ) - with postgres_db.connect() as conn: - conn.execute(stmt) - - -@pytest.fixture -async def app(postgres_dsn: dict, inject_tables): - dsn = DataSourceName( - user=postgres_dsn["user"], - password=postgres_dsn["password"], - database=postgres_dsn["database"], - host=postgres_dsn["host"], - port=postgres_dsn["port"], - ) - - async with create_pg_engine(dsn) as engine: - fake_app = {APP_AIOPG_ENGINE_KEY: engine} - yield fake_app - - -async def test_classfiers_from_bundle(app): - repo = GroupClassifierRepository(app) - - assert not await repo.group_uses_scicrunch(gid=1) - - bundle = await repo.get_classifiers_from_bundle(gid=1) - assert bundle - - # Prunes extras and excludes unset and nones - assert bundle["classifiers"]["project::dak"] == { - "classifier": "project::dak", - "display_name": "DAK", - } diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers_repository.py b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers_repository.py new file mode 100644 index 000000000000..3eb480dd2e6a --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers_repository.py @@ -0,0 +1,93 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + +import pytest +from models_library.groups import EVERYONE_GROUP_ID +from pytest_simcore.helpers.faker_factories import random_group_classifier +from pytest_simcore.helpers.postgres_tools import insert_and_get_row_lifespan +from simcore_postgres_database.models.classifiers import group_classifiers +from simcore_service_webserver.groups._classifiers_repository import ( + GroupClassifierRepository, +) +from sqlalchemy.ext.asyncio import AsyncEngine + + +@pytest.fixture +async def group_classifier_in_db(asyncpg_engine: AsyncEngine): + """Pre-populate group_classifiers table with test data.""" + data = random_group_classifier(gid=EVERYONE_GROUP_ID) + + # pylint: disable=contextmanager-generator-missing-cleanup + # NOTE: this code is safe since `@asynccontextmanager` takes care of the cleanup + async with insert_and_get_row_lifespan( + asyncpg_engine, + table=group_classifiers, + values=data, + pk_col=group_classifiers.c.id, + pk_value=data.get("id"), + ) as row: + yield row + + +@pytest.fixture +def group_classifier_repository( + asyncpg_engine: AsyncEngine, +) -> GroupClassifierRepository: + """Create GroupClassifierRepository instance.""" + return GroupClassifierRepository(engine=asyncpg_engine) + + +async def test_get_classifiers_from_bundle_returns_bundle( + group_classifier_repository: GroupClassifierRepository, + group_classifier_in_db: dict, +): + """Test get_classifiers_from_bundle returns the stored bundle.""" + # Act + bundle = await group_classifier_repository.get_classifiers_from_bundle( + gid=group_classifier_in_db["gid"] + ) + + # Assert + assert bundle is not None + assert bundle["vcs_url"] == "https://organization.classifiers.git" + assert "classifiers" in bundle + assert "project::dak" in bundle["classifiers"] + assert bundle["classifiers"]["project::dak"]["display_name"] == "DAK" + + +async def test_group_uses_scicrunch_returns_false( + group_classifier_repository: GroupClassifierRepository, + group_classifier_in_db: dict, +): + """Test group_uses_scicrunch returns False for non-scicrunch group.""" + # Act + uses_scicrunch = await group_classifier_repository.group_uses_scicrunch( + gid=group_classifier_in_db["gid"] + ) + + # Assert + assert uses_scicrunch is False + + +async def test_get_classifiers_from_bundle_returns_none_for_missing_gid( + group_classifier_repository: GroupClassifierRepository, +): + """Test get_classifiers_from_bundle returns None for non-existent gid.""" + # Act + bundle = await group_classifier_repository.get_classifiers_from_bundle(gid=999999) + + # Assert + assert bundle is None + + +async def test_group_uses_scicrunch_returns_false_for_missing_gid( + group_classifier_repository: GroupClassifierRepository, +): + """Test group_uses_scicrunch returns False for non-existent gid.""" + # Act + uses_scicrunch = await group_classifier_repository.group_uses_scicrunch(gid=999999) + + # Assert + assert uses_scicrunch is False