Skip to content

Commit 56cd7ec

Browse files
authored
Merge pull request #2052 from transformerlab/add/experiment-tags-design
Add tags for experiments
2 parents 6dad5bb + 1074c58 commit 56cd7ec

10 files changed

Lines changed: 763 additions & 15 deletions

File tree

.agents/skills/transformerlab-cli/references/commands.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,20 @@ Show current user, team, and server.
7676

7777
---
7878

79+
## Experiment Commands
80+
81+
### `experiment list [--tag <tag>]...`
82+
83+
List experiments. Pass `--tag` one or more times to filter to experiments that have **all** of the given tags (AND semantics). Filtering is client-side.
84+
85+
```bash
86+
lab experiment list --tag fine-tuning # all experiments tagged fine-tuning
87+
lab experiment list --tag fine-tuning --tag llama # must have both tags
88+
lab --format json experiment list --tag llama # JSON output includes `tags` field
89+
```
90+
91+
---
92+
7993
## Task Commands
8094

8195
**All task commands require `current_experiment` to be set.**
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import pytest
2+
from unittest.mock import AsyncMock, MagicMock, patch
3+
4+
from transformerlab.services import experiment_service as svc
5+
6+
7+
def test_normalize_tags_lowercases_and_trims():
8+
assert svc.normalize_tags([" Foo ", "BAR"]) == ["foo", "bar"]
9+
10+
11+
def test_normalize_tags_deduplicates_preserving_order():
12+
assert svc.normalize_tags(["foo", "Bar", "FOO", "bar"]) == ["foo", "bar"]
13+
14+
15+
def test_normalize_tags_allows_dot_dash_underscore_digits():
16+
assert svc.normalize_tags(["fine-tune", "v1.0", "exp_2"]) == [
17+
"fine-tune",
18+
"v1.0",
19+
"exp_2",
20+
]
21+
22+
23+
def test_normalize_tags_rejects_spaces_inside_tag():
24+
with pytest.raises(ValueError, match="hello world"):
25+
svc.normalize_tags(["hello world"])
26+
27+
28+
def test_normalize_tags_rejects_punctuation():
29+
with pytest.raises(ValueError, match="bad!"):
30+
svc.normalize_tags(["bad!"])
31+
32+
33+
def test_normalize_tags_rejects_unicode():
34+
with pytest.raises(ValueError, match="café"):
35+
svc.normalize_tags(["café"])
36+
37+
38+
def test_normalize_tags_rejects_over_32_chars():
39+
long_tag = "a" * 33
40+
with pytest.raises(ValueError, match="32"):
41+
svc.normalize_tags([long_tag])
42+
43+
44+
def test_normalize_tags_accepts_exactly_32_chars():
45+
long_tag = "a" * 32
46+
assert svc.normalize_tags([long_tag]) == [long_tag]
47+
48+
49+
def test_normalize_tags_rejects_empty_after_strip():
50+
with pytest.raises(ValueError):
51+
svc.normalize_tags([" "])
52+
53+
54+
def test_normalize_tags_empty_input_returns_empty_list():
55+
assert svc.normalize_tags([]) == []
56+
57+
58+
def _mock_experiment(current_tags=None):
59+
"""Build a mock Experiment whose get_json_data returns config.tags=current_tags."""
60+
mock_exp = MagicMock()
61+
mock_exp.get_json_data = AsyncMock(return_value={"config": {"tags": list(current_tags) if current_tags else []}})
62+
mock_exp.update_config_field = AsyncMock()
63+
return mock_exp
64+
65+
66+
@patch("transformerlab.services.experiment_service.cache.invalidate", new_callable=AsyncMock)
67+
@patch("transformerlab.services.experiment_service.Experiment.get", new_callable=AsyncMock)
68+
async def test_experiment_add_tags_merges_with_existing(mock_get, _mock_cache):
69+
mock_exp = _mock_experiment(current_tags=["foo"])
70+
mock_get.return_value = mock_exp
71+
72+
result = await svc.experiment_add_tags("exp1", ["Bar", "BAZ"])
73+
74+
assert result == ["foo", "bar", "baz"]
75+
mock_exp.update_config_field.assert_awaited_once_with("tags", ["foo", "bar", "baz"])
76+
77+
78+
@patch("transformerlab.services.experiment_service.cache.invalidate", new_callable=AsyncMock)
79+
@patch("transformerlab.services.experiment_service.Experiment.get", new_callable=AsyncMock)
80+
async def test_experiment_add_tags_is_idempotent(mock_get, _mock_cache):
81+
mock_exp = _mock_experiment(current_tags=["foo", "bar"])
82+
mock_get.return_value = mock_exp
83+
84+
result = await svc.experiment_add_tags("exp1", ["FOO", "bar"])
85+
86+
assert result == ["foo", "bar"]
87+
mock_exp.update_config_field.assert_awaited_once_with("tags", ["foo", "bar"])
88+
89+
90+
@patch("transformerlab.services.experiment_service.cache.invalidate", new_callable=AsyncMock)
91+
@patch("transformerlab.services.experiment_service.Experiment.get", new_callable=AsyncMock)
92+
async def test_experiment_add_tags_handles_missing_tags_field(mock_get, _mock_cache):
93+
mock_exp = MagicMock()
94+
mock_exp.get_json_data = AsyncMock(return_value={"config": {}})
95+
mock_exp.update_config_field = AsyncMock()
96+
mock_get.return_value = mock_exp
97+
98+
result = await svc.experiment_add_tags("exp1", ["foo"])
99+
100+
assert result == ["foo"]
101+
102+
103+
@patch("transformerlab.services.experiment_service.Experiment.get", new_callable=AsyncMock)
104+
async def test_experiment_add_tags_enforces_max_cap(mock_get):
105+
existing = [f"t{i}" for i in range(19)]
106+
mock_exp = _mock_experiment(current_tags=existing)
107+
mock_get.return_value = mock_exp
108+
109+
with pytest.raises(ValueError, match="20"):
110+
await svc.experiment_add_tags("exp1", ["new1", "new2"])
111+
mock_exp.update_config_field.assert_not_awaited()
112+
113+
114+
@patch("transformerlab.services.experiment_service.cache.invalidate", new_callable=AsyncMock)
115+
@patch("transformerlab.services.experiment_service.Experiment.get", new_callable=AsyncMock)
116+
async def test_experiment_remove_tags_removes_present(mock_get, _mock_cache):
117+
mock_exp = _mock_experiment(current_tags=["foo", "bar", "baz"])
118+
mock_get.return_value = mock_exp
119+
120+
result = await svc.experiment_remove_tags("exp1", ["Bar"])
121+
122+
assert result == ["foo", "baz"]
123+
mock_exp.update_config_field.assert_awaited_once_with("tags", ["foo", "baz"])
124+
125+
126+
@patch("transformerlab.services.experiment_service.cache.invalidate", new_callable=AsyncMock)
127+
@patch("transformerlab.services.experiment_service.Experiment.get", new_callable=AsyncMock)
128+
async def test_experiment_remove_tags_absent_is_noop(mock_get, _mock_cache):
129+
mock_exp = _mock_experiment(current_tags=["foo"])
130+
mock_get.return_value = mock_exp
131+
132+
result = await svc.experiment_remove_tags("exp1", ["missing"])
133+
134+
assert result == ["foo"]
135+
mock_exp.update_config_field.assert_awaited_once_with("tags", ["foo"])
136+
137+
138+
def test_aggregate_tags_dedupes_and_sorts():
139+
experiments = [
140+
{"id": "a", "config": {"tags": ["foo", "bar"]}},
141+
{"id": "b", "config": {"tags": ["bar", "baz"]}},
142+
{"id": "c", "config": {}},
143+
{"id": "d", "config": {"tags": []}},
144+
]
145+
assert svc.aggregate_tags(experiments) == ["bar", "baz", "foo"]
146+
147+
148+
def test_aggregate_tags_handles_string_config_blob():
149+
experiments = [{"id": "a", "config": '{"tags": ["zeta", "alpha"]}'}]
150+
assert svc.aggregate_tags(experiments) == ["alpha", "zeta"]
151+
152+
153+
def test_aggregate_tags_skips_non_string_entries():
154+
experiments = [{"id": "a", "config": {"tags": ["foo", 42, None, "bar"]}}]
155+
assert svc.aggregate_tags(experiments) == ["bar", "foo"]
156+
157+
158+
def test_aggregate_tags_empty_input():
159+
assert svc.aggregate_tags([]) == []

