Skip to content

Commit be1f63c

Browse files
authored
Merge pull request #74 from forcedotcom/jo_deploy_sf-cli
deploy sf cli flag and sanitization
2 parents ba3da5c + 2be82d0 commit be1f63c

4 files changed

Lines changed: 387 additions & 16 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: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
from html import unescape
1818
import json
1919
import os
20+
import re
2021
import shutil
22+
import subprocess
2123
import tempfile
2224
import time
2325
from typing import (
@@ -57,6 +59,19 @@
5759
}
5860

5961

62+
def _sanitize_api_name(name: str) -> str:
63+
"""Sanitize an API name to comply with Salesforce naming rules.
64+
65+
Replaces spaces and hyphens with underscores, removes invalid characters,
66+
collapses consecutive underscores, and strips leading/trailing underscores.
67+
"""
68+
sanitized = re.sub(r"[ \-]", "_", name)
69+
sanitized = re.sub(r"[^\w]", "", sanitized)
70+
sanitized = re.sub(r"_+", "_", sanitized)
71+
sanitized = sanitized.strip("_")
72+
return sanitized
73+
74+
6075
class CodeExtensionMetadata(BaseModel):
6176
name: str
6277
version: str
@@ -66,6 +81,24 @@ class CodeExtensionMetadata(BaseModel):
6681
functionInvokeOptions: Union[list[str], None] = None
6782

6883
def __init__(self, **data):
84+
name = data.get("name", "")
85+
sanitized = _sanitize_api_name(name)
86+
if sanitized != name:
87+
logger.warning(f"API name '{name}' was sanitized to '{sanitized}'")
88+
data["name"] = sanitized
89+
if not sanitized:
90+
raise ValueError(
91+
f"API name '{name}' is invalid and could not be sanitized to a"
92+
" valid name."
93+
)
94+
if not sanitized[0].isalpha():
95+
raise ValueError(
96+
f"API name '{sanitized}' must begin with a letter. "
97+
"The name can only contain underscores and alphanumeric"
98+
" characters, must begin with a letter, not include spaces,"
99+
" not end with an underscore, and not contain two consecutive"
100+
" underscores."
101+
)
69102
super().__init__(**data)
70103

71104

@@ -156,6 +189,54 @@ def _retrieve_access_token(credentials: Credentials) -> AccessTokenResponse:
156189
return AccessTokenResponse(**response)
157190

158191

192+
def _retrieve_access_token_from_sf_cli(sf_cli_org: str) -> AccessTokenResponse:
193+
"""Get an access token from the Salesforce CLI."""
194+
try:
195+
result = subprocess.run(
196+
["sf", "org", "display", "--target-org", sf_cli_org, "--json"],
197+
capture_output=True,
198+
text=True,
199+
check=True,
200+
timeout=30,
201+
)
202+
except FileNotFoundError as exc:
203+
raise RuntimeError(
204+
"The 'sf' command was not found. "
205+
"Please install Salesforce CLI: https://developer.salesforce.com/tools/salesforcecli"
206+
) from exc
207+
except subprocess.TimeoutExpired as exc:
208+
raise RuntimeError(
209+
f"'sf org display' timed out for org '{sf_cli_org}'"
210+
) from exc
211+
except subprocess.CalledProcessError as exc:
212+
raise RuntimeError(
213+
f"'sf org display' failed for org '{sf_cli_org}'.\n"
214+
f"Ensure the org is authenticated via 'sf org login web'.\n"
215+
f"stderr: {exc.stderr.strip()}"
216+
) from exc
217+
218+
try:
219+
data = json.loads(result.stdout)
220+
except json.JSONDecodeError as exc:
221+
raise RuntimeError(f"Failed to parse 'sf org display' output: {exc}") from exc
222+
223+
if data.get("status") != 0:
224+
raise RuntimeError(
225+
f"SF CLI error for org '{sf_cli_org}': "
226+
f"{data.get('message', 'unknown error')}"
227+
)
228+
229+
org_result = data.get("result", {})
230+
access_token = org_result.get("accessToken")
231+
instance_url = org_result.get("instanceUrl")
232+
if not access_token or not instance_url:
233+
raise RuntimeError(
234+
f"'sf org display' did not return an access token or instance URL "
235+
f"for org '{sf_cli_org}'"
236+
)
237+
return AccessTokenResponse(access_token=access_token, instance_url=instance_url)
238+
239+
159240
class CreateDeploymentResponse(BaseModel):
160241
fileUploadUrl: str
161242

