Skip to content

Commit 21b738e

Browse files
Improving external SDK for function
1 parent 23643f1 commit 21b738e

6 files changed

Lines changed: 227 additions & 71 deletions

File tree

src/datacustomcode/cli.py

Lines changed: 14 additions & 9 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

@@ -85,9 +92,9 @@ def _generate_function_test_file(entrypoint_path: str) -> Optional[str]:
8592
"""
8693
from datacustomcode.function_utils import generate_test_json
8794

88-
tests_dir = os.path.join(os.path.dirname(entrypoint_path), "tests")
95+
tests_dir = os.path.join(os.path.dirname(entrypoint_path), TESTS_DIR)
8996
os.makedirs(tests_dir, exist_ok=True)
90-
test_json_path = os.path.join(tests_dir, "test.json")
97+
test_json_path = os.path.join(tests_dir, TEST_FILE)
9198

9299
try:
93100
generate_test_json(entrypoint_path, test_json_path)
@@ -236,7 +243,7 @@ def deploy(
236243

237244
if package_type == "function":
238245
# Infer use_in_feature from function signature
239-
entrypoint_path = os.path.join(path, "entrypoint.py")
246+
entrypoint_path = os.path.join(path, ENTRYPOINT_FILE)
240247
use_in_feature = infer_use_in_feature(entrypoint_path)
241248
if use_in_feature:
242249
logger.info(f"Inferred use_in_feature: {use_in_feature}")
@@ -288,11 +295,9 @@ def init(directory: str, code_type: str, use_in_feature: Optional[str]):
288295
if code_type == "script":
289296
copy_script_template(directory)
290297
elif code_type == "function":
291-
# Default to SearchIndexChunking if not provided
292-
feature = use_in_feature
293-
copy_function_template(directory, feature)
294-
entrypoint_path = os.path.join(directory, "payload", "entrypoint.py")
295-
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)
296301

297302
# Write package type to SDK-specific config
298303
sdk_config = {"type": code_type}
@@ -344,7 +349,7 @@ def init(directory: str, code_type: str, use_in_feature: Optional[str]):
344349
def scan(filename: str, config: str, dry_run: bool, no_requirements: bool):
345350
from datacustomcode.scan import update_config, write_requirements_file
346351

347-
config_location = config or os.path.join(os.path.dirname(filename), "config.json")
352+
config_location = config or os.path.join(os.path.dirname(filename), CONFIG_FILE)
348353
click.echo(
349354
"Dumping scan results to config file: "
350355
+ click.style(config_location, fg="blue", bold=True)

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: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
import requests
3636

3737
from datacustomcode.cmd import cmd_output
38+
from datacustomcode.constants import (
39+
REQUEST_TYPE_TO_FEATURE,
40+
USE_IN_FEATURE_MAPPING_FOR_CONNECT_API,
41+
)
3842
from datacustomcode.scan import find_base_directory, get_package_type
3943

4044
DATA_CUSTOM_CODE_PATH = "services/data/v63.0/ssot/data-custom-code"
@@ -65,32 +69,23 @@ def _sanitize_api_name(name: str) -> str:
6569
return sanitized
6670

6771

68-
# Mapping from user-facing feature names to internal API names
69-
USE_IN_FEATURE_MAPPING_FOR_CONNECT_API = {
70-
"SearchIndexChunking": "UnstructuredChunking",
71-
}
72-
73-
# Mapping from Pydantic request/response types to feature names
74-
REQUEST_TYPE_TO_FEATURE = {
75-
"SearchIndexChunkingV1Request": "SearchIndexChunking",
76-
"SearchIndexChunkingV1Response": "SearchIndexChunking",
77-
}
78-
7972
def infer_use_in_feature(entrypoint_path: str) -> Union[str, None]:
8073
"""Infer the use_in_feature from function signature.
8174
8275
Checks both the request parameter type and return type annotation.
8376
Both must map to the same feature for a valid inference.
8477
78+
Uses static AST parsing to avoid importing dependencies.
79+
8580
Args:
8681
entrypoint_path: Path to the entrypoint.py file
8782
8883
Returns:
8984
The feature name if both request and response match, None otherwise
9085
"""
91-
from datacustomcode.function_utils import inspect_function_types
86+
from datacustomcode.function_utils import inspect_function_types_static
9287

93-
request_type_name, response_type_name = inspect_function_types(entrypoint_path)
88+
request_type_name, response_type_name = inspect_function_types_static(entrypoint_path)
9489

9590
if not request_type_name or not response_type_name:
9691
return None

src/datacustomcode/function_utils.py

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
"""Utilities for inspecting and working with function entrypoints."""
1717

18+
import ast
1819
import importlib.util
1920
import inspect
2021
import json
@@ -107,6 +108,93 @@ def get_function_signature_types(
107108
return request_type, response_type, request_type_name, response_type_name
108109

109110

111+
def inspect_function_types_static(entrypoint_path: str) -> Tuple[Optional[str], Optional[str]]:
112+
"""Inspect function types using static AST parsing (no imports).
113+
114+
This parses the Python file without executing it, so it doesn't
115+
require dependencies to be installed.
116+
117+
Args:
118+
entrypoint_path: Path to the entrypoint.py file
119+
120+
Returns:
121+
Tuple of (request_type_name, response_type_name)
122+
"""
123+
try:
124+
with open(entrypoint_path, 'r') as f:
125+
tree = ast.parse(f.read(), filename=entrypoint_path)
126+
127+
# Find the 'function' definition
128+
for node in ast.walk(tree):
129+
if isinstance(node, ast.FunctionDef) and node.name == "function":
130+
# Get request type (first parameter annotation)
131+
request_type_name = None
132+
if node.args.args and len(node.args.args) > 0:
133+
first_param = node.args.args[0]
134+
if first_param.annotation:
135+
request_type_name = _get_type_name_from_ast(first_param.annotation)
136+
137+
# Get response type (return annotation)
138+
response_type_name = None
139+
if node.returns:
140+
response_type_name = _get_type_name_from_ast(node.returns)
141+
142+
return request_type_name, response_type_name
143+
144+
return None, None
145+
except Exception:
146+
return None, None
147+
148+
149+
def _get_type_name_from_ast(annotation) -> Optional[str]:
150+
"""Extract type name from an AST annotation node."""
151+
if isinstance(annotation, ast.Name):
152+
# Simple type: MyType
153+
return annotation.id
154+
elif isinstance(annotation, ast.Attribute):
155+
# Module.Type - just return the type name
156+
return annotation.attr
157+
elif isinstance(annotation, ast.Subscript):
158+
# Generic type: List[MyType], Optional[MyType]
159+
# Return the base type name
160+
return _get_type_name_from_ast(annotation.value)
161+
return None
162+
163+
164+
def _import_pydantic_model(entrypoint_path: str, type_name: str) -> Optional[Any]:
165+
"""Import a Pydantic model by finding its import statement.
166+
167+
Parses the entrypoint to find where the type is imported from,
168+
then imports just that module (not the entrypoint itself).
169+
170+
Args:
171+
entrypoint_path: Path to entrypoint.py
172+
type_name: Name of the type to import (e.g., "SearchIndexChunkingV1Request")
173+
174+
Returns:
175+
The Pydantic model class, or None if not found
176+
"""
177+
try:
178+
with open(entrypoint_path, 'r') as f:
179+
tree = ast.parse(f.read(), filename=entrypoint_path)
180+
181+
# Find where this type is imported from
182+
for node in ast.walk(tree):
183+
if isinstance(node, ast.ImportFrom):
184+
# from module import Type1, Type2
185+
for alias in node.names:
186+
if alias.name == type_name:
187+
# Found it! Import from the module
188+
module_name = node.module
189+
if module_name:
190+
module = importlib.import_module(module_name)
191+
return getattr(module, type_name, None)
192+
193+
return None
194+
except Exception:
195+
return None
196+
197+
110198
def inspect_function_types(
111199
entrypoint_path: str,
112200
) -> Tuple[Optional[str], Optional[str]]:
@@ -230,17 +318,29 @@ def generate_sample_value(field_type, field_name: str):
230318
def generate_test_json(entrypoint_path: str, output_path: str) -> None:
231319
"""Generate a sample test.json file for a function.
232320
321+
First tries static AST parsing to get type names, then uses those
322+
to import only the Pydantic model classes (not the entrypoint).
323+
233324
Args:
234325
entrypoint_path: Path to the function entrypoint.py
235326
output_path: Output path for test.json
236327
237328
Raises:
238-
ImportError: If the module cannot be loaded
239-
AttributeError: If the function is not found
240-
ValueError: If the request type is not a Pydantic model
329+
ImportError: If the Pydantic model cannot be loaded
330+
ValueError: If the request type is not found or not a Pydantic model
241331
"""
242-
# Get the request type
243-
request_type = get_request_type(entrypoint_path)
332+
# First, get the type name using static parsing (no imports)
333+
request_type_name, _ = inspect_function_types_static(entrypoint_path)
334+
335+
if not request_type_name:
336+
raise ValueError("Could not determine request type from function signature")
337+
338+
# Now try to import the Pydantic model class
339+
# Look for it in the entrypoint's imports
340+
request_type = _import_pydantic_model(entrypoint_path, request_type_name)
341+
342+
if not request_type:
343+
raise ValueError(f"Could not import Pydantic model: {request_type_name}")
244344

245345
# Check if it's a Pydantic model
246346
if not hasattr(request_type, "model_fields"):

src/datacustomcode/template.py

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
from loguru import logger
1919

20+
from datacustomcode.constants import FEATURE_TEMPLATE_MAPPING
21+
2022
script_template_dir = os.path.join(os.path.dirname(__file__), "templates", "script")
2123
function_template_dir = os.path.join(os.path.dirname(__file__), "templates", "function")
2224

@@ -37,28 +39,40 @@ def copy_script_template(target_dir: str) -> None:
3739
shutil.copy2(source, destination)
3840

3941

40-
MAPPED_FOLDER = {"SearchIndexChunking": "chunking"}
41-
42-
4342
def copy_function_template(target_dir: str, use_in_feature: str) -> None:
4443
os.makedirs(target_dir, exist_ok=True)
4544

46-
if use_in_feature and use_in_feature in MAPPED_FOLDER:
47-
feature_function_template_dir = os.path.join(
48-
function_template_dir, MAPPED_FOLDER[use_in_feature]
49-
)
50-
else:
51-
feature_function_template_dir = function_template_dir
52-
53-
for item in os.listdir(feature_function_template_dir):
54-
source = os.path.join(feature_function_template_dir, item)
45+
# First, copy common files from base function template
46+
for item in os.listdir(function_template_dir):
47+
source = os.path.join(function_template_dir, item)
5548
destination = os.path.join(target_dir, item)
5649

50+
# Skip feature-specific subdirectories
51+
if os.path.isdir(source) and item in FEATURE_TEMPLATE_MAPPING.values():
52+
continue
53+
5754
if os.path.isdir(source):
5855
logger.debug(f"Copying directory {source} to {destination}...")
5956
shutil.copytree(source, destination, dirs_exist_ok=True)
6057
else:
6158
logger.debug(f"Copying file {source} to {destination}...")
6259
shutil.copy2(source, destination)
6360

61+
# Then, copy feature-specific files (overwriting if needed)
62+
if use_in_feature and use_in_feature in FEATURE_TEMPLATE_MAPPING:
63+
feature_function_template_dir = os.path.join(
64+
function_template_dir, FEATURE_TEMPLATE_MAPPING[use_in_feature]
65+
)
66+
67+
for item in os.listdir(feature_function_template_dir):
68+
source = os.path.join(feature_function_template_dir, item)
69+
destination = os.path.join(target_dir, item)
70+
71+
if os.path.isdir(source):
72+
logger.debug(f"Copying feature-specific directory {source} to {destination}...")
73+
shutil.copytree(source, destination, dirs_exist_ok=True)
74+
else:
75+
logger.debug(f"Copying feature-specific file {source} to {destination}...")
76+
shutil.copy2(source, destination)
77+
6478

0 commit comments

Comments
 (0)