diff --git a/app/backend/api/routers/project_router.py b/app/backend/api/routers/project_router.py index b3fd829..9f6e349 100644 --- a/app/backend/api/routers/project_router.py +++ b/app/backend/api/routers/project_router.py @@ -26,6 +26,7 @@ ProjectImportIn, ProtocolWizardExecuteResponse, ProtocolWizardExecuteRequest) from app.backend.api.services.project_service import ProjectService +from app.backend.models.project_model import ExternalViewerLaunchRequest from app.backend.models.protocol_model import ( ProtocolRequest, ProtocolRenameIn, @@ -515,16 +516,36 @@ def renameProtocol( ) try: - newName = getattr(payload, "name", None) - if not newName or not str(newName).strip(): + newName = getattr(payload, "runName", None) + newComment = getattr(payload, "comment", "") + + if newName is None: return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={"status": 1, - "errors": ["Missing name"], - "workflow": []}, + content={ + "status": 1, + "errors": ["Missing name"], + "workflow": [], + }, ) - service.renameProtocol(protocolId, str(newName).strip()) + newNameText = str(newName) + + if newNameText != "" and not newNameText.strip(): + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "status": 1, + "errors": ["Missing name"], + "workflow": [], + }, + ) + + service.renameProtocol( + protocolId, + newNameText.strip(), + str(newComment or "").strip(), + ) service.syncProjectGraphAfterMutation( mapper, projectId, @@ -2628,6 +2649,98 @@ def getMetadataTableWindow( resp.headers["Vary"] = "Authorization" return resp +# ====================================================================== +# ANALYZE RESULTS: EXTERNAL VIEWERS +# ====================================================================== + +@router.get( + "/{projectId}/protocols/{protocolId}/outputs/{outputName}/external-viewers", + response_model=Any, + status_code=status.HTTP_200_OK, +) +def listExternalViewers( + projectId: int, + protocolId: int, + outputName: str, + objectId: Optional[str] = Query(None), + objectKind: Optional[str] = Query(None), + currentUser=Depends(getCurrentUser), + mapper: PostgresqlFlatMapper = Depends(getMapper), + service: ProjectService = Depends(getProjectService), +): + project = service.getProjectById( + mapper, + projectId, + currentUser, + refresh=False, + checkPid=False, + ) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + try: + viewers = service.listExternalViewers( + protocolId=protocolId, + outputName=outputName, + objectId=objectId, + objectKind=objectKind, + ) + return {"viewers": viewers} + except HTTPException: + raise + except Exception as e: + logger.exception("Error in listExternalViewers: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list external viewers: {e}", + ) + + +@router.post( + "/{projectId}/protocols/{protocolId}/outputs/{outputName}/external-viewers/{viewerId}/launch", + response_model=Any, + status_code=status.HTTP_200_OK, +) +def launchExternalViewer( + projectId: int, + protocolId: int, + outputName: str, + viewerId: str, + payload: Optional[ExternalViewerLaunchRequest] = Body(default=None), + currentUser=Depends(getCurrentUser), + mapper: PostgresqlFlatMapper = Depends(getMapper), + service: ProjectService = Depends(getProjectService), +): + project = service.getProjectById( + mapper, + projectId, + currentUser, + refresh=False, + checkPid=False, + ) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + payload = payload or ExternalViewerLaunchRequest() + + try: + return service.launchExternalViewer( + protocolId=protocolId, + outputName=outputName, + viewerId=viewerId, + objectId=payload.objectId, + objectKind=payload.objectKind, + params=payload.params or {}, + ) + except HTTPException: + raise + except Exception as e: + logger.exception("Error in launchExternalViewer: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to launch external viewer: {e}", + ) + # ====================================================================== # PROTOCOL TAGS # ====================================================================== diff --git a/app/backend/api/services/plugin_service.py b/app/backend/api/services/plugin_service.py index 296b1f8..3187928 100644 --- a/app/backend/api/services/plugin_service.py +++ b/app/backend/api/services/plugin_service.py @@ -11,6 +11,7 @@ from app.backend.api.services.plugin_task_log import appendPluginTaskLog, writePluginTaskStep from app.utils.scipion_helper import serializeToJson +from app.backend.resources import getPluginCategoryIds, getPluginCategoryData logger = logging.getLogger(__name__) @@ -77,6 +78,15 @@ def getPlugins(self, forceRefresh: bool = False) -> List[Dict[str, Any]]: serializedPlugin = serializeToJson(pluginObj) serializedPlugin["fullLogo"] = self._buildFullLogo(serializedPlugin) + pipName = str(serializedPlugin.get("pipName") or pluginKey).strip() + categories = getPluginCategoryIds(pipName) + serializedPlugin["categories"] = categories + serializedPlugin["categoryData"] = getPluginCategoryData(pipName) + + # if 'tomography' not in categories: + # continue + # serializedPlugin["categories"] = ['tomography'] + # serializedPlugin["categoryData"] = [{'description': 'Tomograms, tilt series and subtomogram workflows', 'id': 'tomography', 'title': 'Tomography'}] isInstalled = False try: diff --git a/app/backend/api/services/project_service.py b/app/backend/api/services/project_service.py index 248f2b1..fde6929 100644 --- a/app/backend/api/services/project_service.py +++ b/app/backend/api/services/project_service.py @@ -62,6 +62,12 @@ from pyworkflow.protocol import MODE_RESUME, MODE_RESTART, STATUS_LAUNCHED, STATUS_RUNNING, STATUS_SCHEDULED from pyworkflow.template import TemplateList +try: + from pyworkflow.viewer import DESKTOP_TKINTER +except Exception: + DESKTOP_TKINTER = None + findViewers=None + logger = logging.getLogger(__name__) import os @@ -170,6 +176,205 @@ def initializeOrderManager(self): self.objectManager = self._createObjectManager() return self.objectManager + def _getPreviewObjectManager(self) -> ObjectManager: + """ + Return an ObjectManager for preview operations. + + Prefer a fresh instance to avoid sharing SQLite connections across + concurrent HTTP requests. + """ + return self._createObjectManager() + + def _safeScipionValue(self, value: Any) -> Any: + """ + Convert Scipion/Python values into JSON-safe preview values. + """ + if value is None: + return None + + if isinstance(value, (str, int, float, bool)): + if isinstance(value, str) and len(value) > 240: + return value[:240] + "..." + return value + + if isinstance(value, (list, tuple)): + return [self._safeScipionValue(v) for v in value[:20]] + + if isinstance(value, dict): + return { + str(k): self._safeScipionValue(v) + for k, v in list(value.items())[:30] + } + + try: + text = str(value) + return text[:240] + "..." if len(text) > 240 else text + except Exception: + return repr(value) + + def _tryReadScipionSetWithObjectManager(self, filePath: FsPath) -> Optional[Any]: + """ + Try several ObjectManager entry points because different metadata + viewer versions expose slightly different method names. + """ + objMgr = self._getPreviewObjectManager() + fileName = str(filePath) + + candidateCalls = [ + ("read", (fileName,)), + ("load", (fileName,)), + ("open", (fileName,)), + ("getObject", (fileName,)), + ("getDataObject", (fileName,)), + ("getDataObjects", (fileName,)), + ] + + lastError = None + + for methodName, args in candidateCalls: + method = getattr(objMgr, methodName, None) + if method is None: + continue + + try: + result = method(*args) + if result is not None: + if isinstance(result, (list, tuple)) and result: + return result[0] + return result + except Exception as exc: + lastError = exc + + if lastError is not None: + logger.debug( + "Could not read Scipion sqlite with ObjectManager. file=%s error=%s", + fileName, + lastError, + ) + + return None + + def _extractScipionSetPreviewInfo(self, obj: Any) -> Dict[str, Any]: + """ + Build a compact preview payload from a Scipion set-like object. + """ + objectClass = obj.__class__.__name__ if obj is not None else None + + objectCount = None + for methodName in ("getSize", "__len__"): + try: + if methodName == "__len__": + objectCount = len(obj) + else: + method = getattr(obj, methodName, None) + if method is not None: + objectCount = int(method()) + if objectCount is not None: + break + except Exception: + pass + + summary: list[Dict[str, Any]] = [] + + if objectClass: + summary.append({"key": "Object class", "value": objectClass}) + if objectCount is not None: + summary.append({"key": "Items", "value": objectCount}) + + scalarMethods = [ + ("Sampling rate", "getSamplingRate"), + ("Dimensions", "getDimensions"), + ("First item", "getFirstItem"), + ("File name", "getFileName"), + ] + + for label, methodName in scalarMethods: + try: + method = getattr(obj, methodName, None) + if method is None: + continue + value = method() + safeValue = self._safeScipionValue(value) + if safeValue not in (None, ""): + summary.append({"key": label, "value": safeValue}) + except Exception: + pass + + sampleRows = [] + sampleColumns: list[str] = [] + + try: + iterator = iter(obj) + for index, item in enumerate(iterator): + if index >= 10: + break + + row = self._buildScipionItemPreviewRow(item) + if row: + for key in row.keys(): + if key not in sampleColumns: + sampleColumns.append(key) + sampleRows.append(row) + except Exception: + pass + + return { + "objectClass": objectClass, + "objectCount": objectCount, + "summary": summary, + "sample": { + "columns": sampleColumns, + "rows": sampleRows, + }, + } + + def _buildScipionItemPreviewRow(self, item: Any) -> Dict[str, Any]: + """ + Build a compact preview row for one Scipion object item. + """ + row: Dict[str, Any] = {} + + candidates = [ + ("id", "getObjId"), + ("class", "getClassName"), + ("fileName", "getFileName"), + ("index", "getIndex"), + ("enabled", "isEnabled"), + ("samplingRate", "getSamplingRate"), + ("dimensions", "getDimensions"), + ] + + for key, methodName in candidates: + try: + method = getattr(item, methodName, None) + if method is None: + continue + value = method() + row[key] = self._safeScipionValue(value) + except Exception: + pass + + if not row: + try: + row["value"] = self._safeScipionValue(item) + except Exception: + pass + + return row + + def _inspectScipionSqliteDatabase(self, filePath: FsPath) -> Optional[Dict[str, Any]]: + """ + Inspect a Scipion SQLite object database using the metadata viewer + ObjectManager when possible. + """ + obj = self._tryReadScipionSetWithObjectManager(filePath) + if obj is None: + return None + + info = self._extractScipionSetPreviewInfo(obj) + info["reader"] = "ObjectManager" + return info + @staticmethod def sanitizeProjectName(rawName: str) -> str: """ @@ -2281,7 +2486,7 @@ def saveProtocol(self, mapper, projectId, protocolId, protocolClassName, params, if key == "runName": protocol.runName.set(castedValue) - protocol.setObjLabel(castedValue) + # protocol.setObjLabel(castedValue) logger.info("[INFO] Set param %s = %s", key, castedValue) except Exception as e: @@ -3009,7 +3214,7 @@ def _buildProtocolMutationResult(message: str, **extra) -> Dict[str, Any]: return result - def renameProtocol(self, protocolId, newName): + def renameProtocol(self, protocolId, newName, newComment): protocol = self.currentProject.getProtocol(int(protocolId)) if protocol is None: raise HTTPException( @@ -3019,7 +3224,8 @@ def renameProtocol(self, protocolId, newName): try: protocol.runName.set(newName) - protocol.setObjLabel(newName) + protocol._objComment = newComment + # protocol.setObjLabel(newName) self.currentProject._storeProtocol(protocol) except Exception as e: logger.exception( @@ -3548,9 +3754,17 @@ def previewRemoteEntry(self, protocolId: str, path: str): if self._isGlobalFsBrowserMode(protocolId): root = self._getGlobalFsBrowserRoot() - return fileHandlers.previewRemoteEntryUnderRoot(root, path) + return fileHandlers.previewRemoteEntryUnderRoot( + root, + path, + databaseInspector=self._inspectScipionSqliteDatabase, + ) - return fileHandlers.previewProtocolRemoteEntry(protocolId, path) + return fileHandlers.previewProtocolRemoteEntry( + protocolId, + path, + databaseInspector=self._inspectScipionSqliteDatabase, + ) def previewProtocolImageFile(self, protocolId, path, inline: bool): """ @@ -6832,6 +7046,580 @@ def getMetadataTableWindowService( "rows": resultRows, } + # ----------------------------- + # External viewers methods + # ----------------------------- + + def _resolveExternalViewerCoords3dTomogram( + self, + outputObj: Any, + objectId: Union[str, int], + ) -> Any: + targetId = str(objectId).strip() + if not targetId: + return None + + cached = getattr(self, "tomoList", {}).get(targetId) + if cached is not None: + return cached + + getTomogram = getattr(outputObj, "_getTomogram", None) + if callable(getTomogram): + try: + tomo = getTomogram(targetId) + if tomo is not None: + return tomo + except Exception: + pass + + iterTomograms = getattr(outputObj, "iterTomograms", None) + if callable(iterTomograms): + try: + for tomo in iterTomograms(): + tomoIds = self._getExternalViewerObjectIds(tomo) + + getTsId = getattr(tomo, "getTsId", None) + if callable(getTsId): + try: + tomoIds.add(str(getTsId())) + except Exception: + pass + + getObjLabel = getattr(tomo, "getObjLabel", None) + if callable(getObjLabel): + try: + tomoIds.add(str(getObjLabel())) + except Exception: + pass + + if targetId in tomoIds: + if not hasattr(self, "tomoList") or self.tomoList is None: + self.tomoList = {} + self.tomoList[targetId] = tomo + return tomo + except Exception: + pass + + return None + + def _resolveExternalViewerCTFTomoSeries( + self, + outputObj: Any, + objectId: Union[str, int], + ) -> Any: + targetId = str(objectId).strip() + if not targetId: + return None + + try: + for item in outputObj: + itemIds = self._getExternalViewerObjectIds(item) + + for methodName in ( + "getTsId", + "getTomoId", + "getCTFTomoSeriesId", + "getObjId", + "getObjLabel", + "getName", + ): + method = getattr(item, methodName, None) + if callable(method): + try: + value = method() + if value is not None: + itemIds.add(str(value)) + except Exception: + pass + + if targetId in itemIds: + return item + except Exception: + pass + + return None + + def _isSingleExternalViewerObject(self, outputObj: Any) -> bool: + if outputObj is None: + return False + + getItem = getattr(outputObj, "getItem", None) + if callable(getItem): + return False + + iterItems = getattr(outputObj, "__iter__", None) + if callable(iterItems): + return False + + getFileName = getattr(outputObj, "getFileName", None) + if callable(getFileName): + return True + + return False + + def _findExternalViewerClasses(self, targetObj: Any) -> List[Any]: + try: + viewers = Config.getDomain().findViewers(targetObj, DESKTOP_TKINTER) or [] + return list(viewers) + except BaseException as e: + logger.exception( + "Failed to find external viewers for object type %s: %s", + type(targetObj).__name__, + e, + ) + return [] + + def _normalizeExternalViewerId(self, viewerClass: Any) -> str: + className = getattr(viewerClass, "__name__", "") or str(viewerClass) + viewerId = className.strip() + + if viewerId.lower().endswith("viewer"): + viewerId = viewerId[:-6] + + viewerId = re.sub(r"[^A-Za-z0-9]+", "-", viewerId).strip("-").lower() + return viewerId or "viewer" + + def _buildExternalViewerDescriptor(self, viewerClass: Any) -> Dict[str, Any]: + className = getattr(viewerClass, "__name__", "") or str(viewerClass) + moduleName = getattr(viewerClass, "__module__", None) + + label = ( + getattr(viewerClass, "_label", None) + or getattr(viewerClass, "label", None) + or className + ) + + label = str(label).replace("Viewer", "").strip() or className + + return { + "id": self._normalizeExternalViewerId(viewerClass), + "label": label, + "className": className, + "moduleName": moduleName, + "available": True, + "reason": None, + } + + def _unwrapScipionObject(self, obj: Any) -> Any: + if obj is None: + return None + + getter = getattr(obj, "get", None) + if callable(getter): + try: + value = getter() + if value is not None: + return value + except Exception: + pass + + return obj + + def _getProtocolOutputObject( + self, + protocolId: int, + outputName: str, + ) -> Tuple[Any, Any]: + if self.currentProject is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="No current project loaded", + ) + + try: + protocol = self.currentProject.getProtocol(int(protocolId)) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Protocol not found: {protocolId}. {e}", + ) + + if protocol is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Protocol not found: {protocolId}", + ) + + outputObj = None + + if hasattr(protocol, outputName): + outputObj = getattr(protocol, outputName) + + if outputObj is None: + iterator = getattr(protocol, "iterOutputAttributes", None) + if callable(iterator): + try: + for attrName, attrObj in iterator(): + if str(attrName) == str(outputName): + outputObj = attrObj + break + except Exception: + pass + + outputObj = self._unwrapScipionObject(outputObj) + + if outputObj is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Output not found: {outputName}", + ) + + return protocol, outputObj + + def _getExternalViewerObjectIds(self, obj: Any) -> Set[str]: + values: Set[str] = set() + + def addValue(value: Any): + if value is None: + return + + getter = getattr(value, "get", None) + if callable(getter): + try: + value = getter() + except Exception: + pass + + if value is None: + return + + text = str(value).strip() + if text: + values.add(text) + + for methodName in ( + "getTsId", + "getObjId", + "getId", + "getName", + "getFileName", + ): + method = getattr(obj, methodName, None) + if callable(method): + try: + addValue(method()) + except Exception: + pass + + for attrName in ( + "tsId", + "id", + "objId", + "_objId", + "name", + "label", + "filename", + "fileName", + ): + if hasattr(obj, attrName): + try: + addValue(getattr(obj, attrName)) + except Exception: + pass + + return values + + def _resolveExternalViewerTargetObject( + self, + outputObj: Any, + objectId: Optional[Union[str, int]] = None, + objectKind: Optional[str] = None, + ) -> Any: + if objectId is None or str(objectId).strip() == "": + return outputObj + + targetId = str(objectId).strip() + objectKindText = str(objectKind or "").strip().lower() + + if objectKindText in {"volume", "tomogram"} and self._isSingleExternalViewerObject(outputObj): + if targetId in {"0", "1"}: + return outputObj + + if objectKindText in {"coords3dtomogram", "coords3d-tomogram", "coordinates3dtomogram"}: + resolved = self._resolveExternalViewerCoords3dTomogram( + outputObj=outputObj, + objectId=objectId, + ) + if resolved is not None: + return resolved + + if objectKindText in {"ctftomoseries", "ctf-tomo-series", "ctfseries"}: + resolved = self._resolveExternalViewerCTFTomoSeries( + outputObj=outputObj, + objectId=objectId, + ) + if resolved is not None: + return resolved + + if objectKindText in {"volume", "tomogram"}: + resolved = self._resolveExternalViewerSetItemByPublicId( + outputObj=outputObj, + objectId=objectId, + ) + if resolved is not None: + return resolved + + try: + for item in outputObj: + itemIds = self._getExternalViewerObjectIds(item) + if targetId in itemIds: + return item + except Exception: + pass + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=( + f"Object '{targetId}' not found inside output. " + f"objectKind={objectKind or 'unknown'}" + ), + ) + + def _resolveExternalViewerSetItemByPublicId( + self, + outputObj: Any, + objectId: Union[str, int], + ) -> Any: + try: + publicId = int(objectId) + except Exception: + return None + + getItem = getattr(outputObj, "getItem", None) + if callable(getItem): + for key, value in ( + ("_objId", publicId + 1), + ("_objId", publicId), + ("id", publicId), + ("index", publicId), + ): + try: + item = getItem(key, value) + if item is not None: + return item + except Exception: + pass + + try: + for index, item in enumerate(outputObj): + if index == publicId: + return item + + itemIds = self._getExternalViewerObjectIds(item) + if str(publicId) in itemIds or str(publicId + 1) in itemIds: + return item + except Exception: + pass + + return None + + def listExternalViewers( + self, + protocolId: int, + outputName: str, + objectId: Optional[Union[str, int]] = None, + objectKind: Optional[str] = None, + ) -> List[Dict[str, Any]]: + protocol, outputObj = self._getProtocolOutputObject( + protocolId=protocolId, + outputName=outputName, + ) + + targetObj = self._resolveExternalViewerTargetObject( + outputObj=outputObj, + objectId=objectId, + objectKind=objectKind, + ) + + viewerClasses = self._findExternalViewerClasses(targetObj) + + descriptors = [] + seenIds: Set[str] = set() + excludedViewer = ['TomoDataViewer', 'MDViewer', 'DataViewer', 'CtfEstimationTomoViewer'] + for viewerClass in viewerClasses: + descriptor = self._buildExternalViewerDescriptor(viewerClass) + viewerId = descriptor["id"] + if descriptor['className'] in excludedViewer: + continue + + if viewerId in seenIds: + className = descriptor.get("className") or viewerId + viewerId = f"{viewerId}-{len(seenIds) + 1}" + descriptor["id"] = viewerId + descriptor["className"] = className + + seenIds.add(viewerId) + descriptors.append(descriptor) + + return descriptors + + def _matchExternalViewerClass( + self, + viewerClasses: List[Any], + viewerId: str, + ) -> Tuple[Any, Dict[str, Any]]: + requested = str(viewerId or "").strip().lower() + + for viewerClass in viewerClasses: + descriptor = self._buildExternalViewerDescriptor(viewerClass) + + tokens = { + str(descriptor.get("id") or "").lower(), + str(descriptor.get("label") or "").lower(), + str(descriptor.get("className") or "").lower(), + str(descriptor.get("moduleName") or "").lower(), + } + + className = str(descriptor.get("className") or "") + if className.lower().endswith("viewer"): + tokens.add(className[:-6].lower()) + + if requested in tokens: + return viewerClass, descriptor + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"External viewer not found or not compatible: {viewerId}", + ) + + def _createExternalViewerInstance(self, viewerClass: Any, protocol: Any) -> Any: + attempts = [ + {"project": self.currentProject, "protocol": protocol}, + {"protocol": protocol}, + {"project": self.currentProject}, + {}, + ] + + lastError = None + + for kwargs in attempts: + try: + viewer = viewerClass(**kwargs) + return viewer + except TypeError as e: + lastError = e + except Exception as e: + lastError = e + break + + raise RuntimeError(f"Could not create viewer instance: {lastError}") + + def _showExternalView(self, view: Any): + if view is None: + return + + for methodName in ("show", "execute", "launch", "run"): + method = getattr(view, methodName, None) + if callable(method): + method() + return + + if callable(view): + view() + + def _runExternalViewer(self, viewerClass: Any, protocol: Any, targetObj: Any): + viewer = self._createExternalViewerInstance(viewerClass, protocol) + + for methodName in ("setProject",): + method = getattr(viewer, methodName, None) + if callable(method): + try: + method(self.currentProject) + except Exception: + pass + + for methodName in ("setProtocol",): + method = getattr(viewer, methodName, None) + if callable(method): + try: + method(protocol) + except Exception: + pass + + visualize = getattr(viewer, "visualize", None) + if not callable(visualize): + visualize = getattr(viewer, "_visualize", None) + + if not callable(visualize): + raise RuntimeError("Viewer does not expose a visualize method") + + views = visualize(targetObj) + + if views is None: + return + + if not isinstance(views, (list, tuple)): + views = [views] + + for view in views: + self._showExternalView(view) + + def launchExternalViewer( + self, + protocolId: int, + outputName: str, + viewerId: str, + objectId: Optional[Union[str, int]] = None, + objectKind: Optional[str] = None, + params: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + protocol, outputObj = self._getProtocolOutputObject( + protocolId=protocolId, + outputName=outputName, + ) + + targetObj = self._resolveExternalViewerTargetObject( + outputObj=outputObj, + objectId=objectId, + objectKind=objectKind, + ) + + viewerClasses = self._findExternalViewerClasses(targetObj) + + viewerClass, descriptor = self._matchExternalViewerClass( + viewerClasses=viewerClasses, + viewerId=viewerId, + ) + + thread = threading.Thread( + target=self._safeRunExternalViewer, + args=(viewerClass, protocol, targetObj, descriptor), + daemon=True, + ) + thread.start() + + return { + "success": True, + "viewerId": descriptor["id"], + "message": f"{descriptor['label']} launch requested.", + "pid": None, + "data": { + "objectId": objectId, + "objectKind": objectKind, + }, + } + + def _safeRunExternalViewer( + self, + viewerClass: Any, + protocol: Any, + targetObj: Any, + descriptor: Dict[str, Any], + ): + try: + self._runExternalViewer( + viewerClass=viewerClass, + protocol=protocol, + targetObj=targetObj, + ) + except Exception as e: + logger.exception( + "External viewer failed. viewerId=%s className=%s error=%s", + descriptor.get("id"), + descriptor.get("className"), + e, + ) + # ----------------------------- # Tags Service Methods # ----------------------------- diff --git a/app/backend/models/project_model.py b/app/backend/models/project_model.py index b9a04cc..4398db2 100644 --- a/app/backend/models/project_model.py +++ b/app/backend/models/project_model.py @@ -24,6 +24,7 @@ # * # ****************************************************************************** # models/project_model.py +from typing import Optional, Union, Dict, Any from pydantic import BaseModel from datetime import datetime @@ -75,3 +76,9 @@ class ProjectResponse(BaseModel): class ProjectUpdateRequest(BaseModel): name: str description: str + + +class ExternalViewerLaunchRequest(BaseModel): + objectId: Optional[Union[str, int]] = None + objectKind: Optional[str] = None + params: Optional[Dict[str, Any]] = None \ No newline at end of file diff --git a/app/backend/models/protocol_model.py b/app/backend/models/protocol_model.py index e382a50..fa1a717 100644 --- a/app/backend/models/protocol_model.py +++ b/app/backend/models/protocol_model.py @@ -103,7 +103,8 @@ class ProtocolUpdateRequest(BaseModel): class ProtocolRenameIn(BaseModel): - name: str + runName: Optional[str] = "" + comment: Optional[str] = "" class ProtocolDuplicateIn(BaseModel): diff --git a/app/backend/resources/__init__.py b/app/backend/resources/__init__.py new file mode 100644 index 0000000..6e3e6f0 --- /dev/null +++ b/app/backend/resources/__init__.py @@ -0,0 +1,26 @@ +# ****************************************************************************** +# * +# * Authors: Yunior C. Fonseca Reyna +# * +# * Unidad de Bioinformatica of Centro Nacional de Biotecnologia , CSIC +# * +# * This program is free software; you can redistribute it and/or modify +# * it under the terms of the GNU General Public License as published by +# * the Free Software Foundation; either version 3 of the License, or +# * (at your option) any later version. +# * +# * This program is distributed in the hope that it will be useful, +# * but WITHOUT ANY WARRANTY; without even the implied warranty of +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# * GNU General Public License for more details. +# * +# * You should have received a copy of the GNU General Public License +# * along with this program; if not, write to the Free Software +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA +# * 02111-1307 USA +# * +# * All comments concerning this program package may be sent to the +# * e-mail address 'scipion@cnb.csic.es' +# * +# ****************************************************************************** +from .plugin_categories import * \ No newline at end of file diff --git a/app/backend/resources/plugin_categories.py b/app/backend/resources/plugin_categories.py new file mode 100644 index 0000000..830b9c7 --- /dev/null +++ b/app/backend/resources/plugin_categories.py @@ -0,0 +1,198 @@ +from typing import List, Dict, Any + +categories = { + "single_particle": { + "title": "SPA", + "description": "SPA processing, classification, refinement and reconstruction" + }, + "tomography": { + "title": "Tomography", + "description": "Tomograms, tilt series and subtomogram workflows" + }, + "modelling": { + "title": "Modelling", + "description": "Model building, fitting, validation and visualization" + }, + "flexibility": { + "title": "Flexibility", + "description": "Visualization and manipulation of flexibility data" + }, + "chem": { + "title": "CHEM", + "description": "CHEMoinformatics and virtual drug screening" + }, + "unclassified": { + "title": "Unclassified", + "description": "Unclassified plugins" + }, +} + +plugins = { + "scipion-em-xmipp": ["single_particle"], + "scipion-em-relion": ["single_particle"], + "scipion-em-emready": ["single_particle"], + "scipion-em-reweighting": ["single_particle"], + "scipion-em-localrec": ["single_particle"], + "scipion-em-cryosparc2": ["single_particle"], + "scipion-em-prody": ["single_particle"], + "scipion-em-eman": ["single_particle"], + "scipion-em-resmap": ["single_particle"], + "scipion-em-eman2": ["single_particle"], + "scipion-em-appion": ["single_particle"], + "scipion-em-gautomatch": ["single_particle"], + "scipion-em-bamfordlab": ["single_particle"], + "scipion-em-atsas": ["single_particle"], + "scipion-em-imagic": ["single_particle"], + "scipion-em-bsoft": ["single_particle"], + "scipion-em-spider": ["single_particle"], + "scipion-em-localscale": ["single_particle"], + "scipion-em-atlas": ["single_particle"], + "scipion-em-isolde": ["single_particle"], + "scipion-em-grigoriefflab": ["single_particle"], + "scipion-em-simple": ["single_particle"], + + + + "scipion-em-reliontomo": ["tomography"], + "scipion-em-motioncorr": ["tomography", "single_particle"], + "scipion-em-tomo": ["tomography"], + "scipion-em-dynamo": ["tomography"], + "scipion-em-warp": ["tomography", "single_particle"], + "scipion-em-aretomo": ["tomography"], + "scipion-em-topaz": ["tomography"], + "scipion-em-kiharalab": ["tomography"], + "scipion-em-gapstop": ["tomography"], + "scipion-em-imod": ["tomography"], + "scipion-em-tomo3d": ["tomography"], + "scipion-em-nextpyp": ["tomography"], + "scipion-em-emantomo": ["tomography"], + "scipion-em-cryotiger": ["tomography"], + "scipion-em-cryodrgn": ["tomography"], + "scipion-em-crysieve": ["tomography"], + "scipion-em-membrain": ["tomography"], + "scipion-em-fidder": ["tomography"], + "scipion-em-markerfree": ["tomography"], + "scipion-em-tomosegmemtv": ["tomography"], + "scipion-em-tardis": ["tomography"], + "scipion-em-sphire": ["tomography", "single_particle"], + "scipion-em-novactf": ["tomography"], + "scipion-em-deepfinder": ["tomography"], + "scipion-em-cryocare": ["tomography"], + "scipion-em-miffi": ["tomography"], + "scipion-em-gctf": ["tomography", "single_particle"], + "scipion-em-arctic": ["tomography"], + "scipion-em-gmconvert": ["tomography"], + "scipion-em-deepdenwedge": ["tomography"], + "scipion-em-imodfit": ["tomography"], + "scipion-em-isonet": ["tomography"], + "scipion-em-emclarity": ["tomography"], + "scipion-em-rodmus": ["tomography"], + "scipion-em-cryoassess": ["tomography"], + "scipion-em-cryoef": ["tomography"], + "scipion-em-cistem": ["tomography", "single_particle"], + "scipion-em-spoc": ["tomography"], + "scipion-em-ais": ["tomography"], + "scipion-em-teamtomo": ["tomography"], + "scipion-em-repic": ["tomography"], + "scipion-em-tomotwin": ["tomography"], + "scipion-em-susantomo": ["tomography"], + "scipion-em-sidespitter": ["tomography"], + "scipion-em-deeppic": ["tomography"], + "scipion-em-tomoviz": ["tomography"], + "scipion-em-pyseg": ["tomography"], + "scipion-em-esrf": ["tomography"], + "scipion-em-artiax": ["tomography"], + "scipion-em-pickyolo": ["tomography"], + "scipion-em-gctffind": ["tomography"], + "scipion-em-blik": ["tomography"], + "scipion-em-resem": ["tomography"], + "scipion-em-clusteralign": ["tomography"], + "scipion-em-bsofttomo": ["tomography"], + "scipion-em-surfacemorphometrics": ["tomography"], + "scipion-em-scf": ["tomography"], + "scipion-em-epu": ["tomography"], + "scipion-em-tomoj": ["tomography"], + "scipion-em-aitom": ["tomography"], + + + + + "scipion-em-chimera": ["modelling", "single_particle"], + "scipion-em-phenix": ["modelling", "single_particle"], + "scipion-em-modelangelo": ["modelling", "single_particle"], + "scipion-em-ccp4": ["modelling", "single_particle"], + "scipion-em-atomstructutils": ["modelling", "single_particle"], + "scipion-em-carbonara": ["modelling", "single_particle"], + "scipion-em-esm": ["modelling"], + "scipion-em-bindcraft": ["modelling"], + + + + "scipion-em-hax": ["flexibility"], + "scipion-em-flexutils": ["flexibility"], + "scipion-em-continuousflex": ["flexibility"], + "scipion-em-mainmast": ["flexibility"], + "scipion-em-segger": ["flexibility"], + "scipion-em-pymol": ["single_particle"], + + + "scipion-em-mica": ["chem"], + "scipion-em-mapq": ["chem"], + "scipion-em-cryoten": ["chem"], + "scipion-em-emprot": ["chem"], + + "scipion-em-opusdsd": ["unclassified"], + "scipion-em-devtools": ["unclassified"], + "scipion-em-smartscope": ["unclassified"], + "scipion-em-goctf": ["unclassified"], + "scipion-em-empiar": ["unclassified"], + "scipion-em-facilities": ["unclassified"], + "scipion-em-datamanager": ["unclassified"], + "scipion-em-workflowhub": ["unclassified"], + "scipion-em-xmltools": ["unclassified"], + "scipion-em-powerfit": ["unclassified"], + "scipion-protein-docking": ["unclassified"], + "scipion-em-emxlib": ["unclassified"], + "scipion-em-bioinformatics": ["unclassified"], + "scipion-em-miplib": ["unclassified"], + "scipion-em-ccpem": ["unclassified"], + "scipion-em-ispyb": ["unclassified"], + "scipion-em-xmipptomo": ["unclassified"], +} + + +def normalizePluginName(pluginName: str) -> str: + return str(pluginName or "").strip().lower() + + +def getPluginCategoryIds(pluginName: str) -> List[str]: + normalizedName = normalizePluginName(pluginName) + categoryIds = plugins.get(normalizedName) + + if not categoryIds: + return ["unclassified"] + + validCategoryIds = [ + categoryId + for categoryId in categoryIds + if categoryId in categories + ] + + return validCategoryIds or ["unclassified"] + + +def getPluginCategoryData(pluginName: str) -> List[Dict[str, Any]]: + categoryIds = getPluginCategoryIds(pluginName) + + return [ + { + "id": categoryId, + "title": categories[categoryId]["title"], + "description": categories[categoryId].get("description", ""), + } + for categoryId in categoryIds + ] + + +def getPluginCategoriesCatalog() -> dict: + return categories \ No newline at end of file diff --git a/app/backend/utils/file_handlers.py b/app/backend/utils/file_handlers.py index 9ccffc6..c45bd0d 100644 --- a/app/backend/utils/file_handlers.py +++ b/app/backend/utils/file_handlers.py @@ -27,7 +27,10 @@ from __future__ import annotations import io import os +import json +import sqlite3 import mimetypes +from urllib.parse import quote from pathlib import Path as FsPath from typing import Union, Dict, Any, Optional @@ -318,7 +321,12 @@ def previewImageFileUnderRoot(self, root: Union[str, FsPath], path: str, inline: }, ) - def previewRemoteEntryUnderRoot(self, root: Union[str, FsPath], path: str) -> Response: + def previewRemoteEntryUnderRoot( + self, + root: Union[str, FsPath], + path: str, + databaseInspector=None, + ) -> Response: """ Return a unified lightweight preview for a file under an arbitrary safe root. """ @@ -427,6 +435,14 @@ def previewRemoteEntryUnderRoot(self, root: Union[str, FsPath], path: str) -> Re meta=meta, ) + if self._isSqliteLike(filePath): + return self._previewSqliteDatabase( + filePath=filePath, + mime=mime, + sizeBytes=sizeBytes, + databaseInspector=databaseInspector, + ) + maxBytes = 256 * 1024 try: with open(filePath, "rb") as f: @@ -584,9 +600,18 @@ def listProtocolDir(self, protocolId: str, path: str) -> list[Dict[str, Any]]: root = self._browserRootAbs(protocolId).resolve() return self.listRemoteDirectoryUnderRoot(root, path) - def previewProtocolRemoteEntry(self, protocolId: str, path: str) -> Response: + def previewProtocolRemoteEntry( + self, + protocolId: str, + path: str, + databaseInspector=None, + ) -> Response: root = self._browserRootAbs(protocolId).resolve() - return self.previewRemoteEntryUnderRoot(root, path) + return self.previewRemoteEntryUnderRoot( + root, + path, + databaseInspector=databaseInspector, + ) def _renderImageSpecAsPngAndMeta(self, imageSpec: str, backingPath: FsPath): """ @@ -696,6 +721,422 @@ def _isPreviewableMrc(self, filePath: FsPath) -> bool: suf = filePath.suffix.lower() return suf in IMAGES_FILE_EXTENSIONS + def _isSqliteLike(self, filePath: FsPath) -> bool: + """ + Return True if this file should be inspected as a SQLite database. + """ + suffix = filePath.suffix.lower() + if suffix in {".sqlite", ".sqlite3", ".db"}: + return True + + try: + with open(filePath, "rb") as handle: + return handle.read(16) == b"SQLite format 3\x00" + except Exception: + return False + + def _connectReadonlySqlite(self, filePath: FsPath) -> sqlite3.Connection: + """ + Open a SQLite database in read-only mode. + """ + resolvedPath = str(filePath.resolve()).replace("\\", "/") + sqliteUri = f"file:{quote(resolvedPath, safe='/:')}?mode=ro" + + conn = sqlite3.connect(sqliteUri, uri=True, timeout=2.0) + conn.row_factory = sqlite3.Row + return conn + + @staticmethod + def _quoteSqliteIdentifier(identifier: str) -> str: + """ + Quote a SQLite identifier safely. + """ + return '"' + str(identifier).replace('"', '""') + '"' + + @staticmethod + def _normalizeSqliteCell(value: Any) -> Any: + """ + Convert SQLite values into JSON-safe preview values. + """ + if value is None: + return None + + if isinstance(value, (int, float, str)): + if isinstance(value, str) and len(value) > 240: + return value[:240] + "..." + return value + + if isinstance(value, bytes): + return f"" + + return str(value) + + def _fetchSqlitePragmaValue( + self, + conn: sqlite3.Connection, + pragmaName: str, + defaultValue: Any = None, + ) -> Any: + """ + Fetch a single-value PRAGMA safely. + """ + try: + row = conn.execute(f"PRAGMA {pragmaName}").fetchone() + if row is None: + return defaultValue + if len(row.keys()) <= 0: + return defaultValue + return row[0] + except Exception: + return defaultValue + + def _fetchSqliteQuickCheck(self, conn: sqlite3.Connection) -> str: + """ + Run a lightweight integrity check. + """ + try: + row = conn.execute("PRAGMA quick_check(1)").fetchone() + if row is None: + return "unknown" + return str(row[0]) + except Exception: + return "unknown" + + def _inspectGenericSqliteDatabase( + self, + filePath: FsPath, + sizeBytes: Optional[int], + ) -> Dict[str, Any]: + """ + Inspect a SQLite database without depending on Scipion object readers. + """ + warnings: list[str] = [] + tables: list[Dict[str, Any]] = [] + sample: Optional[Dict[str, Any]] = None + + try: + conn = self._connectReadonlySqlite(filePath) + except Exception as exc: + return { + "engine": "sqlite", + "readable": False, + "isScipion": False, + "summary": [ + {"key": "Status", "value": "Could not open database"}, + {"key": "Error", "value": str(exc)}, + ], + "tables": [], + "sample": None, + "warnings": [str(exc)], + } + + try: + pageSize = self._fetchSqlitePragmaValue(conn, "page_size") + pageCount = self._fetchSqlitePragmaValue(conn, "page_count") + encoding = self._fetchSqlitePragmaValue(conn, "encoding") + userVersion = self._fetchSqlitePragmaValue(conn, "user_version") + applicationId = self._fetchSqlitePragmaValue(conn, "application_id") + quickCheck = self._fetchSqliteQuickCheck(conn) + + schemaRows = conn.execute( + """ + SELECT name, type + FROM sqlite_master + WHERE type IN ('table', 'view') + AND name NOT LIKE 'sqlite_%' + ORDER BY type, name + """ + ).fetchall() + + indexCountRow = conn.execute( + """ + SELECT COUNT(*) AS count + FROM sqlite_master + WHERE type = 'index' + AND name NOT LIKE 'sqlite_%' + """ + ).fetchone() + indexCount = int(indexCountRow["count"]) if indexCountRow is not None else 0 + + maxTables = 60 + if len(schemaRows) > maxTables: + warnings.append(f"Only the first {maxTables} tables/views are described.") + + for schemaRow in schemaRows[:maxTables]: + tableName = str(schemaRow["name"]) + tableType = str(schemaRow["type"]) + quotedName = self._quoteSqliteIdentifier(tableName) + + columnsInfo = [] + try: + pragmaRows = conn.execute(f"PRAGMA table_info({quotedName})").fetchall() + for pragmaRow in pragmaRows: + columnsInfo.append({ + "name": str(pragmaRow["name"]), + "type": str(pragmaRow["type"] or ""), + "notNull": bool(pragmaRow["notnull"]), + "primaryKey": bool(pragmaRow["pk"]), + }) + except Exception as exc: + warnings.append(f"Could not inspect columns for {tableName}: {exc}") + + rowCount: Optional[int] = None + if tableType == "table": + try: + countRow = conn.execute(f"SELECT COUNT(*) AS count FROM {quotedName}").fetchone() + rowCount = int(countRow["count"]) if countRow is not None else None + except Exception as exc: + warnings.append(f"Could not count rows for {tableName}: {exc}") + + tables.append({ + "name": tableName, + "type": tableType, + "rows": rowCount, + "columns": len(columnsInfo), + "columnPreview": columnsInfo[:12], + }) + + sampleTable = self._chooseSqliteSampleTable(tables) + if sampleTable: + sample = self._buildSqliteSample(conn, sampleTable) + + isScipionLike = self._looksLikeScipionSqlite(tables) + + summary = [ + {"key": "Status", "value": "Readable"}, + {"key": "Type", "value": "Scipion SQLite database" if isScipionLike else "SQLite database"}, + {"key": "Tables", "value": len([t for t in tables if t.get("type") == "table"])}, + {"key": "Views", "value": len([t for t in tables if t.get("type") == "view"])}, + {"key": "Indexes", "value": indexCount}, + ] + + if sizeBytes is not None: + summary.append({"key": "Size", "value": sizeBytes}) + if encoding: + summary.append({"key": "Encoding", "value": encoding}) + if pageSize: + summary.append({"key": "Page size", "value": pageSize}) + if pageCount: + summary.append({"key": "Page count", "value": pageCount}) + if userVersion is not None: + summary.append({"key": "User version", "value": userVersion}) + if applicationId is not None: + summary.append({"key": "Application id", "value": applicationId}) + if quickCheck: + summary.append({"key": "Quick check", "value": quickCheck}) + + return { + "engine": "sqlite", + "readable": True, + "isScipion": isScipionLike, + "objectClass": None, + "summary": summary, + "tables": tables, + "sample": sample, + "warnings": warnings, + } + + except Exception as exc: + return { + "engine": "sqlite", + "readable": False, + "isScipion": False, + "objectClass": None, + "objectCount": None, + "summary": [ + {"key": "Status", "value": "Could not inspect database"}, + {"key": "Error", "value": str(exc)}, + ], + "tables": [], + "sample": None, + "warnings": [str(exc)], + } + + finally: + try: + conn.close() + except Exception: + pass + + def _looksLikeScipionSqlite(self, tables: list[Dict[str, Any]]) -> bool: + """ + Best-effort schema heuristic for Scipion object databases. + """ + tableNames = {str(t.get("name") or "").lower() for t in tables} + + scipionHints = { + "objects", + "relations", + "properties", + "classes", + } + + return bool(tableNames.intersection(scipionHints)) + + def _chooseSqliteSampleTable(self, tables: list[Dict[str, Any]]) -> Optional[str]: + """ + Pick the most useful table for a compact row preview. + """ + if not tables: + return None + + preferredNames = ["Objects", "objects", "Properties", "properties"] + + for preferredName in preferredNames: + for table in tables: + if table.get("type") == "table" and table.get("name") == preferredName: + return str(table["name"]) + + regularTables = [t for t in tables if t.get("type") == "table"] + if not regularTables: + return None + + regularTables.sort( + key=lambda t: ( + int(t.get("rows") or 0), + str(t.get("name") or "").lower(), + ), + reverse=True, + ) + + return str(regularTables[0].get("name") or "") or None + + def _buildSqliteSample( + self, + conn: sqlite3.Connection, + tableName: str, + maxRows: int = 25, + maxColumns: int = 12, + ) -> Optional[Dict[str, Any]]: + """ + Build a compact preview of rows from one SQLite table. + """ + if not tableName: + return None + + quotedName = self._quoteSqliteIdentifier(tableName) + + try: + pragmaRows = conn.execute(f"PRAGMA table_info({quotedName})").fetchall() + columns = [str(row["name"]) for row in pragmaRows][:maxColumns] + except Exception: + columns = [] + + if not columns: + return None + + quotedColumns = ", ".join(self._quoteSqliteIdentifier(c) for c in columns) + + try: + rows = conn.execute( + f"SELECT {quotedColumns} FROM {quotedName} LIMIT ?", + (maxRows,), + ).fetchall() + except Exception: + return None + + outRows = [] + for row in rows: + outRows.append([ + self._normalizeSqliteCell(row[col]) + for col in columns + ]) + + return { + "table": tableName, + "columns": columns, + "rows": outRows, + "truncated": len(outRows) >= maxRows, + } + + def _tryInspectScipionSqliteDatabase(self, filePath: FsPath) -> Optional[Dict[str, Any]]: + """ + Hook for Scipion-specific object inspection. + + This is intentionally isolated so the generic SQLite preview remains + stable even if Scipion readers fail for a particular file. + """ + try: + # Scipion reader integration will be added here. + return None + except Exception: + return None + + def _previewSqliteDatabase( + self, + filePath: FsPath, + mime: str, + sizeBytes: Optional[int], + databaseInspector=None, + ) -> Response: + """ + Return a structured preview for SQLite databases. + """ + effectiveMime = mime + if not effectiveMime or effectiveMime == "application/octet-stream": + effectiveMime = "application/vnd.sqlite3" + + databaseInfo = self._inspectGenericSqliteDatabase( + filePath=filePath, + sizeBytes=sizeBytes, + ) + + scipionInfo = None + + if databaseInspector is not None: + try: + scipionInfo = databaseInspector(filePath) + except Exception as exc: + warnings = databaseInfo.setdefault("warnings", []) + warnings.append(f"Scipion reader failed: {exc}") + + if scipionInfo: + databaseInfo["isScipion"] = True + databaseInfo["scipion"] = scipionInfo + + objectClass = scipionInfo.get("objectClass") + if objectClass: + databaseInfo["objectClass"] = objectClass + + objectCount = scipionInfo.get("objectCount") + if objectCount is not None: + databaseInfo["objectCount"] = objectCount + + databaseType = "scipion" if databaseInfo.get("isScipion") else "sqlite" + + meta = { + "name": filePath.name, + "mime": effectiveMime, + "sizeBytes": sizeBytes, + "databaseType": databaseType, + "readable": bool(databaseInfo.get("readable")), + "tableCount": len(databaseInfo.get("tables") or []), + "objectClass": databaseInfo.get("objectClass"), + } + + payload = { + "kind": "database", + "mime": effectiveMime, + "meta": meta, + "database": databaseInfo, + } + + response = Response( + content=json.dumps(payload, ensure_ascii=False), + media_type="application/json", + headers={ + "Content-Disposition": f'inline; filename="{filePath.name}"', + }, + ) + + return self._attachPreviewContract( + response=response, + kind="database", + name=filePath.name, + meta=meta, + responseMime="application/json", + ) + # ------------------------- # Colormap helpers for volumes # ------------------------- diff --git a/requirements.txt b/requirements.txt index c81a7d3..23e904e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ scipion-pyworkflow scipion-em scipion-app scipion-em-tomo +pyxmipp3 fastapi==0.116.1 uvicorn[standard]==0.33.0 diff --git a/scripts/scipionapi b/scripts/scipionapi index 2cc4ec1..13fd8a4 100755 --- a/scripts/scipionapi +++ b/scripts/scipionapi @@ -23,6 +23,7 @@ pythonVersion="${SCIPIONAPI_PYTHON_VERSION:-3.8}" # pipRobustnessDefaults pipRetries="${SCIPIONAPI_PIP_RETRIES:-10}" pipTimeout="${SCIPIONAPI_PIP_TIMEOUT:-120}" +pipVerbose="${SCIPIONAPI_PIP_VERBOSE:-0}" # scipionCoreInstallToggle installScipionCore="${SCIPIONAPI_INSTALL_SCIPION_CORE:-1}" @@ -71,12 +72,19 @@ envExistsNow() { runPipInstall() { local pipArgs=("$@") + local verboseArgs=() + + if [[ "${pipVerbose}" == "1" ]]; then + verboseArgs=(-v) + fi + local cmd=( - conda run -n "${effectiveEnvName}" + conda run --no-capture-output -n "${effectiveEnvName}" python -m pip install --progress-bar on --retries "${pipRetries}" --timeout "${pipTimeout}" + "${verboseArgs[@]}" "${pipArgs[@]}" ) diff --git a/tests/integration/api/test_projects_router_protocol_ops.py b/tests/integration/api/test_projects_router_protocol_ops.py index bc8078c..0fc7bf8 100644 --- a/tests/integration/api/test_projects_router_protocol_ops.py +++ b/tests/integration/api/test_projects_router_protocol_ops.py @@ -26,6 +26,20 @@ from fastapi import HTTPException +def patchRenameProtocolFake(fakeProjectService): + # patchRenameProtocolFake + def renameProtocol(protocolId, newName, newComment=""): + fakeProjectService.lastRenameProtocolCall = { + "protocolId": protocolId, + "newName": newName, + "newComment": newComment, + } + if fakeProjectService.renameProtocolError is not None: + raise fakeProjectService.renameProtocolError + + fakeProjectService.renameProtocol = renameProtocol + + def test_LoadProtocolReturns404WhenProjectMissing(projectClient, fakeProjectService): fakeProjectService.projectByIdResult = None @@ -212,7 +226,7 @@ def test_SuggestionProtocolReturnsSuggestions(projectClient, fakeProjectService) def test_RenameProtocolRejectsBlankName(projectClient): response = projectClient.put( "/projects/1/protocols/10/rename", - json={"name": " "}, + json={"runName": " ", "comment": "Ignored comment"}, ) assert response.status_code == 422 @@ -224,9 +238,14 @@ def test_RenameProtocolRejectsBlankName(projectClient): def test_RenameProtocolDelegatesToService(projectClient, fakeProjectService): + patchRenameProtocolFake(fakeProjectService) + response = projectClient.put( "/projects/1/protocols/10/rename", - json={"name": " Renamed protocol "}, + json={ + "runName": " Renamed protocol ", + "comment": " Updated comment ", + }, ) assert response.status_code == 200 @@ -239,6 +258,7 @@ def test_RenameProtocolDelegatesToService(projectClient, fakeProjectService): assert fakeProjectService.lastRenameProtocolCall == { "protocolId": 10, "newName": "Renamed protocol", + "newComment": "Updated comment", } assert fakeProjectService.lastSyncProjectGraphAfterMutationCall == { @@ -413,7 +433,9 @@ def test_ResetProtocolFromDelegatesToService(projectClient, fakeProjectService): "checkPid": True, } + def test_RenameProtocolReturnsErrorWhenGraphSyncFails(projectClient, fakeProjectService): + patchRenameProtocolFake(fakeProjectService) fakeProjectService.syncProjectGraphAfterMutationError = HTTPException( status_code=500, detail="rename protocol succeeded but graph sync to PostgreSQL failed", @@ -421,7 +443,7 @@ def test_RenameProtocolReturnsErrorWhenGraphSyncFails(projectClient, fakeProject response = projectClient.put( "/projects/1/protocols/10/rename", - json={"name": "Renamed protocol"}, + json={"runName": "Renamed protocol", "comment": "Updated comment"}, ) assert response.status_code == 500 @@ -434,6 +456,7 @@ def test_RenameProtocolReturnsErrorWhenGraphSyncFails(projectClient, fakeProject assert fakeProjectService.lastRenameProtocolCall == { "protocolId": 10, "newName": "Renamed protocol", + "newComment": "Updated comment", } diff --git a/tests/unit/backend/api/services/test_project_service_logs_fs.py b/tests/unit/backend/api/services/test_project_service_logs_fs.py index f9a0f87..95f1d19 100644 --- a/tests/unit/backend/api/services/test_project_service_logs_fs.py +++ b/tests/unit/backend/api/services/test_project_service_logs_fs.py @@ -90,11 +90,11 @@ def previewProtocolTextFile(self, protocolId, path): self.calls.append(("previewProtocolTextFile", protocolId, path)) return {"mode": "protocol-text", "protocolId": protocolId, "path": path} - def previewRemoteEntryUnderRoot(self, root, path): + def previewRemoteEntryUnderRoot(self, root, path, databaseInspector=None): self.calls.append(("previewRemoteEntryUnderRoot", root, path)) return {"mode": "global-preview", "root": str(root), "path": path} - def previewProtocolRemoteEntry(self, protocolId, path): + def previewProtocolRemoteEntry(self, protocolId, path, databaseInspector=None): self.calls.append(("previewProtocolRemoteEntry", protocolId, path)) return {"mode": "protocol-preview", "protocolId": protocolId, "path": path} diff --git a/tests/unit/backend/api/services/test_project_service_protocols.py b/tests/unit/backend/api/services/test_project_service_protocols.py index 4e21fb8..638a5fe 100644 --- a/tests/unit/backend/api/services/test_project_service_protocols.py +++ b/tests/unit/backend/api/services/test_project_service_protocols.py @@ -104,6 +104,7 @@ def __init__(self, objId=None, className="ProtClass", useQueueFlag=False, valida self.gpuList = FakeValueHolder("") self.numberOfThreads = FakeValueHolder(1) self.runMode = FakeValueHolder(None) + self.runName = FakeValueHolder("") def addParam(self, name, param): self._params[name] = param @@ -312,6 +313,8 @@ def test_SaveProtocolCreatesNewProtocolAndPersistsContext(projectServiceModule, "projectId": projectId, "protocolId": protocol.getObjId(), "label": protocol._label, + "runName": protocol.runName.get(), + "comment": protocol._objComment.get(), }, ) @@ -348,7 +351,8 @@ def buildProtocol(): assert errors == [] assert protocol.getObjId() == 999 - assert protocol._label == "My protocol" + assert protocol._label is None + assert protocol.runName.get() == "My protocol" assert protocol.attributeValues["runName"] == "My protocol" assert protocol.attributeValues["iterations"] == 5 assert protocol._objComment.get() == "comment" @@ -357,7 +361,9 @@ def buildProtocol(): { "projectId": 1, "protocolId": 999, - "label": "My protocol", + "label": None, + "runName": "My protocol", + "comment": "comment", } ] @@ -509,14 +515,16 @@ def test_LaunchProtocolSchedulesProtocol(service, mapper, monkeypatch): assert service.currentProject.launchedProtocols == [] -def test_RenameProtocolStoresNewLabel(service): +def test_RenameProtocolStoresAnnotation(service): protocol = FakeProtocol(objId=10) service.currentProject.protocols[10] = protocol - result = service.renameProtocol(10, "Renamed protocol") + result = service.renameProtocol(10, "Renamed protocol", "Updated comment") assertSuccessEnvelope(result) - assert protocol._label == "Renamed protocol" + assert protocol._label is None + assert protocol.runName.get() == "Renamed protocol" + assert protocol._objComment == "Updated comment" assert service.currentProject.storedProtocols == [protocol] diff --git a/tests/unit/backend/utils/test_file_handlers.py b/tests/unit/backend/utils/test_file_handlers.py index 76dd887..04d9a2d 100644 --- a/tests/unit/backend/utils/test_file_handlers.py +++ b/tests/unit/backend/utils/test_file_handlers.py @@ -25,6 +25,8 @@ # ****************************************************************************** import importlib +import json +import sqlite3 from pathlib import Path import pytest @@ -221,4 +223,200 @@ def test_PreviewRemoteEntryUnderRootWrapsTextPreviewWithContract(handlers, tmp_p assert response.headers["X-Preview-Kind"] == "text" assert response.headers["X-Preview-Name"] == "notes.txt" assert response.headers["X-Preview-Mime"] == "text/plain" - assert response.headers["X-Preview-Schema"] == "scipion" \ No newline at end of file + assert response.headers["X-Preview-Schema"] == "scipion" + +def test_PreviewRemoteEntryUnderRootReturnsDatabasePreviewForSqlite(handlers, tmp_path): + root = tmp_path / "browser-root" + root.mkdir(parents=True, exist_ok=True) + + dbPath = root / "particles.sqlite" + + conn = sqlite3.connect(dbPath) + try: + conn.execute( + """ + CREATE TABLE Objects ( + id INTEGER PRIMARY KEY, + fileName TEXT, + enabled INTEGER + ) + """ + ) + conn.execute( + "INSERT INTO Objects (id, fileName, enabled) VALUES (?, ?, ?)", + (1, "particles.mrcs", 1), + ) + conn.commit() + finally: + conn.close() + + response = handlers.previewRemoteEntryUnderRoot(root, "particles.sqlite") + + assert response.media_type == "application/json" + assert response.headers["X-Preview-Kind"] == "database" + assert response.headers["X-Preview-Name"] == "particles.sqlite" + assert response.headers["X-Preview-Mime"] == "application/vnd.sqlite3" + assert response.headers["X-Preview-ResponseMime"] == "application/json" + assert response.headers["X-Preview-Schema"] == "scipion" + + payload = json.loads(response.body.decode("utf-8")) + + assert payload["kind"] == "database" + assert payload["mime"] == "application/vnd.sqlite3" + assert payload["meta"]["name"] == "particles.sqlite" + assert payload["meta"]["databaseType"] == "scipion" + assert payload["meta"]["readable"] is True + assert payload["meta"]["tableCount"] >= 1 + + database = payload["database"] + assert database["engine"] == "sqlite" + assert database["readable"] is True + assert database["isScipion"] is True + + tables = database["tables"] + assert any(table["name"] == "Objects" for table in tables) + + sample = database["sample"] + assert sample["table"] == "Objects" + assert sample["columns"] == ["id", "fileName", "enabled"] + assert sample["rows"][0] == [1, "particles.mrcs", 1] + + +def test_PreviewRemoteEntryUnderRootReturnsDatabasePreviewForDbExtension(handlers, tmp_path): + root = tmp_path / "browser-root" + root.mkdir(parents=True, exist_ok=True) + + dbPath = root / "cache.db" + + conn = sqlite3.connect(dbPath) + try: + conn.execute("CREATE TABLE CacheItems (id INTEGER PRIMARY KEY, name TEXT)") + conn.execute("INSERT INTO CacheItems (id, name) VALUES (?, ?)", (1, "first")) + conn.commit() + finally: + conn.close() + + response = handlers.previewRemoteEntryUnderRoot(root, "cache.db") + payload = json.loads(response.body.decode("utf-8")) + + assert response.headers["X-Preview-Kind"] == "database" + assert payload["kind"] == "database" + assert payload["database"]["readable"] is True + assert payload["database"]["isScipion"] is False + assert any(table["name"] == "CacheItems" for table in payload["database"]["tables"]) + + +def test_PreviewRemoteEntryUnderRootDetectsSqliteByHeaderWithoutKnownExtension(handlers, tmp_path): + root = tmp_path / "browser-root" + root.mkdir(parents=True, exist_ok=True) + + dbPath = root / "metadata.dat" + + conn = sqlite3.connect(dbPath) + try: + conn.execute("CREATE TABLE Metadata (id INTEGER PRIMARY KEY, label TEXT)") + conn.execute("INSERT INTO Metadata (id, label) VALUES (?, ?)", (1, "demo")) + conn.commit() + finally: + conn.close() + + response = handlers.previewRemoteEntryUnderRoot(root, "metadata.dat") + payload = json.loads(response.body.decode("utf-8")) + + assert response.headers["X-Preview-Kind"] == "database" + assert payload["kind"] == "database" + assert payload["database"]["readable"] is True + assert any(table["name"] == "Metadata" for table in payload["database"]["tables"]) + + +def test_PreviewRemoteEntryUnderRootDatabaseInspectorCanAddScipionInfo(handlers, tmp_path): + root = tmp_path / "browser-root" + root.mkdir(parents=True, exist_ok=True) + + dbPath = root / "particles.sqlite" + + conn = sqlite3.connect(dbPath) + try: + conn.execute("CREATE TABLE Objects (id INTEGER PRIMARY KEY)") + conn.commit() + finally: + conn.close() + + def fakeDatabaseInspector(filePath): + assert filePath.name == "particles.sqlite" + return { + "reader": "ObjectManager", + "objectClass": "SetOfParticles", + "objectCount": 42, + "summary": [ + {"key": "Object class", "value": "SetOfParticles"}, + {"key": "Items", "value": 42}, + ], + } + + response = handlers.previewRemoteEntryUnderRoot( + root, + "particles.sqlite", + databaseInspector=fakeDatabaseInspector, + ) + payload = json.loads(response.body.decode("utf-8")) + + assert payload["kind"] == "database" + assert payload["meta"]["databaseType"] == "scipion" + assert payload["meta"]["objectClass"] == "SetOfParticles" + + database = payload["database"] + assert database["isScipion"] is True + assert database["objectClass"] == "SetOfParticles" + assert database["objectCount"] == 42 + assert database["scipion"]["reader"] == "ObjectManager" + + +def test_PreviewRemoteEntryUnderRootDatabaseInspectorFailureKeepsGenericPreview(handlers, tmp_path): + root = tmp_path / "browser-root" + root.mkdir(parents=True, exist_ok=True) + + dbPath = root / "cache.sqlite" + + conn = sqlite3.connect(dbPath) + try: + conn.execute("CREATE TABLE CacheItems (id INTEGER PRIMARY KEY)") + conn.commit() + finally: + conn.close() + + def failingDatabaseInspector(filePath): + raise RuntimeError("reader exploded") + + response = handlers.previewRemoteEntryUnderRoot( + root, + "cache.sqlite", + databaseInspector=failingDatabaseInspector, + ) + payload = json.loads(response.body.decode("utf-8")) + + assert payload["kind"] == "database" + assert payload["database"]["readable"] is True + assert any( + "Scipion reader failed: reader exploded" in warning + for warning in payload["database"]["warnings"] + ) + + +def test_PreviewRemoteEntryUnderRootUnreadableSqliteReturnsDatabasePayload(handlers, tmp_path): + root = tmp_path / "browser-root" + root.mkdir(parents=True, exist_ok=True) + + brokenDb = root / "broken.sqlite" + brokenDb.write_bytes(b"not a sqlite database") + + response = handlers.previewRemoteEntryUnderRoot(root, "broken.sqlite") + payload = json.loads(response.body.decode("utf-8")) + + assert response.headers["X-Preview-Kind"] == "database" + assert payload["kind"] == "database" + assert payload["database"]["engine"] == "sqlite" + assert payload["database"]["readable"] is False + assert payload["meta"]["readable"] is False + assert payload["database"]["tables"] == [] + assert payload["database"]["warnings"] \ No newline at end of file