lyceum-cli 1.0.25__py3-none-any.whl → 1.0.27__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lyceum/external/auth/login.py +18 -18
- lyceum/external/compute/execution/docker.py +4 -2
- lyceum/external/compute/execution/docker_compose.py +263 -0
- lyceum/external/compute/execution/notebook.py +0 -2
- lyceum/external/compute/execution/python.py +2 -1
- lyceum/external/compute/inference/batch.py +8 -10
- lyceum/external/vms/instances.py +301 -0
- lyceum/external/vms/management.py +383 -0
- lyceum/main.py +3 -0
- lyceum/shared/config.py +19 -24
- lyceum/shared/display.py +12 -31
- lyceum/shared/streaming.py +17 -45
- {lyceum_cli-1.0.25.dist-info → lyceum_cli-1.0.27.dist-info}/METADATA +1 -1
- lyceum_cli-1.0.27.dist-info/RECORD +34 -0
- {lyceum_cli-1.0.25.dist-info → lyceum_cli-1.0.27.dist-info}/WHEEL +1 -1
- {lyceum_cli-1.0.25.dist-info → lyceum_cli-1.0.27.dist-info}/top_level.txt +0 -1
- lyceum/external/compute/execution/docker_config.py +0 -123
- lyceum/external/storage/files.py +0 -273
- lyceum_cli-1.0.25.dist-info/RECORD +0 -46
- tests/__init__.py +0 -1
- tests/conftest.py +0 -200
- tests/unit/__init__.py +0 -1
- tests/unit/external/__init__.py +0 -1
- tests/unit/external/compute/__init__.py +0 -1
- tests/unit/external/compute/execution/__init__.py +0 -1
- tests/unit/external/compute/execution/test_data.py +0 -33
- tests/unit/external/compute/execution/test_dependency_resolver.py +0 -257
- tests/unit/external/compute/execution/test_python_helpers.py +0 -406
- tests/unit/external/compute/execution/test_python_run.py +0 -289
- tests/unit/shared/__init__.py +0 -1
- tests/unit/shared/test_config.py +0 -341
- tests/unit/shared/test_streaming.py +0 -259
- /lyceum/external/{storage → vms}/__init__.py +0 -0
- {lyceum_cli-1.0.25.dist-info → lyceum_cli-1.0.27.dist-info}/entry_points.txt +0 -0
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
"""Tests for run_python CLI command."""
|
|
2
|
-
|
|
3
|
-
from unittest.mock import MagicMock, patch
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
from typer.testing import CliRunner
|
|
7
|
-
|
|
8
|
-
from lyceum.external.compute.execution.python import python_app
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class TestRunPythonCommand:
|
|
12
|
-
"""Tests for the run_python CLI command."""
|
|
13
|
-
|
|
14
|
-
@pytest.fixture
|
|
15
|
-
def cli_runner(self):
|
|
16
|
-
return CliRunner()
|
|
17
|
-
|
|
18
|
-
@patch("lyceum.external.compute.execution.python.stream_execution_output")
|
|
19
|
-
@patch("lyceum.external.compute.execution.python.submit_execution")
|
|
20
|
-
@patch("lyceum.external.compute.execution.python.validate_machine_type")
|
|
21
|
-
@patch("lyceum.external.compute.execution.python.config")
|
|
22
|
-
def test_run_inline_code(
|
|
23
|
-
self,
|
|
24
|
-
mock_config,
|
|
25
|
-
mock_validate,
|
|
26
|
-
mock_submit,
|
|
27
|
-
mock_stream,
|
|
28
|
-
cli_runner,
|
|
29
|
-
mock_execution_id,
|
|
30
|
-
):
|
|
31
|
-
mock_config.get_client.return_value = None
|
|
32
|
-
mock_validate.return_value = True
|
|
33
|
-
mock_submit.return_value = (mock_execution_id, "http://stream.url")
|
|
34
|
-
mock_stream.return_value = True
|
|
35
|
-
|
|
36
|
-
result = cli_runner.invoke(python_app, ["run", "print('hello')"])
|
|
37
|
-
|
|
38
|
-
assert result.exit_code == 0
|
|
39
|
-
mock_submit.assert_called_once()
|
|
40
|
-
|
|
41
|
-
@patch("lyceum.external.compute.execution.python.stream_execution_output")
|
|
42
|
-
@patch("lyceum.external.compute.execution.python.submit_execution")
|
|
43
|
-
@patch("lyceum.external.compute.execution.python.validate_machine_type")
|
|
44
|
-
@patch("lyceum.external.compute.execution.python.config")
|
|
45
|
-
def test_run_file(
|
|
46
|
-
self,
|
|
47
|
-
mock_config,
|
|
48
|
-
mock_validate,
|
|
49
|
-
mock_submit,
|
|
50
|
-
mock_stream,
|
|
51
|
-
cli_runner,
|
|
52
|
-
sample_workspace,
|
|
53
|
-
mock_execution_id,
|
|
54
|
-
):
|
|
55
|
-
mock_config.get_client.return_value = None
|
|
56
|
-
mock_validate.return_value = True
|
|
57
|
-
mock_submit.return_value = (mock_execution_id, "http://stream.url")
|
|
58
|
-
mock_stream.return_value = True
|
|
59
|
-
|
|
60
|
-
main_file = sample_workspace / "main.py"
|
|
61
|
-
result = cli_runner.invoke(python_app, ["run", str(main_file)])
|
|
62
|
-
|
|
63
|
-
assert result.exit_code == 0
|
|
64
|
-
|
|
65
|
-
@patch("lyceum.external.compute.execution.python.validate_machine_type")
|
|
66
|
-
@patch("lyceum.external.compute.execution.python.config")
|
|
67
|
-
def test_invalid_machine_type(
|
|
68
|
-
self,
|
|
69
|
-
mock_config,
|
|
70
|
-
mock_validate,
|
|
71
|
-
cli_runner,
|
|
72
|
-
):
|
|
73
|
-
mock_config.get_client.return_value = None
|
|
74
|
-
mock_validate.return_value = False
|
|
75
|
-
|
|
76
|
-
result = cli_runner.invoke(python_app, ["run", "print('hi')", "-m", "invalid"])
|
|
77
|
-
|
|
78
|
-
assert result.exit_code == 1
|
|
79
|
-
assert "don't have access" in result.output
|
|
80
|
-
|
|
81
|
-
@patch("lyceum.external.compute.execution.python.stream_execution_output")
|
|
82
|
-
@patch("lyceum.external.compute.execution.python.submit_execution")
|
|
83
|
-
@patch("lyceum.external.compute.execution.python.validate_machine_type")
|
|
84
|
-
@patch("lyceum.external.compute.execution.python.config")
|
|
85
|
-
def test_execution_failure(
|
|
86
|
-
self,
|
|
87
|
-
mock_config,
|
|
88
|
-
mock_validate,
|
|
89
|
-
mock_submit,
|
|
90
|
-
mock_stream,
|
|
91
|
-
cli_runner,
|
|
92
|
-
mock_execution_id,
|
|
93
|
-
):
|
|
94
|
-
mock_config.get_client.return_value = None
|
|
95
|
-
mock_validate.return_value = True
|
|
96
|
-
mock_submit.return_value = (mock_execution_id, "http://stream.url")
|
|
97
|
-
mock_stream.return_value = False # Execution failed
|
|
98
|
-
|
|
99
|
-
result = cli_runner.invoke(python_app, ["run", "print('hi')"])
|
|
100
|
-
|
|
101
|
-
assert result.exit_code == 1
|
|
102
|
-
|
|
103
|
-
@patch("lyceum.external.compute.execution.python.stream_execution_output")
|
|
104
|
-
@patch("lyceum.external.compute.execution.python.submit_execution")
|
|
105
|
-
@patch("lyceum.external.compute.execution.python.validate_machine_type")
|
|
106
|
-
@patch("lyceum.external.compute.execution.python.config")
|
|
107
|
-
def test_machine_type_option(
|
|
108
|
-
self,
|
|
109
|
-
mock_config,
|
|
110
|
-
mock_validate,
|
|
111
|
-
mock_submit,
|
|
112
|
-
mock_stream,
|
|
113
|
-
cli_runner,
|
|
114
|
-
mock_execution_id,
|
|
115
|
-
):
|
|
116
|
-
mock_config.get_client.return_value = None
|
|
117
|
-
mock_validate.return_value = True
|
|
118
|
-
mock_submit.return_value = (mock_execution_id, "http://stream.url")
|
|
119
|
-
mock_stream.return_value = True
|
|
120
|
-
|
|
121
|
-
result = cli_runner.invoke(python_app, ["run", "print('hi')", "-m", "a100"])
|
|
122
|
-
|
|
123
|
-
assert result.exit_code == 0
|
|
124
|
-
# Check that payload was built with correct machine type
|
|
125
|
-
call_args = mock_submit.call_args[0][0]
|
|
126
|
-
assert call_args["execution_type"] == "a100"
|
|
127
|
-
|
|
128
|
-
@patch("lyceum.external.compute.execution.python.stream_execution_output")
|
|
129
|
-
@patch("lyceum.external.compute.execution.python.submit_execution")
|
|
130
|
-
@patch("lyceum.external.compute.execution.python.validate_machine_type")
|
|
131
|
-
@patch("lyceum.external.compute.execution.python.config")
|
|
132
|
-
def test_file_name_option(
|
|
133
|
-
self,
|
|
134
|
-
mock_config,
|
|
135
|
-
mock_validate,
|
|
136
|
-
mock_submit,
|
|
137
|
-
mock_stream,
|
|
138
|
-
cli_runner,
|
|
139
|
-
mock_execution_id,
|
|
140
|
-
):
|
|
141
|
-
mock_config.get_client.return_value = None
|
|
142
|
-
mock_validate.return_value = True
|
|
143
|
-
mock_submit.return_value = (mock_execution_id, "http://stream.url")
|
|
144
|
-
mock_stream.return_value = True
|
|
145
|
-
|
|
146
|
-
result = cli_runner.invoke(
|
|
147
|
-
python_app, ["run", "print('hi')", "-f", "custom_name.py"]
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
assert result.exit_code == 0
|
|
151
|
-
call_args = mock_submit.call_args[0][0]
|
|
152
|
-
assert call_args["file_name"] == "custom_name.py"
|
|
153
|
-
|
|
154
|
-
@patch("lyceum.external.compute.execution.python.stream_execution_output")
|
|
155
|
-
@patch("lyceum.external.compute.execution.python.submit_execution")
|
|
156
|
-
@patch("lyceum.external.compute.execution.python.validate_machine_type")
|
|
157
|
-
@patch("lyceum.external.compute.execution.python.config")
|
|
158
|
-
def test_requirements_option(
|
|
159
|
-
self,
|
|
160
|
-
mock_config,
|
|
161
|
-
mock_validate,
|
|
162
|
-
mock_submit,
|
|
163
|
-
mock_stream,
|
|
164
|
-
cli_runner,
|
|
165
|
-
mock_execution_id,
|
|
166
|
-
):
|
|
167
|
-
mock_config.get_client.return_value = None
|
|
168
|
-
mock_validate.return_value = True
|
|
169
|
-
mock_submit.return_value = (mock_execution_id, "http://stream.url")
|
|
170
|
-
mock_stream.return_value = True
|
|
171
|
-
|
|
172
|
-
result = cli_runner.invoke(
|
|
173
|
-
python_app, ["run", "print('hi')", "-r", "numpy>=1.0"]
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
assert result.exit_code == 0
|
|
177
|
-
call_args = mock_submit.call_args[0][0]
|
|
178
|
-
assert call_args["requirements_content"] == "numpy>=1.0"
|
|
179
|
-
|
|
180
|
-
@patch("lyceum.external.compute.execution.python.stream_execution_output")
|
|
181
|
-
@patch("lyceum.external.compute.execution.python.submit_execution")
|
|
182
|
-
@patch("lyceum.external.compute.execution.python.validate_machine_type")
|
|
183
|
-
@patch("lyceum.external.compute.execution.python.config")
|
|
184
|
-
def test_shows_execution_id(
|
|
185
|
-
self,
|
|
186
|
-
mock_config,
|
|
187
|
-
mock_validate,
|
|
188
|
-
mock_submit,
|
|
189
|
-
mock_stream,
|
|
190
|
-
cli_runner,
|
|
191
|
-
mock_execution_id,
|
|
192
|
-
):
|
|
193
|
-
mock_config.get_client.return_value = None
|
|
194
|
-
mock_validate.return_value = True
|
|
195
|
-
mock_submit.return_value = (mock_execution_id, "http://stream.url")
|
|
196
|
-
mock_stream.return_value = True
|
|
197
|
-
|
|
198
|
-
result = cli_runner.invoke(python_app, ["run", "print('hi')"])
|
|
199
|
-
|
|
200
|
-
assert result.exit_code == 0
|
|
201
|
-
assert mock_execution_id in result.output
|
|
202
|
-
|
|
203
|
-
@patch("lyceum.external.compute.execution.python.stream_execution_output")
|
|
204
|
-
@patch("lyceum.external.compute.execution.python.submit_execution")
|
|
205
|
-
@patch("lyceum.external.compute.execution.python.validate_machine_type")
|
|
206
|
-
@patch("lyceum.external.compute.execution.python.load_workspace_config")
|
|
207
|
-
@patch("lyceum.external.compute.execution.python.config")
|
|
208
|
-
def test_no_config_option(
|
|
209
|
-
self,
|
|
210
|
-
mock_config,
|
|
211
|
-
mock_load_workspace,
|
|
212
|
-
mock_validate,
|
|
213
|
-
mock_submit,
|
|
214
|
-
mock_stream,
|
|
215
|
-
cli_runner,
|
|
216
|
-
mock_execution_id,
|
|
217
|
-
):
|
|
218
|
-
mock_config.get_client.return_value = None
|
|
219
|
-
mock_validate.return_value = True
|
|
220
|
-
mock_submit.return_value = (mock_execution_id, "http://stream.url")
|
|
221
|
-
mock_stream.return_value = True
|
|
222
|
-
|
|
223
|
-
result = cli_runner.invoke(
|
|
224
|
-
python_app, ["run", "print('hi')", "--no-config"]
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
assert result.exit_code == 0
|
|
228
|
-
mock_load_workspace.assert_not_called()
|
|
229
|
-
|
|
230
|
-
@patch("lyceum.external.compute.execution.python.stream_execution_output")
|
|
231
|
-
@patch("lyceum.external.compute.execution.python.submit_execution")
|
|
232
|
-
@patch("lyceum.external.compute.execution.python.validate_machine_type")
|
|
233
|
-
@patch("lyceum.external.compute.execution.python.config")
|
|
234
|
-
def test_script_args_passed_through(
|
|
235
|
-
self,
|
|
236
|
-
mock_config,
|
|
237
|
-
mock_validate,
|
|
238
|
-
mock_submit,
|
|
239
|
-
mock_stream,
|
|
240
|
-
cli_runner,
|
|
241
|
-
mock_execution_id,
|
|
242
|
-
):
|
|
243
|
-
mock_config.get_client.return_value = None
|
|
244
|
-
mock_validate.return_value = True
|
|
245
|
-
mock_submit.return_value = (mock_execution_id, "http://stream.url")
|
|
246
|
-
mock_stream.return_value = True
|
|
247
|
-
|
|
248
|
-
result = cli_runner.invoke(
|
|
249
|
-
python_app, ["run", "print('hi')", "--", "--epochs", "10", "--lr", "0.001"]
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
assert result.exit_code == 0
|
|
253
|
-
# Verify that the code was modified to include sys.argv
|
|
254
|
-
call_args = mock_submit.call_args[0][0]
|
|
255
|
-
assert "sys.argv" in call_args["code"]
|
|
256
|
-
assert "--epochs" in call_args["code"]
|
|
257
|
-
assert "10" in call_args["code"]
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
class TestRunPythonWithDebug:
|
|
261
|
-
"""Tests for run_python command with debug flag."""
|
|
262
|
-
|
|
263
|
-
@pytest.fixture
|
|
264
|
-
def cli_runner(self):
|
|
265
|
-
return CliRunner()
|
|
266
|
-
|
|
267
|
-
@patch("lyceum.external.compute.execution.python.stream_execution_output")
|
|
268
|
-
@patch("lyceum.external.compute.execution.python.submit_execution")
|
|
269
|
-
@patch("lyceum.external.compute.execution.python.validate_machine_type")
|
|
270
|
-
@patch("lyceum.external.compute.execution.python.config")
|
|
271
|
-
def test_debug_flag(
|
|
272
|
-
self,
|
|
273
|
-
mock_config,
|
|
274
|
-
mock_validate,
|
|
275
|
-
mock_submit,
|
|
276
|
-
mock_stream,
|
|
277
|
-
cli_runner,
|
|
278
|
-
mock_execution_id,
|
|
279
|
-
):
|
|
280
|
-
mock_config.get_client.return_value = None
|
|
281
|
-
mock_validate.return_value = True
|
|
282
|
-
mock_submit.return_value = (mock_execution_id, "http://stream.url")
|
|
283
|
-
mock_stream.return_value = True
|
|
284
|
-
|
|
285
|
-
result = cli_runner.invoke(python_app, ["run", "print('hi')", "--debug"])
|
|
286
|
-
|
|
287
|
-
assert result.exit_code == 0
|
|
288
|
-
# Debug output should contain payload summary
|
|
289
|
-
assert "DEBUG" in result.output or "Payload" in result.output or mock_execution_id in result.output
|
tests/unit/shared/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Unit tests for shared utilities."""
|
tests/unit/shared/test_config.py
DELETED
|
@@ -1,341 +0,0 @@
|
|
|
1
|
-
"""Tests for authentication config."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import time
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from unittest.mock import MagicMock, patch
|
|
7
|
-
|
|
8
|
-
import jwt
|
|
9
|
-
import pytest
|
|
10
|
-
from click.exceptions import Exit as ClickExit
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class TestConfigLoad:
|
|
14
|
-
"""Tests for Config.load() method."""
|
|
15
|
-
|
|
16
|
-
def test_load_existing_config(self, tmp_path):
|
|
17
|
-
config_dir = tmp_path / ".lyceum"
|
|
18
|
-
config_dir.mkdir(parents=True)
|
|
19
|
-
config_file = config_dir / "config.json"
|
|
20
|
-
config_data = {
|
|
21
|
-
"api_key": "test-api-key",
|
|
22
|
-
"refresh_token": "test-refresh-token",
|
|
23
|
-
"base_url": "https://api.test.dev",
|
|
24
|
-
}
|
|
25
|
-
config_file.write_text(json.dumps(config_data))
|
|
26
|
-
|
|
27
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
28
|
-
from lyceum.shared.config import Config
|
|
29
|
-
|
|
30
|
-
config = Config()
|
|
31
|
-
|
|
32
|
-
assert config.api_key == "test-api-key"
|
|
33
|
-
assert config.refresh_token == "test-refresh-token"
|
|
34
|
-
assert config.base_url == "https://api.test.dev"
|
|
35
|
-
|
|
36
|
-
def test_load_missing_file(self, tmp_path):
|
|
37
|
-
missing_path = tmp_path / ".lyceum" / "config.json"
|
|
38
|
-
|
|
39
|
-
with patch("lyceum.shared.config.CONFIG_FILE", missing_path):
|
|
40
|
-
from lyceum.shared.config import Config
|
|
41
|
-
|
|
42
|
-
config = Config()
|
|
43
|
-
|
|
44
|
-
assert config.api_key is None
|
|
45
|
-
assert config.refresh_token is None
|
|
46
|
-
|
|
47
|
-
def test_load_partial_config(self, tmp_path):
|
|
48
|
-
config_dir = tmp_path / ".lyceum"
|
|
49
|
-
config_dir.mkdir(parents=True)
|
|
50
|
-
config_file = config_dir / "config.json"
|
|
51
|
-
config_data = {"api_key": "only-api-key"}
|
|
52
|
-
config_file.write_text(json.dumps(config_data))
|
|
53
|
-
|
|
54
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
55
|
-
from lyceum.shared.config import Config
|
|
56
|
-
|
|
57
|
-
config = Config()
|
|
58
|
-
|
|
59
|
-
assert config.api_key == "only-api-key"
|
|
60
|
-
assert config.refresh_token is None
|
|
61
|
-
|
|
62
|
-
def test_load_malformed_json(self, tmp_path):
|
|
63
|
-
config_dir = tmp_path / ".lyceum"
|
|
64
|
-
config_dir.mkdir(parents=True)
|
|
65
|
-
config_file = config_dir / "config.json"
|
|
66
|
-
config_file.write_text("not valid json {{{")
|
|
67
|
-
|
|
68
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
69
|
-
from lyceum.shared.config import Config
|
|
70
|
-
|
|
71
|
-
config = Config()
|
|
72
|
-
# Should not crash, just use defaults
|
|
73
|
-
assert config.api_key is None
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
class TestConfigSave:
|
|
77
|
-
"""Tests for Config.save() method."""
|
|
78
|
-
|
|
79
|
-
def test_save_creates_directory(self, tmp_path):
|
|
80
|
-
config_dir = tmp_path / ".lyceum"
|
|
81
|
-
config_file = config_dir / "config.json"
|
|
82
|
-
|
|
83
|
-
with (
|
|
84
|
-
patch("lyceum.shared.config.CONFIG_DIR", config_dir),
|
|
85
|
-
patch("lyceum.shared.config.CONFIG_FILE", config_file),
|
|
86
|
-
):
|
|
87
|
-
from lyceum.shared.config import Config
|
|
88
|
-
|
|
89
|
-
config = Config()
|
|
90
|
-
config.api_key = "new-api-key"
|
|
91
|
-
config.refresh_token = "new-refresh-token"
|
|
92
|
-
config.save()
|
|
93
|
-
|
|
94
|
-
assert config_dir.exists()
|
|
95
|
-
assert config_file.exists()
|
|
96
|
-
|
|
97
|
-
saved_data = json.loads(config_file.read_text())
|
|
98
|
-
assert saved_data["api_key"] == "new-api-key"
|
|
99
|
-
assert saved_data["refresh_token"] == "new-refresh-token"
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
class TestConfigIsTokenExpired:
|
|
103
|
-
"""Tests for Config.is_token_expired() method."""
|
|
104
|
-
|
|
105
|
-
def test_valid_token_not_expired(self, tmp_path):
|
|
106
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
107
|
-
|
|
108
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
109
|
-
from lyceum.shared.config import Config
|
|
110
|
-
|
|
111
|
-
# Create token expiring in 1 hour
|
|
112
|
-
future_exp = int(time.time()) + 3600
|
|
113
|
-
token = jwt.encode({"exp": future_exp}, "secret", algorithm="HS256")
|
|
114
|
-
|
|
115
|
-
config = Config()
|
|
116
|
-
config.api_key = token
|
|
117
|
-
|
|
118
|
-
assert config.is_token_expired() is False
|
|
119
|
-
|
|
120
|
-
def test_expired_token(self, tmp_path):
|
|
121
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
122
|
-
|
|
123
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
124
|
-
from lyceum.shared.config import Config
|
|
125
|
-
|
|
126
|
-
# Create token that expired 1 hour ago
|
|
127
|
-
past_exp = int(time.time()) - 3600
|
|
128
|
-
token = jwt.encode({"exp": past_exp}, "secret", algorithm="HS256")
|
|
129
|
-
|
|
130
|
-
config = Config()
|
|
131
|
-
config.api_key = token
|
|
132
|
-
|
|
133
|
-
assert config.is_token_expired() is True
|
|
134
|
-
|
|
135
|
-
def test_grace_period(self, tmp_path):
|
|
136
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
137
|
-
|
|
138
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
139
|
-
from lyceum.shared.config import Config
|
|
140
|
-
|
|
141
|
-
# Create token expiring in 3 minutes (within 5-min grace period)
|
|
142
|
-
near_exp = int(time.time()) + 180
|
|
143
|
-
token = jwt.encode({"exp": near_exp}, "secret", algorithm="HS256")
|
|
144
|
-
|
|
145
|
-
config = Config()
|
|
146
|
-
config.api_key = token
|
|
147
|
-
|
|
148
|
-
assert config.is_token_expired() is True
|
|
149
|
-
|
|
150
|
-
def test_legacy_api_key_never_expired(self, tmp_path):
|
|
151
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
152
|
-
|
|
153
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
154
|
-
from lyceum.shared.config import Config
|
|
155
|
-
|
|
156
|
-
config = Config()
|
|
157
|
-
config.api_key = "lk_test_api_key_12345"
|
|
158
|
-
|
|
159
|
-
assert config.is_token_expired() is False
|
|
160
|
-
|
|
161
|
-
def test_missing_token(self, tmp_path):
|
|
162
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
163
|
-
|
|
164
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
165
|
-
from lyceum.shared.config import Config
|
|
166
|
-
|
|
167
|
-
config = Config()
|
|
168
|
-
config.api_key = None
|
|
169
|
-
|
|
170
|
-
assert config.is_token_expired() is True
|
|
171
|
-
|
|
172
|
-
def test_invalid_jwt_token(self, tmp_path):
|
|
173
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
174
|
-
|
|
175
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
176
|
-
from lyceum.shared.config import Config
|
|
177
|
-
|
|
178
|
-
config = Config()
|
|
179
|
-
config.api_key = "not-a-valid-jwt-token"
|
|
180
|
-
|
|
181
|
-
# Invalid JWT should be treated as expired
|
|
182
|
-
assert config.is_token_expired() is True
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
class TestConfigRefreshToken:
|
|
186
|
-
"""Tests for Config.refresh_access_token() method."""
|
|
187
|
-
|
|
188
|
-
def test_refresh_success(self, tmp_path):
|
|
189
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
190
|
-
config_dir = tmp_path / ".lyceum"
|
|
191
|
-
|
|
192
|
-
with (
|
|
193
|
-
patch("lyceum.shared.config.CONFIG_FILE", config_file),
|
|
194
|
-
patch("lyceum.shared.config.CONFIG_DIR", config_dir),
|
|
195
|
-
patch("lyceum.shared.config.create_client") as mock_create_client,
|
|
196
|
-
):
|
|
197
|
-
mock_session = MagicMock()
|
|
198
|
-
mock_session.access_token = "new-access-token"
|
|
199
|
-
mock_session.refresh_token = "new-refresh-token"
|
|
200
|
-
|
|
201
|
-
mock_response = MagicMock()
|
|
202
|
-
mock_response.session = mock_session
|
|
203
|
-
|
|
204
|
-
mock_auth = MagicMock()
|
|
205
|
-
mock_auth.refresh_session.return_value = mock_response
|
|
206
|
-
|
|
207
|
-
mock_client = MagicMock()
|
|
208
|
-
mock_client.auth = mock_auth
|
|
209
|
-
mock_create_client.return_value = mock_client
|
|
210
|
-
|
|
211
|
-
from lyceum.shared.config import Config
|
|
212
|
-
|
|
213
|
-
config = Config()
|
|
214
|
-
config.refresh_token = "old-refresh-token"
|
|
215
|
-
|
|
216
|
-
result = config.refresh_access_token()
|
|
217
|
-
|
|
218
|
-
assert result is True
|
|
219
|
-
assert config.api_key == "new-access-token"
|
|
220
|
-
assert config.refresh_token == "new-refresh-token"
|
|
221
|
-
|
|
222
|
-
def test_refresh_failure_no_session(self, tmp_path):
|
|
223
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
224
|
-
|
|
225
|
-
with (
|
|
226
|
-
patch("lyceum.shared.config.CONFIG_FILE", config_file),
|
|
227
|
-
patch("lyceum.shared.config.create_client") as mock_create_client,
|
|
228
|
-
):
|
|
229
|
-
mock_response = MagicMock()
|
|
230
|
-
mock_response.session = None
|
|
231
|
-
|
|
232
|
-
mock_auth = MagicMock()
|
|
233
|
-
mock_auth.refresh_session.return_value = mock_response
|
|
234
|
-
|
|
235
|
-
mock_client = MagicMock()
|
|
236
|
-
mock_client.auth = mock_auth
|
|
237
|
-
mock_create_client.return_value = mock_client
|
|
238
|
-
|
|
239
|
-
from lyceum.shared.config import Config
|
|
240
|
-
|
|
241
|
-
config = Config()
|
|
242
|
-
config.refresh_token = "invalid-token"
|
|
243
|
-
|
|
244
|
-
result = config.refresh_access_token()
|
|
245
|
-
|
|
246
|
-
assert result is False
|
|
247
|
-
|
|
248
|
-
def test_refresh_failure_no_refresh_token(self, tmp_path):
|
|
249
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
250
|
-
|
|
251
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
252
|
-
from lyceum.shared.config import Config
|
|
253
|
-
|
|
254
|
-
config = Config()
|
|
255
|
-
config.refresh_token = None
|
|
256
|
-
|
|
257
|
-
result = config.refresh_access_token()
|
|
258
|
-
|
|
259
|
-
assert result is False
|
|
260
|
-
|
|
261
|
-
def test_refresh_skipped_for_legacy_key(self, tmp_path):
|
|
262
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
263
|
-
|
|
264
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
265
|
-
from lyceum.shared.config import Config
|
|
266
|
-
|
|
267
|
-
config = Config()
|
|
268
|
-
config.api_key = "lk_test_api_key"
|
|
269
|
-
config.refresh_token = "some-refresh-token"
|
|
270
|
-
|
|
271
|
-
result = config.refresh_access_token()
|
|
272
|
-
|
|
273
|
-
assert result is False
|
|
274
|
-
|
|
275
|
-
def test_refresh_exception_handling(self, tmp_path):
|
|
276
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
277
|
-
|
|
278
|
-
with (
|
|
279
|
-
patch("lyceum.shared.config.CONFIG_FILE", config_file),
|
|
280
|
-
patch("lyceum.shared.config.create_client") as mock_create_client,
|
|
281
|
-
):
|
|
282
|
-
mock_create_client.side_effect = Exception("Connection error")
|
|
283
|
-
|
|
284
|
-
from lyceum.shared.config import Config
|
|
285
|
-
|
|
286
|
-
config = Config()
|
|
287
|
-
config.refresh_token = "some-token"
|
|
288
|
-
|
|
289
|
-
result = config.refresh_access_token()
|
|
290
|
-
|
|
291
|
-
assert result is False
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
class TestConfigGetClient:
|
|
295
|
-
"""Tests for Config.get_client() method."""
|
|
296
|
-
|
|
297
|
-
def test_get_client_no_api_key(self, tmp_path):
|
|
298
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
299
|
-
|
|
300
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
301
|
-
from lyceum.shared.config import Config
|
|
302
|
-
|
|
303
|
-
config = Config()
|
|
304
|
-
config.api_key = None
|
|
305
|
-
|
|
306
|
-
with pytest.raises(ClickExit):
|
|
307
|
-
config.get_client()
|
|
308
|
-
|
|
309
|
-
def test_get_client_expired_token_refresh_fails(self, tmp_path):
|
|
310
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
311
|
-
|
|
312
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
313
|
-
from lyceum.shared.config import Config
|
|
314
|
-
|
|
315
|
-
# Create expired token
|
|
316
|
-
past_exp = int(time.time()) - 3600
|
|
317
|
-
token = jwt.encode({"exp": past_exp}, "secret", algorithm="HS256")
|
|
318
|
-
|
|
319
|
-
config = Config()
|
|
320
|
-
config.api_key = token
|
|
321
|
-
config.refresh_token = None # No refresh token
|
|
322
|
-
|
|
323
|
-
with pytest.raises(ClickExit):
|
|
324
|
-
config.get_client()
|
|
325
|
-
|
|
326
|
-
def test_get_client_valid_token(self, tmp_path):
|
|
327
|
-
config_file = tmp_path / ".lyceum" / "config.json"
|
|
328
|
-
|
|
329
|
-
with patch("lyceum.shared.config.CONFIG_FILE", config_file):
|
|
330
|
-
from lyceum.shared.config import Config
|
|
331
|
-
|
|
332
|
-
# Create token expiring in 1 hour
|
|
333
|
-
future_exp = int(time.time()) + 3600
|
|
334
|
-
token = jwt.encode({"exp": future_exp}, "secret", algorithm="HS256")
|
|
335
|
-
|
|
336
|
-
config = Config()
|
|
337
|
-
config.api_key = token
|
|
338
|
-
|
|
339
|
-
result = config.get_client()
|
|
340
|
-
|
|
341
|
-
assert result is config
|