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.ext.asyncio import AsyncSession
from backend.dependencies import (
from backend.api.dependencies import (
RequestContext,
database_session,
request_context,
@@ -9,7 +9,7 @@ Cookie+JWT authentication endpoints.
The user-facing flow is:
1. POST /api/v1/auth/login verify password, set HttpOnly cookie
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
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.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 开发环境会根据
# 实际请求协议决定是否设置,避免浏览器因 Secure Cookie 而丢弃登录状态。
@@ -96,7 +96,7 @@ def _workspace_payload(
# 校验账号密码,设置登录 Cookie,并返回用户可进入的工作区列表。
@router.post("/api/v1/auth/login")
@router.post("/login")
async def login(
request: Request,
response: Response,
@@ -198,7 +198,7 @@ async def login(
# 清除浏览器 Cookie,使当前会话立即失效。
@router.post("/api/v1/auth/logout")
@router.post("/logout")
async def logout(response: Response) -> dict[str, Any]:
"""Clear the session cookie. Idempotent."""
_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(
request: Request,
session: AsyncSession = Depends(database_session),
@@ -19,8 +19,8 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.dependencies import database_session
from backend.runtime_client import RuntimeClientError
from backend.api.dependencies import database_session
from backend.clients.runtime import RuntimeClientError
# ---------------------------------------------------------------------------
# (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
router = APIRouter(tags=["jupyter"])
router = APIRouter(prefix="/api/v1/auth", tags=["jupyter"])
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 的身份、成员关系和文件锁,
# 再返回应转发到的 Jupyter 地址及内部令牌。
@router.get("/api/v1/auth/jupyter")
@router.get("/jupyter")
async def verify_jupyter_access(
request: Request,
response: Response,
@@ -9,7 +9,7 @@ System-admin (platform-scope) endpoints for workspace & membership management.
All routes under ``/api/v1/platform/*`` are gated by
:func:`system_admin_context`, which requires the requester to hold a
``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
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.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"])
@@ -216,7 +216,7 @@ async def system_admin_context(
"""Resolve the requester as a system admin.
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).
2. Require ``Users.platform_role_id`` to point to a row whose
``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.ext.asyncio import AsyncSession
from backend.dependencies import (
from backend.api.dependencies import (
RequestContext,
database_session,
request_context,
)
from backend.scripts import _escape_like_pattern, normalize_user_path
from backend.schemas import (
from backend.api.scripts import _escape_like_pattern, normalize_user_path
from backend.schemas.common import DownloadUrlRequest
from backend.schemas.resources import (
CompleteResourceUploadRequest,
CreateResourceUploadRequest,
DownloadUrlRequest,
ResourceRelativePathRequest,
)
from backend.services.storage import (
@@ -41,13 +41,13 @@ from pydantic import Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.dependencies import (
from backend.api.dependencies import (
RequestContext,
database_session,
request_context,
)
router = APIRouter(tags=["schedule-runs"])
router = APIRouter(prefix="/api/v1", tags=["schedule-runs"])
RunStatus = Literal[
"queued",
"running",
@@ -238,7 +238,7 @@ async def _artifact_bytes(
# 立即触发一次调度:写入运行记录和 Outbox,由 schedule 容器异步接手执行。
@router.post(
"/api/v1/schedules/{schedule_id}/run",
"/schedules/{schedule_id}/run",
status_code=status.HTTP_202_ACCEPTED,
)
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(
schedule_id: str | None = Query(default=None),
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(
run_id: str,
context: RequestContext = Depends(request_context),
@@ -334,7 +334,7 @@ async def get_schedule_run(
# 返回某个节点运行关联的日志/结果产物元数据及可访问地址。
@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(
run_id: str,
@@ -355,7 +355,7 @@ async def get_schedule_node_run_artifacts(
context=context,
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}"
return {
"request_id": context.request_id,
@@ -377,7 +377,7 @@ async def get_schedule_node_run_artifacts(
# 读取节点运行日志正文,通常由前端日志面板按需调用。
@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(
run_id: str,
@@ -404,7 +404,7 @@ async def read_schedule_node_run_logs(
# 为节点运行结果生成下载响应或重定向地址。
@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(
run_id: str,
@@ -7,7 +7,6 @@
from __future__ import annotations
import heapq
from datetime import UTC, datetime
from decimal import Decimal
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.ext.asyncio import AsyncSession
from backend.dependencies import (
from backend.api.dependencies import (
RequestContext,
database_session,
request_context,
)
from backend.schedule_schemas import (
from backend.schemas.schedules import (
CreateScheduleEdgeRequest,
CreateScheduleNodeRequest,
CreateScheduleRequest,
@@ -47,7 +46,7 @@ from backend.schedule_schemas import (
)
from backend.services.storage import soft_delete_object
router = APIRouter(tags=["schedules"])
router = APIRouter(prefix="/api/v1", tags=["schedules"])
_ACTIVE_RUN_STATUSES = ("queued", "running")
@@ -239,118 +238,11 @@ def edge_payload(item: ScheduleEdges) -> dict[str, Any]:
}
def validate_dag(
nodes: list[ScheduleNodes],
edges: list[ScheduleEdges],
) -> dict[str, Any]:
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,
}
# validate_dag is implemented in backend.services.schedules so it can be
# unit-tested without spinning up FastAPI. Re-exported here for the four
# internal callsites and for any external callers that still import it
# from this module.
from backend.services.schedules import validate_dag # noqa: F401
async def schedule_row(
@@ -521,7 +413,7 @@ async def _require_valid_when_enabled(
# 根据 Cron 表达式预览未来触发时间,不会保存或执行任务。
@router.post("/api/v1/cron/preview")
@router.post("/cron/preview")
async def preview_cron(
payload: CronPreviewRequest,
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(
limit: int = Query(default=100, ge=1, le=500),
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(
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
@@ -652,7 +544,7 @@ async def list_schedules(
# 创建新的 DAG 调度定义;初始状态不包含节点和边。
@router.post(
"/api/v1/schedules",
"/schedules",
status_code=status.HTTP_201_CREATED,
)
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(
schedule_id: str,
context: RequestContext = Depends(request_context),
@@ -719,8 +611,8 @@ async def get_schedule(
# 更新调度基本属性,如名称、Cron、时区、是否启用和并发策略。
@router.put("/api/v1/schedules/{schedule_id}")
@router.patch("/api/v1/schedules/{schedule_id}")
@router.put("/schedules/{schedule_id}")
@router.patch("/schedules/{schedule_id}")
async def update_schedule(
schedule_id: str,
payload: UpdateScheduleRequest,
@@ -784,7 +676,7 @@ async def update_schedule(
# 删除调度定义;请求携带 workflow_version 以避免误删他人刚修改的画布。
@router.delete("/api/v1/schedules/{schedule_id}")
@router.delete("/schedules/{schedule_id}")
async def delete_schedule(
schedule_id: str,
payload: WorkflowVersionRequest,
@@ -876,7 +768,7 @@ async def delete_schedule(
# 向调度画布新增一个执行节点,并关联已发布的脚本版本。
@router.post(
"/api/v1/schedules/{schedule_id}/nodes",
"/schedules/{schedule_id}/nodes",
status_code=status.HTTP_201_CREATED,
)
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(
schedule_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(
schedule_id: str,
node_id: str,
@@ -1072,7 +964,7 @@ async def delete_schedule_node(
# 在两个节点之间新增依赖边,表示目标节点必须等待源节点完成。
@router.post(
"/api/v1/schedules/{schedule_id}/edges",
"/schedules/{schedule_id}/edges",
status_code=status.HTTP_201_CREATED,
)
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(
schedule_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(
schedule_id: str,
edge_id: str,
@@ -1218,7 +1110,7 @@ async def delete_schedule_edge(
# 校验画布是否为可执行 DAG,例如是否存在环、孤立节点或无效版本。
@router.post("/api/v1/schedules/{schedule_id}/validate")
@router.post("/schedules/{schedule_id}/validate")
async def validate_schedule(
schedule_id: str,
context: RequestContext = Depends(request_context),
@@ -39,16 +39,16 @@ from loguru import logger
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.dependencies import (
from backend.api.dependencies import (
RequestContext,
database_session,
request_context,
)
from backend.runtime_client import RuntimeClientError
from backend.schemas import (
from backend.clients.runtime import RuntimeClientError
from backend.schemas.common import DownloadUrlRequest
from backend.schemas.scripts import (
CreateScriptRequest,
CreateWorkspaceDirectoryRequest,
DownloadUrlRequest,
LockScriptRequest,
PublishVersionRequest,
UpdateScriptRequest,
@@ -60,7 +60,7 @@ from backend.services.storage import (
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:
@@ -640,7 +640,7 @@ async def create_script_record(
# 新建空的 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(
payload: CreateScriptRequest,
request: Request,
@@ -674,7 +674,7 @@ async def create_script(
# 上传现有脚本文件:校验文件名/类型后写入存储,并建立 Scripts 记录。
@router.post(
"/api/v1/scripts/upload",
"/scripts/upload",
status_code=status.HTTP_201_CREATED,
)
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(
context: RequestContext = Depends(request_context),
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(
parent_path: str = Query(default=""),
context: RequestContext = Depends(request_context),
@@ -881,7 +881,7 @@ async def list_workspace_directories(
# 在工作区内创建逻辑目录;目录信息由脚本相对路径推导,不对应容器本地文件夹。
@router.post(
"/api/v1/workspace-directories",
"/workspace-directories",
status_code=status.HTTP_201_CREATED,
)
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(
request: Request,
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
# 索引(基线迁移里有 ``idx_storage_workspace_relative_path`` 但 ORM
# 模型未声明,不在此修复范围)。
@router.get("/api/v1/scripts")
@router.get("/scripts")
async def list_scripts(
parent_path: str = Query(default="", max_length=1024),
context: RequestContext = Depends(request_context),
@@ -1198,7 +1198,7 @@ async def list_scripts(
# - Workspace-wide ``LIKE 'workspace/%'`` prefix (no embedded user_id) so
# counts span every owner's subtree.
# - No NOT-LIKE filter because the count wants descendants too.
@router.get("/api/v1/scripts/count")
@router.get("/scripts/count")
async def count_scripts(
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
@@ -1233,7 +1233,7 @@ async def count_scripts(
# 读取脚本正文或 Notebook JSON;编辑器打开文件时调用此接口。
@router.get("/api/v1/scripts/{script_id}/content")
@router.get("/scripts/{script_id}/content")
async def get_script_content(
script_id: str,
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(
script_id: str,
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(
script_id: str,
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(
script_id: str,
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(
script_id: str,
request: Request,
@@ -1480,7 +1480,7 @@ async def delete_script(
# 将当前脚本内容发布为不可变版本,供调度节点和回溯下载使用。
@router.post(
"/api/v1/scripts/{script_id}/versions",
"/scripts/{script_id}/versions",
status_code=status.HTTP_201_CREATED,
)
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(
script_id: str,
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(
script_id: str,
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(
versions_id: str,
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(
versions_id: str,
context: RequestContext = Depends(request_context),
@@ -1740,7 +1740,7 @@ async def delete_version(
# 为某个版本产物生成带时效的下载地址,而非把大文件直接经 API 返回。
@router.post("/api/v1/versions/{versions_id}/download-url")
@router.post("/versions/{versions_id}/download-url")
async def version_download_url(
versions_id: str,
payload: DownloadUrlRequest,
@@ -143,7 +143,7 @@ def storage_payload(item: StorageObjects) -> dict[str, Any]:
# 内部路由由 main.py 以 /internal 前缀挂载。数据库引擎、Session 工厂和对象
# 存储实例均在应用生命周期中创建;本模块只定义路由和供 services.storage 复用的
# 存储辅助函数(如 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]:
@@ -253,7 +253,7 @@ async def create_upload_record(
# Two-step server-proxied upload: the caller PUTs the raw bytes to
# ``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``).
return {
"upload_id": upload.upload_id,
@@ -286,7 +286,7 @@ def _public_base_url(request: Request) -> str:
@router.post(
"/v1/objects",
"/objects",
dependencies=[Depends(require_internal_service)],
)
async def create_server_object(
@@ -304,7 +304,7 @@ async def create_server_object(
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(
storage_object_id: str,
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(
payload: dict[str, Any],
request: Request,
@@ -94,7 +94,7 @@ class RuntimeClient:
"""Return a running workspace descriptor, starting it if needed.
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
running. Bumps ``last_used_at`` via the runtime registry on 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 backend.audit import configure_audit_logging
from backend.admin import router as admin_router
from backend.auth import router as auth_router
from backend.jupyter import router as jupyter_router
from backend.platform import router as platform_router
from backend.rclone_rc_client import RcloneRCClient
from backend.resources import router as resources_router
from backend.runtime_client import RuntimeClient
from backend.schedule_runs import router as schedule_runs_router
from backend.schedules import router as schedules_router
from backend.scripts import router as scripts_router
from backend.storage_api import router as storage_api_router
from backend.api.admin import router as admin_router
from backend.api.auth import router as auth_router
from backend.api.jupyter import router as jupyter_router
from backend.api.platform import router as platform_router
from backend.api.resources import router as resources_router
from backend.api.scripts import router as scripts_router
from backend.api.schedules.runs import router as schedule_runs_router
from backend.api.schedules.schedules import router as schedules_router
from backend.api.storage import router as storage_api_router
from backend.clients.rclone import RcloneRCClient
from backend.clients.runtime import RuntimeClient
configure_logging(settings.log_level)
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"
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):
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.
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
schedule worker call these helpers directly instead of going through an
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``.
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
``upload.upload_status = "failed"; raise HTTPException(...)`` would
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,
) -> str:
"""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:
return workspace_artifact_bucket
return BUCKET_FOR_USAGE.get(
@@ -298,7 +298,7 @@ async def create_upload_record(
session; or ``{upload_id, status: "completed", storage_object: {...}}``
when the idempotency key hits an already-completed upload.
"""
from backend.storage_api import (
from backend.api.storage import (
normalized_idempotency_key,
require_workspace_member,
)
@@ -352,7 +352,7 @@ async def create_upload_record(
else:
from datetime import timedelta
from backend.storage_api import utcnow
from backend.api.storage import utcnow
bucket_name = _resolve_bucket_for_usage(
payload.usage_type,
@@ -384,7 +384,7 @@ async def create_upload_record(
await session.flush()
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)
if storage_object is None or storage_object.object_status != "available":
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
schedule worker for run logs / run results.
"""
from backend.storage_api import storage_payload
from backend.api.storage import storage_payload
try:
content = base64.b64decode(payload.content_base64, validate=True)
+2 -2
View File
@@ -15,7 +15,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from backend.scripts import count_scripts
from backend.api.scripts import count_scripts
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
scripts.py before `/api/v1/scripts/{script_id}/...`, otherwise FastAPI's
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(get_script)
+3 -3
View File
@@ -18,12 +18,12 @@ from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
import backend.jupyter as jupyter_module
from backend.jupyter import (
import backend.api.jupyter as jupyter_module
from backend.api.jupyter import (
_JUPYTER_AUTH_CACHE,
verify_jupyter_access,
)
from backend.runtime_client import RuntimeClientError
from backend.clients.runtime import RuntimeClientError
WS_ID = "01WS0000000000000000000A"
USER_ID = "01USR0000000000000000000A"
@@ -27,7 +27,7 @@ from fastapi import HTTPException
from sqlalchemy import Column, MetaData, String, Table, create_engine, select, text
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_workspace_descendant_prefix,
_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:
from backend.scripts import list_scripts
from backend.api.scripts import list_scripts
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:
"""Regression: parent_path containing ``_`` MUST be escaped in the
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] = []
@@ -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:
"""Same regression for ``%``."""
from backend.scripts import list_scripts
from backend.api.scripts import list_scripts
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:
owner_user_id = me OR visibility IN (workspace, public) — exactly like
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] = []
@@ -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:
"""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] = []
@@ -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:
"""list_workspace_directories must escape user input too (was
pre-existing debt)."""
from backend.scripts import list_workspace_directories
from backend.api.scripts import list_workspace_directories
captured_sql: list[str] = []
+11 -11
View File
@@ -13,7 +13,7 @@ import pytest
from fastapi import HTTPException
from sqlalchemy import Column, MetaData, String, Table, create_engine, select
from sqlalchemy.dialects import mysql as mysql_dialect
from backend.resources import (
from backend.api.resources import (
_build_list_resources_descendant_prefix,
can_view,
compute_jupyter_relative_path,
@@ -126,7 +126,7 @@ def _bind_payload():
@pytest.mark.asyncio
async def test_bind_resource_rejects_duplicate_name_in_same_directory() -> None:
"""Same resource_name in the same directory raises 409."""
from backend.resources import bind_resource
from backend.api.resources import bind_resource
existing_rows = [
(
@@ -153,7 +153,7 @@ async def test_bind_resource_rejects_duplicate_name_in_same_directory() -> None:
@pytest.mark.asyncio
async def test_bind_resource_allows_same_name_in_different_directory() -> None:
"""Same resource_name in a different directory binds successfully."""
from backend.resources import bind_resource
from backend.api.resources import bind_resource
existing_rows = [
(
@@ -179,7 +179,7 @@ async def test_bind_resource_allows_same_name_in_different_directory() -> None:
@pytest.mark.asyncio
async def test_bind_resource_allows_same_name_when_workspace_empty() -> None:
"""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(
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
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"
existing_rows = [
@@ -223,7 +223,7 @@ async def test_bind_resource_allows_same_name_for_different_owner() -> None:
@pytest.mark.asyncio
async def test_bind_resource_allows_rebinding_same_storage_object() -> None:
"""重新绑定同一 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"
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
async def test_bind_resource_rejects_non_data_resource_upload() -> None:
"""其他用途(如 working_copy)的 upload session 不能 bind 成数据资源。"""
from backend.resources import bind_resource
from backend.api.resources import bind_resource
session = _BindSessionMock(
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:
from backend.resources import list_resources
from backend.api.resources import list_resources
captured_sql: list[str] = []
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:
"""Regression: parent_path containing ``_`` MUST be escaped in the
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] = []
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:
"""Empty parent_path keeps the legacy workspace-wide behaviour — no
object_key LIKE filter at all."""
from backend.resources import list_resources
from backend.api.resources import list_resources
captured_sql: list[str] = []
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:
"""list_resources must OUTER JOIN users and SELECT users.display_name so
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] = []
mock_session = _list_resources_capturing_session(captured_sql)
@@ -15,7 +15,7 @@ from __future__ import annotations
import httpx
import pytest
import respx
from backend.runtime_client import RuntimeClient, RuntimeClientError
from backend.clients.runtime import RuntimeClient, RuntimeClientError
WORKSPACE_ID = "01HWS0000000000000000000A"
BASE_URL = "http://runtime"
+17 -17
View File
@@ -103,7 +103,7 @@ class _AsyncSessionMock:
@pytest.mark.asyncio
async def test_create_script_record_flushes_storage_object_before_script() -> None:
"""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()
request = _make_request()
@@ -133,7 +133,7 @@ async def test_create_script_record_flushes_storage_object_before_script() -> No
@pytest.mark.asyncio
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."""
from backend.scripts import create_script_record
from backend.api.scripts import create_script_record
class FailingSession(_AsyncSessionMock):
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
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()
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
(/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()
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,
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()
request = _make_request()
@@ -376,7 +376,7 @@ def _storage_object_row() -> StorageObjects:
@pytest.mark.asyncio
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."""
from backend.scripts import delete_script
from backend.api.scripts import delete_script
script = _script_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]:
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(
return_value={
"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(
script_id=script.script_id,
@@ -430,7 +430,7 @@ async def test_delete_script_route_sets_is_deleted(monkeypatch: pytest.MonkeyPat
@pytest.mark.asyncio
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."""
from backend.resources import delete_resource
from backend.api.resources import delete_resource
resource = DataResources(
resource_id="01RES0000000000000000000A",
@@ -455,7 +455,7 @@ async def test_delete_resource_sets_is_deleted(monkeypatch: pytest.MonkeyPatch)
with monkeypatch.context() as mp:
mp.setattr(
"backend.resources.soft_delete_object",
"backend.api.resources.soft_delete_object",
AsyncMock(return_value={"data": {}}),
)
result = await delete_resource(
@@ -558,7 +558,7 @@ async def test_soft_delete_object_streams_via_get_stream() -> None:
@pytest.mark.asyncio
async def test_jupyter_check_notebook_lock_ignores_deleted_scripts() -> None:
"""``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.execute = AsyncMock()
@@ -593,8 +593,8 @@ async def test_update_script_writes_back_storage_object_metadata(
"""
import hashlib
from backend.schemas import UpdateScriptRequest
from backend.scripts import update_script
from backend.schemas.scripts import UpdateScriptRequest
from backend.api.scripts import update_script
script = _script_row()
storage_object = _storage_object_row()
@@ -624,7 +624,7 @@ async def test_update_script_writes_back_storage_object_metadata(
) -> tuple[Scripts, StorageObjects]:
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'
payload = UpdateScriptRequest(content=new_content)
@@ -670,8 +670,8 @@ async def test_update_script_jupyter_only_uses_dict_fallback(
"""
import hashlib
from backend.schemas import UpdateScriptRequest
from backend.scripts import update_script
from backend.schemas.scripts import UpdateScriptRequest
from backend.api.scripts import update_script
script = _script_row()
user_id = "01USR0000000000000000000A"
@@ -695,7 +695,7 @@ async def test_update_script_jupyter_only_uses_dict_fallback(
) -> tuple[Scripts, 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')
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"]