@@ -463,12 +544,15 @@ def zip(
463544
def deploy_full(
464545
directory: str,
465546
metadata: CodeExtensionMetadata,
466-
credentials: Credentials,
547+
credentials: Union["Credentials", AccessTokenResponse],
467548
docker_network: str,
468549
callback=None,
469550
) -> AccessTokenResponse:
470551
"""Deploy a data transform in the DataCloud."""
471-
access_token = _retrieve_access_token(credentials)
552+
if isinstance(credentials, AccessTokenResponse):
553+
access_token = credentials
554+
else:
555+
access_token = _retrieve_access_token(credentials)
472556

473557
# prepare payload
474558
config = get_config(directory)

tests/test_cli.py

Lines changed: 40 additions & 1 deletion
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

@@ -91,7 +92,9 @@ def test_deploy_command_success(self, mock_credentials, mock_deploy_full):
9192
# Check that deploy_full was called with correct arguments
9293
call_args = mock_deploy_full.call_args
9394
assert call_args[0][0] == "payload" # path
94-
assert call_args[0][1].name == "test-job" # metadata
95+
assert (
96+
call_args[0][1].name == "test_job"
97+
) # metadata (hyphen sanitized to underscore)
9598
assert call_args[0][1].version == "1.0.0"
9699
assert call_args[0][1].description == "Custom Data Transform Code"
97100
assert call_args[0][2] == mock_creds # credentials
@@ -180,3 +183,39 @@ def test_deploy_command_custom_description(
180183
# Check that deploy_full was called with custom description
181184
call_args = mock_deploy_full.call_args
182185
assert call_args[0][1].description == "Custom description"
186+
187+
@patch("datacustomcode.deploy.deploy_full")
188+
@patch("datacustomcode.deploy._retrieve_access_token_from_sf_cli")
189+
def test_deploy_command_sf_cli_org(self, mock_sf_cli_token, mock_deploy_full):
190+
"""Test deploy command with --sf-cli-org flag."""
191+
mock_token = AccessTokenResponse(
192+
access_token="test_token", instance_url="https://test.salesforce.com"
193+
)
194+
mock_sf_cli_token.return_value = mock_token
195+
196+
runner = CliRunner()
197+
with runner.isolated_filesystem():
198+
os.makedirs("payload", exist_ok=True)
199+
result = runner.invoke(
200+
deploy, ["--name", "test-job", "--sf-cli-org", "my-org"]
201+
)
202+
203+
assert result.exit_code == 0
204+
mock_sf_cli_token.assert_called_once_with("my-org")
205+
mock_deploy_full.assert_called_once()
206+
call_args = mock_deploy_full.call_args
207+
assert call_args[0][2] == mock_token # AccessTokenResponse passed directly
208+
209+
@patch("datacustomcode.deploy._retrieve_access_token_from_sf_cli")
210+
def test_deploy_command_sf_cli_org_error(self, mock_sf_cli_token):
211+
"""Test deploy command when --sf-cli-org fails."""
212+
mock_sf_cli_token.side_effect = RuntimeError("sf command not found")
213+
214+
runner = CliRunner()
215+
with runner.isolated_filesystem():
216+
os.makedirs("payload", exist_ok=True)
217+
result = runner.invoke(
218+
deploy, ["--name", "test-job", "--sf-cli-org", "bad-org"]
219+
)
220+
assert result.exit_code == 1
221+
assert "sf command not found" in result.output

0 commit comments

Comments
 (0)