update: PythonEditor.tsx
This commit is contained in:
+112
-83
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user