Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
900526d
Merge pull request #90 from forcedotcom/predict
atulikumwenayo Apr 29, 2026
5f197cb
Improving external SDK for function
ritaagarwala-sf Apr 29, 2026
804d084
Improving external SDK for function
ritaagarwala-sf Apr 29, 2026
7be51c3
Improving external SDK for function
ritaagarwala-sf Apr 29, 2026
89ab4bc
Improving external SDK for function
ritaagarwala-sf Apr 29, 2026
5cf33a9
Updating sf_cli_integration.yml
ritaagarwala-sf Apr 29, 2026
874f821
Updating sf_cli_integration.yml
ritaagarwala-sf Apr 29, 2026
f9b0deb
Make lint
ritaagarwala-sf Apr 29, 2026
3702b2b
Make lint
ritaagarwala-sf Apr 29, 2026
6a2b7bd
changing the argument name
ritaagarwala-sf Apr 29, 2026
b0608ea
Removing function_invoke_option testcases
ritaagarwala-sf Apr 30, 2026
9b3cd22
Adding testcase for function_utils.py
ritaagarwala-sf Apr 30, 2026
45547c1
Adding unit test
ritaagarwala-sf Apr 30, 2026
2c17783
Correcting the testcase
ritaagarwala-sf Apr 30, 2026
8a9e8f0
Fixing lint error
ritaagarwala-sf Apr 30, 2026
e000a03
Removing unnecessary emoji
ritaagarwala-sf May 1, 2026
cdaf788
Updating the sf_cli_integration test for function run
ritaagarwala-sf May 1, 2026
77f5b8e
removing function-invoke-opt from deploy
ritaagarwala-sf May 1, 2026
099321a
sf_cli_integration fix
ritaagarwala-sf May 1, 2026
23604b2
Updating sf_cli_integration.yml
ritaagarwala-sf May 1, 2026
0e2147f
Getting Dockerfile.dependencies from parent of payload folder
ritaagarwala-sf May 1, 2026
530c407
Fixing test failure
ritaagarwala-sf May 1, 2026
6328855
Fixing test failure
ritaagarwala-sf May 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions .github/workflows/sf_cli_integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ jobs:
echo "::error::testFunction/.datacustomcode_proj/sdk_config.json not found after function init."
exit 1
}
test -f testFunction/payload/tests/test.json || {
echo "::error::testFunction/payload/tests/test.json not found after function init."
exit 1
}

# ── Function: scan ────────────────────────────────────────────────────────

Expand Down Expand Up @@ -251,14 +255,18 @@ jobs:

# ── Function: run ─────────────────────────────────────────────────────────

- name: '[function] run — sf data-code-extension function run --entrypoint testFunction/payload/entrypoint.py -o dev1'
- name: Install testFunction/requirements.txt
run: |
pip install -r testFunction/requirements.txt

- name: '[function] run — sf data-code-extension function run --entrypoint testFunction/payload/entrypoint.py --test-with testFunction/payload/tests/test.json '
run: |
sf data-code-extension function run \
--entrypoint testFunction/payload/entrypoint.py \
-o dev1 || {
echo "::error::sf data-code-extension function run FAILED. Check mock server output above; the --entrypoint flag or SF CLI org auth contract may have changed."
exit 1
}
--test-with testFunction/payload/tests/test.json || {
echo "::error::sf data-code-extension function run FAILED. Check mock server output above; the --entrypoint flag or SF CLI org auth contract may have changed."
exit 1
}

# ── Function: deploy ─────────────────────────────────────────────────────

