Skip to content

Commit da5062c

Browse files
Merge pull request #94 from forcedotcom/chunking_contract
Search Index Chunking contract
2 parents d083bab + 2c8f040 commit da5062c

15 files changed

Lines changed: 1172 additions & 88 deletions

File tree

.github/workflows/sf_cli_integration.yml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ jobs:
200200
echo "::error::testFunction/.datacustomcode_proj/sdk_config.json not found after function init."
201201
exit 1
202202
}
203+
test -f testFunction/payload/tests/test.json || {
204+
echo "::error::testFunction/payload/tests/test.json not found after function init."
205+
exit 1
206+
}
203207
204208
# ── Function: scan ────────────────────────────────────────────────────────
205209

@@ -251,14 +255,14 @@ jobs:
251255
252256
# ── Function: run ─────────────────────────────────────────────────────────
253257

254-
- name: '[function] run — sf data-code-extension function run --entrypoint testFunction/payload/entrypoint.py -o dev1'
258+
- name: '[function] run — sf data-code-extension function run --entrypoint testFunction/payload/entrypoint.py --test-with testFunction/payload/tests/test.json -o dev1'
255259
run: |
256260
sf data-code-extension function run \
257261
--entrypoint testFunction/payload/entrypoint.py \
258-
-o dev1 || {
259-
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."
260-
exit 1
261-
}
262+
--test-with testFunction/payload/tests/test.json || {
263+
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."
264+
exit 1
265+
}
262266
263267
# ── Function: deploy ─────────────────────────────────────────────────────
264268

