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
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),