Skip to content

Commit 68d4f9f

Browse files
authored
Fix Windows FileNotFoundError when invoking sf CLI (#121)
* Fix Windows FileNotFoundError when invoking sf CLI On Windows, subprocess.run(["sf", ...]) raises FileNotFoundError because sf is a .cmd wrapper, not a plain executable. Passing shell=True (only on win32) lets cmd.exe resolve the .cmd extension. No-op on macOS/Linux. * fix: use shutil.which to resolve sf path, remove shell=True Addresses reviewer concern about command injection risk with shell=True. shutil.which resolves the sf executable (falling back to sf.cmd on Windows), keeping shell=False everywhere while still working on Windows. * fix(tests): mock shutil.which so existing tests pass without sf installed The previous commit replaced shell=True + "sf" string with shutil.which() to resolve the sf executable path before calling subprocess.run. This moved the "sf not found" check earlier — before _run_sf_command is even defined — meaning tests that only mocked subprocess.run were now hitting the shutil.which() call for real. On CI (Linux, no SF CLI), which() returns None and raises immediately, causing all 11 SFCLITokenProvider and SFCLIDataCloudReader tests to fail with "sf command was not found" rather than exercising the error condition they were testing. Fix: mock shutil.which alongside subprocess.run in each affected test, using the same unittest.mock.patch pattern already used throughout the test suite. return_value="sf" keeps the resolved path consistent with the hardcoded "sf" string the command-list assertions in test_sf_cli.py expect. The "sf not found" tests are updated to patch shutil.which returning None instead of patching subprocess.run with FileNotFoundError, since that FileNotFoundError path was removed from the production code. No new test files, no new imports, no new patterns — all changes are confined to existing test classes and follow the same nesting style used in TestCredentialsTokenProvider and the rest of the suite.
1 parent 16e8b86 commit 68d4f9f

3 files changed

Lines changed: 41 additions & 31 deletions

File tree

src/datacustomcode/token_provider.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,19 @@ def __init__(self, sf_cli_org: str):
9797
def get_token(self) -> "AccessTokenResponse":
9898
"""Get token from Salesforce SF CLI"""
9999
import json
100+
import shutil
100101
import subprocess
101102

102103
from datacustomcode.deploy import AccessTokenResponse
103104

105+
sf_path = shutil.which("sf") or shutil.which("sf.cmd")
106+
if sf_path is None:
107+
raise RuntimeError(
108+
"The 'sf' command was not found. "
109+
"Install Salesforce CLI: "
110+
"https://developer.salesforce.com/tools/salesforcecli"
111+
)
112+
104113
def _run_sf_command(args: list[str], description: str) -> dict:
105114
try:
106115
result = subprocess.run(
@@ -110,12 +119,6 @@ def _run_sf_command(args: list[str], description: str) -> dict:
110119
check=True,
111120
timeout=120,
112121
)
113-
except FileNotFoundError as exc:
114-
raise RuntimeError(
115-
"The 'sf' command was not found. "
116-
"Install Salesforce CLI: "
117-
"https://developer.salesforce.com/tools/salesforcecli"
118-
) from exc
119122
except subprocess.TimeoutExpired as exc:
120123
raise RuntimeError(
121124
f"'{description}' timed out for org '{self.sf_cli_org}'"
@@ -142,7 +145,7 @@ def _run_sf_command(args: list[str], description: str) -> dict:
142145

143146
# Get org info from sf org display
144147
display_data = _run_sf_command(
145-
["sf", "org", "display", "--target-org", self.sf_cli_org, "--json"],
148+
[sf_path, "org", "display", "--target-org", self.sf_cli_org, "--json"],
146149
"sf org display",
147150
)
148151
result_data = display_data.get("result", {})
@@ -159,7 +162,7 @@ def _run_sf_command(args: list[str], description: str) -> dict:
159162
try:
160163
token_data = _run_sf_command(
161164
[
162-
"sf",
165+
sf_path,
163166
"org",
164167
"auth",
165168
"show-access-token",

tests/io/reader/test_sf_cli.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ class TestGetToken:
7373
def reader(self):
7474
return _make_reader()
7575

76+
@pytest.fixture(autouse=True)
77+
def mock_sf_which(self):
78+
with patch("shutil.which", return_value="sf"):
79+
yield
80+
7681
def _run_result(self, stdout: str) -> MagicMock:
7782
result = MagicMock()
7883
result.stdout = stdout
@@ -118,7 +123,7 @@ def test_returns_token_and_instance_url(self, reader):
118123
)
119124

120125
def test_file_not_found_raises_runtime_error(self, reader):
121-
with patch("subprocess.run", side_effect=FileNotFoundError):
126+
with patch("shutil.which", return_value=None):
122127
with pytest.raises(RuntimeError, match="'sf' command was not found"):
123128
reader._get_token()
124129

tests/test_token_provider.py

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -178,24 +178,25 @@ def test_successful_token_retrieval(self):
178178
}
179179
)
180180

181-
with patch("subprocess.run") as mock_run:
182-
mock_run.side_effect = [
183-
MagicMock(stdout=display_output),
184-
MagicMock(stdout=token_output),
185-
]
181+
with patch("shutil.which", return_value="sf"):
182+
with patch("subprocess.run") as mock_run:
183+
mock_run.side_effect = [
184+
MagicMock(stdout=display_output),
185+
MagicMock(stdout=token_output),
186+
]
186187

187-
result = provider.get_token()
188+
result = provider.get_token()
188189

189-
assert isinstance(result, AccessTokenResponse)
190-
assert result.access_token == "cli_access_token"
191-
assert result.instance_url == "https://cli.salesforce.com"
190+
assert isinstance(result, AccessTokenResponse)
191+
assert result.access_token == "cli_access_token"
192+
assert result.instance_url == "https://cli.salesforce.com"
192193

193-
# Verify both commands were called
194-
assert mock_run.call_count == 2
195-
display_call = mock_run.call_args_list[0]
196-
token_call = mock_run.call_args_list[1]
197-
assert "org" in display_call[0][0] and "display" in display_call[0][0]
198-
assert "show-access-token" in token_call[0][0]
194+
# Verify both commands were called
195+
assert mock_run.call_count == 2
196+
display_call = mock_run.call_args_list[0]
197+
token_call = mock_run.call_args_list[1]
198+
assert "org" in display_call[0][0] and "display" in display_call[0][0]
199+
assert "show-access-token" in token_call[0][0]
199200

200201
def test_fallback_to_display_token_on_older_cli(self):
201202
"""Test fallback to sf org display token when show-access-token unavailable."""
@@ -224,17 +225,18 @@ def side_effect(*args, **kwargs):
224225
mock.stdout = display_output
225226
return mock
226227

227-
with patch("subprocess.run", side_effect=side_effect):
228-
result = provider.get_token()
228+
with patch("shutil.which", return_value="sf"):
229+
with patch("subprocess.run", side_effect=side_effect):
230+
result = provider.get_token()
229231

230-
assert isinstance(result, AccessTokenResponse)
231-
assert result.access_token == "display_token"
232-
assert result.instance_url == "https://cli.salesforce.com"
232+
assert isinstance(result, AccessTokenResponse)
233+
assert result.access_token == "display_token"
234+
assert result.instance_url == "https://cli.salesforce.com"
233235

234236
def test_sf_command_not_found(self):
235-
"""Test that FileNotFoundError is wrapped in RuntimeError."""
237+
"""Test that a missing sf binary raises RuntimeError."""
236238
provider = SFCLITokenProvider("test_org")
237239

238-
with patch("subprocess.run", side_effect=FileNotFoundError()):
240+
with patch("shutil.which", return_value=None):
239241
with pytest.raises(RuntimeError, match="'sf' command was not found"):
240242
provider.get_token()

0 commit comments

Comments
 (0)