@@ -270,7 +274,6 @@ jobs:
270274
--description "Test function deploy" \
271275
--package-dir testFunction/payload \
272276
--cpu-size CPU_2XL \
273-
--function-invoke-opt UnstructuredChunking \
274277
-o dev1 || {
275278
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."
276279
exit 1

src/datacustomcode/cli.py

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@
2727

2828
from datacustomcode import AuthType
2929
from datacustomcode.auth import configure_oauth_tokens
30+
from datacustomcode.constants import (
31+
CONFIG_FILE,
32+
ENTRYPOINT_FILE,
33+
PAYLOAD_DIR,
34+
TEST_FILE,
35+
TESTS_DIR,
36+
)
3037
from datacustomcode.scan import find_base_directory, get_package_type
3138

3239

@@ -74,6 +81,30 @@ def _configure_client_credentials(
7481
)
7582

7683

84+
def _generate_function_test_file(entrypoint_path: str) -> Optional[str]:
85+
"""Generate test.json file for a function.
86+
87+
Args:
88+
entrypoint_path: Path to the function's entrypoint.py
89+
90+
Returns:
91+
Path to generated test.json, or None if generation failed
92+
"""
93+
from datacustomcode.function_utils import generate_test_json
94+
95+
tests_dir = os.path.join(os.path.dirname(entrypoint_path), TESTS_DIR)
96+
os.makedirs(tests_dir, exist_ok=True)
97+
test_json_path = os.path.join(tests_dir, TEST_FILE)
98+
99+
try:
100+
generate_test_json(entrypoint_path, test_json_path)
101+
logger.debug(f"Generated test JSON at {test_json_path}")
102+
return test_json_path
103+
except Exception as e:
104+
logger.warning(f"Could not generate test.json: {e}")
105+
return None
106+
107+
77108
@cli.command()
78109
@click.option("--profile", default="default", help="Credential profile name")
79110
@click.option(
@@ -162,7 +193,6 @@ def zip(path: str, network: str):
162193
163194
Choose based on your workload requirements.""",
164195
)
165-
@click.option("--function-invoke-opt")
166196
@click.option(
167197
"--sf-cli-org",
168198
default=None,
@@ -176,13 +206,14 @@ def deploy(
176206
cpu_size: str,
177207
profile: str,
178208
network: str,
179-
function_invoke_opt: str,
180209
sf_cli_org: Optional[str],
181210
):
211+
from datacustomcode.constants import USE_IN_FEATURE_MAPPING_FOR_CONNECT_API
182212
from datacustomcode.deploy import (
183213
COMPUTE_TYPES,
184214
CodeExtensionMetadata,
185215
deploy_full,
216+
infer_use_in_feature,
186217
)
187218
from datacustomcode.token_provider import (
188219
CredentialsTokenProvider,
@@ -211,15 +242,24 @@ def deploy(
211242
)
212243

213244
if package_type == "function":
214-
if not function_invoke_opt:
245+
# Infer use_in_feature from function signature
246+
entrypoint_path = os.path.join(path, ENTRYPOINT_FILE)
247+
use_in_feature = infer_use_in_feature(entrypoint_path)
248+
if use_in_feature:
249+
logger.info(f"Inferred use_in_feature: {use_in_feature}")
250+
else:
215251
click.secho(
216-
"Error: Function invoke options are required for function package type",
252+
"Error: Could not infer function invoke options. "
253+
"Please provide --use-in-feature",
217254
fg="red",
218255
)
219256
raise click.Abort()
220-
else:
221-
function_invoke_options = function_invoke_opt.split(",")
222-
metadata.functionInvokeOptions = function_invoke_options
257+
258+
# Map user-provided feature names to API names
259+
mapped_feature = USE_IN_FEATURE_MAPPING_FOR_CONNECT_API.get(
260+
use_in_feature, use_in_feature
261+
)
262+
metadata.functionInvokeOptions = [mapped_feature]
223263

224264
try:
225265
if sf_cli_org:
@@ -238,7 +278,12 @@ def deploy(
238278
@click.option(
239279
"--code-type", default="script", type=click.Choice(["script", "function"])
240280
)
241-
def init(directory: str, code_type: str):
281+
@click.option(
282+
"--use-in-feature",
283+
default="SearchIndexChunking",
284+
help="Feature where this function will be used (only applicable for function).",
285+
)
286+
def init(directory: str, code_type: str, use_in_feature: Optional[str]):
242287
from datacustomcode.scan import (
243288
dc_config_json_from_file,
244289
update_config,
@@ -250,9 +295,9 @@ def init(directory: str, code_type: str):
250295
if code_type == "script":
251296
copy_script_template(directory)
252297
elif code_type == "function":
253-
copy_function_template(directory)
254-
entrypoint_path = os.path.join(directory, "payload", "entrypoint.py")
255-
config_location = os.path.join(os.path.dirname(entrypoint_path), "config.json")
298+
copy_function_template(directory, use_in_feature)
299+
entrypoint_path = os.path.join(directory, PAYLOAD_DIR, ENTRYPOINT_FILE)
300+
config_location = os.path.join(os.path.dirname(entrypoint_path), CONFIG_FILE)
256301

257302
# Write package type to SDK-specific config
258303
sdk_config = {"type": code_type}
@@ -265,6 +310,7 @@ def init(directory: str, code_type: str):
265310
updated_config_json = update_config(entrypoint_path)
266311
with open(config_location, "w") as f:
267312
json.dump(updated_config_json, f, indent=2)
313+
268314
click.echo(
269315
"Start developing by updating the code in "
270316
+ click.style(entrypoint_path, fg="blue", bold=True)
@@ -275,6 +321,24 @@ def init(directory: str, code_type: str):
275321
+ " to automatically update config.json when you make changes to your code"
276322
)
277323

324+
# Generate test.json for functions
325+
if code_type == "function":
326+
test_json_path = _generate_function_test_file(entrypoint_path)
327+
if test_json_path:
328+
click.echo(
329+
"Generated test file at "
330+
+ click.style(test_json_path, fg="blue", bold=True)
331+
)
332+
click.echo(
333+
"Test your function locally with "
334+
+ click.style(
335+
f"datacustomcode run {entrypoint_path} "
336+
f"--test-with {test_json_path}",
337+
fg="blue",
338+
bold=True,
339+
)
340+
)
341+
278342

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

289-
config_location = config or os.path.join(os.path.dirname(filename), "config.json")
353+
config_location = config or os.path.join(os.path.dirname(filename), CONFIG_FILE)
290354
click.echo(
291355
"Dumping scan results to config file: "
292356
+ click.style(config_location, fg="blue", bold=True)
@@ -312,6 +376,12 @@ def scan(filename: str, config: str, dry_run: bool, no_requirements: bool):
312376
@click.option("--config-file", default=None)
313377
@click.option("--dependencies", default=[], multiple=True)
314378
@click.option("--profile", default="default")
379+
@click.option(
380+
"--test-with",
381+
default=None,
382+
type=click.Path(exists=True),
383+
help="Path to test JSON file for function testing",
384+
)
315385
@click.option(
316386
"--sf-cli-org",
317387
default=None,
@@ -322,10 +392,16 @@ def run(
322392
config_file: Union[str, None],
323393
dependencies: List[str],
324394
profile: str,
395+
test_with: Optional[str],
325396
sf_cli_org: Optional[str],
326397
):
327398
from datacustomcode.run import run_entrypoint
328399

329400
run_entrypoint(
330-
entrypoint, config_file, dependencies, profile, sf_cli_org=sf_cli_org
401+
entrypoint,
402+
config_file,
403+
dependencies,
404+
profile,
405+
test_file=test_with,
406+
sf_cli_org=sf_cli_org,
331407
)

src/datacustomcode/constants.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright (c) 2025, Salesforce, Inc.
2+
# SPDX-License-Identifier: Apache-2
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Constants used throughout the datacustomcode package."""
17+
18+
# File and directory names
19+
ENTRYPOINT_FILE = "entrypoint.py"
20+
CONFIG_FILE = "config.json"
21+
PAYLOAD_DIR = "payload"
22+
TESTS_DIR = "tests"
23+
TEST_FILE = "test.json"
24+
REQUIREMENTS_FILE = "requirements.txt"
25+
26+
# Default values
27+
DEFAULT_PROFILE = "default"
28+
DEFAULT_NETWORK = "default"
29+
DEFAULT_CPU_SIZE = "CPU_2XL"
30+
31+
# Feature to template folder mapping
32+
FEATURE_TEMPLATE_MAPPING = {
33+
"SearchIndexChunking": "chunking",
34+
}
35+
36+
# Feature name to Connect API name mapping
37+
USE_IN_FEATURE_MAPPING_FOR_CONNECT_API = {
38+
"SearchIndexChunking": "UnstructuredChunking",
39+
}
40+
41+
# Pydantic request/response type names to feature names
42+
REQUEST_TYPE_TO_FEATURE = {
43+
"SearchIndexChunkingV1Request": "SearchIndexChunking",
44+
"SearchIndexChunkingV1Response": "SearchIndexChunking",
45+
}

src/datacustomcode/deploy.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import requests
3636

3737
from datacustomcode.cmd import cmd_output
38+
from datacustomcode.constants import REQUEST_TYPE_TO_FEATURE
3839
from datacustomcode.scan import find_base_directory, get_package_type
3940

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

6768

69+
def infer_use_in_feature(entrypoint_path: str) -> Union[str, None]:
70+
"""Infer the use_in_feature from function signature.
71+
72+
Checks both the request parameter type and return type annotation.
73+
Both must map to the same feature for a valid inference.
74+
75+
Uses static AST parsing to avoid importing dependencies.
76+
77+
Args:
78+
entrypoint_path: Path to the entrypoint.py file
79+
80+
Returns:
81+
The feature name if both request and response match, None otherwise
82+
"""
83+
from datacustomcode.function_utils import inspect_function_types_static
84+
85+
request_type_name, response_type_name = inspect_function_types_static(
86+
entrypoint_path
87+
)
88+
89+
if not request_type_name or not response_type_name:
90+
return None
91+
92+
# Look up features for both types
93+
request_feature = REQUEST_TYPE_TO_FEATURE.get(request_type_name)
94+
response_feature = REQUEST_TYPE_TO_FEATURE.get(response_type_name)
95+
96+
# Both must be present and must match
97+
if request_feature and response_feature and request_feature == response_feature:
98+
return request_feature
99+
100+
return None
101+
102+
68103
class CodeExtensionMetadata(BaseModel):
69104
name: str
70105
version: str

0 commit comments

Comments
 (0)