Skip to content

Commit 0aecc3a

Browse files
committed
deploy sf-cli-org
1 parent ba3da5c commit 0aecc3a

4 files changed

Lines changed: 264 additions & 15 deletions

File tree

src/datacustomcode/cli.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ def zip(path: str, network: str):
160160
Choose based on your workload requirements.""",
161161
)
162162
@click.option("--function-invoke-opt")
163+
@click.option(
164+
"--sf-cli-org",
165+
default=None,
166+
help="SF CLI org alias or username. Fetches credentials via `sf org display`.",
167+
)
163168
def deploy(
164169
path: str,
165170
name: str,
@@ -169,15 +174,19 @@ def deploy(
169174
profile: str,
170175
network: str,
171176
function_invoke_opt: str,
177+
sf_cli_org: Optional[str],
172178
):
173179
from datacustomcode.credentials import Credentials
174-
from datacustomcode.deploy import CodeExtensionMetadata, deploy_full
180+
from datacustomcode.deploy import (
181+
COMPUTE_TYPES,
182+
AccessTokenResponse,
183+
CodeExtensionMetadata,
184+
_retrieve_access_token_from_sf_cli,
185+
deploy_full,
186+
)
175187

176188
logger.debug("Deploying project")
177189

178-
# Validate compute type
179-
from datacustomcode.deploy import COMPUTE_TYPES
180-
181190
if cpu_size not in COMPUTE_TYPES.keys():
182191
click.secho(
183192
f"Error: Invalid CPU size '{cpu_size}'. "
@@ -208,15 +217,23 @@ def deploy(
208217
function_invoke_options = function_invoke_opt.split(",")
209218
metadata.functionInvokeOptions = function_invoke_options
210219

211-
try:
212-
credentials = Credentials.from_available(profile=profile)
213-
except ValueError as e:
214-
click.secho(
215-
f"Error: {e}",
216-
fg="red",
217-
)
218-
raise click.Abort() from None
219-
deploy_full(path, metadata, credentials, network)
220+
auth: Union[Credentials, AccessTokenResponse]
221+
if sf_cli_org:
222+
try:
223+
auth = _retrieve_access_token_from_sf_cli(sf_cli_org)
224+
except RuntimeError as e:
225+
click.secho(f"Error: {e}", fg="red")
226+
raise click.Abort() from None
227+
else:
228+
try:
229+
auth = Credentials.from_available(profile=profile)
230+
except ValueError as e:
231+
click.secho(
232+
f"Error: {e}",
233+
fg="red",
234+
)
235+
raise click.Abort() from None
236+
deploy_full(path, metadata, auth, network)
220237

221238

222239
@cli.command()

src/datacustomcode/deploy.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import json
1919
import os
2020
import shutil
21+
import subprocess
2122
import tempfile
2223
import time
2324
from typing import (
@@ -156,6 +157,54 @@ def _retrieve_access_token(credentials: Credentials) -> AccessTokenResponse:
156157
return AccessTokenResponse(**response)
157158

158159

160+
def _retrieve_access_token_from_sf_cli(sf_cli_org: str) -> AccessTokenResponse:
161+
"""Get an access token from the Salesforce CLI."""
162+
try:
163+
result = subprocess.run(
164+
["sf", "org", "display", "--target-org", sf_cli_org, "--json"],
165+
capture_output=True,
166+
text=True,
167+
check=True,
168+
timeout=30,
169+
)
170+
except FileNotFoundError as exc:
171+
raise RuntimeError(
172+
"The 'sf' command was not found. "
173+
"Please install Salesforce CLI: https://developer.salesforce.com/tools/salesforcecli"
174+
) from exc
175+
except subprocess.TimeoutExpired as exc:
176+
raise RuntimeError(
177+
f"'sf org display' timed out for org '{sf_cli_org}'"
178+
) from exc
179+
except subprocess.CalledProcessError as exc:
180+
raise RuntimeError(
181+
f"'sf org display' failed for org '{sf_cli_org}'.\n"
182+
f"Ensure the org is authenticated via 'sf org login web'.\n"
183+
f"stderr: {exc.stderr.strip()}"
184+
) from exc
185+
186+
try:
187+
data = json.loads(result.stdout)
188+
except json.JSONDecodeError as exc:
189+
raise RuntimeError(f"Failed to parse 'sf org display' output: {exc}") from exc
190+
191+
if data.get("status") != 0:
192+
raise RuntimeError(
193+
f"SF CLI error for org '{sf_cli_org}': "
194+
f"{data.get('message', 'unknown error')}"
195+
)
196+
197+
org_result = data.get("result", {})
198+
access_token = org_result.get("accessToken")
199+
instance_url = org_result.get("instanceUrl")
200+
if not access_token or not instance_url:
201+
raise RuntimeError(
202+
f"'sf org display' did not return an access token or instance URL "
203+
f"for org '{sf_cli_org}'"
204+
)
205+
return AccessTokenResponse(access_token=access_token, instance_url=instance_url)
206+
207+
159208
class CreateDeploymentResponse(BaseModel):
160209
fileUploadUrl: str
161210

@@ -463,12 +512,15 @@ def zip(
463512
def deploy_full(
464513
directory: str,
465514
metadata: CodeExtensionMetadata,
466-
credentials: Credentials,
515+
credentials: Union["Credentials", AccessTokenResponse],
467516
docker_network: str,
468517
callback=None,
469518
) -> AccessTokenResponse:
470519
"""Deploy a data transform in the DataCloud."""
471-
access_token = _retrieve_access_token(credentials)
520+
if isinstance(credentials, AccessTokenResponse):
521+
access_token = credentials
522+
else:
523+
access_token = _retrieve_access_token(credentials)
472524

473525
# prepare payload
474526
config = get_config(directory)

tests/test_cli.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from click.testing import CliRunner
66

77
from datacustomcode.cli import deploy, init
8+
from datacustomcode.deploy import AccessTokenResponse
89
from datacustomcode.scan import write_sdk_config
910

1011

@@ -180,3 +181,39 @@ def test_deploy_command_custom_description(
180181
# Check that deploy_full was called with custom description
181182
call_args = mock_deploy_full.call_args
182183
assert call_args[0][1].description == "Custom description"
184+
185+
@patch("datacustomcode.deploy.deploy_full")
186+
@patch("datacustomcode.deploy._retrieve_access_token_from_sf_cli")
187+
def test_deploy_command_sf_cli_org(self, mock_sf_cli_token, mock_deploy_full):
188+
"""Test deploy command with --sf-cli-org flag."""
189+
mock_token = AccessTokenResponse(
190+
access_token="test_token", instance_url="https://test.salesforce.com"
191+
)
192+
mock_sf_cli_token.return_value = mock_token
193+
194+
runner = CliRunner()
195+
with runner.isolated_filesystem():
196+
os.makedirs("payload", exist_ok=True)
197+
result = runner.invoke(
198+
deploy, ["--name", "test-job", "--sf-cli-org", "my-org"]
199+
)
200+
201+
assert result.exit_code == 0
202+
mock_sf_cli_token.assert_called_once_with("my-org")
203+
mock_deploy_full.assert_called_once()
204+
call_args = mock_deploy_full.call_args
205+
assert call_args[0][2] == mock_token # AccessTokenResponse passed directly
206+
207+
@patch("datacustomcode.deploy._retrieve_access_token_from_sf_cli")
208+
def test_deploy_command_sf_cli_org_error(self, mock_sf_cli_token):
209+
"""Test deploy command when --sf-cli-org fails."""
210+
mock_sf_cli_token.side_effect = RuntimeError("sf command not found")
211+
212+
runner = CliRunner()
213+
with runner.isolated_filesystem():
214+
os.makedirs("payload", exist_ok=True)
215+
result = runner.invoke(
216+
deploy, ["--name", "test-job", "--sf-cli-org", "bad-org"]
217+
)
218+
assert result.exit_code == 1
219+
assert "sf command not found" in result.output

tests/test_deploy.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the deploy module."""
22

