Skip to content

Commit 6a9ca1d

Browse files
committed
allow commands from settings
1 parent 2a3080b commit 6a9ca1d

File tree

3 files changed

+179
-15
lines changed

3 files changed

+179
-15
lines changed

README.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ Configuration
7878
- ``normal``, ``silent``, ``skip`` or ``error``
7979
- ``mypy`` **parameter** ``follow-imports``. In ``mypy`` this is ``normal`` by default. We set it ``silent``, to sort out unwanted results. This can cause cache invalidation if you also run ``mypy`` in other ways. Setting this to ``normal`` avoids this at the cost of a small performance penalty.
8080
- ``silent``
81+
* - ``mypy_command``
82+
- ``pylsp.plugins.pylsp_mypy.mypy_command``
83+
- ``array`` of ``string`` items
84+
- **The command to run mypy**. This is useful if you want to run mypy in a specific virtual environment. Requires env variable ``PYLSP_MYPY_ALLOW_DANGEROUS_CODE_EXECUTION`` to be set.
85+
- ``[]``
86+
* - ``dmypy_command``
87+
- ``pylsp.plugins.pylsp_mypy.dmypy_command``
88+
- ``array`` of ``string`` items
89+
- **The command to run dmypy**. This is useful if you want to run dmypy in a specific virtual environment. Requires env variable ``PYLSP_MYPY_ALLOW_DANGEROUS_CODE_EXECUTION`` to be set.
90+
- ``[]``
91+
92+
Both ``mypy_command`` and ``dmypy_command`` could be used by a malicious repo to execute arbitrary code by looking at its source with this plugin active.
93+
Still users want this feature. For security reasons this is disabled by default. If you really want it and accept the risks set the environment variable ``PYLSP_MYPY_ALLOW_DANGEROUS_CODE_EXECUTION`` in order to activate it.
8194

8295
Using a ``pyproject.toml`` for configuration, which is in fact the preferred way, your configuration could look like this:
8396

@@ -151,6 +164,26 @@ With ``report_progress`` your config could look like this:
151164
"report_progress": True
152165
}
153166

167+
With ``mypy_command`` your config could look like this:
168+
169+
::
170+
171+
{
172+
"enabled": True,
173+
"mypy_command": ["poetry", "run", "mypy"]
174+
}
175+
176+
With ``dmypy_command`` your config could look like this:
177+
178+
::
179+
180+
{
181+
"enabled": True,
182+
"live_mode": False,
183+
"dmypy": True,
184+
"dmypy_command": ["/path/to/venv/bin/dmypy"]
185+
}
186+
154187
Developing
155188
-------------
156189

pylsp_mypy/plugin.py

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,30 @@ def match_exclude_patterns(document_path: str, exclude_patterns: list) -> bool:
165165
return False
166166

167167

