Skip to content

Commit fc0d26a

Browse files
committed
cocalc-api/tests: jupyterExecute increase timeout + retry logic
1 parent 3f27741 commit fc0d26a

File tree

2 files changed

+88
-59
lines changed

2 files changed

+88
-59
lines changed

src/python/cocalc-api/src/cocalc_api/project.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ def __init__(self, api_key: str, host: str = "https://cocalc.com", project_id: O
1010
self.project_id = project_id
1111
self.api_key = api_key
1212
self.host = host
13-
# Use longer timeout for API calls (30 seconds instead of default 5)
14-
self.client = httpx.Client(auth=(api_key, ""), headers={"Content-Type": "application/json"}, timeout=30.0)
13+
# Use longer timeout for API calls (60 seconds to handle slow kernel startups in CI)
14+
self.client = httpx.Client(auth=(api_key, ""), headers={"Content-Type": "application/json"}, timeout=60.0)
1515

1616
def call(self, name: str, arguments: list[Any], timeout: Optional[int] = None) -> Any:
1717
"""
@@ -28,7 +28,7 @@ def call(self, name: str, arguments: list[Any], timeout: Optional[int] = None) -
2828
payload: dict[str, Any] = {"name": name, "args": arguments, "project_id": self.project_id}
2929
if timeout is not None:
3030
payload["timeout"] = timeout
31-
resp = self.client.post(self.host + '/api/conat/project', json=payload)
31+
resp = self.client.post(self.host + "/api/conat/project", json=payload)
3232
resp.raise_for_status()
3333
return handle_error(resp.json())
3434

Lines changed: 85 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Tests for Jupyter kernel functionality.
33
"""
4+
45
import pytest
56
import time
67
from typing import Optional
@@ -15,12 +16,12 @@ def test_install_ipykernel(self, project_client):
1516
result = project_client.system.exec(
1617
command="python3",
1718
args=["-m", "pip", "install", "ipykernel"],
18-
timeout=120 # 2 minutes should be enough for pip install
19+
timeout=120, # 2 minutes should be enough for pip install
1920
)
2021

2122
# Check that installation succeeded
22-
assert result['exit_code'] == 0
23-
assert 'stderr' in result
23+
assert result["exit_code"] == 0
24+
assert "stderr" in result
2425

2526
def test_install_jupyter_kernel(self, project_client):
2627
"""Test installing the Python 3 Jupyter kernel."""
@@ -33,20 +34,21 @@ def test_install_jupyter_kernel(self, project_client):
3334
"install",
3435
"--user", # Install to user location, not system
3536
"--name=python3",
36-
"--display-name=Python 3"
37+
"--display-name=Python 3",
3738
],
38-
timeout=30)
39+
timeout=30,
40+
)
3941

4042
# Check that kernel installation succeeded
41-
assert result['exit_code'] == 0
43+
assert result["exit_code"] == 0
4244

4345

4446
class TestJupyterKernels:
4547
"""Tests for Jupyter kernel availability."""
4648

4749
def test_kernels_list_with_project(self, hub, temporary_project):
4850
"""Test getting kernel specs for a specific project."""
49-
project_id = temporary_project['project_id']
51+
project_id = temporary_project["project_id"]
5052
kernels = hub.jupyter.kernels(project_id=project_id)
5153

5254
# Should return a list of kernel specs
@@ -55,12 +57,12 @@ def test_kernels_list_with_project(self, hub, temporary_project):
5557

5658
def test_python3_kernel_available(self, hub, temporary_project):
5759
"""Test that the python3 kernel is available after installation."""
58-
project_id = temporary_project['project_id']
60+
project_id = temporary_project["project_id"]
5961
kernels = hub.jupyter.kernels(project_id=project_id)
6062

6163
# Extract kernel names from the list
62-
kernel_names = [k.get('name') for k in kernels if isinstance(k, dict)]
63-
assert 'python3' in kernel_names
64+
kernel_names = [k.get("name") for k in kernels if isinstance(k, dict)]
65+
assert "python3" in kernel_names
6466

6567

6668
class TestJupyterExecuteViaHub:
@@ -69,64 +71,64 @@ class TestJupyterExecuteViaHub:
6971
@pytest.mark.skip(reason="hub.jupyter.execute() has timeout issues - use project.system.jupyter_execute() instead")
7072
def test_execute_simple_sum(self, hub, temporary_project):
7173
"""Test executing a simple sum using the python3 kernel."""
72-
project_id = temporary_project['project_id']
74+
project_id = temporary_project["project_id"]
7375

74-
result = hub.jupyter.execute(input='sum(range(100))', kernel='python3', project_id=project_id)
76+
result = hub.jupyter.execute(input="sum(range(100))", kernel="python3", project_id=project_id)
7577

7678
# Check the result structure
7779
assert isinstance(result, dict)
78-
assert 'output' in result
80+
assert "output" in result
7981

