update: PythonEditor.tsx

This commit is contained in:
tao.chen
2026-08-12 16:49:21 +08:00
parent 8d83adf1aa
commit 2fcdf51cfc
+112 -83
View File
@@ -123,11 +123,36 @@ def _jupyter_path(script_type: str, script_id: str) -> str:
(``/jupyter/<ws>/notebooks/<file>.ipynb``) is jupyter's URL route (``/jupyter/<ws>/notebooks/<file>.ipynb``) is jupyter's URL route
for the editor view, not a filesystem path — jupyter routes that for the editor view, not a filesystem path — jupyter routes that
URL to the file at the workspace root. 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" ext = ".ipynb" if script_type == "notebook" else ".py"
return f"{script_id}{ext}" 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: def validate_script_content(content: str, script_type: str) -> bytes:
encoded = content.encode("utf-8") encoded = content.encode("utf-8")
if len(encoded) > 10 * 1024 * 1024: if len(encoded) > 10 * 1024 * 1024:
@@ -156,9 +181,14 @@ def validate_script_content(content: str, script_type: str) -> bytes:
def script_payload( def script_payload(
script: Scripts, script: Scripts,
storage_object: StorageObjects | dict[str, Any], storage_object: StorageObjects | dict[str, Any] | None,
) -> dict[str, Any]: ) -> 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") relative_path = storage_object.get("relative_path")
object_key = storage_object.get("object_key") object_key = storage_object.get("object_key")
content_hash = storage_object.get("content_hash") content_hash = storage_object.get("content_hash")
@@ -169,11 +199,14 @@ def script_payload(
content_hash = storage_object.content_hash content_hash = storage_object.content_hash
size_bytes = storage_object.size_bytes size_bytes = storage_object.size_bytes
workspace_prefix = f"{script.workspace_id}/" workspace_prefix = f"{script.workspace_id}/"
jupyter_path = ( if object_key:
object_key[len(workspace_prefix) :] jupyter_path = (
if object_key and object_key.startswith(workspace_prefix) object_key[len(workspace_prefix) :]
else object_key if object_key.startswith(workspace_prefix)
) else object_key
)
else:
jupyter_path = _jupyter_path(script.script_type, script.script_id)
return { return {
"script_id": script.script_id, "script_id": script.script_id,
"workspace_id": script.workspace_id, "workspace_id": script.workspace_id,
@@ -219,7 +252,8 @@ async def get_script_row(
session: AsyncSession, session: AsyncSession,
*, *,
for_update: bool = False, for_update: bool = False,
) -> tuple[Scripts, StorageObjects]: allow_missing_storage_object: bool = False,
) -> tuple[Scripts, StorageObjects | None]:
if for_update: if for_update:
script = await session.scalar( script = await session.scalar(
select(Scripts) select(Scripts)
@@ -239,25 +273,39 @@ async def get_script_row(
StorageObjects, StorageObjects,
script.current_object_id, script.current_object_id,
) )
if storage_object is None: if storage_object is None and not allow_missing_storage_object:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"script working-copy metadata is missing", "script working-copy metadata is missing",
) )
row = (script, storage_object) row = (script, storage_object)
else: else:
statement = ( if allow_missing_storage_object:
select(Scripts, StorageObjects) statement = (
.join( select(Scripts, StorageObjects)
StorageObjects, .outerjoin(
StorageObjects.storage_object_id == Scripts.current_object_id, 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( else:
Scripts.script_id == script_id, statement = (
Scripts.workspace_id == context.workspace.workspace_id, select(Scripts, StorageObjects)
Scripts.status == "active", .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() row = (await session.execute(statement)).one_or_none()
if row is None: if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "script not found") raise HTTPException(status.HTTP_404_NOT_FOUND, "script not found")
@@ -971,24 +1019,16 @@ async def update_script(
context: RequestContext = Depends(request_context), context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
# Look up the Scripts row on its own: jupyter-only scripts do not # Load the working-copy StorageObject as well as the Scripts row.
# have a StorageObjects row to JOIN against, and update is an # Jupyter-only scripts may not have a StorageObjects row; allow that
# in-place overwrite of the same jupyter path, so we do not need # case and fall back to the flat _jupyter_path() name.
# any object-store metadata. script, storage_object = await get_script_row(
script = await session.scalar( script_id,
select(Scripts) context,
.where( session,
Scripts.script_id == script_id, for_update=True,
Scripts.workspace_id == context.workspace.workspace_id, allow_missing_storage_object=True,
Scripts.status == "active",
)
.with_for_update()
) )
if script is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"script not found",
)
require_script_modify_access( require_script_modify_access(
script, script,
user_id=context.user.user_id, user_id=context.user.user_id,
@@ -997,21 +1037,23 @@ async def update_script(
content = validate_script_content(payload.content, script.script_type) content = validate_script_content(payload.content, script.script_type)
runtime_client = request.app.state.runtime_client runtime_client = request.app.state.runtime_client
workspace_id = context.workspace.workspace_id 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: try:
if script.script_type == "notebook": if script.script_type == "notebook":
notebook = json.loads(content.decode("utf-8")) notebook = json.loads(content.decode("utf-8"))
jupyter_resp = await runtime_client.create_notebook( jupyter_resp = await runtime_client.create_notebook(
workspace_id, workspace_id,
name=jupyter_name, name=jupyter_path,
cells=notebook.get("cells"), cells=notebook.get("cells"),
) )
else: else:
jupyter_resp = await runtime_client.upload_file( jupyter_resp = await runtime_client.upload_file(
workspace_id, workspace_id,
name=jupyter_name, name=jupyter_path,
content=content.decode("utf-8"), 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: except RuntimeClientError as exc:
raise HTTPException( raise HTTPException(
@@ -1022,8 +1064,8 @@ async def update_script(
script.updated_at = datetime.now(UTC).replace(tzinfo=None) script.updated_at = datetime.now(UTC).replace(tzinfo=None)
storage_data = { storage_data = {
"storage_object_id": script.current_object_id, "storage_object_id": script.current_object_id,
"relative_path": jupyter_name, "relative_path": jupyter_path,
"object_key": f"{workspace_id}/{jupyter_name}", "object_key": f"{workspace_id}/{jupyter_path}",
"content_hash": hashlib.sha256(content).hexdigest(), "content_hash": hashlib.sha256(content).hexdigest(),
"size_bytes": len(content), "size_bytes": len(content),
} }
@@ -1083,23 +1125,16 @@ async def delete_script(
context: RequestContext = Depends(request_context), context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
# Scripts created via the jupyter path do not have a corresponding # Load the working-copy StorageObject if it exists. Jupyter-only
# StorageObjects row, so we look up the Scripts row on its own and # scripts may not have a StorageObjects row; fall back to the flat
# forward the delete to the workspace's live Jupyter instance. # _jupyter_path() name in that case.
script = await session.scalar( script, storage_object = await get_script_row(
select(Scripts) script_id,
.where( context,
Scripts.script_id == script_id, session,
Scripts.workspace_id == context.workspace.workspace_id, for_update=True,
Scripts.status == "active", allow_missing_storage_object=True,
)
.with_for_update()
) )
if script is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"script not found",
)
require_script_modify_access( require_script_modify_access(
script, script,
user_id=context.user.user_id, user_id=context.user.user_id,
@@ -1107,11 +1142,12 @@ async def delete_script(
) )
runtime_client = request.app.state.runtime_client 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: try:
await runtime_client.delete_file( await runtime_client.delete_file(workspace_id, name=jupyter_path)
context.workspace.workspace_id,
name=_jupyter_path(script.script_type, script.script_id),
)
except RuntimeClientError as exc: except RuntimeClientError as exc:
raise HTTPException( raise HTTPException(
status_code=exc.status_code, status_code=exc.status_code,
@@ -1142,25 +1178,16 @@ async def publish_version(
context: RequestContext = Depends(request_context), context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
# Jupyter-only scripts do not have a StorageObjects row, so the # Load the working-copy StorageObject if it exists. Jupyter-only
# legacy get_script_row helper raises 409 before we even get here. # scripts may not have a StorageObjects row; fall back to the flat
# Look up the Scripts row on its own — version publication is a # _jupyter_path() name when reading from Jupyter.
# metadata operation, we do not need the working-copy object script, storage_object = await get_script_row(
# metadata. script_id,
script = await session.scalar( context,
select(Scripts) session,
.where( for_update=True,
Scripts.script_id == script_id, allow_missing_storage_object=True,
Scripts.workspace_id == context.workspace.workspace_id,
Scripts.status == "active",
)
.with_for_update()
) )
if script is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"script not found",
)
require_script_modify_access( require_script_modify_access(
script, script,
user_id=context.user.user_id, user_id=context.user.user_id,
@@ -1180,9 +1207,11 @@ async def publish_version(
# files come back as a UTF-8 string. # files come back as a UTF-8 string.
runtime_client = request.app.state.runtime_client runtime_client = request.app.state.runtime_client
workspace_id = context.workspace.workspace_id 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: 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: except RuntimeClientError as exc:
raise HTTPException( raise HTTPException(
status_code=exc.status_code, status_code=exc.status_code,
@@ -1241,7 +1270,7 @@ async def publish_version(
artifact_object_id=artifact_data["storage_object_id"], artifact_object_id=artifact_data["storage_object_id"],
version_no=version_no, version_no=version_no,
version_label=f"v{version_no}.0", version_label=f"v{version_no}.0",
source_path=jupyter_name, source_path=jupyter_path,
artifact_path=artifact_data["storage_uri"], artifact_path=artifact_data["storage_uri"],
content_hash=content_hash, content_hash=content_hash,
file_size_bytes=len(content), file_size_bytes=len(content),