Expand All @@ -270,7 +278,6 @@ jobs:
--description "Test function deploy" \
--package-dir testFunction/payload \
--cpu-size CPU_2XL \
--function-invoke-opt UnstructuredChunking \
-o dev1 || {
echo "::error::sf data-code-extension function deploy FAILED. Check mock server output above for which endpoint failed. The deploy command flags or API contract may have changed."
exit 1
Expand Down
102 changes: 89 additions & 13 deletions src/datacustomcode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@

from datacustomcode import AuthType
from datacustomcode.auth import configure_oauth_tokens
from datacustomcode.constants import (
CONFIG_FILE,
ENTRYPOINT_FILE,
PAYLOAD_DIR,
TEST_FILE,
TESTS_DIR,
)
from datacustomcode.scan import find_base_directory, get_package_type


Expand Down Expand Up @@ -74,6 +81,30 @@ def _configure_client_credentials(
)


def _generate_function_test_file(entrypoint_path: str) -> Optional[str]:
"""Generate test.json file for a function.

Args:
entrypoint_path: Path to the function's entrypoint.py

Returns:
Path to generated test.json, or None if generation failed
"""
from datacustomcode.function_utils import generate_test_json

tests_dir = os.path.join(os.path.dirname(entrypoint_path), TESTS_DIR)
os.makedirs(tests_dir, exist_ok=True)
test_json_path = os.path.join(tests_dir, TEST_FILE)

try:
generate_test_json(entrypoint_path, test_json_path)
logger.debug(f"Generated test JSON at {test_json_path}")
return test_json_path
except Exception as e:
logger.warning(f"Could not generate test.json: {e}")
return None


@cli.command()
@click.option("--profile", default="default", help="Credential profile name")
@click.option(
Expand Down Expand Up @@ -162,7 +193,6 @@ def zip(path: str, network: str):

Choose based on your workload requirements.""",
)
@click.option("--function-invoke-opt")
@click.option(
"--sf-cli-org",
default=None,
Expand All @@ -176,13 +206,14 @@ def deploy(
cpu_size: str,
profile: str,
network: str,
function_invoke_opt: str,
sf_cli_org: Optional[str],
):
from datacustomcode.constants import USE_IN_FEATURE_MAPPING_FOR_CONNECT_API
from datacustomcode.deploy import (
COMPUTE_TYPES,
CodeExtensionMetadata,
deploy_full,
infer_use_in_feature,
)
from datacustomcode.token_provider import (
CredentialsTokenProvider,
Expand Down Expand Up @@ -211,15 +242,24 @@ def deploy(
)

if package_type == "function":
if not function_invoke_opt:
# Infer use_in_feature from function signature
entrypoint_path = os.path.join(path, ENTRYPOINT_FILE)
use_in_feature = infer_use_in_feature(entrypoint_path)
if use_in_feature:
logger.info(f"Inferred use_in_feature: {use_in_feature}")
else:
click.secho(
"Error: Function invoke options are required for function package type",
"Error: Could not infer function invoke options. "
"Please provide --use-in-feature",
fg="red",
)
raise click.Abort()
else:
function_invoke_options = function_invoke_opt.split(",")
metadata.functionInvokeOptions = function_invoke_options

# Map user-provided feature names to API names
mapped_feature = USE_IN_FEATURE_MAPPING_FOR_CONNECT_API.get(
use_in_feature, use_in_feature
)
metadata.functionInvokeOptions = [mapped_feature]

try:
if sf_cli_org:
Expand All @@ -238,7 +278,12 @@ def deploy(
@click.option(
"--code-type", default="script", type=click.Choice(["script", "function"])
)
def init(directory: str, code_type: str):
@click.option(
"--use-in-feature",
default="SearchIndexChunking",
help="Feature where this function will be used (only applicable for function).",
)
def init(directory: str, code_type: str, use_in_feature: Optional[str]):
from datacustomcode.scan import (
dc_config_json_from_file,
update_config,
Expand All @@ -250,9 +295,9 @@ def init(directory: str, code_type: str):
if code_type == "script":
copy_script_template(directory)
elif code_type == "function":
copy_function_template(directory)
entrypoint_path = os.path.join(directory, "payload", "entrypoint.py")
config_location = os.path.join(os.path.dirname(entrypoint_path), "config.json")
copy_function_template(directory, use_in_feature)
entrypoint_path = os.path.join(directory, PAYLOAD_DIR, ENTRYPOINT_FILE)
config_location = os.path.join(os.path.dirname(entrypoint_path), CONFIG_FILE)

# Write package type to SDK-specific config
sdk_config = {"type": code_type}
Expand All @@ -265,6 +310,7 @@ def init(directory: str, code_type: str):
updated_config_json = update_config(entrypoint_path)
with open(config_location, "w") as f:
json.dump(updated_config_json, f, indent=2)

click.echo(
"Start developing by updating the code in "
+ click.style(entrypoint_path, fg="blue", bold=True)
Expand All @@ -275,6 +321,24 @@ def init(directory: str, code_type: str):
+ " to automatically update config.json when you make changes to your code"
)

# Generate test.json for functions
if code_type == "function":
test_json_path = _generate_function_test_file(entrypoint_path)
if test_json_path:
click.echo(
"Generated test file at "
+ click.style(test_json_path, fg="blue", bold=True)
)
click.echo(
"Test your function locally with "
+ click.style(
f"datacustomcode run {entrypoint_path} "
f"--test-with {test_json_path}",
fg="blue",
bold=True,
)
)


@cli.command()
@click.argument("filename")
Expand All @@ -286,7 +350,7 @@ def init(directory: str, code_type: str):
def scan(filename: str, config: str, dry_run: bool, no_requirements: bool):
from datacustomcode.scan import update_config, write_requirements_file

config_location = config or os.path.join(os.path.dirname(filename), "config.json")
config_location = config or os.path.join(os.path.dirname(filename), CONFIG_FILE)
click.echo(
"Dumping scan results to config file: "
+ click.style(config_location, fg="blue", bold=True)
Expand All @@ -312,6 +376,12 @@ def scan(filename: str, config: str, dry_run: bool, no_requirements: bool):
@click.option("--config-file", default=None)
@click.option("--dependencies", default=[], multiple=True)
@click.option("--profile", default="default")
@click.option(
"--test-with",
default=None,
type=click.Path(exists=True),
help="Path to test JSON file for function testing",
)
@click.option(
"--sf-cli-org",
default=None,
Expand All @@ -322,10 +392,16 @@ def run(
config_file: Union[str, None],
dependencies: List[str],
profile: str,
test_with: Optional[str],
sf_cli_org: Optional[str],
):
from datacustomcode.run import run_entrypoint

run_entrypoint(
entrypoint, config_file, dependencies, profile, sf_cli_org=sf_cli_org
entrypoint,
config_file,
dependencies,
profile,
test_file=test_with,
sf_cli_org=sf_cli_org,
)
45 changes: 45 additions & 0 deletions src/datacustomcode/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Copyright (c) 2025, Salesforce, Inc.
# SPDX-License-Identifier: Apache-2
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Constants used throughout the datacustomcode package."""

# File and directory names
ENTRYPOINT_FILE = "entrypoint.py"
CONFIG_FILE = "config.json"
PAYLOAD_DIR = "payload"
TESTS_DIR = "tests"
TEST_FILE = "test.json"
REQUIREMENTS_FILE = "requirements.txt"

# Default values
DEFAULT_PROFILE = "default"
DEFAULT_NETWORK = "default"
DEFAULT_CPU_SIZE = "CPU_2XL"

# Feature to template folder mapping
FEATURE_TEMPLATE_MAPPING = {
"SearchIndexChunking": "chunking",
}

# Feature name to Connect API name mapping
USE_IN_FEATURE_MAPPING_FOR_CONNECT_API = {
"SearchIndexChunking": "UnstructuredChunking",
}

# Pydantic request/response type names to feature names
REQUEST_TYPE_TO_FEATURE = {
"SearchIndexChunkingV1Request": "SearchIndexChunking",
"SearchIndexChunkingV1Response": "SearchIndexChunking",
}
49 changes: 46 additions & 3 deletions src/datacustomcode/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import requests

from datacustomcode.cmd import cmd_output
from datacustomcode.constants import REQUEST_TYPE_TO_FEATURE
from datacustomcode.scan import find_base_directory, get_package_type

DATA_CUSTOM_CODE_PATH = "services/data/v63.0/ssot/data-custom-code"
Expand Down Expand Up @@ -65,6 +66,40 @@ def _sanitize_api_name(name: str) -> str:
return sanitized


def infer_use_in_feature(entrypoint_path: str) -> Union[str, None]:
"""Infer the use_in_feature from function signature.

Checks both the request parameter type and return type annotation.
Both must map to the same feature for a valid inference.

Uses static AST parsing to avoid importing dependencies.

Args:
entrypoint_path: Path to the entrypoint.py file

Returns:
The feature name if both request and response match, None otherwise
"""
from datacustomcode.function_utils import inspect_function_types_static

request_type_name, response_type_name = inspect_function_types_static(
entrypoint_path
)

if not request_type_name or not response_type_name:
return None

# Look up features for both types
request_feature = REQUEST_TYPE_TO_FEATURE.get(request_type_name)
response_feature = REQUEST_TYPE_TO_FEATURE.get(response_type_name)

# Both must be present and must match
if request_feature and response_feature and request_feature == response_feature:
return request_feature

return None


class CodeExtensionMetadata(BaseModel):
name: str
version: str
Expand Down Expand Up @@ -205,6 +240,11 @@ def create_deployment(
def prepare_dependency_archive(
directory: str, docker_network: str, package_type: str
) -> None:
# The parent directory of 'directory' contains Dockerfile.dependencies,
# requirements.txt, and build_native_dependencies.sh
# (same location checked by has_nonempty_requirements_file)
parent_dir = os.path.dirname(directory)

cmd = f"docker images -q {DOCKER_IMAGE_NAME}"
image_exists = cmd_output(cmd)

Expand All @@ -213,7 +253,8 @@ def prepare_dependency_archive(
if not image_exists:
logger.info(f"Building docker image with docker network: {docker_network}...")
cmd = docker_build_cmd(docker_network)
cmd_output(cmd, env=docker_env)
# Run docker build from parent_dir where Dockerfile.dependencies exists
cmd_output(cmd, env=docker_env, cwd=parent_dir)

# ignore_cleanup_errors=True: on Windows, Docker creates files inside the
# mounted volume whose permissions prevent the host from deleting them.
Expand All @@ -223,9 +264,11 @@ def prepare_dependency_archive(
logger.info(
f"Building dependencies archive with docker network: {docker_network}"
)
shutil.copy("requirements.txt", temp_dir)
shutil.copy("build_native_dependencies.sh", temp_dir)
# Copy from parent_dir where files actually exist
shutil.copy(os.path.join(parent_dir, "requirements.txt"), temp_dir)
shutil.copy(os.path.join(parent_dir, "build_native_dependencies.sh"), temp_dir)
cmd = docker_run_cmd(docker_network, temp_dir)
# Docker run doesn't need cwd since temp_dir is absolute and mounted
cmd_output(cmd, env=docker_env)
if package_type == "function":
source_py_files = os.path.join(temp_dir, "py-files")
Expand Down
Loading
Loading