sfdc-agentforce-byoc-python-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. agentforce_byoc/__init__.py +26 -0
  2. agentforce_byoc/_http.py +142 -0
  3. agentforce_byoc/cli/__init__.py +8 -0
  4. agentforce_byoc/cli/commands.py +913 -0
  5. agentforce_byoc/cli/main.py +44 -0
  6. agentforce_byoc/client.py +224 -0
  7. agentforce_byoc/deploy_client.py +246 -0
  8. agentforce_byoc/gateway/__init__.py +26 -0
  9. agentforce_byoc/gateway/base.py +66 -0
  10. agentforce_byoc/gateway/mock_relay_client.py +54 -0
  11. agentforce_byoc/gateway/relay_client.py +394 -0
  12. agentforce_byoc/logging/__init__.py +20 -0
  13. agentforce_byoc/logging/formatter.py +96 -0
  14. agentforce_byoc/logging/logger.py +59 -0
  15. agentforce_byoc/runtime/__init__.py +21 -0
  16. agentforce_byoc/runtime/agentforce_runtime.py +47 -0
  17. agentforce_byoc/runtime/base.py +54 -0
  18. agentforce_byoc/runtime/mock_runtime.py +50 -0
  19. agentforce_byoc/schema_utils/__init__.py +35 -0
  20. agentforce_byoc/schema_utils/entry_func.py +48 -0
  21. agentforce_byoc/schema_utils/generator.py +694 -0
  22. agentforce_byoc/schema_utils/schema.py +79 -0
  23. agentforce_byoc/templates/__init__.py +4 -0
  24. agentforce_byoc/templates/agent/.dockerignore.template +51 -0
  25. agentforce_byoc/templates/agent/.gitignore.template +64 -0
  26. agentforce_byoc/templates/agent/.python-version.template +1 -0
  27. agentforce_byoc/templates/agent/Dockerfile.template +41 -0
  28. agentforce_byoc/templates/agent/Makefile.template +51 -0
  29. agentforce_byoc/templates/agent/README.md.template +358 -0
  30. agentforce_byoc/templates/agent/config.json.template +3 -0
  31. agentforce_byoc/templates/agent/entry_func.py.template +51 -0
  32. agentforce_byoc/templates/agent/main.py.template +149 -0
  33. agentforce_byoc/templates/agent/pyproject.toml.template +24 -0
  34. agentforce_byoc/templates/agent/schema.py.template +73 -0
  35. sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/METADATA +439 -0
  36. sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/RECORD +39 -0
  37. sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/WHEEL +4 -0
  38. sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/entry_points.txt +3 -0
  39. sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/licenses/LICENSE +206 -0
