refactor(backend): split into api/ schemas/ services/ clients/ layers
4-phase restructuring of the previously flat backend/ package. Each
phase lands as a single squash commit so future bisects stay readable
per phase if needed.
## Phase 1 — move + shim (location-only, zero behavior change)
* git mv 14 files into api/ schemas/ services/ clients/ subpackages
(history preserved via RM/R renames)
* New files: api/{admin,auth,dependencies,jupyter,platform,resources,
scripts,storage}.py + api/schedules/{schedules,runs}.py
* New files: schemas/{auth,common,jupyter,platform,resources,
schedules,scripts}.py
* New files: clients/{rclone,runtime,scheduler}.py
* Old paths kept as 1-line `from backend.<new> import *` shims so
tests/main.py/importers kept working untouched
* schemas/__init__.py now re-exports from backend.schemas.<domain>
## Phase 2 — APIRouter prefix consolidation
* Every APIRouter() now carries its prefix (e.g. prefix="/api/v1/auth")
and decorators are stripped of the redundant path prefix
* URL paths exposed to the frontend are byte-identical to before
* Affected: api/{auth,jupyter,admin,platform,resources,scripts,
storage}.py + api/schedules/{schedules,runs}.py
## Phase 3 — first service-layer extraction
* backend.services.schedules.validate_dag moved out of api/
(pure DAG validator, no Request/BackgroundTasks/DB)
* api/schedules/schedules.py now re-exports the symbol so existing
4 callsites keep working unchanged
* Added backend/tests/test_validate_dag.py: 8 unit tests covering
DAG_EMPTY, linear chain, diamond, cycle, self-edge, duplicate
edge, orphan edge, multi-root ordering
## Phase 4 — delete shims + unify test imports
* Removed 14 flat shim files + schemas/__init__.py
* Migrated 5 test files (32 import sites) to new paths:
backend.scripts.* → backend.api.scripts.*
backend.resources.* → backend.api.resources.*
backend.jupyter.* → backend.api.jupyter.*
backend.runtime_client.* → backend.clients.runtime.*
backend.schemas.UpdateScriptRequest → backend.schemas.scripts.*
* audit.py kept at backend.audit (main.py references it; not a
shim, real code)
## Final structure
backend/src/backend/
main.py, audit.py, __init__.py
api/ (10 files: routes + 2 subpackage)
schemas/ (7 files: Pydantic contracts)
services/ (storage + schedules)
clients/ (rclone, runtime, scheduler)
## Verification
* uv run python -m compileall backend/src backend/tests — clean
* uv run --package backend pytest backend/tests -q — 122 passed
(114 → 114 → 122 → 122 across phases)
* grep -r 'from backend\.\(scripts\|resources\|...\)' backend/ — 0 hits
* git blame --follow still traces file origins through the renames
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
from sqlalchemy import delete, func, or_, select
|
from sqlalchemy import delete, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.dependencies import (
|
from backend.api.dependencies import (
|
||||||
RequestContext,
|
RequestContext,
|
||||||
database_session,
|
database_session,
|
||||||
request_context,
|
request_context,
|
||||||
@@ -9,7 +9,7 @@ Cookie+JWT authentication endpoints.
|
|||||||
The user-facing flow is:
|
The user-facing flow is:
|
||||||
1. POST /api/v1/auth/login — verify password, set HttpOnly cookie
|
1. POST /api/v1/auth/login — verify password, set HttpOnly cookie
|
||||||
2. every other /api/ request reads the cookie via
|
2. every other /api/ request reads the cookie via
|
||||||
``backend.dependencies.request_context``
|
``backend.api.dependencies.request_context``
|
||||||
3. POST /api/v1/auth/logout — clear the cookie
|
3. POST /api/v1/auth/logout — clear the cookie
|
||||||
4. GET /api/v1/auth/me — return the current user
|
4. GET /api/v1/auth/me — return the current user
|
||||||
|
|
||||||
@@ -32,9 +32,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.dependencies import database_session, load_user_permissions
|
from backend.api.dependencies import database_session, load_user_permissions
|
||||||
|
|
||||||
router = APIRouter(tags=["auth"])
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
|
||||||
# Cookie 配置:生产环境走 HTTPS 时应设置 Secure;本地 HTTP 开发环境会根据
|
# Cookie 配置:生产环境走 HTTPS 时应设置 Secure;本地 HTTP 开发环境会根据
|
||||||
# 实际请求协议决定是否设置,避免浏览器因 Secure Cookie 而丢弃登录状态。
|
# 实际请求协议决定是否设置,避免浏览器因 Secure Cookie 而丢弃登录状态。
|
||||||
@@ -96,7 +96,7 @@ def _workspace_payload(
|
|||||||
|
|
||||||
|
|
||||||
# 校验账号密码,设置登录 Cookie,并返回用户可进入的工作区列表。
|
# 校验账号密码,设置登录 Cookie,并返回用户可进入的工作区列表。
|
||||||
@router.post("/api/v1/auth/login")
|
@router.post("/login")
|
||||||
async def login(
|
async def login(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
@@ -198,7 +198,7 @@ async def login(
|
|||||||
|
|
||||||
|
|
||||||
# 清除浏览器 Cookie,使当前会话立即失效。
|
# 清除浏览器 Cookie,使当前会话立即失效。
|
||||||
@router.post("/api/v1/auth/logout")
|
@router.post("/logout")
|
||||||
async def logout(response: Response) -> dict[str, Any]:
|
async def logout(response: Response) -> dict[str, Any]:
|
||||||
"""Clear the session cookie. Idempotent."""
|
"""Clear the session cookie. Idempotent."""
|
||||||
_clear_session_cookie(response)
|
_clear_session_cookie(response)
|
||||||
@@ -210,7 +210,7 @@ async def logout(response: Response) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
# 返回当前登录用户、权限和可访问工作区,用于前端初始化登录态。
|
# 返回当前登录用户、权限和可访问工作区,用于前端初始化登录态。
|
||||||
@router.get("/api/v1/auth/me")
|
@router.get("/me")
|
||||||
async def me(
|
async def me(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: AsyncSession = Depends(database_session),
|
session: AsyncSession = Depends(database_session),
|
||||||
@@ -19,8 +19,8 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.dependencies import database_session
|
from backend.api.dependencies import database_session
|
||||||
from backend.runtime_client import RuntimeClientError
|
from backend.clients.runtime import RuntimeClientError
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# (workspace_id, user_id) -> (expires_at_monotonic, payload) 的 5 秒验证结果缓存。
|
# (workspace_id, user_id) -> (expires_at_monotonic, payload) 的 5 秒验证结果缓存。
|
||||||
@@ -42,7 +42,7 @@ _JUPYTER_AUTH_CACHE_LOCK = threading.Lock()
|
|||||||
_JUPYTER_AUTH_CACHE_TTL_SECONDS = 5.0
|
_JUPYTER_AUTH_CACHE_TTL_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["jupyter"])
|
router = APIRouter(prefix="/api/v1/auth", tags=["jupyter"])
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ def _jupyter_auth_cache_put(workspace_id: str, user_id: str, payload: dict[str,
|
|||||||
|
|
||||||
# 供 Nginx auth_request 调用:验证访问 Jupyter 的身份、成员关系和文件锁,
|
# 供 Nginx auth_request 调用:验证访问 Jupyter 的身份、成员关系和文件锁,
|
||||||
# 再返回应转发到的 Jupyter 地址及内部令牌。
|
# 再返回应转发到的 Jupyter 地址及内部令牌。
|
||||||
@router.get("/api/v1/auth/jupyter")
|
@router.get("/jupyter")
|
||||||
async def verify_jupyter_access(
|
async def verify_jupyter_access(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
@@ -9,7 +9,7 @@ System-admin (platform-scope) endpoints for workspace & membership management.
|
|||||||
All routes under ``/api/v1/platform/*`` are gated by
|
All routes under ``/api/v1/platform/*`` are gated by
|
||||||
:func:`system_admin_context`, which requires the requester to hold a
|
:func:`system_admin_context`, which requires the requester to hold a
|
||||||
``Users.platform_role_id`` pointing to a ``Roles`` row whose
|
``Users.platform_role_id`` pointing to a ``Roles`` row whose
|
||||||
``role_code == 'admin'``. Unlike ``backend.dependencies.request_context``,
|
``role_code == 'admin'``. Unlike ``backend.api.dependencies.request_context``,
|
||||||
this dependency does NOT require an active workspace membership — system
|
this dependency does NOT require an active workspace membership — system
|
||||||
admins can manage workspaces before/without being a member of any.
|
admins can manage workspaces before/without being a member of any.
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
from sqlalchemy import func, insert, or_, select, update
|
from sqlalchemy import func, insert, or_, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.dependencies import current_user, database_session
|
from backend.api.dependencies import current_user, database_session
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
|
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ async def system_admin_context(
|
|||||||
"""Resolve the requester as a system admin.
|
"""Resolve the requester as a system admin.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Reuse :func:`backend.dependencies.current_user` to validate the JWT
|
1. Reuse :func:`backend.api.dependencies.current_user` to validate the JWT
|
||||||
cookie and fetch the active ``Users`` row (raises 401 on failure).
|
cookie and fetch the active ``Users`` row (raises 401 on failure).
|
||||||
2. Require ``Users.platform_role_id`` to point to a row whose
|
2. Require ``Users.platform_role_id`` to point to a row whose
|
||||||
``role_code == 'admin'`` — anything else is 403.
|
``role_code == 'admin'`` — anything else is 403.
|
||||||
@@ -23,16 +23,16 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, s
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.dependencies import (
|
from backend.api.dependencies import (
|
||||||
RequestContext,
|
RequestContext,
|
||||||
database_session,
|
database_session,
|
||||||
request_context,
|
request_context,
|
||||||
)
|
)
|
||||||
from backend.scripts import _escape_like_pattern, normalize_user_path
|
from backend.api.scripts import _escape_like_pattern, normalize_user_path
|
||||||
from backend.schemas import (
|
from backend.schemas.common import DownloadUrlRequest
|
||||||
|
from backend.schemas.resources import (
|
||||||
CompleteResourceUploadRequest,
|
CompleteResourceUploadRequest,
|
||||||
CreateResourceUploadRequest,
|
CreateResourceUploadRequest,
|
||||||
DownloadUrlRequest,
|
|
||||||
ResourceRelativePathRequest,
|
ResourceRelativePathRequest,
|
||||||
)
|
)
|
||||||
from backend.services.storage import (
|
from backend.services.storage import (
|
||||||
@@ -41,13 +41,13 @@ from pydantic import Field
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.dependencies import (
|
from backend.api.dependencies import (
|
||||||
RequestContext,
|
RequestContext,
|
||||||
database_session,
|
database_session,
|
||||||
request_context,
|
request_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["schedule-runs"])
|
router = APIRouter(prefix="/api/v1", tags=["schedule-runs"])
|
||||||
RunStatus = Literal[
|
RunStatus = Literal[
|
||||||
"queued",
|
"queued",
|
||||||
"running",
|
"running",
|
||||||
@@ -238,7 +238,7 @@ async def _artifact_bytes(
|
|||||||
|
|
||||||
# 立即触发一次调度:写入运行记录和 Outbox,由 schedule 容器异步接手执行。
|
# 立即触发一次调度:写入运行记录和 Outbox,由 schedule 容器异步接手执行。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/schedules/{schedule_id}/run",
|
"/schedules/{schedule_id}/run",
|
||||||
status_code=status.HTTP_202_ACCEPTED,
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
)
|
)
|
||||||
async def run_schedule_now(
|
async def run_schedule_now(
|
||||||
@@ -288,7 +288,7 @@ async def run_schedule_now(
|
|||||||
|
|
||||||
|
|
||||||
# 按调度或状态筛选运行历史,供前端运行记录列表展示。
|
# 按调度或状态筛选运行历史,供前端运行记录列表展示。
|
||||||
@router.get("/api/v1/schedule-runs")
|
@router.get("/schedule-runs")
|
||||||
async def list_schedule_runs(
|
async def list_schedule_runs(
|
||||||
schedule_id: str | None = Query(default=None),
|
schedule_id: str | None = Query(default=None),
|
||||||
run_status: RunStatus | None = Query(default=None, alias="status"),
|
run_status: RunStatus | None = Query(default=None, alias="status"),
|
||||||
@@ -318,7 +318,7 @@ async def list_schedule_runs(
|
|||||||
|
|
||||||
|
|
||||||
# 查询一次运行的详情,包括每个节点的执行状态。
|
# 查询一次运行的详情,包括每个节点的执行状态。
|
||||||
@router.get("/api/v1/schedule-runs/{run_id}")
|
@router.get("/schedule-runs/{run_id}")
|
||||||
async def get_schedule_run(
|
async def get_schedule_run(
|
||||||
run_id: str,
|
run_id: str,
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -334,7 +334,7 @@ async def get_schedule_run(
|
|||||||
|
|
||||||
# 返回某个节点运行关联的日志/结果产物元数据及可访问地址。
|
# 返回某个节点运行关联的日志/结果产物元数据及可访问地址。
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/artifacts"
|
"/schedule-runs/{run_id}/node-runs/{node_run_id}/artifacts"
|
||||||
)
|
)
|
||||||
async def get_schedule_node_run_artifacts(
|
async def get_schedule_node_run_artifacts(
|
||||||
run_id: str,
|
run_id: str,
|
||||||
@@ -355,7 +355,7 @@ async def get_schedule_node_run_artifacts(
|
|||||||
context=context,
|
context=context,
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
base_path = f"/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}"
|
base_path = f"/schedule-runs/{run_id}/node-runs/{node_run_id}"
|
||||||
workspace_query = f"workspace_id={context.workspace.workspace_id}"
|
workspace_query = f"workspace_id={context.workspace.workspace_id}"
|
||||||
return {
|
return {
|
||||||
"request_id": context.request_id,
|
"request_id": context.request_id,
|
||||||
@@ -377,7 +377,7 @@ async def get_schedule_node_run_artifacts(
|
|||||||
|
|
||||||
# 读取节点运行日志正文,通常由前端日志面板按需调用。
|
# 读取节点运行日志正文,通常由前端日志面板按需调用。
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/logs"
|
"/schedule-runs/{run_id}/node-runs/{node_run_id}/logs"
|
||||||
)
|
)
|
||||||
async def read_schedule_node_run_logs(
|
async def read_schedule_node_run_logs(
|
||||||
run_id: str,
|
run_id: str,
|
||||||
@@ -404,7 +404,7 @@ async def read_schedule_node_run_logs(
|
|||||||
|
|
||||||
# 为节点运行结果生成下载响应或重定向地址。
|
# 为节点运行结果生成下载响应或重定向地址。
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/result"
|
"/schedule-runs/{run_id}/node-runs/{node_run_id}/result"
|
||||||
)
|
)
|
||||||
async def download_schedule_node_run_result(
|
async def download_schedule_node_run_result(
|
||||||
run_id: str,
|
run_id: str,
|
||||||
+23
-131
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import heapq
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -29,12 +28,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|||||||
from sqlalchemy import delete, func, or_, select
|
from sqlalchemy import delete, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.dependencies import (
|
from backend.api.dependencies import (
|
||||||
RequestContext,
|
RequestContext,
|
||||||
database_session,
|
database_session,
|
||||||
request_context,
|
request_context,
|
||||||
)
|
)
|
||||||
from backend.schedule_schemas import (
|
from backend.schemas.schedules import (
|
||||||
CreateScheduleEdgeRequest,
|
CreateScheduleEdgeRequest,
|
||||||
CreateScheduleNodeRequest,
|
CreateScheduleNodeRequest,
|
||||||
CreateScheduleRequest,
|
CreateScheduleRequest,
|
||||||
@@ -47,7 +46,7 @@ from backend.schedule_schemas import (
|
|||||||
)
|
)
|
||||||
from backend.services.storage import soft_delete_object
|
from backend.services.storage import soft_delete_object
|
||||||
|
|
||||||
router = APIRouter(tags=["schedules"])
|
router = APIRouter(prefix="/api/v1", tags=["schedules"])
|
||||||
|
|
||||||
_ACTIVE_RUN_STATUSES = ("queued", "running")
|
_ACTIVE_RUN_STATUSES = ("queued", "running")
|
||||||
|
|
||||||
@@ -239,118 +238,11 @@ def edge_payload(item: ScheduleEdges) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_dag(
|
# validate_dag is implemented in backend.services.schedules so it can be
|
||||||
nodes: list[ScheduleNodes],
|
# unit-tested without spinning up FastAPI. Re-exported here for the four
|
||||||
edges: list[ScheduleEdges],
|
# internal callsites and for any external callers that still import it
|
||||||
) -> dict[str, Any]:
|
# from this module.
|
||||||
node_by_id = {item.node_id: item for item in nodes}
|
from backend.services.schedules import validate_dag # noqa: F401
|
||||||
indegree = {item.node_id: 0 for item in nodes}
|
|
||||||
outgoing: dict[str, set[str]] = {
|
|
||||||
item.node_id: set()
|
|
||||||
for item in nodes
|
|
||||||
}
|
|
||||||
errors: list[dict[str, Any]] = []
|
|
||||||
seen_edges: set[tuple[str, str]] = set()
|
|
||||||
|
|
||||||
if not nodes:
|
|
||||||
errors.append(
|
|
||||||
{
|
|
||||||
"code": "DAG_EMPTY",
|
|
||||||
"message": "schedule must contain at least one node",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
for edge in edges:
|
|
||||||
if (
|
|
||||||
edge.source_node_id not in node_by_id
|
|
||||||
or edge.target_node_id not in node_by_id
|
|
||||||
):
|
|
||||||
errors.append(
|
|
||||||
{
|
|
||||||
"code": "DAG_EDGE_NODE_MISSING",
|
|
||||||
"message": "edge references a node outside the schedule",
|
|
||||||
"edge_id": edge.edge_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
pair = (edge.source_node_id, edge.target_node_id)
|
|
||||||
if edge.source_node_id == edge.target_node_id:
|
|
||||||
errors.append(
|
|
||||||
{
|
|
||||||
"code": "DAG_SELF_EDGE",
|
|
||||||
"message": "a node cannot depend on itself",
|
|
||||||
"edge_id": edge.edge_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if pair in seen_edges:
|
|
||||||
errors.append(
|
|
||||||
{
|
|
||||||
"code": "DAG_DUPLICATE_EDGE",
|
|
||||||
"message": "duplicate directed edge",
|
|
||||||
"edge_id": edge.edge_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
seen_edges.add(pair)
|
|
||||||
outgoing[edge.source_node_id].add(edge.target_node_id)
|
|
||||||
indegree[edge.target_node_id] += 1
|
|
||||||
|
|
||||||
root_ids = sorted(
|
|
||||||
(node_id for node_id, degree in indegree.items() if degree == 0),
|
|
||||||
key=lambda node_id: node_by_id[node_id].node_key,
|
|
||||||
)
|
|
||||||
leaf_ids = sorted(
|
|
||||||
(node_id for node_id, targets in outgoing.items() if not targets),
|
|
||||||
key=lambda node_id: node_by_id[node_id].node_key,
|
|
||||||
)
|
|
||||||
queue = [
|
|
||||||
(node_by_id[node_id].node_key, node_id)
|
|
||||||
for node_id in root_ids
|
|
||||||
]
|
|
||||||
heapq.heapify(queue)
|
|
||||||
remaining_indegree = dict(indegree)
|
|
||||||
ordered_ids: list[str] = []
|
|
||||||
while queue:
|
|
||||||
_, node_id = heapq.heappop(queue)
|
|
||||||
ordered_ids.append(node_id)
|
|
||||||
for target_id in sorted(
|
|
||||||
outgoing[node_id],
|
|
||||||
key=lambda value: node_by_id[value].node_key,
|
|
||||||
):
|
|
||||||
remaining_indegree[target_id] -= 1
|
|
||||||
if remaining_indegree[target_id] == 0:
|
|
||||||
heapq.heappush(
|
|
||||||
queue,
|
|
||||||
(node_by_id[target_id].node_key, target_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(ordered_ids) != len(nodes):
|
|
||||||
cycle_node_ids = sorted(
|
|
||||||
(
|
|
||||||
node_id
|
|
||||||
for node_id, degree in remaining_indegree.items()
|
|
||||||
if degree > 0
|
|
||||||
),
|
|
||||||
key=lambda node_id: node_by_id[node_id].node_key,
|
|
||||||
)
|
|
||||||
errors.append(
|
|
||||||
{
|
|
||||||
"code": "DAG_CYCLE",
|
|
||||||
"message": "schedule graph contains a directed cycle",
|
|
||||||
"node_ids": cycle_node_ids,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"valid": not errors,
|
|
||||||
"node_count": len(nodes),
|
|
||||||
"edge_count": len(edges),
|
|
||||||
"root_node_ids": root_ids,
|
|
||||||
"leaf_node_ids": leaf_ids,
|
|
||||||
"topological_order": ordered_ids,
|
|
||||||
"errors": errors,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def schedule_row(
|
async def schedule_row(
|
||||||
@@ -521,7 +413,7 @@ async def _require_valid_when_enabled(
|
|||||||
|
|
||||||
|
|
||||||
# 根据 Cron 表达式预览未来触发时间,不会保存或执行任务。
|
# 根据 Cron 表达式预览未来触发时间,不会保存或执行任务。
|
||||||
@router.post("/api/v1/cron/preview")
|
@router.post("/cron/preview")
|
||||||
async def preview_cron(
|
async def preview_cron(
|
||||||
payload: CronPreviewRequest,
|
payload: CronPreviewRequest,
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -539,7 +431,7 @@ async def preview_cron(
|
|||||||
|
|
||||||
|
|
||||||
# 列出调度产生的可展示版本/产物,供前端结果面板使用。
|
# 列出调度产生的可展示版本/产物,供前端结果面板使用。
|
||||||
@router.get("/api/v1/schedule-artifacts")
|
@router.get("/schedule-artifacts")
|
||||||
async def list_schedule_artifacts(
|
async def list_schedule_artifacts(
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -589,7 +481,7 @@ async def list_schedule_artifacts(
|
|||||||
|
|
||||||
|
|
||||||
# 列出当前工作区的调度定义及其节点、边数量等摘要信息。
|
# 列出当前工作区的调度定义及其节点、边数量等摘要信息。
|
||||||
@router.get("/api/v1/schedules")
|
@router.get("/schedules")
|
||||||
async def list_schedules(
|
async def list_schedules(
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
session: AsyncSession = Depends(database_session),
|
session: AsyncSession = Depends(database_session),
|
||||||
@@ -652,7 +544,7 @@ async def list_schedules(
|
|||||||
|
|
||||||
# 创建新的 DAG 调度定义;初始状态不包含节点和边。
|
# 创建新的 DAG 调度定义;初始状态不包含节点和边。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/schedules",
|
"/schedules",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def create_schedule(
|
async def create_schedule(
|
||||||
@@ -704,7 +596,7 @@ async def create_schedule(
|
|||||||
|
|
||||||
|
|
||||||
# 获取一个调度的完整画布数据,包括节点、边和当前工作流版本。
|
# 获取一个调度的完整画布数据,包括节点、边和当前工作流版本。
|
||||||
@router.get("/api/v1/schedules/{schedule_id}")
|
@router.get("/schedules/{schedule_id}")
|
||||||
async def get_schedule(
|
async def get_schedule(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -719,8 +611,8 @@ async def get_schedule(
|
|||||||
|
|
||||||
|
|
||||||
# 更新调度基本属性,如名称、Cron、时区、是否启用和并发策略。
|
# 更新调度基本属性,如名称、Cron、时区、是否启用和并发策略。
|
||||||
@router.put("/api/v1/schedules/{schedule_id}")
|
@router.put("/schedules/{schedule_id}")
|
||||||
@router.patch("/api/v1/schedules/{schedule_id}")
|
@router.patch("/schedules/{schedule_id}")
|
||||||
async def update_schedule(
|
async def update_schedule(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
payload: UpdateScheduleRequest,
|
payload: UpdateScheduleRequest,
|
||||||
@@ -784,7 +676,7 @@ async def update_schedule(
|
|||||||
|
|
||||||
|
|
||||||
# 删除调度定义;请求携带 workflow_version 以避免误删他人刚修改的画布。
|
# 删除调度定义;请求携带 workflow_version 以避免误删他人刚修改的画布。
|
||||||
@router.delete("/api/v1/schedules/{schedule_id}")
|
@router.delete("/schedules/{schedule_id}")
|
||||||
async def delete_schedule(
|
async def delete_schedule(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
payload: WorkflowVersionRequest,
|
payload: WorkflowVersionRequest,
|
||||||
@@ -876,7 +768,7 @@ async def delete_schedule(
|
|||||||
|
|
||||||
# 向调度画布新增一个执行节点,并关联已发布的脚本版本。
|
# 向调度画布新增一个执行节点,并关联已发布的脚本版本。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/schedules/{schedule_id}/nodes",
|
"/schedules/{schedule_id}/nodes",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def create_schedule_node(
|
async def create_schedule_node(
|
||||||
@@ -931,7 +823,7 @@ async def create_schedule_node(
|
|||||||
|
|
||||||
|
|
||||||
# 更新节点名称、执行参数、超时、重试和画布坐标等配置。
|
# 更新节点名称、执行参数、超时、重试和画布坐标等配置。
|
||||||
@router.put("/api/v1/schedules/{schedule_id}/nodes/{node_id}")
|
@router.put("/schedules/{schedule_id}/nodes/{node_id}")
|
||||||
async def update_schedule_node(
|
async def update_schedule_node(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
@@ -987,7 +879,7 @@ async def update_schedule_node(
|
|||||||
|
|
||||||
|
|
||||||
# 从调度画布删除节点,并同步清理关联边。
|
# 从调度画布删除节点,并同步清理关联边。
|
||||||
@router.delete("/api/v1/schedules/{schedule_id}/nodes/{node_id}")
|
@router.delete("/schedules/{schedule_id}/nodes/{node_id}")
|
||||||
async def delete_schedule_node(
|
async def delete_schedule_node(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
@@ -1072,7 +964,7 @@ async def delete_schedule_node(
|
|||||||
|
|
||||||
# 在两个节点之间新增依赖边,表示目标节点必须等待源节点完成。
|
# 在两个节点之间新增依赖边,表示目标节点必须等待源节点完成。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/schedules/{schedule_id}/edges",
|
"/schedules/{schedule_id}/edges",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def create_schedule_edge(
|
async def create_schedule_edge(
|
||||||
@@ -1148,7 +1040,7 @@ async def create_schedule_edge(
|
|||||||
|
|
||||||
|
|
||||||
# 修改一条依赖边的条件表达式或其他可编辑字段。
|
# 修改一条依赖边的条件表达式或其他可编辑字段。
|
||||||
@router.put("/api/v1/schedules/{schedule_id}/edges/{edge_id}")
|
@router.put("/schedules/{schedule_id}/edges/{edge_id}")
|
||||||
async def update_schedule_edge(
|
async def update_schedule_edge(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
edge_id: str,
|
edge_id: str,
|
||||||
@@ -1183,7 +1075,7 @@ async def update_schedule_edge(
|
|||||||
|
|
||||||
|
|
||||||
# 删除节点之间的依赖关系,不会删除节点本身。
|
# 删除节点之间的依赖关系,不会删除节点本身。
|
||||||
@router.delete("/api/v1/schedules/{schedule_id}/edges/{edge_id}")
|
@router.delete("/schedules/{schedule_id}/edges/{edge_id}")
|
||||||
async def delete_schedule_edge(
|
async def delete_schedule_edge(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
edge_id: str,
|
edge_id: str,
|
||||||
@@ -1218,7 +1110,7 @@ async def delete_schedule_edge(
|
|||||||
|
|
||||||
|
|
||||||
# 校验画布是否为可执行 DAG,例如是否存在环、孤立节点或无效版本。
|
# 校验画布是否为可执行 DAG,例如是否存在环、孤立节点或无效版本。
|
||||||
@router.post("/api/v1/schedules/{schedule_id}/validate")
|
@router.post("/schedules/{schedule_id}/validate")
|
||||||
async def validate_schedule(
|
async def validate_schedule(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -39,16 +39,16 @@ from loguru import logger
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.dependencies import (
|
from backend.api.dependencies import (
|
||||||
RequestContext,
|
RequestContext,
|
||||||
database_session,
|
database_session,
|
||||||
request_context,
|
request_context,
|
||||||
)
|
)
|
||||||
from backend.runtime_client import RuntimeClientError
|
from backend.clients.runtime import RuntimeClientError
|
||||||
from backend.schemas import (
|
from backend.schemas.common import DownloadUrlRequest
|
||||||
|
from backend.schemas.scripts import (
|
||||||
CreateScriptRequest,
|
CreateScriptRequest,
|
||||||
CreateWorkspaceDirectoryRequest,
|
CreateWorkspaceDirectoryRequest,
|
||||||
DownloadUrlRequest,
|
|
||||||
LockScriptRequest,
|
LockScriptRequest,
|
||||||
PublishVersionRequest,
|
PublishVersionRequest,
|
||||||
UpdateScriptRequest,
|
UpdateScriptRequest,
|
||||||
@@ -60,7 +60,7 @@ from backend.services.storage import (
|
|||||||
soft_delete_object,
|
soft_delete_object,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["scripts"])
|
router = APIRouter(prefix="/api/v1", tags=["scripts"])
|
||||||
|
|
||||||
|
|
||||||
def normalize_user_path(value: str, *, allow_empty: bool = True) -> str:
|
def normalize_user_path(value: str, *, allow_empty: bool = True) -> str:
|
||||||
@@ -640,7 +640,7 @@ async def create_script_record(
|
|||||||
|
|
||||||
|
|
||||||
# 新建空的 Python 脚本或 Notebook:同时创建数据库元数据和初始文件内容。
|
# 新建空的 Python 脚本或 Notebook:同时创建数据库元数据和初始文件内容。
|
||||||
@router.post("/api/v1/scripts", status_code=status.HTTP_201_CREATED)
|
@router.post("/scripts", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_script(
|
async def create_script(
|
||||||
payload: CreateScriptRequest,
|
payload: CreateScriptRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -674,7 +674,7 @@ async def create_script(
|
|||||||
|
|
||||||
# 上传现有脚本文件:校验文件名/类型后写入存储,并建立 Scripts 记录。
|
# 上传现有脚本文件:校验文件名/类型后写入存储,并建立 Scripts 记录。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/scripts/upload",
|
"/scripts/upload",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def upload_script(
|
async def upload_script(
|
||||||
@@ -734,7 +734,7 @@ async def upload_script(
|
|||||||
|
|
||||||
|
|
||||||
# 返回旧版一次性完整目录树,保留给兼容旧前端;新页面通常按目录懒加载。
|
# 返回旧版一次性完整目录树,保留给兼容旧前端;新页面通常按目录懒加载。
|
||||||
@router.get("/api/v1/workspace-tree")
|
@router.get("/workspace-tree")
|
||||||
async def get_workspace_tree(
|
async def get_workspace_tree(
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
session: AsyncSession = Depends(database_session),
|
session: AsyncSession = Depends(database_session),
|
||||||
@@ -806,7 +806,7 @@ async def get_workspace_tree(
|
|||||||
|
|
||||||
|
|
||||||
# 查询某个目录下的直接子目录,供前端按需展开工作区树。
|
# 查询某个目录下的直接子目录,供前端按需展开工作区树。
|
||||||
@router.get("/api/v1/workspace-directories")
|
@router.get("/workspace-directories")
|
||||||
async def list_workspace_directories(
|
async def list_workspace_directories(
|
||||||
parent_path: str = Query(default=""),
|
parent_path: str = Query(default=""),
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -881,7 +881,7 @@ async def list_workspace_directories(
|
|||||||
|
|
||||||
# 在工作区内创建逻辑目录;目录信息由脚本相对路径推导,不对应容器本地文件夹。
|
# 在工作区内创建逻辑目录;目录信息由脚本相对路径推导,不对应容器本地文件夹。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/workspace-directories",
|
"/workspace-directories",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def create_workspace_directory(
|
async def create_workspace_directory(
|
||||||
@@ -1037,7 +1037,7 @@ async def create_workspace_directory(
|
|||||||
|
|
||||||
|
|
||||||
# 删除逻辑目录及其下属脚本记录;实际文件按存储层的软删除规则处理。
|
# 删除逻辑目录及其下属脚本记录;实际文件按存储层的软删除规则处理。
|
||||||
@router.delete("/api/v1/workspace-directories")
|
@router.delete("/workspace-directories")
|
||||||
async def delete_workspace_directory(
|
async def delete_workspace_directory(
|
||||||
request: Request,
|
request: Request,
|
||||||
path: str = Query(min_length=1, max_length=1024),
|
path: str = Query(min_length=1, max_length=1024),
|
||||||
@@ -1133,7 +1133,7 @@ async def delete_workspace_directory(
|
|||||||
# ``STRAIGHT_JOIN`` 或给 ``storage_objects.relative_path`` 加 prefix
|
# ``STRAIGHT_JOIN`` 或给 ``storage_objects.relative_path`` 加 prefix
|
||||||
# 索引(基线迁移里有 ``idx_storage_workspace_relative_path`` 但 ORM
|
# 索引(基线迁移里有 ``idx_storage_workspace_relative_path`` 但 ORM
|
||||||
# 模型未声明,不在此修复范围)。
|
# 模型未声明,不在此修复范围)。
|
||||||
@router.get("/api/v1/scripts")
|
@router.get("/scripts")
|
||||||
async def list_scripts(
|
async def list_scripts(
|
||||||
parent_path: str = Query(default="", max_length=1024),
|
parent_path: str = Query(default="", max_length=1024),
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -1198,7 +1198,7 @@ async def list_scripts(
|
|||||||
# - Workspace-wide ``LIKE 'workspace/%'`` prefix (no embedded user_id) so
|
# - Workspace-wide ``LIKE 'workspace/%'`` prefix (no embedded user_id) so
|
||||||
# counts span every owner's subtree.
|
# counts span every owner's subtree.
|
||||||
# - No NOT-LIKE filter because the count wants descendants too.
|
# - No NOT-LIKE filter because the count wants descendants too.
|
||||||
@router.get("/api/v1/scripts/count")
|
@router.get("/scripts/count")
|
||||||
async def count_scripts(
|
async def count_scripts(
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
session: AsyncSession = Depends(database_session),
|
session: AsyncSession = Depends(database_session),
|
||||||
@@ -1233,7 +1233,7 @@ async def count_scripts(
|
|||||||
|
|
||||||
|
|
||||||
# 读取脚本正文或 Notebook JSON;编辑器打开文件时调用此接口。
|
# 读取脚本正文或 Notebook JSON;编辑器打开文件时调用此接口。
|
||||||
@router.get("/api/v1/scripts/{script_id}/content")
|
@router.get("/scripts/{script_id}/content")
|
||||||
async def get_script_content(
|
async def get_script_content(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -1284,7 +1284,7 @@ async def get_script_content(
|
|||||||
|
|
||||||
|
|
||||||
# 查询单个脚本的元数据,例如类型、路径、锁状态和拥有者。
|
# 查询单个脚本的元数据,例如类型、路径、锁状态和拥有者。
|
||||||
@router.get("/api/v1/scripts/{script_id}")
|
@router.get("/scripts/{script_id}")
|
||||||
async def get_script(
|
async def get_script(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -1303,7 +1303,7 @@ async def get_script(
|
|||||||
|
|
||||||
|
|
||||||
# 保存编辑器提交的新内容;会校验工作区权限和文件编辑锁。
|
# 保存编辑器提交的新内容;会校验工作区权限和文件编辑锁。
|
||||||
@router.put("/api/v1/scripts/{script_id}")
|
@router.put("/scripts/{script_id}")
|
||||||
async def update_script(
|
async def update_script(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
payload: UpdateScriptRequest,
|
payload: UpdateScriptRequest,
|
||||||
@@ -1390,7 +1390,7 @@ async def update_script(
|
|||||||
|
|
||||||
|
|
||||||
# 修改脚本锁定状态,避免其他用户同时编辑同一份文件。
|
# 修改脚本锁定状态,避免其他用户同时编辑同一份文件。
|
||||||
@router.patch("/api/v1/scripts/{script_id}/lock")
|
@router.patch("/scripts/{script_id}/lock")
|
||||||
async def set_script_lock(
|
async def set_script_lock(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
payload: LockScriptRequest,
|
payload: LockScriptRequest,
|
||||||
@@ -1433,7 +1433,7 @@ async def set_script_lock(
|
|||||||
|
|
||||||
|
|
||||||
# 软删除脚本;元数据标记删除,历史版本可按规则继续保留。
|
# 软删除脚本;元数据标记删除,历史版本可按规则继续保留。
|
||||||
@router.delete("/api/v1/scripts/{script_id}")
|
@router.delete("/scripts/{script_id}")
|
||||||
async def delete_script(
|
async def delete_script(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -1480,7 +1480,7 @@ async def delete_script(
|
|||||||
|
|
||||||
# 将当前脚本内容发布为不可变版本,供调度节点和回溯下载使用。
|
# 将当前脚本内容发布为不可变版本,供调度节点和回溯下载使用。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/scripts/{script_id}/versions",
|
"/scripts/{script_id}/versions",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def publish_version(
|
async def publish_version(
|
||||||
@@ -1601,7 +1601,7 @@ async def publish_version(
|
|||||||
|
|
||||||
|
|
||||||
# 列出某脚本已经发布的历史版本。
|
# 列出某脚本已经发布的历史版本。
|
||||||
@router.get("/api/v1/scripts/{script_id}/versions")
|
@router.get("/scripts/{script_id}/versions")
|
||||||
async def list_versions(
|
async def list_versions(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -1623,7 +1623,7 @@ async def list_versions(
|
|||||||
|
|
||||||
|
|
||||||
# 读取脚本最近一次发布的版本;未发布时返回空结果。
|
# 读取脚本最近一次发布的版本;未发布时返回空结果。
|
||||||
@router.get("/api/v1/scripts/{script_id}/latest-version")
|
@router.get("/scripts/{script_id}/latest-version")
|
||||||
async def latest_version(
|
async def latest_version(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -1675,7 +1675,7 @@ async def latest_version(
|
|||||||
|
|
||||||
|
|
||||||
# 查询单个发布版本的元数据和关联脚本信息。
|
# 查询单个发布版本的元数据和关联脚本信息。
|
||||||
@router.get("/api/v1/versions/{versions_id}")
|
@router.get("/versions/{versions_id}")
|
||||||
async def get_version(
|
async def get_version(
|
||||||
versions_id: str,
|
versions_id: str,
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -1692,7 +1692,7 @@ async def get_version(
|
|||||||
|
|
||||||
|
|
||||||
# 隐藏/删除一个发布版本;是否保留实际产物由存储删除策略决定。
|
# 隐藏/删除一个发布版本;是否保留实际产物由存储删除策略决定。
|
||||||
@router.delete("/api/v1/versions/{versions_id}")
|
@router.delete("/versions/{versions_id}")
|
||||||
async def delete_version(
|
async def delete_version(
|
||||||
versions_id: str,
|
versions_id: str,
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -1740,7 +1740,7 @@ async def delete_version(
|
|||||||
|
|
||||||
|
|
||||||
# 为某个版本产物生成带时效的下载地址,而非把大文件直接经 API 返回。
|
# 为某个版本产物生成带时效的下载地址,而非把大文件直接经 API 返回。
|
||||||
@router.post("/api/v1/versions/{versions_id}/download-url")
|
@router.post("/versions/{versions_id}/download-url")
|
||||||
async def version_download_url(
|
async def version_download_url(
|
||||||
versions_id: str,
|
versions_id: str,
|
||||||
payload: DownloadUrlRequest,
|
payload: DownloadUrlRequest,
|
||||||
@@ -143,7 +143,7 @@ def storage_payload(item: StorageObjects) -> dict[str, Any]:
|
|||||||
# 内部路由由 main.py 以 /internal 前缀挂载。数据库引擎、Session 工厂和对象
|
# 内部路由由 main.py 以 /internal 前缀挂载。数据库引擎、Session 工厂和对象
|
||||||
# 存储实例均在应用生命周期中创建;本模块只定义路由和供 services.storage 复用的
|
# 存储实例均在应用生命周期中创建;本模块只定义路由和供 services.storage 复用的
|
||||||
# 存储辅助函数(如 storage_payload、resolve_bucket、BUCKET_FOR_USAGE)。
|
# 存储辅助函数(如 storage_payload、resolve_bucket、BUCKET_FOR_USAGE)。
|
||||||
router = APIRouter(tags=["internal-storage"])
|
router = APIRouter(prefix="/v1", tags=["internal-storage"])
|
||||||
|
|
||||||
|
|
||||||
async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
|
async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
|
||||||
@@ -253,7 +253,7 @@ async def create_upload_record(
|
|||||||
|
|
||||||
# Two-step server-proxied upload: the caller PUTs the raw bytes to
|
# Two-step server-proxied upload: the caller PUTs the raw bytes to
|
||||||
# ``upload_path`` after this response, which routes through
|
# ``upload_path`` after this response, which routes through
|
||||||
# ``backend.resources.upload_bytes_to_session`` (the canonical helper
|
# ``backend.api.resources.upload_bytes_to_session`` (the canonical helper
|
||||||
# in ``services.storage``).
|
# in ``services.storage``).
|
||||||
return {
|
return {
|
||||||
"upload_id": upload.upload_id,
|
"upload_id": upload.upload_id,
|
||||||
@@ -286,7 +286,7 @@ def _public_base_url(request: Request) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/v1/objects",
|
"/objects",
|
||||||
dependencies=[Depends(require_internal_service)],
|
dependencies=[Depends(require_internal_service)],
|
||||||
)
|
)
|
||||||
async def create_server_object(
|
async def create_server_object(
|
||||||
@@ -304,7 +304,7 @@ async def create_server_object(
|
|||||||
return await create_server_object_payload(payload, request, session)
|
return await create_server_object_payload(payload, request, session)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/v1/objects/{storage_object_id}/restore")
|
@router.post("/objects/{storage_object_id}/restore")
|
||||||
async def restore_object(
|
async def restore_object(
|
||||||
storage_object_id: str,
|
storage_object_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -371,7 +371,7 @@ async def restore_object(
|
|||||||
|
|
||||||
|
|
||||||
# 管理动作:永久清理超过保留期限或指定的回收站对象。
|
# 管理动作:永久清理超过保留期限或指定的回收站对象。
|
||||||
@router.post("/v1/admin/trash/purge")
|
@router.post("/admin/trash/purge")
|
||||||
async def purge_trash_object(
|
async def purge_trash_object(
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -94,7 +94,7 @@ class RuntimeClient:
|
|||||||
"""Return a running workspace descriptor, starting it if needed.
|
"""Return a running workspace descriptor, starting it if needed.
|
||||||
|
|
||||||
Mirrors the lazy-start pattern used by
|
Mirrors the lazy-start pattern used by
|
||||||
:func:`backend.jupyter.verify_jupyter_access`: try ``get``
|
:func:`backend.api.jupyter.verify_jupyter_access`: try ``get``
|
||||||
first, fall through to ``start`` if the workspace is not yet
|
first, fall through to ``start`` if the workspace is not yet
|
||||||
running. Bumps ``last_used_at`` via the runtime registry on the
|
running. Bumps ``last_used_at`` via the runtime registry on the
|
||||||
way in, so the idle reaper is satisfied for the duration of the
|
way in, so the idle reaper is satisfied for the duration of the
|
||||||
+11
-11
@@ -34,17 +34,17 @@ from fastapi.responses import JSONResponse
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from backend.audit import configure_audit_logging
|
from backend.audit import configure_audit_logging
|
||||||
from backend.admin import router as admin_router
|
from backend.api.admin import router as admin_router
|
||||||
from backend.auth import router as auth_router
|
from backend.api.auth import router as auth_router
|
||||||
from backend.jupyter import router as jupyter_router
|
from backend.api.jupyter import router as jupyter_router
|
||||||
from backend.platform import router as platform_router
|
from backend.api.platform import router as platform_router
|
||||||
from backend.rclone_rc_client import RcloneRCClient
|
from backend.api.resources import router as resources_router
|
||||||
from backend.resources import router as resources_router
|
from backend.api.scripts import router as scripts_router
|
||||||
from backend.runtime_client import RuntimeClient
|
from backend.api.schedules.runs import router as schedule_runs_router
|
||||||
from backend.schedule_runs import router as schedule_runs_router
|
from backend.api.schedules.schedules import router as schedules_router
|
||||||
from backend.schedules import router as schedules_router
|
from backend.api.storage import router as storage_api_router
|
||||||
from backend.scripts import router as scripts_router
|
from backend.clients.rclone import RcloneRCClient
|
||||||
from backend.storage_api import router as storage_api_router
|
from backend.clients.runtime import RuntimeClient
|
||||||
|
|
||||||
configure_logging(settings.log_level)
|
configure_logging(settings.log_level)
|
||||||
configure_audit_logging(
|
configure_audit_logging(
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Reserved for stage-3 extraction. Currently empty."""
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""跨域共享的请求/响应模型。
|
||||||
|
|
||||||
|
目前唯一成员是 `DownloadUrlRequest`:资源(resources)和脚本版本
|
||||||
|
(scripts)两个域都要用它生成预签名下载 URL。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from common.schemas import StrictModel
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadUrlRequest(StrictModel):
|
||||||
|
expires_seconds: int = Field(default=300, ge=30, le=3600)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Reserved for stage-3 extraction. Currently empty."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Reserved for stage-3 extraction. Currently empty."""
|
||||||
@@ -39,40 +39,5 @@ class CompleteResourceUploadRequest(StrictModel):
|
|||||||
visibility: Literal["private", "workspace", "public"] = "private"
|
visibility: Literal["private", "workspace", "public"] = "private"
|
||||||
|
|
||||||
|
|
||||||
class CreateScriptRequest(StrictModel):
|
|
||||||
script_name: str = Field(min_length=1, max_length=255)
|
|
||||||
script_type: Literal["python", "notebook"]
|
|
||||||
content: str = Field(max_length=10 * 1024 * 1024)
|
|
||||||
visibility: Literal["private", "workspace", "public"] = "private"
|
|
||||||
parent_path: str | None = Field(default=None, max_length=1024)
|
|
||||||
|
|
||||||
|
|
||||||
class CreateWorkspaceDirectoryRequest(StrictModel):
|
|
||||||
directory_name: str = Field(min_length=1, max_length=255)
|
|
||||||
parent_path: str = Field(default="", max_length=1024)
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateScriptRequest(StrictModel):
|
|
||||||
content: str = Field(max_length=10 * 1024 * 1024)
|
|
||||||
|
|
||||||
|
|
||||||
class LockScriptRequest(StrictModel):
|
|
||||||
is_locked: bool
|
|
||||||
|
|
||||||
|
|
||||||
class PublishVersionRequest(StrictModel):
|
|
||||||
source_object_id: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
min_length=26,
|
|
||||||
max_length=26,
|
|
||||||
)
|
|
||||||
release_note: str | None = Field(default=None, max_length=1000)
|
|
||||||
visibility: Literal["private", "workspace", "public"] = "workspace"
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadUrlRequest(StrictModel):
|
|
||||||
expires_seconds: int = Field(default=300, ge=30, le=3600)
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceRelativePathRequest(StrictModel):
|
class ResourceRelativePathRequest(StrictModel):
|
||||||
script_path: str = Field(min_length=1, max_length=512)
|
script_path: str = Field(min_length=1, max_length=512)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from common.schemas import StrictModel
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
|
||||||
|
class CreateScriptRequest(StrictModel):
|
||||||
|
script_name: str = Field(min_length=1, max_length=255)
|
||||||
|
script_type: Literal["python", "notebook"]
|
||||||
|
content: str = Field(max_length=10 * 1024 * 1024)
|
||||||
|
visibility: Literal["private", "workspace", "public"] = "private"
|
||||||
|
parent_path: str | None = Field(default=None, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateWorkspaceDirectoryRequest(StrictModel):
|
||||||
|
directory_name: str = Field(min_length=1, max_length=255)
|
||||||
|
parent_path: str = Field(default="", max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateScriptRequest(StrictModel):
|
||||||
|
content: str = Field(max_length=10 * 1024 * 1024)
|
||||||
|
|
||||||
|
|
||||||
|
class LockScriptRequest(StrictModel):
|
||||||
|
is_locked: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PublishVersionRequest(StrictModel):
|
||||||
|
source_object_id: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
min_length=26,
|
||||||
|
max_length=26,
|
||||||
|
)
|
||||||
|
release_note: str | None = Field(default=None, max_length=1000)
|
||||||
|
visibility: Literal["private", "workspace", "public"] = "workspace"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Reserved for stage-3 extraction. Currently empty."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Reserved for stage-3 extraction. Currently empty."""
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Schedule-domain services.
|
||||||
|
|
||||||
|
Pure business logic extracted from ``backend.api.schedules`` so it can be
|
||||||
|
unit-tested without spinning up FastAPI / a DB session. Functions here
|
||||||
|
must not depend on ``Request``, ``BackgroundTasks``, or any FastAPI
|
||||||
|
router primitive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import heapq
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from common.db.models import ScheduleEdges, ScheduleNodes
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dag(
|
||||||
|
nodes: list[ScheduleNodes],
|
||||||
|
edges: list[ScheduleEdges],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate a schedule DAG and return a structural report.
|
||||||
|
|
||||||
|
The returned dict has keys:
|
||||||
|
|
||||||
|
* ``valid`` — True iff ``errors`` is empty
|
||||||
|
* ``node_count`` / ``edge_count`` — input sizes
|
||||||
|
* ``root_node_ids`` / ``leaf_node_ids`` — sorted by node_key so the
|
||||||
|
output is deterministic regardless of insertion order
|
||||||
|
* ``topological_order`` — Kahn's algorithm over node_key ties
|
||||||
|
* ``errors`` — list of dicts with ``code`` plus enough context
|
||||||
|
(``edge_id``, ``node_ids``) for the caller to surface back to
|
||||||
|
the UI; never raises
|
||||||
|
|
||||||
|
Recognised error codes:
|
||||||
|
|
||||||
|
* ``DAG_EMPTY`` — no nodes
|
||||||
|
* ``DAG_EDGE_NODE_MISSING`` — edge references unknown node_id
|
||||||
|
* ``DAG_SELF_EDGE`` — source == target
|
||||||
|
* ``DAG_DUPLICATE_EDGE`` — same directed pair seen twice
|
||||||
|
* ``DAG_CYCLE`` — topological sort did not consume all nodes
|
||||||
|
"""
|
||||||
|
node_by_id = {item.node_id: item for item in nodes}
|
||||||
|
indegree = {item.node_id: 0 for item in nodes}
|
||||||
|
outgoing: dict[str, set[str]] = {
|
||||||
|
item.node_id: set()
|
||||||
|
for item in nodes
|
||||||
|
}
|
||||||
|
errors: list[dict[str, Any]] = []
|
||||||
|
seen_edges: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
if not nodes:
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"code": "DAG_EMPTY",
|
||||||
|
"message": "schedule must contain at least one node",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for edge in edges:
|
||||||
|
if (
|
||||||
|
edge.source_node_id not in node_by_id
|
||||||
|
or edge.target_node_id not in node_by_id
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"code": "DAG_EDGE_NODE_MISSING",
|
||||||
|
"message": "edge references a node outside the schedule",
|
||||||
|
"edge_id": edge.edge_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
pair = (edge.source_node_id, edge.target_node_id)
|
||||||
|
if edge.source_node_id == edge.target_node_id:
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"code": "DAG_SELF_EDGE",
|
||||||
|
"message": "a node cannot depend on itself",
|
||||||
|
"edge_id": edge.edge_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if pair in seen_edges:
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"code": "DAG_DUPLICATE_EDGE",
|
||||||
|
"message": "duplicate directed edge",
|
||||||
|
"edge_id": edge.edge_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
seen_edges.add(pair)
|
||||||
|
outgoing[edge.source_node_id].add(edge.target_node_id)
|
||||||
|
indegree[edge.target_node_id] += 1
|
||||||
|
|
||||||
|
root_ids = sorted(
|
||||||
|
(node_id for node_id, degree in indegree.items() if degree == 0),
|
||||||
|
key=lambda node_id: node_by_id[node_id].node_key,
|
||||||
|
)
|
||||||
|
leaf_ids = sorted(
|
||||||
|
(node_id for node_id, targets in outgoing.items() if not targets),
|
||||||
|
key=lambda node_id: node_by_id[node_id].node_key,
|
||||||
|
)
|
||||||
|
queue = [
|
||||||
|
(node_by_id[node_id].node_key, node_id)
|
||||||
|
for node_id in root_ids
|
||||||
|
]
|
||||||
|
heapq.heapify(queue)
|
||||||
|
remaining_indegree = dict(indegree)
|
||||||
|
ordered_ids: list[str] = []
|
||||||
|
while queue:
|
||||||
|
_, node_id = heapq.heappop(queue)
|
||||||
|
ordered_ids.append(node_id)
|
||||||
|
for target_id in sorted(
|
||||||
|
outgoing[node_id],
|
||||||
|
key=lambda value: node_by_id[value].node_key,
|
||||||
|
):
|
||||||
|
remaining_indegree[target_id] -= 1
|
||||||
|
if remaining_indegree[target_id] == 0:
|
||||||
|
heapq.heappush(
|
||||||
|
queue,
|
||||||
|
(node_by_id[target_id].node_key, target_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(ordered_ids) != len(nodes):
|
||||||
|
cycle_node_ids = sorted(
|
||||||
|
(
|
||||||
|
node_id
|
||||||
|
for node_id, degree in remaining_indegree.items()
|
||||||
|
if degree > 0
|
||||||
|
),
|
||||||
|
key=lambda node_id: node_by_id[node_id].node_key,
|
||||||
|
)
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"code": "DAG_CYCLE",
|
||||||
|
"message": "schedule graph contains a directed cycle",
|
||||||
|
"node_ids": cycle_node_ids,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": not errors,
|
||||||
|
"node_count": len(nodes),
|
||||||
|
"edge_count": len(edges),
|
||||||
|
"root_node_ids": root_ids,
|
||||||
|
"leaf_node_ids": leaf_ids,
|
||||||
|
"topological_order": ordered_ids,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["validate_dag"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Reserved for stage-3 extraction. Currently empty."""
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""In-process storage helpers.
|
"""In-process storage helpers.
|
||||||
|
|
||||||
The HTTP ``/internal/v1/*`` routes in ``backend.storage_api`` are wrappers
|
The HTTP ``/internal/v1/*`` routes in ``backend.api.storage`` are wrappers
|
||||||
around these. Other backend modules (``scripts``, ``resources``) and the
|
around these. Other backend modules (``scripts``, ``resources``) and the
|
||||||
schedule worker call these helpers directly instead of going through an
|
schedule worker call these helpers directly instead of going through an
|
||||||
HTTP client — the storage layer lives in the same process, so the
|
HTTP client — the storage layer lives in the same process, so the
|
||||||
@@ -175,7 +175,7 @@ async def _mark_upload_failed_and_raise(
|
|||||||
``GET_LOCK``.
|
``GET_LOCK``.
|
||||||
|
|
||||||
Why this helper exists at all: the route handler wraps every request
|
Why this helper exists at all: the route handler wraps every request
|
||||||
in ``session_scope`` (see ``backend.dependencies.database_session``),
|
in ``session_scope`` (see ``backend.api.dependencies.database_session``),
|
||||||
which rolls back on exception. Without this helper, a naive
|
which rolls back on exception. Without this helper, a naive
|
||||||
``upload.upload_status = "failed"; raise HTTPException(...)`` would
|
``upload.upload_status = "failed"; raise HTTPException(...)`` would
|
||||||
lose the status flip and leave the row stuck in ``created``/``uploading``
|
lose the status flip and leave the row stuck in ``created``/``uploading``
|
||||||
@@ -279,7 +279,7 @@ def _resolve_bucket_for_usage(
|
|||||||
workspace_artifact_bucket: str | None,
|
workspace_artifact_bucket: str | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Mirror of storage_api.resolve_bucket, but pure (no DB / Request)."""
|
"""Mirror of storage_api.resolve_bucket, but pure (no DB / Request)."""
|
||||||
from backend.storage_api import BUCKET_FOR_USAGE
|
from backend.api.storage import BUCKET_FOR_USAGE
|
||||||
if workspace_artifact_bucket:
|
if workspace_artifact_bucket:
|
||||||
return workspace_artifact_bucket
|
return workspace_artifact_bucket
|
||||||
return BUCKET_FOR_USAGE.get(
|
return BUCKET_FOR_USAGE.get(
|
||||||
@@ -298,7 +298,7 @@ async def create_upload_record(
|
|||||||
session; or ``{upload_id, status: "completed", storage_object: {...}}``
|
session; or ``{upload_id, status: "completed", storage_object: {...}}``
|
||||||
when the idempotency key hits an already-completed upload.
|
when the idempotency key hits an already-completed upload.
|
||||||
"""
|
"""
|
||||||
from backend.storage_api import (
|
from backend.api.storage import (
|
||||||
normalized_idempotency_key,
|
normalized_idempotency_key,
|
||||||
require_workspace_member,
|
require_workspace_member,
|
||||||
)
|
)
|
||||||
@@ -352,7 +352,7 @@ async def create_upload_record(
|
|||||||
else:
|
else:
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from backend.storage_api import utcnow
|
from backend.api.storage import utcnow
|
||||||
|
|
||||||
bucket_name = _resolve_bucket_for_usage(
|
bucket_name = _resolve_bucket_for_usage(
|
||||||
payload.usage_type,
|
payload.usage_type,
|
||||||
@@ -384,7 +384,7 @@ async def create_upload_record(
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
if upload.upload_status == "completed" and upload.storage_object_id:
|
if upload.upload_status == "completed" and upload.storage_object_id:
|
||||||
from backend.storage_api import storage_payload
|
from backend.api.storage import storage_payload
|
||||||
storage_object = await session.get(StorageObjects, upload.storage_object_id)
|
storage_object = await session.get(StorageObjects, upload.storage_object_id)
|
||||||
if storage_object is None or storage_object.object_status != "available":
|
if storage_object is None or storage_object.object_status != "available":
|
||||||
upload.storage_object_id = None
|
upload.storage_object_id = None
|
||||||
@@ -552,7 +552,7 @@ async def create_server_object_payload(
|
|||||||
Used by scripts.py when publishing version artifacts and by the
|
Used by scripts.py when publishing version artifacts and by the
|
||||||
schedule worker for run logs / run results.
|
schedule worker for run logs / run results.
|
||||||
"""
|
"""
|
||||||
from backend.storage_api import storage_payload
|
from backend.api.storage import storage_payload
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = base64.b64decode(payload.content_base64, validate=True)
|
content = base64.b64decode(payload.content_base64, validate=True)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from backend.scripts import count_scripts
|
from backend.api.scripts import count_scripts
|
||||||
|
|
||||||
|
|
||||||
def _ctx(
|
def _ctx(
|
||||||
@@ -139,7 +139,7 @@ async def test_count_scripts_route_declared_before_script_id_route() -> None:
|
|||||||
"""Static check: the `/api/v1/scripts/count` route MUST be declared in
|
"""Static check: the `/api/v1/scripts/count` route MUST be declared in
|
||||||
scripts.py before `/api/v1/scripts/{script_id}/...`, otherwise FastAPI's
|
scripts.py before `/api/v1/scripts/{script_id}/...`, otherwise FastAPI's
|
||||||
declaration-order matching will interpret `count` as a script_id."""
|
declaration-order matching will interpret `count` as a script_id."""
|
||||||
from backend.scripts import count_scripts, get_script
|
from backend.api.scripts import count_scripts, get_script
|
||||||
|
|
||||||
assert callable(count_scripts)
|
assert callable(count_scripts)
|
||||||
assert callable(get_script)
|
assert callable(get_script)
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ from unittest.mock import AsyncMock
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
import backend.jupyter as jupyter_module
|
import backend.api.jupyter as jupyter_module
|
||||||
from backend.jupyter import (
|
from backend.api.jupyter import (
|
||||||
_JUPYTER_AUTH_CACHE,
|
_JUPYTER_AUTH_CACHE,
|
||||||
verify_jupyter_access,
|
verify_jupyter_access,
|
||||||
)
|
)
|
||||||
from backend.runtime_client import RuntimeClientError
|
from backend.clients.runtime import RuntimeClientError
|
||||||
|
|
||||||
WS_ID = "01WS0000000000000000000A"
|
WS_ID = "01WS0000000000000000000A"
|
||||||
USER_ID = "01USR0000000000000000000A"
|
USER_ID = "01USR0000000000000000000A"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from fastapi import HTTPException
|
|||||||
from sqlalchemy import Column, MetaData, String, Table, create_engine, select, text
|
from sqlalchemy import Column, MetaData, String, Table, create_engine, select, text
|
||||||
from sqlalchemy.dialects import mysql as mysql_dialect
|
from sqlalchemy.dialects import mysql as mysql_dialect
|
||||||
|
|
||||||
from backend.scripts import (
|
from backend.api.scripts import (
|
||||||
_build_list_scripts_descendant_prefix,
|
_build_list_scripts_descendant_prefix,
|
||||||
_build_list_scripts_workspace_descendant_prefix,
|
_build_list_scripts_workspace_descendant_prefix,
|
||||||
_escape_like_pattern,
|
_escape_like_pattern,
|
||||||
@@ -179,7 +179,7 @@ def _compile_sql(stmt) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() -> None:
|
async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() -> None:
|
||||||
from backend.scripts import list_scripts
|
from backend.api.scripts import list_scripts
|
||||||
|
|
||||||
captured_sql: list[str] = []
|
captured_sql: list[str] = []
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper()
|
|||||||
async def test_list_scripts_where_clause_escapes_pattern_literal() -> None:
|
async def test_list_scripts_where_clause_escapes_pattern_literal() -> None:
|
||||||
"""Regression: parent_path containing ``_`` MUST be escaped in the
|
"""Regression: parent_path containing ``_`` MUST be escaped in the
|
||||||
compiled LIKE pattern, otherwise sibling-path leak returns to bite."""
|
compiled LIKE pattern, otherwise sibling-path leak returns to bite."""
|
||||||
from backend.scripts import list_scripts
|
from backend.api.scripts import list_scripts
|
||||||
|
|
||||||
captured_sql: list[str] = []
|
captured_sql: list[str] = []
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ async def test_list_scripts_where_clause_escapes_pattern_literal() -> None:
|
|||||||
|
|
||||||
async def test_list_scripts_where_clause_escapes_percent_pattern() -> None:
|
async def test_list_scripts_where_clause_escapes_percent_pattern() -> None:
|
||||||
"""Same regression for ``%``."""
|
"""Same regression for ``%``."""
|
||||||
from backend.scripts import list_scripts
|
from backend.api.scripts import list_scripts
|
||||||
|
|
||||||
captured_sql: list[str] = []
|
captured_sql: list[str] = []
|
||||||
|
|
||||||
@@ -264,7 +264,7 @@ async def test_list_scripts_non_admin_adds_visibility_filter() -> None:
|
|||||||
"""Workspace-wide listing is narrowed by visibility for non-admin:
|
"""Workspace-wide listing is narrowed by visibility for non-admin:
|
||||||
owner_user_id = me OR visibility IN (workspace, public) — exactly like
|
owner_user_id = me OR visibility IN (workspace, public) — exactly like
|
||||||
list_resources. The workspace prefix contains NO user_id (cross-owner)."""
|
list_resources. The workspace prefix contains NO user_id (cross-owner)."""
|
||||||
from backend.scripts import list_scripts
|
from backend.api.scripts import list_scripts
|
||||||
|
|
||||||
captured_sql: list[str] = []
|
captured_sql: list[str] = []
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ async def test_list_scripts_non_admin_adds_visibility_filter() -> None:
|
|||||||
|
|
||||||
async def test_list_scripts_admin_skips_visibility_filter() -> None:
|
async def test_list_scripts_admin_skips_visibility_filter() -> None:
|
||||||
"""Admin short-circuits the visibility predicate and sees everything."""
|
"""Admin short-circuits the visibility predicate and sees everything."""
|
||||||
from backend.scripts import list_scripts
|
from backend.api.scripts import list_scripts
|
||||||
|
|
||||||
captured_sql: list[str] = []
|
captured_sql: list[str] = []
|
||||||
|
|
||||||
@@ -316,7 +316,7 @@ async def test_list_scripts_admin_skips_visibility_filter() -> None:
|
|||||||
async def test_list_workspace_directories_where_clause_escapes_pattern() -> None:
|
async def test_list_workspace_directories_where_clause_escapes_pattern() -> None:
|
||||||
"""list_workspace_directories must escape user input too (was
|
"""list_workspace_directories must escape user input too (was
|
||||||
pre-existing debt)."""
|
pre-existing debt)."""
|
||||||
from backend.scripts import list_workspace_directories
|
from backend.api.scripts import list_workspace_directories
|
||||||
|
|
||||||
captured_sql: list[str] = []
|
captured_sql: list[str] = []
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import pytest
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import Column, MetaData, String, Table, create_engine, select
|
from sqlalchemy import Column, MetaData, String, Table, create_engine, select
|
||||||
from sqlalchemy.dialects import mysql as mysql_dialect
|
from sqlalchemy.dialects import mysql as mysql_dialect
|
||||||
from backend.resources import (
|
from backend.api.resources import (
|
||||||
_build_list_resources_descendant_prefix,
|
_build_list_resources_descendant_prefix,
|
||||||
can_view,
|
can_view,
|
||||||
compute_jupyter_relative_path,
|
compute_jupyter_relative_path,
|
||||||
@@ -126,7 +126,7 @@ def _bind_payload():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bind_resource_rejects_duplicate_name_in_same_directory() -> None:
|
async def test_bind_resource_rejects_duplicate_name_in_same_directory() -> None:
|
||||||
"""Same resource_name in the same directory raises 409."""
|
"""Same resource_name in the same directory raises 409."""
|
||||||
from backend.resources import bind_resource
|
from backend.api.resources import bind_resource
|
||||||
|
|
||||||
existing_rows = [
|
existing_rows = [
|
||||||
(
|
(
|
||||||
@@ -153,7 +153,7 @@ async def test_bind_resource_rejects_duplicate_name_in_same_directory() -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bind_resource_allows_same_name_in_different_directory() -> None:
|
async def test_bind_resource_allows_same_name_in_different_directory() -> None:
|
||||||
"""Same resource_name in a different directory binds successfully."""
|
"""Same resource_name in a different directory binds successfully."""
|
||||||
from backend.resources import bind_resource
|
from backend.api.resources import bind_resource
|
||||||
|
|
||||||
existing_rows = [
|
existing_rows = [
|
||||||
(
|
(
|
||||||
@@ -179,7 +179,7 @@ async def test_bind_resource_allows_same_name_in_different_directory() -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bind_resource_allows_same_name_when_workspace_empty() -> None:
|
async def test_bind_resource_allows_same_name_when_workspace_empty() -> None:
|
||||||
"""No same-name rows at all: bind succeeds (root directory)."""
|
"""No same-name rows at all: bind succeeds (root directory)."""
|
||||||
from backend.resources import bind_resource
|
from backend.api.resources import bind_resource
|
||||||
|
|
||||||
session = _BindSessionMock(
|
session = _BindSessionMock(
|
||||||
new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv",
|
new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv",
|
||||||
@@ -197,7 +197,7 @@ async def test_bind_resource_allows_same_name_when_workspace_empty() -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bind_resource_allows_same_name_for_different_owner() -> None:
|
async def test_bind_resource_allows_same_name_for_different_owner() -> None:
|
||||||
"""其他用户在同目录下的同名资源不阻塞当前用户的绑定。"""
|
"""其他用户在同目录下的同名资源不阻塞当前用户的绑定。"""
|
||||||
from backend.resources import bind_resource
|
from backend.api.resources import bind_resource
|
||||||
|
|
||||||
other_user = "01USR0000000000000000000B"
|
other_user = "01USR0000000000000000000B"
|
||||||
existing_rows = [
|
existing_rows = [
|
||||||
@@ -223,7 +223,7 @@ async def test_bind_resource_allows_same_name_for_different_owner() -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bind_resource_allows_rebinding_same_storage_object() -> None:
|
async def test_bind_resource_allows_rebinding_same_storage_object() -> None:
|
||||||
"""重新绑定同一 upload_id 应走 idempotent 复用路径,不触发 409。"""
|
"""重新绑定同一 upload_id 应走 idempotent 复用路径,不触发 409。"""
|
||||||
from backend.resources import bind_resource
|
from backend.api.resources import bind_resource
|
||||||
|
|
||||||
new_object_key = f"{_BIND_WS}/{_BIND_USER}/data.csv"
|
new_object_key = f"{_BIND_WS}/{_BIND_USER}/data.csv"
|
||||||
existing_resource = _make_resource(_BIND_WS, _BIND_USER)
|
existing_resource = _make_resource(_BIND_WS, _BIND_USER)
|
||||||
@@ -251,7 +251,7 @@ async def test_bind_resource_allows_rebinding_same_storage_object() -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bind_resource_rejects_non_data_resource_upload() -> None:
|
async def test_bind_resource_rejects_non_data_resource_upload() -> None:
|
||||||
"""其他用途(如 working_copy)的 upload session 不能 bind 成数据资源。"""
|
"""其他用途(如 working_copy)的 upload session 不能 bind 成数据资源。"""
|
||||||
from backend.resources import bind_resource
|
from backend.api.resources import bind_resource
|
||||||
|
|
||||||
session = _BindSessionMock(
|
session = _BindSessionMock(
|
||||||
new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv",
|
new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv",
|
||||||
@@ -566,7 +566,7 @@ def _list_resources_capturing_session(captured_sql: list[str]) -> MagicMock:
|
|||||||
|
|
||||||
|
|
||||||
async def test_list_resources_where_clause_uses_like_prefix_and_excludes_deeper() -> None:
|
async def test_list_resources_where_clause_uses_like_prefix_and_excludes_deeper() -> None:
|
||||||
from backend.resources import list_resources
|
from backend.api.resources import list_resources
|
||||||
|
|
||||||
captured_sql: list[str] = []
|
captured_sql: list[str] = []
|
||||||
mock_session = _list_resources_capturing_session(captured_sql)
|
mock_session = _list_resources_capturing_session(captured_sql)
|
||||||
@@ -589,7 +589,7 @@ async def test_list_resources_where_clause_uses_like_prefix_and_excludes_deeper(
|
|||||||
async def test_list_resources_where_clause_escapes_underscore() -> None:
|
async def test_list_resources_where_clause_escapes_underscore() -> None:
|
||||||
"""Regression: parent_path containing ``_`` MUST be escaped in the
|
"""Regression: parent_path containing ``_`` MUST be escaped in the
|
||||||
compiled LIKE pattern, otherwise sibling-path leak (``fooXbar``) returns."""
|
compiled LIKE pattern, otherwise sibling-path leak (``fooXbar``) returns."""
|
||||||
from backend.resources import list_resources
|
from backend.api.resources import list_resources
|
||||||
|
|
||||||
captured_sql: list[str] = []
|
captured_sql: list[str] = []
|
||||||
mock_session = _list_resources_capturing_session(captured_sql)
|
mock_session = _list_resources_capturing_session(captured_sql)
|
||||||
@@ -614,7 +614,7 @@ async def test_list_resources_where_clause_escapes_underscore() -> None:
|
|||||||
async def test_list_resources_without_parent_path_adds_no_like_clause() -> None:
|
async def test_list_resources_without_parent_path_adds_no_like_clause() -> None:
|
||||||
"""Empty parent_path keeps the legacy workspace-wide behaviour — no
|
"""Empty parent_path keeps the legacy workspace-wide behaviour — no
|
||||||
object_key LIKE filter at all."""
|
object_key LIKE filter at all."""
|
||||||
from backend.resources import list_resources
|
from backend.api.resources import list_resources
|
||||||
|
|
||||||
captured_sql: list[str] = []
|
captured_sql: list[str] = []
|
||||||
mock_session = _list_resources_capturing_session(captured_sql)
|
mock_session = _list_resources_capturing_session(captured_sql)
|
||||||
@@ -634,7 +634,7 @@ async def test_list_resources_without_parent_path_adds_no_like_clause() -> None:
|
|||||||
async def test_list_resources_joins_users_for_display_name() -> None:
|
async def test_list_resources_joins_users_for_display_name() -> None:
|
||||||
"""list_resources must OUTER JOIN users and SELECT users.display_name so
|
"""list_resources must OUTER JOIN users and SELECT users.display_name so
|
||||||
every resource carries owner_display_name (frontend displayName chain)."""
|
every resource carries owner_display_name (frontend displayName chain)."""
|
||||||
from backend.resources import list_resources
|
from backend.api.resources import list_resources
|
||||||
|
|
||||||
captured_sql: list[str] = []
|
captured_sql: list[str] = []
|
||||||
mock_session = _list_resources_capturing_session(captured_sql)
|
mock_session = _list_resources_capturing_session(captured_sql)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
from backend.runtime_client import RuntimeClient, RuntimeClientError
|
from backend.clients.runtime import RuntimeClient, RuntimeClientError
|
||||||
|
|
||||||
WORKSPACE_ID = "01HWS0000000000000000000A"
|
WORKSPACE_ID = "01HWS0000000000000000000A"
|
||||||
BASE_URL = "http://runtime"
|
BASE_URL = "http://runtime"
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class _AsyncSessionMock:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_script_record_flushes_storage_object_before_script() -> None:
|
async def test_create_script_record_flushes_storage_object_before_script() -> None:
|
||||||
"""StorageObjects must flush first so path conflicts surface early."""
|
"""StorageObjects must flush first so path conflicts surface early."""
|
||||||
from backend.scripts import create_script_record
|
from backend.api.scripts import create_script_record
|
||||||
|
|
||||||
session = _AsyncSessionMock()
|
session = _AsyncSessionMock()
|
||||||
request = _make_request()
|
request = _make_request()
|
||||||
@@ -133,7 +133,7 @@ async def test_create_script_record_flushes_storage_object_before_script() -> No
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_script_record_storage_object_flush_failure_does_not_add_script() -> None:
|
async def test_create_script_record_storage_object_flush_failure_does_not_add_script() -> None:
|
||||||
"""If the StorageObjects flush fails, the Scripts row must never be added."""
|
"""If the StorageObjects flush fails, the Scripts row must never be added."""
|
||||||
from backend.scripts import create_script_record
|
from backend.api.scripts import create_script_record
|
||||||
|
|
||||||
class FailingSession(_AsyncSessionMock):
|
class FailingSession(_AsyncSessionMock):
|
||||||
async def flush(self) -> None:
|
async def flush(self) -> None:
|
||||||
@@ -172,7 +172,7 @@ async def test_create_script_record_allows_reupload_after_delete() -> None:
|
|||||||
"""Without uk_scripts_workspace_name_active, re-uploading a script with
|
"""Without uk_scripts_workspace_name_active, re-uploading a script with
|
||||||
the same name after the previous one was soft-deleted succeeds.
|
the same name after the previous one was soft-deleted succeeds.
|
||||||
"""
|
"""
|
||||||
from backend.scripts import create_script_record
|
from backend.api.scripts import create_script_record
|
||||||
|
|
||||||
session = _AsyncSessionMock()
|
session = _AsyncSessionMock()
|
||||||
request = _make_request()
|
request = _make_request()
|
||||||
@@ -230,7 +230,7 @@ async def test_create_script_record_allows_same_name_different_parent() -> None:
|
|||||||
must coexist — they correspond to different Jupyter paths
|
must coexist — they correspond to different Jupyter paths
|
||||||
(/user/foo.ipynb vs /user/test/foo.ipynb).
|
(/user/foo.ipynb vs /user/test/foo.ipynb).
|
||||||
"""
|
"""
|
||||||
from backend.scripts import create_script_record
|
from backend.api.scripts import create_script_record
|
||||||
|
|
||||||
session = _AsyncSessionMock()
|
session = _AsyncSessionMock()
|
||||||
request = _make_request()
|
request = _make_request()
|
||||||
@@ -286,7 +286,7 @@ async def test_create_script_after_soft_delete_does_not_conflict() -> None:
|
|||||||
raise IntegrityError — the generated column is NULL for the deleted row,
|
raise IntegrityError — the generated column is NULL for the deleted row,
|
||||||
so it does not occupy the UNIQUE slot.
|
so it does not occupy the UNIQUE slot.
|
||||||
"""
|
"""
|
||||||
from backend.scripts import create_script_record
|
from backend.api.scripts import create_script_record
|
||||||
|
|
||||||
session = _AsyncSessionMock()
|
session = _AsyncSessionMock()
|
||||||
request = _make_request()
|
request = _make_request()
|
||||||
@@ -376,7 +376,7 @@ def _storage_object_row() -> StorageObjects:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_script_route_sets_is_deleted(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_delete_script_route_sets_is_deleted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Soft-deleting a script via the route handler flips is_deleted=1."""
|
"""Soft-deleting a script via the route handler flips is_deleted=1."""
|
||||||
from backend.scripts import delete_script
|
from backend.api.scripts import delete_script
|
||||||
|
|
||||||
script = _script_row()
|
script = _script_row()
|
||||||
storage_object = _storage_object_row()
|
storage_object = _storage_object_row()
|
||||||
@@ -395,7 +395,7 @@ async def test_delete_script_route_sets_is_deleted(monkeypatch: pytest.MonkeyPat
|
|||||||
) -> tuple[Scripts, StorageObjects]:
|
) -> tuple[Scripts, StorageObjects]:
|
||||||
return script, storage_object
|
return script, storage_object
|
||||||
|
|
||||||
monkeypatch.setattr("backend.scripts.get_script_row", _fake_get_script_row)
|
monkeypatch.setattr("backend.api.scripts.get_script_row", _fake_get_script_row)
|
||||||
mock_soft_delete = AsyncMock(
|
mock_soft_delete = AsyncMock(
|
||||||
return_value={
|
return_value={
|
||||||
"data": {
|
"data": {
|
||||||
@@ -406,7 +406,7 @@ async def test_delete_script_route_sets_is_deleted(monkeypatch: pytest.MonkeyPat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("backend.scripts.soft_delete_object", mock_soft_delete)
|
monkeypatch.setattr("backend.api.scripts.soft_delete_object", mock_soft_delete)
|
||||||
|
|
||||||
result = await delete_script(
|
result = await delete_script(
|
||||||
script_id=script.script_id,
|
script_id=script.script_id,
|
||||||
@@ -430,7 +430,7 @@ async def test_delete_script_route_sets_is_deleted(monkeypatch: pytest.MonkeyPat
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_resource_sets_is_deleted(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_delete_resource_sets_is_deleted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Soft-deleting a data resource must write is_deleted=1 on the row."""
|
"""Soft-deleting a data resource must write is_deleted=1 on the row."""
|
||||||
from backend.resources import delete_resource
|
from backend.api.resources import delete_resource
|
||||||
|
|
||||||
resource = DataResources(
|
resource = DataResources(
|
||||||
resource_id="01RES0000000000000000000A",
|
resource_id="01RES0000000000000000000A",
|
||||||
@@ -455,7 +455,7 @@ async def test_delete_resource_sets_is_deleted(monkeypatch: pytest.MonkeyPatch)
|
|||||||
|
|
||||||
with monkeypatch.context() as mp:
|
with monkeypatch.context() as mp:
|
||||||
mp.setattr(
|
mp.setattr(
|
||||||
"backend.resources.soft_delete_object",
|
"backend.api.resources.soft_delete_object",
|
||||||
AsyncMock(return_value={"data": {}}),
|
AsyncMock(return_value={"data": {}}),
|
||||||
)
|
)
|
||||||
result = await delete_resource(
|
result = await delete_resource(
|
||||||
@@ -558,7 +558,7 @@ async def test_soft_delete_object_streams_via_get_stream() -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_jupyter_check_notebook_lock_ignores_deleted_scripts() -> None:
|
async def test_jupyter_check_notebook_lock_ignores_deleted_scripts() -> None:
|
||||||
"""``is_deleted == 0`` filter must hide deleted notebooks from Jupyter checks."""
|
"""``is_deleted == 0`` filter must hide deleted notebooks from Jupyter checks."""
|
||||||
from backend.jupyter import check_notebook_is_locked
|
from backend.api.jupyter import check_notebook_is_locked
|
||||||
|
|
||||||
session = AsyncMock()
|
session = AsyncMock()
|
||||||
session.execute = AsyncMock()
|
session.execute = AsyncMock()
|
||||||
@@ -593,8 +593,8 @@ async def test_update_script_writes_back_storage_object_metadata(
|
|||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from backend.schemas import UpdateScriptRequest
|
from backend.schemas.scripts import UpdateScriptRequest
|
||||||
from backend.scripts import update_script
|
from backend.api.scripts import update_script
|
||||||
|
|
||||||
script = _script_row()
|
script = _script_row()
|
||||||
storage_object = _storage_object_row()
|
storage_object = _storage_object_row()
|
||||||
@@ -624,7 +624,7 @@ async def test_update_script_writes_back_storage_object_metadata(
|
|||||||
) -> tuple[Scripts, StorageObjects]:
|
) -> tuple[Scripts, StorageObjects]:
|
||||||
return script, storage_object
|
return script, storage_object
|
||||||
|
|
||||||
monkeypatch.setattr("backend.scripts.get_script_row", _fake_get_script_row)
|
monkeypatch.setattr("backend.api.scripts.get_script_row", _fake_get_script_row)
|
||||||
|
|
||||||
new_content = '{"cells": [{"cell_type": "code", "source": ["print(1)"]}]}\n'
|
new_content = '{"cells": [{"cell_type": "code", "source": ["print(1)"]}]}\n'
|
||||||
payload = UpdateScriptRequest(content=new_content)
|
payload = UpdateScriptRequest(content=new_content)
|
||||||
@@ -670,8 +670,8 @@ async def test_update_script_jupyter_only_uses_dict_fallback(
|
|||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from backend.schemas import UpdateScriptRequest
|
from backend.schemas.scripts import UpdateScriptRequest
|
||||||
from backend.scripts import update_script
|
from backend.api.scripts import update_script
|
||||||
|
|
||||||
script = _script_row()
|
script = _script_row()
|
||||||
user_id = "01USR0000000000000000000A"
|
user_id = "01USR0000000000000000000A"
|
||||||
@@ -695,7 +695,7 @@ async def test_update_script_jupyter_only_uses_dict_fallback(
|
|||||||
) -> tuple[Scripts, None]:
|
) -> tuple[Scripts, None]:
|
||||||
return script, None
|
return script, None
|
||||||
|
|
||||||
monkeypatch.setattr("backend.scripts.get_script_row", _fake_get_script_row)
|
monkeypatch.setattr("backend.api.scripts.get_script_row", _fake_get_script_row)
|
||||||
|
|
||||||
payload = UpdateScriptRequest(content='{"cells": []}\n')
|
payload = UpdateScriptRequest(content='{"cells": []}\n')
|
||||||
result = await update_script(
|
result = await update_script(
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Unit tests for backend.services.schedules.validate_dag.
|
||||||
|
|
||||||
|
Pure function — no DB, no FastAPI, no fixtures beyond SimpleNamespace
|
||||||
|
stand-ins for the SQLAlchemy rows. The function only reads five
|
||||||
|
attributes: ``node_id``, ``node_key``, ``edge_id``, ``source_node_id``,
|
||||||
|
``target_node_id``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from backend.services.schedules import validate_dag
|
||||||
|
|
||||||
|
|
||||||
|
def _node(node_id: str, node_key: str) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(node_id=node_id, node_key=node_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _edge(edge_id: str, source: str, target: str) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
edge_id=edge_id,
|
||||||
|
source_node_id=source,
|
||||||
|
target_node_id=target,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_nodes_is_rejected_as_dag_empty() -> None:
|
||||||
|
result = validate_dag(nodes=[], edges=[])
|
||||||
|
assert result["valid"] is False
|
||||||
|
assert result["node_count"] == 0
|
||||||
|
assert result["edge_count"] == 0
|
||||||
|
assert result["topological_order"] == []
|
||||||
|
codes = [err["code"] for err in result["errors"]]
|
||||||
|
assert "DAG_EMPTY" in codes
|
||||||
|
|
||||||
|
|
||||||
|
def test_linear_chain_orders_by_node_key() -> None:
|
||||||
|
nodes = [_node("n1", "A"), _node("n2", "B"), _node("n3", "C")]
|
||||||
|
edges = [_edge("e1", "n1", "n2"), _edge("e2", "n2", "n3")]
|
||||||
|
result = validate_dag(nodes, edges)
|
||||||
|
assert result["valid"] is True
|
||||||
|
assert result["root_node_ids"] == ["n1"]
|
||||||
|
assert result["leaf_node_ids"] == ["n3"]
|
||||||
|
assert result["topological_order"] == ["n1", "n2", "n3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_diamond_topology_is_valid() -> None:
|
||||||
|
# A -> B -> D
|
||||||
|
# A -> C -> D
|
||||||
|
nodes = [
|
||||||
|
_node("a", "A"),
|
||||||
|
_node("b", "B"),
|
||||||
|
_node("c", "C"),
|
||||||
|
_node("d", "D"),
|
||||||
|
]
|
||||||
|
edges = [
|
||||||
|
_edge("e1", "a", "b"),
|
||||||
|
_edge("e2", "a", "c"),
|
||||||
|
_edge("e3", "b", "d"),
|
||||||
|
_edge("e4", "c", "d"),
|
||||||
|
]
|
||||||
|
result = validate_dag(nodes, edges)
|
||||||
|
assert result["valid"] is True
|
||||||
|
assert result["root_node_ids"] == ["a"]
|
||||||
|
assert result["leaf_node_ids"] == ["d"]
|
||||||
|
# Kahn's algorithm with node_key tie-breaking: starting at A, then B
|
||||||
|
# and C both become ready (B alphabetically first), then D.
|
||||||
|
assert result["topological_order"] == ["a", "b", "c", "d"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_is_rejected_with_dag_cycle() -> None:
|
||||||
|
# n1 -> n2 -> n3 -> n1
|
||||||
|
nodes = [_node("n1", "A"), _node("n2", "B"), _node("n3", "C")]
|
||||||
|
edges = [
|
||||||
|
_edge("e1", "n1", "n2"),
|
||||||
|
_edge("e2", "n2", "n3"),
|
||||||
|
_edge("e3", "n3", "n1"),
|
||||||
|
]
|
||||||
|
result = validate_dag(nodes, edges)
|
||||||
|
assert result["valid"] is False
|
||||||
|
codes = [err["code"] for err in result["errors"]]
|
||||||
|
assert "DAG_CYCLE" in codes
|
||||||
|
cycle_err = next(err for err in result["errors"] if err["code"] == "DAG_CYCLE")
|
||||||
|
# The cycle should list every node in the cycle (sorted by node_key).
|
||||||
|
assert set(cycle_err["node_ids"]) == {"n1", "n2", "n3"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_edge_is_rejected_but_does_not_count_as_cycle() -> None:
|
||||||
|
nodes = [_node("n1", "A"), _node("n2", "B")]
|
||||||
|
edges = [
|
||||||
|
_edge("e_self", "n1", "n1"),
|
||||||
|
_edge("e_real", "n1", "n2"),
|
||||||
|
]
|
||||||
|
result = validate_dag(nodes, edges)
|
||||||
|
codes = [err["code"] for err in result["errors"]]
|
||||||
|
assert "DAG_SELF_EDGE" in codes
|
||||||
|
# The A->B edge still makes the DAG valid overall except for the self-edge.
|
||||||
|
assert "DAG_CYCLE" not in codes
|
||||||
|
# One node remains reachable (B), so cycle detection must not fire.
|
||||||
|
assert result["topological_order"] == ["n1", "n2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_edge_is_rejected_with_dag_duplicate_edge() -> None:
|
||||||
|
nodes = [_node("n1", "A"), _node("n2", "B")]
|
||||||
|
edges = [
|
||||||
|
_edge("e1", "n1", "n2"),
|
||||||
|
_edge("e1_dup", "n1", "n2"),
|
||||||
|
]
|
||||||
|
result = validate_dag(nodes, edges)
|
||||||
|
codes = [err["code"] for err in result["errors"]]
|
||||||
|
assert "DAG_DUPLICATE_EDGE" in codes
|
||||||
|
# The first edge still counts toward edge_count, the second is rejected.
|
||||||
|
assert result["edge_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_to_unknown_node_is_dag_edge_node_missing() -> None:
|
||||||
|
nodes = [_node("n1", "A")]
|
||||||
|
edges = [
|
||||||
|
_edge("e1", "n1", "ghost"),
|
||||||
|
_edge("e2", "ghost", "n1"),
|
||||||
|
]
|
||||||
|
result = validate_dag(nodes, edges)
|
||||||
|
codes = [err["code"] for err in result["errors"]]
|
||||||
|
assert codes.count("DAG_EDGE_NODE_MISSING") == 2
|
||||||
|
# No cycle should be reported for orphan edges.
|
||||||
|
assert "DAG_CYCLE" not in codes
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_roots_are_sorted_by_node_key() -> None:
|
||||||
|
nodes = [
|
||||||
|
_node("z", "Z"),
|
||||||
|
_node("a", "A"),
|
||||||
|
_node("m", "M"),
|
||||||
|
]
|
||||||
|
edges = []
|
||||||
|
result = validate_dag(nodes, edges)
|
||||||
|
assert result["valid"] is True
|
||||||
|
# All three nodes are roots (no indegree) and leaves (no outgoing).
|
||||||
|
assert result["root_node_ids"] == ["a", "m", "z"]
|
||||||
|
assert result["leaf_node_ids"] == ["a", "m", "z"]
|
||||||
|
# Topological order picks the smallest node_key first.
|
||||||
|
assert result["topological_order"] == ["a", "m", "z"]
|
||||||
Reference in New Issue
Block a user