Skip to content

Commit 98a9d82

Browse files
authored
Merge pull request #1763 from transformerlab/add/new-cli-live-tests
Add new live tests for CLI
2 parents f737b28 + ab7e4a4 commit 98a9d82

5 files changed

Lines changed: 380 additions & 115 deletions

File tree

.github/workflows/pytest-cli-live-server.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,4 @@ jobs:
104104
TLAB_LIVE_SERVER_URL: "http://127.0.0.1:8338"
105105
run: |
106106
cd ../cli
107-
uv run pytest tests/integration/test_live_compute_provider_routes.py -v
107+
uv run pytest tests/integration/test_live_cli_routes.py -v

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.10"
7+
version = "0.0.11"
88
description = "Transformer Lab CLI"
99
requires-python = ">=3.10"
1010
authors = [{ name = "Transformer Lab", email = "hello@transformerlab.ai" }]
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
"""Live API smoke tests for API routes touched by the CLI.
2+
3+
These tests run against a real server and validate route contracts that CLI commands rely on.
4+
They are skipped by default unless TLAB_RUN_LIVE_SERVER_TESTS=1 is set.
5+
"""
6+
7+
import os
8+
import time
9+
import uuid
10+
11+
import httpx
12+
import pytest
13+
14+
15+
RUN_LIVE_TESTS = os.getenv("TLAB_RUN_LIVE_SERVER_TESTS") == "1"
16+
BASE_URL = os.getenv("TLAB_LIVE_SERVER_URL", "http://127.0.0.1:8338").rstrip("/")
17+
LOGIN_EMAIL = os.getenv("TLAB_LIVE_TEST_EMAIL", "admin@example.com")
18+
LOGIN_PASSWORD = os.getenv("TLAB_LIVE_TEST_PASSWORD", "admin123")
19+
20+
21+
pytestmark = pytest.mark.skipif(not RUN_LIVE_TESTS, reason="Set TLAB_RUN_LIVE_SERVER_TESTS=1 to run live tests")
22+
23+
24+
def _auth_headers(client: httpx.Client) -> dict[str, str]:
25+
login_response = client.post(
26+
f"{BASE_URL}/auth/jwt/login",
27+
data={"username": LOGIN_EMAIL, "password": LOGIN_PASSWORD},
28+
headers={"Content-Type": "application/x-www-form-urlencoded"},
29+
)
30+
assert login_response.status_code == 200, login_response.text
31+
32+
token = login_response.json()["access_token"]
33+
auth_headers = {"Authorization": f"Bearer {token}"}
34+
35+
teams_response = client.get(f"{BASE_URL}/users/me/teams", headers=auth_headers)
36+
assert teams_response.status_code == 200, teams_response.text
37+
38+
teams_payload = teams_response.json()
39+
teams_list: list[dict]
40+
if isinstance(teams_payload, list):
41+
teams_list = teams_payload
42+
elif isinstance(teams_payload, dict):
43+
if isinstance(teams_payload.get("teams"), list):
44+
teams_list = teams_payload["teams"]
45+
elif isinstance(teams_payload.get("data"), list):
46+
teams_list = teams_payload["data"]
47+
else:
48+
teams_list = []
49+
else:
50+
teams_list = []
51+
52+
assert teams_list, f"Expected at least one team for the test user, got: {teams_payload!r}"
53+
return {**auth_headers, "X-Team-Id": str(teams_list[0]["id"])}
54+
55+
56+
def _first_experiment_id(client: httpx.Client, headers: dict[str, str]) -> str:
57+
experiments_response = client.get(f"{BASE_URL}/experiment/", headers=headers)
58+
assert experiments_response.status_code == 200, experiments_response.text
59+
60+
payload = experiments_response.json()
61+
if isinstance(payload, list) and payload:
62+
first = payload[0]
63+
if isinstance(first, dict):
64+
experiment_id = first.get("id")
65+
assert experiment_id, f"Experiment object missing id: {first!r}"
66+
return str(experiment_id)
67+
return str(first)
68+
if isinstance(payload, dict):
69+
for key in ("experiments", "data"):
70+
value = payload.get(key)
71+
if isinstance(value, list) and value:
72+
first = value[0]
73+
if isinstance(first, dict):
74+
experiment_id = first.get("id")
75+
assert experiment_id, f"Experiment object missing id: {first!r}"
76+
return str(experiment_id)
77+
return str(first)
78+
raise AssertionError(f"Expected at least one experiment, got: {payload!r}")
79+
80+
81+
def _assert_status_in(response: httpx.Response, expected: set[int], route_name: str) -> None:
82+
assert response.status_code in expected, (
83+
f"{route_name} returned unexpected status {response.status_code}. "
84+
f"Expected one of {sorted(expected)}. Body: {response.text}"
85+
)
86+
87+
88+
def _assert_json_message(response: httpx.Response, accepted_messages: set[str], route_name: str) -> None:
89+
try:
90+
payload = response.json()
91+
except ValueError as exc:
92+
raise AssertionError(f"{route_name} expected JSON body for 200 response. Body: {response.text}") from exc
93+
message = str(payload.get("message", payload.get("detail", ""))).strip().lower()
94+
assert message in accepted_messages, (
95+
f"{route_name} returned 200 with unexpected JSON message '{message}'. Body: {response.text}"
96+
)
97+
98+
99+
def _assert_missing_contract(response: httpx.Response, route_name: str) -> None:
100+
"""Accept 404 or legacy 200 JSON with missing-resource message."""
101+
if response.status_code == 404:
102+
return
103+
if response.status_code == 200:
104+
_assert_json_message(response, {"not found"}, route_name)
105+
return
106+
raise AssertionError(
107+
f"{route_name} returned unexpected status {response.status_code}. "
108+
f"Expected 404 or legacy 200/NOT FOUND. Body: {response.text}"
109+
)
110+
111+
112+
def _assert_stop_contract(response: httpx.Response, route_name: str) -> None:
113+
"""Accept backend stop-job variants for missing/non-running jobs."""
114+
if response.status_code == 404:
115+
return
116+
if response.status_code == 200:
117+
_assert_json_message(response, {"ok", "not found"}, route_name)
118+
return
119+
raise AssertionError(
120+
f"{route_name} returned unexpected status {response.status_code}. "
121+
f"Expected 404 or legacy 200/OK|NOT FOUND. Body: {response.text}"
122+
)
123+
124+
125+
def _assert_stream_or_logs_missing_contract(response: httpx.Response, route_name: str) -> None:
126+
"""Accept missing-job behavior for log/stream endpoints.
127+
128+
Some backends return 404, while others return 200 with plain text payloads
129+
like 'data: Error: ...' for missing jobs.
130+
"""
131+
if response.status_code == 404:
132+
return
133+
if response.status_code == 200:
134+
content = response.text.lower()
135+
assert any(token in content for token in ("error", "not found", "no log files found")), (
136+
f"{route_name} returned 200 without a recognizable missing/error payload. Body: {response.text}"
137+
)
138+
return
139+
raise AssertionError(
140+
f"{route_name} returned unexpected status {response.status_code}. "
141+
f"Expected 404 or legacy 200/error text. Body: {response.text}"
142+
)
143+
144+
145+
def _get_with_retry(
146+
client: httpx.Client,
147+
url: str,
148+
headers: dict[str, str],
149+
attempts: int = 3,
150+
delay_seconds: float = 0.5,
151+
) -> httpx.Response:
152+
"""Retry GET briefly for routes that may become consistent asynchronously."""
153+
response = client.get(url, headers=headers)
154+
for _ in range(attempts - 1):
155+
if response.status_code != 404:
156+
break
157+
time.sleep(delay_seconds)
158+
response = client.get(url, headers=headers)
159+
return response
160+
161+
162+
@pytest.fixture()
163+
def live_context() -> dict[str, str]:
164+
with httpx.Client(timeout=30.0) as client:
165+
headers = _auth_headers(client)
166+
auth_only_headers = {"Authorization": headers["Authorization"]}
167+
experiment_id = _first_experiment_id(client, headers)
168+
return {"headers": headers, "auth_only_headers": auth_only_headers, "experiment_id": experiment_id}
169+
170+
171+
@pytest.fixture()
172+
def provider_id(live_context: dict[str, str]) -> str:
173+
created_provider_id: str | None = None
174+
with httpx.Client(timeout=30.0) as client:
175+
headers = live_context["headers"]
176+
create_payload = {"name": f"cli-route-smoke-{uuid.uuid4().hex[:8]}", "type": "local", "config": {}}
177+
create_response = client.post(f"{BASE_URL}/compute_provider/providers/", headers=headers, json=create_payload)
178+
_assert_status_in(create_response, {200}, "POST /compute_provider/providers/")
179+
created_provider_id = create_response.json().get("id")
180+
assert created_provider_id, "Provider create response did not include id"
181+
182+
try:
183+
return created_provider_id
184+
finally:
185+
if created_provider_id:
186+
with httpx.Client(timeout=30.0) as cleanup_client:
187+
cleanup_client.delete(
188+
f"{BASE_URL}/compute_provider/providers/{created_provider_id}",
189+
headers=live_context["headers"],
190+
)
191+
192+
193+
def test_cli_auth_and_experiment_routes_live_server(live_context: dict[str, str]) -> None:
194+
with httpx.Client(timeout=30.0) as client:
195+
_assert_status_in(client.get(f"{BASE_URL}/healthz"), {200}, "GET /healthz")
196+
_assert_status_in(
197+
client.get(f"{BASE_URL}/users/me", headers=live_context["auth_only_headers"]),
198+
{200},
199+
"GET /users/me",
200+
)
201+
_assert_status_in(
202+
client.get(f"{BASE_URL}/users/me/teams", headers=live_context["auth_only_headers"]),
203+
{200},
204+
"GET /users/me/teams",
205+
)
206+
_assert_status_in(
207+
client.get(f"{BASE_URL}/experiment/", headers=live_context["headers"]),
208+
{200},
209+
"GET /experiment/",
210+
)
211+
212+
213+
def test_cli_compute_provider_routes_live_server(live_context: dict[str, str], provider_id: str) -> None:
214+
headers = live_context["headers"]
215+
with httpx.Client(timeout=30.0) as client:
216+
_assert_status_in(
217+
client.get(f"{BASE_URL}/compute_provider/providers/?include_disabled=false", headers=headers),
218+
{200},
219+
"GET /compute_provider/providers/",
220+
)
221+
provider_info_response = _get_with_retry(
222+
client,
223+
f"{BASE_URL}/compute_provider/providers/{provider_id}",
224+
headers,
225+
)
226+
_assert_status_in(provider_info_response, {200, 404}, "GET /compute_provider/providers/{id}")
227+
_assert_status_in(
228+
client.get(f"{BASE_URL}/compute_provider/providers/{provider_id}/check", headers=headers),
229+
{200, 400, 404, 422},
230+
"GET /compute_provider/providers/{id}/check",
231+
)
232+
_assert_status_in(
233+
client.patch(
234+
f"{BASE_URL}/compute_provider/providers/{provider_id}", headers=headers, json={"disabled": True}
235+
),
236+
{200, 404},
237+
"PATCH /compute_provider/providers/{id} disable",
238+
)
239+
_assert_status_in(
240+
client.patch(
241+
f"{BASE_URL}/compute_provider/providers/{provider_id}", headers=headers, json={"disabled": False}
242+
),
243+
{200, 404},
244+
"PATCH /compute_provider/providers/{id} enable",
245+
)
246+
_assert_status_in(
247+
client.post(f"{BASE_URL}/compute_provider/providers/{provider_id}/launch/", headers=headers, json={}),
248+
{200, 202, 400, 404, 422},
249+
"POST /compute_provider/providers/{id}/launch/",
250+
)
251+
252+
253+
def test_cli_task_routes_live_server(live_context: dict[str, str]) -> None:
254+
headers = live_context["headers"]
255+
experiment_id = live_context["experiment_id"]
256+
fake_task_id = "route-smoke-task-id"
257+
258+
with httpx.Client(timeout=30.0) as client:
259+
_assert_status_in(
260+
client.get(
261+
f"{BASE_URL}/experiment/{experiment_id}/task/list_by_type_in_experiment?type=REMOTE", headers=headers
262+
),
263+
{200},
264+
"GET /experiment/{id}/task/list_by_type_in_experiment",
265+
)
266+
_assert_status_in(
267+
client.get(f"{BASE_URL}/experiment/{experiment_id}/task/gallery", headers=headers),
268+
{200},
269+
"GET /experiment/{id}/task/gallery",
270+
)
271+
_assert_status_in(
272+
client.get(f"{BASE_URL}/experiment/{experiment_id}/task/gallery/interactive", headers=headers),
273+
{200},
274+
"GET /experiment/{id}/task/gallery/interactive",
275+
)
276+
_assert_status_in(
277+
client.post(
278+
f"{BASE_URL}/experiment/{experiment_id}/task/validate",
279+
headers={**headers, "Content-Type": "text/plain"},
280+
content="name: smoke\nrun: echo hi\ntype: trainer\n",
281+
),
282+
{200, 400, 422},
283+
"POST /experiment/{id}/task/validate",
284+
)
285+
_assert_missing_contract(
286+
client.get(f"{BASE_URL}/experiment/{experiment_id}/task/{fake_task_id}/get", headers=headers),
287+
"GET /experiment/{id}/task/{task_id}/get",
288+
)
289+
_assert_status_in(
290+
client.get(f"{BASE_URL}/experiment/{experiment_id}/task/{fake_task_id}/delete", headers=headers),
291+
{200, 404},
292+
"GET /experiment/{id}/task/{task_id}/delete",
293+
)
294+
_assert_status_in(
295+
client.post(
296+
f"{BASE_URL}/experiment/{experiment_id}/task/create",
297+
headers=headers,
298+
json={"github_repo_url": "https://github.com/does-not-exist/repo"},
299+
),
300+
{200, 400, 404, 422},
301+
"POST /experiment/{id}/task/create (json)",
302+
)
303+
_assert_status_in(
304+
client.post(
305+
f"{BASE_URL}/experiment/{experiment_id}/task/gallery/import",
306+
headers=headers,
307+
json={"gallery_id": "route-smoke-gallery-id", "experiment_id": experiment_id, "is_interactive": False},
308+
),
309+
{200, 400, 404, 422},
310+
"POST /experiment/{id}/task/gallery/import",
311+
)
312+
313+
314+
def test_cli_job_and_artifact_routes_live_server(live_context: dict[str, str]) -> None:
315+
headers = live_context["headers"]
316+
experiment_id = live_context["experiment_id"]
317+
fake_job_id = "route-smoke-job-id"
318+
319+
with httpx.Client(timeout=30.0) as client:
320+
_assert_status_in(
321+
client.get(f"{BASE_URL}/experiment/{experiment_id}/jobs/list?type=REMOTE", headers=headers),
322+
{200},
323+
"GET /experiment/{id}/jobs/list",
324+
)
325+
_assert_stop_contract(
326+
client.get(f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/stop", headers=headers),
327+
"GET /experiment/{id}/jobs/{job_id}/stop",
328+
)
329+
_assert_stream_or_logs_missing_contract(
330+
client.get(f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/provider_logs", headers=headers),
331+
"GET /experiment/{id}/jobs/{job_id}/provider_logs",
332+
)
333+
_assert_stream_or_logs_missing_contract(
334+
client.get(f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/stream_output", headers=headers),
335+
"GET /experiment/{id}/jobs/{job_id}/stream_output",
336+
)
337+
_assert_stream_or_logs_missing_contract(
338+
client.get(f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/request_logs", headers=headers),
339+
"GET /experiment/{id}/jobs/{job_id}/request_logs",
340+
)
341+
_assert_missing_contract(
342+
client.get(f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/tunnel_info", headers=headers),
343+
"GET /experiment/{id}/jobs/{job_id}/tunnel_info",
344+
)
345+
_assert_status_in(
346+
client.get(f"{BASE_URL}/jobs/{fake_job_id}/artifacts", headers=headers),
347+
{200, 400, 404},
348+
"GET /jobs/{job_id}/artifacts",
349+
)
350+
_assert_status_in(
351+
client.get(f"{BASE_URL}/jobs/{fake_job_id}/artifact/does-not-exist.txt?task=download", headers=headers),
352+
{400, 404, 405},
353+
"GET /jobs/{job_id}/artifact/{filename}",
354+
)
355+
_assert_status_in(
356+
client.get(f"{BASE_URL}/jobs/{fake_job_id}/artifacts/download_all", headers=headers),
357+
{400, 404},
358+
"GET /jobs/{job_id}/artifacts/download_all",
359+
)
360+
_assert_status_in(
361+
client.post(
362+
f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/datasets/does-not-exist/save_to_registry"
363+
"?mode=new&tag=latest&version_label=v1",
364+
headers=headers,
365+
),
366+
{400, 404, 422},
367+
"POST /experiment/{id}/jobs/{job_id}/datasets/{name}/save_to_registry",
368+
)
369+
_assert_status_in(
370+
client.post(
371+
f"{BASE_URL}/experiment/{experiment_id}/jobs/{fake_job_id}/models/does-not-exist/save_to_registry"
372+
"?mode=new&tag=latest&version_label=v1",
373+
headers=headers,
374+
),
375+
{400, 404, 422},
376+
"POST /experiment/{id}/jobs/{job_id}/models/{name}/save_to_registry",
377+
)

0 commit comments

Comments
 (0)