Skip to content

Commit bd89ad5

Browse files
authored
Merge pull request #1835 from transformerlab/fix/notes-revamp
Revamp experiment notes fully
2 parents 2c0e948 + 74770a8 commit bd89ad5

12 files changed

Lines changed: 15228 additions & 9492 deletions

File tree

api/test/test_notes_helpers.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import asyncio
2+
from unittest.mock import AsyncMock, MagicMock, patch
3+
4+
5+
def _make_open_mock(content: str):
6+
"""Return a mock async context manager that reads `content`."""
7+
mock_file = AsyncMock()
8+
mock_file.read = AsyncMock(return_value=content)
9+
mock_file.write = AsyncMock()
10+
cm = AsyncMock()
11+
cm.__aenter__ = AsyncMock(return_value=mock_file)
12+
cm.__aexit__ = AsyncMock(return_value=False)
13+
return cm, mock_file
14+
15+
16+
def test_read_notes_returns_new_path_when_exists():
17+
"""read_notes returns notes/readme.md content when it exists."""
18+
19+
async def _run():
20+
from transformerlab.routers.experiment.notes import read_notes
21+
22+
open_cm, mock_file = _make_open_mock("# my notes")
23+
with (
24+
patch("transformerlab.routers.experiment.notes.Experiment") as mock_exp_cls,
25+
patch("transformerlab.routers.experiment.notes.storage") as mock_storage,
26+
):
27+
mock_exp = MagicMock()
28+
mock_exp.get_dir = AsyncMock(return_value="/experiments/exp1")
29+
mock_exp_cls.return_value = mock_exp
30+
mock_storage.join.side_effect = lambda *parts: "/".join(parts)
31+
mock_storage.open = AsyncMock(return_value=open_cm)
32+
result = await read_notes("exp1")
33+
assert result == "# my notes"
34+
mock_storage.open.assert_called_once_with("/experiments/exp1/notes/readme.md", "r", encoding="utf-8")
35+
36+
asyncio.run(_run())
37+
38+
39+
def test_read_notes_falls_back_to_legacy():
40+
"""read_notes falls back to root readme.md when notes/readme.md is missing."""
41+
42+
async def _run():
43+
from transformerlab.routers.experiment.notes import read_notes
44+
45+
legacy_cm, mock_file = _make_open_mock("# legacy notes")
46+
call_count = 0
47+
48+
async def open_side_effect(path, mode, **kwargs):
49+
nonlocal call_count
50+
call_count += 1
51+
if call_count == 1:
52+
raise FileNotFoundError
53+
return legacy_cm
54+
55+
with (
56+
patch("transformerlab.routers.experiment.notes.Experiment") as mock_exp_cls,
57+
patch("transformerlab.routers.experiment.notes.storage") as mock_storage,
58+
):
59+
mock_exp = MagicMock()
60+
mock_exp.get_dir = AsyncMock(return_value="/experiments/exp1")
61+
mock_exp_cls.return_value = mock_exp
62+
mock_storage.join.side_effect = lambda *parts: "/".join(parts)
63+
mock_storage.open.side_effect = open_side_effect
64+
result = await read_notes("exp1")
65+
assert result == "# legacy notes"
66+
67+
asyncio.run(_run())
68+
69+
70+
def test_read_notes_returns_empty_when_neither_exists():
71+
"""read_notes returns empty string when both paths are missing."""
72+
73+
async def _run():
74+
from transformerlab.routers.experiment.notes import read_notes
75+
76+
async def open_always_raises(path, mode, **kwargs):
77+
raise FileNotFoundError
78+
79+
with (
80+
patch("transformerlab.routers.experiment.notes.Experiment") as mock_exp_cls,
81+
patch("transformerlab.routers.experiment.notes.storage") as mock_storage,
82+
):
83+
mock_exp = MagicMock()
84+
mock_exp.get_dir = AsyncMock(return_value="/experiments/exp1")
85+
mock_exp_cls.return_value = mock_exp
86+
mock_storage.join.side_effect = lambda *parts: "/".join(parts)
87+
mock_storage.open.side_effect = open_always_raises
88+
result = await read_notes("exp1")
89+
assert result == ""
90+
91+
asyncio.run(_run())
92+
93+
94+
def test_migrate_if_needed_moves_legacy_file():
95+
"""migrate_if_needed copies readme.md → notes/readme.md and removes the original."""
96+
97+
async def _run():
98+
from transformerlab.routers.experiment.notes import migrate_if_needed
99+
100+
read_cm, _ = _make_open_mock("# old notes")
101+
write_cm, write_file = _make_open_mock("")
102+
103+
open_calls = []
104+
105+
async def open_side_effect(path, mode, **kwargs):
106+
open_calls.append((path, mode))
107+
if mode == "r":
108+
return read_cm
109+
return write_cm
110+
111+
with (
112+
patch("transformerlab.routers.experiment.notes.Experiment") as mock_exp_cls,
113+
patch("transformerlab.routers.experiment.notes.storage") as mock_storage,
114+
):
115+
mock_exp = MagicMock()
116+
mock_exp.get_dir = AsyncMock(return_value="/experiments/exp1")
117+
mock_exp_cls.return_value = mock_exp
118+
mock_storage.join.side_effect = lambda *parts: "/".join(parts)
119+
mock_storage.exists = AsyncMock(side_effect=[False, True]) # notes missing, legacy exists
120+
mock_storage.makedirs = AsyncMock()
121+
mock_storage.open.side_effect = open_side_effect
122+
mock_storage.rm = AsyncMock()
123+
await migrate_if_needed("exp1")
124+
mock_storage.makedirs.assert_called_once()
125+
write_file.write.assert_called_once_with("# old notes")
126+
mock_storage.rm.assert_called_once_with("/experiments/exp1/readme.md")
127+
128+
asyncio.run(_run())
129+
130+
131+
def test_migrate_if_needed_skips_when_notes_already_exist():
132+
"""migrate_if_needed does nothing when notes/readme.md already exists."""
133+
134+
async def _run():
135+
from transformerlab.routers.experiment.notes import migrate_if_needed
136+
137+
with (
138+
patch("transformerlab.routers.experiment.notes.Experiment") as mock_exp_cls,
139+
patch("transformerlab.routers.experiment.notes.storage") as mock_storage,
140+
):
141+
mock_exp = MagicMock()
142+
mock_exp.get_dir = AsyncMock(return_value="/experiments/exp1")
143+
mock_exp_cls.return_value = mock_exp
144+
mock_storage.join.side_effect = lambda *parts: "/".join(parts)
145+
mock_storage.exists = AsyncMock(return_value=True) # notes already exists
146+
mock_storage.rm = AsyncMock()
147+
await migrate_if_needed("exp1")
148+
mock_storage.rm.assert_not_called()
149+
150+
asyncio.run(_run())