api/transformerlab/routers/experiment/experiment.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,15 @@ async def experiments_create(
161161
return newid
162162

163163

164+
@router.get("/tags", summary="List all distinct tags across permitted experiments", tags=["experiment"])
165+
async def experiments_list_all_tags(
166+
session: AsyncSession = Depends(get_async_session),
167+
user_and_team: dict = Depends(get_user_and_team),
168+
):
169+
permitted = await experiments_get_all(session=session, user_and_team=user_and_team)
170+
return {"tags": experiment_service.aggregate_tags(permitted)}
171+
172+
164173
@router.get("/{id}", summary="Get Experiment by ID", tags=["experiment"])
165174
async def experiment_get(
166175
id: str,
@@ -297,3 +306,39 @@ async def experiment_get_file_contents(
297306
return ""
298307

299308
return file_contents
309+
310+
311+
@router.post("/{id}/tags/add", summary="Add tags to an experiment", tags=["experiment"])
312+
async def experiments_tags_add(
313+
id: str,
314+
body: Annotated[dict, Body()],
315+
_: None = Depends(require_permission("experiment", "write")),
316+
):
317+
tags_input = body.get("tags") or []
318+
if not isinstance(tags_input, list):
319+
raise HTTPException(status_code=422, detail="'tags' must be a list of strings")
320+
try:
321+
merged = await experiment_service.experiment_add_tags(id, tags_input)
322+
except ValueError as e:
323+
raise HTTPException(status_code=422, detail=str(e)) from e
324+
except FileNotFoundError as e:
325+
raise HTTPException(status_code=404, detail=f"Experiment {id} not found") from e
326+
return {"tags": merged}
327+
328+
329+
@router.post("/{id}/tags/remove", summary="Remove tags from an experiment", tags=["experiment"])
330+
async def experiments_tags_remove(
331+
id: str,
332+
body: Annotated[dict, Body()],
333+
_: None = Depends(require_permission("experiment", "write")),
334+
):
335+
tags_input = body.get("tags") or []
336+
if not isinstance(tags_input, list):
337+
raise HTTPException(status_code=422, detail="'tags' must be a list of strings")
338+
try:
339+
kept = await experiment_service.experiment_remove_tags(id, tags_input)
340+
except ValueError as e:
341+
raise HTTPException(status_code=422, detail=str(e)) from e
342+
except FileNotFoundError as e:
343+
raise HTTPException(status_code=404, detail=f"Experiment {id} not found") from e
344+
return {"tags": kept}

api/transformerlab/services/experiment_service.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import json
44
import os
5+
import re
56

67
from sqlalchemy import delete
78
from lab import Experiment
@@ -15,6 +16,36 @@
1516
logger = logging.getLogger(__name__)
1617
EXPERIMENT_LIST_CONCURRENCY = max(1, int(os.getenv("TLAB_EXPERIMENT_LIST_CONCURRENCY", "24")))
1718

19+
_TAG_PATTERN = re.compile(r"^[a-z0-9._-]{1,32}$")
20+
TAG_MAX_LEN = 32
21+
TAGS_MAX_PER_EXPERIMENT = 20
22+
23+
24+
def normalize_tags(raw):
25+
"""Lowercase, trim, validate charset (a-z0-9._-, max 32 chars), and dedupe.
26+
27+
Raises ValueError on the first invalid tag.
28+
"""
29+
seen = set()
30+
result = []
31+
for item in raw or []:
32+
if not isinstance(item, str):
33+
raise ValueError(f"Tag must be a string, got {type(item).__name__}: {item!r}")
34+
normalized = item.strip().lower()
35+
if not normalized:
36+
raise ValueError("Tag is empty after trimming whitespace")
37+
if len(normalized) > TAG_MAX_LEN:
38+
raise ValueError(f"Tag {normalized!r} exceeds max length {TAG_MAX_LEN}")
39+
if not _TAG_PATTERN.match(normalized):
40+
raise ValueError(
41+
f"Tag {normalized!r} contains invalid characters (allowed: lowercase a-z, 0-9, '.', '-', '_')"
42+
)
43+
if normalized in seen:
44+
continue
45+
seen.add(normalized)
46+
result.append(normalized)
47+
return result
48+
1849

1950
@cached(key="experiments:list", ttl="2m", tags=["experiments"])
2051
async def experiment_get_all():
@@ -188,3 +219,62 @@ async def experiment_update_configs(id, updates: dict):
188219
print(f"Experiment with id '{id}' not found")
189220
except Exception as e:
190221
print(f"Error updating experiment config: {e}")
222+
223+
224+
async def _read_current_tags(experiment_id):
225+
exp = await Experiment.get(experiment_id)
226+
data = await exp.get_json_data()
227+
config = data.get("config", {})
228+
if isinstance(config, str):
229+
try:
230+
config = json.loads(config)
231+
except json.JSONDecodeError:
232+
config = {}
233+
raw = config.get("tags", []) or []
234+
if not isinstance(raw, list):
235+
return exp, []
236+
return exp, [t for t in raw if isinstance(t, str)]
237+
238+
239+
async def experiment_add_tags(experiment_id, tags):
240+
"""Union-merge ``tags`` into the experiment's existing tags. Returns the full list."""
241+
new_tags = normalize_tags(tags)
242+
exp, current = await _read_current_tags(experiment_id)
243+
merged = list(current)
244+
for t in new_tags:
245+
if t not in merged:
246+
merged.append(t)
247+
if len(merged) > TAGS_MAX_PER_EXPERIMENT:
248+
raise ValueError(f"Cannot exceed {TAGS_MAX_PER_EXPERIMENT} tags per experiment (would be {len(merged)})")
249+
await exp.update_config_field("tags", merged)
250+
await cache.invalidate("experiments")
251+
return merged
252+
253+
254+
async def experiment_remove_tags(experiment_id, tags):
255+
"""Set-difference ``tags`` from the experiment's existing tags. Returns the full list."""
256+
to_remove = set(normalize_tags(tags))
257+
exp, current = await _read_current_tags(experiment_id)
258+
kept = [t for t in current if t not in to_remove]
259+
await exp.update_config_field("tags", kept)
260+
await cache.invalidate("experiments")
261+
return kept
262+
263+
264+
def aggregate_tags(experiments):
265+
"""Return a sorted, deduped list of all tags across ``experiments``."""
266+
bag = set()
267+
for exp in experiments or []:
268+
config = exp.get("config", {}) if isinstance(exp, dict) else {}
269+
if isinstance(config, str):
270+
try:
271+
config = json.loads(config)
272+
except json.JSONDecodeError:
273+
config = {}
274+
tags = config.get("tags", []) if isinstance(config, dict) else []
275+
if not isinstance(tags, list):
276+
continue
277+
for t in tags:
278+
if isinstance(t, str) and t:
279+
bag.add(t)
280+
return sorted(bag)

0 commit comments

Comments
 (0)