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
+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(