api/transformerlab/routers/experiment/experiment.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from transformerlab.routers.experiment import (
1212
documents,
1313
jobs,
14+
notes,
1415
task as task_router,
1516
)
1617
from transformerlab.routers.auth import get_user_and_team
@@ -39,6 +40,12 @@
3940
tags=["task"],
4041
dependencies=[Depends(require_permission("experiment", "read", id_param="experimentId"))],
4142
)
43+
router.include_router(
44+
router=notes.router,
45+
prefix="/{experimentId}",
46+
tags=["notes"],
47+
dependencies=[Depends(require_permission("experiment", "read", id_param="experimentId"))],
48+
)
4249

4350

4451
@router.get("/", summary="Get all Experiments", tags=["experiment"])
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import logging
2+
import os
3+
from typing import Annotated
4+
5+
from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile
6+
from fastapi.responses import Response
7+
from werkzeug.utils import secure_filename
8+
9+
from lab import Experiment, storage
10+
from transformerlab.services.permission_service import require_permission
11+
12+
logger = logging.getLogger(__name__)
13+
14+
router = APIRouter(prefix="/notes", tags=["notes"])
15+
16+
ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg"}
17+
MAX_ASSET_BYTES = 20 * 1024 * 1024
18+
19+
IMAGE_MEDIA_TYPES = {
20+
".png": "image/png",
21+
".jpg": "image/jpeg",
22+
".jpeg": "image/jpeg",
23+
".gif": "image/gif",
24+
".svg": "image/svg+xml",
25+
}
26+
27+
28+
async def read_notes(experimentId: str) -> str:
29+
"""Read notes content, falling back to legacy readme.md at experiment root."""
30+
exp_obj = Experiment(experimentId)
31+
experiment_dir = await exp_obj.get_dir()
32+
notes_file = storage.join(experiment_dir, "notes", "readme.md")
33+
34+
try:
35+
async with await storage.open(notes_file, "r", encoding="utf-8") as f:
36+
return await f.read()
37+
except FileNotFoundError:
38+
pass
39+
40+
legacy_file = storage.join(experiment_dir, "readme.md")
41+
try:
42+
async with await storage.open(legacy_file, "r", encoding="utf-8") as f:
43+
return await f.read()
44+
except FileNotFoundError:
45+
return ""
46+
47+
48+
async def migrate_if_needed(experimentId: str) -> None:
49+
"""Move legacy readme.md → notes/readme.md on first save if needed."""
50+
exp_obj = Experiment(experimentId)
51+
experiment_dir = await exp_obj.get_dir()
52+
notes_dir = storage.join(experiment_dir, "notes")
53+
notes_file = storage.join(notes_dir, "readme.md")
54+
legacy_file = storage.join(experiment_dir, "readme.md")
55+
56+
if await storage.exists(notes_file):
57+
return
58+
59+
if not await storage.exists(legacy_file):
60+
return
61+
62+
async with await storage.open(legacy_file, "r", encoding="utf-8") as f:
63+
content = await f.read()
64+
65+
await storage.makedirs(notes_dir, exist_ok=True)
66+
67+
async with await storage.open(notes_file, "w", encoding="utf-8") as f:
68+
await f.write(content)
69+
70+
await storage.rm(legacy_file)
71+
72+
73+
@router.get("")
74+
async def get_notes(experimentId: str):
75+
return await read_notes(experimentId)
76+
77+
78+
@router.post("")
79+
async def save_notes(
80+
experimentId: str,
81+
file_contents: Annotated[str, Body()],
82+
_: None = Depends(require_permission("experiment", "write", id_param="experimentId")),
83+
):
84+
await migrate_if_needed(experimentId)
85+
exp_obj = Experiment(experimentId)
86+
experiment_dir = await exp_obj.get_dir()
87+
notes_dir = storage.join(experiment_dir, "notes")
88+
notes_file = storage.join(notes_dir, "readme.md")
89+
await storage.makedirs(notes_dir, exist_ok=True)
90+
# Write a space instead of empty string — some storage backends treat empty files as deletions
91+
async with await storage.open(notes_file, "w", encoding="utf-8") as f:
92+
await f.write(file_contents if file_contents.strip() else " ")
93+
return {"message": "OK"}
94+
95+
96+
@router.post("/assets")
97+
async def upload_asset(
98+
experimentId: str,
99+
file: UploadFile,
100+
_: None = Depends(require_permission("experiment", "write", id_param="experimentId")),
101+
):
102+
filename = secure_filename(file.filename or "")
103+
if not filename:
104+
raise HTTPException(status_code=400, detail="Invalid filename")
105+
106+
_, ext = os.path.splitext(filename.lower())
107+
if ext not in ALLOWED_IMAGE_EXTENSIONS:
108+
raise HTTPException(
109+
status_code=400,
110+
detail=f'File type "{ext}" not allowed. Allowed: {", ".join(sorted(ALLOWED_IMAGE_EXTENSIONS))}',
111+
)
112+
113+
content = await file.read()
114+
if len(content) > MAX_ASSET_BYTES:
115+
raise HTTPException(status_code=400, detail="File exceeds 20 MB limit")
116+
117+
exp_obj = Experiment(experimentId)
118+
experiment_dir = await exp_obj.get_dir()
119+
assets_dir = storage.join(experiment_dir, "notes", "assets")
120+
await storage.makedirs(assets_dir, exist_ok=True)
121+
122+
asset_path = storage.join(assets_dir, filename)
123+
async with await storage.open(asset_path, "wb") as f:
124+
await f.write(content)
125+
126+
return {"path": f"notes/assets/{filename}"}
127+
128+
129+
@router.get("/assets/{filename}")
130+
async def get_asset(experimentId: str, filename: str):
131+
filename = secure_filename(filename)
132+
if not filename:
133+
raise HTTPException(status_code=400, detail="Invalid filename")
134+
135+
_, ext = os.path.splitext(filename.lower())
136+
if ext not in ALLOWED_IMAGE_EXTENSIONS:
137+
raise HTTPException(status_code=400, detail="File type not allowed")
138+
139+
exp_obj = Experiment(experimentId)
140+
experiment_dir = await exp_obj.get_dir()
141+
asset_path = storage.join(experiment_dir, "notes", "assets", filename)
142+
143+
if not await storage.exists(asset_path):
144+
raise HTTPException(status_code=404, detail=f"Asset '{filename}' not found")
145+
146+
async with await storage.open(asset_path, "rb") as f:
147+
content = await f.read()
148+
149+
return Response(
150+
content=content,
151+
media_type=IMAGE_MEDIA_TYPES.get(ext, "application/octet-stream"),
152+
)

cli/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "transformerlab-cli"
7-
version = "0.0.23"
7+
version = "0.0.24"
88
description = "Transformer Lab CLI"
99
requires-python = ">=3.10"
1010
authors = [{ name = "Transformer Lab", email = "hello@transformerlab.ai" }]

0 commit comments

Comments
 (0)