168+
def get_cmd(settings: Dict[str, Any], cmd: str) -> List[str]:
169+
"""
170+
Get the command to run from settings, falling back to searching the PATH.
171+
If the command is not found in the settings and is not available on the PATH, an
172+
empty list is returned.
173+
"""
174+
command_key = f"{cmd}_command"
175+
command: List[str] = settings.get(command_key, [])
176+
177+
if not (command and os.getenv("PYLSP_MYPY_ALLOW_DANGEROUS_CODE_EXECUTION")):
178+
# env var is required to allow command from settings
179+
if shutil.which(cmd): # Fallback to PATH
180+
log.debug(
181+
f"'{command_key}' not found in settings or not allowed, using '{cmd}' from PATH"
182+
)
183+
command = [cmd]
184+
else: # Fallback to API
185+
command = []
186+
187+
log.debug(f"Using {cmd} command: {command}")
188+
189+
return command
190+
191+
168192
@hookimpl
169193
def pylsp_lint(
170194
config: Config, workspace: Workspace, document: Document, is_saved: bool
@@ -304,18 +328,21 @@ def get_diagnostics(
304328
args.extend(["--incremental", "--follow-imports", settings.get("follow-imports", "silent")])
305329
args = apply_overrides(args, overrides)
306330

307-
if shutil.which("mypy"):
308-
# mypy exists on path
309-
# -> use mypy on path
331+
mypy_command: List[str] = get_cmd(settings, "mypy")
332+
333+
if mypy_command:
334+
# mypy exists on PATH or was provided by settings
335+
# -> use this mypy
310336
log.info("executing mypy args = %s on path", args)
311337
completed_process = subprocess.run(
312-
["mypy", *args], capture_output=True, **windows_flag, encoding="utf-8"
338+
[*mypy_command, *args], capture_output=True, **windows_flag, encoding="utf-8"
313339
)
314340
report = completed_process.stdout
315341
errors = completed_process.stderr
316342
exit_status = completed_process.returncode
317343
else:
318-
# mypy does not exist on path, but must exist in the env pylsp-mypy is installed in
344+
# mypy does not exist on PATH and was not provided by settings,
345+
# but must exist in the env pylsp-mypy is installed in
319346
# -> use mypy via api
320347
log.info("executing mypy args = %s via api", args)
321348
report, errors, exit_status = mypy_api.run(args)
@@ -326,11 +353,13 @@ def get_diagnostics(
326353
# If daemon is dead/absent, kill will no-op.
327354
# In either case, reset to fresh state
328355

329-
if shutil.which("dmypy"):
330-
# dmypy exists on path
331-
# -> use dmypy on path
356+
dmypy_command: List[str] = get_cmd(settings, "dmypy")
357+
358+
if dmypy_command:
359+
# dmypy exists on PATH or was provided by settings
360+
# -> use this dmypy
332361
completed_process = subprocess.run(
333-
["dmypy", "--status-file", dmypy_status_file, "status"],
362+
[*dmypy_command, "--status-file", dmypy_status_file, "status"],
334363
capture_output=True,
335364
**windows_flag,
336365
encoding="utf-8",
@@ -350,7 +379,8 @@ def get_diagnostics(
350379
encoding="utf-8",
351380
)
352381
else:
353-
# dmypy does not exist on path, but must exist in the env pylsp-mypy is installed in
382+
# dmypy does not exist on PATH and was not provided by settings,
383+
# but must exist in the env pylsp-mypy is installed in
354384
# -> use dmypy via api
355385
_, errors, exit_status = mypy_api.run_dmypy(
356386
["--status-file", dmypy_status_file, "status"]
@@ -365,18 +395,19 @@ def get_diagnostics(
365395

366396
# run to use existing daemon or restart if required
367397
args = ["--status-file", dmypy_status_file, "run", "--"] + apply_overrides(args, overrides)
368-
if shutil.which("dmypy"):
369-
# dmypy exists on path
370-
# -> use mypy on path
398+
if dmypy_command:
399+
# dmypy exists on PATH or was provided by settings
400+
# -> use this dmypy
371401
log.info("dmypy run args = %s via path", args)
372402
completed_process = subprocess.run(
373-
["dmypy", *args], capture_output=True, **windows_flag, encoding="utf-8"
403+
[*dmypy_command, *args], capture_output=True, **windows_flag, encoding="utf-8"
374404
)
375405
report = completed_process.stdout
376406
errors = completed_process.stderr
377407
exit_status = completed_process.returncode
378408
else:
379-
# dmypy does not exist on path, but must exist in the env pylsp-mypy is installed in
409+
# dmypy does not exist on PATH and was not provided by settings,
410+
# but must exist in the env pylsp-mypy is installed in
380411
# -> use dmypy via api
381412
log.info("dmypy run args = %s via api", args)
382413
report, errors, exit_status = mypy_api.run_dmypy(args)

test/test_plugin.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,3 +383,103 @@ def test_config_exclude(tmpdir, workspace):
383383
workspace.update_config({"pylsp": {"plugins": {"pylsp_mypy": {"exclude": [exclude_path]}}}})
384384
diags = plugin.pylsp_lint(workspace._config, workspace, doc, is_saved=False)
385385
assert diags == []
386+
387+
388+
@pytest.mark.parametrize(
389+
("command", "settings", "cmd_on_path", "environmentVariableSet", "expected"),
390+
[
391+
("mypy", {}, ["/bin/mypy"], True, ["mypy"]),
392+
("mypy", {}, None, True, []),
393+
("mypy", {"mypy_command": ["/path/to/mypy"]}, "/bin/mypy", True, ["/path/to/mypy"]),
394+
("mypy", {"mypy_command": ["/path/to/mypy"]}, None, True, ["/path/to/mypy"]),
395+
("dmypy", {}, "/bin/dmypy", True, ["dmypy"]),
396+
("dmypy", {}, None, True, []),
397+
("dmypy", {"dmypy_command": ["/path/to/dmypy"]}, "/bin/dmypy", True, ["/path/to/dmypy"]),
398+
("dmypy", {"dmypy_command": ["/path/to/dmypy"]}, None, True, ["/path/to/dmypy"]),
399+
("mypy", {}, ["/bin/mypy"], False, ["mypy"]),
400+
("mypy", {}, None, False, []),
401+
("mypy", {"mypy_command": ["/path/to/mypy"]}, "/bin/mypy", False, ["mypy"]),
402+
("mypy", {"mypy_command": ["/path/to/mypy"]}, None, False, []),
403+
("dmypy", {}, "/bin/dmypy", False, ["dmypy"]),
404+
("dmypy", {}, None, False, []),
405+
("dmypy", {"dmypy_command": ["/path/to/dmypy"]}, "/bin/dmypy", False, ["dmypy"]),
406+
("dmypy", {"dmypy_command": ["/path/to/dmypy"]}, None, False, []),
407+
],
408+
)
409+
def test_get_cmd(command, settings, cmd_on_path, environmentVariableSet: bool, expected):
410+
with patch("shutil.which", return_value=cmd_on_path):
411+
if environmentVariableSet:
412+
os.environ["PYLSP_MYPY_ALLOW_DANGEROUS_CODE_EXECUTION"] = "Does not matter at all"
413+
else:
414+
os.environ.pop("PYLSP_MYPY_ALLOW_DANGEROUS_CODE_EXECUTION", None)
415+
assert plugin.get_cmd(settings, command) == expected
416+
417+
418+
def test_config_overrides_mypy_command(last_diagnostics_monkeypatch, workspace):
419+
last_diagnostics_monkeypatch.setattr(
420+
FakeConfig,
421+
"plugin_settings",
422+
lambda _, p: (
423+
{
424+
"mypy_command": ["/path/to/mypy"],
425+
}
426+
if p == "pylsp_mypy"
427+
else {}
428+
),
429+
)
430+
431+
m = Mock(wraps=lambda a, **_: Mock(returncode=0, **{"stdout": ""}))
432+
last_diagnostics_monkeypatch.setattr(plugin.subprocess, "run", m)
433+
434+
document = Document(DOC_URI, workspace, DOC_TYPE_ERR)
435+
436+
config = FakeConfig(uris.to_fs_path(workspace.root_uri))
437+
os.environ["PYLSP_MYPY_ALLOW_DANGEROUS_CODE_EXECUTION"] = "Does not matter at all"
438+
plugin.pylsp_settings(config)
439+
440+
plugin.pylsp_lint(
441+
config=config,
442+
workspace=workspace,
443+
document=document,
444+
is_saved=False,
445+
)
446+
447+
called_argv = m.call_args.args[0]
448+
called_cmd = called_argv[0]
449+
assert called_cmd == "/path/to/mypy"
450+
451+
452+
def test_config_overrides_dmypy_command(last_diagnostics_monkeypatch, workspace):
453+
last_diagnostics_monkeypatch.setattr(
454+
FakeConfig,
455+
"plugin_settings",
456+
lambda _, p: (
457+
{
458+
"dmypy": True,
459+
"live_mode": False,
460+
"dmypy_command": ["poetry", "run", "dmypy"],
461+
}
462+
if p == "pylsp_mypy"
463+
else {}
464+
),
465+
)
466+
467+
m = Mock(wraps=lambda a, **_: Mock(returncode=0, **{"stdout": ""}))
468+
last_diagnostics_monkeypatch.setattr(plugin.subprocess, "run", m)
469+
470+
document = Document(DOC_URI, workspace, DOC_TYPE_ERR)
471+
472+
config = FakeConfig(uris.to_fs_path(workspace.root_uri))
473+
os.environ["PYLSP_MYPY_ALLOW_DANGEROUS_CODE_EXECUTION"] = "Does not matter at all"
474+
plugin.pylsp_settings(config)
475+
476+
plugin.pylsp_lint(
477+
config=config,
478+
workspace=workspace,
479+
document=document,
480+
is_saved=False,
481+
)
482+
483+
called_argv = m.call_args.args[0]
484+
called_cmd = called_argv[:3]
485+
assert called_cmd == ["poetry", "run", "dmypy"]

0 commit comments

Comments
 (0)