@@ -0,0 +1,694 @@
1
+ # Copyright (c) 2026, Salesforce, Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
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
+ """Generate an OpenAPI spec from ``@entry_func`` functions.
17
+
18
+ Scans a Python source file for functions decorated with ``@entry_func`` (see
19
+ :mod:`agentforce_byoc.schema_utils.entry_func`), extracts their signatures and
20
+ the ``@requestSchema`` / ``@responseSchema`` classes, and produces an OpenAPI
21
+ 3.0.0 specification exposing each function as a POST operation with the
22
+ ``x-sfdc.code-extension`` block used for BYOC Code Extension registration.
23
+
24
+ The source is parsed at the **AST level** — it is never imported or executed —
25
+ so generation is safe to run on untrusted customer code and needs none of the
26
+ code's own runtime dependencies. The only dependency here is PyYAML.
27
+
28
+ Each ``@entry_func`` function must accept exactly one parameter named
29
+ ``request`` whose type is ``Dict`` (or ``dict``) and must return ``Dict`` (or
30
+ ``dict``). A bare ``dict`` is only allowed when the corresponding
31
+ ``@requestSchema`` / ``@responseSchema`` decorator supplies the field types.
32
+
33
+ Example::
34
+
35
+ from agentforce_byoc.schema_utils import generate_schema_yaml
36
+
37
+ yaml_str = generate_schema_yaml(
38
+ source_path="main.py", namespace="myOrg", package="myPackage"
39
+ )
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import ast
46
+ import sys
47
+ from typing import Any, Dict, List, Optional, Tuple, cast
48
+
49
+ # NOTE: PyYAML is imported lazily inside the functions that need it
50
+ # (generate_schema_yaml / load_config), NOT at module top level. This module is
51
+ # re-exported from agentforce_byoc.schema_utils, which generated packages import
52
+ # just to get the @entry_func / @requestSchema / @responseSchema decorators.
53
+ # Those packages don't depend on PyYAML (the AST-based generator is a CLI/dev
54
+ # tool, not a runtime dependency), so a top-level `import yaml` would crash the
55
+ # package at import time with ModuleNotFoundError.
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Python-type → OpenAPI type mapping
59
+ # ---------------------------------------------------------------------------
60
+
61
+ _SIMPLE_TYPE_MAP: Dict[str, Dict[str, str]] = {
62
+ "int": {"type": "integer"},
63
+ "float": {"type": "number"},
64
+ "str": {"type": "string"},
65
+ "bool": {"type": "boolean"},
66
+ }
67
+
68
+ _DEFAULT_NAMESPACE = "myOrg"
69
+ _DEFAULT_PACKAGE = "myPackage"
70
+
71
+
72
+ def python_type_to_openapi(type_annotation: Any) -> Dict[str, Any]:
73
+ """Convert a Python type annotation (str or AST node) to an OpenAPI schema dict.
74
+
75
+ Supports simple types (``int``, ``str``, …), generic containers
76
+ (``List[int]``, ``Dict[str, int]``), and arbitrarily nested combinations
77
+ (``Dict[str, Dict[str, List[int]]]``).
78
+ """
79
+ if isinstance(type_annotation, ast.Subscript):
80
+ return _ast_node_to_openapi(type_annotation)
81
+
82
+ if isinstance(type_annotation, str):
83
+ if type_annotation in _SIMPLE_TYPE_MAP:
84
+ return dict(_SIMPLE_TYPE_MAP[type_annotation])
85
+ if type_annotation in ("list", "List"):
86
+ return {"type": "array"}
87
+ if type_annotation in ("dict", "Dict"):
88
+ return {"type": "object"}
89
+ raise ValueError(f"Unsupported type annotation: {type_annotation}")
90
+
91
+ if isinstance(type_annotation, ast.Name):
92
+ return python_type_to_openapi(type_annotation.id)
93
+
94
+ raise ValueError(f"Unsupported type annotation: {ast.dump(type_annotation)}")
95
+
96
+
97
+ def _ast_node_to_openapi(
98
+ node: ast.expr,
99
+ class_map: Optional[Dict[str, "ClassSchema"]] = None,
100
+ ) -> Dict[str, Any]:
101
+ """Recursively convert an AST annotation node to an OpenAPI schema."""
102
+ if isinstance(node, ast.Name):
103
+ if class_map and node.id in class_map:
104
+ return _build_class_openapi_schema(class_map[node.id], class_map)
105
+ return python_type_to_openapi(node.id)
106
+
107
+ if isinstance(node, ast.Constant):
108
+ return python_type_to_openapi(type(node.value).__name__)
109
+
110
+ if isinstance(node, ast.Subscript):
111
+ base = node.value
112
+ if not isinstance(base, ast.Name):
113
+ raise ValueError(f"Unsupported generic base: {ast.dump(base)}")
114
+ base_name = base.id
115
+
116
+ if base_name in ("List", "list"):
117
+ items_schema = _ast_node_to_openapi(node.slice, class_map)
118
+ return {"type": "array", "items": items_schema}
119
+
120
+ if base_name in ("Dict", "dict"):
121
+ if isinstance(node.slice, ast.Tuple):
122
+ elts = node.slice.elts
123
+ if len(elts) != 2:
124
+ raise ValueError(f"Dict requires exactly 2 type args, got {len(elts)}")
125
+ value_schema = _ast_node_to_openapi(elts[1], class_map)
126
+ return {"type": "object", "additionalProperties": value_schema}
127
+ value_schema = _ast_node_to_openapi(node.slice, class_map)
128
+ return {"type": "object", "additionalProperties": value_schema}
129
+
130
+ raise ValueError(f"Unsupported generic type: {base_name}")
131
+
132
+ raise ValueError(f"Unsupported annotation node: {ast.dump(node)}")
133
+
134
+
135
+ def _is_dict_type(type_str: str) -> bool:
136
+ """Check if a type string represents a Dict type (with or without parameters)."""
137
+ _dict_names = ("dict", "Dict")
138
+ return type_str in _dict_names or any(type_str.startswith(f"{n}[") for n in _dict_names)
139
+
140
+
141
+ def _contains_any(type_str: str) -> bool:
142
+ """Check if a type string contains 'Any' as a standalone type token."""
143
+ import re
144
+
145
+ tokens = re.split(r"[\[\],\s]+", type_str)
146
+ return "Any" in tokens
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # AST helpers
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ def _get_decorator_names(node: ast.FunctionDef) -> List[str]:
155
+ """Return the simple names of all decorators on *node*."""
156
+ names: List[str] = []
157
+ for dec in node.decorator_list:
158
+ if isinstance(dec, ast.Name):
159
+ names.append(dec.id)
160
+ elif isinstance(dec, ast.Attribute):
161
+ names.append(dec.attr)
162
+ return names
163
+
164
+
165
+ def _resolve_default(node: ast.expr) -> Any:
166
+ """Turn an AST default-value node into a Python literal."""
167
+ return ast.literal_eval(node)
168
+
169
+
170
+ def _annotation_to_str(node: ast.expr) -> str:
171
+ """Return the source text of an annotation node, including generics."""
172
+ if isinstance(node, ast.Name):
173
+ return node.id
174
+ if isinstance(node, ast.Constant):
175
+ return type(node.value).__name__
176
+ if isinstance(node, ast.Attribute):
177
+ return node.attr
178
+ if isinstance(node, ast.Subscript):
179
+ base = _annotation_to_str(node.value)
180
+ if isinstance(node.slice, ast.Tuple):
181
+ args = ", ".join(_annotation_to_str(e) for e in node.slice.elts)
182
+ else:
183
+ args = _annotation_to_str(node.slice)
184
+ return f"{base}[{args}]"
185
+ return ast.dump(node)
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Core extraction
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ class ClassSchema:
194
+ """Parsed representation of a class with typed fields (e.g. TypedDict)."""
195
+
196
+ def __init__(self, name: str, fields: List[Tuple[str, ast.expr]]) -> None:
197
+ self.name = name
198
+ self.fields = fields # [(field_name, type_annotation_node), ...]
199
+
200
+
201
+ class FunctionSchema:
202
+ """Parsed representation of a single ``@entry_func`` function."""
203
+
204
+ def __init__(
205
+ self,
206
+ name: str,
207
+ docstring: Optional[str],
208
+ params: List[Dict[str, Any]],
209
+ return_type: Optional[str],
210
+ return_type_node: Optional[ast.expr],
211
+ prototype: str,
212
+ input_schema: Optional[ClassSchema] = None,
213
+ output_schema: Optional[ClassSchema] = None,
214
+ ) -> None:
215
+ self.name = name
216
+ self.docstring = docstring
217
+ self.params = params
218
+ self.return_type = return_type
219
+ self.return_type_node = return_type_node
220
+ self.prototype = prototype
221
+ self.input_schema = input_schema
222
+ self.output_schema = output_schema
223
+ self.class_map: Dict[str, ClassSchema] = {}
224
+
225
+
226
+ def _find_class_in_tree(tree: ast.Module, class_name: str) -> ClassSchema:
227
+ """Find a class definition by name and extract its annotated fields."""
228
+ for node in ast.walk(tree):
229
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
230
+ fields: List[Tuple[str, ast.expr]] = []
231
+ for stmt in node.body:
232
+ if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
233
+ fields.append((stmt.target.id, stmt.annotation))
234
+ if not fields:
235
+ raise ValueError(
236
+ f"Class '{class_name}' has no typed fields; " "cannot be used as a schema"
237
+ )
238
+ return ClassSchema(class_name, fields)
239
+ raise ValueError(f"Class '{class_name}' not found in the source file")
240
+
241
+
242
+ def _is_parameterized_dict(type_str: str) -> bool:
243
+ """Check if a type string is a parameterized Dict (not bare dict/Dict)."""
244
+ return any(type_str.startswith(f"{n}[") for n in ("dict", "Dict"))
245
+
246
+
247
+ def _is_optional(node: ast.expr) -> bool:
248
+ """Check if an annotation node is ``Optional[X]``."""
249
+ return (
250
+ isinstance(node, ast.Subscript)
251
+ and isinstance(node.value, ast.Name)
252
+ and node.value.id == "Optional"
253
+ )
254
+
255
+
256
+ def _unwrap_optional(node: ast.expr) -> ast.expr:
257
+ """If *node* is ``Optional[X]``, return the inner ``X``."""
258
+ if _is_optional(node):
259
+ # _is_optional guarantees a Subscript; assert narrows it for the type checker.
260
+ assert isinstance(node, ast.Subscript)
261
+ return node.slice
262
+ return node
263
+
264
+
265
+ def _build_class_openapi_schema(
266
+ schema: ClassSchema,
267
+ class_map: Optional[Dict[str, "ClassSchema"]] = None,
268
+ ) -> Dict[str, Any]:
269
+ """Build an OpenAPI schema from a ClassSchema's typed fields.
270
+
271
+ Fields annotated with ``Optional[X]`` are included in ``properties`` but
272
+ omitted from the ``required`` list.
273
+ """
274
+ properties: Dict[str, Any] = {}
275
+ required: List[str] = []
276
+ for field_name, type_node in schema.fields:
277
+ if _is_optional(type_node):
278
+ properties[field_name] = _ast_node_to_openapi(_unwrap_optional(type_node), class_map)
279
+ else:
280
+ properties[field_name] = _ast_node_to_openapi(type_node, class_map)
281
+ required.append(field_name)
282
+ result: Dict[str, Any] = {"type": "object", "properties": properties}
283
+ if required:
284
+ result["required"] = required
285
+ return result
286
+
287
+
288
+ def extract_entry_functions(source: str) -> List[FunctionSchema]:
289
+ """Parse *source* and return schemas for every ``@entry_func`` function.
290
+
291
+ Raises ``ValueError`` when a decorated function has untyped parameters, the
292
+ wrong signature, or uses a bare ``dict`` / ``Any`` without a schema class.
293
+ """
294
+ tree = ast.parse(source)
295
+ results: List[FunctionSchema] = []
296
+
297
+ # Build a map of all class definitions for nested TypedDict resolution.
298
+ class_map: Dict[str, ClassSchema] = {}
299
+ for node in ast.walk(tree):
300
+ if isinstance(node, ast.ClassDef):
301
+ fields: List[Tuple[str, ast.expr]] = []
302
+ for stmt in node.body:
303
+ if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
304
+ fields.append((stmt.target.id, stmt.annotation))
305
+ if fields:
306
+ class_map[node.name] = ClassSchema(node.name, fields)
307
+
308
+ for node in ast.walk(tree):
309
+ if not isinstance(node, ast.FunctionDef):
310
+ continue
311
+ if "entry_func" not in _get_decorator_names(node):
312
+ continue
313
+
314
+ args = node.args
315
+ all_args = args.args
316
+ defaults = args.defaults
317
+ num_no_default = len(all_args) - len(defaults)
318
+
319
+ params: List[Dict[str, Any]] = []
320
+ proto_parts: List[str] = []
321
+
322
+ for idx, arg in enumerate(all_args):
323
+ if arg.annotation is None:
324
+ raise ValueError(
325
+ f"Parameter '{arg.arg}' of function '{node.name}' "
326
+ "is missing a type annotation"
327
+ )
328
+ type_str = _annotation_to_str(arg.annotation)
329
+ param: Dict[str, Any] = {
330
+ "name": arg.arg,
331
+ "type": type_str,
332
+ "type_node": arg.annotation,
333
+ }
334
+ proto = f"{arg.arg}: {type_str}"
335
+
336
+ default_idx = idx - num_no_default
337
+ if default_idx >= 0:
338
+ default_val = _resolve_default(defaults[default_idx])
339
+ param["default"] = default_val
340
+ proto += f" = {default_val!r}"
341
+
342
+ params.append(param)
343
+ proto_parts.append(proto)
344
+
345
+ return_type: Optional[str] = None
346
+ return_type_node: Optional[ast.expr] = None
347
+ if node.returns is not None:
348
+ return_type = _annotation_to_str(node.returns)
349
+ return_type_node = node.returns
350
+
351
+ if len(params) != 1:
352
+ raise ValueError(
353
+ f"Function '{node.name}' must have exactly one parameter "
354
+ f"named 'request', got {len(params)} parameter(s)"
355
+ )
356
+ if params[0]["name"] != "request":
357
+ raise ValueError(
358
+ f"Function '{node.name}' must have a single parameter "
359
+ f"named 'request', got '{params[0]['name']}'"
360
+ )
361
+ if not _is_dict_type(params[0]["type"]):
362
+ raise ValueError(
363
+ f"Parameter 'request' of function '{node.name}' must be "
364
+ f"Dict type, got '{params[0]['type']}'"
365
+ )
366
+ if return_type is None:
367
+ raise ValueError(f"Function '{node.name}' must have a Dict return type annotation")
368
+ if not _is_dict_type(return_type):
369
+ raise ValueError(f"Function '{node.name}' must return Dict type, got '{return_type}'")
370
+ if _contains_any(params[0]["type"]):
371
+ raise ValueError(
372
+ f"Parameter 'request' of function '{node.name}' uses 'Any' "
373
+ "which is not allowed; all types must be fully specified"
374
+ )
375
+ if _contains_any(return_type):
376
+ raise ValueError(
377
+ f"Return type of function '{node.name}' uses 'Any' "
378
+ "which is not allowed; all types must be fully specified"
379
+ )
380
+
381
+ # --- detect @requestSchema / @responseSchema decorators -------------
382
+ input_class_name: Optional[str] = None
383
+ output_class_name: Optional[str] = None
384
+ for dec in node.decorator_list:
385
+ if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name):
386
+ if dec.func.id == "requestSchema" and dec.args:
387
+ if isinstance(dec.args[0], ast.Name):
388
+ input_class_name = dec.args[0].id
389
+ elif dec.func.id == "responseSchema" and dec.args:
390
+ if isinstance(dec.args[0], ast.Name):
391
+ output_class_name = dec.args[0].id
392
+
393
+ input_schema: Optional[ClassSchema] = None
394
+ output_schema: Optional[ClassSchema] = None
395
+
396
+ if input_class_name:
397
+ input_schema = _find_class_in_tree(tree, input_class_name)
398
+ for field_name, type_node_f in input_schema.fields:
399
+ if _contains_any(_annotation_to_str(type_node_f)):
400
+ raise ValueError(
401
+ f"Field '{field_name}' in class '{input_class_name}' "
402
+ "uses 'Any' which is not allowed; all types must be "
403
+ "fully specified"
404
+ )
405
+
406
+ if output_class_name:
407
+ output_schema = _find_class_in_tree(tree, output_class_name)
408
+ for field_name, type_node_f in output_schema.fields:
409
+ if _contains_any(_annotation_to_str(type_node_f)):
410
+ raise ValueError(
411
+ f"Field '{field_name}' in class '{output_class_name}' "
412
+ "uses 'Any' which is not allowed; all types must be "
413
+ "fully specified"
414
+ )
415
+
416
+ if not input_schema and not _is_parameterized_dict(params[0]["type"]):
417
+ raise ValueError(
418
+ f"Parameter 'request' of function '{node.name}' uses bare "
419
+ "dict without @requestSchema; either use a parameterized "
420
+ "Dict type (e.g. Dict[str, int]) or add @requestSchema"
421
+ )
422
+ if not output_schema and not _is_parameterized_dict(return_type):
423
+ raise ValueError(
424
+ f"Return type of function '{node.name}' uses bare dict "
425
+ "without @responseSchema; either use a parameterized "
426
+ "Dict type (e.g. Dict[str, int]) or add @responseSchema"
427
+ )
428
+
429
+ ret_str = f" -> {return_type}" if return_type else ""
430
+ prototype = f"{node.name}({', '.join(proto_parts)}){ret_str}"
431
+
432
+ fn_schema = FunctionSchema(
433
+ name=node.name,
434
+ docstring=ast.get_docstring(node),
435
+ params=params,
436
+ return_type=return_type,
437
+ return_type_node=return_type_node,
438
+ prototype=prototype,
439
+ input_schema=input_schema,
440
+ output_schema=output_schema,
441
+ )
442
+ fn_schema.class_map = class_map
443
+ results.append(fn_schema)
444
+
445
+ return results
446
+
447
+
448
+ # ---------------------------------------------------------------------------
449
+ # OpenAPI generation
450
+ # ---------------------------------------------------------------------------
451
+
452
+
453
+ def _title_from_name(name: str) -> str:
454
+ """Convert a snake_case function name to a Title Case service name."""
455
+ return name.replace("_", " ").title() + " Service"
456
+
457
+
458
+ def _build_schema_prototype(schema: ClassSchema) -> str:
459
+ """Build a Python-style prototype string from a ClassSchema, e.g. ``dict(a: int)``."""
460
+ parts = []
461
+ for field_name, type_node in schema.fields:
462
+ parts.append(f"{field_name}: {_annotation_to_str(type_node)}")
463
+ return f"dict({', '.join(parts)})"
464
+
465
+
466
+ def _build_request_schema(
467
+ params: List[Dict[str, Any]],
468
+ input_schema: Optional[ClassSchema] = None,
469
+ class_map: Optional[Dict[str, ClassSchema]] = None,
470
+ ) -> Dict[str, Any]:
471
+ """Build the request body schema from the single ``request`` Dict parameter."""
472
+ if input_schema:
473
+ return _build_class_openapi_schema(input_schema, class_map)
474
+ p = params[0]
475
+ type_source = p.get("type_node", p["type"])
476
+ return python_type_to_openapi(type_source)
477
+
478
+
479
+ def _build_response_schema(
480
+ schema: FunctionSchema,
481
+ class_map: Optional[Dict[str, ClassSchema]] = None,
482
+ ) -> Dict[str, Any]:
483
+ """Build the response schema from the Dict return type."""
484
+ if schema.output_schema:
485
+ return _build_class_openapi_schema(schema.output_schema, class_map)
486
+ type_source = schema.return_type_node or schema.return_type
487
+ return python_type_to_openapi(type_source)
488
+
489
+
490
+ def _build_x_function(
491
+ schema: FunctionSchema,
492
+ namespace: str,
493
+ package: str,
494
+ ) -> Dict[str, Any]:
495
+ """Build the ``x-sfdc`` code-extension object for an OpenAPI operation."""
496
+ x_fn: Dict[str, Any] = {
497
+ "feature": "BYOC",
498
+ "language": "python",
499
+ "namespace": namespace,
500
+ "package": package,
501
+ "name": f"{schema.name}Fn",
502
+ "prototype": schema.prototype,
503
+ }
504
+ if schema.input_schema:
505
+ x_fn["requestSchema"] = _build_schema_prototype(schema.input_schema)
506
+ if schema.output_schema:
507
+ x_fn["responseSchema"] = _build_schema_prototype(schema.output_schema)
508
+ x_fn["description"] = f"Will perform {schema.name} operation"
509
+ return x_fn
510
+
511
+
512
+ def generate_openapi(
513
+ schemas: List[FunctionSchema],
514
+ *,
515
+ namespace: str = _DEFAULT_NAMESPACE,
516
+ package: str = _DEFAULT_PACKAGE,
517
+ ) -> Dict[str, Any]:
518
+ """Build an OpenAPI 3.0.0 dict from a list of ``FunctionSchema`` objects."""
519
+ if not schemas:
520
+ raise ValueError("No @entry_func functions found in the input file")
521
+
522
+ first = schemas[0]
523
+ spec: Dict[str, Any] = {
524
+ "openapi": "3.0.0",
525
+ "info": {
526
+ "title": _title_from_name(first.name),
527
+ "description": "API generated from Python function schema",
528
+ "version": "1.0.0",
529
+ },
530
+ "paths": {},
531
+ }
532
+
533
+ for schema in schemas:
534
+ summary = (schema.docstring or "").split("\n")[0].rstrip(".")
535
+ path_op: Dict[str, Any] = {
536
+ "summary": summary,
537
+ "description": schema.docstring or "",
538
+ "operationId": schema.name,
539
+ "x-sfdc": {
540
+ "code-extension": _build_x_function(schema, namespace, package),
541
+ },
542
+ "requestBody": {
543
+ "required": True,
544
+ "content": {
545
+ "application/json": {
546
+ "schema": _build_request_schema(
547
+ schema.params, schema.input_schema, schema.class_map
548
+ ),
549
+ }
550
+ },
551
+ },
552
+ "responses": {
553
+ "200": {
554
+ "description": "Successful response",
555
+ "content": {
556
+ "application/json": {
557
+ "schema": _build_response_schema(schema, schema.class_map),
558
+ }
559
+ },
560
+ },
561
+ "400": {"description": "Invalid input"},
562
+ },
563
+ }
564
+ spec["paths"][f"/{schema.name}"] = {"post": path_op}
565
+
566
+ return spec
567
+
568
+
569
+ # ---------------------------------------------------------------------------
570
+ # Public API
571
+ # ---------------------------------------------------------------------------
572
+
573
+
574
+ def generate_schema(
575
+ source: str,
576
+ *,
577
+ namespace: str = _DEFAULT_NAMESPACE,
578
+ package: str = _DEFAULT_PACKAGE,
579
+ ) -> Dict[str, Any]:
580
+ """Return the OpenAPI 3.0.0 dict for the ``@entry_func`` functions in *source*.
581
+
582
+ *source* is Python source code (a string), parsed via AST — never imported.
583
+ """
584
+ return generate_openapi(extract_entry_functions(source), namespace=namespace, package=package)
585
+
586
+
587
+ def generate_schema_yaml(
588
+ source_path: str,
589
+ *,
590
+ namespace: str = _DEFAULT_NAMESPACE,
591
+ package: str = _DEFAULT_PACKAGE,
592
+ ) -> str:
593
+ """Read the Python file at *source_path* and return its OpenAPI spec as YAML."""
594
+ import yaml
595
+
596
+ with open(source_path, "r") as f:
597
+ source = f.read()
598
+ spec = generate_schema(source, namespace=namespace, package=package)
599
+ return cast(str, yaml.dump(spec, default_flow_style=False, sort_keys=False))
600
+
601
+
602
+ # ---------------------------------------------------------------------------
603
+ # Config file support
604
+ # ---------------------------------------------------------------------------
605
+
606
+
607
+ def load_config(config_path: str) -> Dict[str, str]:
608
+ """Load namespace/package from a YAML or JSON config file.
609
+
610
+ The file may contain any keys; only ``namespace`` and ``package`` are used.
611
+ """
612
+ with open(config_path, "r") as f:
613
+ raw = f.read()
614
+
615
+ if config_path.endswith(".json"):
616
+ import json
617
+
618
+ data = json.loads(raw)
619
+ else:
620
+ import yaml
621
+
622
+ data = yaml.safe_load(raw)
623
+
624
+ if not isinstance(data, dict):
625
+ raise ValueError(f"Config file must contain a mapping, got {type(data).__name__}")
626
+ return {k: v for k, v in data.items() if k in ("namespace", "package")}
627
+
628
+
629
+ def resolve_config(
630
+ *,
631
+ cli_namespace: Optional[str],
632
+ cli_package: Optional[str],
633
+ config_path: Optional[str],
634
+ ) -> Tuple[str, str]:
635
+ """Determine final namespace and package values.
636
+
637
+ Precedence (highest → lowest): CLI flags, config file, built-in defaults.
638
+ """
639
+ file_ns: Optional[str] = None
640
+ file_pkg: Optional[str] = None
641
+
642
+ if config_path is not None:
643
+ cfg = load_config(config_path)
644
+ file_ns = cfg.get("namespace")
645
+ file_pkg = cfg.get("package")
646
+
647
+ namespace = cli_namespace or file_ns or _DEFAULT_NAMESPACE
648
+ package = cli_package or file_pkg or _DEFAULT_PACKAGE
649
+ return namespace, package
650
+
651
+
652
+ # ---------------------------------------------------------------------------
653
+ # CLI (python -m agentforce_byoc.schema_utils.generator ...)
654
+ # ---------------------------------------------------------------------------
655
+
656
+
657
+ def main(argv: Optional[List[str]] = None) -> int:
658
+ parser = argparse.ArgumentParser(
659
+ description="Generate OpenAPI spec from @entry_func functions."
660
+ )
661
+ parser.add_argument("input", help="Path to the Python source file")
662
+ parser.add_argument("-o", "--output", default=None, help="Output YAML file (default: stdout)")
663
+ parser.add_argument(
664
+ "--namespace", default=None, help="Function namespace (overrides config file)"
665
+ )
666
+ parser.add_argument("--package", default=None, help="Function package (overrides config file)")
667
+ parser.add_argument(
668
+ "-c",
669
+ "--config",
670
+ default=None,
671
+ help="Path to a YAML or JSON config file with namespace/package",
672
+ )
673
+
674
+ args = parser.parse_args(argv)
675
+
676
+ namespace, package = resolve_config(
677
+ cli_namespace=args.namespace,
678
+ cli_package=args.package,
679
+ config_path=args.config,
680
+ )
681
+
682
+ yaml_str = generate_schema_yaml(args.input, namespace=namespace, package=package)
683
+
684
+ if args.output:
685
+ with open(args.output, "w") as f:
686
+ f.write(yaml_str)
687
+ else:
688
+ sys.stdout.write(yaml_str)
689
+
690
+ return 0
691
+
692
+
693
+ if __name__ == "__main__":
694
+ raise SystemExit(main())