8082
# Check that we got the correct result (sum of 0..99 = 4950)
81-
output = result['output']
83+
output = result["output"]
8284
assert len(output) > 0
8385

8486
# Extract the result from the output
8587
# Format: [{'data': {'text/plain': '4950'}}]
8688
first_output = output[0]
87-
assert 'data' in first_output
88-
assert 'text/plain' in first_output['data']
89-
assert first_output['data']['text/plain'] == '4950'
89+
assert "data" in first_output
90+
assert "text/plain" in first_output["data"]
91+
assert first_output["data"]["text/plain"] == "4950"
9092

9193
@pytest.mark.skip(reason="hub.jupyter.execute() has timeout issues - use project.system.jupyter_execute() instead")
9294
def test_execute_with_history(self, hub, temporary_project):
9395
"""Test executing code with history context."""
94-
project_id = temporary_project['project_id']
96+
project_id = temporary_project["project_id"]
9597

96-
result = hub.jupyter.execute(history=['a = 100'], input='sum(range(a + 1))', kernel='python3', project_id=project_id)
98+
result = hub.jupyter.execute(history=["a = 100"], input="sum(range(a + 1))", kernel="python3", project_id=project_id)
9799

98100
# Check the result (sum of 0..100 = 5050)
99101
assert isinstance(result, dict)
100-
assert 'output' in result
102+
assert "output" in result
101103

102-
output = result['output']
104+
output = result["output"]
103105
assert len(output) > 0
104106

105107
first_output = output[0]
106-
assert 'data' in first_output
107-
assert 'text/plain' in first_output['data']
108-
assert first_output['data']['text/plain'] == '5050'
108+
assert "data" in first_output
109+
assert "text/plain" in first_output["data"]
110+
assert first_output["data"]["text/plain"] == "5050"
109111

110112
@pytest.mark.skip(reason="hub.jupyter.execute() has timeout issues - use project.system.jupyter_execute() instead")
111113
def test_execute_print_statement(self, hub, temporary_project):
112114
"""Test executing code that prints output."""
113-
project_id = temporary_project['project_id']
115+
project_id = temporary_project["project_id"]
114116

115-
result = hub.jupyter.execute(input='print("Hello from Jupyter")', kernel='python3', project_id=project_id)
117+
result = hub.jupyter.execute(input='print("Hello from Jupyter")', kernel="python3", project_id=project_id)
116118

117119
# Check that we got output
118120
assert isinstance(result, dict)
119-
assert 'output' in result
121+
assert "output" in result
120122

121-
output = result['output']
123+
output = result["output"]
122124
assert len(output) > 0
123125

124126
# Print statements produce stream output
125127
first_output = output[0]
126-
assert 'name' in first_output
127-
assert first_output['name'] == 'stdout'
128-
assert 'text' in first_output
129-
assert 'Hello from Jupyter' in first_output['text']
128+
assert "name" in first_output
129+
assert first_output["name"] == "stdout"
130+
assert "text" in first_output
131+
assert "Hello from Jupyter" in first_output["text"]
130132

131133

132134
class TestJupyterExecuteViaProject:
@@ -147,10 +149,10 @@ def test_jupyter_execute_simple_sum(self, project_client):
147149

148150
for attempt in range(max_retries):
149151
try:
150-
result = project_client.system.jupyter_execute(input='sum(range(100))', kernel='python3')
152+
result = project_client.system.jupyter_execute(input="sum(range(100))", kernel="python3")
151153
break
152154
except RuntimeError as e:
153-
if 'timeout' in str(e).lower() and attempt < max_retries - 1:
155+
if "timeout" in str(e).lower() and attempt < max_retries - 1:
154156
print(f"Attempt {attempt + 1} timed out, retrying in {retry_delay}s...")
155157
time.sleep(retry_delay)
156158
else:
@@ -162,45 +164,59 @@ def test_jupyter_execute_simple_sum(self, project_client):
162164

163165
# Check that we got the correct result (sum of 0..99 = 4950)
164166
first_output = result[0]
165-
assert 'data' in first_output
166-
assert 'text/plain' in first_output['data']
167-
assert first_output['data']['text/plain'] == '4950'
167+
assert "data" in first_output
168+
assert "text/plain" in first_output["data"]
169+
assert first_output["data"]["text/plain"] == "4950"
168170

169171
def test_jupyter_execute_with_history(self, project_client):
170172
"""
171173
Test executing code with history via project API.
172174
173175
The result is a list of output items directly.
174176
"""
175-
result = project_client.system.jupyter_execute(history=['b = 50'], input='b * 2', kernel='python3')
177+
result = project_client.system.jupyter_execute(history=["b = 50"], input="b * 2", kernel="python3")
176178

177179
# Result is a list
178180
assert isinstance(result, list)
179181
assert len(result) > 0
180182

