Jeremiah Lowin commited on
Commit
75ee53f
·
unverified ·
2 Parent(s): 9d287ee752553b

Merge pull request #394 from didier-durand/add-cli-tests

Browse files
Files changed (1) hide show
  1. tests/cli/test_cli.py +478 -0
tests/cli/test_cli.py ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the CLI module."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from unittest.mock import MagicMock, Mock, patch
6
+
7
+ import pytest
8
+ from typer.testing import CliRunner
9
+
10
+ from fastmcp.cli import cli
11
+
12
+ # Set up test runner
13
+ runner = CliRunner()
14
+
15
+
16
+ @pytest.fixture
17
+ def mock_console():
18
+ """Mock the rich console to test output."""
19
+ with patch("fastmcp.cli.cli.console") as mock_console:
20
+ yield mock_console
21
+
22
+
23
+ @pytest.fixture
24
+ def mock_logger():
25
+ """Mock the logger to test logging."""
26
+ with patch("fastmcp.cli.cli.logger") as mock_logger:
27
+ yield mock_logger
28
+
29
+
30
+ @pytest.fixture
31
+ def mock_exit():
32
+ """Mock sys.exit to prevent tests from exiting."""
33
+ with patch("sys.exit") as mock_exit:
34
+ yield mock_exit
35
+
36
+
37
+ @pytest.fixture
38
+ def temp_python_file(tmp_path):
39
+ """Create a temporary Python file with a test server."""
40
+ server_code = """
41
+ from mcp import Server
42
+
43
+ class TestServer(Server):
44
+ name = "test_server"
45
+ dependencies = ["package1", "package2"]
46
+
47
+ def run(self, **kwargs):
48
+ print("Running server with", kwargs)
49
+
50
+ mcp = TestServer()
51
+ server = TestServer()
52
+ app = TestServer()
53
+ custom_server = TestServer()
54
+ """
55
+ file_path = tmp_path / "test_server.py"
56
+ file_path.write_text(server_code)
57
+ return file_path
58
+
59
+
60
+ @pytest.fixture
61
+ def temp_env_file(tmp_path):
62
+ """Create a temporary .env file."""
63
+ env_content = """
64
+ TEST_VAR1=value1
65
+ TEST_VAR2=value2
66
+ """
67
+ env_path = tmp_path / ".env"
68
+ env_path.write_text(env_content)
69
+ return env_path
70
+
71
+
72
+ class TestHelperFunctions:
73
+ """Tests for helper functions in cli.py."""
74
+
75
+ def test_get_npx_command_unix(self):
76
+ """Test getting npx command on unix systems."""
77
+ with patch("sys.platform", "linux"):
78
+ with patch("subprocess.run") as mock_run:
79
+ mock_run.return_value = Mock(returncode=0)
80
+ assert cli._get_npx_command() == "npx"
81
+
82
+ def test_get_npx_command_windows(self):
83
+ """Test getting npx command on Windows."""
84
+ with patch("sys.platform", "win32"):
85
+ with patch("subprocess.run") as mock_run:
86
+ # First try fails, second succeeds
87
+ mock_run.side_effect = [
88
+ subprocess.CalledProcessError(1, "npx.cmd"),
89
+ Mock(returncode=0),
90
+ ]
91
+ assert cli._get_npx_command() == "npx.exe"
92
+
93
+ def test_get_npx_command_not_found(self):
94
+ """Test when npx command is not found."""
95
+ with patch("sys.platform", "win32"):
96
+ with patch("subprocess.run") as mock_run:
97
+ mock_run.side_effect = [
98
+ subprocess.CalledProcessError(1, "npx.cmd"),
99
+ subprocess.CalledProcessError(1, "npx.exe"),
100
+ subprocess.CalledProcessError(1, "npx"),
101
+ ]
102
+ assert cli._get_npx_command() is None
103
+
104
+ def test_parse_env_var_valid(self):
105
+ """Test parsing valid environment variables."""
106
+ assert cli._parse_env_var("KEY=VALUE") == ("KEY", "VALUE")
107
+ assert cli._parse_env_var("KEY=") == ("KEY", "")
108
+ assert cli._parse_env_var("KEY=VALUE=WITH=EQUALS") == (
109
+ "KEY",
110
+ "VALUE=WITH=EQUALS",
111
+ )
112
+ assert cli._parse_env_var(" KEY = VALUE ") == ("KEY", "VALUE")
113
+
114
+ def test_build_uv_command_basic(self):
115
+ """Test building basic uv command."""
116
+ cmd = cli._build_uv_command("file.py")
117
+ assert cmd == ["uv", "run", "--with", "fastmcp", "fastmcp", "run", "file.py"]
118
+
119
+ def test_build_uv_command_with_editable(self):
120
+ """Test building uv command with editable flag."""
121
+ project_path = Path("/path/to/project")
122
+ cmd = cli._build_uv_command("file.py", with_editable=project_path)
123
+ assert cmd == [
124
+ "uv",
125
+ "run",
126
+ "--with",
127
+ "fastmcp",
128
+ "--with-editable",
129
+ str(project_path),
130
+ "fastmcp",
131
+ "run",
132
+ "file.py",
133
+ ]
134
+
135
+ def test_build_uv_command_with_packages(self):
136
+ """Test building uv command with additional packages."""
137
+ cmd = cli._build_uv_command("file.py", with_packages=["pkg1", "pkg2"])
138
+ assert cmd == [
139
+ "uv",
140
+ "run",
141
+ "--with",
142
+ "fastmcp",
143
+ "--with",
144
+ "pkg1",
145
+ "--with",
146
+ "pkg2",
147
+ "fastmcp",
148
+ "run",
149
+ "file.py",
150
+ ]
151
+
152
+ def test_build_uv_command_full(self):
153
+ """Test building full uv command with all options."""
154
+ project_path = Path("/path/to/project")
155
+ cmd = cli._build_uv_command(
156
+ "file.py:server",
157
+ with_editable=project_path,
158
+ with_packages=["pkg1", "pkg2"],
159
+ )
160
+ assert cmd == [
161
+ "uv",
162
+ "run",
163
+ "--with",
164
+ "fastmcp",
165
+ "--with-editable",
166
+ str(project_path),
167
+ "--with",
168
+ "pkg1",
169
+ "--with",
170
+ "pkg2",
171
+ "fastmcp",
172
+ "run",
173
+ "file.py:server",
174
+ ]
175
+
176
+ def test_parse_file_path_simple(self):
177
+ """Test parsing simple file path."""
178
+ with (
179
+ patch("pathlib.Path.exists") as mock_exists,
180
+ patch("pathlib.Path.is_file") as mock_is_file,
181
+ patch("pathlib.Path.expanduser") as mock_expanduser,
182
+ patch("pathlib.Path.resolve") as mock_resolve,
183
+ ):
184
+ mock_exists.return_value = True
185
+ mock_is_file.return_value = True
186
+ mock_expanduser.return_value = Path("file.py")
187
+ mock_resolve.return_value = Path("file.py")
188
+
189
+ path, obj = cli._parse_file_path("file.py")
190
+ assert path == Path("file.py")
191
+ assert obj is None
192
+
193
+ def test_parse_file_path_with_object(self):
194
+ """Test parsing file path with object."""
195
+ with (
196
+ patch("pathlib.Path.exists") as mock_exists,
197
+ patch("pathlib.Path.is_file") as mock_is_file,
198
+ patch("pathlib.Path.expanduser") as mock_expanduser,
199
+ patch("pathlib.Path.resolve") as mock_resolve,
200
+ ):
201
+ mock_exists.return_value = True
202
+ mock_is_file.return_value = True
203
+ mock_expanduser.return_value = Path("file.py")
204
+ mock_resolve.return_value = Path("file.py")
205
+
206
+ path, obj = cli._parse_file_path("file.py:server")
207
+ assert path == Path("file.py")
208
+ assert obj == "server"
209
+
210
+ def test_parse_file_path_windows(self):
211
+ """Test parsing Windows file path."""
212
+ with (
213
+ patch("pathlib.Path.exists") as mock_exists,
214
+ patch("pathlib.Path.is_file") as mock_is_file,
215
+ patch("pathlib.Path.expanduser") as mock_expanduser,
216
+ patch("pathlib.Path.resolve") as mock_resolve,
217
+ ):
218
+ mock_exists.return_value = True
219
+ mock_is_file.return_value = True
220
+ mock_expanduser.return_value = Path("C:/path/file.py")
221
+ mock_resolve.return_value = Path("C:/path/file.py")
222
+
223
+ path, obj = cli._parse_file_path("C:/path/file.py:server")
224
+ assert path == Path("C:/path/file.py")
225
+ assert obj == "server"
226
+
227
+ def test_parse_file_path_not_file(self, mock_exit, mock_logger):
228
+ """Test parsing path that is not a file."""
229
+ with (
230
+ patch("pathlib.Path.exists") as mock_exists,
231
+ patch("pathlib.Path.is_file") as mock_is_file,
232
+ patch("pathlib.Path.expanduser") as mock_expanduser,
233
+ patch("pathlib.Path.resolve") as mock_resolve,
234
+ ):
235
+ mock_exists.return_value = True
236
+ mock_is_file.return_value = False
237
+ mock_expanduser.return_value = Path("directory")
238
+ mock_resolve.return_value = Path("directory")
239
+
240
+ cli._parse_file_path("directory")
241
+ mock_logger.error.assert_called_once()
242
+ mock_exit.assert_called_once_with(1)
243
+
244
+
245
+ class TestVersionCommand:
246
+ """Tests for the version command."""
247
+
248
+ def test_version_early_exit_with_resilient_parsing(self):
249
+ """Test version command exits early with resilient parsing."""
250
+ ctx = MagicMock()
251
+ ctx.resilient_parsing = True
252
+ result = cli.version(ctx)
253
+ assert result is None
254
+
255
+
256
+ class TestDevCommand:
257
+ """Tests for the dev command."""
258
+
259
+ def test_dev_command_success(self, temp_python_file, mock_logger):
260
+ """Test successful dev command execution."""
261
+ with (
262
+ patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
263
+ patch("fastmcp.cli.cli._import_server") as mock_import,
264
+ patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
265
+ patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
266
+ patch("subprocess.run") as mock_run,
267
+ ):
268
+ mock_parse.return_value = (temp_python_file, None)
269
+ mock_server = MagicMock()
270
+ mock_server.dependencies = ["extra_dep"]
271
+ mock_import.return_value = mock_server
272
+ mock_get_npx.return_value = "npx"
273
+ mock_build_uv.return_value = ["uv", "command"]
274
+ mock_run.return_value = MagicMock(returncode=0)
275
+
276
+ result = runner.invoke(cli.app, ["dev", str(temp_python_file)])
277
+ assert result.exit_code == 0
278
+ mock_run.assert_called_once()
279
+
280
+ # Check dependencies were passed correctly
281
+ mock_build_uv.assert_called_once_with(
282
+ str(temp_python_file), None, ["extra_dep"]
283
+ )
284
+
285
+ def test_dev_command_with_ui_port(self, temp_python_file):
286
+ """Test dev command with UI port."""
287
+ with (
288
+ patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
289
+ patch("fastmcp.cli.cli._import_server") as mock_import,
290
+ patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
291
+ patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
292
+ patch("subprocess.run") as mock_run,
293
+ ):
294
+ mock_parse.return_value = (temp_python_file, None)
295
+ mock_import.return_value = MagicMock(dependencies=[])
296
+ mock_get_npx.return_value = "npx"
297
+ mock_build_uv.return_value = ["uv", "command"]
298
+ mock_run.return_value = MagicMock(returncode=0)
299
+
300
+ result = runner.invoke(
301
+ cli.app, ["dev", str(temp_python_file), "--ui-port", "3000"]
302
+ )
303
+ assert result.exit_code == 0
304
+
305
+ # Check environment variables were set
306
+ env = mock_run.call_args[1]["env"]
307
+ assert "CLIENT_PORT" in env
308
+ assert env["CLIENT_PORT"] == "3000"
309
+
310
+ def test_dev_command_with_server_port(self, temp_python_file):
311
+ """Test dev command with server port."""
312
+ with (
313
+ patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
314
+ patch("fastmcp.cli.cli._import_server") as mock_import,
315
+ patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
316
+ patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
317
+ patch("subprocess.run") as mock_run,
318
+ ):
319
+ mock_parse.return_value = (temp_python_file, None)
320
+ mock_import.return_value = MagicMock(dependencies=[])
321
+ mock_get_npx.return_value = "npx"
322
+ mock_build_uv.return_value = ["uv", "command"]
323
+ mock_run.return_value = MagicMock(returncode=0)
324
+
325
+ result = runner.invoke(
326
+ cli.app, ["dev", str(temp_python_file), "--server-port", "8080"]
327
+ )
328
+ assert result.exit_code == 0
329
+
330
+ # Check environment variables were set
331
+ env = mock_run.call_args[1]["env"]
332
+ assert "SERVER_PORT" in env
333
+ assert env["SERVER_PORT"] == "8080"
334
+
335
+ def test_dev_command_inspector_version(self, temp_python_file):
336
+ """Test dev command with specific inspector version."""
337
+ with (
338
+ patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
339
+ patch("fastmcp.cli.cli._import_server") as mock_import,
340
+ patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
341
+ patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
342
+ patch("subprocess.run") as mock_run,
343
+ ):
344
+ mock_parse.return_value = (temp_python_file, None)
345
+ mock_import.return_value = MagicMock(dependencies=[])
346
+ mock_get_npx.return_value = "npx"
347
+ mock_build_uv.return_value = ["uv", "command"]
348
+ mock_run.return_value = MagicMock(returncode=0)
349
+
350
+ result = runner.invoke(
351
+ cli.app, ["dev", str(temp_python_file), "--inspector-version", "1.0.0"]
352
+ )
353
+ assert result.exit_code == 0
354
+
355
+ # Check inspector version was used
356
+ inspector_cmd = mock_run.call_args[0][0][1]
357
+ assert inspector_cmd == "@modelcontextprotocol/inspector@1.0.0"
358
+
359
+
360
+ class TestRunCommand:
361
+ """Tests for the run command."""
362
+
363
+ def test_run_command_success(self, temp_python_file, mock_logger):
364
+ """Test successful run command execution."""
365
+ with (
366
+ patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
367
+ patch("fastmcp.cli.cli._import_server") as mock_import,
368
+ ):
369
+ mock_parse.return_value = (temp_python_file, None)
370
+ mock_server = MagicMock()
371
+ mock_server.name = "test_server"
372
+ mock_import.return_value = mock_server
373
+
374
+ result = runner.invoke(cli.app, ["run", str(temp_python_file)])
375
+ assert result.exit_code == 0
376
+ mock_server.run.assert_called_once_with()
377
+ mock_logger.info.assert_called_with(
378
+ f'Found server "test_server" in {temp_python_file}'
379
+ )
380
+
381
+ def test_run_command_with_transport(self, temp_python_file):
382
+ """Test run command with transport option."""
383
+ with (
384
+ patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
385
+ patch("fastmcp.cli.cli._import_server") as mock_import,
386
+ ):
387
+ mock_parse.return_value = (temp_python_file, None)
388
+ mock_server = MagicMock()
389
+ mock_server.name = "test_server"
390
+ mock_import.return_value = mock_server
391
+
392
+ result = runner.invoke(
393
+ cli.app, ["run", str(temp_python_file), "--transport", "sse"]
394
+ )
395
+ assert result.exit_code == 0
396
+ mock_server.run.assert_called_once_with(transport="sse")
397
+
398
+ def test_run_command_with_host(self, temp_python_file):
399
+ """Test run command with host option."""
400
+ with (
401
+ patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
402
+ patch("fastmcp.cli.cli._import_server") as mock_import,
403
+ ):
404
+ mock_parse.return_value = (temp_python_file, None)
405
+ mock_server = MagicMock()
406
+ mock_server.name = "test_server"
407
+ mock_import.return_value = mock_server
408
+
409
+ result = runner.invoke(
410
+ cli.app, ["run", str(temp_python_file), "--host", "0.0.0.0"]
411
+ )
412
+ assert result.exit_code == 0
413
+ mock_server.run.assert_called_once_with(host="0.0.0.0")
414
+
415
+ def test_run_command_with_port(self, temp_python_file):
416
+ """Test run command with port option."""
417
+ with (
418
+ patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
419
+ patch("fastmcp.cli.cli._import_server") as mock_import,
420
+ ):
421
+ mock_parse.return_value = (temp_python_file, None)
422
+ mock_server = MagicMock()
423
+ mock_server.name = "test_server"
424
+ mock_import.return_value = mock_server
425
+
426
+ result = runner.invoke(
427
+ cli.app, ["run", str(temp_python_file), "--port", "8080"]
428
+ )
429
+ assert result.exit_code == 0
430
+ mock_server.run.assert_called_once_with(port=8080)
431
+
432
+ def test_run_command_with_log_level(self, temp_python_file):
433
+ """Test run command with log level option."""
434
+ with (
435
+ patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
436
+ patch("fastmcp.cli.cli._import_server") as mock_import,
437
+ ):
438
+ mock_parse.return_value = (temp_python_file, None)
439
+ mock_server = MagicMock()
440
+ mock_server.name = "test_server"
441
+ mock_import.return_value = mock_server
442
+
443
+ result = runner.invoke(
444
+ cli.app, ["run", str(temp_python_file), "--log-level", "DEBUG"]
445
+ )
446
+ assert result.exit_code == 0
447
+ mock_server.run.assert_called_once_with(log_level="DEBUG")
448
+
449
+ def test_run_command_with_multiple_options(self, temp_python_file):
450
+ """Test run command with multiple options."""
451
+ with (
452
+ patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
453
+ patch("fastmcp.cli.cli._import_server") as mock_import,
454
+ ):
455
+ mock_parse.return_value = (temp_python_file, None)
456
+ mock_server = MagicMock()
457
+ mock_server.name = "test_server"
458
+ mock_import.return_value = mock_server
459
+
460
+ result = runner.invoke(
461
+ cli.app,
462
+ [
463
+ "run",
464
+ str(temp_python_file),
465
+ "--transport",
466
+ "sse",
467
+ "--host",
468
+ "0.0.0.0",
469
+ "--port",
470
+ "8080",
471
+ "--log-level",
472
+ "DEBUG",
473
+ ],
474
+ )
475
+ assert result.exit_code == 0
476
+ mock_server.run.assert_called_once_with(
477
+ transport="sse", host="0.0.0.0", port=8080, log_level="DEBUG"
478
+ )