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>
274 lines
9.3 KiB
Python
274 lines
9.3 KiB
Python
"""Unit tests for the three new directory methods on RuntimeClient.
|
|
|
|
Covers:
|
|
- `create_directory` — PUT with `{"type": "directory"}` body.
|
|
- `delete_directory` — DELETE, surfaces Jupyter's 409 on non-empty dirs.
|
|
- `ensure_directory` — GET-first, falls back to `create_directory` on 404.
|
|
|
|
Uses `respx` to mock httpx transport so we don't need a live Jupyter.
|
|
The runtime descriptor (`get_workspace`) is patched to a synchronous return
|
|
so `_ensure_workspace` short-circuits without hitting the Runtime service.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
from backend.clients.runtime import RuntimeClient, RuntimeClientError
|
|
|
|
WORKSPACE_ID = "01HWS0000000000000000000A"
|
|
BASE_URL = "http://runtime"
|
|
PORT = 34567
|
|
TOKEN = "test-token"
|
|
JUPYTER_URL = f"{BASE_URL}:{PORT}/jupyter/{WORKSPACE_ID}/api/contents"
|
|
|
|
|
|
def _running_descriptor() -> dict:
|
|
return {
|
|
"status": "running",
|
|
"workspace_id": WORKSPACE_ID,
|
|
"base_url": BASE_URL,
|
|
"port": PORT,
|
|
"token": TOKEN,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(timeout=httpx.Timeout(5.0))
|
|
|
|
|
|
@pytest.fixture
|
|
def runtime(client: httpx.AsyncClient) -> RuntimeClient:
|
|
rt = RuntimeClient(client)
|
|
# Bypass the real `_ensure_workspace` so tests don't have to mock the
|
|
# Runtime service. The descriptor is otherwise identical to what the
|
|
# production path returns.
|
|
rt._ensure_workspace = _ensure_workspace_stub # type: ignore[assignment]
|
|
return rt
|
|
|
|
|
|
async def _ensure_workspace_stub(workspace_id: str) -> dict:
|
|
return _running_descriptor()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# create_directory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_create_directory_happy_path(runtime: RuntimeClient) -> None:
|
|
with respx.mock(assert_all_called=True) as router:
|
|
route = router.put(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock(
|
|
return_value=httpx.Response(
|
|
201,
|
|
json={
|
|
"name": "01DIRAAAAAAAAAAAAAAA",
|
|
"type": "directory",
|
|
"path": "01DIRAAAAAAAAAAAAAAA",
|
|
},
|
|
)
|
|
)
|
|
|
|
async with httpx.AsyncClient() as transport:
|
|
runtime.client = transport # type: ignore[assignment]
|
|
result = await runtime.create_directory(
|
|
WORKSPACE_ID, name="01DIRAAAAAAAAAAAAAAA"
|
|
)
|
|
|
|
assert route.called
|
|
assert result["type"] == "directory"
|
|
# Verify request body shape (PUT contents/ directory).
|
|
request = route.calls[0].request
|
|
assert request.headers["Authorization"] == f"token {TOKEN}"
|
|
assert request.headers["Content-Type"] == "application/json"
|
|
assert request.content == b'{"type":"directory"}'
|
|
|
|
|
|
async def test_create_directory_nested_path(runtime: RuntimeClient) -> None:
|
|
"""Nested path `{parent_ulid}/{dir_ulid}` lands on Jupyter correctly."""
|
|
nested = "01DIR_PARENT_ULID/01DIR_CHILD_ULID"
|
|
with respx.mock(assert_all_called=True) as router:
|
|
route = router.put(f"{JUPYTER_URL}/{nested}").mock(
|
|
return_value=httpx.Response(
|
|
201, json={"name": "01DIR_CHILD_ULID", "type": "directory"}
|
|
)
|
|
)
|
|
|
|
async with httpx.AsyncClient() as transport:
|
|
runtime.client = transport # type: ignore[assignment]
|
|
await runtime.create_directory(WORKSPACE_ID, name=nested)
|
|
|
|
assert route.called
|
|
|
|
|
|
async def test_create_directory_propagates_jupyter_4xx(
|
|
runtime: RuntimeClient,
|
|
) -> None:
|
|
with respx.mock() as router:
|
|
put_route = router.put(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock(
|
|
return_value=httpx.Response(
|
|
400,
|
|
json={
|
|
"detail": {
|
|
"code": "BAD_REQUEST",
|
|
"message": "invalid name",
|
|
}
|
|
},
|
|
)
|
|
)
|
|
|
|
async with httpx.AsyncClient() as transport:
|
|
runtime.client = transport # type: ignore[assignment]
|
|
with pytest.raises(RuntimeClientError) as exc_info:
|
|
await runtime.create_directory(
|
|
WORKSPACE_ID, name="01DIRAAAAAAAAAAAAAAA"
|
|
)
|
|
|
|
assert put_route.called
|
|
assert exc_info.value.status_code == 400
|
|
# `_jupyter_request` surfaces the whole JSON body as `detail` — the
|
|
# inner `detail` envelope is preserved verbatim.
|
|
assert exc_info.value.detail == {
|
|
"detail": {
|
|
"code": "BAD_REQUEST",
|
|
"message": "invalid name",
|
|
}
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# delete_directory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_delete_directory_happy_path(runtime: RuntimeClient) -> None:
|
|
with respx.mock(assert_all_called=True) as router:
|
|
route = router.delete(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock(
|
|
return_value=httpx.Response(204)
|
|
)
|
|
|
|
async with httpx.AsyncClient() as transport:
|
|
runtime.client = transport # type: ignore[assignment]
|
|
result = await runtime.delete_directory(
|
|
WORKSPACE_ID, name="01DIRAAAAAAAAAAAAAAA"
|
|
)
|
|
|
|
assert route.called
|
|
assert result is None
|
|
|
|
|
|
async def test_delete_directory_non_empty_409(runtime: RuntimeClient) -> None:
|
|
"""Jupyter rejects non-empty directory deletes with 409; surface as-is."""
|
|
with respx.mock() as router:
|
|
router.delete(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock(
|
|
return_value=httpx.Response(
|
|
409,
|
|
json={
|
|
"detail": {
|
|
"code": "DIRECTORY_NOT_EMPTY",
|
|
"message": "Directory is not empty",
|
|
}
|
|
},
|
|
)
|
|
)
|
|
|
|
async with httpx.AsyncClient() as transport:
|
|
runtime.client = transport # type: ignore[assignment]
|
|
with pytest.raises(RuntimeClientError) as exc_info:
|
|
await runtime.delete_directory(
|
|
WORKSPACE_ID, name="01DIRAAAAAAAAAAAAAAA"
|
|
)
|
|
|
|
assert exc_info.value.status_code == 409
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ensure_directory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_ensure_directory_already_exists(runtime: RuntimeClient) -> None:
|
|
"""GET succeeds → no PUT. The lazy-backfill is a no-op."""
|
|
put_called = False
|
|
|
|
def _track_put(request: httpx.Request) -> httpx.Response:
|
|
nonlocal put_called
|
|
put_called = True
|
|
return httpx.Response(201, json={})
|
|
|
|
with respx.mock(assert_all_called=False) as router:
|
|
router.get(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"name": "01DIRAAAAAAAAAAAAAAA", "type": "directory"},
|
|
)
|
|
)
|
|
router.put(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock(
|
|
side_effect=_track_put
|
|
)
|
|
|
|
async with httpx.AsyncClient() as transport:
|
|
runtime.client = transport # type: ignore[assignment]
|
|
await runtime.ensure_directory(
|
|
WORKSPACE_ID, "01DIRAAAAAAAAAAAAAAA"
|
|
)
|
|
|
|
assert not put_called, "PUT must not be issued when GET already shows the dir exists"
|
|
|
|
|
|
async def test_ensure_directory_missing_creates_it(
|
|
runtime: RuntimeClient,
|
|
) -> None:
|
|
"""GET 404 → PUT. The lazy-backfill creates the missing directory."""
|
|
with respx.mock(assert_all_called=True) as router:
|
|
get_route = router.get(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock(
|
|
return_value=httpx.Response(
|
|
404, json={"detail": {"code": "NOT_FOUND"}}
|
|
)
|
|
)
|
|
put_route = router.put(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock(
|
|
return_value=httpx.Response(
|
|
201, json={"type": "directory"}
|
|
)
|
|
)
|
|
|
|
async with httpx.AsyncClient() as transport:
|
|
runtime.client = transport # type: ignore[assignment]
|
|
await runtime.ensure_directory(
|
|
WORKSPACE_ID, "01DIRAAAAAAAAAAAAAAA"
|
|
)
|
|
|
|
assert get_route.called
|
|
assert put_route.called
|
|
|
|
|
|
async def test_ensure_directory_propagates_non_404_error(
|
|
runtime: RuntimeClient,
|
|
) -> None:
|
|
"""GET 500 → propagate; do NOT fall through to PUT."""
|
|
put_called = False
|
|
|
|
def _track_put(request: httpx.Request) -> httpx.Response:
|
|
nonlocal put_called
|
|
put_called = True
|
|
return httpx.Response(201, json={})
|
|
|
|
with respx.mock(assert_all_called=False) as router:
|
|
router.get(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock(
|
|
return_value=httpx.Response(500, text="internal error")
|
|
)
|
|
router.put(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock(
|
|
side_effect=_track_put
|
|
)
|
|
|
|
async with httpx.AsyncClient() as transport:
|
|
runtime.client = transport # type: ignore[assignment]
|
|
with pytest.raises(RuntimeClientError) as exc_info:
|
|
await runtime.ensure_directory(
|
|
WORKSPACE_ID, "01DIRAAAAAAAAAAAAAAA"
|
|
)
|
|
|
|
assert exc_info.value.status_code == 500
|
|
assert not put_called, "PUT must not be issued after a non-404 GET error" |