diff --git a/pyproject.toml b/pyproject.toml index c7cf0aa..8b6745d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.17.0" +version = "0.18.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" @@ -35,6 +35,7 @@ dependencies = [ ] [project.optional-dependencies] +extensibility = ["a2a-sdk>=0.2.0"] starlette = ["starlette>=0.40.0"] langchain = ["langchain-core>=1.2.7"] @@ -49,6 +50,7 @@ packages = ["src/sap_cloud_sdk", "src/buf"] dev = [ "pytest>=8.4.2", "pytest-cov>=7.0.0", + "pytest-asyncio>=1.0.0", "pytest-bdd>=7.2.0", "python-dotenv>=1.0.0", "ty>=0.0.21", @@ -57,8 +59,8 @@ dev = [ "starlette>=0.40.0", "anyio>=3.6.2", "httpx>=0.27.0", + "a2a-sdk>=0.2.0", "langchain-core>=1.2.7", - "pytest-asyncio>=1.0.0", ] [tool.pytest.ini_options] diff --git a/src/sap_cloud_sdk/core/telemetry/__init__.py b/src/sap_cloud_sdk/core/telemetry/__init__.py index 4936fe0..1febadd 100644 --- a/src/sap_cloud_sdk/core/telemetry/__init__.py +++ b/src/sap_cloud_sdk/core/telemetry/__init__.py @@ -24,6 +24,37 @@ execute_tool_span, invoke_agent_span, ) +from sap_cloud_sdk.core.telemetry.extensions import ( + extension_context, + get_extension_context, + ExtensionType, + ATTR_IS_EXTENSION, + ATTR_EXTENSION_TYPE, + ATTR_CAPABILITY_ID, + ATTR_EXTENSION_ID, + ATTR_EXTENSION_NAME, + ATTR_EXTENSION_VERSION, + ATTR_EXTENSION_ITEM_NAME, + ATTR_EXTENSION_URL, + ATTR_SOLUTION_ID, + ATTR_SUMMARY_TOTAL_OPERATION_COUNT, + ATTR_SUMMARY_TOTAL_DURATION_MS, + ATTR_SUMMARY_TOOL_CALL_COUNT, + ATTR_SUMMARY_HOOK_CALL_COUNT, + ATTR_SUMMARY_HAS_INSTRUCTION, + resolve_source_info, + build_extension_span_attributes, + reset_tool_call_metrics, + get_tool_call_metrics, + record_tool_call_duration, + reset_hook_call_metrics, + get_hook_call_metrics, + record_hook_call_duration, + call_extension_tool, + call_extension_hook, + emit_extensions_summary_span, + ExtensionContextLogFilter, +) from sap_cloud_sdk.core.telemetry.middleware import TelemetryMiddleware __all__ = [ @@ -42,6 +73,35 @@ "chat_span", "execute_tool_span", "invoke_agent_span", + "extension_context", + "get_extension_context", + "ExtensionType", + "ATTR_IS_EXTENSION", + "ATTR_EXTENSION_TYPE", + "ATTR_CAPABILITY_ID", + "ATTR_EXTENSION_ID", + "ATTR_EXTENSION_NAME", + "ATTR_EXTENSION_VERSION", + "ATTR_EXTENSION_ITEM_NAME", + "ATTR_EXTENSION_URL", + "ATTR_SOLUTION_ID", + "ATTR_SUMMARY_TOTAL_OPERATION_COUNT", + "ATTR_SUMMARY_TOTAL_DURATION_MS", + "ATTR_SUMMARY_TOOL_CALL_COUNT", + "ATTR_SUMMARY_HOOK_CALL_COUNT", + "ATTR_SUMMARY_HAS_INSTRUCTION", + "resolve_source_info", + "build_extension_span_attributes", + "reset_tool_call_metrics", + "get_tool_call_metrics", + "record_tool_call_duration", + "reset_hook_call_metrics", + "get_hook_call_metrics", + "record_hook_call_duration", + "call_extension_tool", + "call_extension_hook", + "emit_extensions_summary_span", + "ExtensionContextLogFilter", "TelemetryMiddleware", ] diff --git a/src/sap_cloud_sdk/core/telemetry/extensions.py b/src/sap_cloud_sdk/core/telemetry/extensions.py new file mode 100644 index 0000000..495692a --- /dev/null +++ b/src/sap_cloud_sdk/core/telemetry/extensions.py @@ -0,0 +1,657 @@ +""" +Extension telemetry utilities for OpenTelemetry context propagation. + +This module provides utilities for setting extension context in OTel baggage, +enabling propagation of extension metadata to downstream MCP servers via +HTTP headers. + +When an agent calls an extension tool or hook, the extension context +(capability_id, extension_name, extension_type, extension_id, +extension_version, item_name) is set in OTel baggage. The baggage is +automatically propagated via the ``baggage`` HTTP header when using +instrumented HTTP clients (enabled by auto_instrument()). + +MCP servers can extract this context from the incoming request's baggage header +to identify extension calls and add appropriate attributes to their spans. + +Additionally provides: +- Source info resolution from ``ExtensionSourceInfo`` dataclass or plain dicts. +- Instrumented wrappers for extension tool and hook calls. +- ContextVar-based metrics accumulators for tool/hook call duration tracking. +- A summary span emitter for aggregate extension operation metrics. +- A logging filter that injects extension context into log records. +""" + +import logging +import time +from contextlib import contextmanager +from contextvars import ContextVar +from enum import Enum +from typing import Any, Generator + +from opentelemetry import baggage, trace +from opentelemetry.context import attach, detach, get_current + +logger = logging.getLogger(__name__) + +# Extension attribute/baggage keys +# Same keys are used for both OTel baggage (HTTP propagation) and span +# attributes, providing a unified ``sap.extension.*`` prefix. +ATTR_IS_EXTENSION = "sap.extension.isExtension" +ATTR_EXTENSION_TYPE = "sap.extension.extensionType" +ATTR_CAPABILITY_ID = "sap.extension.capabilityId" +ATTR_EXTENSION_ID = "sap.extension.extensionId" +ATTR_EXTENSION_NAME = "sap.extension.extensionName" +ATTR_EXTENSION_VERSION = "sap.extension.extensionVersion" +ATTR_EXTENSION_ITEM_NAME = "sap.extension.extension.item.name" +ATTR_EXTENSION_URL = "sap.extension.extensionUrl" +ATTR_SOLUTION_ID = "sap.extension.solution_id" + + +class ExtensionType(str, Enum): + """Type of extension being executed. + + Used to distinguish between different extension mechanisms: + - TOOL: MCP tool call + - INSTRUCTION: Instruction/prompt injection + - HOOK: Hook call + """ + + TOOL = "tool" + INSTRUCTION = "instruction" + HOOK = "hook" + + +@contextmanager +def extension_context( + capability_id: str, + extension_name: str, + extension_type: ExtensionType, + extension_id: str = "", + extension_version: str = "", + item_name: str = "", + extension_url: str = "", + solution_id: str = "", +) -> Generator[None, None, None]: + """Set extension context in OTel baggage for propagation. + + This context manager sets baggage values that are automatically + propagated via HTTP headers when using instrumented HTTP clients. + MCP servers receiving requests within this context will see the + extension metadata in the ``baggage`` header. + + Baggage keys set: + - ``sap.extension.isExtension``: ``"true"`` + - ``sap.extension.extensionType``: The extension type + - ``sap.extension.capabilityId``: The capability ID + - ``sap.extension.extensionId``: The extension UUID + - ``sap.extension.extensionName``: The extension name + - ``sap.extension.extensionVersion``: The extension version + - ``sap.extension.extension.item.name``: The tool or hook name + - ``sap.extension.extensionUrl``: The extension URL (when provided) + - ``sap.extension.solution_id``: The solution ID (when provided) + + Args: + capability_id: The capability ID for the extension + (e.g. ``"default"``). + extension_name: The name of the extension + (e.g. ``"ServiceNow Extension"``). + extension_type: The type of extension (tool, instruction, or hook). + extension_id: The unique identifier (UUID) of the extension. + extension_version: The version of the extension (e.g. ``"1"``). + item_name: The name of the specific tool or hook being called. + extension_url: The build extension URL (empty string if not available). + solution_id: The build solution ID (empty string if not available). + + Yields: + None. The context is active for the duration of the with block. + + Example: + ```python + from sap_cloud_sdk.core.telemetry import ( + extension_context, + ExtensionType, + ) + + with extension_context( + "default", + "ServiceNow Extension", + ExtensionType.TOOL, + extension_id="a1b2c3d4-...", + extension_version="1", + item_name="create_ticket", + solution_id="my-solution-42", + ): + result = await mcp_client.call_tool("create_ticket", args) + ``` + + Note: + Requires ``auto_instrument()`` to be called at application startup + for automatic baggage header injection into HTTP requests. + """ + ctx = get_current() + ctx = baggage.set_baggage(ATTR_IS_EXTENSION, "true", context=ctx) + ctx = baggage.set_baggage(ATTR_EXTENSION_TYPE, extension_type.value, context=ctx) + ctx = baggage.set_baggage(ATTR_CAPABILITY_ID, capability_id, context=ctx) + ctx = baggage.set_baggage(ATTR_EXTENSION_ID, extension_id, context=ctx) + ctx = baggage.set_baggage(ATTR_EXTENSION_NAME, extension_name, context=ctx) + ctx = baggage.set_baggage(ATTR_EXTENSION_VERSION, extension_version, context=ctx) + ctx = baggage.set_baggage(ATTR_EXTENSION_ITEM_NAME, item_name, context=ctx) + if extension_url: + ctx = baggage.set_baggage(ATTR_EXTENSION_URL, extension_url, context=ctx) + if solution_id: + ctx = baggage.set_baggage(ATTR_SOLUTION_ID, solution_id, context=ctx) + + token = attach(ctx) + try: + yield + finally: + detach(token) + + +def get_extension_context() -> dict[str, Any] | None: + """Extract extension context from the current OTel baggage. + + Use this function in MCP servers or downstream services to detect + if the current request is an extension call and retrieve the + extension metadata. + + Returns: + A dictionary with extension metadata if in an extension context: + + - ``is_extension``: ``True`` + - ``extension_type``: The extension type string + - ``capability_id``: The capability ID + - ``extension_id``: The extension UUID + - ``extension_name``: The extension name + - ``extension_version``: The extension version string + - ``item_name``: The tool or hook name + - ``extension_url``: The extension URL (empty string if not set) + - ``solution_id``: The solution ID (empty string if not set) + + Returns ``None`` if not in an extension context. + + Example: + ```python + from sap_cloud_sdk.core.telemetry import get_extension_context + + ext_ctx = get_extension_context() + if ext_ctx: + logger.info(f"Extension call: {ext_ctx['extension_name']}") + ``` + """ + is_extension = baggage.get_baggage(ATTR_IS_EXTENSION) + if is_extension != "true": + return None + + return { + "is_extension": True, + "extension_type": baggage.get_baggage(ATTR_EXTENSION_TYPE), + "capability_id": baggage.get_baggage(ATTR_CAPABILITY_ID), + "extension_id": baggage.get_baggage(ATTR_EXTENSION_ID), + "extension_name": baggage.get_baggage(ATTR_EXTENSION_NAME), + "extension_version": baggage.get_baggage(ATTR_EXTENSION_VERSION), + "item_name": baggage.get_baggage(ATTR_EXTENSION_ITEM_NAME), + "extension_url": baggage.get_baggage(ATTR_EXTENSION_URL) or "", + "solution_id": baggage.get_baggage(ATTR_SOLUTION_ID) or "", + } + + +# --------------------------------------------------------------------------- +# Summary span attribute constants +# --------------------------------------------------------------------------- + +ATTR_SUMMARY_TOTAL_OPERATION_COUNT = "sap.extension.summary.totalOperationCount" +ATTR_SUMMARY_TOTAL_DURATION_MS = "sap.extension.summary.totalDurationMs" +ATTR_SUMMARY_TOOL_CALL_COUNT = "sap.extension.summary.toolCallCount" +ATTR_SUMMARY_HOOK_CALL_COUNT = "sap.extension.summary.hookCallCount" +ATTR_SUMMARY_HAS_INSTRUCTION = "sap.extension.summary.hasInstruction" + +# --------------------------------------------------------------------------- +# Private state +# --------------------------------------------------------------------------- + +_tracer = trace.get_tracer("sap.cloud_sdk.extension") + +_tool_call_durations: ContextVar[list[float]] = ContextVar("ext_tool_call_durations") +_hook_call_durations: ContextVar[list[float]] = ContextVar("ext_hook_call_durations") + +# Mapping: OTel baggage key -> LogRecord attribute name (for log filter) +_BAGGAGE_LOG_FIELDS = [ + (ATTR_IS_EXTENSION, "ext_is_extension"), + (ATTR_EXTENSION_TYPE, "ext_extension_type"), + (ATTR_CAPABILITY_ID, "ext_capability_id"), + (ATTR_EXTENSION_ID, "ext_extension_id"), + (ATTR_EXTENSION_NAME, "ext_extension_name"), + (ATTR_EXTENSION_VERSION, "ext_extension_version"), + (ATTR_EXTENSION_ITEM_NAME, "ext_item_name"), + (ATTR_EXTENSION_URL, "ext_extension_url"), + (ATTR_SOLUTION_ID, "ext_solution_id"), +] + + +# --------------------------------------------------------------------------- +# Source info resolution +# --------------------------------------------------------------------------- + + +def resolve_source_info( + key: str, + source_mapping: dict[str, Any] | None, + fallback_name: str, +) -> tuple[str, str, str, str, str]: + """Resolve extension name, id, version, url, and solution_id from a source mapping. + + Source mapping values may be ``ExtensionSourceInfo`` dataclass instances + (with attributes ``extension_name``, ``extension_id``, + ``extension_version``, ``extension_url``, ``solution_id``) or plain dicts + with camelCase keys (``extensionName``, ``extensionId``, + ``extensionVersion``, ``extensionUrl``, ``solutionId``). + + Falls back to *fallback_name* for the name and empty strings for other + fields when the key is not found in the mapping. + + Args: + key: Lookup key in the source mapping (e.g. prefixed tool name or + hook ID). + source_mapping: Mapping of keys to source info objects or dicts. + May be ``None``. + fallback_name: Name to use when the key is not found or the resolved + name is empty. + + Returns: + Tuple of ``(extension_name, extension_id, extension_version, extension_url, solution_id)``. + """ + info = (source_mapping or {}).get(key) + if info is None: + return (fallback_name or "unknown", "", "", "", "") + # SDK ExtensionSourceInfo dataclass (duck-typed to avoid circular import) + if hasattr(info, "extension_name"): + return ( + info.extension_name or fallback_name or "unknown", + info.extension_id or "", + str(info.extension_version) if info.extension_version else "", + getattr(info, "extension_url", "") or "", + getattr(info, "solution_id", "") or "", + ) + # Plain dict with camelCase keys (older SDK or manual construction) + if isinstance(info, dict): + return ( + info.get("extensionName") or fallback_name or "unknown", + info.get("extensionId") or "", + str(info.get("extensionVersion", "")) or "", + info.get("extensionUrl") or "", + info.get("solutionId") or "", + ) + return (fallback_name or "unknown", "", "", "", "") + + +# --------------------------------------------------------------------------- +# Span attribute builder +# --------------------------------------------------------------------------- + + +def build_extension_span_attributes( + extension_name: str, + extension_id: str, + extension_version: str, + ext_type: ExtensionType, + capability: str, + item_name: str, + extension_url: str = "", + solution_id: str = "", +) -> dict[str, Any]: + """Build the full set of ``sap.extension.*`` span attributes. + + Args: + extension_name: Human-readable name of the extension. + extension_id: Unique identifier (UUID) of the extension. + extension_version: Version of the extension. + ext_type: The extension type (tool, instruction, or hook). + capability: Extension capability ID (e.g. ``"default"``). + item_name: Name of the specific tool or hook being called. + extension_url: Build extension URL (empty string if not available). + solution_id: Build solution ID (empty string if not available). + + Returns: + Dict with all ``sap.extension.*`` attribute keys. + """ + attrs: dict[str, Any] = { + ATTR_IS_EXTENSION: True, + ATTR_EXTENSION_TYPE: ext_type.value, + ATTR_CAPABILITY_ID: capability, + ATTR_EXTENSION_ID: extension_id, + ATTR_EXTENSION_NAME: extension_name, + ATTR_EXTENSION_VERSION: extension_version, + ATTR_EXTENSION_ITEM_NAME: item_name, + } + if extension_url: + attrs[ATTR_EXTENSION_URL] = extension_url + if solution_id: + attrs[ATTR_SOLUTION_ID] = solution_id + return attrs + + +# --------------------------------------------------------------------------- +# Tool call metrics accumulator +# --------------------------------------------------------------------------- + + +def reset_tool_call_metrics() -> None: + """Initialise a fresh accumulator for tool call durations. + + Call this at the **start** of ``execute()``, before any hooks or + ``agent.run()`` invocations. + """ + _tool_call_durations.set([]) + + +def get_tool_call_metrics() -> tuple[int, float]: + """Return ``(call_count, total_duration_ms)`` for tool calls since reset. + + Returns ``(0, 0.0)`` if ``reset_tool_call_metrics`` was never called. + """ + durations = _tool_call_durations.get([]) + return len(durations), sum(durations) * 1000 + + +def record_tool_call_duration(duration: float) -> None: + """Record the wall-clock duration (seconds) of a single tool call. + + Appends to the ContextVar accumulator. Silently no-ops if + ``reset_tool_call_metrics`` was never called. + + Args: + duration: Duration in **seconds** (e.g. from ``time.monotonic()`` + difference). + """ + durations = _tool_call_durations.get(None) + if durations is not None: + durations.append(duration) + + +# --------------------------------------------------------------------------- +# Hook call metrics accumulator +# --------------------------------------------------------------------------- + + +def reset_hook_call_metrics() -> None: + """Initialise a fresh accumulator for hook call durations. + + Call this at the **start** of ``execute()``, before any hooks or + ``agent.run()`` invocations. + """ + _hook_call_durations.set([]) + + +def get_hook_call_metrics() -> tuple[int, float]: + """Return ``(call_count, total_duration_ms)`` for hook calls since reset. + + Returns ``(0, 0.0)`` if ``reset_hook_call_metrics`` was never called. + """ + durations = _hook_call_durations.get([]) + return len(durations), sum(durations) * 1000 + + +def record_hook_call_duration(duration: float) -> None: + """Record the wall-clock duration (seconds) of a single hook call. + + Appends to the ContextVar accumulator. Silently no-ops if + ``reset_hook_call_metrics`` was never called. + + Args: + duration: Duration in **seconds** (e.g. from ``time.monotonic()`` + difference). + """ + durations = _hook_call_durations.get(None) + if durations is not None: + durations.append(duration) + + +# --------------------------------------------------------------------------- +# Instrumented extension tool call +# --------------------------------------------------------------------------- + + +async def call_extension_tool( + mcp_client: Any, + tool_name: str, + args: dict[str, Any], + extension_name: str, + capability: str = "default", + source_mapping: dict[str, Any] | None = None, + tool_prefix: str = "", +) -> Any: + """Call an MCP tool with telemetry instrumentation. + + Wraps the tool call with ``extension_context`` (sets OTel baggage for + downstream propagation) and creates an explicit tracer span with all + seven ``sap.extension.*`` attributes so the call is visible in + agent-side traces. + + Args: + mcp_client: The MCP client session connected to the tool's server. + Must have an async ``call_tool(name, args)`` method. + tool_name: The raw MCP tool name (before any prefixing). + args: Dictionary of arguments to pass to the tool. + extension_name: Human-readable name of the extension. Used as + fallback when *source_mapping* does not contain the tool. + capability: Extension capability ID (default: ``"default"``). + source_mapping: Optional mapping of prefixed tool names to source + info objects (from ``ext_impl.source.tools``). + tool_prefix: The tool prefix (e.g. ``"sap_mcp_servicenow_v1_"``). + Used to reconstruct the lookup key for *source_mapping*. + + Returns: + The tool's response from the MCP server. + """ + lookup_key = tool_prefix + tool_name if tool_prefix else tool_name + + resolved_name, resolved_id, resolved_version, resolved_url, resolved_solution_id = ( + resolve_source_info(lookup_key, source_mapping, extension_name) + ) + + attrs = build_extension_span_attributes( + resolved_name, + resolved_id, + resolved_version, + ExtensionType.TOOL, + capability, + tool_name, + extension_url=resolved_url, + solution_id=resolved_solution_id, + ) + + t0 = time.monotonic() + try: + with ( + extension_context( + capability_id=capability, + extension_name=resolved_name, + extension_type=ExtensionType.TOOL, + extension_id=resolved_id, + extension_version=resolved_version, + item_name=tool_name, + extension_url=resolved_url, + solution_id=resolved_solution_id, + ), + _tracer.start_as_current_span( + f"extension_tool {tool_name}", + attributes=attrs, + ), + ): + logger.info("Calling extension tool: %s", tool_name) + result = await mcp_client.call_tool(tool_name, args) + logger.info("Extension tool completed: %s", tool_name) + return result + finally: + record_tool_call_duration(time.monotonic() - t0) + + +# --------------------------------------------------------------------------- +# Instrumented extension hook call +# --------------------------------------------------------------------------- + + +async def call_extension_hook( + extensibility_client: Any, + hook: Any, + payload: Any, + extension_name: str, + capability: str = "default", + source_mapping: dict[str, Any] | None = None, + hook_id: str = "", +) -> Any: + """Call an extension hook with telemetry instrumentation. + + Wraps the hook call with ``extension_context`` (sets OTel baggage for + downstream propagation) and creates an explicit tracer span with all + seven ``sap.extension.*`` attributes so the call is visible in + agent-side traces. + + Args: + extensibility_client: The extensibility client. Must have an async + ``call_hook(hook, payload)`` method. + hook: The hook object to invoke. If it has a ``name`` attribute, + that is used as the ``item_name`` in telemetry. + payload: The payload to send to the hook endpoint. + extension_name: Human-readable name of the extension. Used as + fallback when *source_mapping* does not contain the hook. + capability: Extension capability ID (default: ``"default"``). + source_mapping: Optional mapping of hook IDs to source info + objects (from ``ext_impl.source.hooks``). + hook_id: The unique hook ``id`` (UUID), used as lookup key in + *source_mapping*. + + Returns: + The hook's response. + """ + resolved_name, resolved_id, resolved_version, resolved_url, resolved_solution_id = ( + resolve_source_info(hook_id, source_mapping, extension_name) + ) + + item_name = getattr(hook, "name", None) or hook_id + + attrs = build_extension_span_attributes( + resolved_name, + resolved_id, + resolved_version, + ExtensionType.HOOK, + capability, + item_name, + extension_url=resolved_url, + solution_id=resolved_solution_id, + ) + + t0 = time.monotonic() + try: + with ( + extension_context( + capability_id=capability, + extension_name=resolved_name, + extension_type=ExtensionType.HOOK, + extension_id=resolved_id, + extension_version=resolved_version, + item_name=item_name, + extension_url=resolved_url, + solution_id=resolved_solution_id, + ), + _tracer.start_as_current_span( + f"extension_hook {item_name}", + attributes=attrs, + ), + ): + logger.info("Calling extension hook: %s", item_name) + result = await extensibility_client.call_hook(hook, payload) + logger.info("Extension hook completed: %s", item_name) + return result + finally: + record_hook_call_duration(time.monotonic() - t0) + + +# --------------------------------------------------------------------------- +# Aggregate summary span +# --------------------------------------------------------------------------- + + +def emit_extensions_summary_span( + *, + tool_call_count: int, + hook_call_count: int, + has_instruction: bool, + total_duration_ms: float, +) -> None: + """Emit a sibling summary span with aggregate extension metrics. + + Creates a zero-duration ``agent_extensions_summary`` span carrying + aggregate counts and timing for all extension operations performed + during one agent execution. The span is created via ``start_span`` + (not ``start_as_current_span``) and immediately ended, so it appears + as a **sibling** of the individual ``extension_tool`` / + ``extension_hook`` spans — it never becomes a parent or alters the + existing span hierarchy. + + Call this **once** at the end of the agent's ``execute()`` method, + after all extension operations have completed. + + Args: + tool_call_count: Number of extension tool calls made. + hook_call_count: Number of hook calls executed (pre + post). + has_instruction: Whether an extension instruction was injected + into the system prompt. + total_duration_ms: Wall-clock sum (milliseconds) of all extension + operations. + """ + total = tool_call_count + hook_call_count + (1 if has_instruction else 0) + attrs = { + ATTR_SUMMARY_TOTAL_OPERATION_COUNT: total, + ATTR_SUMMARY_TOTAL_DURATION_MS: total_duration_ms, + ATTR_SUMMARY_TOOL_CALL_COUNT: tool_call_count, + ATTR_SUMMARY_HOOK_CALL_COUNT: hook_call_count, + ATTR_SUMMARY_HAS_INSTRUCTION: has_instruction, + } + span = _tracer.start_span("agent_extensions_summary", attributes=attrs) + span.end() + + +# --------------------------------------------------------------------------- +# Log filter +# --------------------------------------------------------------------------- + + +class ExtensionContextLogFilter(logging.Filter): + """Logging filter that injects extension context from OTel baggage. + + When a log statement is emitted inside an ``extension_context()`` block, + the filter reads the seven ``sap.extension.*`` baggage values from the + current OTel context and sets them as extra attributes on the + ``LogRecord``. Combined with a JSON formatter these attributes are + serialised into the JSON log line. + + When the current context has **no** extension baggage, the filter does + **not** add ``ext_*`` attributes, keeping non-extension log lines clean. + + .. warning:: + + The filter **must** be added to the **handler**, not the logger. + Logger-level filters are only checked in ``Logger.handle()``, which + is **not** called when a child logger's record propagates up via + ``callHandlers()``. Handler-level filters are checked in + ``Handler.handle()``, which runs for every record regardless of + origin. + + Example:: + + handler = logging.StreamHandler() + handler.addFilter(ExtensionContextLogFilter()) + logging.getLogger().addHandler(handler) + """ + + def filter(self, record: logging.LogRecord) -> bool: + ctx = get_current() + is_extension = baggage.get_baggage(ATTR_IS_EXTENSION, context=ctx) + if is_extension: + for baggage_key, attr_name in _BAGGAGE_LOG_FIELDS: + value = baggage.get_baggage(baggage_key, context=ctx) + setattr(record, attr_name, value or "") + return True diff --git a/src/sap_cloud_sdk/core/telemetry/module.py b/src/sap_cloud_sdk/core/telemetry/module.py index 8f3495d..bb5bdec 100644 --- a/src/sap_cloud_sdk/core/telemetry/module.py +++ b/src/sap_cloud_sdk/core/telemetry/module.py @@ -11,6 +11,7 @@ class Module(str, Enum): AUDITLOG_NG = "auditlog_ng" AGENT_MEMORY = "agent_memory" DESTINATION = "destination" + EXTENSIBILITY = "extensibility" OBJECTSTORE = "objectstore" DMS = "dms" AGENTGATEWAY = "agentgateway" diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index e42abf7..8619145 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -64,6 +64,12 @@ class Operation(str, Enum): OBJECTSTORE_LIST_OBJECTS = "list_objects" OBJECTSTORE_OBJECT_EXISTS = "object_exists" + # Extensibility Operations + EXTENSIBILITY_GET_EXTENSION_CAPABILITY_IMPLEMENTATION = ( + "get_extension_capability_implementation" + ) + EXTENSIBILITY_CALL_HOOK = "call_hook" + # AI Core Operations AICORE_SET_CONFIG = "set_aicore_config" AICORE_AUTO_INSTRUMENT = "auto_instrument" diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index 80d19d7..19430a3 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -83,7 +83,54 @@ with context_overlay(GenAIOperation.RETRIEVAL, attributes={"index": "knowledge-b documents = retrieve_documents(query) ``` -Available operations: +Thread-safe and async-safe. Automatic Propagation. + +### Propagate extension context + +When calling extension tools (e.g., MCP servers), wrap the call in +`extension_context()` to propagate extension metadata via OTel baggage: + +```python +from sap_cloud_sdk.core.telemetry import ( + extension_context, + ExtensionType, +) + +# When calling an extension tool +with extension_context( + capability_id="default", + extension_name="ServiceNow Extension", +): + result = await mcp_client.call_tool("generate_offer_letter", args) + # HTTP request includes baggage header with extension metadata +``` + +Available extension types: + +```python +ExtensionType.TOOL # MCP tool call (default) +ExtensionType.INSTRUCTION # Instruction/prompt injection +``` + +In downstream services, read the propagated context: + +```python +from sap_cloud_sdk.core.telemetry import get_extension_context + +ext_ctx = get_extension_context() +if ext_ctx: + print(ext_ctx["capability_id"]) # "default" + print(ext_ctx["extension_name"]) # "ServiceNow Extension" + print(ext_ctx["extension_type"]) # "tool" +``` + +The extension baggage span processor (registered automatically by `auto_instrument()`) +stamps `sap.extension.*` attributes on all spans created inside an +`extension_context()` block, including spans from third-party instrumentation. +It uses the official `BaggageSpanProcessor` from `opentelemetry-processor-baggage` +under the hood, filtering to only propagate `sap.extension.*` baggage keys. + +### Available operations ```python GenAIOperation.CHAT diff --git a/src/sap_cloud_sdk/extensibility/__init__.py b/src/sap_cloud_sdk/extensibility/__init__.py new file mode 100644 index 0000000..3454f95 --- /dev/null +++ b/src/sap_cloud_sdk/extensibility/__init__.py @@ -0,0 +1,220 @@ +"""Extensibility module for SAP Cloud SDK for Python. + +Enables agent developers to: + +1. **Retrieve extension capability implementations at runtime** -- the active + extension's tools (delivered via MCP servers) and custom instruction. +2. **Declare extension capabilities for A2A discovery** -- define what parts + of the agent are extensible, for serialization into the agent's A2A card. + +Basic usage:: + + from sap_cloud_sdk.extensibility import create_client + + client = create_client("sap.ai:agent:myAgent:v1") + ext_cap_impl = client.get_extension_capability_implementation(tenant=tenant_id) + + for server in ext_cap_impl.mcp_servers: + print(server.ord_id, server.tool_names) + + if ext_cap_impl.instruction: + print(ext_cap_impl.instruction) + +A2A card serialization:: + + from sap_cloud_sdk.extensibility import ( + ExtensionCapability, + build_extension_capabilities, + ) + + capabilities = [ + ExtensionCapability( + display_name="Onboarding Workflow", + description="Add tools to the onboarding workflow.", + ), + ] + extensions = build_extension_capabilities(capabilities) +""" + +import logging +import os +from typing import Optional + +from sap_cloud_sdk.core.telemetry import Module + +_logger = logging.getLogger(__name__) + + +def _mock_file(name: str) -> str: + """Return the absolute path to a mocks/ file relative to the working directory.""" + return os.path.join(os.getcwd(), "mocks", name) + + +# --------------------------------------------------------------------------- +# Dependency check — a2a-sdk is an optional extra +# --------------------------------------------------------------------------- + +try: + import a2a as _a2a # noqa: F401 +except ImportError as _exc: + raise ImportError( + "The 'a2a-sdk' package is required to use the extensibility module. " + "Install it with: pip install sap-cloud-sdk[extensibility]" + ) from _exc + +from sap_cloud_sdk.extensibility._a2a import ( + EXTENSION_CAPABILITY_SCHEMA_VERSION, + build_extension_capabilities, +) +from sap_cloud_sdk.extensibility._local_transport import ( + CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV, + EXTENSIBILITY_MOCK_FILE, + LocalTransport, +) +from sap_cloud_sdk.extensibility._models import ( + DEFAULT_EXTENSION_CAPABILITY_ID, + DeploymentType, + ExecutionMode, + ExtensionCapability, + ExtensionCapabilityImplementation, + ExtensionSourceInfo, + ExtensionSourceMapping, + Hook, + HookCapability, + HookType, + McpServer, + N8nWorkflowConfig, + OnFailure, + ToolAdditions, + Tools, +) +from sap_cloud_sdk.extensibility._noop_transport import NoOpTransport +from sap_cloud_sdk.extensibility._ord_integration import ( + add_extension_integration_dependencies, +) +from sap_cloud_sdk.extensibility._ums_transport import UmsTransport +from sap_cloud_sdk.extensibility.client import ExtensibilityClient +from sap_cloud_sdk.extensibility.config import ExtensibilityConfig, HookConfig +from sap_cloud_sdk.extensibility.exceptions import ( + ClientCreationError, + ExtensibilityError, + TransportError, +) + + +def create_client( + agent_ord_id: str, + *, + config: Optional[ExtensibilityConfig] = None, + _telemetry_source: Optional[Module] = None, +) -> ExtensibilityClient: + """Create an :class:`ExtensibilityClient` for runtime extension lookups. + + Local mode is activated in two ways (checked in order): + + 1. **Environment variable** ``CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE`` set to a + file path -- the client reads extension data from that file. + 2. **File-presence detection** -- if ``mocks/extensibility.json`` exists at + the repository root, it is used automatically. + + The environment variable takes precedence when both are present. In either + case the JSON file must use the same schema as the backend response + (see :mod:`_local_transport`). + + This function never raises. If the client cannot be constructed + (e.g. missing destination credentials in local development), it logs + the error and returns a client backed by a no-op transport that + always returns empty results. This ensures the agent can always + start and operate with its built-in tools. + + Args: + agent_ord_id: ORD ID of the agent (e.g., + ``"sap.ai:agent:myAgent:v1"``). Required for the backend transport + to identify the agent when querying extensibility data. + config: Optional configuration overrides for destination name and + instance. If omitted, uses default :class:`ExtensibilityConfig`. + _telemetry_source: Internal telemetry source identifier. Not intended for external use. + + Returns: + A client ready for :meth:`ExtensibilityClient.get_extension_capability_implementation`. + """ + try: + # 1. Env var takes precedence (explicit always wins) + local_file = os.environ.get(CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV) + if local_file: + _logger.warning( + "Local mock mode active: using LocalTransport backed by %s. " + "This is intended for local development only and must not be " + "used in production.", + local_file, + ) + transport = LocalTransport(local_file) + return ExtensibilityClient(transport, _telemetry_source=_telemetry_source) + + # 2. File-presence detection at mocks/extensibility.json + mock_path = _mock_file(EXTENSIBILITY_MOCK_FILE) + if os.path.isfile(mock_path): + _logger.warning( + "Local mock mode active: using LocalTransport backed by %s. " + "This is intended for local development only and must not be " + "used in production.", + mock_path, + ) + transport = LocalTransport(mock_path) + return ExtensibilityClient(transport, _telemetry_source=_telemetry_source) + + # 3. Cloud mode via extensibility service transport + effective = config or ExtensibilityConfig() + transport = UmsTransport(agent_ord_id, effective) + return ExtensibilityClient(transport, _telemetry_source=_telemetry_source) + except Exception: + _logger.error( + "Failed to create extensibility client. " + "Returning no-op client. The agent will operate without extensions.", + exc_info=True, + ) + return ExtensibilityClient(NoOpTransport()) + + +__all__ = [ + # Client + "create_client", + "ExtensibilityClient", + # Local mode + "LocalTransport", + "CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV", + "EXTENSIBILITY_MOCK_FILE", + # A2A card helpers + "build_extension_capabilities", + "EXTENSION_CAPABILITY_SCHEMA_VERSION", + # ORD integration + "add_extension_integration_dependencies", + # Models -- A2A card + "ExtensionCapability", + "ToolAdditions", + "Tools", + "HookCapability", + # Models -- runtime + "ExtensionCapabilityImplementation", + "ExtensionSourceInfo", + "ExtensionSourceMapping", + "McpServer", + "Hook", + "N8nWorkflowConfig", + # Constants + "DEFAULT_EXTENSION_CAPABILITY_ID", + # Enums + "HookType", + "DeploymentType", + "ExecutionMode", + "OnFailure", + # Config + "ExtensibilityConfig", + "HookConfig", + # Transports + "UmsTransport", + # Exceptions + "ClientCreationError", + "ExtensibilityError", + "TransportError", +] diff --git a/src/sap_cloud_sdk/extensibility/_a2a.py b/src/sap_cloud_sdk/extensibility/_a2a.py new file mode 100644 index 0000000..85c4b73 --- /dev/null +++ b/src/sap_cloud_sdk/extensibility/_a2a.py @@ -0,0 +1,223 @@ +"""A2A card serialization helpers for the extensibility module. + +Converts ``ExtensionCapability`` dataclasses into ``a2a-sdk``'s +``AgentExtension`` objects for inclusion in an agent's A2A card. +""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Any, Dict, List + +from a2a.types import AgentExtension + +from sap_cloud_sdk.extensibility._models import ExtensionCapability, HookCapability + +logger = logging.getLogger(__name__) + +#: Schema version embedded in the extension capability URN. +#: Incremented when breaking changes are made to ExtensionCapability. +EXTENSION_CAPABILITY_SCHEMA_VERSION = 1 + +#: URN template for extension capability URIs. +_URN_TEMPLATE = "urn:sap:extension-capability:v{version}:{id}" + + +def _to_camel_case(snake_str: str) -> str: + """Convert a snake_case string to camelCase. + + Args: + snake_str: A snake_case string (e.g., ``"display_name"``). + + Returns: + The camelCase equivalent (e.g., ``"displayName"``). + """ + parts = snake_str.split("_") + return parts[0] + "".join(word.capitalize() for word in parts[1:]) + + +def _tools_to_dict(tools: Any) -> Dict[str, Any]: + """Serialize a ``Tools`` dataclass to a dict with camelCase keys. + + Recursively converts all nested dataclass fields (e.g. ``additions``) + to dicts with camelCase keys, mirroring the A2A card wire format. + + Args: + tools: A ``Tools`` instance. + + Returns: + Dict with camelCase keys (e.g., + ``{"additions": {"enabled": True}}``). + """ + raw = dataclasses.asdict(tools) + return _snake_dict_to_camel(raw) + + +def _supported_hooks_to_dict( + supported_hooks: List[HookCapability], +) -> List[Dict[str, Any]]: + """Serialize a list of ``HookCapability`` dataclass to a list of dict with camelCase keys. + + Recursively converts all nested dataclass fields to dicts with camelCase keys, mirroring the A2A card wire format. + + Args: + hooks: A list of ``HookCapability`` instances. + + Returns: + List of dict with camelCase keys (e.g., + ``[{"type": "BEFORE", "id": "onboarding_before", "displayName": "Onboarding Before Hook", "description": "Hook executed before onboarding workflow step."}]``). + """ + result = [] + for hook in supported_hooks: + raw = dataclasses.asdict(hook) + result.append(_snake_dict_to_camel(raw)) + return result + + +def _snake_dict_to_camel(d: Dict[str, Any]) -> Dict[str, Any]: + """Recursively convert a dict's keys from snake_case to camelCase. + + Args: + d: Dict with snake_case keys, possibly nested. + + Returns: + New dict with camelCase keys at all levels. + """ + result: Dict[str, Any] = {} + for k, v in d.items(): + camel_key = _to_camel_case(k) + if isinstance(v, dict): + result[camel_key] = _snake_dict_to_camel(v) + else: + result[camel_key] = v + return result + + +def _validate_extension_capabilities( + extension_capabilities: List[ExtensionCapability], +) -> None: + """Validate extension capabilities and log warnings for issues. + + Checks for: + - Empty extension capability lists + - Duplicate extension capability IDs + - Empty or whitespace-only IDs + + Warnings are logged rather than exceptions raised, following + the extensibility module's convention of graceful degradation. + + Args: + extension_capabilities: List of extension capabilities to validate. + """ + if not extension_capabilities: + logger.warning( + "build_extension_capabilities() called with an empty list. " + "No AgentExtension objects will be produced." + ) + return + + seen_ids: Dict[str, int] = {} + for i, cap in enumerate(extension_capabilities): + if not cap.id or not cap.id.strip(): + logger.warning( + "Extension capability at index %d has an empty or " + "whitespace-only ID. This may cause issues with " + "A2A card serialization.", + i, + ) + + cap_id = cap.id + if cap_id in seen_ids: + logger.warning( + "Duplicate extension capability ID %r found at " + "indices %d and %d. Each extension capability should " + "have a unique ID.", + cap_id, + seen_ids[cap_id], + i, + ) + else: + seen_ids[cap_id] = i + + +def build_extension_capabilities( + extension_capabilities: List[ExtensionCapability], +) -> List[AgentExtension]: + """Convert extension capability definitions to A2A AgentExtension objects. + + Each ``ExtensionCapability`` is mapped to an ``AgentExtension`` with: + + - ``uri``: ``urn:sap:extension-capability:v{version}:{id}`` + - ``description``: From the capability's description field + - ``params``: A dict with camelCase keys containing ``capabilityId``, + ``displayName``, ``instructionSupported``, ``tools`` (serialized + ``Tools`` and ``supportedHooks`` (serialized ``HookCapability``)) + - ``required``: Always ``False`` + + Input (list of ExtensionCapability):: + + [{ + "id": "default", + "display_name": "Default", + "description": "Extension capability to further enhance agent.", + "instruction_supported": True, + "tools": Tools(additions=ToolAdditions(enabled=True)), + "supported_hooks": [HookCapability(type="BEFORE", id="onboarding_before", display_name="Onboarding Before Hook", description="Hook executed before onboarding workflow step.")] + }] + + Output (list of AgentExtension):: + + [{ + "uri": "urn:sap:extension-capability:v1:default", + "description": "Extension capability to further enhance agent.", + "params": { + "capabilityId": "default", + "displayName": "Default", + "instructionSupported": True, + "tools": {"additions": {"enabled": True}}, + "supportedHooks": [{"type": "BEFORE", "id": "onboarding_before", "displayName": "Onboarding Before Hook", "description": "Hook executed before onboarding workflow step."}] + }, + "required": False + }] + + Args: + extension_capabilities: List of extension capabilities declared + by the agent. Each entry must have: id, display_name, + description, instruction_supported, and tools config. + + Returns: + List of ``AgentExtension`` objects for inclusion in + ``AgentCapabilities.extensions``. Each ``AgentExtension`` carries + the URI, description, params (with camelCase keys), and + ``required=False``. + """ + _validate_extension_capabilities(extension_capabilities) + + result: List[AgentExtension] = [] + for cap in extension_capabilities: + uri = _URN_TEMPLATE.format( + version=EXTENSION_CAPABILITY_SCHEMA_VERSION, + id=cap.id, + ) + + tools_dict = _tools_to_dict(cap.tools) + supported_hooks_dict = _supported_hooks_to_dict(cap.supported_hooks) + + params: Dict[str, Any] = { + "capabilityId": cap.id, + "displayName": cap.display_name, + "instructionSupported": cap.instruction_supported, + "tools": tools_dict, + "supportedHooks": supported_hooks_dict, + } + + agent_extension = AgentExtension( + uri=uri, + description=cap.description, + params=params, + required=False, + ) + result.append(agent_extension) + + return result diff --git a/src/sap_cloud_sdk/extensibility/_local_transport.py b/src/sap_cloud_sdk/extensibility/_local_transport.py new file mode 100644 index 0000000..5f44c94 --- /dev/null +++ b/src/sap_cloud_sdk/extensibility/_local_transport.py @@ -0,0 +1,98 @@ +"""Local file-based transport for extensibility development and testing. + +Reads extension capability implementations from a local JSON file +instead of calling the extensibility backend. Activated in two ways: + +1. **File-presence detection** (zero-config): place a file at + ``/mocks/extensibility.json``. +2. **Environment variable** (explicit): set + ``CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE`` to any file path. + +The environment variable takes precedence when both are present. + +The JSON file must use the same schema as the backend response. +See ``local_extensibility_example.json`` in this package for a ready-to-use +template:: + + CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE=src/sap_cloud_sdk/extensibility/local_extensibility_example.json +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path + +from sap_cloud_sdk.extensibility._models import ( + DEFAULT_EXTENSION_CAPABILITY_ID, + ExtensionCapabilityImplementation, +) +from sap_cloud_sdk.extensibility.exceptions import TransportError + +logger = logging.getLogger(__name__) + +#: Environment variable that activates local mode. +#: Set to a file path containing the JSON response. +CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV = "CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE" + +#: Default mock file name used for file-presence detection under ``mocks/``. +EXTENSIBILITY_MOCK_FILE = "extensibility.json" + + +class LocalTransport: + """File-based transport that reads extension data from a local JSON file. + + Intended for local development and testing. The JSON file uses the same + schema as the extensibility backend response, so a captured real + response can be used directly. + + Args: + file_path: Path to the JSON file containing the extension data. + """ + + def __init__(self, file_path: str) -> None: + self._file_path = Path(file_path) + + def get_extension_capability_implementation( + self, + capability_id: str = DEFAULT_EXTENSION_CAPABILITY_ID, + skip_cache: bool = False, + tenant: str = "", + ) -> ExtensionCapabilityImplementation: + """Read extension capability implementation from the local JSON file. + + Args: + capability_id: Extension capability ID. Ignored for file-based + lookup (the file contains a single response), but accepted + for interface compatibility. + skip_cache: Accepted for interface compatibility but ignored + by the local transport (no caching layer). + tenant: Accepted for interface compatibility but ignored + by the local transport. + + Returns: + Parsed ``ExtensionCapabilityImplementation`` from the JSON file. + + Raises: + TransportError: If the file cannot be read or parsed. + """ + try: + raw = self._file_path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise TransportError( + f"Local extensibility file not found: {self._file_path}" + ) from exc + except OSError as exc: + raise TransportError( + f"Failed to read local extensibility file '{self._file_path}': {exc}" + ) from exc + + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise TransportError( + f"Failed to parse local extensibility file '{self._file_path}' " + f"as JSON: {exc}" + ) from exc + + return ExtensionCapabilityImplementation.from_dict(data) diff --git a/src/sap_cloud_sdk/extensibility/_models.py b/src/sap_cloud_sdk/extensibility/_models.py new file mode 100644 index 0000000..fbdebee --- /dev/null +++ b/src/sap_cloud_sdk/extensibility/_models.py @@ -0,0 +1,803 @@ +"""Data models for the extensibility module. + +Defines the dataclasses used to represent extension capabilities (for A2A card +serialization) and extension capability implementations (resolved at runtime). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from http import HTTPMethod +from typing import Any, Dict, List, Optional + +#: Default extension capability ID used in v1 (single capability per agent). +DEFAULT_EXTENSION_CAPABILITY_ID = "default" + +#: Default hook execution timeout in seconds, used when the backend omits the field. +DEFAULT_HOOK_TIMEOUT: int = 30 + + +class HookType(StrEnum): + """Hook type for extension hooks. + + Defines the possible types of hooks that can be registered. + + Attributes: + BEFORE: Hook executed before an operation. + AFTER: Hook executed after an operation. + """ + + BEFORE = "BEFORE" + AFTER = "AFTER" + + +class DeploymentType(StrEnum): + """Deployment type for extension hooks. + + Defines the possible deployment types for hooks. + + Attributes: + N8N: Hook deployed on N8N platform. + SERVERLESS: Hook deployed as Serverless function. + UNKNOWN: Unrecognized deployment type returned by the backend. + """ + + N8N = "N8N" + SERVERLESS = "SERVERLESS" + UNKNOWN = "UNKNOWN" + + +class ExecutionMode(StrEnum): + """Execution mode for extension hooks. + + Defines how hooks are executed. + + Attributes: + SYNC: Synchronous execution - waits for hook to complete. + ASYNC: Asynchronous execution - does not wait for hook to complete. + """ + + SYNC = "SYNC" + ASYNC = "ASYNC" + + +class OnFailure(StrEnum): + """Behavior when a hook execution fails. + + Defines the possible behaviors when a hook fails. + + Attributes: + CONTINUE: Continue execution despite hook failure. + BLOCK: Block execution when hook fails. + """ + + CONTINUE = "CONTINUE" + BLOCK = "BLOCK" + + +def _parse_hook_type(value: Any) -> HookType | None: + """Parse a hook type value into a HookType enum. + + Args: + value: Hook type value (string, HookType, or None). + + Returns: + HookType enum if value matches a known type, otherwise None. + """ + if value is None: + return None + if isinstance(value, HookType): + return value + if isinstance(value, str): + for m in HookType: + if m.value == value: + return m + return None + + +def _parse_deployment_type(value: Any) -> DeploymentType | None: + """Parse a deployment type value into a DeploymentType enum. + + Args: + value: Deployment type value (string, DeploymentType, or None). + + Returns: + DeploymentType enum if value matches a known type, otherwise None. + """ + if value is None: + return None + if isinstance(value, DeploymentType): + return value + if isinstance(value, str): + for m in DeploymentType: + if m.value == value: + return m + return None + + +def _parse_execution_mode(value: Any) -> ExecutionMode | None: + """Parse an execution mode value into an ExecutionMode enum. + + Args: + value: Execution mode value (string, ExecutionMode, or None). + + Returns: + ExecutionMode enum if value matches a known type, otherwise None. + """ + if value is None: + return None + if isinstance(value, ExecutionMode): + return value + if isinstance(value, str): + for m in ExecutionMode: + if m.value == value: + return m + return None + + +def _parse_on_failure(value: Any) -> OnFailure | None: + """Parse an on_failure value into an OnFailure enum. + + Args: + value: On failure value (string, OnFailure, or None). + + Returns: + OnFailure enum if value matches a known type, otherwise None. + """ + if value is None: + return None + if isinstance(value, OnFailure): + return value + if isinstance(value, str): + for m in OnFailure: + if m.value == value: + return m + return None + + +def _parse_http_method(value: Any) -> HTTPMethod | None: + """Parse an HTTP method value into an HTTPMethod enum. + + Args: + value: HTTP method value (string, HTTPMethod, or None). + + Returns: + HTTPMethod enum if value matches a known method, otherwise None. + """ + if value is None: + return None + if isinstance(value, HTTPMethod): + return value + if isinstance(value, str): + # Convert to uppercase for case-insensitive matching + upper_value = value.upper() + for m in HTTPMethod: + if m.value == upper_value: + return m + return None + + +@dataclass +class ToolAdditions: + """Configuration for tool additions at an extension capability. + + Controls whether an extension capability accepts additional tools. + + Attributes: + enabled: Whether tool additions are enabled. + """ + + enabled: bool = True + + +@dataclass +class Tools: + """Tool-related configuration for an extension capability. + + Groups all tool configuration options. The structure mirrors the + wire format in the A2A card (``"tools": {"additions": {...}}``). + + Attributes: + additions: Configuration for tool additions. + """ + + additions: ToolAdditions = field(default_factory=ToolAdditions) + + +@dataclass +class HookCapability: + """Configuration for hooks at an extension capability. + + Controls whether hooks are supported and which hooks are supported. + + Attributes: + id: Hook ID. + type: Type of the hook (BEFORE, AFTER). + display_name: Human-readable name of the hook. + description: Description of the hook. + """ + + id: str + type: HookType + display_name: str + description: str + + def __post_init__(self) -> None: + """Validate that type is a valid HookType enum value.""" + if not isinstance(self.type, HookType): + raise ValueError( + f"type must be a HookType enum value, got: {type(self.type).__name__} = {self.type!r}" + ) + + +@dataclass +class N8nWorkflowConfig: + """n8n workflow configuration embedded in a hook. + + Attributes: + workflow_id: n8n workflow ID. + method: HTTP method for the n8n webhook call. + """ + + workflow_id: str + method: HTTPMethod + + @classmethod + def from_dict(cls, obj: Dict[str, Any]) -> N8nWorkflowConfig: + """Parse an N8nWorkflowConfig entry from the backend JSON response. + + Expected JSON shape:: + + { + "workflowId": "unique_n8n_workflow_id", + "method": "POST" + } + + Args: + obj: Raw dict from the ``n8nWorkflowConfig`` field. + + Returns: + Parsed ``N8nWorkflowConfig`` instance. + """ + method = _parse_http_method(obj.get("method", "POST")) or HTTPMethod.POST + return cls( + workflow_id=obj.get("workflowId", ""), + method=method, + ) + + +@dataclass +class Hook: + """Hook-related implementation for an extension capability. + + Groups all hook configuration options. + + Attributes: + id: Database-generated UUID for the hook (from cuid). + hook_id: Developer-defined hook key (e.g., "before_tool_execution"); + not guaranteed to be unique. + n8n_workflow_config: n8n workflow configuration (workflow ID and HTTP method). + name: Human-readable name of the hook. + type: Type of the hook (e.g., "BEFORE", "AFTER") + deployment_type: Deployment type of the hook (e.g., "N8N", "SERVERLESS") + timeout: Timeout in seconds for hook execution. + execution_mode: Execution mode for the hook (e.g., "SYNC", "ASYNC"). + on_failure: Behavior if the hook execution fails (e.g., "CONTINUE", "BLOCK"). + order: Execution order of the hook relative to other hooks. + can_short_circuit: Whether this hook can short-circuit the main execution flow. + + """ + + id: str + hook_id: str + n8n_workflow_config: N8nWorkflowConfig + name: str + type: HookType + deployment_type: DeploymentType + timeout: int + execution_mode: ExecutionMode + on_failure: OnFailure + order: int + can_short_circuit: bool + + def __post_init__(self) -> None: + """Validate that enum fields have valid values.""" + if not isinstance(self.type, HookType): + raise ValueError( + f"type must be a HookType enum value, got: {type(self.type).__name__} = {self.type!r}" + ) + if not isinstance(self.deployment_type, DeploymentType): + raise ValueError( + f"deployment_type must be a DeploymentType enum value, got: {type(self.deployment_type).__name__} = {self.deployment_type!r}" + ) + if not isinstance(self.execution_mode, ExecutionMode): + raise ValueError( + f"execution_mode must be an ExecutionMode enum value, got: {type(self.execution_mode).__name__} = {self.execution_mode!r}" + ) + if not isinstance(self.on_failure, OnFailure): + raise ValueError( + f"on_failure must be an OnFailure enum value, got: {type(self.on_failure).__name__} = {self.on_failure!r}" + ) + + @classmethod + def from_dict(cls, obj: Dict[str, Any]) -> Hook: + """Parse an Hook entry from the extensibility service JSON response. + + Expected JSON shape:: + + { + "id": "UUID", + "hookId": "before_tool_execution", + "name": "Before Tool Execution Hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "timeout": 30, + "executionMode": "SYNC", + "onFailure": "CONTINUE", + "order": 1, + "canShortCircuit": true, + "n8nWorkflowConfig": { + "workflowId": "unique_n8n_workflow_id", + "method": "POST" + } + } + + Args: + obj: Raw dict from the extensibility service ``hooks[]`` array. + + Returns: + Parsed ``Hook`` instance. + + Raises: + ValueError: If required enum fields have invalid values. + """ + hook_type = _parse_hook_type(obj.get("hookType", "")) + deployment_type = _parse_deployment_type(obj.get("deploymentType", "")) + execution_mode = _parse_execution_mode(obj.get("executionMode", "SYNC")) + on_failure = _parse_on_failure(obj.get("onFailure", "CONTINUE")) + n8n_workflow_config = N8nWorkflowConfig.from_dict( + obj.get("n8nWorkflowConfig") or {} + ) + + # Validate required enum fields + if hook_type is None: + hook_type_value = obj.get("hookType", "") + raise ValueError( + f"Invalid or missing hookType: {hook_type_value!r}. " + f"Must be one of: {', '.join(m.value for m in HookType)}" + ) + if deployment_type is None: + deployment_type_value = obj.get("deploymentType", "") + raise ValueError( + f"Invalid or missing deploymentType: {deployment_type_value!r}. " + f"Must be one of: {', '.join(m.value for m in DeploymentType)}" + ) + + # Use default enum values if parsing failed + if execution_mode is None: + execution_mode = ExecutionMode.SYNC + if on_failure is None: + on_failure = OnFailure.CONTINUE + + return cls( + id=obj.get("id", ""), + hook_id=obj.get("hookId", ""), + n8n_workflow_config=n8n_workflow_config, + name=obj.get("name", ""), + type=hook_type, + deployment_type=deployment_type, + timeout=obj.get("timeout", DEFAULT_HOOK_TIMEOUT), + execution_mode=execution_mode, + on_failure=on_failure, + order=obj.get("order", 0), + can_short_circuit=obj.get("canShortCircuit", False), + ) + + +@dataclass +class ExtensionSourceInfo: + """Attribution info for a single tool or hook contributed by an extension. + + Contains the extension's identity metadata so that telemetry spans and + baggage can carry per-item attribution (which extension contributed this + specific tool or hook). + + Attributes: + extension_name: Human-readable name of the extension + (e.g., ``"ap-invoice-extension"``). + extension_version: Version number of the extension. + extension_id: Unique identifier (UUID) of the extension. + extension_url: Build extension URL, or empty string if not provided. + solution_id: Build solution ID extracted from extension_url, or empty + string if not available. + """ + + extension_name: str + extension_version: str + extension_id: str + extension_url: str = "" + solution_id: str = "" + + @classmethod + def from_dict(cls, obj: Dict[str, Any]) -> ExtensionSourceInfo: + """Parse an extension source info entry from the backend JSON response. + + Expected JSON shape:: + + { + "extensionName": "ap-invoice-extension", + "extensionVersion": "1", + "extensionId": "a1b2c3d4-...", + "extensionUrl": "https://...", + "solutionId": "f9cbd5c1-..." + } + + Args: + obj: Raw dict from a ``source.tools`` or ``source.hooks`` value. + + Returns: + Parsed ``ExtensionSourceInfo`` instance. + """ + return cls( + extension_name=obj.get("extensionName", ""), + extension_version=obj.get("extensionVersion", ""), + extension_id=obj.get("extensionId", ""), + extension_url=obj.get("extensionUrl") or "", + solution_id=obj.get("solutionId") or "", + ) + + @classmethod + def from_value(cls, value: Any) -> ExtensionSourceInfo: + """Parse either a string (old format) or dict (new format). + + Provides backward compatibility with the old backend response format + where ``source.tools`` and ``source.hooks`` values were plain extension + name strings. + + Args: + value: Either a plain string (extension name) or a dict with + ``extensionName``, ``extensionVersion``, and ``extensionId``. + + Returns: + Parsed ``ExtensionSourceInfo`` instance. + """ + if isinstance(value, str): + return cls( + extension_name=value, + extension_version="", + extension_id="", + extension_url="", + solution_id="", + ) + if isinstance(value, dict): + return cls.from_dict(value) + return cls( + extension_name="", + extension_version="", + extension_id="", + extension_url="", + solution_id="", + ) + + +@dataclass +class ExtensionSourceMapping: + """Source attribution mapping for tools and hooks. + + Maps individual tools and hooks back to the extension that contributed them. + Returned by the extensibility backend when multiple extensions are merged + into a single capability implementation response. + + Tool keys are prefixed tool names + (e.g., ``"sap_mcp_servicenow_v1_create_ticket"``). + Hook keys are hook IDs (UUIDs) (e.g., + ``"3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e"``). + Values are :class:`ExtensionSourceInfo` objects containing the extension's + name, version, and unique identifier. + + Attributes: + tools: Mapping of prefixed tool name to extension source info. + hooks: Mapping of hook ID to extension source info. + """ + + tools: Dict[str, ExtensionSourceInfo] = field(default_factory=dict) + hooks: Dict[str, ExtensionSourceInfo] = field(default_factory=dict) + + @classmethod + def from_dict(cls, obj: Dict[str, Any]) -> ExtensionSourceMapping: + """Parse a source mapping from the backend JSON response. + + Expected JSON shape (new format):: + + { + "tools": { + "sap_mcp_taxvalidator_validate_validate_tax": { + "extensionName": "ap-invoice-extension", + "extensionVersion": "1", + "extensionId": "a1b2c3d4-..." + } + }, + "hooks": { + "3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e": { + "extensionName": "ap-invoice-extension", + "extensionVersion": "1", + "extensionId": "a1b2c3d4-..." + } + } + } + + Also accepts the old format where values are plain extension name + strings for backward compatibility. + + Args: + obj: Raw dict from the backend ``source`` field. + + Returns: + Parsed ``ExtensionSourceMapping`` instance. + """ + raw_tools = obj.get("tools", {}) + raw_hooks = obj.get("hooks", {}) + return cls( + tools={k: ExtensionSourceInfo.from_value(v) for k, v in raw_tools.items()}, + hooks={k: ExtensionSourceInfo.from_value(v) for k, v in raw_hooks.items()}, + ) + + +@dataclass +class ExtensionCapability: + """Declaration of an agent extension capability for A2A card serialization. + + Used by the agent developer to describe what parts of the agent + can be extended. Passed to ``build_extension_capabilities()`` to populate + the agent's A2A card. This is metadata -- it does not carry runtime data. + + Attributes: + display_name: Human-readable name of the extension capability. + description: Description of the extension capability. + id: Internal identifier of the extension capability. Defaults to ``"default"`` + for v1 single-capability usage. + tools: Tool-related configuration (additions, and future sub-options). + instruction_supported: Whether the extension capability supports custom instructions. + supported_hooks: List of supported hooks for this extension capability. + """ + + display_name: str + description: str + id: str = DEFAULT_EXTENSION_CAPABILITY_ID + tools: Tools = field(default_factory=Tools) + instruction_supported: bool = True + supported_hooks: List[HookCapability] = field(default_factory=list) + + +@dataclass +class McpServer: + """An MCP server contributed by an extension to a capability implementation. + + Groups one or more tools hosted on the same MCP server. + + Attributes: + ord_id: MCP server ORD ID (e.g., ``"sap.mcp:apiResource:serviceNow:v1"``). + global_tenant_id: Global tenant ID of the MCP server. + tool_names: Approved tool names on this server. ``None`` means all + tools on this server are approved for use (no filtering needed). + """ + + ord_id: str + global_tenant_id: str + tool_names: Optional[List[str]] = None + + @classmethod + def from_dict(cls, obj: Dict[str, Any]) -> McpServer: + """Parse an MCP server entry from the backend JSON response. + + Expected JSON shape:: + + { + "ordId": "sap.mcp:apiResource:serviceNow:v1", + "globalTenantId": "tenant-abc-123", + "toolNames": ["create_ticket"] + } + + Args: + obj: Raw dict from the backend ``mcpServers[]`` array. + + Returns: + Parsed ``McpServer`` instance. + """ + return cls( + ord_id=obj.get("ordId", ""), + global_tenant_id=obj.get("globalTenantId", ""), + tool_names=obj.get("toolNames"), + ) + + +@dataclass +class ExtensionCapabilityImplementation: + """A resolved extension capability implementation at runtime. + + Returned by :meth:`ExtensibilityClient.get_extension_capability_implementation`. + Contains the contributing extensions' MCP servers, instruction, and hooks + for a given extension capability. + + When multiple extensions are merged into a single response, the ``source`` + mapping tracks which extension contributed each tool and hook. Use + :meth:`get_extension_for_tool` and :meth:`get_extension_for_hook` for + per-item attribution (e.g., telemetry span attributes). + + Attributes: + capability_id: Extension capability ID (e.g., ``"default"``). + extension_names: Names of all extensions that contributed to this + response. Empty when no extensions are active. + mcp_servers: MCP servers contributed by the active extension(s). + instruction: Custom instruction for this extension capability. + hooks: List of hooks attached for this extension capability. + source: Per-tool and per-hook attribution mapping. ``None`` when the + backend does not provide source information. + """ + + capability_id: str + extension_names: List[str] = field(default_factory=list) + mcp_servers: List[McpServer] = field(default_factory=list) + instruction: Optional[str] = None + hooks: List[Hook] = field(default_factory=list) + source: Optional[ExtensionSourceMapping] = None + + @classmethod + def from_dict(cls, obj: Dict[str, Any]) -> ExtensionCapabilityImplementation: + """Parse an extension capability implementation from the backend JSON response. + + Expected JSON shape:: + + { + "capabilityId": "default", + "extensionNames": ["ServiceNow Extension"], + "instruction": "Only use ...", + "mcpServers": [ + { + "ordId": "sap.mcp:apiResource:serviceNow:v1", + "toolNames": ["create_ticket"] + } + ], + "hooks": [ + { + "id": "UUID", + "hookId": "before_tool_execution", + "name": "Before Tool Execution Hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "timeout": 30, + "executionMode": "SYNC", + "onFailure": "CONTINUE", + "order": 1, + "canShortCircuit": true, + "n8nWorkflowConfig": { + "workflowId": "unique_n8n_workflow_id", + "method": "POST" + } + } + ], + "source": { + "tools": { + "sap_mcp_servicenow_v1_create_ticket": { + "extensionName": "servicenow-ext", + "extensionVersion": "1", + "extensionId": "abc-123" + } + }, + "hooks": { + "3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e": { + "extensionName": "workflow-ext", + "extensionVersion": "1", + "extensionId": "def-456" + } + } + } + } + + A plain string or a nested ``{"text": string}`` instruction value are + both accepted. + + Fields not present in the backend response default to ``None`` or + empty lists. Unknown fields (e.g. ``agentOrdId``) are ignored. + + Args: + obj: Raw dict from the backend response. + + Returns: + Parsed ``ExtensionCapabilityImplementation`` instance. + """ + mcp_servers_raw = obj.get("mcpServers", []) + mcp_servers = [McpServer.from_dict(s) for s in mcp_servers_raw] + + hooks = obj.get("hooks", []) + hooks_parsed = [Hook.from_dict(h) for h in hooks] + + # The schema defines instruction as {text: string}. Extract the text + # value, but also accept a plain string for local transport compat. + raw_instruction = obj.get("instruction") + if isinstance(raw_instruction, dict): + instruction = raw_instruction.get("text") + else: + instruction = raw_instruction + + source_raw = obj.get("source") + source = ExtensionSourceMapping.from_dict(source_raw) if source_raw else None + + raw_names = obj.get("extensionNames", []) + extension_names = [n for n in raw_names if isinstance(n, str)] + + return cls( + capability_id=obj.get("capabilityId", DEFAULT_EXTENSION_CAPABILITY_ID), + extension_names=extension_names, + mcp_servers=mcp_servers, + instruction=instruction, + hooks=hooks_parsed, + source=source, + ) + + def get_extension_for_tool(self, tool_name: str) -> Optional[str]: + """Look up the extension name that contributed a specific tool. + + Args: + tool_name: The prefixed tool name (e.g., + ``"sap_mcp_servicenow_v1_create_ticket"``). + + Returns: + Extension name, or ``None`` if source mapping is not available + or the tool is not found. + """ + if self.source and tool_name in self.source.tools: + return self.source.tools[tool_name].extension_name + return None + + def get_extension_for_hook(self, hook_id: str) -> Optional[str]: + """Look up the extension name that contributed a specific hook. + + Args: + hook_id: The hook ID (UUID), e.g. + ``"3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e"``. + + Returns: + Extension name, or ``None`` if source mapping is not available + or the hook is not found. + """ + if self.source and hook_id in self.source.hooks: + return self.source.hooks[hook_id].extension_name + return None + + def get_source_info_for_tool(self, tool_name: str) -> Optional[ExtensionSourceInfo]: + """Look up the full source info for a specific tool. + + Returns the :class:`ExtensionSourceInfo` containing extension name, + version, and ID for the extension that contributed this tool. Returns + ``None`` when source mapping is not available or the tool is not found. + + Args: + tool_name: The prefixed tool name (e.g., + ``"sap_mcp_servicenow_v1_create_ticket"``). + + Returns: + :class:`ExtensionSourceInfo` for the tool, or ``None``. + """ + if self.source and tool_name in self.source.tools: + return self.source.tools[tool_name] + return None + + def get_source_info_for_hook(self, hook_id: str) -> Optional[ExtensionSourceInfo]: + """Look up the full source info for a specific hook. + + Returns the :class:`ExtensionSourceInfo` containing extension name, + version, and ID for the extension that contributed this hook. Returns + ``None`` when source mapping is not available or the hook is not found. + + Args: + hook_id: The hook ID (UUID), e.g. + ``"3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e"``. + + Returns: + :class:`ExtensionSourceInfo` for the hook, or ``None``. + """ + if self.source and hook_id in self.source.hooks: + return self.source.hooks[hook_id] + return None diff --git a/src/sap_cloud_sdk/extensibility/_noop_transport.py b/src/sap_cloud_sdk/extensibility/_noop_transport.py new file mode 100644 index 0000000..f51a9d7 --- /dev/null +++ b/src/sap_cloud_sdk/extensibility/_noop_transport.py @@ -0,0 +1,25 @@ +"""No-op transport for graceful degradation. + +Used by :func:`create_client` when the live transport cannot be +constructed (e.g. missing destination credentials in local development). +Ensures the agent can always start and operate with its built-in tools. +""" + +from __future__ import annotations + +from sap_cloud_sdk.extensibility._models import ( + DEFAULT_EXTENSION_CAPABILITY_ID, + ExtensionCapabilityImplementation, +) + + +class NoOpTransport: + """Fallback transport that always returns empty results.""" + + def get_extension_capability_implementation( + self, + capability_id: str = DEFAULT_EXTENSION_CAPABILITY_ID, + skip_cache: bool = False, + tenant: str = "", + ) -> ExtensionCapabilityImplementation: + return ExtensionCapabilityImplementation(capability_id=capability_id) diff --git a/src/sap_cloud_sdk/extensibility/_ord_integration.py b/src/sap_cloud_sdk/extensibility/_ord_integration.py new file mode 100644 index 0000000..2074b6b --- /dev/null +++ b/src/sap_cloud_sdk/extensibility/_ord_integration.py @@ -0,0 +1,229 @@ +"""ORD integration helpers for extensibility. + +Provides utilities to inject extension capability MCP servers into the +ORD (Open Resource Discovery) document at runtime. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Optional + +from sap_cloud_sdk.extensibility.client import ExtensibilityClient +from sap_cloud_sdk.extensibility._models import ExtensionCapabilityImplementation + +logger = logging.getLogger(__name__) + + +def _derive_mcp_name_from_ord_id(ord_id: str) -> str: + """Derive a readable MCP server name from its ordId. + + ordId format: {namespace}:apiResource:{resource-name}:v1 + + Returns the namespace short name (first segment before any dot) in title case. + Example: "sap.s4:apiResource:s4bpintelmcp:v1" -> "S4" + + Args: + ord_id: The MCP server ordId. + + Returns: + A readable name derived from the ordId. + """ + namespace = ord_id.split(":")[0] if ":" in ord_id else ord_id + namespace_short = namespace.split(".")[-1] if namespace else "unknown" + return namespace_short.replace("_", " ").replace("-", " ").title() + + +def _map_capability_to_integration_dependencies( + capability_impl: ExtensionCapabilityImplementation, + agent: Optional[dict] = None, + base_integration_deps: Optional[list[dict]] = None, +) -> list[dict]: + """Map a capability implementation to ORD IntegrationDependency structure. + + Args: + capability_impl: ExtensionCapabilityImplementation from the extensibility client. + agent: The agent dict from the ORD document to derive namespace and partOfPackage. + base_integration_deps: List of existing document-level integration dependencies + to check against for duplicate MCP servers. + + Returns: + List of IntegrationDependency dicts ready for ORD injection. + """ + mcp_servers = capability_impl.mcp_servers if capability_impl else [] + if not mcp_servers: + return [] + + base_api_resource_ord_ids: set = set() + if base_integration_deps: + for base_dep in base_integration_deps: + for aspect in base_dep.get("aspects", []): + for api_resource in aspect.get("apiResources", []): + base_api_resource_ord_ids.add(api_resource["ordId"]) + + current_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + namespace = "" + part_of_package = None + if agent: + agent_ord_id = agent.get("ordId", "") + namespace = agent_ord_id.split(":")[0] if agent_ord_id else "" + part_of_package = agent.get("partOfPackage") + + aspects = [] + for mcp_server in mcp_servers: + if mcp_server.ord_id in base_api_resource_ord_ids: + logger.debug( + "Skipping MCP %s - already in base integration dependencies", + mcp_server.ord_id, + ) + continue + + mcp_name = _derive_mcp_name_from_ord_id(mcp_server.ord_id) + aspect = { + "title": f"{mcp_name} Extension MCP", + "mandatory": False, + "apiResources": [ + { + "ordId": mcp_server.ord_id, + } + ], + } + aspects.append(aspect) + + if not aspects: + logger.info( + "All extended MCP servers already present in base integration dependencies" + ) + return [] + + integration_dependency = { + "ordId": f"{namespace}:integrationDependency:extension-mcp:v1", + "title": "Extension MCP Servers", + "version": "1.0.0", + "releaseStatus": "active", + "visibility": "public", + "mandatory": False, + "partOfPackage": part_of_package, + "lastUpdate": current_time, + "aspects": aspects, + } + + return [integration_dependency] + + +def fetch_extension_integration_dependencies( + ext_client: ExtensibilityClient, + capability_id: str = "default", + agent: Optional[dict] = None, + base_integration_deps: Optional[list[dict]] = None, + tenant: Optional[str] = None, +) -> list[dict]: + """Fetch extension capability implementation and map to IntegrationDependencies. + + This function: + 1. Calls ext_client.get_extension_capability_implementation() + 2. Maps the response to ORD IntegrationDependency structure + 3. Returns list of IntegrationDependencies to inject into ORD document + + Args: + ext_client: The extensibility client. + capability_id: The capability ID to fetch (default: "default") + agent: The agent dict from the ORD document to derive namespace and partOfPackage + base_integration_deps: List of existing document-level integration dependencies + to check against for duplicate MCP servers + tenant: Tenant ID for the extensibility service request. + + Returns: + List of IntegrationDependency dicts ready for ORD injection. + """ + try: + capability_impl = ext_client.get_extension_capability_implementation( + capability_id=capability_id, + skip_cache=True, + tenant=tenant or "", + ) + + if not capability_impl: + logger.info( + "No extension capability implementation found for capability_id=%s", + capability_id, + ) + return [] + + return _map_capability_to_integration_dependencies( + capability_impl, agent, base_integration_deps + ) + + except Exception as e: + logger.error("Failed to fetch extension capabilities: %s", e) + raise + + +def add_extension_integration_dependencies( + document: dict, + local_tenant_id: Optional[str] = None, + ext_client: Optional[ExtensibilityClient] = None, +) -> None: + """Add extension integration dependencies to the ORD document. + + This method: + 1. Gets the agent from the document + 2. Fetches extension capability implementation via ext_client + 3. Injects full IntegrationDependency objects at document level + 4. Injects ordId references at agent level + + Note: + This method should only be used for the system-instance ORD document. + It is not intended for use with other ORD documents. + + Args: + document: The ORD document dict (modified in-place) + local_tenant_id: Optional tenant ID for fetching tenant-specific extensions + ext_client: Optional extensibility client. If not provided, no extension + dependencies will be added. + """ + if ext_client is None: + logger.debug( + "No extensibility client provided, skipping extension integration dependencies" + ) + return + + try: + agent = document.get("agents", [{}])[0] if document.get("agents") else None + + base_integration_deps = document.get("integrationDependencies", []) + + ext_integration_deps = fetch_extension_integration_dependencies( + ext_client=ext_client, + agent=agent, + base_integration_deps=base_integration_deps, + tenant=local_tenant_id, + ) + + if not ext_integration_deps: + return + + if "integrationDependencies" not in document: + document["integrationDependencies"] = [] + document["integrationDependencies"].extend(ext_integration_deps) + + if agent: + if "integrationDependencies" not in agent: + agent["integrationDependencies"] = [] + for ext_dep in ext_integration_deps: + ext_ord_id = ext_dep["ordId"] + if ext_ord_id not in agent["integrationDependencies"]: + agent["integrationDependencies"].append(ext_ord_id) + + logger.info( + "Added %d extension integration dependencies to instance ORD", + len(ext_integration_deps), + ) + + except Exception as e: + logger.warning( + "Failed to fetch extension capabilities, continuing without them: %s", + e, + ) diff --git a/src/sap_cloud_sdk/extensibility/_ums_transport.py b/src/sap_cloud_sdk/extensibility/_ums_transport.py new file mode 100644 index 0000000..437161a --- /dev/null +++ b/src/sap_cloud_sdk/extensibility/_ums_transport.py @@ -0,0 +1,698 @@ +"""UMS GraphQL transport for the extensibility service. + +Queries UMS directly (per destination) instead of going through the legacy HTTP +backend. Each destination is resolved via the Destination SDK, then a +GraphQL query is sent to the UMS ``/graphql`` endpoint. + +Authentication uses **client certificates (mTLS)**. The certificate is +obtained from the resolved destination and written to a temporary file +for the duration of the HTTP request. +""" + +from __future__ import annotations + +import base64 +import collections +import logging +import os +import tempfile +import threading +import time +from http import HTTPMethod +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +import httpx + +from sap_cloud_sdk.destination import Level +from sap_cloud_sdk.destination import create_client as create_destination_client +from sap_cloud_sdk.extensibility._models import ( + DEFAULT_EXTENSION_CAPABILITY_ID, + DEFAULT_HOOK_TIMEOUT, + DeploymentType, + ExecutionMode, + ExtensionCapabilityImplementation, + ExtensionSourceInfo, + ExtensionSourceMapping, + Hook, + HookType, + McpServer, + N8nWorkflowConfig, + OnFailure, +) +from sap_cloud_sdk.extensibility.exceptions import TransportError + +if TYPE_CHECKING: + from sap_cloud_sdk.extensibility.config import ExtensibilityConfig + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Environment variable for UMS destination name construction +# --------------------------------------------------------------------------- + +ENV_CONHOS_LANDSCAPE = "APPFND_CONHOS_LANDSCAPE" +ENV_UMS_DESTINATION_NAME = "APPFND_UMS_DESTINATION_NAME" +_UMS_DESTINATION_PREFIX = "sap-managed-runtime-ums-" + +# --------------------------------------------------------------------------- +# GraphQL query +# --------------------------------------------------------------------------- + +_GRAPHQL_QUERY_FRAGMENT = """\ + edges { + node { + id + title + extensionVersion + solutionId + capabilityImplementations { + capabilityId + instruction { text } + tools { + additions { type mcpConfig { globalTenantId ordId toolNames } } + } + hooks { id hookId type name onFailure timeout deploymentType canShortCircuit + n8nWorkflowConfig { workflowId method } + } + } + } + } + pageInfo { + hasNextPage + cursor + }""" + +_GRAPHQL_QUERY = ( + """\ +query GetExtCapImplementations($filters: EXTHUB__ExtCapImplementationFilterInput) { + EXTHUB__ExtCapImplementationInstances( + filters: $filters + first: 50 + ) { +%s + } +}""" + % _GRAPHQL_QUERY_FRAGMENT +) + +_GRAPHQL_QUERY_WITH_CURSOR = ( + """\ +query GetExtCapImplementations($filters: EXTHUB__ExtCapImplementationFilterInput, $after: String) { + EXTHUB__ExtCapImplementationInstances( + filters: $filters + first: 50 + after: $after + ) { +%s + } +}""" + % _GRAPHQL_QUERY_FRAGMENT +) + +_GRAPHQL_HEADERS: dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json", +} + +_UMS_GRAPHQL_PATH = "/graphql" + +# --------------------------------------------------------------------------- +# Cache configuration +# --------------------------------------------------------------------------- + +#: Time-to-live for cached UMS responses, in seconds (10 minutes). +_CACHE_TTL_SECONDS: int = 600 + +#: Maximum number of entries in the in-memory UMS response cache. +#: When the cache exceeds this size, expired entries are swept first, +#: then the least-recently-used entries are evicted. +_CACHE_MAX_SIZE: int = 256 + +#: Maximum number of pages to fetch when paginating UMS results. +#: Acts as a safety limit to prevent infinite loops (50 * 100 = 5 000 extensions). +_MAX_PAGES: int = 100 + +# --------------------------------------------------------------------------- +# Enum parsing helpers +# --------------------------------------------------------------------------- + +_HOOK_TYPE_MAP: dict[str, HookType] = {member.value: member for member in HookType} +_ON_FAILURE_MAP: dict[str, OnFailure] = {member.value: member for member in OnFailure} +_DEPLOYMENT_TYPE_MAP: dict[str, DeploymentType] = { + member.value: member for member in DeploymentType +} +_HTTP_METHOD_MAP: dict[str, HTTPMethod] = { + member.value: member for member in HTTPMethod +} + + +def _parse_hook_type_safe(value: str) -> Optional[HookType]: + """Return a ``HookType`` for *value*, or ``None`` if unknown.""" + return _HOOK_TYPE_MAP.get(value) + + +def _parse_on_failure_safe(value: str) -> OnFailure: + """Return an ``OnFailure`` for *value*, defaulting to ``CONTINUE``.""" + result = _ON_FAILURE_MAP.get(value) + if result is None and value: + logger.warning("Unknown onFailure value %r; defaulting to CONTINUE", value) + return result or OnFailure.CONTINUE + + +def _parse_deployment_type_safe(value: str) -> DeploymentType: + """Return a ``DeploymentType`` for *value*, defaulting to ``UNKNOWN``.""" + result = _DEPLOYMENT_TYPE_MAP.get(value) + if result is None and value: + logger.warning("Unknown deploymentType value %r; defaulting to UNKNOWN", value) + return result or DeploymentType.UNKNOWN + + +def _parse_method_safe(value: str) -> HTTPMethod: + """Return an ``HTTPMethod`` for *value*, defaulting to ``POST``.""" + result = _HTTP_METHOD_MAP.get(value) + if result is None and value: + logger.warning("Unknown HTTP method %r; defaulting to POST", value) + return result or HTTPMethod.POST + + +# --------------------------------------------------------------------------- +# Destination name resolution +# --------------------------------------------------------------------------- + + +def _ums_destination_name(config_override: Optional[str] = None) -> Optional[str]: + """Construct the UMS destination name from configuration or environment. + + Resolution order: + + 1. **Config override** -- if ``config.destination_name`` is set, use + it directly. + 2. **Explicit env var override** -- if ``APPFND_UMS_DESTINATION_NAME`` + is set, use its value directly. This is useful in subaccounts + where the UMS destination follows a non-standard naming convention. + 3. **Landscape-based construction** -- the destination name is built as + ``sap-managed-runtime-ums-{APPFND_CONHOS_LANDSCAPE}``. + + Args: + config_override: Optional destination name from + :class:`ExtensibilityConfig`. Takes highest priority when set. + + Returns: + The resolved UMS destination name, or ``None`` if no configuration + or environment variables are available to determine it. + """ + # 0. Config-level override takes highest priority + if config_override: + logger.debug( + "Using UMS destination name from config override: %s", + config_override, + ) + return config_override + + # 1. Explicit env var override takes precedence + override = os.environ.get(ENV_UMS_DESTINATION_NAME) + if override: + logger.debug( + "Using UMS destination name from %s: %s", + ENV_UMS_DESTINATION_NAME, + override, + ) + return override + + # 2. Construct from landscape (existing logic) + landscape = os.environ.get(ENV_CONHOS_LANDSCAPE) + if not landscape: + logger.warning( + "%s is not set; cannot construct UMS destination name. " + "Set %s or %s to configure the UMS destination name.", + ENV_CONHOS_LANDSCAPE, + ENV_UMS_DESTINATION_NAME, + ENV_CONHOS_LANDSCAPE, + ) + return None + + destination_name = f"{_UMS_DESTINATION_PREFIX}{landscape}" + logger.debug( + "Resolved UMS destination name from %s: %s", + ENV_CONHOS_LANDSCAPE, + destination_name, + ) + return destination_name + + +# --------------------------------------------------------------------------- +# Response transformation helpers +# --------------------------------------------------------------------------- + + +def _build_mcp_server(addition: Dict[str, Any]) -> McpServer: + """Convert a UMS ``tools.additions[]`` entry into an :class:`McpServer`. + + The UMS schema nests ``ordId`` and ``toolNames`` under the + ``mcpConfig`` object inside each addition. + """ + mcp_config = addition.get("mcpConfig") or {} + return McpServer( + ord_id=mcp_config.get("ordId", ""), + global_tenant_id=mcp_config.get("globalTenantId", ""), + tool_names=mcp_config.get("toolNames"), + ) + + +def _build_hook(raw: Dict[str, Any]) -> Optional[Hook]: + """Convert a UMS ``hooks[]`` entry into a :class:`Hook`. + + Maps fields from the UMS GraphQL schema: + + * ``id`` → ``id`` (DB UUID) + * ``hookId`` → ``hook_id`` (developer-defined identifier) + * ``type`` → ``type`` (parsed to :class:`HookType`) + * ``name`` → ``name`` + * ``onFailure`` → ``on_failure`` (parsed to :class:`OnFailure`, default ``CONTINUE``) + * ``timeout`` → ``timeout`` (default :data:`DEFAULT_HOOK_TIMEOUT`) + * ``deploymentType`` → ``deployment_type`` (parsed to :class:`DeploymentType`, default ``N8N``) + * ``canShortCircuit`` → ``can_short_circuit`` (default ``False``) + * ``n8nWorkflowConfig`` → ``n8n_workflow_config`` (:class:`N8nWorkflowConfig`) + + Returns ``None`` when the hook type is unknown/missing. + """ + hook_type = _parse_hook_type_safe(raw.get("type", "")) + if hook_type is None: + logger.warning( + "Skipping hook with unknown type %r (hookId=%s)", + raw.get("type"), + raw.get("hookId"), + ) + return None + + n8n_config = raw.get("n8nWorkflowConfig") or {} + workflow_id = n8n_config.get("workflowId", "") + if not workflow_id: + logger.warning( + "Skipping hook with missing workflowId (hookId=%s)", + raw.get("hookId"), + ) + return None + + deployment_type = _parse_deployment_type_safe(raw.get("deploymentType", "")) + method = _parse_method_safe(n8n_config.get("method", "POST")) + on_failure = _parse_on_failure_safe(raw.get("onFailure", "")) + + return Hook( + id=raw.get("id", ""), + hook_id=raw.get("hookId", ""), + n8n_workflow_config=N8nWorkflowConfig(workflow_id=workflow_id, method=method), + name=raw.get("name", ""), + type=hook_type, + deployment_type=deployment_type, + timeout=raw.get("timeout", DEFAULT_HOOK_TIMEOUT), + execution_mode=ExecutionMode.SYNC, + on_failure=on_failure, + order=0, + can_short_circuit=raw.get("canShortCircuit", False), + ) + + +def _build_source_mapping( + nodes: List[Dict[str, Any]], + mcp_servers: List[McpServer], + hooks: List[Hook], +) -> ExtensionSourceMapping: + """Build a source mapping from per-node title to contributed tools/hooks. + + Each node has a ``title`` (the extension name) and a list of + ``capabilityImplementations`` whose tools and hooks were contributed + by that extension. + """ + tool_map: Dict[str, ExtensionSourceInfo] = {} + hook_map: Dict[str, ExtensionSourceInfo] = {} + + for node in nodes: + title = node.get("title", "") + source_info = ExtensionSourceInfo( + extension_name=title, + extension_version=node.get("extensionVersion", ""), + extension_id=node.get("id", ""), + solution_id=node.get("solutionId") or "", + ) + + for cap_impl in node.get("capabilityImplementations", []): + # Map tools (toolNames are nested under mcpConfig) + additions = (cap_impl.get("tools") or {}).get("additions", []) + for addition in additions: + mcp_config = addition.get("mcpConfig") or {} + tool_names = mcp_config.get("toolNames") or [] + for tool_name in tool_names: + tool_map[tool_name] = source_info + + # Map hooks (use id as the mapping key) + for raw_hook in cap_impl.get("hooks") or []: + hook_id = raw_hook.get("id", "") + if hook_id: + hook_map[hook_id] = source_info + + return ExtensionSourceMapping(tools=tool_map, hooks=hook_map) + + +def _transform_ums_response( + data: Dict[str, Any], + capability_id: str, +) -> ExtensionCapabilityImplementation: + """Transform a UMS GraphQL response into an :class:`ExtensionCapabilityImplementation`. + + Args: + data: The ``data`` portion of the GraphQL JSON response. + capability_id: The requested capability ID to filter by. + + Returns: + A populated ``ExtensionCapabilityImplementation``. + """ + edges = data.get("EXTHUB__ExtCapImplementationInstances", {}).get("edges", []) + + extension_names: List[str] = [] + mcp_servers: List[McpServer] = [] + hooks: List[Hook] = [] + instructions: List[str] = [] + nodes: List[Dict[str, Any]] = [] + + for edge in edges: + node = edge.get("node", {}) + nodes.append(node) + title = node.get("title", "") + if title: + extension_names.append(title) + + for cap_impl in node.get("capabilityImplementations", []): + # Filter by capability_id + if cap_impl.get("capabilityId") != capability_id: + continue + + # Instruction + raw_instruction = cap_impl.get("instruction") + if raw_instruction and isinstance(raw_instruction, dict): + text = raw_instruction.get("text") + if text: + instructions.append(text) + + # MCP servers from tools.additions + additions = (cap_impl.get("tools") or {}).get("additions", []) + for addition in additions: + mcp_servers.append(_build_mcp_server(addition)) + + # Hooks + for raw_hook in cap_impl.get("hooks") or []: + hook = _build_hook(raw_hook) + if hook is not None: + hooks.append(hook) + + source = _build_source_mapping(nodes, mcp_servers, hooks) + + instruction = "\n\n".join(instructions) if instructions else None + + return ExtensionCapabilityImplementation( + capability_id=capability_id, + extension_names=extension_names, + mcp_servers=mcp_servers, + instruction=instruction, + hooks=hooks, + source=source, + ) + + +# --------------------------------------------------------------------------- +# Transport +# --------------------------------------------------------------------------- + + +class UmsTransport: + """UMS GraphQL transport for the extensibility service. + + Resolves the UMS destination via the Destination SDK, then sends + a GraphQL query to the UMS ``/graphql`` endpoint and transforms + the response into an :class:`ExtensionCapabilityImplementation`. + + The destination name is resolved in order: + + 1. ``config.destination_name`` (explicit config override). + 2. ``APPFND_UMS_DESTINATION_NAME`` environment variable. + 3. ``sap-managed-runtime-ums-{APPFND_CONHOS_LANDSCAPE}`` (constructed). + 4. ``EXTENSIBILITY_SERVICE`` (fallback with warning). + + Args: + agent_ord_id: ORD ID of the agent. + config: Extensibility configuration with optional + ``destination_name`` override and ``destination_instance``. + """ + + def __init__(self, agent_ord_id: str, config: ExtensibilityConfig) -> None: + self._agent_ord_id = agent_ord_id + self._config = config + self._destination_name = _ums_destination_name(config.destination_name) + self._dest_client = create_destination_client( + instance=config.destination_instance + ) + self._cache: collections.OrderedDict[ + tuple[str, str], + tuple[float, List[Dict[str, Any]]], + ] = collections.OrderedDict() + self._cache_lock = threading.Lock() + + # ------------------------------------------------------------------ + # get_extension_capability_implementation + # ------------------------------------------------------------------ + + def get_extension_capability_implementation( + self, + capability_id: str = DEFAULT_EXTENSION_CAPABILITY_ID, + skip_cache: bool = False, + tenant: str = "", + ) -> ExtensionCapabilityImplementation: + """Fetch extension capability implementation from UMS via GraphQL. + + Resolves the UMS destination, sends the + ``EXTHUB__ExtCapImplementationInstances`` GraphQL query, and + transforms the response into an + :class:`ExtensionCapabilityImplementation`. + + Results are cached in-memory for 10 minutes (see + :data:`_CACHE_TTL_SECONDS`), keyed by + ``(tenant, capability_id)``. + Set *skip_cache* to ``True`` to bypass the cache and fetch a fresh + result -- the fresh result will still be written back into the + cache so that subsequent normal reads benefit from the update. + + Args: + capability_id: Extension capability ID to filter by. + Defaults to ``"default"``. + skip_cache: When ``True``, bypass the in-memory cache and + fetch a fresh result from UMS. The fresh result is + written back into the cache. Defaults to ``False``. + tenant: Tenant ID for the request. Included in the GraphQL + query as ``agent.uclSystemInstance.localTenantIdIn`` + and sent as the ``X-Tenant`` HTTP header. Also used + as a cache isolation key. + + Returns: + Parsed ``ExtensionCapabilityImplementation`` from UMS. + + Raises: + TransportError: If destination resolution, HTTP communication, + or response parsing fails. + """ + # Guard: destination name must be resolved + if self._destination_name is None: + raise TransportError( + "UMS destination name could not be resolved. " + "Set the APPFND_UMS_DESTINATION_NAME or " + "APPFND_CONHOS_LANDSCAPE environment variable, or provide " + "a destination_name in ExtensibilityConfig." + ) + + # 0. Cache lookup ------------------------------------------------ + cache_key = (tenant, capability_id) + all_edges: List[Dict[str, Any]] = [] + + if not skip_cache: + with self._cache_lock: + cached = self._cache.get(cache_key) + if cached is not None: + ts, cached_edges = cached + if (time.monotonic() - ts) < _CACHE_TTL_SECONDS: + logger.debug( + "UMS cache hit for tenant=%s capability_id=%s", + tenant, + capability_id, + ) + self._cache.move_to_end(cache_key) + all_edges = cached_edges + combined_data: Dict[str, Any] = { + "EXTHUB__ExtCapImplementationInstances": { + "edges": all_edges + }, + } + return _transform_ums_response(combined_data, capability_id) + logger.debug( + "UMS cache expired for tenant=%s capability_id=%s", + tenant, + capability_id, + ) + + # 1. Resolve destination ----------------------------------------- + try: + dest = self._dest_client.get_destination( + self._destination_name, level=Level.SUB_ACCOUNT + ) + except Exception as exc: + raise TransportError( + f"Failed to resolve destination '{self._destination_name}': {exc}" + ) from exc + + if dest is None: + raise TransportError( + f"Destination '{self._destination_name}' not found in Destination Service." + ) + + base_url = dest.url + if base_url is None: + raise TransportError( + f"Destination '{self._destination_name}' has no URL configured." + ) + + # 2. Extract client certificate ---------------------------------- + if not dest.certificates: + raise TransportError( + f"Destination '{self._destination_name}' has no " + f"client certificates. UmsTransport requires mTLS via " + f"ClientCertificateAuthentication." + ) + + cert = dest.certificates[0] + try: + cert_bytes = base64.b64decode(cert.content) + except Exception as exc: + raise TransportError( + f"Failed to decode client certificate '{cert.name}': {exc}" + ) from exc + + # 3. Build GraphQL request -------------------------------------- + url = f"{base_url.rstrip('/')}{_UMS_GRAPHQL_PATH}" + + agent_filter: dict[str, Any] = { + "ordIdEquals": self._agent_ord_id, + "uclSystemInstance": { + "localTenantIdIn": tenant, + }, + } + + filters: dict[str, Any] = {"agent": agent_filter} + + variables: dict[str, Any] = { + "filters": filters, + } + + request_headers = { + **_GRAPHQL_HEADERS, + "X-Tenant": tenant, + } + + # 4. Send paginated requests with mTLS -------------------------- + all_edges = [] + cursor: Optional[str] = None + try: + with tempfile.NamedTemporaryFile(suffix=".pem") as cert_file: + cert_file.write(cert_bytes) + cert_file.flush() + + with httpx.Client(cert=cert_file.name) as client: + for _ in range(_MAX_PAGES): + if cursor is not None: + query = _GRAPHQL_QUERY_WITH_CURSOR + variables["after"] = cursor + else: + query = _GRAPHQL_QUERY + variables.pop("after", None) + + gql_body = { + "query": query, + "variables": variables, + } + response = client.post( + url, + json=gql_body, + headers=request_headers, + ) + + # 5. Parse response --------------------------------- + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise TransportError( + f"UMS returned HTTP {response.status_code}: " + f"{response.text}" + ) from exc + + try: + body = response.json() + except Exception as exc: + raise TransportError( + f"Failed to parse UMS response as JSON: {exc}" + ) from exc + + # Check for GraphQL-level errors + if "errors" in body: + error_messages = [ + e.get("message", "Unknown error") + for e in body["errors"] + ] + raise TransportError( + f"UMS GraphQL errors: {'; '.join(error_messages)}" + ) + + data = body.get("data") + if data is None: + raise TransportError( + "UMS response is missing the 'data' field." + ) + + connection = data.get( + "EXTHUB__ExtCapImplementationInstances", {} + ) + all_edges.extend(connection.get("edges", [])) + + # Check for next page + page_info = connection.get("pageInfo") or {} + if not page_info.get("hasNextPage", False): + break + cursor = page_info.get("cursor") + + except TransportError: + raise + except Exception as exc: + raise TransportError(f"HTTP request to UMS endpoint failed: {exc}") from exc + + # 6. Populate cache ---------------------------------------------- + now = time.monotonic() + + with self._cache_lock: + # Evict expired entries first. + expired_keys = [ + k + for k, (ts, _) in self._cache.items() + if (now - ts) >= _CACHE_TTL_SECONDS + ] + for k in expired_keys: + del self._cache[k] + + # If still at capacity, evict the least-recently-used entry. + while len(self._cache) >= _CACHE_MAX_SIZE: + self._cache.popitem(last=False) + + self._cache[cache_key] = (now, all_edges) + + # 7. Transform ----------------------------------------------------------- + combined_data: Dict[str, Any] = { + "EXTHUB__ExtCapImplementationInstances": {"edges": all_edges}, + } + result = _transform_ums_response(combined_data, capability_id) + + return result diff --git a/src/sap_cloud_sdk/extensibility/client.py b/src/sap_cloud_sdk/extensibility/client.py new file mode 100644 index 0000000..01267be --- /dev/null +++ b/src/sap_cloud_sdk/extensibility/client.py @@ -0,0 +1,412 @@ +"""Extensibility service client.""" + +from __future__ import annotations + +import itertools +import json +import logging +import time +from typing import TYPE_CHECKING, Any, Optional, Union, cast + +import httpx +from a2a.types import Message +from opentelemetry.propagate import inject +from pydantic_core import ValidationError + +from sap_cloud_sdk.core.telemetry import Module, Operation +from sap_cloud_sdk.core.telemetry.metrics_decorator import record_metrics +from sap_cloud_sdk.extensibility._models import ( + DEFAULT_EXTENSION_CAPABILITY_ID, + ExtensionCapabilityImplementation, + Hook, +) +from sap_cloud_sdk.extensibility.config import HookConfig +from sap_cloud_sdk.extensibility.exceptions import TransportError + +if TYPE_CHECKING: + from sap_cloud_sdk.extensibility._local_transport import LocalTransport + from sap_cloud_sdk.extensibility._noop_transport import NoOpTransport + from sap_cloud_sdk.extensibility._ums_transport import UmsTransport + + Transport = Union[LocalTransport, NoOpTransport, UmsTransport] + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# n8n MCP constants +# --------------------------------------------------------------------------- + +_EXECUTE_WORKFLOW_TOOL_NAME = "execute_workflow" +_GET_EXECUTION_TOOL_NAME = "get_execution" + +_JSONRPC_VERSION = "2.0" + +_JSONRPC_HEADERS: dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", +} + +#: execute-workflow statuses that mean the execution cannot continue. +_EXECUTE_TERMINAL_STATUSES = frozenset({"error", "canceled", "crashed", "unknown"}) +#: get-execution statuses that mean the execution has permanently failed. +_EXECUTION_TERMINAL_STATUSES = frozenset({"error", "canceled", "crashed"}) + +_HOOK_POLL_INTERVAL = 0.5 # seconds between get-execution polls + +# --------------------------------------------------------------------------- +# JSON-RPC helpers +# --------------------------------------------------------------------------- + +_request_id_counter = itertools.count(1) + + +def _build_tool_call(arguments: dict[str, Any], tool_name: str) -> dict[str, Any]: + return { + "jsonrpc": _JSONRPC_VERSION, + "id": next(_request_id_counter), + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments}, + } + + +def _parse_sse_response(text: str) -> dict[str, Any]: + """Extract the last JSON-RPC message from an SSE ``data:`` stream.""" + result: dict[str, Any] | None = None + for line in text.splitlines(): + if line.startswith("data:"): + payload = line[len("data:") :].strip() + if payload: + result = json.loads(payload) + if result is None: + raise TransportError("No JSON-RPC message found in SSE response.") + return result + + +def _parse_response(response: httpx.Response) -> dict[str, Any]: + response.raise_for_status() + if "text/event-stream" in response.headers.get("content-type", ""): + return _parse_sse_response(response.text) + return response.json() + + +def _extract_tool_result(jsonrpc: dict[str, Any]) -> dict[str, Any]: + if "error" in jsonrpc: + msg = jsonrpc["error"].get("message", "Unknown error") + raise TransportError(f"n8n returned an error: {msg}") + + result = jsonrpc.get("result", {}) + if result.get("isError"): + content = result.get("content", []) + error_text = next( + (c.get("text", "") for c in content if c.get("type") == "text"), "" + ) + raise TransportError(f"n8n tool call failed: {error_text}") + + for item in result.get("content", []): + if item.get("type") == "text": + try: + return json.loads(item["text"]) + except (json.JSONDecodeError, KeyError): + continue + + structured = result.get("structuredContent") + if structured is not None: + return structured + + raise TransportError("Hook response contains no parseable content.") + + +class ExtensibilityClient: + """Client for SAP Extensibility operations. + + Retrieves extension capability implementations (MCP servers and instructions) + from the extensibility service backend. + + Note: + Do not instantiate this class directly. Use :func:`create_client` instead, + which wires the transport and configuration. + + Example: + ```python + from sap_cloud_sdk.extensibility import create_client + + client = create_client("sap.ai:agent:myAgent:v1") + ext = client.get_extension_capability_implementation(tenant=tenant_id) + ``` + """ + + def __init__( + self, transport: Transport, _telemetry_source: Optional[Module] = None + ) -> None: + """Initialize the client with a transport. + + Warning: + For internal and testing use. Use :func:`create_client` in application code. + + Args: + transport: Configured transport for extensibility requests. + Either :class:`UmsTransport` (cloud), :class:`LocalTransport` + (local dev), or :class:`NoOpTransport` (graceful degradation). + _telemetry_source: Internal telemetry source identifier. Not intended for external use. + """ + self._transport = transport + self._telemetry_source = _telemetry_source + + @record_metrics( + Module.EXTENSIBILITY, + Operation.EXTENSIBILITY_GET_EXTENSION_CAPABILITY_IMPLEMENTATION, + ) + def get_extension_capability_implementation( + self, + *, + tenant: str, + capability_id: str = DEFAULT_EXTENSION_CAPABILITY_ID, + skip_cache: bool = False, + ) -> ExtensionCapabilityImplementation: + """Retrieve the active extension's contribution for a capability. + + On failure (service unavailable, destination errors, etc.), logs the error + and returns an empty ``ExtensionCapabilityImplementation`` so the agent can + continue with built-in tools only. + + Args: + tenant: Tenant ID for the request. Used to filter extensions + in the GraphQL query (via + ``agent.uclSystemInstance.localTenantIdIn``) and sent as + the ``X-Tenant`` HTTP header. Also used as a cache + isolation key so that different tenants receive their own + cached results. Typically extracted from the incoming + request's JWT. + capability_id: Extension capability ID to look up. Defaults to ``"default"``. + skip_cache: When ``True``, bypass any transport-level cache and + fetch a fresh result. Useful for ORD document creation or + other scenarios that require up-to-date data. The fresh + result is still written back into the cache so that + subsequent normal reads benefit. Defaults to ``False``. + + Returns: + Parsed implementation from the extensibility backend, or an empty result on any error. + + Example:: + + from sap_cloud_sdk.extensibility import create_client + + client = create_client("sap.ai:agent:myAgent:v1") + ext = client.get_extension_capability_implementation( + tenant="1d2e1a41-a28b-431f-9e3f-42e9704bfa75", + ) + """ + try: + return self._transport.get_extension_capability_implementation( + capability_id=capability_id, + skip_cache=skip_cache, + tenant=tenant, + ) + except Exception: + logger.error( + "Failed to retrieve extension capability implementation. " + "Returning empty result. The agent will continue with built-in tools only.", + exc_info=True, + ) + return ExtensionCapabilityImplementation(capability_id=capability_id) + + @record_metrics( + Module.EXTENSIBILITY, + Operation.EXTENSIBILITY_CALL_HOOK, + ) + def call_hook( + self, + hook: Hook, + hook_config: HookConfig, + ) -> Optional[Message]: + """Call a hook's MCP endpoint and poll until completion. + + Executes the workflow via ``execute-workflow``, then polls + ``get-execution`` every 500 ms until the execution succeeds, fails, + or ``hook.timeout`` seconds elapse. + + This method is transport-agnostic: regardless of how extension + metadata was fetched (backend, local file, or no-op), + the actual hook invocation is always a direct HTTP call to the + URL embedded in the :class:`Hook` object. + + Args: + hook: Hook configuration (workflow ID, method, timeout). + hook_config: Hook invocation configuration (endpoint URL, auth token, optional payload). + + Returns: + Parsed ``Message`` from the last executed workflow node, or ``None`` + if the hook completed successfully but produced no message. + + Raises: + TransportError: On HTTP errors, terminal execution failures, or timeout. + + Example: + ```python + from sap_cloud_sdk.extensibility import create_client + + client = create_client("sap.ai:agent:myAgent:v1") + impl = client.get_extension_capability_implementation(tenant="tenant-abc") + + if impl.hooks: + hook = impl.hooks[0] + result = client.call_hook( + hook, + HookConfig( + endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}", + auth_token="my-secret-token", + payload={"foo": "bar"}, + ), + ) + ``` + """ + headers = {**_JSONRPC_HEADERS} + inject(headers) + + message_payload: dict[str, Any] = {} + if hook_config.payload is not None: + model_dump = getattr(hook_config.payload, "model_dump", None) + if callable(model_dump): + message_payload = cast(dict[str, Any], model_dump(exclude_none=True)) + + # 1. Execute workflow + execute_workflow_arguments = { + "workflowId": hook.n8n_workflow_config.workflow_id, + "inputs": { + "type": "webhook", + "webhookData": { + "method": hook.n8n_workflow_config.method, + "query": {}, + "body": message_payload, + "headers": headers, + }, + }, + } + + try: + with httpx.Client( + headers={"Authorization": f"Bearer {hook_config.auth_token}"}, + timeout=hook.timeout, + ) as client: + tool_resp = client.post( + hook_config.endpoint, + json=_build_tool_call( + execute_workflow_arguments, _EXECUTE_WORKFLOW_TOOL_NAME + ), + headers=headers, + ) + except TransportError: + raise + except Exception as exc: + raise TransportError( + f"HTTP request to hook MCP endpoint failed: {exc}" + ) from exc + + try: + data = _extract_tool_result(_parse_response(tool_resp)) + except TransportError: + raise + except Exception as exc: + raise TransportError(f"Could not parse hook response: {exc}") from exc + + status = data.get("status", "") + + # 2. Fail fast on terminal statuses from execute-workflow + if status in _EXECUTE_TERMINAL_STATUSES: + error_msg = data.get("error", "") + raise TransportError( + f"Workflow execution failed with status {status!r}" + + (f": {error_msg}" if error_msg else "") + ) + + # 3. Return immediately if execution completed synchronously + if status == "success": + try: + result_data = data.get("data", {}).get("resultData", {}) + last_node = result_data.get("lastNodeExecuted", "") + response_json = ( + result_data.get("runData", {}) + .get(last_node, [{}])[0] + .get("data", {}) + .get("main", [[{}]])[0][0] + .get("json", {}) + ) + return Message(**response_json) + except (KeyError, IndexError, TypeError, ValidationError) as exc: + raise TransportError( + f"Failed to extract response from last executed node: {exc}" + ) from exc + + # 4. Poll get-execution for running/new/waiting/started + execution_id = data.get("executionId") + get_execution_arguments = { + "workflowId": hook.n8n_workflow_config.workflow_id, + "executionId": str(execution_id), + "includeData": True, + } + + deadline = time.monotonic() + hook.timeout + last_status = status + while time.monotonic() < deadline: + time.sleep(_HOOK_POLL_INTERVAL) + + try: + with httpx.Client( + headers={"Authorization": f"Bearer {hook_config.auth_token}"}, + timeout=hook.timeout, + ) as client: + tool_resp = client.post( + hook_config.endpoint, + json=_build_tool_call( + get_execution_arguments, _GET_EXECUTION_TOOL_NAME + ), + headers=headers, + ) + except TransportError: + raise + except Exception as exc: + raise TransportError( + f"HTTP request to hook MCP endpoint failed: {exc}" + ) from exc + + try: + data = _extract_tool_result(_parse_response(tool_resp)) + except TransportError: + raise + except Exception as exc: + raise TransportError(f"Could not parse hook response: {exc}") from exc + + last_status = data.get("execution", {}).get("status", "") or data.get( + "status", "" + ) + + if last_status == "success": + try: + result_data = data.get("data", {}).get("resultData", {}) + last_node = result_data.get("lastNodeExecuted", "") + response_json = ( + result_data.get("runData", {}) + .get(last_node, [{}])[0] + .get("data", {}) + .get("main", [[{}]])[0][0] + .get("json", {}) + ) + return Message(**response_json) + except (KeyError, IndexError, TypeError, ValidationError) as exc: + raise TransportError( + f"Failed to extract response from last executed node: {exc}" + ) from exc + + if last_status in _EXECUTION_TERMINAL_STATUSES: + error_msg = data.get("error", "") + raise TransportError( + f"Workflow execution failed with status {last_status!r}" + + (f": {error_msg}" if error_msg else "") + ) + + # Continue polling for: running, waiting, new, unknown + + raise TransportError( + f"Workflow execution timed out after {hook.timeout}s. " + f"Last status: {last_status!r}" + ) diff --git a/src/sap_cloud_sdk/extensibility/config.py b/src/sap_cloud_sdk/extensibility/config.py new file mode 100644 index 0000000..649a700 --- /dev/null +++ b/src/sap_cloud_sdk/extensibility/config.py @@ -0,0 +1,55 @@ +"""Configuration for the extensibility module.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class ExtensibilityConfig: + """Optional configuration overrides for the extensibility service connection. + + The backend service URL and credentials are resolved automatically + from the Destination Service binding -- injected via ``app.yaml``. + The SDK communicates with UMS via GraphQL; no + URL patterns or manual setup needed. + + This config holds **optional overrides only**. The required + ``agent_ord_id`` is passed directly to :func:`create_client`. + + Attributes: + destination_name: Optional override for the UMS destination name. + When ``None`` (the default), the destination name is resolved + automatically in order: + (1) ``APPFND_UMS_DESTINATION_NAME`` environment variable, + (2) ``sap-managed-runtime-ums-{APPFND_CONHOS_LANDSCAPE}``, + (3) fallback to ``"EXTENSIBILITY_SERVICE"`` with a warning. + Set this only when the destination follows a non-standard + naming convention that cannot be expressed via environment + variables. + destination_instance: Destination service instance name. When ``"default"``, + resolves to the default destination service instance. Specify a name + only if your deployment binds the destination service under a + non-default instance name. + """ + + destination_name: Optional[str] = None + destination_instance: str = "default" + + +@dataclass +class HookConfig: + """Configuration for calling hooks. + + Attributes: + endpoint: Full URL of the hook endpoint to call including MCP ORD ID, GTID, and any path segments (e.g. ``"https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}"``). + auth_token: Optional bearer token for authenticating against the hook endpoint. + payload: Optional dictionary to send as JSON payload in the hook request. + headers: Optional additional HTTP headers to include in the hook request. + """ + + endpoint: str + auth_token: Optional[str] = None + payload: Optional[dict] = None + headers: Optional[dict] = None diff --git a/src/sap_cloud_sdk/extensibility/exceptions.py b/src/sap_cloud_sdk/extensibility/exceptions.py new file mode 100644 index 0000000..268e5bb --- /dev/null +++ b/src/sap_cloud_sdk/extensibility/exceptions.py @@ -0,0 +1,24 @@ +"""Exception classes for the extensibility module.""" + + +class ExtensibilityError(Exception): + """Base exception for all extensibility module errors.""" + + pass + + +class ClientCreationError(ExtensibilityError): + """Raised when :func:`create_client` fails to construct the client.""" + + pass + + +class TransportError(ExtensibilityError): + """Raised when transport operations fail. + + This includes network errors, unexpected status codes, + and response parsing failures when communicating with the + extensibility backend service. + """ + + pass diff --git a/src/sap_cloud_sdk/extensibility/local_extensibility_example.json b/src/sap_cloud_sdk/extensibility/local_extensibility_example.json new file mode 100644 index 0000000..77b9120 --- /dev/null +++ b/src/sap_cloud_sdk/extensibility/local_extensibility_example.json @@ -0,0 +1,48 @@ +{ + "capabilityId": "default", + "extensionNames": ["employee-onboarding-tools"], + "instruction": "Use the ServiceNow tools for hardware provisioning tasks during onboarding.", + "mcpServers": [ + { + "ordId": "sap.mcp:apiResource:serviceNow:v1", + "globalTenantId": "tenant-abc-123", + "toolNames": [ + "create_hardware_ticket_tool" + ] + } + ], + "hooks": [ + { + "hookId": "hook-1774481137698", + "id": "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11", + "name": "Currency Conversion", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": { + "workflowId": "wf-currency-conversion-001", + "method": "POST" + }, + "timeout": 30, + "executionMode": "SYNC", + "onFailure": "CONTINUE", + "order": 0, + "canShortCircuit": false + }, + { + "hookId": "hook-1774481142176", + "id": "6a9e0cef-eed6-4f1b-9f86-3d8e9f5c1d22", + "name": "Trigger Email Notification", + "hookType": "AFTER", + "deploymentType": "N8N", + "n8nWorkflowConfig": { + "workflowId": "wf-trigger-email-notification-001", + "method": "POST" + }, + "timeout": 30, + "executionMode": "SYNC", + "onFailure": "CONTINUE", + "order": 1, + "canShortCircuit": false + } + ] +} diff --git a/src/sap_cloud_sdk/extensibility/py.typed b/src/sap_cloud_sdk/extensibility/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/sap_cloud_sdk/extensibility/user-guide.md b/src/sap_cloud_sdk/extensibility/user-guide.md new file mode 100644 index 0000000..043e539 --- /dev/null +++ b/src/sap_cloud_sdk/extensibility/user-guide.md @@ -0,0 +1,924 @@ +# Extensibility User Guide + +This module enables SAP AI agents to be extended at runtime with third-party tools (delivered via MCP servers) and custom instructions. It communicates with UMS (Unified Metadata Service) via GraphQL to retrieve the active extension's contribution, and provides helpers to declare extensible capabilities in the agent's A2A card for discovery. + +## Installation + +This package is part of the `application_foundation` SDK. Import and use it directly in your application. + +## Import + +```python +from sap_cloud_sdk.extensibility import ( + # Runtime + create_client, + ExtensibilityClient, + ExtensionCapabilityImplementation, + McpServer, + Hook, + # A2A card + build_extension_capabilities, + ExtensionCapability, + ToolAdditions, + Tools, + HookCapability, + # Config & constants + ExtensibilityConfig, + DEFAULT_EXTENSION_CAPABILITY_ID, + EXTENSION_CAPABILITY_SCHEMA_VERSION, + # Enums + HookType, + DeploymentType, + ExecutionMode, + OnFailure, + # Exceptions + ClientCreationError, + ExtensibilityError, + TransportError, +) + +# For hook payloads +from a2a.types import Message, Role +``` + +## Quick Start + +### Retrieve Extension Tools at Runtime + +Create a client with `create_client()`, then call `get_extension_capability_implementation()` on the client. If the service is unavailable, the method returns an empty result and the agent continues with built-in tools only. + +```python +from sap_cloud_sdk.extensibility import create_client + +client = create_client("sap.ai:agent:myAgent:v1") +ext = client.get_extension_capability_implementation( + tenant=tenant_id, +) + +for server in ext.mcp_servers: + print(server.ord_id) + if server.tool_names: + print("Approved tools:", server.tool_names) + +for hook in ext.hooks: + print(hook.id, hook.n8n_workflow_config.workflow_id) + +if ext.instruction: + print("Extension instruction:", ext.instruction) +``` + +Reuse the same client instance for multiple calls (e.g. per request lifecycle) to avoid rebuilding the destination-backed transport each time. + +### Declare Extension Capabilities for A2A Discovery + +Define what parts of the agent are extensible and serialize them into the agent's A2A card: + +```python +from sap_cloud_sdk.extensibility import ( + ExtensionCapability, + build_extension_capabilities, +) + +capabilities = [ + ExtensionCapability( + display_name="Onboarding Workflow", + description="Add tools to the onboarding workflow.", + ), +] +agent_extensions = build_extension_capabilities(capabilities) +# Returns List[AgentExtension] for inclusion in AgentCapabilities.extensions +``` + +## Concepts + +- **Extension Capability** (design-time): A declaration by the agent developer describing what parts of the agent can be extended. Serialized into the A2A card via `build_extension_capabilities()`. This is metadata only -- it carries no runtime data. Currently each agent supports a single extension capability (ID `"default"`). Support for multiple capabilities per agent is planned for a future release. + +- **Extension Capability Implementation** (runtime): The active extension's contribution retrieved from the extensibility service at runtime. Contains MCP servers (with tool filtering) and an optional custom instruction. + +- **MCP Server**: A Model Context Protocol server contributed by an extension. Each server has an ORD ID and an optional allowlist of approved tool names. + +- **Hook**: A workflow to be executed before or after the agent execution. Each hook has a unique UUID `id`, a developer-facing `hook_id` (not guaranteed to be unique), and an `n8n_workflow_config` containing the workflow ID and HTTP method. Hook payloads and responses use the `Message` type from the `a2a.types` module for standardized agent-to-agent communication. + +- **UMS (Unified Metadata Service)**: The SAP backend service that manages agent extensions. The module communicates with it via GraphQL over mTLS, using the BTP Destination Service for URL and credential resolution. + +- **Graceful Degradation**: A core design principle. The extensibility module never prevents agent startup or causes agent failures. Both `create_client()` and `ExtensibilityClient.get_extension_capability_implementation()` handle errors internally: + - If the client cannot be constructed (e.g. missing destination credentials in local development), `create_client()` logs the error and returns a client backed by a no-op transport that always returns empty results. + - If the extensibility service is unavailable or any error occurs during `get_extension_capability_implementation()`, the client logs the error and returns an empty result (no MCP servers, no instruction). + - In both cases, the agent continues operating with its built-in tools. No errors are raised to the caller. + +## API + +### `create_client()` + +Factory that builds an `ExtensibilityClient` with a transport wired to your BTP destination configuration. This function never raises. If the client cannot be constructed (e.g. missing destination credentials in local development), it logs the error and returns a client backed by a no-op transport that always returns empty results. + +```python +def create_client( + agent_ord_id: str, + *, + config: Optional[ExtensibilityConfig] = None, +) -> ExtensibilityClient: ... +``` + +- `agent_ord_id`: ORD ID of the agent (e.g., `"sap.ai:agent:myAgent:v1"`). Required for the backend transport to identify the agent when querying the extensibility service. Ignored when local mode is active. +- `config`: Optional overrides for destination name and instance. Defaults to `ExtensibilityConfig()`. + +### `ExtensibilityClient.get_extension_capability_implementation()` + +Retrieves the active extension's MCP servers and instruction from the extensibility service. + +```python +def get_extension_capability_implementation( + self, + *, + tenant: str, + capability_id: str = "default", + skip_cache: bool = False, +) -> ExtensionCapabilityImplementation: ... +``` + +- `tenant`: Tenant ID for the request. Used to filter extensions in the UMS GraphQL query and sent as the `X-Tenant` HTTP header. Also used as a cache isolation key. Typically extracted from the incoming request's JWT. +- `capability_id`: Extension capability to look up. Defaults to `"default"`, which is the only supported value currently. The parameter exists to support multiple capabilities per agent in a future release. +- `skip_cache`: When `True`, bypasses the transport-level cache and fetches a fresh result from UMS. The fresh result is still written back into the cache. Defaults to `False`. +- Returns an `ExtensionCapabilityImplementation`. On any error during the request, returns an instance with an empty `mcp_servers` list. + +### `ExtensibilityClient.call_hook()` + +Executes a hook endpoint with the provided payload. + +```python +def call_hook( + self, + hook: Hook, + hook_config: HookConfig, +) -> Optional[Message]: ... +``` + +- `hook`: Hook configuration object containing workflow config (`n8n_workflow_config`), timeout, and other settings. +- `hook_config`: Hook invocation configuration (`endpoint`, optional `auth_token`, and optional `payload`). +- Returns the response data as a `Message` object, or `None` if no message is produced. +- Raises `TransportError` if the HTTP request fails or the response cannot be parsed as a valid `Message`. +- The hook's `timeout` setting is used for the HTTP request timeout. +- The hook HTTP method is taken from `hook.n8n_workflow_config.method`. +- The workflow ID is taken from `hook.n8n_workflow_config.workflow_id`. + +#### `N8nWorkflowConfig` + +Workflow configuration embedded in each `Hook`. + +```python +@dataclass +class N8nWorkflowConfig: + workflow_id: str # Workflow ID + method: HTTPMethod # HTTP method used by webhook execution +``` + +#### `HookConfig` + +Runtime invocation config required by `call_hook()`. + +```python +@dataclass +class HookConfig: + endpoint: str # Full URL of the hook MCP endpoint + auth_token: Optional[str] # Bearer token for authentication + payload: Optional[dict] # Optional JSON payload +``` + +### `build_extension_capabilities()` + +Converts extension capability declarations into A2A `AgentExtension` objects. + +```python +def build_extension_capabilities( + extension_capabilities: List[ExtensionCapability], +) -> List[AgentExtension]: ... +``` + +Each capability is mapped to an `AgentExtension` with: + +- `uri`: `urn:sap:extension-capability:v{version}:{id}` +- `description`: from the capability +- `params`: camelCase dict with `capabilityId`, `displayName`, `instructionSupported`, `tools` (serialized `Tools`) and `supportedHooks` (serialized `HookCapability`) +- `required`: always `False` + +Validates inputs and logs warnings for empty lists, duplicate IDs, or empty IDs, but always produces output. + +### Models + +#### `ExtensionCapability` + +Design-time declaration for A2A card serialization. + +```python +@dataclass +class ExtensionCapability: + display_name: str # Human-readable name + description: str # Description of the capability + id: str = "default" # Capability identifier (only "default" currently supported) + tools: Tools = ... # Tool config (default: Tools(additions=ToolAdditions(enabled=True))) + instruction_supported: bool = True # Whether custom instructions are supported + supported_hooks: List[HookCapability] # List of supported hooks +``` + +#### `Tools` + +Tool-related configuration for an extension capability. Groups all tool options; mirrors the wire format. + +```python +@dataclass +class Tools: + additions: ToolAdditions = ... # Tool addition config (default: enabled=True) +``` + +#### `ToolAdditions` + +Configuration for tool additions at an extension capability. + +```python +@dataclass +class ToolAdditions: + enabled: bool = True # Whether tool additions are enabled +``` + +### `HookCapability` + +Configuration for supported hook addition at an extension capability. + +```python +@dataclass +class HookCapability: + + id: str + type: str + display_name: str + description: str +``` + +#### `ExtensionCapabilityImplementation` + +Runtime result returned by `ExtensibilityClient.get_extension_capability_implementation()`. + +```python +@dataclass +class ExtensionCapabilityImplementation: + capability_id: str # e.g. "default" + extension_names: List[str] = [] # Names of contributing extensions + mcp_servers: List[McpServer] = [] # MCP servers from the extension(s) + instruction: Optional[str] = None # Custom instruction text + hooks: List[Hook] = [] # Custom hooks registered as BEFORE or AFTER +``` + +#### `McpServer` + +An MCP server contributed by an extension. + +```python +@dataclass +class McpServer: + ord_id: str # ORD ID, e.g. "sap.mcp:apiResource:serviceNow:v1" + global_tenant_id: str # Global tenant ID of the MCP server + tool_names: Optional[List[str]] = None # Approved tools (None = all approved) +``` + +- `global_tenant_id` is the global tenant ID of the MCP server. +- `tool_names=None` means all tools on this server are approved for use (no filtering needed). +- `tool_names=["create_ticket", "get_ticket"]` means only those tools are approved. + +### `Hook` + +A workflow created as a hook to be executed. + +```python +@dataclass +class Hook: + hook_id: str # Developer-facing hook key (not guaranteed unique) + id: str # Hook ID (UUID) + name: str # Human-readable name + type: HookType # Hook type (BEFORE, AFTER) + deployment_type: DeploymentType # Deployment type (N8N, SERVERLESS) + n8n_workflow_config: N8nWorkflowConfig # Workflow config (workflow ID + HTTP method) + timeout: int # Timeout in seconds + execution_mode: ExecutionMode # Execution mode (SYNC, ASYNC) + on_failure: OnFailure # Failure behavior (CONTINUE, BLOCK) + order: int # Execution order + can_short_circuit: bool # Whether hook can short-circuit execution +``` + +#### Hook Enums + +The `Hook` class uses several enums for type-safe field values: + +**HookType** + +```python +class HookType(Enum): + BEFORE = "BEFORE" # Hook executed before an operation + AFTER = "AFTER" # Hook executed after an operation +``` + +**DeploymentType** + +```python +class DeploymentType(Enum): + N8N = "N8N" # Hook deployed on N8N platform + SERVERLESS = "SERVERLESS" # Hook deployed as Serverless function +``` + +**ExecutionMode** + +```python +class ExecutionMode(Enum): + SYNC = "SYNC" # Synchronous execution - waits for hook to complete + ASYNC = "ASYNC" # Asynchronous execution - does not wait for completion +``` + +**OnFailure** + +```python +class OnFailure(Enum): + CONTINUE = "CONTINUE" # Continue execution despite hook failure + BLOCK = "BLOCK" # Block execution when hook fails +``` + +**HTTPMethod** + +```python +from http import HTTPMethod + +# Standard Python HTTP methods enum with values: +# GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, CONNECT, TRACE +``` + +These enums provide type safety and validation. When parsing from JSON, the `Hook.from_dict()` method validates that enum field values match known types and raises `ValueError` if invalid values are encountered. + +#### `ExtensibilityConfig` + +Optional configuration overrides for the extensibility service connection. + +```python +@dataclass +class ExtensibilityConfig: + destination_name: Optional[str] = None # Optional destination name override + destination_instance: str = "default" # Destination service instance name +``` + +### Constants + +- `DEFAULT_EXTENSION_CAPABILITY_ID = "default"` -- The only supported capability ID currently. Each agent declares a single extension capability. +- `EXTENSION_CAPABILITY_SCHEMA_VERSION = 1` -- Schema version embedded in extension capability URNs. + +## Usage Examples + +### Custom Configuration + +```python +from sap_cloud_sdk.extensibility import create_client, ExtensibilityConfig + +config = ExtensibilityConfig( + destination_name="MY_EXTENSIBILITY_DESTINATION", + destination_instance="staging", +) +client = create_client("sap.ai:agent:myAgent:v1", config=config) +ext = client.get_extension_capability_implementation(tenant=tenant_id) +``` + +### Multiple Capabilities (Future) + +Currently each agent supports a single extension capability with the default ID `"default"`. Support for multiple capabilities per agent is planned for a future release. The `capability_id` parameter and the `id` field on `ExtensionCapability` exist to prepare for this. For now, use the defaults: + +```python +from sap_cloud_sdk.extensibility import ( + ExtensionCapability, + build_extension_capabilities, + create_client, +) + +# Design-time: declare a single capability for A2A card +capabilities = [ + ExtensionCapability( + display_name="Agent Extensions", + description="Extend the agent with additional tools.", + ), +] +agent_extensions = build_extension_capabilities(capabilities) + +# Runtime: retrieve the extension (uses default capability ID) +client = create_client("sap.ai:agent:myAgent:v1") +ext = client.get_extension_capability_implementation(tenant=tenant_id) +``` + +### Using MCP Server Data + +```python +from sap_cloud_sdk.extensibility import create_client + +client = create_client("sap.ai:agent:myAgent:v1") +ext = client.get_extension_capability_implementation(tenant=tenant_id) + +for server in ext.mcp_servers: + # Connect to the MCP server + print(f"Server: {server.ord_id}") + + # Filter tools if an allowlist is specified + if server.tool_names is not None: + print(f"Approved tools: {server.tool_names}") + else: + print("All tools on this server are approved") +``` + +### Using Hooks + +Hooks allow you to execute custom workflows before or after agent operations. The extensibility client provides a `call_hook()` method to invoke hook endpoints. + +```python +from sap_cloud_sdk.extensibility import create_client + +client = create_client("sap.ai:agent:myAgent:v1") +ext = client.get_extension_capability_implementation(tenant=tenant_id) + +for hook in ext.hooks: + print(f"Hook ID: {hook.id}") + print(f"Hook Key: {hook.hook_id}") + print(f"Type: {hook.type}") + print(f"Execution mode: {hook.execution_mode}") + print(f"On failure: {hook.on_failure}") +``` + +### Calling Hook Endpoints + +Use the `call_hook()` method to execute a hook with a custom payload. Payloads use the `Message` type from `a2a.types`. + +```python +from sap_cloud_sdk.extensibility import create_client, HookType +from sap_cloud_sdk.extensibility.config import HookConfig +from a2a.types import Message, Role, TextPart + +client = create_client("sap.ai:agent:myAgent:v1") +ext = client.get_extension_capability_implementation(tenant=tenant_id) + +# Find a specific hook by type +before_hooks = [h for h in ext.hooks if h.type == HookType.BEFORE] + +if before_hooks: + hook = before_hooks[0] + + hook_config = HookConfig( + endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}", + auth_token="my-secret-token", + payload=Message( + message_id="msg-hook-call-001", + role=Role.user, + parts=[TextPart(text="Tool execution starting: create_ticket with priority=high")], + ), + ) + + try: + response = client.call_hook(hook, hook_config) + if response: + print(f"Hook response: {response}") + else: + print("Hook returned no content (204)") + except Exception as e: + print(f"Hook execution failed: {e}") +``` + +### Hook Execution Patterns + +#### HTTP Methods for Hooks + +Hooks support configurable HTTP methods via `hook.n8n_workflow_config.method`. By default, hooks use POST, but can be configured to use GET, PUT, PATCH, or DELETE based on the hook's purpose: + +```python +from sap_cloud_sdk.extensibility import create_client +from sap_cloud_sdk.extensibility.config import HookConfig +from http import HTTPMethod +from a2a.types import Message, Role, TextPart + +client = create_client("sap.ai:agent:myAgent:v1") +ext = client.get_extension_capability_implementation(tenant=tenant_id) + +for hook in ext.hooks: + print(f"Hook {hook.name} uses HTTP {hook.n8n_workflow_config.method}") + + hook_config = HookConfig( + endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}", + auth_token="my-secret-token", + payload=Message( + message_id="msg-hook-payload-001", + role=Role.user, + parts=[TextPart(text="Hook payload")], + ), + ) + + # The client uses hook.n8n_workflow_config.method internally + response = client.call_hook(hook, hook_config) +``` + +The `HTTPMethod` enum ensures type safety: + +- `HTTPMethod.GET` - Retrieve data from the hook endpoint +- `HTTPMethod.POST` - Send data to create or process (default) +- `HTTPMethod.PUT` - Update or replace data +- `HTTPMethod.PATCH` - Partially update data +- `HTTPMethod.DELETE` - Delete or cancel an operation + +The workflow used for execution comes from `hook.n8n_workflow_config.workflow_id`. + +#### Synchronous Hook Execution + +For hooks with `execution_mode=ExecutionMode.SYNC`, the call waits for the hook to complete: + +```python +from sap_cloud_sdk.extensibility import create_client, ExecutionMode +from sap_cloud_sdk.extensibility.config import HookConfig +from a2a.types import Message, Role, TextPart + +client = create_client("sap.ai:agent:myAgent:v1") +ext = client.get_extension_capability_implementation(tenant=tenant_id) + +for hook in ext.hooks: + if hook.execution_mode == ExecutionMode.SYNC: + hook_config = HookConfig( + endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}", + auth_token="my-secret-token", + payload=Message( + message_id="msg-sync-hook-001", + role=Role.user, + parts=[TextPart(text="Processing sync hook")], + ), + ) + response = client.call_hook(hook, hook_config) + # Process response immediately + if response: + print(f"Sync hook completed: {response}") +``` + +#### Handling Hook Failures + +Hooks can be configured with different failure behaviors via the `on_failure` field: + +```python +from sap_cloud_sdk.extensibility import create_client, OnFailure +from sap_cloud_sdk.extensibility.config import HookConfig +from sap_cloud_sdk.extensibility.exceptions import TransportError +from a2a.types import Message, Role, TextPart + +client = create_client("sap.ai:agent:myAgent:v1") +ext = client.get_extension_capability_implementation(tenant=tenant_id) + +for hook in ext.hooks: + hook_config = HookConfig( + endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}", + auth_token="my-secret-token", + payload=Message( + message_id="msg-validate-001", + role=Role.user, + parts=[TextPart(text="Validating operation")], + ), + ) + + try: + response = client.call_hook(hook, hook_config) + if response: + print(f"Hook succeeded: {response}") + except TransportError as e: + if hook.on_failure == OnFailure.BLOCK: + # Hook is configured to block on failure + print(f"Critical hook failed, blocking: {e}") + raise + else: # OnFailure.CONTINUE + # Hook is configured to continue on failure + print(f"Hook failed but continuing: {e}") +``` + +#### Executing Hooks in Order + +Hooks have an `order` field that specifies their execution sequence: + +```python +from sap_cloud_sdk.extensibility import create_client +from sap_cloud_sdk.extensibility.config import HookConfig +from a2a.types import Message, Role, TextPart + +client = create_client("sap.ai:agent:myAgent:v1") +ext = client.get_extension_capability_implementation(tenant=tenant_id) + +# Sort hooks by order +sorted_hooks = sorted(ext.hooks, key=lambda h: h.order) + +for hook in sorted_hooks: + print(f"Executing hook {hook.name} (order: {hook.order})") + hook_config = HookConfig( + endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}", + auth_token="my-secret-token", + payload=Message( + message_id=f"msg-step-{hook.order}", + role=Role.user, + parts=[TextPart(text=f"Step {hook.order}")], + ), + ) + response = client.call_hook(hook, hook_config) + if response: + print(f"Response: {response}") +``` + +#### Short-Circuit Execution + +Some hooks can short-circuit the main execution flow: + +```python +from sap_cloud_sdk.extensibility import create_client, HookType +from sap_cloud_sdk.extensibility.config import HookConfig +from a2a.types import Message, Role, TextPart + +client = create_client("sap.ai:agent:myAgent:v1") +ext = client.get_extension_capability_implementation(tenant=tenant_id) + +for hook in ext.hooks: + if hook.type == HookType.BEFORE: + hook_config = HookConfig( + endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}", + auth_token="my-secret-token", + payload=Message( + message_id="msg-pre-validation-001", + role=Role.user, + parts=[TextPart(text="Pre-validation check")], + ), + ) + response = client.call_hook(hook, hook_config) + + # Check if hook wants to short-circuit + if hook.can_short_circuit and response and response.metadata: + if response.metadata.get("stop_execution"): + reason = response.metadata.get("stop_execution_reason", "Unknown") + print(f"Hook requested short-circuit: {reason}") + break +``` + +### Integrating the Extension Instruction + +```python +from sap_cloud_sdk.extensibility import create_client + +client = create_client("sap.ai:agent:myAgent:v1") +ext = client.get_extension_capability_implementation(tenant=tenant_id) + +system_prompt = "You are a helpful assistant." + +if ext.instruction: + system_prompt += f"\n\nExtension instruction:\n{ext.instruction}" +``` + +## Error Handling + +The extensibility module uses graceful degradation throughout: neither `create_client()` nor `get_extension_capability_implementation()` raise exceptions to the caller. The agent always starts and always gets a usable result. + +### `create_client()` + +On any failure (e.g. missing destination credentials in local development), the function: + +1. Logs the error at `ERROR` level with full traceback (`exc_info=True`) +2. Returns an `ExtensibilityClient` backed by a no-op transport +3. Subsequent calls to `get_extension_capability_implementation()` on this client return empty results immediately + +No `try/except` wrapper is needed around this call. + +### `ExtensibilityClient.get_extension_capability_implementation()` + +On any failure (network error, destination resolution failure, service unavailability), the method: + +1. Logs the error at `ERROR` level with full traceback (`exc_info=True`) +2. Returns an `ExtensionCapabilityImplementation` with an empty `mcp_servers` list and no instruction +3. The agent continues operating with built-in tools only + +No `try/except` wrapper is needed around this call for those failures. + +### `build_extension_capabilities()` + +Validates inputs and logs warnings for: + +- Empty capability lists +- Duplicate capability IDs +- Empty or whitespace-only IDs + +Validation issues produce log warnings but never prevent output generation. + +### Exception Hierarchy + +- `ExtensibilityError` -- Base exception for all extensibility module errors. +- `ClientCreationError(ExtensibilityError)` -- Represents a client construction failure. Not raised by `create_client()` (which handles it internally), but available for use in custom client-construction logic. +- `TransportError(ExtensibilityError)` -- Raised by the transport layer on failure. Not seen when using the client, which catches all transport errors and returns an empty result. + +## Service Binding + +The module resolves the extensibility service URL and credentials through the SAP BTP Destination Service. The destination is looked up at the subaccount level. + +- **Default destination name resolution**: (1) `APPFND_UMS_DESTINATION_NAME` env var, (2) `sap-managed-runtime-ums-{APPFND_CONHOS_LANDSCAPE}`, (3) `EXTENSIBILITY_SERVICE` fallback. +- **Default destination instance**: `default` +- Override via `ExtensibilityConfig(destination_name=...)` when the destination uses a non-standard name. + +## Local Development Mode + +When running locally (without access to the extensibility service), `create_client()` can be backed by a local JSON file instead of the remote backend. No credentials or network access are required. + +### Activation + +Local mode is activated in two ways, checked in order: + +| Priority | Mechanism | How to activate | +|---|---|---| +| 1 | **Environment variable** | Set `CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE` to a file path | +| 2 | **File-presence detection** | Place a file at `mocks/extensibility.json` in the repository root | + +The environment variable takes precedence when both are present. In either case, the JSON file must follow the same schema as the backend response. + +> **WARNING: Local mode is for local development only.** +> The local transport performs no authentication and reads data from a plain JSON file on disk. Never use local mode in a deployed or production environment. A warning is logged at `WARNING` level when file-presence detection is used; an info message is logged when the environment variable is used. + +### Using file-presence detection (recommended) + +Copy the example file to `mocks/extensibility.json`: + +```bash +mkdir -p mocks +cp src/sap_cloud_sdk/extensibility/local_extensibility_example.json mocks/extensibility.json +``` + +Then use `create_client()` as usual -- it will automatically detect the file: + +```python +from sap_cloud_sdk.extensibility import create_client + +client = create_client("sap.ai:agent:myAgent:v1") # Uses mocks/extensibility.json automatically +ext = client.get_extension_capability_implementation(tenant=tenant_id) +``` + +The `mocks/` directory is already in `.gitignore` to prevent accidental commits. + +### Using the environment variable + +For CI pipelines or switching between multiple fixture files: + +```bash +export CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE=path/to/my/extensions.json +``` + +```python +from sap_cloud_sdk.extensibility import create_client + +client = create_client("sap.ai:agent:myAgent:v1") # Uses the file from the environment variable +ext = client.get_extension_capability_implementation(tenant=tenant_id) +``` + +### Mock file format + +The JSON file uses the same schema as the extensibility backend response: + +```json +{ + "capabilityId": "default", + "extensionNames": ["employee-onboarding-tools"], + "instruction": "Use the ServiceNow tools for hardware provisioning tasks during onboarding.", + "mcpServers": [ + { + "ordId": "sap.mcp:apiResource:serviceNow:v1", + "toolNames": ["create_hardware_ticket_tool"] + } + ], + "hooks": [ + { + "hookId": "hook-123", + "id": "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11", + "name": "Currency Conversion", + "type": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": { + "workflowId": "wf-currency-conversion-001", + "method": "POST" + }, + "timeout": 30, + "executionMode": "SYNC", + "onFailure": "CONTINUE", + "order": 0, + "canShortCircuit": false + } + ] +} +``` + +See `src/sap_cloud_sdk/extensibility/local_extensibility_example.json` for a ready-to-use template. + +## ORD Document Integration + +> **Note**: This feature should only be used for the system-instance ORD document. It is not intended for use with other ORD documents. + +The `add_extension_integration_dependencies()` function injects extension capability MCP servers into the ORD (Open Resource Discovery) document at runtime. This enables the agent to dynamically expose MCP servers from the extensibility service in its ORD document for discovery. + +### Import + +```python +from sap_cloud_sdk.extensibility import ( + add_extension_integration_dependencies, + create_client, +) +``` + +### Usage + +```python +from sap_cloud_sdk.extensibility import ( + add_extension_integration_dependencies, + create_client, +) + +# Create an extensibility client +ext_client = create_client("sap.ai:agent:myAgent:v1") + +# Load your ORD document (system-instance) +document = load_ord_document(ORD_SYSTEM_INSTANCE_PATH) + +# Inject extension integration dependencies +add_extension_integration_dependencies( + document=document, + local_tenant_id=local_tenant_id, + ext_client=ext_client, +) +``` + +### Parameters + +- `document`: The ORD document dict. Modified in-place. +- `local_tenant_id`: Optional tenant ID for fetching tenant-specific extensions. +- `ext_client`: The extensibility client. If not provided, no extension dependencies will be added. + +### What It Does + +1. Fetches the extension capability implementation from the extensibility service via the client +2. Checks each MCP server against existing document-level integration dependencies +3. Filters out MCP servers that are already present in base integration dependencies +4. Creates a new IntegrationDependency with aspects for only the new MCP servers +5. Injects the IntegrationDependency at document level (full object) +6. Injects the IntegrationDependency ordId reference at agent level (string array) + +### Duplicate Filtering + +The function checks if an MCP server's ordId already exists in any base integration dependency's aspects. If it exists, that MCP server is skipped to avoid duplication. + +If ALL extended MCP servers are already present in base integration dependencies, no extension integration dependency is created. + +### Example: Full ORD Endpoint Integration + +```python +from sap_cloud_sdk.extensibility import ( + add_extension_integration_dependencies, + create_client, +) +import json +from pathlib import Path + +ORD_SYSTEM_INSTANCE_PATH = Path(__file__).parent / "document-system-instance.json" + + +def load_ord_document(path: Path) -> dict: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +async def ord_document_system_instance(request: Request) -> JSONResponse: + local_tenant_id = request.query_params.get("local-tenant-id", "") + + document = load_ord_document(ORD_SYSTEM_INSTANCE_PATH) + + # Replace tenant placeholder + doc_str = json.dumps(document) + doc_str = doc_str.replace("{{LOCAL_TENANT_ID}}", local_tenant_id) + document = json.loads(doc_str) + + # Create extensibility client + ext_client = create_client("sap.ai:agent:myAgent:v1") + + # Add extension integration dependencies + add_extension_integration_dependencies( + document=document, + local_tenant_id=local_tenant_id, + ext_client=ext_client, + ) + + return JSONResponse(content=document) +``` + +### Important Notes + +- This function modifies the `document` dict in-place. +- If an MCP server is already defined in a base integration dependency, it will not be duplicated in the extension integration dependency. +- If all MCP servers from extensibility are duplicates, the function returns without adding anything. +- On failure (e.g., extensibility service unavailable), the function logs a warning and continues without adding extension dependencies. The agent continues with its base ORD document. +- The `lastUpdate` field is set to the current timestamp to trigger re-aggregation. + +## Notes + +- Create one `ExtensibilityClient` (via `create_client()`) and reuse it for multiple capability lookups where appropriate. +- The `instruction` field in the service response accepts both a plain string (`"Use these tools carefully."`) and a nested object (`{"text": "Use these tools carefully."}`). +- Hook payloads and responses use the `Message` type from `a2a.types` for type-safe, structured communication. This ensures compatibility with the Agent-to-Agent (A2A) protocol. +- OpenTelemetry metrics are recorded automatically for `ExtensibilityClient.get_extension_capability_implementation()` and `ExtensibilityClient.call_hook()` calls. diff --git a/tests/core/unit/telemetry/test_extensions.py b/tests/core/unit/telemetry/test_extensions.py new file mode 100644 index 0000000..5fc6552 --- /dev/null +++ b/tests/core/unit/telemetry/test_extensions.py @@ -0,0 +1,1124 @@ +"""Tests for extension telemetry utilities.""" + +import asyncio +import logging +import pytest +from dataclasses import dataclass +from unittest.mock import AsyncMock, MagicMock, patch + +from sap_cloud_sdk.core.telemetry.extensions import ( + extension_context, + get_extension_context, + ExtensionType, + ATTR_IS_EXTENSION, + ATTR_EXTENSION_TYPE, + ATTR_CAPABILITY_ID, + ATTR_EXTENSION_ID, + ATTR_EXTENSION_NAME, + ATTR_EXTENSION_VERSION, + ATTR_EXTENSION_ITEM_NAME, + ATTR_EXTENSION_URL, + ATTR_SOLUTION_ID, + ATTR_SUMMARY_TOTAL_OPERATION_COUNT, + ATTR_SUMMARY_TOTAL_DURATION_MS, + ATTR_SUMMARY_TOOL_CALL_COUNT, + ATTR_SUMMARY_HOOK_CALL_COUNT, + ATTR_SUMMARY_HAS_INSTRUCTION, + resolve_source_info, + build_extension_span_attributes, + reset_tool_call_metrics, + get_tool_call_metrics, + record_tool_call_duration, + reset_hook_call_metrics, + get_hook_call_metrics, + record_hook_call_duration, + call_extension_tool, + call_extension_hook, + emit_extensions_summary_span, + ExtensionContextLogFilter, +) + + +class TestExtensionType: + """Test suite for ExtensionType enum.""" + + def test_extension_type_values(self): + """Test ExtensionType enum has correct values.""" + assert ExtensionType.TOOL.value == "tool" + assert ExtensionType.INSTRUCTION.value == "instruction" + assert ExtensionType.HOOK.value == "hook" + + def test_extension_type_is_string_enum(self): + """Test ExtensionType is a string enum.""" + assert isinstance(ExtensionType.TOOL, str) + assert ExtensionType.TOOL == "tool" + + def test_extension_type_all_values(self): + """Test all ExtensionType values are accessible.""" + all_types = list(ExtensionType) + assert len(all_types) == 3 + assert ExtensionType.TOOL in all_types + assert ExtensionType.INSTRUCTION in all_types + assert ExtensionType.HOOK in all_types + + +class TestAttributeKeys: + """Test suite for attribute/baggage key constants.""" + + def test_attribute_keys_are_strings(self): + """Test attribute keys are strings.""" + assert isinstance(ATTR_IS_EXTENSION, str) + assert isinstance(ATTR_EXTENSION_TYPE, str) + assert isinstance(ATTR_CAPABILITY_ID, str) + assert isinstance(ATTR_EXTENSION_ID, str) + assert isinstance(ATTR_EXTENSION_NAME, str) + assert isinstance(ATTR_EXTENSION_VERSION, str) + assert isinstance(ATTR_EXTENSION_ITEM_NAME, str) + + def test_attribute_keys_have_sap_extension_prefix(self): + """Test attribute keys have sap.extension. prefix.""" + assert ATTR_IS_EXTENSION.startswith("sap.extension.") + assert ATTR_EXTENSION_TYPE.startswith("sap.extension.") + assert ATTR_CAPABILITY_ID.startswith("sap.extension.") + assert ATTR_EXTENSION_ID.startswith("sap.extension.") + assert ATTR_EXTENSION_NAME.startswith("sap.extension.") + assert ATTR_EXTENSION_VERSION.startswith("sap.extension.") + assert ATTR_EXTENSION_ITEM_NAME.startswith("sap.extension.") + + def test_attribute_keys_values(self): + """Test attribute keys have expected values.""" + assert ATTR_IS_EXTENSION == "sap.extension.isExtension" + assert ATTR_EXTENSION_TYPE == "sap.extension.extensionType" + assert ATTR_CAPABILITY_ID == "sap.extension.capabilityId" + assert ATTR_EXTENSION_ID == "sap.extension.extensionId" + assert ATTR_EXTENSION_NAME == "sap.extension.extensionName" + assert ATTR_EXTENSION_VERSION == "sap.extension.extensionVersion" + assert ATTR_EXTENSION_ITEM_NAME == "sap.extension.extension.item.name" + + +class TestExtensionContext: + """Test suite for extension_context function.""" + + def test_extension_context_sets_all_baggage(self): + """Test extension_context sets all baggage values including url and solution_id.""" + captured_baggage = {} + + def mock_set_baggage(key, value, context=None): + captured_baggage[key] = value + return context or MagicMock() + + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage") as mock_baggage: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.get_current" + ) as mock_get_current: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.attach" + ) as mock_attach: + with patch("sap_cloud_sdk.core.telemetry.extensions.detach"): + mock_get_current.return_value = MagicMock() + mock_baggage.set_baggage = mock_set_baggage + mock_attach.return_value = "token" + + with extension_context( + "default", + "ServiceNow Extension", + ExtensionType.TOOL, + extension_id="uuid-123", + extension_version="3", + item_name="create_ticket", + extension_url="https://ext.example.com", + solution_id="sol-789", + ): + pass + + assert captured_baggage[ATTR_IS_EXTENSION] == "true" + assert captured_baggage[ATTR_EXTENSION_TYPE] == "tool" + assert captured_baggage[ATTR_CAPABILITY_ID] == "default" + assert captured_baggage[ATTR_EXTENSION_ID] == "uuid-123" + assert captured_baggage[ATTR_EXTENSION_NAME] == "ServiceNow Extension" + assert captured_baggage[ATTR_EXTENSION_VERSION] == "3" + assert captured_baggage[ATTR_EXTENSION_ITEM_NAME] == "create_ticket" + assert captured_baggage[ATTR_EXTENSION_URL] == "https://ext.example.com" + assert captured_baggage[ATTR_SOLUTION_ID] == "sol-789" + + def test_extension_context_defaults_for_new_params(self): + """Test extension_context uses defaults when new params are omitted.""" + captured_baggage = {} + + def mock_set_baggage(key, value, context=None): + captured_baggage[key] = value + return context or MagicMock() + + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage") as mock_baggage: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.get_current" + ) as mock_get_current: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.attach" + ) as mock_attach: + with patch("sap_cloud_sdk.core.telemetry.extensions.detach"): + mock_get_current.return_value = MagicMock() + mock_baggage.set_baggage = mock_set_baggage + mock_attach.return_value = "token" + + with extension_context( + "default", + "ServiceNow Extension", + ExtensionType.TOOL, + ): + pass + + assert captured_baggage[ATTR_IS_EXTENSION] == "true" + assert captured_baggage[ATTR_EXTENSION_TYPE] == "tool" + assert captured_baggage[ATTR_CAPABILITY_ID] == "default" + assert captured_baggage[ATTR_EXTENSION_ID] == "" + assert captured_baggage[ATTR_EXTENSION_NAME] == "ServiceNow Extension" + assert captured_baggage[ATTR_EXTENSION_VERSION] == "" + assert captured_baggage[ATTR_EXTENSION_ITEM_NAME] == "" + assert ATTR_EXTENSION_URL not in captured_baggage + assert ATTR_SOLUTION_ID not in captured_baggage + + def test_extension_context_hook_type(self): + """Test extension_context with hook extension type.""" + captured_baggage = {} + + def mock_set_baggage(key, value, context=None): + captured_baggage[key] = value + return context or MagicMock() + + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage") as mock_baggage: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.get_current" + ) as mock_get_current: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.attach" + ) as mock_attach: + with patch("sap_cloud_sdk.core.telemetry.extensions.detach"): + mock_get_current.return_value = MagicMock() + mock_baggage.set_baggage = mock_set_baggage + mock_attach.return_value = "token" + + with extension_context( + "default", + "ap-invoice-extension", + ExtensionType.HOOK, + extension_id="uuid-hook", + extension_version="2", + item_name="Pre Invoice Hook", + ): + pass + + assert captured_baggage[ATTR_EXTENSION_TYPE] == "hook" + assert captured_baggage[ATTR_EXTENSION_NAME] == "ap-invoice-extension" + assert captured_baggage[ATTR_EXTENSION_ITEM_NAME] == "Pre Invoice Hook" + + def test_extension_context_attaches_and_detaches(self): + """Test extension_context attaches and detaches context.""" + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage") as mock_baggage: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.get_current" + ) as mock_get_current: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.attach" + ) as mock_attach: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.detach" + ) as mock_detach: + mock_ctx = MagicMock() + mock_get_current.return_value = mock_ctx + mock_baggage.set_baggage.return_value = mock_ctx + mock_attach.return_value = "test_token" + + with extension_context("cap", "ext_name", ExtensionType.TOOL): + mock_attach.assert_called_once() + + mock_detach.assert_called_once_with("test_token") + + def test_extension_context_detaches_on_exception(self): + """Test extension_context detaches even when exception occurs.""" + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage") as mock_baggage: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.get_current" + ) as mock_get_current: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.attach" + ) as mock_attach: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.detach" + ) as mock_detach: + mock_ctx = MagicMock() + mock_get_current.return_value = mock_ctx + mock_baggage.set_baggage.return_value = mock_ctx + mock_attach.return_value = "test_token" + + with pytest.raises(ValueError, match="Test error"): + with extension_context( + "cap", "ext_name", ExtensionType.TOOL + ): + raise ValueError("Test error") + + mock_detach.assert_called_once_with("test_token") + + def test_extension_context_propagates_exception(self): + """Test extension_context propagates exceptions.""" + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage"): + with patch("sap_cloud_sdk.core.telemetry.extensions.get_current"): + with patch("sap_cloud_sdk.core.telemetry.extensions.attach"): + with patch("sap_cloud_sdk.core.telemetry.extensions.detach"): + with pytest.raises(RuntimeError, match="Test"): + with extension_context( + "cap", "ext_name", ExtensionType.TOOL + ): + raise RuntimeError("Test") + + def test_extension_context_with_different_types(self): + """Test extension_context with different extension types.""" + for extension_type in ExtensionType: + captured_type = None + + def mock_set_baggage(key, value, context=None): + nonlocal captured_type + if key == ATTR_EXTENSION_TYPE: + captured_type = value + return context or MagicMock() + + with patch( + "sap_cloud_sdk.core.telemetry.extensions.baggage" + ) as mock_baggage: + with patch("sap_cloud_sdk.core.telemetry.extensions.get_current"): + with patch("sap_cloud_sdk.core.telemetry.extensions.attach"): + with patch("sap_cloud_sdk.core.telemetry.extensions.detach"): + mock_baggage.set_baggage = mock_set_baggage + + with extension_context("cap", "ext_name", extension_type): + pass + + assert captured_type == extension_type.value + + def test_extension_context_with_various_extension_names(self): + """Test extension_context with various extension name formats.""" + test_names = [ + "ServiceNow Extension", + "Jira Integration", + "Custom HR Tool", + "simple-name", + ] + + for ext_name in test_names: + captured_name = None + + def mock_set_baggage(key, value, context=None): + nonlocal captured_name + if key == ATTR_EXTENSION_NAME: + captured_name = value + return context or MagicMock() + + with patch( + "sap_cloud_sdk.core.telemetry.extensions.baggage" + ) as mock_baggage: + with patch("sap_cloud_sdk.core.telemetry.extensions.get_current"): + with patch("sap_cloud_sdk.core.telemetry.extensions.attach"): + with patch("sap_cloud_sdk.core.telemetry.extensions.detach"): + mock_baggage.set_baggage = mock_set_baggage + + with extension_context("cap", ext_name, ExtensionType.TOOL): + pass + + assert captured_name == ext_name + + def test_extension_context_with_various_capability_ids(self): + """Test extension_context with various capability IDs.""" + test_capability_ids = [ + "default", + "hr-management", + "custom.capability.v2", + ] + + for capability_id in test_capability_ids: + captured_cap = None + + def mock_set_baggage(key, value, context=None): + nonlocal captured_cap + if key == ATTR_CAPABILITY_ID: + captured_cap = value + return context or MagicMock() + + with patch( + "sap_cloud_sdk.core.telemetry.extensions.baggage" + ) as mock_baggage: + with patch("sap_cloud_sdk.core.telemetry.extensions.get_current"): + with patch("sap_cloud_sdk.core.telemetry.extensions.attach"): + with patch("sap_cloud_sdk.core.telemetry.extensions.detach"): + mock_baggage.set_baggage = mock_set_baggage + + with extension_context( + capability_id, "ext_name", ExtensionType.TOOL + ): + pass + + assert captured_cap == capability_id + + def test_extension_context_nested(self): + """Test nested extension_context calls.""" + attach_calls = [] + detach_calls = [] + + def mock_attach(ctx): + token = f"token_{len(attach_calls)}" + attach_calls.append(token) + return token + + def mock_detach(token): + detach_calls.append(token) + + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage") as mock_baggage: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.get_current" + ) as mock_get_current: + with patch( + "sap_cloud_sdk.core.telemetry.extensions.attach", + side_effect=mock_attach, + ): + with patch( + "sap_cloud_sdk.core.telemetry.extensions.detach", + side_effect=mock_detach, + ): + mock_ctx = MagicMock() + mock_get_current.return_value = mock_ctx + mock_baggage.set_baggage.return_value = mock_ctx + + with extension_context( + "outer_cap", + "outer_ext", + ExtensionType.INSTRUCTION, + ): + with extension_context( + "inner_cap", "inner_ext", ExtensionType.TOOL + ): + pass + + assert len(attach_calls) == 2 + assert len(detach_calls) == 2 + # Detach should be in reverse order (LIFO) + assert detach_calls == ["token_1", "token_0"] + + +class TestGetExtensionContext: + """Test suite for get_extension_context function.""" + + def test_returns_none_when_not_in_context(self): + """Test returns None when not in extension context.""" + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage") as mock_baggage: + mock_baggage.get_baggage.return_value = None + result = get_extension_context() + assert result is None + + def test_returns_none_when_is_extension_not_true(self): + """Test returns None when isExtension is not 'true'.""" + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage") as mock_baggage: + mock_baggage.get_baggage.return_value = "false" + result = get_extension_context() + assert result is None + + def test_returns_dict_when_in_context(self): + """Test returns dict with all extension metadata.""" + + def mock_get_baggage(key): + values = { + ATTR_IS_EXTENSION: "true", + ATTR_EXTENSION_TYPE: "tool", + ATTR_CAPABILITY_ID: "default", + ATTR_EXTENSION_ID: "uuid-123", + ATTR_EXTENSION_NAME: "ServiceNow Extension", + ATTR_EXTENSION_VERSION: "3", + ATTR_EXTENSION_ITEM_NAME: "create_ticket", + ATTR_EXTENSION_URL: "https://ext.example.com", + ATTR_SOLUTION_ID: "sol-789", + } + return values.get(key) + + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage") as mock_baggage: + mock_baggage.get_baggage = mock_get_baggage + result = get_extension_context() + + assert result is not None + assert result["is_extension"] is True + assert result["extension_type"] == "tool" + assert result["capability_id"] == "default" + assert result["extension_id"] == "uuid-123" + assert result["extension_name"] == "ServiceNow Extension" + assert result["extension_version"] == "3" + assert result["item_name"] == "create_ticket" + assert result["extension_url"] == "https://ext.example.com" + assert result["solution_id"] == "sol-789" + + def test_with_different_extension_types(self): + """Test get_extension_context with different extension types.""" + for extension_type in ExtensionType: + + def mock_get_baggage(key, et_val=extension_type.value): + values = { + ATTR_IS_EXTENSION: "true", + ATTR_EXTENSION_TYPE: et_val, + ATTR_CAPABILITY_ID: "cap", + ATTR_EXTENSION_ID: "uuid", + ATTR_EXTENSION_NAME: "ext_name", + ATTR_EXTENSION_VERSION: "1", + ATTR_EXTENSION_ITEM_NAME: "item", + } + return values.get(key) + + with patch( + "sap_cloud_sdk.core.telemetry.extensions.baggage" + ) as mock_baggage: + mock_baggage.get_baggage = mock_get_baggage + result = get_extension_context() + assert result is not None + assert result["extension_type"] == extension_type.value + + def test_with_none_extension_name(self): + """Test get_extension_context when extension_name is None.""" + + def mock_get_baggage(key): + values = { + ATTR_IS_EXTENSION: "true", + ATTR_EXTENSION_TYPE: "tool", + ATTR_CAPABILITY_ID: "default", + ATTR_EXTENSION_ID: "uuid", + ATTR_EXTENSION_NAME: None, + ATTR_EXTENSION_VERSION: "", + ATTR_EXTENSION_ITEM_NAME: "", + } + return values.get(key) + + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage") as mock_baggage: + mock_baggage.get_baggage = mock_get_baggage + result = get_extension_context() + + assert result is not None + assert result["is_extension"] is True + assert result["extension_name"] is None + assert result["extension_type"] == "tool" + + def test_with_none_extension_type(self): + """Test get_extension_context when extension_type is None.""" + + def mock_get_baggage(key): + values = { + ATTR_IS_EXTENSION: "true", + ATTR_EXTENSION_TYPE: None, + ATTR_CAPABILITY_ID: "default", + ATTR_EXTENSION_ID: "uuid", + ATTR_EXTENSION_NAME: "ext_name", + ATTR_EXTENSION_VERSION: "1", + ATTR_EXTENSION_ITEM_NAME: "item", + } + return values.get(key) + + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage") as mock_baggage: + mock_baggage.get_baggage = mock_get_baggage + result = get_extension_context() + + assert result is not None + assert result["is_extension"] is True + assert result["extension_type"] is None + assert result["extension_name"] == "ext_name" + + def test_with_none_capability_id(self): + """Test get_extension_context when capability_id is None.""" + + def mock_get_baggage(key): + values = { + ATTR_IS_EXTENSION: "true", + ATTR_EXTENSION_TYPE: "tool", + ATTR_CAPABILITY_ID: None, + ATTR_EXTENSION_ID: "uuid", + ATTR_EXTENSION_NAME: "ext_name", + ATTR_EXTENSION_VERSION: "1", + ATTR_EXTENSION_ITEM_NAME: "item", + } + return values.get(key) + + with patch("sap_cloud_sdk.core.telemetry.extensions.baggage") as mock_baggage: + mock_baggage.get_baggage = mock_get_baggage + result = get_extension_context() + + assert result is not None + assert result["is_extension"] is True + assert result["capability_id"] is None + + +class TestExtensionContextIntegration: + """Integration tests using real OTel baggage (no mocks).""" + + def test_extension_context_and_get_work_together(self): + """Test that extension_context sets values get_extension_context reads.""" + result_before = get_extension_context() + assert result_before is None + + with extension_context( + "default", + "ServiceNow Extension", + ExtensionType.TOOL, + extension_id="uuid-sn", + extension_version="2", + item_name="create_ticket", + extension_url="https://ext.example.com", + solution_id="sol-789", + ): + result_during = get_extension_context() + assert result_during is not None + assert result_during["is_extension"] is True + assert result_during["capability_id"] == "default" + assert result_during["extension_name"] == "ServiceNow Extension" + assert result_during["extension_type"] == "tool" + assert result_during["extension_id"] == "uuid-sn" + assert result_during["extension_version"] == "2" + assert result_during["item_name"] == "create_ticket" + assert result_during["extension_url"] == "https://ext.example.com" + assert result_during["solution_id"] == "sol-789" + + result_after = get_extension_context() + assert result_after is None + + def test_nested_extension_contexts(self): + """Test nested extension contexts with real OTel baggage.""" + with extension_context( + "outer_cap", + "outer_ext", + ExtensionType.INSTRUCTION, + extension_id="uuid-outer", + extension_version="1", + item_name="outer_item", + ): + outer = get_extension_context() + assert outer is not None + assert outer["capability_id"] == "outer_cap" + assert outer["extension_name"] == "outer_ext" + assert outer["extension_type"] == "instruction" + assert outer["extension_id"] == "uuid-outer" + assert outer["extension_version"] == "1" + assert outer["item_name"] == "outer_item" + + with extension_context( + "inner_cap", + "inner_ext", + ExtensionType.TOOL, + extension_id="uuid-inner", + extension_version="5", + item_name="inner_tool", + ): + inner = get_extension_context() + assert inner is not None + assert inner["capability_id"] == "inner_cap" + assert inner["extension_name"] == "inner_ext" + assert inner["extension_type"] == "tool" + assert inner["extension_id"] == "uuid-inner" + assert inner["extension_version"] == "5" + assert inner["item_name"] == "inner_tool" + + after_inner = get_extension_context() + assert after_inner is not None + assert after_inner["capability_id"] == "outer_cap" + assert after_inner["extension_name"] == "outer_ext" + assert after_inner["extension_type"] == "instruction" + + final = get_extension_context() + assert final is None + + def test_extension_context_defaults_integration(self): + """Test extension_context with only required params using real OTel.""" + with extension_context("default", "my-ext", ExtensionType.HOOK): + result = get_extension_context() + assert result is not None + assert result["is_extension"] is True + assert result["extension_type"] == "hook" + assert result["capability_id"] == "default" + assert result["extension_name"] == "my-ext" + assert result["extension_id"] == "" + assert result["extension_version"] == "" + assert result["item_name"] == "" + assert result["extension_url"] == "" + assert result["solution_id"] == "" + + +# --------------------------------------------------------------------------- +# resolve_source_info +# --------------------------------------------------------------------------- + + +class TestResolveSourceInfo: + """Tests for resolve_source_info.""" + + def test_none_mapping_returns_fallback(self): + name, ext_id, ver, url, sid = resolve_source_info("key", None, "fallback") + assert name == "fallback" + assert ext_id == "" + assert ver == "" + assert url == "" + assert sid == "" + + def test_missing_key_returns_fallback(self): + name, ext_id, ver, url, sid = resolve_source_info( + "missing", {"other": {}}, "fb" + ) + assert name == "fb" + + def test_empty_fallback_returns_unknown(self): + name, _, _, _, _ = resolve_source_info("missing", None, "") + assert name == "unknown" + + def test_dataclass_source_info(self): + @dataclass + class FakeSourceInfo: + extension_name: str + extension_id: str + extension_version: str + + info = FakeSourceInfo("My Ext", "uuid-1", "3") + name, ext_id, ver, url, sid = resolve_source_info("k", {"k": info}, "fb") + assert name == "My Ext" + assert ext_id == "uuid-1" + assert ver == "3" + assert url == "" + assert sid == "" + + def test_dataclass_empty_name_uses_fallback(self): + @dataclass + class FakeSourceInfo: + extension_name: str + extension_id: str + extension_version: str + + info = FakeSourceInfo("", "uuid-1", "3") + name, _, _, _, _ = resolve_source_info("k", {"k": info}, "fb") + assert name == "fb" + + def test_dict_source_info(self): + info = { + "extensionName": "Dict Ext", + "extensionId": "uuid-2", + "extensionVersion": "5", + } + name, ext_id, ver, url, sid = resolve_source_info("k", {"k": info}, "fb") + assert name == "Dict Ext" + assert ext_id == "uuid-2" + assert ver == "5" + assert url == "" + assert sid == "" + + def test_dict_empty_name_uses_fallback(self): + info = {"extensionName": "", "extensionId": "x", "extensionVersion": "1"} + name, _, _, _, _ = resolve_source_info("k", {"k": info}, "fb") + assert name == "fb" + + def test_unknown_type_returns_fallback(self): + name, ext_id, ver, url, sid = resolve_source_info("k", {"k": 42}, "fb") + assert name == "fb" + assert ext_id == "" + assert ver == "" + assert url == "" + assert sid == "" + + def test_dataclass_none_version(self): + @dataclass + class FakeSourceInfo: + extension_name: str + extension_id: str + extension_version: str | None + + info = FakeSourceInfo("Ext", "id", None) + _, _, ver, _, _ = resolve_source_info("k", {"k": info}, "fb") + assert ver == "" + + def test_dataclass_with_url_and_solution_id(self): + @dataclass + class FakeSourceInfo: + extension_name: str + extension_id: str + extension_version: str + extension_url: str + solution_id: str + + info = FakeSourceInfo("Ext", "id", "1", "https://url", "sol-123") + _, _, _, url, sid = resolve_source_info("k", {"k": info}, "fb") + assert url == "https://url" + assert sid == "sol-123" + + def test_dict_with_url_and_solution_id(self): + info = { + "extensionName": "Ext", + "extensionId": "id", + "extensionVersion": "1", + "extensionUrl": "https://url", + "solutionId": "sol-456", + } + _, _, _, url, sid = resolve_source_info("k", {"k": info}, "fb") + assert url == "https://url" + assert sid == "sol-456" + + +# --------------------------------------------------------------------------- +# build_extension_span_attributes +# --------------------------------------------------------------------------- + + +class TestBuildExtensionSpanAttributes: + """Tests for build_extension_span_attributes.""" + + def test_returns_all_seven_keys(self): + attrs = build_extension_span_attributes( + "Ext", "id-1", "2", ExtensionType.TOOL, "default", "my_tool" + ) + assert attrs[ATTR_IS_EXTENSION] is True + assert attrs[ATTR_EXTENSION_TYPE] == "tool" + assert attrs[ATTR_CAPABILITY_ID] == "default" + assert attrs[ATTR_EXTENSION_ID] == "id-1" + assert attrs[ATTR_EXTENSION_NAME] == "Ext" + assert attrs[ATTR_EXTENSION_VERSION] == "2" + assert attrs[ATTR_EXTENSION_ITEM_NAME] == "my_tool" + + def test_hook_type(self): + attrs = build_extension_span_attributes( + "Ext", "", "", ExtensionType.HOOK, "cap", "hook_name" + ) + assert attrs[ATTR_EXTENSION_TYPE] == "hook" + + def test_includes_solution_id_when_provided(self): + attrs = build_extension_span_attributes( + "Ext", + "id", + "1", + ExtensionType.TOOL, + "default", + "tool", + extension_url="https://url", + solution_id="sol-123", + ) + assert attrs["sap.extension.solution_id"] == "sol-123" + assert attrs["sap.extension.extensionUrl"] == "https://url" + + def test_omits_solution_id_when_empty(self): + attrs = build_extension_span_attributes( + "Ext", + "id", + "1", + ExtensionType.TOOL, + "default", + "tool", + ) + assert "sap.extension.solution_id" not in attrs + assert "sap.extension.extensionUrl" not in attrs + + +# --------------------------------------------------------------------------- +# Tool call metrics +# --------------------------------------------------------------------------- + + +class TestToolCallMetrics: + """Tests for tool call duration tracking.""" + + def test_no_reset_returns_zero(self): + # Use a fresh context by not calling reset + count, duration = get_tool_call_metrics() + # May or may not be zero depending on prior test state, but shouldn't crash + assert isinstance(count, int) + assert isinstance(duration, (int, float)) + + def test_reset_and_record(self): + reset_tool_call_metrics() + record_tool_call_duration(0.1) + record_tool_call_duration(0.2) + count, total_ms = get_tool_call_metrics() + assert count == 2 + assert abs(total_ms - 300.0) < 1.0 + + def test_record_noop_without_reset(self): + """record_tool_call_duration silently no-ops if never reset.""" + from sap_cloud_sdk.core.telemetry.extensions import _tool_call_durations + + # Remove the ContextVar value to simulate never-reset state + tok = _tool_call_durations.set([]) + _tool_call_durations.reset(tok) + # Should not raise + record_tool_call_duration(1.0) + + +# --------------------------------------------------------------------------- +# Hook call metrics +# --------------------------------------------------------------------------- + + +class TestHookCallMetrics: + """Tests for hook call duration tracking.""" + + def test_reset_and_record(self): + reset_hook_call_metrics() + record_hook_call_duration(0.05) + count, total_ms = get_hook_call_metrics() + assert count == 1 + assert abs(total_ms - 50.0) < 1.0 + + def test_record_noop_without_reset(self): + from sap_cloud_sdk.core.telemetry.extensions import _hook_call_durations + + tok = _hook_call_durations.set([]) + _hook_call_durations.reset(tok) + record_hook_call_duration(1.0) + + def test_independent_from_tool_metrics(self): + """Hook and tool accumulators do not interfere with each other.""" + reset_tool_call_metrics() + reset_hook_call_metrics() + record_tool_call_duration(0.1) + record_tool_call_duration(0.2) + record_hook_call_duration(0.05) + + tool_count, tool_ms = get_tool_call_metrics() + hook_count, hook_ms = get_hook_call_metrics() + + assert tool_count == 2 + assert hook_count == 1 + assert abs(tool_ms - 300.0) < 1.0 + assert abs(hook_ms - 50.0) < 1.0 + + +# --------------------------------------------------------------------------- +# call_extension_tool +# --------------------------------------------------------------------------- + + +class TestCallExtensionTool: + """Tests for call_extension_tool.""" + + def test_calls_mcp_client(self): + async def _run(): + mock_client = AsyncMock() + mock_client.call_tool.return_value = "result-123" + + reset_tool_call_metrics() + result = await call_extension_tool( + mcp_client=mock_client, + tool_name="create_ticket", + args={"title": "Bug"}, + extension_name="ServiceNow", + ) + assert result == "result-123" + mock_client.call_tool.assert_awaited_once_with( + "create_ticket", {"title": "Bug"} + ) + count, _ = get_tool_call_metrics() + assert count == 1 + + asyncio.run(_run()) + + def test_uses_source_mapping(self): + async def _run(): + @dataclass + class FakeSourceInfo: + extension_name: str + extension_id: str + extension_version: str + + mapping = {"prefix_tool1": FakeSourceInfo("Mapped Ext", "uuid-m", "7")} + mock_client = AsyncMock() + mock_client.call_tool.return_value = "ok" + + reset_tool_call_metrics() + result = await call_extension_tool( + mcp_client=mock_client, + tool_name="tool1", + args={}, + extension_name="Fallback", + source_mapping=mapping, + tool_prefix="prefix_", + ) + assert result == "ok" + + asyncio.run(_run()) + + def test_records_duration_on_error(self): + async def _run(): + mock_client = AsyncMock() + mock_client.call_tool.side_effect = RuntimeError("fail") + + reset_tool_call_metrics() + with pytest.raises(RuntimeError, match="fail"): + await call_extension_tool( + mcp_client=mock_client, + tool_name="t", + args={}, + extension_name="E", + ) + count, _ = get_tool_call_metrics() + assert count == 1 # Duration still recorded + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- +# call_extension_hook +# --------------------------------------------------------------------------- + + +class TestCallExtensionHook: + """Tests for call_extension_hook.""" + + def test_calls_extensibility_client(self): + async def _run(): + mock_client = AsyncMock() + mock_client.call_hook.return_value = {"status": "ok"} + mock_hook = MagicMock() + mock_hook.name = "pre_process" + + reset_hook_call_metrics() + result = await call_extension_hook( + extensibility_client=mock_client, + hook=mock_hook, + payload={"data": 1}, + extension_name="My Ext", + ) + assert result == {"status": "ok"} + mock_client.call_hook.assert_awaited_once_with(mock_hook, {"data": 1}) + count, _ = get_hook_call_metrics() + assert count == 1 + + asyncio.run(_run()) + + def test_hook_without_name_uses_hook_id(self): + async def _run(): + mock_client = AsyncMock() + mock_client.call_hook.return_value = None + mock_hook = object() # No .name attribute + + reset_hook_call_metrics() + await call_extension_hook( + extensibility_client=mock_client, + hook=mock_hook, + payload={}, + extension_name="Ext", + hook_id="ord:hook:1", + ) + count, _ = get_hook_call_metrics() + assert count == 1 + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- +# emit_extensions_summary_span +# --------------------------------------------------------------------------- + + +class TestEmitExtensionsSummarySpan: + """Tests for emit_extensions_summary_span.""" + + def test_creates_span_with_attributes(self): + with patch("sap_cloud_sdk.core.telemetry.extensions._tracer") as mock_tracer: + mock_span = MagicMock() + mock_tracer.start_span.return_value = mock_span + + emit_extensions_summary_span( + tool_call_count=3, + hook_call_count=2, + has_instruction=True, + total_duration_ms=1500.0, + ) + + mock_tracer.start_span.assert_called_once() + call_args = mock_tracer.start_span.call_args + assert call_args[0][0] == "agent_extensions_summary" + attrs = call_args[1]["attributes"] + assert attrs[ATTR_SUMMARY_TOTAL_OPERATION_COUNT] == 6 # 3+2+1 + assert attrs[ATTR_SUMMARY_TOTAL_DURATION_MS] == 1500.0 + assert attrs[ATTR_SUMMARY_TOOL_CALL_COUNT] == 3 + assert attrs[ATTR_SUMMARY_HOOK_CALL_COUNT] == 2 + assert attrs[ATTR_SUMMARY_HAS_INSTRUCTION] is True + mock_span.end.assert_called_once() + + def test_no_instruction_count(self): + with patch("sap_cloud_sdk.core.telemetry.extensions._tracer") as mock_tracer: + mock_span = MagicMock() + mock_tracer.start_span.return_value = mock_span + + emit_extensions_summary_span( + tool_call_count=1, + hook_call_count=0, + has_instruction=False, + total_duration_ms=100.0, + ) + + attrs = mock_tracer.start_span.call_args[1]["attributes"] + assert attrs[ATTR_SUMMARY_TOTAL_OPERATION_COUNT] == 1 # no +1 + + +# --------------------------------------------------------------------------- +# ExtensionContextLogFilter +# --------------------------------------------------------------------------- + + +class TestExtensionContextLogFilter: + """Tests for ExtensionContextLogFilter.""" + + def test_adds_attributes_in_extension_context(self): + filt = ExtensionContextLogFilter() + record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None) + + with extension_context( + "cap", + "ext", + ExtensionType.TOOL, + extension_id="id1", + extension_version="2", + item_name="tool1", + extension_url="https://ext.example.com", + solution_id="sol-42", + ): + result = filt.filter(record) + + assert result is True + assert getattr(record, "ext_is_extension") == "true" + assert getattr(record, "ext_extension_type") == "tool" + assert getattr(record, "ext_capability_id") == "cap" + assert getattr(record, "ext_extension_name") == "ext" + assert getattr(record, "ext_extension_id") == "id1" + assert getattr(record, "ext_extension_version") == "2" + assert getattr(record, "ext_item_name") == "tool1" + assert getattr(record, "ext_extension_url") == "https://ext.example.com" + assert getattr(record, "ext_solution_id") == "sol-42" + + def test_no_attributes_outside_context(self): + filt = ExtensionContextLogFilter() + record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None) + result = filt.filter(record) + assert result is True + assert not hasattr(record, "ext_is_extension") + + def test_always_returns_true(self): + """Filter never suppresses log records, even outside extension context.""" + filt = ExtensionContextLogFilter() + for level in (logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR): + record = logging.LogRecord("t", level, "", 0, "m", (), None) + assert filt.filter(record) is True + + def test_empty_values_set_as_empty_string(self): + """When baggage values are empty, attributes are set to empty string.""" + filt = ExtensionContextLogFilter() + record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None) + + with extension_context( + "cap", + "ext", + ExtensionType.TOOL, + extension_id="", + extension_version="", + item_name="", + ): + filt.filter(record) + + assert getattr(record, "ext_extension_id") == "" + assert getattr(record, "ext_extension_version") == "" + assert getattr(record, "ext_item_name") == "" + assert getattr(record, "ext_extension_url") == "" + assert getattr(record, "ext_solution_id") == "" diff --git a/tests/core/unit/telemetry/test_module.py b/tests/core/unit/telemetry/test_module.py index e92d100..f596521 100644 --- a/tests/core/unit/telemetry/test_module.py +++ b/tests/core/unit/telemetry/test_module.py @@ -47,10 +47,11 @@ def test_module_in_collection(self): def test_all_modules_present(self): """Test that all expected modules are present.""" all_modules = list(Module) - assert len(all_modules) == 8 + assert len(all_modules) == 9 assert Module.AICORE in all_modules assert Module.AUDITLOG in all_modules assert Module.DESTINATION in all_modules + assert Module.EXTENSIBILITY in all_modules assert Module.OBJECTSTORE in all_modules assert Module.DMS in all_modules assert Module.AGENT_MEMORY in all_modules @@ -63,3 +64,4 @@ def test_module_iteration(self): assert "auditlog_ng" in module_values assert "destination" in module_values assert "objectstore" in module_values + assert "extensibility" in module_values diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index 8f41171..1205626 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -1,7 +1,5 @@ """Tests for Operation enum.""" -import pytest - from sap_cloud_sdk.core.telemetry.operation import Operation @@ -16,20 +14,44 @@ def test_auditlog_operations(self): def test_destination_operations(self): """Test Destination operation values.""" - assert Operation.DESTINATION_GET_INSTANCE_DESTINATION.value == "get_instance_destination" - assert Operation.DESTINATION_GET_SUBACCOUNT_DESTINATION.value == "get_subaccount_destination" - assert Operation.DESTINATION_LIST_INSTANCE_DESTINATIONS.value == "list_instance_destinations" - assert Operation.DESTINATION_LIST_SUBACCOUNT_DESTINATIONS.value == "list_subaccount_destinations" + assert ( + Operation.DESTINATION_GET_INSTANCE_DESTINATION.value + == "get_instance_destination" + ) + assert ( + Operation.DESTINATION_GET_SUBACCOUNT_DESTINATION.value + == "get_subaccount_destination" + ) + assert ( + Operation.DESTINATION_LIST_INSTANCE_DESTINATIONS.value + == "list_instance_destinations" + ) + assert ( + Operation.DESTINATION_LIST_SUBACCOUNT_DESTINATIONS.value + == "list_subaccount_destinations" + ) assert Operation.DESTINATION_CREATE_DESTINATION.value == "create_destination" assert Operation.DESTINATION_UPDATE_DESTINATION.value == "update_destination" assert Operation.DESTINATION_DELETE_DESTINATION.value == "delete_destination" def test_certificate_operations(self): """Test Certificate operation values.""" - assert Operation.CERTIFICATE_GET_INSTANCE_CERTIFICATE.value == "get_instance_certificate" - assert Operation.CERTIFICATE_GET_SUBACCOUNT_CERTIFICATE.value == "get_subaccount_certificate" - assert Operation.CERTIFICATE_LIST_INSTANCE_CERTIFICATES.value == "list_instance_certificates" - assert Operation.CERTIFICATE_LIST_SUBACCOUNT_CERTIFICATES.value == "list_subaccount_certificates" + assert ( + Operation.CERTIFICATE_GET_INSTANCE_CERTIFICATE.value + == "get_instance_certificate" + ) + assert ( + Operation.CERTIFICATE_GET_SUBACCOUNT_CERTIFICATE.value + == "get_subaccount_certificate" + ) + assert ( + Operation.CERTIFICATE_LIST_INSTANCE_CERTIFICATES.value + == "list_instance_certificates" + ) + assert ( + Operation.CERTIFICATE_LIST_SUBACCOUNT_CERTIFICATES.value + == "list_subaccount_certificates" + ) assert Operation.CERTIFICATE_CREATE_CERTIFICATE.value == "create_certificate" assert Operation.CERTIFICATE_UPDATE_CERTIFICATE.value == "update_certificate" assert Operation.CERTIFICATE_DELETE_CERTIFICATE.value == "delete_certificate" @@ -37,9 +59,18 @@ def test_certificate_operations(self): def test_fragment_operations(self): """Test Fragment operation values.""" assert Operation.FRAGMENT_GET_INSTANCE_FRAGMENT.value == "get_instance_fragment" - assert Operation.FRAGMENT_GET_SUBACCOUNT_FRAGMENT.value == "get_subaccount_fragment" - assert Operation.FRAGMENT_LIST_INSTANCE_FRAGMENTS.value == "list_instance_fragments" - assert Operation.FRAGMENT_LIST_SUBACCOUNT_FRAGMENTS.value == "list_subaccount_fragments" + assert ( + Operation.FRAGMENT_GET_SUBACCOUNT_FRAGMENT.value + == "get_subaccount_fragment" + ) + assert ( + Operation.FRAGMENT_LIST_INSTANCE_FRAGMENTS.value + == "list_instance_fragments" + ) + assert ( + Operation.FRAGMENT_LIST_SUBACCOUNT_FRAGMENTS.value + == "list_subaccount_fragments" + ) assert Operation.FRAGMENT_CREATE_FRAGMENT.value == "create_fragment" assert Operation.FRAGMENT_UPDATE_FRAGMENT.value == "update_fragment" assert Operation.FRAGMENT_DELETE_FRAGMENT.value == "delete_fragment" @@ -47,8 +78,12 @@ def test_fragment_operations(self): def test_objectstore_operations(self): """Test Object Store operation values.""" assert Operation.OBJECTSTORE_PUT_OBJECT.value == "put_object" - assert Operation.OBJECTSTORE_PUT_OBJECT_FROM_FILE.value == "put_object_from_file" - assert Operation.OBJECTSTORE_PUT_OBJECT_FROM_BYTES.value == "put_object_from_bytes" + assert ( + Operation.OBJECTSTORE_PUT_OBJECT_FROM_FILE.value == "put_object_from_file" + ) + assert ( + Operation.OBJECTSTORE_PUT_OBJECT_FROM_BYTES.value == "put_object_from_bytes" + ) assert Operation.OBJECTSTORE_GET_OBJECT.value == "get_object" assert Operation.OBJECTSTORE_HEAD_OBJECT.value == "head_object" assert Operation.OBJECTSTORE_DELETE_OBJECT.value == "delete_object" @@ -60,6 +95,14 @@ def test_aicore_operations(self): assert Operation.AICORE_SET_CONFIG.value == "set_aicore_config" assert Operation.AICORE_AUTO_INSTRUMENT.value == "auto_instrument" + def test_extensibility_operations(self): + """Test Extensibility operation values.""" + assert ( + Operation.EXTENSIBILITY_GET_EXTENSION_CAPABILITY_IMPLEMENTATION.value + == "get_extension_capability_implementation" + ) + assert Operation.EXTENSIBILITY_CALL_HOOK.value == "call_hook" + def test_dms_operations(self): """Test DMS operation values.""" assert Operation.DMS_ONBOARD_REPOSITORY.value == "onboard_repository" @@ -89,7 +132,10 @@ def test_dms_operations(self): def test_operation_str_representation(self): """Test that Operation enum converts to string correctly.""" assert str(Operation.AUDITLOG_LOG) == "log" - assert str(Operation.DESTINATION_GET_INSTANCE_DESTINATION) == "get_instance_destination" + assert ( + str(Operation.DESTINATION_GET_INSTANCE_DESTINATION) + == "get_instance_destination" + ) assert str(Operation.OBJECTSTORE_PUT_OBJECT) == "put_object" assert str(Operation.AICORE_AUTO_INSTRUMENT) == "auto_instrument" @@ -125,6 +171,7 @@ def test_operation_iteration(self): assert any("AUDITLOG" in op.name for op in all_operations) assert any("DESTINATION" in op.name for op in all_operations) assert any("CERTIFICATE" in op.name for op in all_operations) + assert any("EXTENSIBILITY" in op.name for op in all_operations) assert any("FRAGMENT" in op.name for op in all_operations) assert any("OBJECTSTORE" in op.name for op in all_operations) assert any("AICORE" in op.name for op in all_operations) @@ -132,6 +179,6 @@ def test_operation_iteration(self): def test_operation_count(self): """Test that we have the expected number of operations.""" all_operations = list(Operation) - # 3 auditlog + 11 destination + 10 certificate + 10 fragment - # + 8 objectstore + 2 aicore + 23 dms + 13 agent_memory + 2 agentgateway = 82 - assert len(all_operations) == 82 + # 3 auditlog + 11 destination + 10 certificate + 10 fragment + 8 objectstore + # + 2 extensibility + 2 aicore + 23 dms + 2 agentgateway + 13 agent_memory = 84 + assert len(all_operations) == 84 diff --git a/tests/extensibility/__init__.py b/tests/extensibility/__init__.py new file mode 100644 index 0000000..400c105 --- /dev/null +++ b/tests/extensibility/__init__.py @@ -0,0 +1 @@ +# Extensibility tests package diff --git a/tests/extensibility/unit/__init__.py b/tests/extensibility/unit/__init__.py new file mode 100644 index 0000000..ac5c1bf --- /dev/null +++ b/tests/extensibility/unit/__init__.py @@ -0,0 +1 @@ +# Extensibility unit tests package diff --git a/tests/extensibility/unit/_ums_test_helpers.py b/tests/extensibility/unit/_ums_test_helpers.py new file mode 100644 index 0000000..8073fd5 --- /dev/null +++ b/tests/extensibility/unit/_ums_test_helpers.py @@ -0,0 +1,343 @@ +"""Shared fixtures and test data for UMS transport tests.""" + +import base64 +import json +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from sap_cloud_sdk.extensibility._ums_transport import ( + UmsTransport, + ENV_CONHOS_LANDSCAPE, +) +from sap_cloud_sdk.extensibility.config import ExtensibilityConfig + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +AGENT_ORD_ID = "sap.ai:agent:employeeOnboarding:v1" + +# Fake PEM certificate content for testing +_FAKE_PEM = b"-----BEGIN CERTIFICATE-----\nfakecertdata\n-----END CERTIFICATE-----\n" +_FAKE_PEM_B64 = base64.b64encode(_FAKE_PEM).decode() + + +# --------------------------------------------------------------------------- +# UMS response fixtures +# --------------------------------------------------------------------------- + +UMS_RESPONSE_SINGLE = { + "data": { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-instance-1", + "title": "ServiceNow Extension", + "extensionVersion": "2.1.0", + "capabilityImplementations": [ + { + "capabilityId": "default", + "instruction": { + "text": "Use ServiceNow tools for ticket management." + }, + "tools": { + "additions": [ + { + "type": "MCP", + "mcpConfig": { + "globalTenantId": "tenant-sn-1", + "ordId": "sap.mcp:apiResource:serviceNow:v1", + "toolNames": [ + "create_ticket", + "update_ticket", + ], + }, + } + ] + }, + "hooks": [ + { + "id": "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11", + "hookId": "before_tool_execution", + "type": "BEFORE", + "name": "Before Tool Execution", + "onFailure": "CONTINUE", + "timeout": 30, + "deploymentType": "N8N", + "canShortCircuit": False, + "n8nWorkflowConfig": { + "workflowId": "wf-before-001", + "method": "POST", + }, + } + ], + } + ], + } + } + ], + "pageInfo": {"hasNextPage": False, "cursor": None}, + } + } +} + +UMS_RESPONSE_MULTIPLE = { + "data": { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-instance-1", + "title": "ServiceNow Extension", + "extensionVersion": "1.0.0", + "capabilityImplementations": [ + { + "capabilityId": "default", + "instruction": {"text": "ServiceNow instruction."}, + "tools": { + "additions": [ + { + "type": "MCP", + "mcpConfig": { + "globalTenantId": "tenant-sn-2", + "ordId": "sap.mcp:apiResource:serviceNow:v1", + "toolNames": ["create_ticket"], + }, + } + ] + }, + "hooks": [], + } + ], + } + }, + { + "node": { + "id": "ext-instance-2", + "title": "Jira Extension", + "extensionVersion": "3.2.1", + "capabilityImplementations": [ + { + "capabilityId": "default", + "instruction": {"text": "Jira instruction."}, + "tools": { + "additions": [ + { + "type": "MCP", + "mcpConfig": { + "globalTenantId": "tenant-jira-1", + "ordId": "sap.mcp:apiResource:jira:v1", + "toolNames": ["create_issue"], + }, + } + ] + }, + "hooks": [ + { + "id": "6a9e0cef-eed6-4f1b-9f86-3d8e9f5c1d22", + "hookId": "after_tool_execution", + "type": "AFTER", + "name": "After Tool Execution", + "onFailure": "CONTINUE", + "timeout": 30, + "deploymentType": "N8N", + "canShortCircuit": False, + "n8nWorkflowConfig": { + "workflowId": "wf-after-001", + "method": "POST", + }, + } + ], + } + ], + } + }, + ], + "pageInfo": {"hasNextPage": False, "cursor": None}, + } + } +} + +UMS_RESPONSE_EMPTY = { + "data": { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [], + "pageInfo": {"hasNextPage": False, "cursor": None}, + } + } +} + +UMS_RESPONSE_NO_INSTRUCTION = { + "data": { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-1", + "title": "Minimal Extension", + "extensionVersion": "0.1.0", + "capabilityImplementations": [ + { + "capabilityId": "default", + "tools": { + "additions": [ + { + "type": "MCP", + "mcpConfig": { + "globalTenantId": "tenant-min-1", + "ordId": "sap.mcp:apiResource:minimal:v1", + "toolNames": ["do_thing"], + }, + } + ] + }, + "hooks": [], + } + ], + } + } + ], + "pageInfo": {"hasNextPage": False, "cursor": None}, + } + } +} + +UMS_RESPONSE_EMPTY_INSTRUCTION = { + "data": { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-1", + "title": "Empty Instruction Extension", + "extensionVersion": "1.0.0", + "capabilityImplementations": [ + { + "capabilityId": "default", + "instruction": {"text": ""}, + "tools": {"additions": []}, + "hooks": [], + } + ], + } + } + ], + "pageInfo": {"hasNextPage": False, "cursor": None}, + } + } +} + +UMS_RESPONSE_DIFFERENT_CAPABILITY = { + "data": { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-1", + "title": "Other Extension", + "extensionVersion": "2.0.0", + "capabilityImplementations": [ + { + "capabilityId": "onboarding", + "instruction": {"text": "Onboarding instruction."}, + "tools": { + "additions": [ + { + "type": "MCP", + "mcpConfig": { + "globalTenantId": "tenant-onb-1", + "ordId": "sap.mcp:apiResource:onboarding:v1", + "toolNames": ["onboard_user"], + }, + } + ] + }, + "hooks": [], + }, + { + "capabilityId": "default", + "instruction": {"text": "Default instruction."}, + "tools": { + "additions": [ + { + "type": "MCP", + "mcpConfig": { + "globalTenantId": "tenant-gen-1", + "ordId": "sap.mcp:apiResource:general:v1", + "toolNames": ["general_tool"], + }, + } + ] + }, + "hooks": [], + }, + ], + } + } + ], + "pageInfo": {"hasNextPage": False, "cursor": None}, + } + } +} + + +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- + + +def _make_config(**overrides): + defaults: dict = { + "destination_name": None, + "destination_instance": "default", + } + defaults.update(overrides) + return ExtensibilityConfig(**defaults) + + +def _make_dest(url="https://ums.example.com", cert_content=_FAKE_PEM_B64): + dest = MagicMock() + dest.url = url + if cert_content is not None: + cert = MagicMock() + cert.name = "client-cert.pem" + cert.content = cert_content + cert.type = "PEM" + dest.certificates = [cert] + else: + dest.certificates = [] + return dest + + +def _make_httpx_response(json_body, status_code=200): + """Create a mock httpx.Response with the given body.""" + response = MagicMock(spec=httpx.Response) + response.status_code = status_code + response.json.return_value = json_body + response.text = json.dumps(json_body) + response.raise_for_status = MagicMock() + if status_code >= 400: + response.raise_for_status.side_effect = httpx.HTTPStatusError( + f"HTTP {status_code}", + request=MagicMock(), + response=response, + ) + return response + + +def _make_transport(monkeypatch, dest=None): + """Create a UmsTransport with mocked destination client.""" + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + with patch( + "sap_cloud_sdk.extensibility._ums_transport.create_destination_client" + ) as mock_dest_client: + config = _make_config() + if dest is None: + dest = _make_dest() + mock_dest_client.return_value.get_destination.return_value = dest + transport = UmsTransport(AGENT_ORD_ID, config) + return transport, mock_dest_client.return_value diff --git a/tests/extensibility/unit/test_a2a.py b/tests/extensibility/unit/test_a2a.py new file mode 100644 index 0000000..0d96ad1 --- /dev/null +++ b/tests/extensibility/unit/test_a2a.py @@ -0,0 +1,700 @@ +"""Tests for A2A card serialization helpers.""" + +import logging + + +from a2a.types import AgentExtension + +from sap_cloud_sdk.extensibility._a2a import ( + EXTENSION_CAPABILITY_SCHEMA_VERSION, + _to_camel_case, + _tools_to_dict, + _supported_hooks_to_dict, + _validate_extension_capabilities, + build_extension_capabilities, +) +from sap_cloud_sdk.extensibility._models import ( + ExtensionCapability, + HookCapability, + ToolAdditions, + Tools, + HookType, +) + + +class TestExtensionCapabilitySchemaVersion: + """Tests for the EXTENSION_CAPABILITY_SCHEMA_VERSION constant.""" + + def test_schema_version_is_integer(self): + assert isinstance(EXTENSION_CAPABILITY_SCHEMA_VERSION, int) + + def test_schema_version_value(self): + assert EXTENSION_CAPABILITY_SCHEMA_VERSION == 1 + + def test_schema_version_exported_from_package(self): + from sap_cloud_sdk.extensibility import ( + EXTENSION_CAPABILITY_SCHEMA_VERSION as exported, + ) + + assert exported == 1 + + +class TestToCamelCase: + """Tests for the _to_camel_case helper.""" + + def test_single_word(self): + assert _to_camel_case("enabled") == "enabled" + + def test_two_words(self): + assert _to_camel_case("display_name") == "displayName" + + def test_three_words(self): + assert _to_camel_case("my_long_name") == "myLongName" + + def test_already_camel(self): + # Single word — no underscores — passes through unchanged. + assert _to_camel_case("displayName") == "displayName" + + +class TestToolsToDict: + """Tests for the _tools_to_dict helper.""" + + def test_default_tools(self): + tools = Tools() + result = _tools_to_dict(tools) + assert result == {"additions": {"enabled": True}} + + def test_custom_tools(self): + tools = Tools(additions=ToolAdditions(enabled=False)) + result = _tools_to_dict(tools) + assert result == {"additions": {"enabled": False}} + + +class TestValidateExtensionCapabilities: + """Tests for _validate_extension_capabilities().""" + + def test_empty_list_logs_warning(self, caplog): + with caplog.at_level(logging.WARNING): + _validate_extension_capabilities([]) + + assert "empty list" in caplog.text + + def test_single_valid_capability_no_warnings(self, caplog): + caps = [ + ExtensionCapability( + display_name="Test", + description="A test capability.", + ) + ] + with caplog.at_level(logging.WARNING): + _validate_extension_capabilities(caps) + + assert caplog.text == "" + + def test_duplicate_ids_logs_warning(self, caplog): + caps = [ + ExtensionCapability( + display_name="First", + description="First capability.", + id="same-id", + ), + ExtensionCapability( + display_name="Second", + description="Second capability.", + id="same-id", + ), + ] + with caplog.at_level(logging.WARNING): + _validate_extension_capabilities(caps) + + assert "Duplicate" in caplog.text + assert "same-id" in caplog.text + assert "0" in caplog.text # first index + assert "1" in caplog.text # second index + + def test_empty_id_logs_warning(self, caplog): + caps = [ + ExtensionCapability( + display_name="Bad", + description="Has empty ID.", + id="", + ), + ] + with caplog.at_level(logging.WARNING): + _validate_extension_capabilities(caps) + + assert "empty" in caplog.text.lower() + + def test_whitespace_only_id_logs_warning(self, caplog): + caps = [ + ExtensionCapability( + display_name="Bad", + description="Has whitespace-only ID.", + id=" ", + ), + ] + with caplog.at_level(logging.WARNING): + _validate_extension_capabilities(caps) + + assert "whitespace" in caplog.text.lower() + + def test_multiple_duplicates_each_logged(self, caplog): + caps = [ + ExtensionCapability(display_name="A", description="A.", id="dup"), + ExtensionCapability(display_name="B", description="B.", id="dup"), + ExtensionCapability(display_name="C", description="C.", id="dup"), + ] + with caplog.at_level(logging.WARNING): + _validate_extension_capabilities(caps) + + # Should warn about index 1 and index 2 being duplicates of index 0 + warning_messages = [ + r.message for r in caplog.records if r.levelno == logging.WARNING + ] + assert len(warning_messages) == 2 + + +class TestBuildExtensionCapabilities: + """Tests for build_extension_capabilities().""" + + def test_single_default_capability(self): + caps = [ + ExtensionCapability( + display_name="Default", + description="Extension capability to further enhance agent.", + ) + ] + + result = build_extension_capabilities(caps) + + assert len(result) == 1 + ext = result[0] + assert isinstance(ext, AgentExtension) + assert ext.uri == "urn:sap:extension-capability:v1:default" + assert ext.description == "Extension capability to further enhance agent." + assert ext.required is False + assert ext.params == { + "capabilityId": "default", + "displayName": "Default", + "instructionSupported": True, + "tools": { + "additions": { + "enabled": True, + }, + }, + "supportedHooks": [], + } + + def test_uri_generation_with_custom_id(self): + caps = [ + ExtensionCapability( + display_name="Doc Processing", + description="Document processing pipeline.", + id="doc-processing", + ) + ] + + result = build_extension_capabilities(caps) + + assert result[0].uri == ("urn:sap:extension-capability:v1:doc-processing") + + def test_uri_includes_schema_version(self): + """URI embeds the current schema version.""" + caps = [ + ExtensionCapability( + display_name="Test", + description="Test.", + ) + ] + + result = build_extension_capabilities(caps) + + expected_version = f"v{EXTENSION_CAPABILITY_SCHEMA_VERSION}" + assert expected_version in result[0].uri + + def test_params_camel_case_conversion(self): + """Verify that params keys use camelCase.""" + caps = [ + ExtensionCapability( + display_name="My Cap", + description="A capability.", + instruction_supported=False, + tools=Tools(additions=ToolAdditions(enabled=False)), + supported_hooks=[ + HookCapability( + id="pre_test_hook", + type=HookType.BEFORE, + display_name="Test Pre Hook", + description="Test description", + ), + HookCapability( + id="post_test_hook", + type=HookType.AFTER, + display_name="Test Post Hook", + description="Test description", + ), + ], + ) + ] + + result = build_extension_capabilities(caps) + params = result[0].params + assert params is not None + + # camelCase keys + assert "capabilityId" in params + assert "displayName" in params + assert "instructionSupported" in params + assert "tools" in params + assert "supportedHooks" in params + + # No snake_case keys + assert "capability_id" not in params + assert "display_name" not in params + assert "instruction_supported" not in params + assert "supported_hooks" not in params + + # Values + assert params["capabilityId"] == "default" + assert params["displayName"] == "My Cap" + assert params["instructionSupported"] is False + assert params["tools"]["additions"]["enabled"] is False + assert params["supportedHooks"][0]["id"] == "pre_test_hook" + assert params["supportedHooks"][0]["type"] == "BEFORE" + assert params["supportedHooks"][1]["id"] == "post_test_hook" + assert params["supportedHooks"][1]["type"] == "AFTER" + + def test_tool_additions_nested_under_additions_key(self): + """Verify tools dict has 'additions' wrapper matching the design spec.""" + caps = [ + ExtensionCapability( + display_name="Test", + description="Test.", + tools=Tools(additions=ToolAdditions(enabled=True)), + ) + ] + + result = build_extension_capabilities(caps) + assert result[0].params is not None + + tools = result[0].params["tools"] + assert "additions" in tools + assert tools["additions"] == {"enabled": True} + + def test_multiple_capabilities(self): + caps = [ + ExtensionCapability( + display_name="Onboarding", + description="Onboarding workflow extensions.", + id="onboarding", + ), + ExtensionCapability( + display_name="Doc Processing", + description="Document processing pipeline.", + id="doc-processing", + instruction_supported=False, + tools=Tools(additions=ToolAdditions(enabled=True)), + ), + ExtensionCapability( + display_name="Invoice Processing", + description="Invoice processing pipeline.", + id="invoice-processing", + instruction_supported=False, + tools=Tools(additions=ToolAdditions(enabled=False)), + supported_hooks=[ + HookCapability( + id="before_invoice_hook", + type=HookType.BEFORE, + display_name="Before Invoice Hook", + description="Hook executed before invoice processing.", + ) + ], + ), + ] + + result = build_extension_capabilities(caps) + + assert len(result) == 3 + + assert result[0].uri == ("urn:sap:extension-capability:v1:onboarding") + assert result[0].params is not None + assert result[0].params["capabilityId"] == "onboarding" + assert result[0].params["displayName"] == "Onboarding" + + assert result[1].uri == ("urn:sap:extension-capability:v1:doc-processing") + assert result[1].params is not None + assert result[1].params["capabilityId"] == "doc-processing" + assert result[1].params["instructionSupported"] is False + assert result[1].params["tools"]["additions"]["enabled"] is True + + assert result[2].uri == ("urn:sap:extension-capability:v1:invoice-processing") + assert result[2].params is not None + assert result[2].params["capabilityId"] == "invoice-processing" + assert result[2].params["supportedHooks"][0]["id"] == "before_invoice_hook" + assert result[2].params["supportedHooks"][0]["type"] == "BEFORE" + + def test_empty_list_returns_empty(self): + result = build_extension_capabilities([]) + assert result == [] + + def test_required_always_false(self): + caps = [ + ExtensionCapability( + display_name="Test", + description="Test.", + ) + ] + + result = build_extension_capabilities(caps) + + assert result[0].required is False + + def test_description_passed_through(self): + desc = "This is a detailed description of the extension capability." + caps = [ + ExtensionCapability( + display_name="Test", + description=desc, + ) + ] + + result = build_extension_capabilities(caps) + + assert result[0].description == desc + + def test_validation_runs_before_conversion(self, caplog): + """Validation warnings are logged but conversion still proceeds.""" + caps = [ + ExtensionCapability( + display_name="Dup1", + description="First.", + id="dup", + ), + ExtensionCapability( + display_name="Dup2", + description="Second.", + id="dup", + ), + ] + + with caplog.at_level(logging.WARNING): + result = build_extension_capabilities(caps) + + # Warning logged + assert "Duplicate" in caplog.text + # But both are still converted + assert len(result) == 2 + + def test_return_type_is_list_of_agent_extension(self): + caps = [ + ExtensionCapability( + display_name="Test", + description="Test.", + ) + ] + + result = build_extension_capabilities(caps) + + assert isinstance(result, list) + assert all(isinstance(ext, AgentExtension) for ext in result) + + def test_instruction_supported_true(self): + caps = [ + ExtensionCapability( + display_name="Test", + description="Test.", + instruction_supported=True, + ) + ] + + result = build_extension_capabilities(caps) + assert result[0].params is not None + + assert result[0].params["instructionSupported"] is True + + def test_instruction_supported_false(self): + caps = [ + ExtensionCapability( + display_name="Test", + description="Test.", + instruction_supported=False, + ) + ] + + result = build_extension_capabilities(caps) + assert result[0].params is not None + + assert result[0].params["instructionSupported"] is False + + def test_matches_design_spec_example(self): + """Verify output matches the exact example from the design spec.""" + caps = [ + ExtensionCapability( + id="default", + display_name="Default", + description="Extension capability to further enhance agent. ...", + instruction_supported=True, + tools=Tools(additions=ToolAdditions(enabled=True)), + supported_hooks=[ + HookCapability( + id="pre_test_hook", + type=HookType.BEFORE, + display_name="Test Pre Hook", + description="Test description", + ) + ], + ) + ] + + result = build_extension_capabilities(caps) + + ext = result[0] + assert ext.uri == "urn:sap:extension-capability:v1:default" + assert ext.description == "Extension capability to further enhance agent. ..." + assert ext.required is False + assert ext.params == { + "capabilityId": "default", + "instructionSupported": True, + "displayName": "Default", + "tools": { + "additions": { + "enabled": True, + }, + }, + "supportedHooks": [ + { + "id": "pre_test_hook", + "type": "BEFORE", + "displayName": "Test Pre Hook", + "description": "Test description", + } + ], + } + + +class TestSupportedHooksToDict: + """Tests for the _supported_hooks_to_dict helper.""" + + def test_empty_list(self): + result = _supported_hooks_to_dict([]) + assert result == [] + + def test_single_hook(self): + hooks = [ + HookCapability( + id="before_tool_execution", + type=HookType.BEFORE, + display_name="Before Tool Execution", + description="Hook that runs before tool execution", + ) + ] + result = _supported_hooks_to_dict(hooks) + assert result == [ + { + "id": "before_tool_execution", + "type": "BEFORE", + "displayName": "Before Tool Execution", + "description": "Hook that runs before tool execution", + } + ] + + def test_multiple_hooks(self): + hooks = [ + HookCapability( + id="before_hook", + type=HookType.BEFORE, + display_name="Before Hook", + description="Before hook description", + ), + HookCapability( + id="after_hook", + type=HookType.AFTER, + display_name="After Hook", + description="After hook description", + ), + ] + result = _supported_hooks_to_dict(hooks) + assert len(result) == 2 + assert result[0]["id"] == "before_hook" + assert result[0]["type"] == "BEFORE" + assert result[0]["displayName"] == "Before Hook" + assert result[1]["id"] == "after_hook" + assert result[1]["type"] == "AFTER" + assert result[1]["displayName"] == "After Hook" + + def test_camel_case_conversion(self): + """Verify that hook fields are converted to camelCase.""" + hooks = [ + HookCapability( + id="test_hook", + type=HookType.BEFORE, + display_name="Test Hook", + description="Test description", + ) + ] + result = _supported_hooks_to_dict(hooks) + assert "displayName" in result[0] + assert "display_name" not in result[0] + + +class TestBuildExtensionCapabilitiesWithHooks: + """Tests for build_extension_capabilities() with hooks support.""" + + def test_capability_with_empty_hooks(self): + caps = [ + ExtensionCapability( + display_name="Test", description="Test capability.", supported_hooks=[] + ) + ] + result = build_extension_capabilities(caps) + assert result[0].params is not None + assert result[0].params["supportedHooks"] == [] + + def test_capability_with_single_hook(self): + hooks = [ + HookCapability( + id="before_tool_execution", + type=HookType.BEFORE, + display_name="Before Tool Execution", + description="Hook that runs before tool execution", + ) + ] + caps = [ + ExtensionCapability( + display_name="Test", + description="Test capability.", + supported_hooks=hooks, + ) + ] + result = build_extension_capabilities(caps) + assert result[0].params is not None + assert "supportedHooks" in result[0].params + assert len(result[0].params["supportedHooks"]) == 1 + assert result[0].params["supportedHooks"][0]["id"] == "before_tool_execution" + assert result[0].params["supportedHooks"][0]["type"] == "BEFORE" + assert ( + result[0].params["supportedHooks"][0]["displayName"] + == "Before Tool Execution" + ) + + def test_capability_with_multiple_hooks(self): + hooks = [ + HookCapability( + id="before_hook", + type=HookType.BEFORE, + display_name="Before Hook", + description="Before hook description", + ), + HookCapability( + id="after_hook", + type=HookType.AFTER, + display_name="After Hook", + description="After hook description", + ), + ] + caps = [ + ExtensionCapability( + display_name="Test", + description="Test capability.", + supported_hooks=hooks, + ) + ] + result = build_extension_capabilities(caps) + assert result[0].params is not None + assert len(result[0].params["supportedHooks"]) == 2 + assert result[0].params["supportedHooks"][0]["id"] == "before_hook" + assert result[0].params["supportedHooks"][1]["id"] == "after_hook" + + def test_full_capability_with_tools_and_hooks(self): + """Test capability with both tools and hooks configured.""" + hooks = [ + HookCapability( + id="onboarding_before", + type=HookType.BEFORE, + display_name="Onboarding Before Hook", + description="Hook executed before onboarding workflow step.", + ) + ] + caps = [ + ExtensionCapability( + id="onboarding", + display_name="Onboarding", + description="Onboarding workflow extensions.", + instruction_supported=True, + tools=Tools(additions=ToolAdditions(enabled=True)), + supported_hooks=hooks, + ) + ] + result = build_extension_capabilities(caps) + + assert result[0].uri == "urn:sap:extension-capability:v1:onboarding" + assert result[0].params is not None + assert result[0].params["capabilityId"] == "onboarding" + assert result[0].params["displayName"] == "Onboarding" + assert result[0].params["instructionSupported"] is True + assert result[0].params["tools"]["additions"]["enabled"] is True + assert len(result[0].params["supportedHooks"]) == 1 + assert result[0].params["supportedHooks"][0]["id"] == "onboarding_before" + assert result[0].params["supportedHooks"][0]["type"] == "BEFORE" + + def test_multiple_capabilities_with_different_hooks(self): + """Test multiple capabilities each with different hooks.""" + caps = [ + ExtensionCapability( + id="onboarding", + display_name="Onboarding", + description="Onboarding workflow.", + supported_hooks=[ + HookCapability( + id="onboarding_before", + type=HookType.BEFORE, + display_name="Onboarding Before", + description="Before onboarding", + ) + ], + ), + ExtensionCapability( + id="doc-processing", + display_name="Doc Processing", + description="Document processing.", + supported_hooks=[ + HookCapability( + id="doc_validation", + type=HookType.BEFORE, + display_name="Doc Validation", + description="Validate documents", + ), + HookCapability( + id="doc_after", + type=HookType.AFTER, + display_name="Doc After", + description="After document processing", + ), + ], + ), + ] + result = build_extension_capabilities(caps) + + assert len(result) == 2 + assert result[0].params is not None + assert len(result[0].params["supportedHooks"]) == 1 + assert result[0].params["supportedHooks"][0]["id"] == "onboarding_before" + assert result[1].params is not None + assert len(result[1].params["supportedHooks"]) == 2 + assert result[1].params["supportedHooks"][0]["id"] == "doc_validation" + assert result[1].params["supportedHooks"][1]["id"] == "doc_after" + + +class TestBuildExtensionCapabilitiesExportedFromPackage: + """Test that build_extension_capabilities is properly exported.""" + + def test_importable_from_package(self): + from sap_cloud_sdk.extensibility import build_extension_capabilities + + assert callable(build_extension_capabilities) + + def test_in_all(self): + import sap_cloud_sdk.extensibility as ext + + assert "build_extension_capabilities" in ext.__all__ + assert "EXTENSION_CAPABILITY_SCHEMA_VERSION" in ext.__all__ diff --git a/tests/extensibility/unit/test_client.py b/tests/extensibility/unit/test_client.py new file mode 100644 index 0000000..ef409e5 --- /dev/null +++ b/tests/extensibility/unit/test_client.py @@ -0,0 +1,208 @@ +"""Tests for ExtensibilityClient and create_client.""" + +from unittest.mock import MagicMock, patch + + +from sap_cloud_sdk.extensibility import create_client +from sap_cloud_sdk.extensibility.client import ExtensibilityClient +from sap_cloud_sdk.extensibility._models import ( + ExtensionCapabilityImplementation, + McpServer, + Hook, + HookType, + DeploymentType, + OnFailure, + ExecutionMode, + N8nWorkflowConfig, +) +from http import HTTPMethod +from sap_cloud_sdk.extensibility.config import ExtensibilityConfig +from sap_cloud_sdk.extensibility.exceptions import TransportError + + +class TestCreateClient: + """Tests for the create_client factory.""" + + @patch("sap_cloud_sdk.extensibility.UmsTransport") + def test_uses_default_config(self, mock_transport_cls): + client = create_client("sap.ai:agent:test:v1") + assert isinstance(client, ExtensibilityClient) + call_args = mock_transport_cls.call_args + assert call_args[0][0] == "sap.ai:agent:test:v1" + config_arg = call_args[0][1] + assert isinstance(config_arg, ExtensibilityConfig) + assert config_arg.destination_name is None + assert config_arg.destination_instance == "default" + + @patch("sap_cloud_sdk.extensibility.UmsTransport") + def test_custom_config(self, mock_transport_cls): + config = ExtensibilityConfig(destination_name="MY_DEST") + client = create_client("sap.ai:agent:test:v1", config=config) + mock_transport_cls.assert_called_once_with("sap.ai:agent:test:v1", config) + assert isinstance(client, ExtensibilityClient) + + @patch("sap_cloud_sdk.extensibility.UmsTransport") + def test_graceful_degradation_on_transport_failure(self, mock_transport_cls): + """create_client() returns a no-op client instead of raising.""" + mock_transport_cls.side_effect = RuntimeError("init failed") + + client = create_client("sap.ai:agent:test:v1") + + # Should return a usable client, not raise + assert isinstance(client, ExtensibilityClient) + + # The client should return empty results + result = client.get_extension_capability_implementation(tenant=_TENANT) + assert isinstance(result, ExtensionCapabilityImplementation) + assert result.mcp_servers == [] + assert result.instruction is None + assert result.hooks == [] + + @patch("sap_cloud_sdk.extensibility.UmsTransport") + def test_graceful_degradation_logs_error(self, mock_transport_cls): + """create_client() logs the error when falling back to no-op.""" + mock_transport_cls.side_effect = RuntimeError("init failed") + + with patch("sap_cloud_sdk.extensibility._logger") as mock_logger: + create_client("sap.ai:agent:test:v1") + mock_logger.error.assert_called_once() + assert ( + "Failed to create extensibility client" + in mock_logger.error.call_args[0][0] + ) + + +_TENANT = "1d2e1a41-a28b-431f-9e3f-42e9704bfa75" + + +class TestExtensibilityClientGetExtensionCapabilityImplementation: + """Tests for ExtensibilityClient.get_extension_capability_implementation.""" + + def test_success(self): + expected = ExtensionCapabilityImplementation( + capability_id="default", + mcp_servers=[ + McpServer( + ord_id="sap.mcp:apiResource:serviceNow:v1", + global_tenant_id="tenant-sn-1", + tool_names=["create_ticket"], + ) + ], + instruction="Use with care.", + hooks=[ + Hook( + hook_id="agent_pre_hook", + id="9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11", + n8n_workflow_config=N8nWorkflowConfig( + workflow_id="wf-pre-001", + method=HTTPMethod.POST, + ), + name="Before Agent Hook", + type=HookType.BEFORE, + deployment_type=DeploymentType.N8N, + timeout=30, + execution_mode=ExecutionMode.SYNC, + on_failure=OnFailure.CONTINUE, + order=1, + can_short_circuit=True, + ), + Hook( + hook_id="agent_post_hook", + id="6a9e0cef-eed6-4f1b-9f86-3d8e9f5c1d22", + n8n_workflow_config=N8nWorkflowConfig( + workflow_id="wf-post-001", + method=HTTPMethod.POST, + ), + name="After Agent Hook", + type=HookType.AFTER, + deployment_type=DeploymentType.N8N, + timeout=30, + execution_mode=ExecutionMode.SYNC, + on_failure=OnFailure.CONTINUE, + order=1, + can_short_circuit=True, + ), + ], + ) + mock_transport = MagicMock() + mock_transport.get_extension_capability_implementation.return_value = expected + + client = ExtensibilityClient(mock_transport) + result = client.get_extension_capability_implementation(tenant=_TENANT) + + mock_transport.get_extension_capability_implementation.assert_called_once_with( + capability_id="default", + skip_cache=False, + tenant=_TENANT, + ) + assert result is expected + + def test_graceful_degradation_on_transport_error(self): + mock_transport = MagicMock() + mock_transport.get_extension_capability_implementation.side_effect = ( + TransportError("service unavailable") + ) + client = ExtensibilityClient(mock_transport) + result = client.get_extension_capability_implementation(tenant=_TENANT) + + assert isinstance(result, ExtensionCapabilityImplementation) + assert result.capability_id == "default" + assert result.mcp_servers == [] + assert result.instruction is None + assert result.hooks == [] + + def test_graceful_degradation_on_unexpected_error(self): + mock_transport = MagicMock() + mock_transport.get_extension_capability_implementation.side_effect = ( + RuntimeError("unexpected") + ) + client = ExtensibilityClient(mock_transport) + result = client.get_extension_capability_implementation(tenant=_TENANT) + + assert isinstance(result, ExtensionCapabilityImplementation) + assert result.capability_id == "default" + assert result.mcp_servers == [] + assert result.hooks == [] + + def test_capability_id_passed_to_transport(self): + mock_transport = MagicMock() + mock_transport.get_extension_capability_implementation.return_value = ( + ExtensionCapabilityImplementation(capability_id="custom") + ) + client = ExtensibilityClient(mock_transport) + result = client.get_extension_capability_implementation( + tenant=_TENANT, capability_id="custom" + ) + + mock_transport.get_extension_capability_implementation.assert_called_once_with( + capability_id="custom", + skip_cache=False, + tenant=_TENANT, + ) + assert result.capability_id == "custom" + + def test_fallback_uses_provided_capability_id(self): + mock_transport = MagicMock() + mock_transport.get_extension_capability_implementation.side_effect = ( + TransportError("service unavailable") + ) + client = ExtensibilityClient(mock_transport) + result = client.get_extension_capability_implementation( + tenant=_TENANT, capability_id="my-capability" + ) + + assert result.capability_id == "my-capability" + assert result.mcp_servers == [] + assert result.hooks == [] + + def test_error_logging(self): + mock_transport = MagicMock() + mock_transport.get_extension_capability_implementation.side_effect = ( + TransportError("boom") + ) + client = ExtensibilityClient(mock_transport) + + with patch("sap_cloud_sdk.extensibility.client.logger") as mock_logger: + client.get_extension_capability_implementation(tenant=_TENANT) + mock_logger.error.assert_called_once() + assert "Failed to retrieve" in mock_logger.error.call_args[0][0] diff --git a/tests/extensibility/unit/test_config.py b/tests/extensibility/unit/test_config.py new file mode 100644 index 0000000..f089e01 --- /dev/null +++ b/tests/extensibility/unit/test_config.py @@ -0,0 +1,40 @@ +"""Tests for extensibility configuration.""" + +from sap_cloud_sdk.extensibility.config import ExtensibilityConfig + + +class TestExtensibilityConfig: + """Tests for ExtensibilityConfig dataclass.""" + + def test_defaults(self): + config = ExtensibilityConfig() + assert config.destination_name is None + assert config.destination_instance == "default" + + def test_custom_destination_name(self): + config = ExtensibilityConfig(destination_name="MY_CUSTOM_DEST") + assert config.destination_name == "MY_CUSTOM_DEST" + assert config.destination_instance == "default" + + def test_custom_destination_instance(self): + config = ExtensibilityConfig(destination_instance="production") + assert config.destination_name is None + assert config.destination_instance == "production" + + def test_fully_custom(self): + config = ExtensibilityConfig( + destination_name="EXT_BACKEND_V2", + destination_instance="staging", + ) + assert config.destination_name == "EXT_BACKEND_V2" + assert config.destination_instance == "staging" + + def test_equality(self): + c1 = ExtensibilityConfig() + c2 = ExtensibilityConfig() + assert c1 == c2 + + def test_inequality(self): + c1 = ExtensibilityConfig() + c2 = ExtensibilityConfig(destination_name="OTHER") + assert c1 != c2 diff --git a/tests/extensibility/unit/test_exceptions.py b/tests/extensibility/unit/test_exceptions.py new file mode 100644 index 0000000..5819a30 --- /dev/null +++ b/tests/extensibility/unit/test_exceptions.py @@ -0,0 +1,38 @@ +"""Tests for extensibility exception hierarchy.""" + +import pytest + +from sap_cloud_sdk.extensibility.exceptions import ( + ClientCreationError, + ExtensibilityError, + TransportError, +) + + +class TestExceptionHierarchy: + """Tests for exception class hierarchy and inheritance.""" + + def test_extensibility_error_is_exception(self): + assert issubclass(ExtensibilityError, Exception) + + def test_transport_error_is_extensibility_error(self): + assert issubclass(TransportError, ExtensibilityError) + + def test_client_creation_error_is_extensibility_error(self): + assert issubclass(ClientCreationError, ExtensibilityError) + + def test_client_creation_error_caught_by_base(self): + with pytest.raises(ExtensibilityError): + raise ClientCreationError("failed") + + def test_transport_error_caught_by_base(self): + with pytest.raises(ExtensibilityError): + raise TransportError("transport failure") + + def test_transport_error_message(self): + err = TransportError("connection refused") + assert str(err) == "connection refused" + + def test_extensibility_error_message(self): + err = ExtensibilityError("generic error") + assert str(err) == "generic error" diff --git a/tests/extensibility/unit/test_local_transport.py b/tests/extensibility/unit/test_local_transport.py new file mode 100644 index 0000000..19bf5ca --- /dev/null +++ b/tests/extensibility/unit/test_local_transport.py @@ -0,0 +1,572 @@ +"""Tests for LocalTransport and local mode integration via create_client.""" + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from sap_cloud_sdk.extensibility import create_client +from sap_cloud_sdk.extensibility._local_transport import ( + CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV, + LocalTransport, +) +from sap_cloud_sdk.extensibility._models import ( + ExtensionCapabilityImplementation, + Hook, + HookType, + DeploymentType, + ExecutionMode, + N8nWorkflowConfig, + OnFailure, +) +from http import HTTPMethod +from sap_cloud_sdk.extensibility.client import ExtensibilityClient +from sap_cloud_sdk.extensibility.exceptions import TransportError + + +# Sample data matching the backend response schema +SAMPLE_RESPONSE = { + "capabilityId": "default", + "extensionNames": ["my-local-extension"], + "instruction": "Use these tools for local testing.", + "mcpServers": [ + { + "ordId": "sap.mcp:apiResource:myService:v1", + "url": "http://localhost:8080/mcp", + "toolPrefix": "sap_mcp_myservice_v1_", + "toolNames": ["my_tool"], + }, + ], +} + +SAMPLE_EMPTY_RESPONSE = { + "capabilityId": "default", + "instruction": None, + "mcpServers": [], +} + +SAMPLE_MULTI_SERVER_RESPONSE = { + "capabilityId": "onboarding", + "extensionNames": ["multi-server-ext"], + "instruction": {"text": "Handle onboarding with these tools."}, + "mcpServers": [ + { + "ordId": "sap.mcp:apiResource:serviceNow:v1", + "url": "http://localhost:9001/mcp", + "toolPrefix": "sap_mcp_servicenow_v1_", + "toolNames": ["create_ticket"], + }, + { + "ordId": "sap.mcp:apiResource:jira:v1", + "url": "http://localhost:9002/mcp", + "toolPrefix": "sap_mcp_jira_v1_", + "toolNames": None, + }, + ], +} + +SAMPLE_RESPONSE_WITH_HOOKS = { + "capabilityId": "default", + "extensionNames": ["my-local-extension"], + "instruction": "Use these tools for local testing.", + "mcpServers": [], + "hooks": [ + { + "hookId": "before_tool_execution", + "id": "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11", + "name": "Before Tool Execution Hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-before-001", "method": "POST"}, + "timeout": 30, + "executionMode": "SYNC", + "onFailure": "CONTINUE", + "order": 1, + "canShortCircuit": True, + } + ], +} + +SAMPLE_RESPONSE_WITH_INSTRUCTION_MCP_HOOKS = { + "capabilityId": "default", + "extensionNames": ["my-local-extension"], + "instruction": "Use these tools for local testing.", + "mcpServers": [ + { + "ordId": "sap.mcp:apiResource:service1:v1", + "url": "http://localhost:8080/mcp", + "toolPrefix": "sap_mcp_service1_v1_", + "toolNames": ["tool1", "tool2"], + }, + ], + "hooks": [ + { + "hookId": "pre_execution", + "id": "6a9e0cef-eed6-4f1b-9f86-3d8e9f5c1d22", + "name": "Pre Execution Hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-pre-001", "method": "POST"}, + "timeout": 20, + "executionMode": "SYNC", + "onFailure": "CONTINUE", + "order": 0, + "canShortCircuit": False, + } + ], +} + +SAMPLE_RESPONSE_WITH_MULTIPLE_HOOKS = { + "capabilityId": "advanced", + "extensionNames": ["multi-hook-extension"], + "instruction": "Extension with multiple hooks for testing.", + "mcpServers": [ + { + "ordId": "sap.mcp:apiResource:testService:v1", + "url": "http://localhost:8080/mcp", + "toolPrefix": "sap_mcp_testservice_v1_", + "toolNames": ["test_tool"], + }, + ], + "hooks": [ + { + "hookId": "before_tool_execution", + "id": "11111111-1111-4111-8111-111111111111", + "name": "Before Tool Execution Hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-before-001", "method": "POST"}, + "timeout": 30, + "executionMode": "SYNC", + "onFailure": "BLOCK", + "order": 1, + "canShortCircuit": True, + }, + { + "hookId": "after_tool_execution", + "id": "22222222-2222-4222-8222-222222222222", + "name": "After Tool Execution Hook", + "hookType": "AFTER", + "deploymentType": "SERVERLESS", + "n8nWorkflowConfig": {"workflowId": "wf-after-001", "method": "POST"}, + "timeout": 60, + "executionMode": "ASYNC", + "onFailure": "CONTINUE", + "order": 2, + "canShortCircuit": False, + }, + { + "hookId": "validation_hook", + "id": "33333333-3333-4333-8333-333333333333", + "name": "Validation Hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-validate-001", "method": "POST"}, + "timeout": 15, + "executionMode": "SYNC", + "onFailure": "BLOCK", + "order": 0, + "canShortCircuit": True, + }, + ], +} + +HOOK_RESPONSE_SAMPLE = { + "agent_pre_hook": { + "role": "user", + "parts": [{"kind": "text", "text": "Extend agent"}], + "messageId": "msg-response-pre-hook", + }, + "hook_with_no_response": None, + "hook_with_non_dict_response": "not a dict", + "hook_with_empty_response": {}, + "hook_with_stop_execution": { + "role": "user", + "parts": [], + "messageId": "msg-stop-execution", + "metadata": { + "stop_execution": True, + "stop_execution_reason": "Hook requested to stop execution", + }, + }, +} + + +def _make_hook( + hook_id: str = "before_tool_execution", + hook_uuid: str = "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11", +) -> Hook: + """Create a minimal Hook instance for testing.""" + return Hook( + hook_id=hook_id, + id=hook_uuid, + n8n_workflow_config=N8nWorkflowConfig( + workflow_id=f"wf-{hook_id}", + method=HTTPMethod.POST, + ), + name=f"Test Hook {hook_id}", + type=HookType.BEFORE, + deployment_type=DeploymentType.N8N, + timeout=30, + execution_mode=ExecutionMode.SYNC, + on_failure=OnFailure.CONTINUE, + order=0, + can_short_circuit=False, + ) + + +def _write_json(path: Path, data: dict) -> None: + """Helper to write a dict as JSON to a file.""" + path.write_text(json.dumps(data), encoding="utf-8") + + +class TestLocalTransport: + """Tests for LocalTransport reading from a JSON file.""" + + def test_reads_full_response(self, tmp_path: Path): + file = tmp_path / "extensions.json" + _write_json(file, SAMPLE_RESPONSE) + + transport = LocalTransport(str(file)) + result = transport.get_extension_capability_implementation() + + assert isinstance(result, ExtensionCapabilityImplementation) + assert result.capability_id == "default" + assert result.extension_names == ["my-local-extension"] + assert result.instruction == "Use these tools for local testing." + assert len(result.mcp_servers) == 1 + assert result.mcp_servers[0].ord_id == "sap.mcp:apiResource:myService:v1" + assert result.mcp_servers[0].tool_names == ["my_tool"] + + def test_reads_empty_response(self, tmp_path: Path): + file = tmp_path / "extensions.json" + _write_json(file, SAMPLE_EMPTY_RESPONSE) + + transport = LocalTransport(str(file)) + result = transport.get_extension_capability_implementation() + + assert result.capability_id == "default" + assert result.mcp_servers == [] + assert result.instruction is None + assert result.extension_names == [] + + def test_reads_multi_server_with_nested_instruction(self, tmp_path: Path): + file = tmp_path / "extensions.json" + _write_json(file, SAMPLE_MULTI_SERVER_RESPONSE) + + transport = LocalTransport(str(file)) + result = transport.get_extension_capability_implementation() + + assert result.capability_id == "onboarding" + assert result.extension_names == ["multi-server-ext"] + assert result.instruction == "Handle onboarding with these tools." + assert len(result.mcp_servers) == 2 + assert result.mcp_servers[0].tool_names == ["create_ticket"] + assert result.mcp_servers[1].tool_names is None + + def test_file_not_found_raises_transport_error(self, tmp_path: Path): + missing = tmp_path / "does_not_exist" / "extensions.json" + transport = LocalTransport(str(missing)) + with pytest.raises(TransportError, match="not found"): + transport.get_extension_capability_implementation() + + def test_invalid_json_raises_transport_error(self, tmp_path: Path): + file = tmp_path / "bad.json" + file.write_text("not valid json {{{", encoding="utf-8") + + transport = LocalTransport(str(file)) + with pytest.raises(TransportError, match="Failed to parse"): + transport.get_extension_capability_implementation() + + def test_empty_file_raises_transport_error(self, tmp_path: Path): + file = tmp_path / "empty.json" + file.write_text("", encoding="utf-8") + + transport = LocalTransport(str(file)) + with pytest.raises(TransportError, match="Failed to parse"): + transport.get_extension_capability_implementation() + + def test_reads_response_with_hooks(self, tmp_path: Path): + """Verify hooks are parsed from the response.""" + file = tmp_path / "extensions.json" + _write_json(file, SAMPLE_RESPONSE_WITH_HOOKS) + + transport = LocalTransport(str(file)) + result = transport.get_extension_capability_implementation() + + assert isinstance(result, ExtensionCapabilityImplementation) + assert result.capability_id == "default" + assert result.extension_names == ["my-local-extension"] + assert len(result.hooks) == 1 + + hook = result.hooks[0] + assert hook.hook_id == "before_tool_execution" + assert hook.id == "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11" + assert hook.n8n_workflow_config.workflow_id == "wf-before-001" + assert hook.name == "Before Tool Execution Hook" + assert hook.type == HookType.BEFORE + assert hook.deployment_type == DeploymentType.N8N + assert hook.timeout == 30 + assert hook.execution_mode == ExecutionMode.SYNC + assert hook.on_failure == OnFailure.CONTINUE + assert hook.order == 1 + assert hook.can_short_circuit is True + + def test_reads_response_with_multiple_hooks(self, tmp_path: Path): + """Verify multiple hooks with different types and configurations are parsed.""" + file = tmp_path / "extensions.json" + _write_json(file, SAMPLE_RESPONSE_WITH_MULTIPLE_HOOKS) + + transport = LocalTransport(str(file)) + result = transport.get_extension_capability_implementation() + + assert result.capability_id == "advanced" + assert result.extension_names == ["multi-hook-extension"] + assert len(result.hooks) == 3 + assert len(result.mcp_servers) == 1 + + # Verify first hook (BEFORE, N8N, SYNC, BLOCK) + hook1 = result.hooks[0] + assert hook1.hook_id == "before_tool_execution" + assert hook1.type == HookType.BEFORE + assert hook1.deployment_type == DeploymentType.N8N + assert hook1.execution_mode == ExecutionMode.SYNC + assert hook1.on_failure == OnFailure.BLOCK + assert hook1.order == 1 + assert hook1.can_short_circuit is True + + # Verify second hook (AFTER, SERVERLESS, ASYNC, CONTINUE) + hook2 = result.hooks[1] + assert hook2.hook_id == "after_tool_execution" + assert hook2.type == HookType.AFTER + assert hook2.deployment_type == DeploymentType.SERVERLESS + assert hook2.execution_mode == ExecutionMode.ASYNC + assert hook2.on_failure == OnFailure.CONTINUE + assert hook2.timeout == 60 + assert hook2.order == 2 + assert hook2.can_short_circuit is False + + # Verify third hook (validation) + hook3 = result.hooks[2] + assert hook3.hook_id == "validation_hook" + assert hook3.type == HookType.BEFORE + assert hook3.timeout == 15 + assert hook3.order == 0 + + def test_reads_response_with_empty_hooks(self, tmp_path: Path): + """Verify response with empty hooks array returns empty list.""" + data = {**SAMPLE_RESPONSE, "hooks": []} + file = tmp_path / "extensions.json" + _write_json(file, data) + + transport = LocalTransport(str(file)) + result = transport.get_extension_capability_implementation() + + assert result.hooks == [] + assert len(result.mcp_servers) == 1 # Other data should still be parsed + + def test_reads_response_without_hooks_field(self, tmp_path: Path): + """Verify response without hooks field defaults to empty list.""" + file = tmp_path / "extensions.json" + _write_json(file, SAMPLE_RESPONSE) # No hooks field + + transport = LocalTransport(str(file)) + result = transport.get_extension_capability_implementation() + + assert result.hooks == [] + assert len(result.mcp_servers) == 1 + + def test_reads_hooks_with_mcp_servers_and_instruction(self, tmp_path: Path): + """Verify hooks are parsed alongside MCP servers and instructions.""" + file = tmp_path / "extensions.json" + _write_json(file, SAMPLE_RESPONSE_WITH_INSTRUCTION_MCP_HOOKS) + + transport = LocalTransport(str(file)) + result = transport.get_extension_capability_implementation() + + assert result.capability_id == "default" + assert result.extension_names == ["my-local-extension"] + assert result.instruction == "Use these tools for local testing." + assert len(result.mcp_servers) == 1 + assert result.mcp_servers[0].tool_names == ["tool1", "tool2"] + assert len(result.hooks) == 1 + assert result.hooks[0].hook_id == "pre_execution" + assert result.hooks[0].timeout == 20 + + +class TestCreateClientLocalMode: + """Tests for create_client() with CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE env var.""" + + def test_env_var_activates_local_mode(self, tmp_path: Path): + file = tmp_path / "extensions.json" + _write_json(file, SAMPLE_RESPONSE_WITH_INSTRUCTION_MCP_HOOKS) + + with patch.dict( + os.environ, {CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV: str(file)} + ): + client = create_client("sap.ai:agent:test:v1") + + assert isinstance(client, ExtensibilityClient) + result = client.get_extension_capability_implementation(tenant="test-tenant") + assert result.extension_names == ["my-local-extension"] + assert len(result.mcp_servers) == 1 + assert len(result.hooks) == 1 + + def test_env_var_takes_precedence_over_config(self, tmp_path: Path): + """When env var is set, config is ignored (no destination resolution).""" + file = tmp_path / "extensions.json" + _write_json(file, SAMPLE_RESPONSE) + + with patch.dict( + os.environ, {CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV: str(file)} + ): + # This would normally fail because destination service isn't available, + # but local mode bypasses it entirely. + from sap_cloud_sdk.extensibility.config import ExtensibilityConfig + + config = ExtensibilityConfig( + destination_name="NONEXISTENT", + destination_instance="bad", + ) + client = create_client("sap.ai:agent:test:v1", config=config) + + result = client.get_extension_capability_implementation(tenant="test-tenant") + assert result.extension_names == ["my-local-extension"] + + def test_no_env_var_uses_ums_transport(self): + """Without the env var, create_client tries UmsTransport; on failure it returns a no-op client.""" + with patch.dict(os.environ, {}, clear=False): + # Ensure the env var is not set + os.environ.pop(CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV, None) + # UmsTransport creation will fail without destination service, + # but create_client gracefully degrades to a no-op client. + client = create_client("sap.ai:agent:test:v1") + + # The no-op client returns empty results instead of raising. + result = client.get_extension_capability_implementation(tenant="test-tenant") + assert result.mcp_servers == [] + assert result.instruction is None + assert result.hooks == [] + + def test_missing_file_via_env_var_graceful_degradation(self, tmp_path: Path): + """Client still works via graceful degradation when the file doesn't exist.""" + bad_path = str(tmp_path / "does_not_exist.json") + + with patch.dict(os.environ, {CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV: bad_path}): + client = create_client("sap.ai:agent:test:v1") + + # The client is created successfully (LocalTransport doesn't read at init time). + # The read happens at get_extension_capability_implementation() and + # the client's graceful degradation catches the TransportError. + result = client.get_extension_capability_implementation(tenant="test-tenant") + assert result.mcp_servers == [] + assert result.instruction is None + assert result.hooks == [] + + def test_local_mode_logs_warning(self, tmp_path: Path, caplog): + file = tmp_path / "extensions.json" + _write_json(file, SAMPLE_RESPONSE) + + with patch.dict( + os.environ, {CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV: str(file)} + ): + with caplog.at_level("WARNING", logger="sap_cloud_sdk.extensibility"): + create_client("sap.ai:agent:test:v1") + + assert "local mock mode active" in caplog.text.lower() + + +class TestCreateClientMockFileMode: + """Tests for create_client() with file-presence detection at mocks/extensibility.json.""" + + def test_mock_file_activates_local_mode(self, tmp_path: Path): + """When mocks/extensibility.json exists, local mode is activated.""" + mock_file = tmp_path / "mocks" / "extensibility.json" + mock_file.parent.mkdir(parents=True) + _write_json(mock_file, SAMPLE_RESPONSE) + + with ( + patch.dict(os.environ, {}, clear=False), + patch( + "os.getcwd", + return_value=str(tmp_path), + ), + ): + os.environ.pop(CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV, None) + client = create_client("sap.ai:agent:test:v1") + + result = client.get_extension_capability_implementation(tenant="test-tenant") + assert result.extension_names == ["my-local-extension"] + assert len(result.mcp_servers) == 1 + + def test_env_var_takes_precedence_over_mock_file(self, tmp_path: Path): + """Env var wins when both env var and mock file are present.""" + # Mock file with one extension name + mock_file = tmp_path / "mocks" / "extensibility.json" + mock_file.parent.mkdir(parents=True) + _write_json( + mock_file, + {**SAMPLE_RESPONSE, "extensionNames": ["from-mock-file"]}, + ) + + # Env var file with a different extension name + env_file = tmp_path / "env_extensions.json" + _write_json( + env_file, + {**SAMPLE_RESPONSE, "extensionNames": ["from-env-var"]}, + ) + + with ( + patch.dict( + os.environ, + {CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV: str(env_file)}, + ), + patch( + "os.getcwd", + return_value=str(tmp_path), + ), + ): + client = create_client("sap.ai:agent:test:v1") + + result = client.get_extension_capability_implementation(tenant="test-tenant") + assert result.extension_names == ["from-env-var"] + + def test_no_mock_file_no_env_var_falls_through(self, tmp_path: Path): + """Without env var or mock file, falls through to cloud mode (then no-op).""" + + with ( + patch.dict(os.environ, {}, clear=False), + patch( + "os.getcwd", + return_value=str(tmp_path), + ), + ): + os.environ.pop(CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV, None) + client = create_client("sap.ai:agent:test:v1") + + # Falls through to cloud mode, which fails without credentials, + # so graceful degradation returns no-op client. + result = client.get_extension_capability_implementation(tenant="test-tenant") + assert result.mcp_servers == [] + assert result.instruction is None + + def test_mock_file_logs_warning(self, tmp_path: Path, caplog): + """File-presence detection logs at WARNING level.""" + mock_file = tmp_path / "mocks" / "extensibility.json" + mock_file.parent.mkdir(parents=True) + _write_json(mock_file, SAMPLE_RESPONSE) + + with ( + patch.dict(os.environ, {}, clear=False), + patch( + "os.getcwd", + return_value=str(tmp_path), + ), + ): + os.environ.pop(CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE_ENV, None) + with caplog.at_level("WARNING", logger="sap_cloud_sdk.extensibility"): + create_client("sap.ai:agent:test:v1") + + assert "local mock mode active" in caplog.text.lower() diff --git a/tests/extensibility/unit/test_models.py b/tests/extensibility/unit/test_models.py new file mode 100644 index 0000000..656a3bb --- /dev/null +++ b/tests/extensibility/unit/test_models.py @@ -0,0 +1,1164 @@ +"""Tests for extensibility data models.""" + +import pytest + +from sap_cloud_sdk.extensibility._models import ( + DeploymentType, + ExecutionMode, + ExtensionCapability, + ExtensionCapabilityImplementation, + ExtensionSourceInfo, + ExtensionSourceMapping, + HookType, + Hook, + HookCapability, + McpServer, + N8nWorkflowConfig, + OnFailure, + ToolAdditions, + Tools, +) +from http import HTTPMethod + + +class TestToolAdditions: + """Tests for ToolAdditions dataclass.""" + + def test_defaults(self): + ta = ToolAdditions() + assert ta.enabled is True + + def test_custom_values(self): + ta = ToolAdditions(enabled=False) + assert ta.enabled is False + + +class TestExtensionCapability: + """Tests for ExtensionCapability dataclass.""" + + def test_defaults(self): + cap = ExtensionCapability( + display_name="Onboarding", + description="Add tools to the onboarding workflow.", + ) + assert cap.display_name == "Onboarding" + assert cap.description == "Add tools to the onboarding workflow." + assert cap.id == "default" + assert cap.instruction_supported is True + assert isinstance(cap.tools, Tools) + assert isinstance(cap.tools.additions, ToolAdditions) + assert cap.tools.additions.enabled is True + + def test_custom_id(self): + cap = ExtensionCapability( + display_name="Doc Processing", + description="Document processing pipeline.", + id="doc-processing", + instruction_supported=False, + ) + assert cap.id == "doc-processing" + assert cap.instruction_supported is False + + def test_custom_tool_additions(self): + ta = ToolAdditions(enabled=False) + cap = ExtensionCapability( + display_name="Test", + description="Test capability.", + tools=Tools(additions=ta), + ) + assert cap.tools.additions.enabled is False + + +class TestMcpServer: + """Tests for McpServer dataclass.""" + + def test_construction(self): + server = McpServer( + ord_id="sap.mcp:apiResource:serviceNow:v1", + global_tenant_id="tenant-abc-123", + tool_names=["create_ticket", "update_ticket"], + ) + assert server.ord_id == "sap.mcp:apiResource:serviceNow:v1" + assert server.global_tenant_id == "tenant-abc-123" + assert server.tool_names == ["create_ticket", "update_ticket"] + + def test_defaults(self): + server = McpServer( + ord_id="test", + global_tenant_id="tenant-xyz", + ) + assert server.tool_names is None + + def test_from_dict(self): + data = { + "ordId": "sap.mcp:apiResource:serviceNow:v1", + "globalTenantId": "tenant-abc-123", + "toolNames": ["create_hardware_ticket_tool"], + } + server = McpServer.from_dict(data) + assert server.ord_id == "sap.mcp:apiResource:serviceNow:v1" + assert server.global_tenant_id == "tenant-abc-123" + assert server.tool_names == ["create_hardware_ticket_tool"] + + def test_from_dict_missing_fields_uses_defaults(self): + data = {} + server = McpServer.from_dict(data) + assert server.ord_id == "" + assert server.global_tenant_id == "" + assert server.tool_names is None + + def test_from_dict_with_null_tool_names(self): + """toolNames: null in JSON maps to None (use all tools).""" + data = { + "ordId": "test", + "globalTenantId": "tenant-xyz", + "toolNames": None, + } + server = McpServer.from_dict(data) + assert server.tool_names is None + + def test_from_dict_without_tool_names_key(self): + """Absent toolNames key maps to None (use all tools).""" + data = { + "ordId": "test", + "globalTenantId": "tenant-xyz", + } + server = McpServer.from_dict(data) + assert server.tool_names is None + + +class TestExtensionCapabilityImplementation: + """Tests for ExtensionCapabilityImplementation dataclass.""" + + def test_defaults(self): + impl = ExtensionCapabilityImplementation(capability_id="default") + assert impl.capability_id == "default" + assert impl.extension_names == [] + assert impl.mcp_servers == [] + assert impl.instruction is None + + def test_full_construction(self): + server = McpServer( + ord_id="sap.mcp:apiResource:serviceNow:v1", + global_tenant_id="tenant-abc-123", + tool_names=["create_ticket"], + ) + impl = ExtensionCapabilityImplementation( + capability_id="default", + extension_names=["servicenow-extension"], + mcp_servers=[server], + instruction="Use the create_ticket tool carefully.", + ) + assert impl.capability_id == "default" + assert impl.extension_names == ["servicenow-extension"] + assert len(impl.mcp_servers) == 1 + assert impl.mcp_servers[0].ord_id == "sap.mcp:apiResource:serviceNow:v1" + assert impl.instruction == "Use the create_ticket tool carefully." + + def test_from_dict_full_backend_response(self): + """Parse a complete backend response matching the confirmed schema.""" + data = { + "agentOrdId": "sap.ai:agent:employeeOnboarding:v1", + "extensionNames": ["employee-onboarding-tools"], + "capabilityId": "onboarding", + "instruction": "Restored onboarding instruction for E2E.", + "mcpServers": [ + { + "ordId": "sap.mcp:apiResource:serviceNow:v1", + "globalTenantId": "tenant-abc-123", + "toolNames": ["create_hardware_ticket_tool"], + }, + ], + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.capability_id == "onboarding" + assert impl.extension_names == ["employee-onboarding-tools"] + assert impl.instruction == "Restored onboarding instruction for E2E." + assert len(impl.mcp_servers) == 1 + + s1 = impl.mcp_servers[0] + assert s1.ord_id == "sap.mcp:apiResource:serviceNow:v1" + assert s1.tool_names == ["create_hardware_ticket_tool"] + + def test_from_dict_empty_mcp_servers(self): + """Handle response with no active extension (empty mcpServers).""" + data = { + "capabilityId": "default", + "instruction": None, + "mcpServers": [], + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.capability_id == "default" + assert impl.mcp_servers == [] + assert impl.instruction is None + + def test_from_dict_missing_mcp_servers_key(self): + """Handle response where mcpServers key is absent.""" + data = {"capabilityId": "default"} + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.mcp_servers == [] + assert impl.instruction is None + + def test_from_dict_with_extension_names(self): + """Handle response that includes extensionNames.""" + data = { + "capabilityId": "default", + "extensionNames": ["jira-confluence"], + "instruction": {"text": "Use Jira tools."}, + "mcpServers": [], + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.extension_names == ["jira-confluence"] + assert impl.instruction == "Use Jira tools." + + def test_from_dict_minimal(self): + """Handle minimal/empty response.""" + data = {} + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.capability_id == "default" + assert impl.extension_names == [] + assert impl.mcp_servers == [] + assert impl.instruction is None + + def test_mutable_default_isolation(self): + """Verify each instance gets its own mcp_servers list.""" + i1 = ExtensionCapabilityImplementation(capability_id="a") + i2 = ExtensionCapabilityImplementation(capability_id="b") + i1.mcp_servers.append(McpServer(ord_id="x", global_tenant_id="t1")) + assert i2.mcp_servers == [] + + def test_from_dict_instruction_nested_object(self): + """Instruction as nested {text: string} is extracted to a flat string.""" + data = { + "capabilityId": "default", + "instruction": {"text": "Use these tools carefully."}, + "mcpServers": [], + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.instruction == "Use these tools carefully." + + def test_from_dict_instruction_plain_string(self): + """Plain string instruction is accepted for backwards compatibility.""" + data = { + "capabilityId": "default", + "instruction": "Use these tools carefully.", + "mcpServers": [], + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.instruction == "Use these tools carefully." + + def test_from_dict_instruction_nested_empty_text(self): + """Instruction object with missing text key results in None.""" + data = { + "capabilityId": "default", + "instruction": {}, + "mcpServers": [], + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.instruction is None + + def test_from_dict_with_hooks(self): + """Handle response that includes hooks.""" + data = { + "capabilityId": "default", + "mcpServers": [], + "hooks": [ + { + "hookId": "before_tool_execution", + "id": "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11", + "name": "Before Tool Execution Hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-001", "method": "POST"}, + "timeout": 30, + "executionMode": "SYNC", + "onFailure": "CONTINUE", + "order": 1, + "canShortCircuit": True, + } + ], + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert len(impl.hooks) == 1 + hook = impl.hooks[0] + assert hook.hook_id == "before_tool_execution" + assert hook.id == "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11" + assert hook.name == "Before Tool Execution Hook" + assert hook.type == HookType.BEFORE + assert hook.deployment_type == DeploymentType.N8N + assert hook.n8n_workflow_config.workflow_id == "wf-001" + assert hook.timeout == 30 + assert hook.execution_mode == ExecutionMode.SYNC + assert hook.on_failure == OnFailure.CONTINUE + assert hook.order == 1 + assert hook.can_short_circuit is True + + def test_from_dict_empty_hooks(self): + """Handle response with empty hooks array.""" + data = {"capabilityId": "default", "mcpServers": [], "hooks": []} + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.hooks == [] + + def test_from_dict_missing_hooks_key(self): + """Handle response where hooks key is absent.""" + data = {"capabilityId": "default", "mcpServers": []} + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.hooks == [] + + def test_from_dict_multiple_hooks(self): + """Handle response with multiple hooks.""" + data = { + "capabilityId": "default", + "mcpServers": [], + "hooks": [ + { + "hookId": "before_hook", + "id": "11111111-1111-4111-8111-111111111111", + "name": "Before Hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-before", "method": "POST"}, + "timeout": 30, + "executionMode": "SYNC", + "onFailure": "CONTINUE", + "order": 1, + "canShortCircuit": True, + }, + { + "hookId": "after_hook", + "id": "22222222-2222-4222-8222-222222222222", + "name": "After Hook", + "hookType": "AFTER", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-after", "method": "POST"}, + "timeout": 60, + "executionMode": "ASYNC", + "onFailure": "BLOCK", + "order": 2, + "canShortCircuit": False, + }, + ], + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert len(impl.hooks) == 2 + assert impl.hooks[0].hook_id == "before_hook" + assert impl.hooks[1].hook_id == "after_hook" + + +class TestHookCapability: + """Tests for HookCapability dataclass.""" + + def test_construction(self): + hook_cap = HookCapability( + id="before_tool_execution", + type=HookType.BEFORE, + display_name="Before Tool Execution", + description="Hook that runs before tool execution", + ) + assert hook_cap.id == "before_tool_execution" + assert hook_cap.type == "BEFORE" + assert hook_cap.display_name == "Before Tool Execution" + assert hook_cap.description == "Hook that runs before tool execution" + + def test_construction_with_different_types(self): + """Test construction with different hook types.""" + after_hook = HookCapability( + id="after_tool_execution", + type=HookType.AFTER, + display_name="After Tool Execution", + description="Hook that runs after tool execution", + ) + assert after_hook.type == "AFTER" + + before_hook = HookCapability( + id="validation_hook", + type=HookType.BEFORE, + display_name="Before Hook", + description="Hook for validation", + ) + assert before_hook.type == "BEFORE" + + +class TestHook: + """Tests for Hook dataclass.""" + + def test_construction(self): + hook = Hook( + hook_id="before_tool_execution", + id="9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11", + n8n_workflow_config=N8nWorkflowConfig( + workflow_id="wf-001", + method=HTTPMethod.POST, + ), + name="Before Tool Execution Hook", + type=HookType.BEFORE, + deployment_type=DeploymentType.N8N, + timeout=30, + execution_mode=ExecutionMode.SYNC, + on_failure=OnFailure.CONTINUE, + order=1, + can_short_circuit=True, + ) + assert hook.hook_id == "before_tool_execution" + assert hook.id == "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11" + assert hook.n8n_workflow_config.workflow_id == "wf-001" + assert hook.n8n_workflow_config.method == HTTPMethod.POST + assert hook.name == "Before Tool Execution Hook" + assert hook.type == "BEFORE" + assert hook.deployment_type == "N8N" + assert hook.timeout == 30 + assert hook.execution_mode == "SYNC" + assert hook.on_failure == "CONTINUE" + assert hook.order == 1 + assert hook.can_short_circuit is True + + def test_from_dict_complete(self): + """Parse a complete hook entry from backend JSON.""" + data = { + "hookId": "before_tool_execution", + "id": "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11", + "name": "Before Tool Execution Hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-001", "method": "POST"}, + "timeout": 30, + "executionMode": "SYNC", + "onFailure": "CONTINUE", + "order": 1, + "canShortCircuit": True, + } + hook = Hook.from_dict(data) + assert hook.hook_id == "before_tool_execution" + assert hook.id == "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11" + assert hook.name == "Before Tool Execution Hook" + assert hook.type == HookType.BEFORE + assert hook.deployment_type == DeploymentType.N8N + assert hook.n8n_workflow_config.workflow_id == "wf-001" + assert hook.n8n_workflow_config.method == HTTPMethod.POST + assert hook.timeout == 30 + assert hook.execution_mode == ExecutionMode.SYNC + assert hook.on_failure == OnFailure.CONTINUE + assert hook.order == 1 + assert hook.can_short_circuit is True + + def test_from_dict_missing_fields_uses_defaults(self): + """Missing required enum fields raises ValueError.""" + data = {} + with pytest.raises(ValueError, match="Invalid or missing hookType"): + Hook.from_dict(data) + + def test_from_dict_partial_fields(self): + """Parse hook with only some fields present but required enums provided.""" + data = { + "hookId": "partial_hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-partial", "method": "POST"}, + } + hook = Hook.from_dict(data) + assert hook.hook_id == "partial_hook" + assert hook.n8n_workflow_config.workflow_id == "wf-partial" + assert hook.type == HookType.BEFORE + assert hook.deployment_type == DeploymentType.N8N + # Other fields should use defaults + assert hook.id == "" + assert hook.timeout == 30 + + def test_from_dict_async_execution_mode(self): + """Parse hook with ASYNC execution mode.""" + data = { + "hookId": "async_hook", + "id": "6a9e0cef-eed6-4f1b-9f86-3d8e9f5c1d22", + "name": "Async Hook", + "hookType": "AFTER", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-async-001", "method": "POST"}, + "timeout": 60, + "executionMode": "ASYNC", + "onFailure": "BLOCK", + "order": 2, + "canShortCircuit": False, + } + hook = Hook.from_dict(data) + assert hook.execution_mode == ExecutionMode.ASYNC + assert hook.on_failure == OnFailure.BLOCK + assert hook.timeout == 60 + assert hook.order == 2 + assert hook.can_short_circuit is False + + def test_from_dict_different_deployment_types(self): + """Parse Hook with different deployment types.""" + n8n_data = { + "hookId": "n8n_hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-n8n", "method": "POST"}, + } + n8n_hook = Hook.from_dict(n8n_data) + assert n8n_hook.deployment_type == DeploymentType.N8N + + serverless_data = { + "hookId": "lambda_hook", + "hookType": "AFTER", + "deploymentType": "SERVERLESS", + "n8nWorkflowConfig": { + "workflowId": "wf-serverless", + "method": "POST", + }, + } + serverless_hook = Hook.from_dict(serverless_data) + assert serverless_hook.deployment_type == DeploymentType.SERVERLESS + + def test_from_dict_different_hook_types(self): + """Parse Hook with different hook types.""" + before_data = { + "hookId": "before", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-before", "method": "POST"}, + } + before_hook = Hook.from_dict(before_data) + assert before_hook.type == HookType.BEFORE + + after_data = { + "hookId": "after", + "hookType": "AFTER", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-after", "method": "POST"}, + } + after_hook = Hook.from_dict(after_data) + assert after_hook.type == HookType.AFTER + + def test_from_dict_can_short_circuit_true(self): + """Parse hook with canShortCircuit set to true.""" + data = { + "hookId": "short_circuit", + "hookType": "BEFORE", + "deploymentType": "N8N", + "canShortCircuit": True, + "n8nWorkflowConfig": {"workflowId": "wf-short", "method": "POST"}, + } + hook = Hook.from_dict(data) + assert hook.can_short_circuit is True + + def test_from_dict_can_short_circuit_false(self): + """Parse hook with canShortCircuit explicitly set to false.""" + data = { + "hookId": "no_short_circuit", + "hookType": "BEFORE", + "deploymentType": "N8N", + "canShortCircuit": False, + "n8nWorkflowConfig": {"workflowId": "wf-no-short", "method": "POST"}, + } + hook = Hook.from_dict(data) + assert hook.can_short_circuit is False + + def test_from_dict_workflow_config_preserved(self): + """Parse hook with n8nWorkflowConfig.""" + data = { + "hookId": "wf_hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-special-123", "method": "PUT"}, + } + hook = Hook.from_dict(data) + assert hook.n8n_workflow_config.workflow_id == "wf-special-123" + assert hook.n8n_workflow_config.method == HTTPMethod.PUT + + def test_from_dict_empty_string_values(self): + """Parse hook with empty string enum values raises ValueError.""" + data = { + "hookId": "", + "id": "", + "name": "", + "hookType": "", + "deploymentType": "", + "n8nWorkflowConfig": {"workflowId": "", "method": "POST"}, + } + with pytest.raises(ValueError, match="Invalid or missing hookType"): + Hook.from_dict(data) + + def test_from_dict_different_http_methods(self): + """Parse hook with different HTTP methods.""" + for method_str, method_enum in [ + ("GET", HTTPMethod.GET), + ("POST", HTTPMethod.POST), + ("PUT", HTTPMethod.PUT), + ("PATCH", HTTPMethod.PATCH), + ("DELETE", HTTPMethod.DELETE), + ]: + data = { + "hookId": f"{method_str.lower()}_hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": { + "workflowId": f"wf-{method_str.lower()}", + "method": method_str, + }, + } + hook = Hook.from_dict(data) + assert hook.n8n_workflow_config.method == method_enum + + def test_from_dict_method_defaults_to_post(self): + """When method field is missing, it defaults to POST.""" + data = { + "hookId": "hook_without_method", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-no-method"}, + } + hook = Hook.from_dict(data) + assert hook.n8n_workflow_config.method == HTTPMethod.POST + + def test_from_dict_invalid_hook_type_raises_error(self): + """Invalid hookType value raises ValueError.""" + data = { + "hookId": "invalid_hook", + "hookType": "INVALID_TYPE", + "deploymentType": "N8N", + "n8nWorkflowConfig": {"workflowId": "wf-invalid-type", "method": "POST"}, + } + with pytest.raises( + ValueError, match="Invalid or missing hookType.*INVALID_TYPE" + ): + Hook.from_dict(data) + + def test_from_dict_invalid_deployment_type_raises_error(self): + """Invalid deploymentType value raises ValueError.""" + data = { + "hookId": "invalid_hook", + "hookType": "BEFORE", + "deploymentType": "INVALID_DEPLOYMENT", + "n8nWorkflowConfig": { + "workflowId": "wf-invalid-deployment", + "method": "POST", + }, + } + with pytest.raises( + ValueError, match="Invalid or missing deploymentType.*INVALID_DEPLOYMENT" + ): + Hook.from_dict(data) + + +class TestExtensionSourceInfo: + """Tests for ExtensionSourceInfo dataclass.""" + + def test_construction(self): + """Construct with explicit fields.""" + info = ExtensionSourceInfo( + extension_name="my-ext", + extension_version="2", + extension_id="uuid-123", + ) + assert info.extension_name == "my-ext" + assert info.extension_version == "2" + assert info.extension_id == "uuid-123" + + def test_from_dict(self): + """Parse from backend JSON shape.""" + data = { + "extensionName": "ap-invoice-extension", + "extensionVersion": "3", + "extensionId": "a1b2c3d4-e5f6", + } + info = ExtensionSourceInfo.from_dict(data) + assert info.extension_name == "ap-invoice-extension" + assert info.extension_version == "3" + assert info.extension_id == "a1b2c3d4-e5f6" + + def test_from_dict_defaults(self): + """Parse from empty dict uses defaults.""" + info = ExtensionSourceInfo.from_dict({}) + assert info.extension_name == "" + assert info.extension_version == "" + assert info.extension_id == "" + + def test_from_dict_partial(self): + """Parse from partial dict fills missing fields with defaults.""" + data = {"extensionName": "my-ext"} + info = ExtensionSourceInfo.from_dict(data) + assert info.extension_name == "my-ext" + assert info.extension_version == "" + assert info.extension_id == "" + + def test_from_value_string(self): + """Plain string (old format) creates info with name only.""" + info = ExtensionSourceInfo.from_value("servicenow-ext") + assert info.extension_name == "servicenow-ext" + assert info.extension_version == "" + assert info.extension_id == "" + + def test_from_value_dict(self): + """Dict (new format) creates full info.""" + data = { + "extensionName": "my-ext", + "extensionVersion": "5", + "extensionId": "uuid-abc", + } + info = ExtensionSourceInfo.from_value(data) + assert info.extension_name == "my-ext" + assert info.extension_version == "5" + assert info.extension_id == "uuid-abc" + + def test_from_value_unexpected_type(self): + """Unexpected type produces empty info.""" + info = ExtensionSourceInfo.from_value(42) + assert info.extension_name == "" + assert info.extension_version == "" + assert info.extension_id == "" + + +class TestExtensionSourceMapping: + """Tests for ExtensionSourceMapping dataclass.""" + + def test_defaults(self): + """Default construction produces empty dicts.""" + mapping = ExtensionSourceMapping() + assert mapping.tools == {} + assert mapping.hooks == {} + + def test_construction(self): + """Construct with explicit tools and hooks.""" + info_a = ExtensionSourceInfo( + extension_name="ext-a", extension_version="1", extension_id="id-a" + ) + info_b = ExtensionSourceInfo( + extension_name="ext-b", extension_version="2", extension_id="id-b" + ) + mapping = ExtensionSourceMapping( + tools={"prefix_tool_a": info_a, "prefix_tool_b": info_b}, + hooks={"9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11": info_a}, + ) + assert mapping.tools["prefix_tool_a"].extension_name == "ext-a" + assert mapping.tools["prefix_tool_b"].extension_name == "ext-b" + assert ( + mapping.hooks["9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11"].extension_name + == "ext-a" + ) + + def test_from_dict_full_new_format(self): + """Parse a complete source mapping from backend JSON (new format).""" + data = { + "tools": { + "sap_mcp_servicenow_v1_create_ticket": { + "extensionName": "servicenow-ext", + "extensionVersion": "2", + "extensionId": "uuid-sn", + }, + "sap_mcp_jira_v1_create_issue": { + "extensionName": "jira-ext", + "extensionVersion": "1", + "extensionId": "uuid-jira", + }, + }, + "hooks": { + "3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e": { + "extensionName": "workflow-ext", + "extensionVersion": "3", + "extensionId": "uuid-wf", + }, + "6a9e0cef-eed6-4f1b-9f86-3d8e9f5c1d22": { + "extensionName": "audit-ext", + "extensionVersion": "1", + "extensionId": "uuid-audit", + }, + }, + } + mapping = ExtensionSourceMapping.from_dict(data) + assert ( + mapping.tools["sap_mcp_servicenow_v1_create_ticket"].extension_name + == "servicenow-ext" + ) + assert ( + mapping.tools["sap_mcp_servicenow_v1_create_ticket"].extension_version + == "2" + ) + assert ( + mapping.tools["sap_mcp_servicenow_v1_create_ticket"].extension_id + == "uuid-sn" + ) + assert ( + mapping.tools["sap_mcp_jira_v1_create_issue"].extension_name == "jira-ext" + ) + assert ( + mapping.hooks["3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e"].extension_name + == "workflow-ext" + ) + assert ( + mapping.hooks["6a9e0cef-eed6-4f1b-9f86-3d8e9f5c1d22"].extension_name + == "audit-ext" + ) + + def test_from_dict_old_format_backward_compat(self): + """Parse old format where values are plain strings.""" + data = { + "tools": { + "sap_mcp_servicenow_v1_create_ticket": "servicenow-ext", + }, + "hooks": { + "3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e": "workflow-ext", + }, + } + mapping = ExtensionSourceMapping.from_dict(data) + assert ( + mapping.tools["sap_mcp_servicenow_v1_create_ticket"].extension_name + == "servicenow-ext" + ) + assert ( + mapping.tools["sap_mcp_servicenow_v1_create_ticket"].extension_version == "" + ) + assert mapping.tools["sap_mcp_servicenow_v1_create_ticket"].extension_id == "" + assert ( + mapping.hooks["3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e"].extension_name + == "workflow-ext" + ) + + def test_from_dict_empty(self): + """Parse empty dict produces empty mappings.""" + mapping = ExtensionSourceMapping.from_dict({}) + assert mapping.tools == {} + assert mapping.hooks == {} + + def test_from_dict_only_tools(self): + """Parse with only tools key present.""" + data = { + "tools": { + "prefix_tool": { + "extensionName": "my-ext", + "extensionVersion": "1", + "extensionId": "id-1", + } + } + } + mapping = ExtensionSourceMapping.from_dict(data) + assert mapping.tools["prefix_tool"].extension_name == "my-ext" + assert mapping.hooks == {} + + def test_from_dict_only_hooks(self): + """Parse with only hooks key present.""" + data = { + "hooks": { + "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11": { + "extensionName": "my-ext", + "extensionVersion": "1", + "extensionId": "id-1", + } + } + } + mapping = ExtensionSourceMapping.from_dict(data) + assert mapping.tools == {} + assert ( + mapping.hooks["9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11"].extension_name + == "my-ext" + ) + + def test_mutable_default_isolation(self): + """Verify each instance gets its own tools and hooks dicts.""" + m1 = ExtensionSourceMapping() + m2 = ExtensionSourceMapping() + m1.tools["new_tool"] = ExtensionSourceInfo( + extension_name="ext-a", extension_version="1", extension_id="id-a" + ) + m1.hooks["new_hook"] = ExtensionSourceInfo( + extension_name="ext-b", extension_version="1", extension_id="id-b" + ) + assert m2.tools == {} + assert m2.hooks == {} + + +class TestExtensionCapabilityImplementationSource: + """Tests for source mapping on ExtensionCapabilityImplementation.""" + + def test_from_dict_with_source_new_format(self): + """Parse a backend response that includes a source mapping (new format).""" + data = { + "capabilityId": "default", + "extensionNames": ["servicenow-ext"], + "mcpServers": [ + { + "ordId": "sap.mcp:apiResource:serviceNow:v1", + "url": "https://example.com/mcp", + "toolPrefix": "sap_mcp_servicenow_v1_", + "toolNames": ["create_ticket"], + } + ], + "hooks": [ + { + "hookId": "before_hook", + "id": "3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e", + "name": "Before Hook", + "hookType": "BEFORE", + "deploymentType": "N8N", + "n8nWorkflowConfig": { + "workflowId": "wf-before-001", + "method": "POST", + }, + "timeout": 30, + "executionMode": "SYNC", + "onFailure": "CONTINUE", + "order": 1, + "canShortCircuit": False, + } + ], + "source": { + "tools": { + "sap_mcp_servicenow_v1_create_ticket": { + "extensionName": "servicenow-ext", + "extensionVersion": "2", + "extensionId": "uuid-sn", + } + }, + "hooks": { + "3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e": { + "extensionName": "workflow-ext", + "extensionVersion": "1", + "extensionId": "uuid-wf", + } + }, + }, + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.source is not None + assert ( + impl.source.tools["sap_mcp_servicenow_v1_create_ticket"].extension_name + == "servicenow-ext" + ) + assert ( + impl.source.tools["sap_mcp_servicenow_v1_create_ticket"].extension_version + == "2" + ) + assert ( + impl.source.tools["sap_mcp_servicenow_v1_create_ticket"].extension_id + == "uuid-sn" + ) + assert ( + impl.source.hooks["3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e"].extension_name + == "workflow-ext" + ) + + def test_from_dict_with_source_old_format(self): + """Parse a backend response with old string-based source mapping.""" + data = { + "capabilityId": "default", + "mcpServers": [], + "source": { + "tools": {"sap_mcp_servicenow_v1_create_ticket": "servicenow-ext"}, + "hooks": {"3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e": "workflow-ext"}, + }, + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.source is not None + assert ( + impl.source.tools["sap_mcp_servicenow_v1_create_ticket"].extension_name + == "servicenow-ext" + ) + assert ( + impl.source.tools["sap_mcp_servicenow_v1_create_ticket"].extension_version + == "" + ) + assert ( + impl.source.hooks["3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e"].extension_name + == "workflow-ext" + ) + + def test_from_dict_without_source(self): + """Absent source key results in None.""" + data = { + "capabilityId": "default", + "mcpServers": [], + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.source is None + + def test_from_dict_with_null_source(self): + """Explicit null source results in None.""" + data = { + "capabilityId": "default", + "mcpServers": [], + "source": None, + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.source is None + + def test_from_dict_with_empty_source(self): + """Empty source object is treated as absent (no attribution data).""" + data = { + "capabilityId": "default", + "mcpServers": [], + "source": {}, + } + impl = ExtensionCapabilityImplementation.from_dict(data) + assert impl.source is None + + def test_get_extension_for_tool_with_source(self): + """Tool in source mapping returns the specific extension name.""" + info_a = ExtensionSourceInfo( + extension_name="ext-a", extension_version="1", extension_id="id-a" + ) + info_b = ExtensionSourceInfo( + extension_name="ext-b", extension_version="2", extension_id="id-b" + ) + impl = ExtensionCapabilityImplementation( + capability_id="default", + source=ExtensionSourceMapping( + tools={ + "prefix_tool_a": info_a, + "prefix_tool_b": info_b, + }, + ), + ) + assert impl.get_extension_for_tool("prefix_tool_a") == "ext-a" + assert impl.get_extension_for_tool("prefix_tool_b") == "ext-b" + + def test_get_extension_for_tool_not_in_source(self): + """Tool NOT in source mapping returns None.""" + info_a = ExtensionSourceInfo( + extension_name="ext-a", extension_version="1", extension_id="id-a" + ) + impl = ExtensionCapabilityImplementation( + capability_id="default", + source=ExtensionSourceMapping( + tools={"prefix_tool_a": info_a}, + ), + ) + assert impl.get_extension_for_tool("unknown_tool") is None + + def test_get_extension_for_tool_no_source(self): + """No source mapping returns None.""" + impl = ExtensionCapabilityImplementation( + capability_id="default", + ) + assert impl.get_extension_for_tool("any_tool") is None + + def test_get_extension_for_hook_with_source(self): + """Hook in source mapping returns the specific extension name.""" + info_wf = ExtensionSourceInfo( + extension_name="workflow-ext", extension_version="1", extension_id="id-wf" + ) + info_audit = ExtensionSourceInfo( + extension_name="audit-ext", extension_version="2", extension_id="id-audit" + ) + impl = ExtensionCapabilityImplementation( + capability_id="default", + source=ExtensionSourceMapping( + hooks={ + "3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e": info_wf, + "6a9e0cef-eed6-4f1b-9f86-3d8e9f5c1d22": info_audit, + }, + ), + ) + assert ( + impl.get_extension_for_hook("3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e") + == "workflow-ext" + ) + assert ( + impl.get_extension_for_hook("6a9e0cef-eed6-4f1b-9f86-3d8e9f5c1d22") + == "audit-ext" + ) + + def test_get_extension_for_hook_not_in_source(self): + """Hook NOT in source mapping returns None.""" + info_a = ExtensionSourceInfo( + extension_name="ext-a", extension_version="1", extension_id="id-a" + ) + impl = ExtensionCapabilityImplementation( + capability_id="default", + source=ExtensionSourceMapping( + hooks={"9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11": info_a}, + ), + ) + assert ( + impl.get_extension_for_hook("00000000-0000-4000-8000-000000000000") is None + ) + + def test_get_extension_for_hook_no_source(self): + """No source mapping returns None.""" + impl = ExtensionCapabilityImplementation( + capability_id="default", + ) + assert ( + impl.get_extension_for_hook("00000000-0000-4000-8000-000000000000") is None + ) + + def test_get_extension_for_hook_no_source_no_names(self): + """No source mapping and no extension_names returns None.""" + impl = ExtensionCapabilityImplementation(capability_id="default") + assert ( + impl.get_extension_for_hook("00000000-0000-4000-8000-000000000000") is None + ) + + def test_get_source_info_for_tool_with_source(self): + """Tool in source mapping returns full ExtensionSourceInfo.""" + info_a = ExtensionSourceInfo( + extension_name="ext-a", extension_version="3", extension_id="uuid-a" + ) + impl = ExtensionCapabilityImplementation( + capability_id="default", + source=ExtensionSourceMapping( + tools={"prefix_tool_a": info_a}, + ), + ) + result = impl.get_source_info_for_tool("prefix_tool_a") + assert result is not None + assert result.extension_name == "ext-a" + assert result.extension_version == "3" + assert result.extension_id == "uuid-a" + + def test_get_source_info_for_tool_not_found(self): + """Tool NOT in source mapping returns None.""" + info_a = ExtensionSourceInfo( + extension_name="ext-a", extension_version="1", extension_id="id-a" + ) + impl = ExtensionCapabilityImplementation( + capability_id="default", + source=ExtensionSourceMapping( + tools={"prefix_tool_a": info_a}, + ), + ) + assert impl.get_source_info_for_tool("unknown_tool") is None + + def test_get_source_info_for_tool_no_source(self): + """No source mapping returns None.""" + impl = ExtensionCapabilityImplementation(capability_id="default") + assert impl.get_source_info_for_tool("any_tool") is None + + def test_get_source_info_for_hook_with_source(self): + """Hook in source mapping returns full ExtensionSourceInfo.""" + info_wf = ExtensionSourceInfo( + extension_name="workflow-ext", extension_version="5", extension_id="uuid-wf" + ) + impl = ExtensionCapabilityImplementation( + capability_id="default", + source=ExtensionSourceMapping( + hooks={"3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e": info_wf}, + ), + ) + result = impl.get_source_info_for_hook("3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e") + assert result is not None + assert result.extension_name == "workflow-ext" + assert result.extension_version == "5" + assert result.extension_id == "uuid-wf" + + def test_get_source_info_for_hook_not_found(self): + """Hook NOT in source mapping returns None.""" + info_a = ExtensionSourceInfo( + extension_name="ext-a", extension_version="1", extension_id="id-a" + ) + impl = ExtensionCapabilityImplementation( + capability_id="default", + source=ExtensionSourceMapping( + hooks={"9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11": info_a}, + ), + ) + assert ( + impl.get_source_info_for_hook("00000000-0000-4000-8000-000000000000") + is None + ) + + def test_get_source_info_for_hook_no_source(self): + """No source mapping returns None.""" + impl = ExtensionCapabilityImplementation(capability_id="default") + assert ( + impl.get_source_info_for_hook("00000000-0000-4000-8000-000000000000") + is None + ) diff --git a/tests/extensibility/unit/test_noop_transport.py b/tests/extensibility/unit/test_noop_transport.py new file mode 100644 index 0000000..1bf0cbf --- /dev/null +++ b/tests/extensibility/unit/test_noop_transport.py @@ -0,0 +1,31 @@ +"""Tests for NoOpTransport.""" + +from sap_cloud_sdk.extensibility._noop_transport import NoOpTransport +from sap_cloud_sdk.extensibility._models import ExtensionCapabilityImplementation + + +class TestNoOpTransport: + """Tests for NoOpTransport.""" + + def test_returns_empty_result(self): + """Test that NoOpTransport returns an empty ExtensionCapabilityImplementation.""" + transport = NoOpTransport() + result = transport.get_extension_capability_implementation() + + assert isinstance(result, ExtensionCapabilityImplementation) + assert result.capability_id == "default" + assert result.mcp_servers == [] + assert result.instruction is None + assert result.extension_names == [] + assert result.hooks == [] + + def test_custom_capability_id(self): + """Test that NoOpTransport passes through the capability_id.""" + transport = NoOpTransport() + result = transport.get_extension_capability_implementation( + capability_id="custom" + ) + + assert result.capability_id == "custom" + assert result.mcp_servers == [] + assert result.hooks == [] diff --git a/tests/extensibility/unit/test_ord_integration.py b/tests/extensibility/unit/test_ord_integration.py new file mode 100644 index 0000000..8da1564 --- /dev/null +++ b/tests/extensibility/unit/test_ord_integration.py @@ -0,0 +1,340 @@ +"""Tests for ORD integration helpers.""" + +from unittest.mock import MagicMock + + +from sap_cloud_sdk.extensibility._ord_integration import ( + _derive_mcp_name_from_ord_id, + _map_capability_to_integration_dependencies, + add_extension_integration_dependencies, +) +from sap_cloud_sdk.extensibility._models import ( + ExtensionCapabilityImplementation, + McpServer, +) + + +class TestDeriveMcpNameFromOrdId: + """Tests for _derive_mcp_name_from_ord_id.""" + + def test_sap_s4_namespace(self): + assert ( + _derive_mcp_name_from_ord_id("sap.s4:apiResource:s4bpintelmcp:v1") == "S4" + ) + + def test_sap_ariba_namespace(self): + assert ( + _derive_mcp_name_from_ord_id("sap.ariba:apiResource:hardwareMcp:v1") + == "Ariba" + ) + + def test_simple_namespace(self): + assert ( + _derive_mcp_name_from_ord_id("sap.mcp:apiResource:serviceNow:v1") == "Mcp" + ) + + def test_multiple_dots_namespace(self): + assert ( + _derive_mcp_name_from_ord_id( + "sap.btpn8n:apiResource:ManagedN8nMcpServer:v1" + ) + == "Btpn8N" + ) + + def test_preserves_full_namespace(self): + result = _derive_mcp_name_from_ord_id("sap.custom:apiResource:customMcp:v1") + assert result == "Custom" + + +class TestMapCapabilityToIntegrationDependencies: + """Tests for _map_capability_to_integration_dependencies.""" + + def test_no_mcp_servers_returns_empty(self): + capability_impl = ExtensionCapabilityImplementation( + capability_id="default", + mcp_servers=[], + ) + result = _map_capability_to_integration_dependencies(capability_impl) + assert result == [] + + def test_with_mcp_servers_no_base_deps(self): + capability_impl = ExtensionCapabilityImplementation( + capability_id="default", + mcp_servers=[ + McpServer( + ord_id="sap.s4:apiResource:s4bpintelmcp:v1", + global_tenant_id="tenant-s4-1", + ), + ], + ) + agent = { + "ordId": "sap.agtpocext:agent:extensibility-agent:v1", + "partOfPackage": "sap.agtpocext:package:test:v1", + } + + result = _map_capability_to_integration_dependencies( + capability_impl, agent=agent + ) + + assert len(result) == 1 + dep = result[0] + assert dep["ordId"] == "sap.agtpocext:integrationDependency:extension-mcp:v1" + assert dep["title"] == "Extension MCP Servers" + assert dep["version"] == "1.0.0" + assert dep["releaseStatus"] == "active" + assert dep["visibility"] == "public" + assert dep["mandatory"] is False + assert dep["partOfPackage"] == "sap.agtpocext:package:test:v1" + assert "lastUpdate" in dep + assert len(dep["aspects"]) == 1 + assert dep["aspects"][0]["title"] == "S4 Extension MCP" + assert dep["aspects"][0]["mandatory"] is False + assert ( + dep["aspects"][0]["apiResources"][0]["ordId"] + == "sap.s4:apiResource:s4bpintelmcp:v1" + ) + + def test_with_duplicate_mcps_filtered(self): + """MCP servers already in base are filtered out.""" + capability_impl = ExtensionCapabilityImplementation( + capability_id="default", + mcp_servers=[ + McpServer( + ord_id="sap.s4:apiResource:s4bpintelmcp:v1", + global_tenant_id="tenant-s4-1", + ), + McpServer( + ord_id="sap.btpn8n:apiResource:ManagedN8nMcpServer:v1", + global_tenant_id="tenant-n8n-1", + ), + ], + ) + agent = { + "ordId": "sap.agtpocext:agent:extensibility-agent:v1", + "partOfPackage": "sap.agtpocext:package:test:v1", + } + + base_integration_deps = [ + { + "ordId": "sap.agtpocext:integrationDependency:n8n-mcp:v1", + "aspects": [ + { + "title": "N8n MCP", + "apiResources": [ + {"ordId": "sap.btpn8n:apiResource:ManagedN8nMcpServer:v1"} + ], + } + ], + } + ] + + result = _map_capability_to_integration_dependencies( + capability_impl, agent=agent, base_integration_deps=base_integration_deps + ) + + assert len(result) == 1 + dep = result[0] + assert len(dep["aspects"]) == 1 + assert ( + dep["aspects"][0]["apiResources"][0]["ordId"] + == "sap.s4:apiResource:s4bpintelmcp:v1" + ) + + def test_all_mcps_duplicate_returns_empty(self): + """All MCP servers are duplicates, returns empty list.""" + capability_impl = ExtensionCapabilityImplementation( + capability_id="default", + mcp_servers=[ + McpServer( + ord_id="sap.btpn8n:apiResource:ManagedN8nMcpServer:v1", + global_tenant_id="tenant-n8n-1", + ), + ], + ) + agent = { + "ordId": "sap.agtpocext:agent:extensibility-agent:v1", + "partOfPackage": "sap.agtpocext:package:test:v1", + } + + base_integration_deps = [ + { + "ordId": "sap.agtpocext:integrationDependency:n8n-mcp:v1", + "aspects": [ + { + "title": "N8n MCP", + "apiResources": [ + {"ordId": "sap.btpn8n:apiResource:ManagedN8nMcpServer:v1"} + ], + } + ], + } + ] + + result = _map_capability_to_integration_dependencies( + capability_impl, agent=agent, base_integration_deps=base_integration_deps + ) + + assert result == [] + + def test_multiple_mcps_all_new(self): + """Multiple new MCP servers, none duplicating base.""" + capability_impl = ExtensionCapabilityImplementation( + capability_id="default", + mcp_servers=[ + McpServer( + ord_id="sap.s4:apiResource:s4bpintelmcp:v1", + global_tenant_id="tenant-s4-1", + ), + McpServer( + ord_id="sap.ariba:apiResource:hardwareMcp:v1", + global_tenant_id="tenant-ariba-1", + ), + ], + ) + agent = { + "ordId": "sap.agtpocext:agent:extensibility-agent:v1", + "partOfPackage": "sap.agtpocext:package:test:v1", + } + + result = _map_capability_to_integration_dependencies( + capability_impl, agent=agent + ) + + assert len(result) == 1 + dep = result[0] + assert len(dep["aspects"]) == 2 + aspect_titles = [a["title"] for a in dep["aspects"]] + assert "S4 Extension MCP" in aspect_titles + assert "Ariba Extension MCP" in aspect_titles + + +class TestAddExtensionIntegrationDependencies: + """Tests for add_extension_integration_dependencies.""" + + def test_no_ext_client_returns_early(self): + """When ext_client is None, returns early without modifying document.""" + document = { + "agents": [{"ordId": "sap.agtpocext:agent:extensibility-agent:v1"}], + "integrationDependencies": [], + } + + add_extension_integration_dependencies(document=document, ext_client=None) + + assert document["integrationDependencies"] == [] + + def test_with_ext_client_adds_integration_deps(self): + """With ext_client, adds integration dependencies to document.""" + mock_client = MagicMock() + mock_client.get_extension_capability_implementation.return_value = ( + ExtensionCapabilityImplementation( + capability_id="default", + mcp_servers=[ + McpServer( + ord_id="sap.s4:apiResource:s4bpintelmcp:v1", + global_tenant_id="tenant-s4-1", + ), + ], + ) + ) + + document = { + "agents": [ + { + "ordId": "sap.agtpocext:agent:extensibility-agent:v1", + "partOfPackage": "sap.agtpocext:package:test:v1", + "integrationDependencies": [], + } + ], + "integrationDependencies": [], + } + + add_extension_integration_dependencies( + document=document, + local_tenant_id="tenant-1", + ext_client=mock_client, + ) + + assert len(document["integrationDependencies"]) == 1 + dep = document["integrationDependencies"][0] + assert dep["ordId"] == "sap.agtpocext:integrationDependency:extension-mcp:v1" + assert len(dep["aspects"]) == 1 + + agent = document["agents"][0] + assert ( + "sap.agtpocext:integrationDependency:extension-mcp:v1" + in agent["integrationDependencies"] + ) + + def test_existing_base_integration_deps_preserved(self): + """Existing base integration dependencies are preserved.""" + mock_client = MagicMock() + mock_client.get_extension_capability_implementation.return_value = ( + ExtensionCapabilityImplementation( + capability_id="default", + mcp_servers=[ + McpServer( + ord_id="sap.s4:apiResource:s4bpintelmcp:v1", + global_tenant_id="tenant-s4-1", + ), + ], + ) + ) + + document = { + "agents": [ + { + "ordId": "sap.agtpocext:agent:extensibility-agent:v1", + "partOfPackage": "sap.agtpocext:package:test:v1", + "integrationDependencies": [], + } + ], + "integrationDependencies": [ + { + "ordId": "sap.agtpocext:integrationDependency:n8n-mcp:v1", + "title": "N8N MCP Servers", + "aspects": [ + { + "title": "N8n MCP", + "apiResources": [ + { + "ordId": "sap.btpn8n:apiResource:ManagedN8nMcpServer:v1" + } + ], + } + ], + } + ], + } + + add_extension_integration_dependencies( + document=document, + local_tenant_id="tenant-1", + ext_client=mock_client, + ) + + ord_ids = [dep["ordId"] for dep in document["integrationDependencies"]] + assert "sap.agtpocext:integrationDependency:n8n-mcp:v1" in ord_ids + assert "sap.agtpocext:integrationDependency:extension-mcp:v1" in ord_ids + + def test_empty_capability_returns_early(self): + """When no MCP servers returned, returns early without adding.""" + mock_client = MagicMock() + mock_client.get_extension_capability_implementation.return_value = ( + ExtensionCapabilityImplementation( + capability_id="default", + mcp_servers=[], + ) + ) + + document = { + "agents": [{"ordId": "sap.agtpocext:agent:extensibility-agent:v1"}], + "integrationDependencies": [], + } + + add_extension_integration_dependencies( + document=document, + local_tenant_id="tenant-1", + ext_client=mock_client, + ) + + assert document["integrationDependencies"] == [] diff --git a/tests/extensibility/unit/test_ums_caching.py b/tests/extensibility/unit/test_ums_caching.py new file mode 100644 index 0000000..9af3c30 --- /dev/null +++ b/tests/extensibility/unit/test_ums_caching.py @@ -0,0 +1,337 @@ +"""Tests for UMS transport TTL cache.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from sap_cloud_sdk.extensibility._ums_transport import ( + UmsTransport, + _CACHE_TTL_SECONDS, + _CACHE_MAX_SIZE, + ENV_CONHOS_LANDSCAPE, +) +from sap_cloud_sdk.extensibility.exceptions import TransportError + +from tests.extensibility.unit._ums_test_helpers import ( + AGENT_ORD_ID, + UMS_RESPONSE_SINGLE, + UMS_RESPONSE_MULTIPLE, + UMS_RESPONSE_DIFFERENT_CAPABILITY, + _make_config, + _make_dest, + _make_httpx_response, +) + + +class TestUmsTransportCache: + """Tests for the UMS transport TTL cache.""" + + @pytest.fixture(autouse=True) + def _set_landscape_env(self, monkeypatch): + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + + @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") + def _make_transport(self, mock_dest_client, dest=None): + config = _make_config() + if dest is None: + dest = _make_dest() + mock_dest_client.return_value.get_destination.return_value = dest + transport = UmsTransport(AGENT_ORD_ID, config) + return transport, mock_dest_client.return_value + + def _patch_httpx(self, response): + """Return a context manager that patches httpx.Client to return *response*.""" + patcher = patch("sap_cloud_sdk.extensibility._ums_transport.httpx.Client") + mock_client_cls = patcher.start() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + return patcher, mock_client + + def test_cache_hit_returns_cached_result(self): + """Second call with same capability_id returns cached result without HTTP.""" + transport, _ = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_SINGLE) + patcher, mock_client = self._patch_httpx(response) + + try: + result1 = transport.get_extension_capability_implementation() + result2 = transport.get_extension_capability_implementation() + finally: + patcher.stop() + + # Only one HTTP call should have been made + assert mock_client.post.call_count == 1 + # Both results should be equal (not identical, since we transform on each read) + assert result1 == result2 + assert result1.extension_names == ["ServiceNow Extension"] + + def test_cache_miss_on_different_capability_id(self): + """Different capability_id keys do not share cache entries.""" + transport, _ = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_DIFFERENT_CAPABILITY) + patcher, mock_client = self._patch_httpx(response) + + try: + result1 = transport.get_extension_capability_implementation( + capability_id="default" + ) + result2 = transport.get_extension_capability_implementation( + capability_id="onboarding" + ) + finally: + patcher.stop() + + # Two HTTP calls -- one per distinct capability_id + assert mock_client.post.call_count == 2 + assert result1.capability_id == "default" + assert result2.capability_id == "onboarding" + + @patch("sap_cloud_sdk.extensibility._ums_transport.time") + def test_cache_expires_after_ttl(self, mock_time): + """After TTL expiry the next call fetches fresh data from UMS.""" + transport, _ = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_SINGLE) + patcher, mock_client = self._patch_httpx(response) + + # First call at t=0 + mock_time.monotonic.return_value = 0.0 + try: + result1 = transport.get_extension_capability_implementation() + assert mock_client.post.call_count == 1 + + # Second call at t=599 (within TTL) -- should be cached + mock_time.monotonic.return_value = 599.0 + result2 = transport.get_extension_capability_implementation() + assert mock_client.post.call_count == 1 + assert result2 == result1 + + # Third call at t=601 (past TTL) -- should fetch fresh + mock_time.monotonic.return_value = 601.0 + transport.get_extension_capability_implementation() + assert mock_client.post.call_count == 2 + finally: + patcher.stop() + + def test_skip_cache_bypasses_cache(self): + """skip_cache=True always triggers an HTTP call even with a valid cache entry.""" + transport, _ = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_SINGLE) + patcher, mock_client = self._patch_httpx(response) + + try: + # Populate cache + transport.get_extension_capability_implementation() + assert mock_client.post.call_count == 1 + + # skip_cache=True should bypass + transport.get_extension_capability_implementation(skip_cache=True) + assert mock_client.post.call_count == 2 + finally: + patcher.stop() + + def test_skip_cache_updates_cache(self): + """After skip_cache=True, the fresh result is written to cache for later reads.""" + transport, _ = self._make_transport() + + response1 = _make_httpx_response(UMS_RESPONSE_SINGLE) + response2 = _make_httpx_response(UMS_RESPONSE_MULTIPLE) + + patcher = patch("sap_cloud_sdk.extensibility._ums_transport.httpx.Client") + mock_client_cls = patcher.start() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + # First call returns SINGLE, second (skip_cache) returns MULTIPLE + mock_client.post.side_effect = [response1, response2] + + try: + # Populate cache with SINGLE + result1 = transport.get_extension_capability_implementation() + assert result1.extension_names == ["ServiceNow Extension"] + + # skip_cache=True fetches MULTIPLE and updates cache + result2 = transport.get_extension_capability_implementation(skip_cache=True) + assert result2.extension_names == ["ServiceNow Extension", "Jira Extension"] + + # Normal call should now return the MULTIPLE result from cache + result3 = transport.get_extension_capability_implementation() + assert result3 == result2 + # Only 2 HTTP calls total (the third was a cache hit) + assert mock_client.post.call_count == 2 + finally: + patcher.stop() + + def test_cache_not_populated_on_error(self): + """If the UMS call fails, nothing is cached.""" + transport, dest_client = self._make_transport() + error_response = _make_httpx_response({"error": "fail"}, status_code=500) + success_response = _make_httpx_response(UMS_RESPONSE_SINGLE) + + patcher = patch("sap_cloud_sdk.extensibility._ums_transport.httpx.Client") + mock_client_cls = patcher.start() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.side_effect = [error_response, success_response] + + try: + # First call fails + with pytest.raises(TransportError): + transport.get_extension_capability_implementation() + + # Cache should be empty, so second call makes a real HTTP request + result = transport.get_extension_capability_implementation() + assert result.extension_names == ["ServiceNow Extension"] + assert mock_client.post.call_count == 2 + finally: + patcher.stop() + + def test_cache_ttl_constant_is_ten_minutes(self): + """Sanity check that the TTL constant is 600 seconds (10 minutes).""" + assert _CACHE_TTL_SECONDS == 600 + + def test_cache_isolated_by_tenant(self): + """Same capability_id but different tenants produce separate cache entries.""" + transport, _ = self._make_transport() + + response1 = _make_httpx_response(UMS_RESPONSE_SINGLE) + response2 = _make_httpx_response(UMS_RESPONSE_MULTIPLE) + + patcher = patch("sap_cloud_sdk.extensibility._ums_transport.httpx.Client") + mock_client_cls = patcher.start() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.side_effect = [response1, response2] + + try: + result_a = transport.get_extension_capability_implementation( + tenant="tenant-a" + ) + result_b = transport.get_extension_capability_implementation( + tenant="tenant-b" + ) + finally: + patcher.stop() + + # Two HTTP calls -- one per tenant + assert mock_client.post.call_count == 2 + assert result_a.extension_names == ["ServiceNow Extension"] + assert result_b.extension_names == ["ServiceNow Extension", "Jira Extension"] + + def test_cache_hit_same_tenant(self): + """Second call with the same tenant returns cached result.""" + transport, _ = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_SINGLE) + patcher, mock_client = self._patch_httpx(response) + + try: + result1 = transport.get_extension_capability_implementation( + tenant="tenant-a" + ) + result2 = transport.get_extension_capability_implementation( + tenant="tenant-a" + ) + finally: + patcher.stop() + + assert mock_client.post.call_count == 1 + assert result1 == result2 + + def test_different_tenants_are_separate_cache_keys(self): + """Different tenant values produce separate cache entries.""" + transport, _ = self._make_transport() + + response1 = _make_httpx_response(UMS_RESPONSE_SINGLE) + response2 = _make_httpx_response(UMS_RESPONSE_MULTIPLE) + + patcher = patch("sap_cloud_sdk.extensibility._ums_transport.httpx.Client") + mock_client_cls = patcher.start() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.side_effect = [response1, response2] + + try: + result_a = transport.get_extension_capability_implementation( + tenant="tenant-a" + ) + result_b = transport.get_extension_capability_implementation( + tenant="tenant-b" + ) + finally: + patcher.stop() + + assert mock_client.post.call_count == 2 + assert result_a is not result_b + + def test_cache_max_size_constant(self): + """Sanity check that the max size constant is 256.""" + assert _CACHE_MAX_SIZE == 256 + + def test_cache_evicts_oldest_when_full(self): + """When cache reaches max size, the least-recently-used entry is evicted.""" + transport, _ = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_SINGLE) + + patcher, mock_client = self._patch_httpx(response) + try: + # Fill the cache to _CACHE_MAX_SIZE entries with distinct tenants + for i in range(_CACHE_MAX_SIZE): + transport.get_extension_capability_implementation(tenant=f"tenant-{i}") + assert len(transport._cache) == _CACHE_MAX_SIZE + + # One more insert should evict the oldest (tenant-0) + transport.get_extension_capability_implementation(tenant="tenant-new") + assert len(transport._cache) == _CACHE_MAX_SIZE + assert ("tenant-0", "default") not in transport._cache + assert ("tenant-new", "default") in transport._cache + finally: + patcher.stop() + + @patch("sap_cloud_sdk.extensibility._ums_transport.time.monotonic") + def test_cache_sweeps_expired_entries_on_insert(self, mock_time): + """Expired entries are removed when a new entry is inserted.""" + transport, _ = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_SINGLE) + + patcher, mock_client = self._patch_httpx(response) + try: + # Insert at t=0 + mock_time.return_value = 0.0 + transport.get_extension_capability_implementation(tenant="old-tenant") + assert len(transport._cache) == 1 + + # Insert at t=TTL+1 -- old entry should be swept + mock_time.return_value = _CACHE_TTL_SECONDS + 1 + transport.get_extension_capability_implementation(tenant="new-tenant") + assert ("old-tenant", "default") not in transport._cache + assert ("new-tenant", "default") in transport._cache + finally: + patcher.stop() + + def test_cache_hit_refreshes_lru_position(self): + """A cache hit moves the entry to the end so it is not evicted first.""" + transport, _ = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_SINGLE) + + patcher, mock_client = self._patch_httpx(response) + try: + # Fill cache: tenant-0 is oldest, tenant-1, tenant-2, ... + for i in range(_CACHE_MAX_SIZE): + transport.get_extension_capability_implementation(tenant=f"tenant-{i}") + assert len(transport._cache) == _CACHE_MAX_SIZE + + # Access tenant-0 so it becomes most-recently-used + transport.get_extension_capability_implementation(tenant="tenant-0") + + # Insert a new entry -- should evict tenant-1 (now the oldest), not tenant-0 + transport.get_extension_capability_implementation(tenant="tenant-new") + assert ("tenant-0", "default") in transport._cache + assert ("tenant-1", "default") not in transport._cache + assert ("tenant-new", "default") in transport._cache + finally: + patcher.stop() + diff --git a/tests/extensibility/unit/test_ums_pagination.py b/tests/extensibility/unit/test_ums_pagination.py new file mode 100644 index 0000000..c79a6ed --- /dev/null +++ b/tests/extensibility/unit/test_ums_pagination.py @@ -0,0 +1,343 @@ +"""Tests for UMS transport cursor-based pagination.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from sap_cloud_sdk.extensibility._ums_transport import ( + UmsTransport, + _MAX_PAGES, + ENV_CONHOS_LANDSCAPE, +) +from sap_cloud_sdk.extensibility.exceptions import TransportError + +from tests.extensibility.unit._ums_test_helpers import ( + AGENT_ORD_ID, + UMS_RESPONSE_SINGLE, + UMS_RESPONSE_EMPTY, + _make_config, + _make_dest, + _make_httpx_response, +) + + +class TestUmsTransportPagination: + """Tests for cursor-based pagination across multiple GraphQL pages.""" + + @pytest.fixture(autouse=True) + def _set_landscape_env(self, monkeypatch): + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + + @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") + def _make_transport(self, mock_dest_client, dest=None): + config = _make_config() + if dest is None: + dest = _make_dest() + mock_dest_client.return_value.get_destination.return_value = dest + transport = UmsTransport(AGENT_ORD_ID, config) + return transport, mock_dest_client.return_value + + def test_single_page_no_next(self): + """A response with hasNextPage=False results in one HTTP call.""" + transport, _ = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_SINGLE) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + result = transport.get_extension_capability_implementation() + + assert mock_client.post.call_count == 1 + assert result.extension_names == ["ServiceNow Extension"] + assert len(result.mcp_servers) == 1 + + def test_multiple_pages_accumulates_edges(self): + """When hasNextPage=True, transport fetches additional pages and merges edges.""" + transport, _ = self._make_transport() + + page1_response = _make_httpx_response( + { + "data": { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-1", + "title": "Extension A", + "extensionVersion": "1.0.0", + "capabilityImplementations": [ + { + "capabilityId": "default", + "instruction": {"text": "A instruction."}, + "tools": { + "additions": [ + { + "type": "MCP", + "mcpConfig": { + "globalTenantId": "tenant-a-1", + "ordId": "sap.mcp:a:v1", + "toolNames": ["tool_a"], + }, + } + ] + }, + "hooks": [], + } + ], + } + } + ], + "pageInfo": {"hasNextPage": True, "cursor": "cursor-page-1"}, + } + } + } + ) + + page2_response = _make_httpx_response( + { + "data": { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-2", + "title": "Extension B", + "extensionVersion": "2.0.0", + "capabilityImplementations": [ + { + "capabilityId": "default", + "instruction": {"text": "B instruction."}, + "tools": { + "additions": [ + { + "type": "MCP", + "mcpConfig": { + "globalTenantId": "tenant-b-1", + "ordId": "sap.mcp:b:v1", + "toolNames": ["tool_b"], + }, + } + ] + }, + "hooks": [], + } + ], + } + } + ], + "pageInfo": {"hasNextPage": False, "cursor": "cursor-page-2"}, + } + } + } + ) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.side_effect = [page1_response, page2_response] + + result = transport.get_extension_capability_implementation() + + # Two HTTP calls + assert mock_client.post.call_count == 2 + + # Both extensions are merged + assert result.extension_names == ["Extension A", "Extension B"] + assert len(result.mcp_servers) == 2 + assert result.mcp_servers[0].ord_id == "sap.mcp:a:v1" + assert result.mcp_servers[1].ord_id == "sap.mcp:b:v1" + + # Source mapping includes both extensions with their versions + assert result.source is not None + assert result.source.tools["tool_a"].extension_version == "1.0.0" + assert result.source.tools["tool_b"].extension_version == "2.0.0" + + # Instructions from all extensions are joined + assert result.instruction == "A instruction.\n\nB instruction." + + def test_cursor_sent_on_subsequent_pages(self): + """The cursor from pageInfo is passed as the 'after' variable on the next request.""" + transport, _ = self._make_transport() + + page1_response = _make_httpx_response( + { + "data": { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-1", + "title": "Ext", + "extensionVersion": "1.0.0", + "capabilityImplementations": [ + { + "capabilityId": "default", + "tools": {"additions": []}, + "hooks": [], + } + ], + } + } + ], + "pageInfo": {"hasNextPage": True, "cursor": "abc123"}, + } + } + } + ) + page2_response = _make_httpx_response( + { + "data": { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [], + "pageInfo": {"hasNextPage": False, "cursor": None}, + } + } + } + ) + + # Capture snapshots of the variables dict at each call + captured_vars = [] + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.side_effect = [page1_response, page2_response] + + original_post = mock_client.post + + def capturing_post(*args, **kwargs): + # Snapshot the variables before they can be mutated + body = kwargs.get("json", {}) + captured_vars.append(dict(body.get("variables", {}))) + return original_post(*args, **kwargs) + + mock_client.post = MagicMock(side_effect=capturing_post) + + transport.get_extension_capability_implementation() + + assert len(captured_vars) == 2 + + # First call: no 'after' variable (uses _GRAPHQL_QUERY without $after) + assert "after" not in captured_vars[0] + + # Second call: after="abc123" (uses _GRAPHQL_QUERY_WITH_CURSOR) + assert captured_vars[1]["after"] == "abc123" + + def test_empty_first_page_no_further_requests(self): + """Empty edges with hasNextPage=False stops after one request.""" + transport, _ = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_EMPTY) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + result = transport.get_extension_capability_implementation() + + assert mock_client.post.call_count == 1 + assert result.extension_names == [] + assert result.mcp_servers == [] + + def test_error_on_second_page_raises(self): + """If the second page returns an error, TransportError is raised.""" + transport, _ = self._make_transport() + + page1_response = _make_httpx_response( + { + "data": { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-1", + "title": "Ext", + "extensionVersion": "1.0.0", + "capabilityImplementations": [ + { + "capabilityId": "default", + "tools": {"additions": []}, + "hooks": [], + } + ], + } + } + ], + "pageInfo": {"hasNextPage": True, "cursor": "page1-cursor"}, + } + } + } + ) + page2_error = _make_httpx_response({"error": "server error"}, status_code=500) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.side_effect = [page1_response, page2_error] + + with pytest.raises(TransportError, match="UMS returned HTTP 500"): + transport.get_extension_capability_implementation() + + def test_missing_page_info_stops_pagination(self): + """If pageInfo is absent, treat as no next page (don't loop forever).""" + transport, _ = self._make_transport() + + response = _make_httpx_response( + { + "data": { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-1", + "title": "Ext", + "extensionVersion": "1.0.0", + "capabilityImplementations": [ + { + "capabilityId": "default", + "tools": {"additions": []}, + "hooks": [], + } + ], + } + } + ], + } + } + } + ) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + result = transport.get_extension_capability_implementation() + + assert mock_client.post.call_count == 1 + assert result.extension_names == ["Ext"] + + def test_max_pages_constant(self): + """Safety limit constant is 100.""" + assert _MAX_PAGES == 100 + diff --git a/tests/extensibility/unit/test_ums_parsing.py b/tests/extensibility/unit/test_ums_parsing.py new file mode 100644 index 0000000..542e972 --- /dev/null +++ b/tests/extensibility/unit/test_ums_parsing.py @@ -0,0 +1,462 @@ +"""Tests for UMS response parsing helpers.""" + +from http import HTTPMethod + +from sap_cloud_sdk.extensibility._models import ( + DeploymentType, + ExecutionMode, + HookType, + OnFailure, +) +from sap_cloud_sdk.extensibility._ums_transport import ( + _build_hook, + _build_mcp_server, + _build_source_mapping, + _transform_ums_response, +) + +from tests.extensibility.unit._ums_test_helpers import ( + UMS_RESPONSE_SINGLE, + UMS_RESPONSE_MULTIPLE, + UMS_RESPONSE_EMPTY, + UMS_RESPONSE_NO_INSTRUCTION, + UMS_RESPONSE_EMPTY_INSTRUCTION, + UMS_RESPONSE_DIFFERENT_CAPABILITY, +) + + +class TestBuildMcpServer: + def test_basic(self): + addition = { + "type": "MCP", + "mcpConfig": { + "globalTenantId": "tenant-abc-123", + "ordId": "sap.mcp:apiResource:serviceNow:v1", + "toolNames": ["create_ticket", "update_ticket"], + }, + } + server = _build_mcp_server(addition) + assert server.ord_id == "sap.mcp:apiResource:serviceNow:v1" + assert server.global_tenant_id == "tenant-abc-123" + assert server.tool_names == ["create_ticket", "update_ticket"] + + def test_missing_tool_names(self): + addition = { + "mcpConfig": { + "globalTenantId": "tenant-xyz", + "ordId": "sap.mcp:apiResource:x:v1", + } + } + server = _build_mcp_server(addition) + assert server.ord_id == "sap.mcp:apiResource:x:v1" + assert server.global_tenant_id == "tenant-xyz" + assert server.tool_names is None + + def test_empty_dict(self): + server = _build_mcp_server({}) + assert server.ord_id == "" + assert server.global_tenant_id == "" + assert server.tool_names is None + + +# --------------------------------------------------------------------------- +# Tests: _build_hook +# --------------------------------------------------------------------------- + + +class TestBuildHook: + def test_before_hook(self): + raw = { + "hookId": "before_tool_execution", + "type": "BEFORE", + "name": "Before Tool Execution", + "onFailure": "CONTINUE", + "timeout": 30, + "deploymentType": "N8N", + "canShortCircuit": False, + "n8nWorkflowConfig": {"workflowId": "wf-before-001", "method": "POST"}, + } + hook = _build_hook(raw) + assert hook is not None + assert hook.id == "" + assert hook.hook_id == "before_tool_execution" + assert hook.name == "Before Tool Execution" + assert hook.type == HookType.BEFORE + assert hook.deployment_type == DeploymentType.N8N + assert hook.n8n_workflow_config.workflow_id == "wf-before-001" + assert hook.n8n_workflow_config.method == HTTPMethod.POST + assert hook.timeout == 30 + assert hook.execution_mode == ExecutionMode.SYNC + assert hook.on_failure == OnFailure.CONTINUE + assert hook.order == 0 + assert hook.can_short_circuit is False + + def test_after_hook(self): + raw = { + "hookId": "after_tool_execution", + "type": "AFTER", + "name": "After Tool Execution", + "onFailure": "BLOCK", + "timeout": 60, + "deploymentType": "SERVERLESS", + "canShortCircuit": True, + "n8nWorkflowConfig": {"workflowId": "wf-after-001", "method": "POST"}, + } + hook = _build_hook(raw) + assert hook is not None + assert hook.type == HookType.AFTER + assert hook.on_failure == OnFailure.BLOCK + assert hook.timeout == 60 + assert hook.deployment_type == DeploymentType.SERVERLESS + assert hook.can_short_circuit is True + + def test_unknown_type_returns_none(self): + raw = {"hookId": "x", "type": "UNKNOWN"} + hook = _build_hook(raw) + assert hook is None + + def test_missing_type_returns_none(self): + raw = {"hookId": "x"} + hook = _build_hook(raw) + assert hook is None + + def test_empty_dict(self): + hook = _build_hook({}) + assert hook is None + + +# --------------------------------------------------------------------------- +# Tests: _build_source_mapping +# --------------------------------------------------------------------------- + + +class TestBuildSourceMapping: + def test_maps_tools_by_tool_name(self): + nodes = [ + { + "id": "ext-1", + "title": "My Extension", + "extensionVersion": "1.2.3", + "capabilityImplementations": [ + { + "capabilityId": "default", + "tools": { + "additions": [ + { + "mcpConfig": { + "globalTenantId": "tenant-ext-1", + "toolNames": ["tool_a", "tool_b"], + } + }, + ] + }, + "hooks": [], + } + ], + } + ] + mapping = _build_source_mapping(nodes, [], []) + assert "tool_a" in mapping.tools + assert mapping.tools["tool_a"].extension_name == "My Extension" + assert mapping.tools["tool_a"].extension_id == "ext-1" + assert mapping.tools["tool_a"].extension_version == "1.2.3" + assert "tool_b" in mapping.tools + + def test_maps_hooks_by_id(self): + nodes = [ + { + "id": "ext-1", + "title": "Hook Extension", + "extensionVersion": "4.0.0", + "capabilityImplementations": [ + { + "capabilityId": "default", + "tools": {"additions": []}, + "hooks": [ + { + "id": "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11", + "hookId": "before_tool_execution", + } + ], + } + ], + } + ] + mapping = _build_source_mapping(nodes, [], []) + assert "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11" in mapping.hooks + assert ( + mapping.hooks["9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11"].extension_name + == "Hook Extension" + ) + assert ( + mapping.hooks["9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11"].extension_version + == "4.0.0" + ) + + def test_empty_nodes(self): + mapping = _build_source_mapping([], [], []) + assert mapping.tools == {} + assert mapping.hooks == {} + + def test_null_hooks_in_capability(self): + """hooks: null should not crash _build_source_mapping.""" + nodes = [ + { + "id": "ext-1", + "title": "Null hooks", + "capabilityImplementations": [ + { + "capabilityId": "default", + "tools": {"additions": []}, + "hooks": None, + } + ], + } + ] + mapping = _build_source_mapping(nodes, [], []) + assert mapping.hooks == {} + + def test_missing_extension_version_defaults_to_empty(self): + """Node without extensionVersion should default to empty string.""" + nodes = [ + { + "id": "ext-1", + "title": "No Version", + "capabilityImplementations": [ + { + "capabilityId": "default", + "tools": { + "additions": [ + { + "mcpConfig": { + "globalTenantId": "tenant-nv-1", + "toolNames": ["tool_x"], + } + } + ], + }, + "hooks": [], + } + ], + } + ] + mapping = _build_source_mapping(nodes, [], []) + assert mapping.tools["tool_x"].extension_version == "" + +# --------------------------------------------------------------------------- +# Tests: _transform_ums_response +# --------------------------------------------------------------------------- + + +class TestTransformUmsResponse: + def test_single_extension(self): + result = _transform_ums_response(UMS_RESPONSE_SINGLE["data"], "default") + assert result.capability_id == "default" + assert result.extension_names == ["ServiceNow Extension"] + assert result.instruction == "Use ServiceNow tools for ticket management." + assert len(result.mcp_servers) == 1 + assert result.mcp_servers[0].ord_id == "sap.mcp:apiResource:serviceNow:v1" + assert result.mcp_servers[0].tool_names == ["create_ticket", "update_ticket"] + assert len(result.hooks) == 1 + assert result.hooks[0].hook_id == "before_tool_execution" + assert result.hooks[0].type == HookType.BEFORE + + def test_multiple_extensions(self): + result = _transform_ums_response(UMS_RESPONSE_MULTIPLE["data"], "default") + assert result.extension_names == ["ServiceNow Extension", "Jira Extension"] + assert len(result.mcp_servers) == 2 + assert result.mcp_servers[0].ord_id == "sap.mcp:apiResource:serviceNow:v1" + assert result.mcp_servers[1].ord_id == "sap.mcp:apiResource:jira:v1" + assert len(result.hooks) == 1 + assert result.hooks[0].type == HookType.AFTER + # Instructions from all extensions are joined + assert result.instruction == "ServiceNow instruction.\n\nJira instruction." + + def test_empty_edges(self): + result = _transform_ums_response(UMS_RESPONSE_EMPTY["data"], "default") + assert result.capability_id == "default" + assert result.extension_names == [] + assert result.mcp_servers == [] + assert result.instruction is None + assert result.hooks == [] + + def test_no_instruction(self): + result = _transform_ums_response(UMS_RESPONSE_NO_INSTRUCTION["data"], "default") + assert result.instruction is None + assert len(result.mcp_servers) == 1 + + def test_empty_instruction_text(self): + result = _transform_ums_response( + UMS_RESPONSE_EMPTY_INSTRUCTION["data"], "default" + ) + # Empty string is falsy, so instruction stays None + assert result.instruction is None + + def test_filters_by_capability_id(self): + result = _transform_ums_response( + UMS_RESPONSE_DIFFERENT_CAPABILITY["data"], "onboarding" + ) + assert result.capability_id == "onboarding" + assert result.instruction == "Onboarding instruction." + assert len(result.mcp_servers) == 1 + assert result.mcp_servers[0].ord_id == "sap.mcp:apiResource:onboarding:v1" + + def test_filters_excludes_non_matching_capability(self): + result = _transform_ums_response( + UMS_RESPONSE_DIFFERENT_CAPABILITY["data"], "default" + ) + assert result.capability_id == "default" + assert result.instruction == "Default instruction." + assert len(result.mcp_servers) == 1 + assert result.mcp_servers[0].ord_id == "sap.mcp:apiResource:general:v1" + + def test_nonexistent_capability_id(self): + result = _transform_ums_response(UMS_RESPONSE_SINGLE["data"], "nonexistent") + assert result.capability_id == "nonexistent" + assert result.mcp_servers == [] + assert result.instruction is None + assert result.hooks == [] + # Extension names are still populated (from node titles) + assert result.extension_names == ["ServiceNow Extension"] + + def test_source_mapping_populated(self): + result = _transform_ums_response(UMS_RESPONSE_SINGLE["data"], "default") + assert result.source is not None + assert "create_ticket" in result.source.tools + assert ( + result.source.tools["create_ticket"].extension_name + == "ServiceNow Extension" + ) + assert result.source.tools["create_ticket"].extension_id == "ext-instance-1" + assert result.source.tools["create_ticket"].extension_version == "2.1.0" + assert "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11" in result.source.hooks + + def test_hooks_with_unknown_type_skipped(self): + data = { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-1", + "title": "Test", + "capabilityImplementations": [ + { + "capabilityId": "default", + "hooks": [ + { + "hookId": "valid_hook", + "type": "BEFORE", + "name": "Valid", + "onFailure": "CONTINUE", + "timeout": 30, + "deploymentType": "N8N", + "canShortCircuit": False, + "n8nWorkflowConfig": { + "workflowId": "wf-valid", + "method": "POST", + }, + }, + { + "hookId": "invalid_hook", + "type": "UNKNOWN", + "name": "Invalid", + }, + ], + "tools": {"additions": []}, + } + ], + } + } + ] + } + } + result = _transform_ums_response(data, "default") + assert len(result.hooks) == 1 + assert result.hooks[0].hook_id == "valid_hook" + + def test_node_without_title(self): + data = { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-1", + "capabilityImplementations": [ + { + "capabilityId": "default", + "instruction": {"text": "hello"}, + "tools": {"additions": []}, + "hooks": [], + } + ], + } + } + ] + } + } + result = _transform_ums_response(data, "default") + # Empty titles are not added to extension_names + assert result.extension_names == [] + assert result.instruction == "hello" + + def test_hooks_null_in_response(self): + """GraphQL may return hooks: null instead of an empty list.""" + data = { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-1", + "title": "Null Hooks Extension", + "capabilityImplementations": [ + { + "capabilityId": "default", + "instruction": {"text": "Do stuff."}, + "tools": { + "additions": [ + { + "type": "MCP", + "mcpConfig": { + "globalTenantId": "tenant-test-1", + "ordId": "sap.mcp:apiResource:test:v1", + "toolNames": ["my_tool"], + }, + } + ] + }, + "hooks": None, + } + ], + } + } + ] + } + } + result = _transform_ums_response(data, "default") + assert result.hooks == [] + assert len(result.mcp_servers) == 1 + assert result.instruction == "Do stuff." + + def test_tools_key_missing(self): + """capabilityImplementations with no 'tools' key.""" + data = { + "EXTHUB__ExtCapImplementationInstances": { + "edges": [ + { + "node": { + "id": "ext-1", + "title": "No tools", + "capabilityImplementations": [ + { + "capabilityId": "default", + "hooks": [], + } + ], + } + } + ] + } + } + result = _transform_ums_response(data, "default") + assert result.mcp_servers == [] + diff --git a/tests/extensibility/unit/test_ums_transport.py b/tests/extensibility/unit/test_ums_transport.py new file mode 100644 index 0000000..f150e97 --- /dev/null +++ b/tests/extensibility/unit/test_ums_transport.py @@ -0,0 +1,561 @@ +"""Tests for UMS transport setup, errors, tenant handling, and integration.""" + +import base64 +import json +from unittest.mock import MagicMock, patch, ANY + +import httpx +import pytest + +from sap_cloud_sdk.extensibility._models import ( + ExtensionCapabilityImplementation, +) +from sap_cloud_sdk.extensibility._ums_transport import ( + UmsTransport, + _ums_destination_name, + _UMS_DESTINATION_PREFIX, + ENV_CONHOS_LANDSCAPE, + ENV_UMS_DESTINATION_NAME, + _GRAPHQL_QUERY, +) +from sap_cloud_sdk.extensibility.config import ExtensibilityConfig +from sap_cloud_sdk.extensibility.exceptions import TransportError + +from tests.extensibility.unit._ums_test_helpers import ( + AGENT_ORD_ID, + _FAKE_PEM, + _FAKE_PEM_B64, + UMS_RESPONSE_SINGLE, + UMS_RESPONSE_EMPTY, + UMS_RESPONSE_DIFFERENT_CAPABILITY, + _make_config, + _make_dest, + _make_httpx_response, +) + + +class TestUmsDestinationName: + def test_constructs_from_landscape_env(self, monkeypatch): + monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + assert _ums_destination_name() == "sap-managed-runtime-ums-exttest-dev-eu12" + + def test_constructs_from_landscape_env_prod(self, monkeypatch): + monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "myagent-prod-eu10") + assert _ums_destination_name() == "sap-managed-runtime-ums-myagent-prod-eu10" + + def test_returns_none_when_env_not_set(self, monkeypatch): + monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) + monkeypatch.delenv(ENV_CONHOS_LANDSCAPE, raising=False) + assert _ums_destination_name() is None + + def test_returns_none_when_env_empty(self, monkeypatch): + monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "") + assert _ums_destination_name() is None + + def test_prefix_constant(self): + assert _UMS_DESTINATION_PREFIX == "sap-managed-runtime-ums-" + + def test_override_env_takes_precedence(self, monkeypatch): + monkeypatch.setenv(ENV_UMS_DESTINATION_NAME, "ums-exttest-dev-eu12") + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + assert _ums_destination_name() == "ums-exttest-dev-eu12" + + def test_override_env_without_landscape(self, monkeypatch): + monkeypatch.setenv(ENV_UMS_DESTINATION_NAME, "my-custom-dest") + monkeypatch.delenv(ENV_CONHOS_LANDSCAPE, raising=False) + assert _ums_destination_name() == "my-custom-dest" + + def test_empty_override_falls_through_to_landscape(self, monkeypatch): + monkeypatch.setenv(ENV_UMS_DESTINATION_NAME, "") + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + assert _ums_destination_name() == "sap-managed-runtime-ums-exttest-dev-eu12" + + def test_config_override_takes_highest_priority(self, monkeypatch): + monkeypatch.setenv(ENV_UMS_DESTINATION_NAME, "env-override") + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + assert _ums_destination_name(config_override="config-dest") == "config-dest" + + def test_config_override_none_falls_through(self, monkeypatch): + monkeypatch.setenv(ENV_UMS_DESTINATION_NAME, "env-override") + assert _ums_destination_name(config_override=None) == "env-override" + + def test_config_override_empty_falls_through(self, monkeypatch): + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) + assert ( + _ums_destination_name(config_override="") + == "sap-managed-runtime-ums-exttest-dev-eu12" + ) + +# --------------------------------------------------------------------------- +# Tests: UmsTransport construction +# --------------------------------------------------------------------------- + + +class TestUmsTransportInit: + @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") + def test_valid_config(self, mock_dest_client, monkeypatch): + monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + config = _make_config() + transport = UmsTransport(AGENT_ORD_ID, config) + assert transport._config is config + assert transport._destination_name == "sap-managed-runtime-ums-exttest-dev-eu12" + mock_dest_client.assert_called_once_with(instance="default") + + @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") + def test_destination_name_none_when_env_not_set( + self, mock_dest_client, monkeypatch + ): + monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) + monkeypatch.delenv(ENV_CONHOS_LANDSCAPE, raising=False) + config = _make_config() + transport = UmsTransport(AGENT_ORD_ID, config) + assert transport._destination_name is None + + @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") + def test_config_destination_name_override(self, mock_dest_client, monkeypatch): + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + config = _make_config(destination_name="MY_CUSTOM_DEST") + transport = UmsTransport(AGENT_ORD_ID, config) + assert transport._destination_name == "MY_CUSTOM_DEST" + + +# --------------------------------------------------------------------------- +# Tests: UmsTransport.get_extension_capability_implementation +# --------------------------------------------------------------------------- + + +class TestUmsTransportGetExtCapImpl: + """Tests for the full transport flow.""" + + @pytest.fixture(autouse=True) + def _set_landscape_env(self, monkeypatch): + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + + @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") + def _make_transport(self, mock_dest_client, dest=None): + config = _make_config() + if dest is None: + dest = _make_dest() + mock_dest_client.return_value.get_destination.return_value = dest + transport = UmsTransport(AGENT_ORD_ID, config) + return transport, mock_dest_client.return_value + + @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") + def test_raises_transport_error_when_destination_name_is_none( + self, mock_dest_client, monkeypatch + ): + monkeypatch.delenv(ENV_CONHOS_LANDSCAPE, raising=False) + monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) + config = _make_config() + transport = UmsTransport(AGENT_ORD_ID, config) + assert transport._destination_name is None + with pytest.raises( + TransportError, match="UMS destination name could not be resolved" + ): + transport.get_extension_capability_implementation() + + def test_full_flow(self): + transport, dest_client = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_SINGLE) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + result = transport.get_extension_capability_implementation() + + assert isinstance(result, ExtensionCapabilityImplementation) + assert result.capability_id == "default" + assert result.extension_names == ["ServiceNow Extension"] + assert len(result.mcp_servers) == 1 + assert result.instruction == "Use ServiceNow tools for ticket management." + + def test_uses_resolved_destination_name(self): + """Verify get_destination is called with the env-var-resolved name.""" + transport, dest_client = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_EMPTY) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + transport.get_extension_capability_implementation() + + dest_client.get_destination.assert_called_once_with( + "sap-managed-runtime-ums-exttest-dev-eu12", level=ANY + ) + + def test_sends_correct_graphql_query(self): + transport, dest_client = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_EMPTY) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + transport.get_extension_capability_implementation() + + # Verify the URL + call_args = mock_client.post.call_args + assert call_args[0][0] == "https://ums.example.com/graphql" + + # Verify the GraphQL body + json_body = call_args[1]["json"] + assert json_body["query"] == _GRAPHQL_QUERY + filters = json_body["variables"]["filters"] + assert filters["agent"]["ordIdEquals"] == AGENT_ORD_ID + assert "tenantInUMSIntersects" not in filters + + def test_client_cert_passed_to_httpx(self): + transport, dest_client = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_EMPTY) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + transport.get_extension_capability_implementation() + + # httpx.Client constructed with cert= pointing to a temp file + init_kwargs = mock_client_cls.call_args[1] + assert "cert" in init_kwargs + # The cert value should be a string (temp file path) + assert isinstance(init_kwargs["cert"], str) + assert init_kwargs["cert"].endswith(".pem") + + def test_custom_capability_id(self): + transport, dest_client = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_DIFFERENT_CAPABILITY) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + result = transport.get_extension_capability_implementation( + capability_id="onboarding" + ) + + assert result.capability_id == "onboarding" + assert result.instruction == "Onboarding instruction." + assert len(result.mcp_servers) == 1 + assert result.mcp_servers[0].ord_id == "sap.mcp:apiResource:onboarding:v1" + + # ------------------------------------------------------------------ + # Error handling + # ------------------------------------------------------------------ + + def test_destination_resolution_failure(self): + transport, dest_client = self._make_transport() + dest_client.get_destination.side_effect = RuntimeError("no dest") + + with pytest.raises(TransportError, match="Failed to resolve destination"): + transport.get_extension_capability_implementation() + + def test_destination_no_url(self): + dest = _make_dest(url=None) + transport, dest_client = self._make_transport(dest=dest) + + with pytest.raises(TransportError, match="has no URL configured"): + transport.get_extension_capability_implementation() + + def test_http_request_failure(self): + transport, dest_client = self._make_transport() + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.side_effect = httpx.ConnectError("connection refused") + + with pytest.raises( + TransportError, match="HTTP request to UMS endpoint failed" + ): + transport.get_extension_capability_implementation() + + def test_http_error_status(self): + transport, dest_client = self._make_transport() + response = _make_httpx_response({"error": "unauthorized"}, status_code=401) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + with pytest.raises(TransportError, match="UMS returned HTTP 401"): + transport.get_extension_capability_implementation() + + def test_invalid_json_response(self): + transport, dest_client = self._make_transport() + response = MagicMock(spec=httpx.Response) + response.status_code = 200 + response.raise_for_status = MagicMock() + response.json.side_effect = ValueError("invalid json") + response.text = "not json" + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + with pytest.raises( + TransportError, match="Failed to parse UMS response as JSON" + ): + transport.get_extension_capability_implementation() + + def test_graphql_errors(self): + transport, dest_client = self._make_transport() + gql_error_response = { + "errors": [ + {"message": "Cannot query field 'x'"}, + {"message": "Another error"}, + ] + } + response = _make_httpx_response(gql_error_response) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + with pytest.raises( + TransportError, match="UMS GraphQL errors.*Cannot query field" + ): + transport.get_extension_capability_implementation() + + def test_missing_data_field(self): + transport, dest_client = self._make_transport() + response = _make_httpx_response({"something_else": True}) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + with pytest.raises(TransportError, match="missing the 'data' field"): + transport.get_extension_capability_implementation() + + def test_no_certificates_raises(self): + dest = _make_dest(cert_content=None) + transport, dest_client = self._make_transport(dest=dest) + + with pytest.raises(TransportError, match="has no client certificates"): + transport.get_extension_capability_implementation() + + def test_invalid_cert_base64_raises(self): + dest = _make_dest(cert_content="!!!not-valid-base64!!!") + transport, dest_client = self._make_transport(dest=dest) + + with pytest.raises(TransportError, match="Failed to decode client certificate"): + transport.get_extension_capability_implementation() + + def test_cert_content_written_to_temp_file(self): + """Verify the decoded cert bytes are written to the temp file.""" + transport, dest_client = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_EMPTY) + + with ( + patch( + "sap_cloud_sdk.extensibility._ums_transport.tempfile.NamedTemporaryFile" + ) as mock_tmpfile, + patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls, + ): + mock_file = MagicMock() + mock_file.name = "/tmp/fake-cert.pem" + mock_tmpfile.return_value.__enter__ = MagicMock(return_value=mock_file) + mock_tmpfile.return_value.__exit__ = MagicMock(return_value=False) + + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + transport.get_extension_capability_implementation() + + # Verify decoded PEM bytes were written + mock_file.write.assert_called_once_with(_FAKE_PEM) + mock_file.flush.assert_called_once() + + # Verify httpx.Client received the temp file path + init_kwargs = mock_client_cls.call_args[1] + assert init_kwargs["cert"] == "/tmp/fake-cert.pem" + + def test_trailing_slash_on_url(self): + """Base URL with trailing slash should not produce double slashes.""" + dest = _make_dest(url="https://ums.example.com/") + transport, dest_client = self._make_transport(dest=dest) + response = _make_httpx_response(UMS_RESPONSE_EMPTY) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + transport.get_extension_capability_implementation() + + call_args = mock_client.post.call_args + assert call_args[0][0] == "https://ums.example.com/graphql" + + +# --------------------------------------------------------------------------- +# Tests: UmsTransport tenant destination forwarding +# --------------------------------------------------------------------------- + + +class TestUmsTransportTenant: + """Tests that tenant is used in the GraphQL filter and X-Tenant header.""" + + @pytest.fixture(autouse=True) + def _set_landscape_env(self, monkeypatch): + monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + + @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") + def _make_transport(self, mock_dest_client, dest=None): + config = _make_config() + if dest is None: + dest = _make_dest() + mock_dest_client.return_value.get_destination.return_value = dest + transport = UmsTransport(AGENT_ORD_ID, config) + return transport, mock_dest_client.return_value + + def test_tenant_does_not_affect_destination_call(self): + """Destination is always resolved provider-scoped, regardless of tenant.""" + transport, dest_client = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_EMPTY) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + transport.get_extension_capability_implementation(tenant="my-subscriber") + + # get_destination is called without any ConsumptionOptions + dest_client.get_destination.assert_called_once_with( + "sap-managed-runtime-ums-exttest-dev-eu12", level=ANY + ) + + def test_tenant_included_in_agent_filter(self): + """Tenant is included as agent.uclSystemInstance.localTenantIdIn.""" + transport, _ = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_EMPTY) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + transport.get_extension_capability_implementation(tenant="my-subscriber") + + json_body = mock_client.post.call_args[1]["json"] + agent_filter = json_body["variables"]["filters"]["agent"] + assert agent_filter["ordIdEquals"] == AGENT_ORD_ID + assert agent_filter["uclSystemInstance"] == { + "localTenantIdIn": "my-subscriber", + } + # tenantInUMSIntersects must NOT be present + assert "tenantInUMSIntersects" not in json_body["variables"]["filters"] + + def test_x_tenant_header_set(self): + """The X-Tenant HTTP header is set to the tenant value.""" + transport, _ = self._make_transport() + response = _make_httpx_response(UMS_RESPONSE_EMPTY) + + with patch( + "sap_cloud_sdk.extensibility._ums_transport.httpx.Client" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = response + + transport.get_extension_capability_implementation( + tenant="1d2e1a41-a28b-431f-9e3f-42e9704bfa75" + ) + + headers = mock_client.post.call_args[1]["headers"] + assert headers["X-Tenant"] == "1d2e1a41-a28b-431f-9e3f-42e9704bfa75" + assert headers["Content-Type"] == "application/json" + + +# --------------------------------------------------------------------------- +# Tests: create_client integration with UmsTransport +# --------------------------------------------------------------------------- + + +class TestCreateClientUmsIntegration: + """Tests that create_client() correctly selects UmsTransport.""" + + @patch("sap_cloud_sdk.extensibility.UmsTransport") + def test_uses_ums_transport_with_config(self, mock_ums_cls): + from sap_cloud_sdk.extensibility import create_client + + config = ExtensibilityConfig(destination_name="MY_UMS") + client = create_client("sap.ai:agent:test:v1", config=config) + + mock_ums_cls.assert_called_once_with("sap.ai:agent:test:v1", config) + assert client is not None + + @patch("sap_cloud_sdk.extensibility.UmsTransport") + def test_ums_init_failure_degrades_to_noop(self, mock_ums_cls): + from sap_cloud_sdk.extensibility import create_client + from sap_cloud_sdk.extensibility.client import ExtensibilityClient + + mock_ums_cls.side_effect = RuntimeError("init failed") + + client = create_client("sap.ai:agent:test:v1") + + assert isinstance(client, ExtensibilityClient) + # Should return empty results (NoOpTransport behavior) + result = client.get_extension_capability_implementation(tenant="test-tenant") + assert result.mcp_servers == [] + assert result.instruction is None diff --git a/uv.lock b/uv.lock index 8b1f55a..fada519 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,26 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[[package]] +name = "a2a-sdk" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "culsans", marker = "python_full_version < '3.13'" }, + { name = "google-api-core" }, + { name = "googleapis-common-protos" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "json-rpc" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/f3/1c312eae0298542eef1a096be378a3ad2d20b171ea0ac6be26b81f542720/a2a_sdk-1.0.2.tar.gz", hash = "sha256:e4ee4dd509894c32c9a6df728319875fa4f049e70ae82476fa447353e3a4b648", size = 375193, upload-time = "2026-04-24T13:50:24.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/03/58c92a44e7b94a42614880df2365f074969e47067c4c736e31e855aca2fd/a2a_sdk-1.0.2-py3-none-any.whl", hash = "sha256:4dbc083b6808ee28207ac6daad263360f87612c37b2d06f5521efb530318141c", size = 234302, upload-time = "2026-04-24T13:50:22.412Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -120,6 +140,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, ] +[[package]] +name = "aiologic" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/27/206615942005471499f6fbc36621582e24d0686f33c74b2d018fcfd4fe67/aiologic-0.16.0-py3-none-any.whl", hash = "sha256:e00ce5f68c5607c864d26aec99c0a33a83bdf8237aa7312ffbb96805af67d8b6", size = 135193, upload-time = "2025-11-27T23:48:40.099Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -533,6 +567,19 @@ version = "0.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/55/ca/d323556e2bf9bfb63219fbb849ce61bb830cc42d1b25b91cde3815451b91/cuid-0.4.tar.gz", hash = "sha256:74eaba154916a2240405c3631acee708c263ef8fa05a86820b87d0f59f84e978", size = 4986, upload-time = "2023-03-06T00:41:12.708Z" } +[[package]] +name = "culsans" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -659,6 +706,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/fc/b86c22ad3b18d8324a9d6fe5a3b55403291d2bf7572ba6a16efa5aa88059/gherkin_official-29.0.0-py3-none-any.whl", hash = "sha256:26967b0d537a302119066742669e0e8b663e632769330be675457ae993e1d1bc", size = 37085, upload-time = "2024-08-12T09:41:07.954Z" }, ] +[[package]] +name = "google-api-core" +version = "2.30.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, +] + +[[package]] +name = "google-auth" +version = "2.52.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/f8/80d2493cbedece1c623dc3e3cb1883300871af0dcdae254409522985ac23/google_auth-2.52.0.tar.gz", hash = "sha256:01f30e1a9e3638698d89464f5e603ce29d18e1c0e63ec31ac570aba4e164aaf5", size = 335027, upload-time = "2026-05-07T19:45:24.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/fc/2cdc74252746f547f81ff3f02d4d4234a3f411b5de5b61af97e633a060b9/google_auth-2.52.0-py3-none-any.whl", hash = "sha256:aee92803ba0ff93a70a3b8a35c7b4797837751cd6380b63ff38372b98f3ed627", size = 245614, upload-time = "2026-05-07T19:45:21.914Z" }, +] + [[package]] name = "google-re2" version = "1.1.20251105" @@ -895,6 +971,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "json-rpc" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9", size = 28854, upload-time = "2023-06-11T09:45:49.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -2239,6 +2324,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "proto-plus" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, +] + [[package]] name = "protobuf" version = "6.33.5" @@ -2268,6 +2365,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/6d/d199a67b9580d45939419c9f2c7c9d6a898b611a908b12606d997c6ab8be/protovalidate-1.1.2-py3-none-any.whl", hash = "sha256:21d4a5ad68a0d59222411af3c53c6f63d1318381e31c069143811e193f6fcf67", size = 29655, upload-time = "2026-03-02T15:15:12.123Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -2831,6 +2949,9 @@ dependencies = [ ] [package.optional-dependencies] +extensibility = [ + { name = "a2a-sdk" }, +] langchain = [ { name = "langchain-core" }, ] @@ -2840,6 +2961,7 @@ starlette = [ [package.dev-dependencies] dev = [ + { name = "a2a-sdk" }, { name = "anyio" }, { name = "cryptography" }, { name = "httpx" }, @@ -2856,6 +2978,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "a2a-sdk", marker = "extra == 'extensibility'", specifier = ">=0.2.0" }, { name = "grpcio", specifier = ">=1.60.0" }, { name = "hatchling", specifier = "~=1.27.0" }, { name = "httpx", specifier = ">=0.27.0" }, @@ -2878,10 +3001,11 @@ requires-dist = [ { name = "traceloop-sdk", specifier = "~=0.54.0" }, { name = "wrapt", specifier = "<2" }, ] -provides-extras = ["starlette", "langchain"] +provides-extras = ["extensibility", "starlette", "langchain"] [package.metadata.requires-dev] dev = [ + { name = "a2a-sdk", specifier = ">=0.2.0" }, { name = "anyio", specifier = ">=3.6.2" }, { name = "cryptography", specifier = ">=46.0.3" }, { name = "httpx", specifier = ">=0.27.0" }, @@ -2914,6 +3038,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sse-starlette" version = "3.4.2"