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:
tao.chen
2026-08-21 15:32:04 +08:00
co-authored by Claude
parent 5501b26628
commit bca239ed4b
36 changed files with 487 additions and 282 deletions
View File
@@ -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,
@@ -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
View File
@@ -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(
+1
View File
@@ -0,0 +1 @@
"""Reserved for stage-3 extraction. Currently empty."""
+12
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Reserved for stage-3 extraction. Currently empty."""
+1
View File
@@ -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)
+35
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
"""Reserved for stage-3 extraction. Currently empty."""
@@ -0,0 +1 @@
"""Reserved for stage-3 extraction. Currently empty."""
+152
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
"""Reserved for stage-3 extraction. Currently empty."""
+7 -7
View File
@@ -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)
+2 -2
View File
@@ -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)
+3 -3
View File
@@ -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] = []
+11 -11
View File
@@ -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"
+17 -17
View File
@@ -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(
+143
View File
@@ -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"]