181183
# Check the result (50 * 2 = 100)
182184
first_output = result[0]
183-
assert 'data' in first_output
184-
assert 'text/plain' in first_output['data']
185-
assert first_output['data']['text/plain'] == '100'
185+
assert "data" in first_output
186+
assert "text/plain" in first_output["data"]
187+
assert first_output["data"]["text/plain"] == "100"
186188

187189
def test_jupyter_execute_list_operation(self, project_client):
188190
"""
189191
Test executing code that works with lists.
190192
191193
The result is a list of output items directly.
192194
"""
193-
result = project_client.system.jupyter_execute(input='[x**2 for x in range(5)]', kernel='python3')
195+
# Retry logic for kernel startup
196+
max_retries = 3
197+
retry_delay = 15
198+
result: Optional[list] = None
199+
200+
for attempt in range(max_retries):
201+
try:
202+
result = project_client.system.jupyter_execute(input="[x**2 for x in range(5)]", kernel="python3")
203+
break
204+
except RuntimeError as e:
205+
if "timeout" in str(e).lower() and attempt < max_retries - 1:
206+
print(f"Attempt {attempt + 1} timed out, retrying in {retry_delay}s...")
207+
time.sleep(retry_delay)
208+
else:
209+
raise
194210

195211
# Result is a list
196212
assert isinstance(result, list)
197213
assert len(result) > 0
198214

199215
# Check the result ([0, 1, 4, 9, 16])
200216
first_output = result[0]
201-
assert 'data' in first_output
202-
assert 'text/plain' in first_output['data']
203-
assert first_output['data']['text/plain'] == '[0, 1, 4, 9, 16]'
217+
assert "data" in first_output
218+
assert "text/plain" in first_output["data"]
219+
assert first_output["data"]["text/plain"] == "[0, 1, 4, 9, 16]"
204220

205221

206222
class TestJupyterKernelManagement:
@@ -209,7 +225,20 @@ class TestJupyterKernelManagement:
209225
def test_list_jupyter_kernels(self, project_client):
210226
"""Test listing running Jupyter kernels."""
211227
# First execute some code to ensure a kernel is running
212-
project_client.system.jupyter_execute(input='1+1', kernel='python3')
228+
# Retry logic for first kernel startup (may take longer in CI)
229+
max_retries = 3
230+
retry_delay = 15
231+
232+
for attempt in range(max_retries):
233+
try:
234+
project_client.system.jupyter_execute(input="1+1", kernel="python3")
235+
break
236+
except RuntimeError as e:
237+
if "timeout" in str(e).lower() and attempt < max_retries - 1:
238+
print(f"Attempt {attempt + 1} timed out, retrying in {retry_delay}s...")
239+
time.sleep(retry_delay)
240+
else:
241+
raise
213242

214243
# List kernels
215244
kernels = project_client.system.list_jupyter_kernels()
@@ -222,30 +251,30 @@ def test_list_jupyter_kernels(self, project_client):
222251

223252
# Each kernel should have required fields
224253
for kernel in kernels:
225-
assert 'pid' in kernel
226-
assert 'connectionFile' in kernel
227-
assert isinstance(kernel['pid'], int)
228-
assert isinstance(kernel['connectionFile'], str)
254+
assert "pid" in kernel
255+
assert "connectionFile" in kernel
256+
assert isinstance(kernel["pid"], int)
257+
assert isinstance(kernel["connectionFile"], str)
229258

230259
def test_stop_jupyter_kernel(self, project_client):
231260
"""Test stopping a specific Jupyter kernel."""
232261
# Execute code to start a kernel
233-
project_client.system.jupyter_execute(input='1+1', kernel='python3')
262+
project_client.system.jupyter_execute(input="1+1", kernel="python3")
234263

235264
# List kernels
236265
kernels = project_client.system.list_jupyter_kernels()
237266
assert len(kernels) > 0
238267

239268
# Stop the first kernel
240269
kernel_to_stop = kernels[0]
241-
result = project_client.system.stop_jupyter_kernel(pid=kernel_to_stop['pid'])
270+
result = project_client.system.stop_jupyter_kernel(pid=kernel_to_stop["pid"])
242271

243272
# Should return success
244273
assert isinstance(result, dict)
245-
assert 'success' in result
246-
assert result['success'] is True
274+
assert "success" in result
275+
assert result["success"] is True
247276

248277
# Verify kernel is no longer in the list
249278
kernels_after = project_client.system.list_jupyter_kernels()
250-
remaining_pids = [k['pid'] for k in kernels_after]
251-
assert kernel_to_stop['pid'] not in remaining_pids
279+
remaining_pids = [k["pid"] for k in kernels_after]
280+
assert kernel_to_stop["pid"] not in remaining_pids

0 commit comments

Comments
 (0)