From 2fcdf51cfcbbe4f662da6630523fe7dc11a2e8e0 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:49:21 +0800 Subject: [PATCH] update: PythonEditor.tsx --- backend/src/backend/scripts.py | 195 +++++++++++++++++++-------------- 1 file changed, 112 insertions(+), 83 deletions(-) diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index 1064caf..a15b1ff 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -123,11 +123,36 @@ def _jupyter_path(script_type: str, script_id: str) -> str: (``/jupyter//notebooks/.ipynb``) is jupyter's URL route for the editor view, not a filesystem path — jupyter routes that URL to the file at the workspace root. + + NOTE: This function returns the flat basename only (``{script_id}.{ext}``). + It must NOT be used directly as a Jupyter path for scripts that live + inside sub-directories. Callers must combine it with the parent + directory ULID (for newly-created files) or derive the real path from + ``StorageObjects.object_key`` (for existing files). """ ext = ".ipynb" if script_type == "notebook" else ".py" return f"{script_id}{ext}" +def _derive_jupyter_path( + storage_object: StorageObjects | None, + workspace_id: str, + script_type: str, + script_id: str, +) -> str: + """Return the real Jupyter path for an existing script. + + For normal workspace files the path is taken from + ``StorageObjects.object_key`` with the workspace prefix removed. + For jupyter-only scripts that have no StorageObjects row, fall back + to the flat ``_jupyter_path()`` basename so existing behavior is + preserved. + """ + if storage_object is None or not storage_object.object_key: + return _jupyter_path(script_type, script_id) + return storage_object.object_key.removeprefix(f"{workspace_id}/") + + def validate_script_content(content: str, script_type: str) -> bytes: encoded = content.encode("utf-8") if len(encoded) > 10 * 1024 * 1024: @@ -156,9 +181,14 @@ def validate_script_content(content: str, script_type: str) -> bytes: def script_payload( script: Scripts, - storage_object: StorageObjects | dict[str, Any], + storage_object: StorageObjects | dict[str, Any] | None, ) -> dict[str, Any]: - if isinstance(storage_object, dict): + if storage_object is None: + relative_path = None + object_key = None + content_hash = None + size_bytes = 0 + elif isinstance(storage_object, dict): relative_path = storage_object.get("relative_path") object_key = storage_object.get("object_key") content_hash = storage_object.get("content_hash") @@ -169,11 +199,14 @@ def script_payload( content_hash = storage_object.content_hash size_bytes = storage_object.size_bytes workspace_prefix = f"{script.workspace_id}/" - jupyter_path = ( - object_key[len(workspace_prefix) :] - if object_key and object_key.startswith(workspace_prefix) - else object_key - ) + if object_key: + jupyter_path = ( + object_key[len(workspace_prefix) :] + if object_key.startswith(workspace_prefix) + else object_key + ) + else: + jupyter_path = _jupyter_path(script.script_type, script.script_id) return { "script_id": script.script_id, "workspace_id": script.workspace_id, @@ -219,7 +252,8 @@ async def get_script_row( session: AsyncSession, *, for_update: bool = False, -) -> tuple[Scripts, StorageObjects]: + allow_missing_storage_object: bool = False, +) -> tuple[Scripts, StorageObjects | None]: if for_update: script = await session.scalar( select(Scripts) @@ -239,25 +273,39 @@ async def get_script_row( StorageObjects, script.current_object_id, ) - if storage_object is None: + if storage_object is None and not allow_missing_storage_object: raise HTTPException( status.HTTP_409_CONFLICT, "script working-copy metadata is missing", ) row = (script, storage_object) else: - statement = ( - select(Scripts, StorageObjects) - .join( - StorageObjects, - StorageObjects.storage_object_id == Scripts.current_object_id, + if allow_missing_storage_object: + statement = ( + select(Scripts, StorageObjects) + .outerjoin( + StorageObjects, + StorageObjects.storage_object_id == Scripts.current_object_id, + ) + .where( + Scripts.script_id == script_id, + Scripts.workspace_id == context.workspace.workspace_id, + Scripts.status == "active", + ) ) - .where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", + else: + statement = ( + select(Scripts, StorageObjects) + .join( + StorageObjects, + StorageObjects.storage_object_id == Scripts.current_object_id, + ) + .where( + Scripts.script_id == script_id, + Scripts.workspace_id == context.workspace.workspace_id, + Scripts.status == "active", + ) ) - ) row = (await session.execute(statement)).one_or_none() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "script not found") @@ -971,24 +1019,16 @@ async def update_script( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - # Look up the Scripts row on its own: jupyter-only scripts do not - # have a StorageObjects row to JOIN against, and update is an - # in-place overwrite of the same jupyter path, so we do not need - # any object-store metadata. - script = await session.scalar( - select(Scripts) - .where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", - ) - .with_for_update() + # Load the working-copy StorageObject as well as the Scripts row. + # Jupyter-only scripts may not have a StorageObjects row; allow that + # case and fall back to the flat _jupyter_path() name. + script, storage_object = await get_script_row( + script_id, + context, + session, + for_update=True, + allow_missing_storage_object=True, ) - if script is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - "script not found", - ) require_script_modify_access( script, user_id=context.user.user_id, @@ -997,21 +1037,23 @@ async def update_script( content = validate_script_content(payload.content, script.script_type) runtime_client = request.app.state.runtime_client workspace_id = context.workspace.workspace_id - jupyter_name = _jupyter_path(script.script_type, script.script_id) + jupyter_path = _derive_jupyter_path( + storage_object, workspace_id, script.script_type, script.script_id + ) try: if script.script_type == "notebook": notebook = json.loads(content.decode("utf-8")) jupyter_resp = await runtime_client.create_notebook( workspace_id, - name=jupyter_name, + name=jupyter_path, cells=notebook.get("cells"), ) else: jupyter_resp = await runtime_client.upload_file( workspace_id, - name=jupyter_name, + name=jupyter_path, content=content.decode("utf-8"), - content_type=(mimetypes.guess_type(jupyter_name)[0] or "text/plain"), + content_type=(mimetypes.guess_type(jupyter_path)[0] or "text/plain"), ) except RuntimeClientError as exc: raise HTTPException( @@ -1022,8 +1064,8 @@ async def update_script( script.updated_at = datetime.now(UTC).replace(tzinfo=None) storage_data = { "storage_object_id": script.current_object_id, - "relative_path": jupyter_name, - "object_key": f"{workspace_id}/{jupyter_name}", + "relative_path": jupyter_path, + "object_key": f"{workspace_id}/{jupyter_path}", "content_hash": hashlib.sha256(content).hexdigest(), "size_bytes": len(content), } @@ -1083,23 +1125,16 @@ async def delete_script( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - # Scripts created via the jupyter path do not have a corresponding - # StorageObjects row, so we look up the Scripts row on its own and - # forward the delete to the workspace's live Jupyter instance. - script = await session.scalar( - select(Scripts) - .where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", - ) - .with_for_update() + # Load the working-copy StorageObject if it exists. Jupyter-only + # scripts may not have a StorageObjects row; fall back to the flat + # _jupyter_path() name in that case. + script, storage_object = await get_script_row( + script_id, + context, + session, + for_update=True, + allow_missing_storage_object=True, ) - if script is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - "script not found", - ) require_script_modify_access( script, user_id=context.user.user_id, @@ -1107,11 +1142,12 @@ async def delete_script( ) runtime_client = request.app.state.runtime_client + workspace_id = context.workspace.workspace_id + jupyter_path = _derive_jupyter_path( + storage_object, workspace_id, script.script_type, script.script_id + ) try: - await runtime_client.delete_file( - context.workspace.workspace_id, - name=_jupyter_path(script.script_type, script.script_id), - ) + await runtime_client.delete_file(workspace_id, name=jupyter_path) except RuntimeClientError as exc: raise HTTPException( status_code=exc.status_code, @@ -1142,25 +1178,16 @@ async def publish_version( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - # Jupyter-only scripts do not have a StorageObjects row, so the - # legacy get_script_row helper raises 409 before we even get here. - # Look up the Scripts row on its own — version publication is a - # metadata operation, we do not need the working-copy object - # metadata. - script = await session.scalar( - select(Scripts) - .where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", - ) - .with_for_update() + # Load the working-copy StorageObject if it exists. Jupyter-only + # scripts may not have a StorageObjects row; fall back to the flat + # _jupyter_path() name when reading from Jupyter. + script, storage_object = await get_script_row( + script_id, + context, + session, + for_update=True, + allow_missing_storage_object=True, ) - if script is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - "script not found", - ) require_script_modify_access( script, user_id=context.user.user_id, @@ -1180,9 +1207,11 @@ async def publish_version( # files come back as a UTF-8 string. runtime_client = request.app.state.runtime_client workspace_id = context.workspace.workspace_id - jupyter_name = _jupyter_path(script.script_type, script.script_id) + jupyter_path = _derive_jupyter_path( + storage_object, workspace_id, script.script_type, script.script_id + ) try: - contents = await runtime_client.get_file(workspace_id, name=jupyter_name) + contents = await runtime_client.get_file(workspace_id, name=jupyter_path) except RuntimeClientError as exc: raise HTTPException( status_code=exc.status_code, @@ -1241,7 +1270,7 @@ async def publish_version( artifact_object_id=artifact_data["storage_object_id"], version_no=version_no, version_label=f"v{version_no}.0", - source_path=jupyter_name, + source_path=jupyter_path, artifact_path=artifact_data["storage_uri"], content_hash=content_hash, file_size_bytes=len(content),