3+
import json
34
from unittest.mock import (
45
MagicMock,
56
mock_open,
@@ -27,6 +28,7 @@
2728
DeploymentsResponse,
2829
_make_api_call,
2930
_retrieve_access_token,
31+
_retrieve_access_token_from_sf_cli,
3032
create_data_transform,
3133
create_deployment,
3234
deploy_full,
@@ -1107,3 +1109,144 @@ def test_deploy_full_happy_path(
11071109
"/test/dir", access_token, metadata, data_transform_config
11081110
)
11091111
assert result == access_token
1112+
1113+
1114+
class TestRetrieveAccessTokenFromSFCLI:
1115+
"""Tests for _retrieve_access_token_from_sf_cli."""
1116+
1117+
SF_CLI_OUTPUT = json.dumps(
1118+
{
1119+
"status": 0,
1120+
"result": {
1121+
"accessToken": "sf_access_token",
1122+
"instanceUrl": "https://sf.salesforce.com",
1123+
},
1124+
}
1125+
)
1126+
1127+
@patch("datacustomcode.deploy.subprocess.run")
1128+
def test_happy_path(self, mock_run):
1129+
"""Successful sf org display returns AccessTokenResponse."""
1130+
mock_run.return_value = MagicMock(stdout=self.SF_CLI_OUTPUT, returncode=0)
1131+
1132+
result = _retrieve_access_token_from_sf_cli("my-org")
1133+
1134+
assert result.access_token == "sf_access_token"
1135+
assert result.instance_url == "https://sf.salesforce.com"
1136+
mock_run.assert_called_once_with(
1137+
["sf", "org", "display", "--target-org", "my-org", "--json"],
1138+
capture_output=True,
1139+
text=True,
1140+
check=True,
1141+
timeout=30,
1142+
)
1143+
1144+
@patch("datacustomcode.deploy.subprocess.run")
1145+
def test_file_not_found(self, mock_run):
1146+
"""FileNotFoundError raised when sf CLI is not installed."""
1147+
mock_run.side_effect = FileNotFoundError("No such file or directory: 'sf'")
1148+
1149+
with pytest.raises(RuntimeError, match="'sf' command was not found"):
1150+
_retrieve_access_token_from_sf_cli("my-org")
1151+
1152+
@patch("datacustomcode.deploy.subprocess.run")
1153+
def test_timeout_expired(self, mock_run):
1154+
"""TimeoutExpired raised when sf CLI times out."""
1155+
import subprocess
1156+
1157+
mock_run.side_effect = subprocess.TimeoutExpired(cmd="sf", timeout=30)
1158+
1159+
with pytest.raises(RuntimeError, match="timed out"):
1160+
_retrieve_access_token_from_sf_cli("my-org")
1161+
1162+
@patch("datacustomcode.deploy.subprocess.run")
1163+
def test_called_process_error(self, mock_run):
1164+
"""CalledProcessError raised when sf CLI exits non-zero."""
1165+
import subprocess
1166+
1167+
mock_run.side_effect = subprocess.CalledProcessError(
1168+
returncode=1, cmd="sf", stderr="Org not found"
1169+
)
1170+
1171+
with pytest.raises(RuntimeError, match="failed for org"):
1172+
_retrieve_access_token_from_sf_cli("my-org")
1173+
1174+
@patch("datacustomcode.deploy.subprocess.run")
1175+
def test_json_decode_error(self, mock_run):
1176+
"""RuntimeError raised when output is not valid JSON."""
1177+
mock_run.return_value = MagicMock(stdout="not-json", returncode=0)
1178+
1179+
with pytest.raises(RuntimeError, match="Failed to parse"):
1180+
_retrieve_access_token_from_sf_cli("my-org")
1181+
1182+
@patch("datacustomcode.deploy.subprocess.run")
1183+
def test_nonzero_status_in_json(self, mock_run):
1184+
"""RuntimeError raised when JSON status field is non-zero."""
1185+
output = json.dumps({"status": 1, "message": "org not found"})
1186+
mock_run.return_value = MagicMock(stdout=output, returncode=0)
1187+
1188+
with pytest.raises(RuntimeError, match="SF CLI error"):
1189+
_retrieve_access_token_from_sf_cli("my-org")
1190+
1191+
@patch("datacustomcode.deploy.subprocess.run")
1192+
def test_missing_access_token(self, mock_run):
1193+
"""RuntimeError raised when accessToken is absent."""
1194+
output = json.dumps(
1195+
{"status": 0, "result": {"instanceUrl": "https://sf.salesforce.com"}}
1196+
)
1197+
mock_run.return_value = MagicMock(stdout=output, returncode=0)
1198+
1199+
with pytest.raises(RuntimeError, match="did not return"):
1200+
_retrieve_access_token_from_sf_cli("my-org")
1201+
1202+
@patch("datacustomcode.deploy.subprocess.run")
1203+
def test_missing_instance_url(self, mock_run):
1204+
"""RuntimeError raised when instanceUrl is absent."""
1205+
output = json.dumps({"status": 0, "result": {"accessToken": "sf_access_token"}})
1206+
mock_run.return_value = MagicMock(stdout=output, returncode=0)
1207+
1208+
with pytest.raises(RuntimeError, match="did not return"):
1209+
_retrieve_access_token_from_sf_cli("my-org")
1210+
1211+
1212+
class TestDeployFullWithAccessTokenResponse:
1213+
"""Test deploy_full when passed an AccessTokenResponse directly."""
1214+
1215+
@patch("datacustomcode.deploy.create_data_transform")
1216+
@patch("datacustomcode.deploy.wait_for_deployment")
1217+
@patch("datacustomcode.deploy.upload_zip")
1218+
@patch("datacustomcode.deploy.zip")
1219+
@patch("datacustomcode.deploy.create_deployment")
1220+
@patch("datacustomcode.deploy.get_config")
1221+
@patch("datacustomcode.deploy._retrieve_access_token")
1222+
def test_deploy_full_with_access_token_response_skips_token_exchange(
1223+
self,
1224+
mock_retrieve_token,
1225+
mock_get_config,
1226+
mock_create_deployment,
1227+
mock_zip,
1228+
mock_upload_zip,
1229+
mock_wait,
1230+
mock_create_transform,
1231+
):
1232+
"""deploy_full skips token exchange when given an AccessTokenResponse."""
1233+
access_token = AccessTokenResponse(
1234+
access_token="direct_token", instance_url="https://instance.example.com"
1235+
)
1236+
metadata = CodeExtensionMetadata(
1237+
name="test",
1238+
version="1.0.0",
1239+
description="desc",
1240+
computeType="CPU_M",
1241+
codeType="script",
1242+
)
1243+
mock_get_config.return_value = MagicMock(spec=[]) # not DataTransformConfig
1244+
mock_create_deployment.return_value = CreateDeploymentResponse(
1245+
fileUploadUrl="https://upload.example.com"
1246+
)
1247+
1248+
result = deploy_full("/test/dir", metadata, access_token, "default")
1249+
1250+
mock_retrieve_token.assert_not_called()
1251+
mock_create_deployment.assert_called_once_with(access_token, metadata)
1252+
assert result == access_token

0 commit comments

Comments
 (0)