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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user