diff --git a/packages/uipath-platform/CLAUDE.md b/packages/uipath-platform/CLAUDE.md index 21896676f..317f7df9b 100644 --- a/packages/uipath-platform/CLAUDE.md +++ b/packages/uipath-platform/CLAUDE.md @@ -48,6 +48,7 @@ sdk.agenthub # AgentHubService sdk.mcp # McpService sdk.resource_catalog # ResourceCatalogService sdk.automation_tracker # AutomationTrackerService +sdk.business_rules # BusinessRulesService ``` ### Authentication @@ -95,6 +96,7 @@ Services provide both sync and async variants (e.g., `.invoke()` and `.invoke_as | `action_center/` | Task management for human-in-the-loop workflows | | `agenthub/` | System agents and LLM model discovery | | `automation_tracker/` | Business Transaction Service (BTS) for Process Mining | +| `business_rules/` | Business rule runs: rules deployed to Orchestrator, or undeployed rules in a debug job's Studio project, behind one `run()` | | `chat/` | LLM gateway, conversations, throttling | | `connections/` | External connection management | | `context_grounding/` | RAG services (DeepRAG, batch RAG, ephemeral indexes) | diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 4699bfeeb..df2ba2732 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.36" +version = "0.2.37" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 98af7b8b6..52f392e66 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -9,6 +9,7 @@ from .agenthub._agenthub_service import AgentHubService from .agenthub._remote_a2a_service import RemoteA2aService from .automation_ops import AutomationOpsService +from .business_rules import BusinessRulesService from .chat import ConversationsService, UiPathLlmChatService, UiPathOpenAIService from .common import ( ApiClient, @@ -190,6 +191,10 @@ def orchestrator_setup(self) -> OrchestratorSetupService: def automation_ops(self) -> AutomationOpsService: return AutomationOpsService(self._config, self._execution_context) + @property + def business_rules(self) -> BusinessRulesService: + return BusinessRulesService(self._config, self._execution_context, self.folders) + @property def pii_detection(self) -> PiiDetectionService: return PiiDetectionService(self._config, self._execution_context) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py new file mode 100644 index 000000000..c9327b2ea --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -0,0 +1,27 @@ +"""Business Rules service package. + +Provides the ``BusinessRulesService`` client for running UiPath Business Rules, +either deployed to Orchestrator or, in a debug session, read undeployed from +the project being debugged, and the Pydantic models for its caller, results +and trace context. +""" + +from ._business_rules_service import BusinessRulesService +from .business_rules import ( + BusinessRuleCaller, + BusinessRuleDecision, + BusinessRuleError, + BusinessRuleRunResult, + BusinessRuleStatus, + BusinessRuleTraceContext, +) + +__all__ = [ + "BusinessRuleCaller", + "BusinessRuleDecision", + "BusinessRuleError", + "BusinessRuleRunResult", + "BusinessRuleStatus", + "BusinessRulesService", + "BusinessRuleTraceContext", +] diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py new file mode 100644 index 000000000..6ceafc857 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py @@ -0,0 +1,490 @@ +"""Business Rules service for UiPath Platform. + +Runs business rules: a rule deployed to Orchestrator, or, inside a debug +session, the undeployed rule from the Studio project being debugged. +""" + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +from ..common._base_service import BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig, UiPathConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext +from ..common._job_context import header_job_key +from ..common._models import Endpoint, RequestSpec +from ..constants import HEADER_FOLDER_KEY, HEADER_TRACEPARENT_ID +from ..orchestrator._folder_service import FolderService +from .business_rules import ( + BusinessRuleCaller, + BusinessRuleDecision, + BusinessRuleError, + BusinessRuleRunResult, + BusinessRuleStatus, + BusinessRuleTraceContext, + _WireResponse, +) + +_EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/evaluate") +_DEBUG_EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/debug/evaluate") + +_MAX_INPUT_KEYS = 256 +_MAX_BUSINESS_RULE_NAME_LENGTH = 256 + + +@dataclass(frozen=True) +class _RunTarget: + """Where a run goes, settled before anything is sent. + + A debug run sets ``is_debug`` and carries no folder. A deployed run carries + exactly one of ``folder_key`` and ``folder_path``: a key ready to send, or a + path still to look up. + """ + + business_rule_name: str + folder_key: Optional[str] = None + folder_path: Optional[str] = None + is_debug: bool = False + + +class BusinessRulesService(FolderContext, BaseService): + """Service for running UiPath Business Rules. + + Each call runs one input against a business rule, named like any other + resource, and returns the decisions it produced. The service picks the + endpoint from the run itself, so the caller never chooses one: inside a debug + session the undeployed rule from the project being debugged runs, and + otherwise the rule deployed to Orchestrator runs. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + + # Not @traced: the service records the run's decision spans under the + # caller's span, so a client span would only wrap them; the .NET client + # opens none either. + def run( + self, + name: str, + input_arguments: Dict[str, Any], + *, + version: Optional[str] = None, + decision_names: Optional[List[str]] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + caller: Optional[BusinessRuleCaller] = None, + trace_context: Optional[BusinessRuleTraceContext] = None, + ) -> BusinessRuleRunResult: + """Run a business rule against one input. + + Args: + name: The name of the business rule. + input_arguments: The input to run, keyed by the rule's input names. Declared + inputs absent from it bind to null. + version: The rule version to run; defaults to the active version. + Ignored on a debug run. + decision_names: The decisions to evaluate; defaults to the whole model. + folder_key: The key of the folder the rule is deployed in. + folder_path: The path of the folder the rule is deployed in. Looked up + and sent as its key. + caller: Who is running the rule, for the deployed run's audit. + Fields left unset default to the current job's values. Ignored + on a debug run. + trace_context: The trace to file the run's spans under. Defaults to + the ambient trace: ``UIPATH_TRACE_ID`` and the current span. + + Which rule runs is decided in this order, first match winning: + + 1. ``folder_key`` or ``folder_path`` given: the rule deployed in that + folder. + 2. Inside a debug session (``UIPATH_PROJECT_ID`` is set, or the job is + rooted to a debug job) with a job key in ``UIPATH_JOB_KEY``: the + undeployed rule from the project being debugged. The service finds the + project and its folders from the job's lineage. + 3. Otherwise: the rule deployed in ``UIPATH_FOLDER_KEY``, or else + ``UIPATH_FOLDER_PATH``. + + A deployed run needs a folder key: ``folder_key`` is sent as is, and + ``folder_path`` is looked up and sent as its key; the two are exclusive. + A ``businessRule`` binding can remap ``name`` and the folder per + environment; its folder then replaces ``folder_key`` or ``folder_path``. + + Returns: + BusinessRuleRunResult: The decisions produced for the input. + + Raises: + ValueError: If the request is invalid or a required folder is missing. + FolderNotFoundException: If no folder matches ``folder_path``. + EnrichedException: If the service rejects the request. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + + # In a debug session this runs the rule from the project being + # debugged; deployed, the rule in the job's folder. + result = client.business_rules.run( + "Loan Pricing", {"creditScore": 740, "age": 34} + ) + for decision in result.decisions: + print(decision.decision_name, decision.outputs) + + # Always the rule deployed in a given folder + result = client.business_rules.run( + "Loan Pricing", {"creditScore": 740}, folder_path="Finance" + ) + ``` + """ + run_target = self._prepare_run(name, input_arguments, folder_key, folder_path) + if run_target.is_debug: + request_spec = self._debug_evaluate_spec( + run_target.business_rule_name, + input_arguments, + decision_names=decision_names, + ) + else: + request_spec = self._evaluate_spec( + run_target.business_rule_name, + input_arguments, + folder_key=self._resolve_folder_key(run_target), + version=version, + decision_names=decision_names, + caller=caller, + ) + # Called here, not in a helper: BaseService names the user agent after + # the method that calls request(), which must be the public run(). + response = self.request( + request_spec.method, **_request_options(request_spec, trace_context) + ) + return _to_run_result(response.json()) + + async def run_async( + self, + name: str, + input_arguments: Dict[str, Any], + *, + version: Optional[str] = None, + decision_names: Optional[List[str]] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + caller: Optional[BusinessRuleCaller] = None, + trace_context: Optional[BusinessRuleTraceContext] = None, + ) -> BusinessRuleRunResult: + """Asynchronously run a business rule against one input. + + Args: + name: The name of the business rule. + input_arguments: The input to run, keyed by the rule's input names. Declared + inputs absent from it bind to null. + version: The rule version to run; defaults to the active version. + Ignored on a debug run. + decision_names: The decisions to evaluate; defaults to the whole model. + folder_key: The key of the folder the rule is deployed in. + folder_path: The path of the folder the rule is deployed in. Looked up + and sent as its key. + caller: Who is running the rule, for the deployed run's audit. + Fields left unset default to the current job's values. Ignored + on a debug run. + trace_context: The trace to file the run's spans under. Defaults to + the ambient trace: ``UIPATH_TRACE_ID`` and the current span. + + Returns: + BusinessRuleRunResult: The decisions produced for the input. + + Raises: + ValueError: If the request is invalid or a required folder is missing. + FolderNotFoundException: If no folder matches ``folder_path``. + EnrichedException: If the service rejects the request. + """ + run_target = self._prepare_run(name, input_arguments, folder_key, folder_path) + if run_target.is_debug: + request_spec = self._debug_evaluate_spec( + run_target.business_rule_name, + input_arguments, + decision_names=decision_names, + ) + else: + request_spec = self._evaluate_spec( + run_target.business_rule_name, + input_arguments, + folder_key=await self._resolve_folder_key_async(run_target), + version=version, + decision_names=decision_names, + caller=caller, + ) + response = await self.request_async( + request_spec.method, **_request_options(request_spec, trace_context) + ) + return _to_run_result(response.json()) + + def _prepare_run( + self, + business_rule_name: str, + input_arguments: Dict[str, Any], + folder_key: Optional[str], + folder_path: Optional[str], + ) -> _RunTarget: + """Do what run() and run_async() share before the first network call. + + Decides between a debug and a deployed run, applies a binding, validates + the arguments and, for a deployed run, picks the folder, so only the + folder lookup and the request itself differ between the two. + """ + is_debug = _is_debug_run(folder_key, folder_path) + business_rule_name, folder_key, folder_path = self._overridden_resource( + business_rule_name, folder_key=folder_key, folder_path=folder_path + ) + _validate_run_arguments(business_rule_name, input_arguments) + if is_debug: + return _RunTarget(business_rule_name=business_rule_name, is_debug=True) + selected_key, selected_path = self._select_folder(folder_key, folder_path) + return _RunTarget( + business_rule_name=business_rule_name, + folder_key=selected_key, + folder_path=selected_path, + ) + + # Not on run(): routing depends on whether the caller named a folder, so + # _prepare_run decides it before a binding swaps the folder. + @resource_override(resource_type="businessRule") + def _overridden_resource( + self, + name: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Tuple[str, Optional[str], Optional[str]]: + return name, folder_key, folder_path + + def _select_folder( + self, folder_key: Optional[str], folder_path: Optional[str] + ) -> Tuple[Optional[str], Optional[str]]: + """Pick where the folder key comes from, as a ``(key, path)`` pair. + + Exactly one of the two is set: a key ready to send, or a path still to + look up. Blank values count as not given, so they fall back to the + environment. + """ + has_folder_key = _has_value(folder_key) + has_folder_path = _has_value(folder_path) + if has_folder_key and has_folder_path: + raise ValueError("Only one of folder_key or folder_path can be provided") + if has_folder_key: + return folder_key, None + if has_folder_path: + return None, folder_path + if self._folder_key: + return self._folder_key, None + if self._folder_path: + return None, self._folder_path + raise ValueError( + "A folder is required for a deployed business rule: pass folder_key or " + "folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" + ) + + def _resolve_folder_key(self, run_target: _RunTarget) -> str: + """Return the key of the folder the rule runs in, looking up a path. + + A path that matches no folder raises FolderNotFoundException. + """ + if run_target.folder_key: + return run_target.folder_key + return self._folders_service.retrieve_folder_key(run_target.folder_path) + + async def _resolve_folder_key_async(self, run_target: _RunTarget) -> str: + """Asynchronously return the key of the folder the rule runs in.""" + if run_target.folder_key: + return run_target.folder_key + return await self._folders_service.retrieve_folder_key_async( + run_target.folder_path + ) + + def _evaluate_spec( + self, + business_rule_name: str, + input_arguments: Dict[str, Any], + *, + folder_key: str, + version: Optional[str] = None, + decision_names: Optional[List[str]] = None, + caller: Optional[BusinessRuleCaller] = None, + ) -> RequestSpec: + request_body: Dict[str, Any] = { + "businessRuleName": business_rule_name, + "input": input_arguments, + } + if _has_value(version): + request_body["version"] = version + if decision_names: + request_body["decisionNames"] = decision_names + caller_payload = _caller_payload(caller) + if caller_payload: + request_body["caller"] = caller_payload + return RequestSpec( + method="POST", + endpoint=_EVALUATE_ENDPOINT, + json=request_body, + headers={HEADER_FOLDER_KEY: folder_key}, + ) + + def _debug_evaluate_spec( + self, + business_rule_name: str, + input_arguments: Dict[str, Any], + *, + decision_names: Optional[List[str]] = None, + ) -> RequestSpec: + request_body: Dict[str, Any] = { + "businessRuleName": business_rule_name, + "input": input_arguments, + } + if decision_names: + request_body["decisionNames"] = decision_names + return RequestSpec( + method="POST", + endpoint=_DEBUG_EVALUATE_ENDPOINT, + json=request_body, + headers=header_job_key(), + ) + + +def _is_debug_run(folder_key: Optional[str], folder_path: Optional[str]) -> bool: + """Return whether this run debugs the project instead of a deployed rule. + + A folder the caller names always means a deployed rule. Otherwise a run in a + debug session debugs the project, named by the session's job; without a job + key there is no lineage to resolve the project from, so it runs deployed. + """ + if _has_value(folder_key) or _has_value(folder_path): + return False + if not _is_debug_session(): + return False + return _has_value(UiPathConfig.job_key) + + +def _request_options( + request_spec: RequestSpec, trace_context: Optional[BusinessRuleTraceContext] +) -> Dict[str, Any]: + """Return the request() arguments for a spec, besides its method.""" + return { + "url": request_spec.endpoint, + "json": request_spec.json, + "headers": _headers_with_trace(request_spec.headers, trace_context), + "scoped": "tenant", + } + + +def _caller_payload(caller: Optional[BusinessRuleCaller]) -> Dict[str, Any]: + """Return the caller as the service names its fields, defaulting from the job. + + Blank fields are left out. ``isDebugRun`` goes with every caller, false unless + the run is a debug session; a caller with every key blank and no debug run + names no one, so it is returned empty and the request carries no caller. + """ + given_caller = caller or BusinessRuleCaller() + caller_keys = { + "resourceKey": given_caller.resource_key or UiPathConfig.process_uuid, + "runKey": given_caller.run_key or UiPathConfig.job_key, + "folderKey": given_caller.folder_key or UiPathConfig.folder_key, + } + caller_fields: Dict[str, Any] = {} + for field_name, field_value in caller_keys.items(): + if _has_value(field_value): + caller_fields[field_name] = field_value + is_debug_run = given_caller.is_debug_run + if is_debug_run is None: + is_debug_run = _is_debug_session() + if not caller_fields and not is_debug_run: + return {} + caller_fields["isDebugRun"] = is_debug_run + return caller_fields + + +def _is_debug_session() -> bool: + return UiPathConfig.is_studio_project or UiPathConfig.is_rooted_to_debug_job + + +def _headers_with_trace( + headers: Dict[str, str], trace_context: Optional[BusinessRuleTraceContext] +) -> Dict[str, str]: + """Return the headers, pinned to ``trace_context`` when the caller gave one. + + BaseService adds the ambient trace header only when none is set, so an + explicit one set here is what gets sent. + """ + if trace_context is None: + return headers + return {**headers, HEADER_TRACEPARENT_ID: trace_context.to_traceparent()} + + +def _has_value(text: Optional[str]) -> bool: + return bool(text and text.strip()) + + +def _validate_run_arguments( + business_rule_name: str, input_arguments: Dict[str, Any] +) -> None: + _validate_business_rule_name(business_rule_name) + _validate_input_arguments(input_arguments) + + +def _validate_business_rule_name(business_rule_name: str) -> None: + # Character rules are left to the service, which answers INVALID_REQUEST. + if not _has_value(business_rule_name): + raise ValueError("name must be specified") + if len(business_rule_name) > _MAX_BUSINESS_RULE_NAME_LENGTH: + raise ValueError( + f"name must not exceed {_MAX_BUSINESS_RULE_NAME_LENGTH} characters" + ) + + +def _validate_input_arguments(input_arguments: Dict[str, Any]) -> None: + if input_arguments is None: + raise ValueError("input_arguments must not be None") + if not isinstance(input_arguments, Mapping): + raise ValueError( + "input_arguments must be a mapping of the rule's input names to values, " + f"not {type(input_arguments).__name__}" + ) + if len(input_arguments) > _MAX_INPUT_KEYS: + raise ValueError(f"input_arguments must not exceed {_MAX_INPUT_KEYS} keys") + + +def _overall_status( + decisions: List[BusinessRuleDecision], errors: List[BusinessRuleError] +) -> BusinessRuleStatus: + if errors: + return BusinessRuleStatus.ALL_FAILED + failed_count = sum(1 for decision in decisions if decision.error is not None) + if failed_count == 0: + return BusinessRuleStatus.SUCCESS + if failed_count == len(decisions): + return BusinessRuleStatus.ALL_FAILED + return BusinessRuleStatus.PARTIAL_SUCCESS + + +def _to_run_result(response_body: Any) -> BusinessRuleRunResult: + wire_response = _WireResponse.model_validate(response_body) + if wire_response.result is None: + raise ValueError("The business rules response did not include a result") + decisions = wire_response.result.decisions or [] + errors = wire_response.result.errors or [] + return BusinessRuleRunResult( + status=_overall_status(decisions, errors), + decisions=decisions, + errors=errors, + top_level_error=wire_response.error.code if wire_response.error else None, + business_rule_name=wire_response.business_rule_name, + version=wire_response.version, + project_id=wire_response.project_id, + file_name=wire_response.file_name, + ) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py new file mode 100644 index 000000000..6dbf421f1 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -0,0 +1,186 @@ +"""Models for the UiPath Business Rules service.""" + +import re +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from ..common._span_utils import _SpanUtils + +_HEX_DIGITS = re.compile(r"^[0-9a-f]+$") + + +class BusinessRuleStatus(str, Enum): + """The overall outcome of running a business rule against one input.""" + + SUCCESS = "Success" + PARTIAL_SUCCESS = "PartialSuccess" + ALL_FAILED = "AllFailed" + + +class BusinessRuleCaller(BaseModel): + """Who is running the rule, kept in the deployed run's execution audit. + + Each field defaults to the current job's value from the environment. This + describes the calling resource, not the rule: ``folder_key`` here is the + caller's folder, which can differ from the folder the rule runs in. + """ + + resource_key: Optional[str] = Field( + default=None, + description="The calling resource's key; for a process, its release key. Defaults to UIPATH_PROCESS_UUID.", + ) + run_key: Optional[str] = Field( + default=None, + description="The calling run, such as its job key. Defaults to UIPATH_JOB_KEY.", + ) + folder_key: Optional[str] = Field( + default=None, + description="The folder the calling resource runs in. Defaults to UIPATH_FOLDER_KEY.", + ) + is_debug_run: Optional[bool] = Field( + default=None, + description=( + "Whether the calling run is a debug session, such as a process debugged " + "in Studio Web; such a run may have no resource key to send. This is " + "about the caller, not the rule: the deployed rule still runs. Defaults " + "to whether the current job is a debug session." + ), + ) + + +class BusinessRuleTraceContext(BaseModel): + """An existing trace to file the run's spans under. + + Optional on ``run()``: when omitted, the SDK takes the trace from + ``UIPATH_TRACE_ID`` and the current span, as it does for every service. + """ + + trace_id: str = Field( + description="The trace id: 32 hex characters, or a UUID with dashes." + ) + parent_span_id: str = Field( + description=( + "The span the run's spans nest under: 16 hex characters, or a UUID " + "whose last 16 are used." + ) + ) + + @field_validator("trace_id") + @classmethod + def _normalize_trace_id(cls, value: str) -> str: + try: + trace_id = _SpanUtils.normalize_trace_id(value.strip()) + except ValueError: + raise ValueError("trace_id must be 32 hex characters or a UUID") from None + _require_nonzero_hex(trace_id, "trace_id") + return trace_id + + @field_validator("parent_span_id") + @classmethod + def _normalize_parent_span_id(cls, value: str) -> str: + try: + span_id = _SpanUtils.normalize_span_id(value.strip()) + except ValueError: + raise ValueError( + "parent_span_id must be 16 hex characters or a UUID" + ) from None + _require_nonzero_hex(span_id, "parent_span_id") + return span_id + + def to_traceparent(self) -> str: + """Return the W3C traceparent value for this context.""" + return f"00-{self.trace_id}-{self.parent_span_id}-01" + + +def _require_nonzero_hex(trace_part: str, field_name: str) -> None: + # Checks _SpanUtils leaves out; W3C traceparent requires both. + if not _HEX_DIGITS.match(trace_part): + raise ValueError(f"{field_name} must contain only hex characters") + if not trace_part.strip("0"): + raise ValueError(f"{field_name} must not be all zeros") + + +class BusinessRuleError(BaseModel): + """A code/message pair describing an input-level or decision-level error.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + code: str = Field(default="", alias="code") + message: str = Field(default="", alias="message") + + +class BusinessRuleDecision(BaseModel): + """The result of evaluating a single decision.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + decision_name: str = Field(default="", alias="decisionName") + outputs: Optional[Dict[str, Any]] = Field(default=None, alias="outputs") + error: Optional[BusinessRuleError] = Field(default=None, alias="error") + + +class BusinessRuleRunResult(BaseModel): + """The outcome of running a business rule against one input. + + The shape is the same for deployed and debug runs. + """ + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + + status: BusinessRuleStatus + decisions: List[BusinessRuleDecision] = Field(default_factory=list) + errors: List[BusinessRuleError] = Field( + default_factory=list, + description="Errors that failed the input as a whole; non-empty means nothing was evaluated.", + ) + top_level_error: Optional[str] = Field( + default=None, + description="The request-level error code, if one was reported.", + ) + business_rule_name: Optional[str] = Field( + default=None, description="The deployed rule that ran, when reported." + ) + version: Optional[str] = Field( + default=None, description="The rule version that ran, when reported." + ) + project_id: Optional[str] = Field( + default=None, + description="The Studio project the rule was read from, when it came from one.", + ) + file_name: Optional[str] = Field( + default=None, + description="The rule file read from the project, when it came from one.", + ) + + +class _WireResult(BaseModel): + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + decisions: Optional[List[BusinessRuleDecision]] = None + errors: Optional[List[BusinessRuleError]] = None + + +class _WireResponse(BaseModel): + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="ignore" + ) + + business_rule_name: Optional[str] = Field(default=None, alias="businessRuleName") + version: Optional[str] = None + project_id: Optional[str] = Field(default=None, alias="projectId") + file_name: Optional[str] = Field(default=None, alias="fileName") + error: Optional[BusinessRuleError] = None + result: Optional[_WireResult] = None diff --git a/packages/uipath-platform/src/uipath/platform/common/_base_service.py b/packages/uipath-platform/src/uipath/platform/common/_base_service.py index 756c2be67..ee1b17e9a 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_base_service.py +++ b/packages/uipath-platform/src/uipath/platform/common/_base_service.py @@ -21,7 +21,7 @@ stop_after_attempt, ) -from uipath.platform.constants import HEADER_USER_AGENT +from uipath.platform.constants import HEADER_TRACEPARENT_ID, HEADER_USER_AGENT from ..errors import EnrichedException from ._config import UiPathApiConfig @@ -65,9 +65,6 @@ def _get_caller_component() -> str: return "" -_TRACE_PARENT_HEADER = "x-uipath-traceparent-id" - - def resolve_trace_id(fallback: str | None = None) -> str | None: """Resolve the current UiPath trace id as a 32-char hex string. @@ -129,7 +126,12 @@ def _inject_trace_context(headers: dict[str, str]) -> None: remapping the LLMOps exporter applies), falling back to the OTEL trace ID. Span ID: uses the LLMOps tool span (via external span provider) so the span ID matches what's visible in the LLMOps trace UI. + + A trace header already in ``headers`` is left as is: the caller chose it. """ + if HEADER_TRACEPARENT_ID in headers: + return + from uipath.core.tracing.span_utils import UiPathSpanUtils from ._config import UiPathConfig @@ -148,7 +150,7 @@ def _inject_trace_context(headers: dict[str, str]) -> None: else format_trace_id(ctx.trace_id) ) span_id = format_span_id(ctx.span_id) - headers[_TRACE_PARENT_HEADER] = f"00-{trace_id}-{span_id}-01" + headers[HEADER_TRACEPARENT_ID] = f"00-{trace_id}-{span_id}-01" class BaseService: diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index a93880896..11f5142a2 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -61,6 +61,7 @@ class GenericResourceOverwrite(ResourceOverwrite): "queue", "remoteA2aAgent", "memorySpace", + "businessRule", ] name: str = Field(alias="name") folder_path: str = Field(alias="folderPath") @@ -215,6 +216,9 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): _resource_overwrites.reset(self._token) +_OTHER_FOLDER_IDENTIFIER = {"folder_path": "folder_key", "folder_key": "folder_path"} + + def resource_override( resource_type: str, resource_identifier: str = "name", @@ -283,6 +287,16 @@ def process_args(args, kwargs) -> dict[str, Any]: all_args[folder_identifier] = ( matched_overwrite.folder_identifier ) + # Otherwise header_folder() gets both and raises. + other_folder_identifier = _OTHER_FOLDER_IDENTIFIER.get( + folder_identifier + ) + if ( + other_folder_identifier is not None + and other_folder_identifier in sig.parameters + and matched_overwrite.folder_identifier + ): + all_args[other_folder_identifier] = None logger.debug( "Resource overwrite applied for %s on %s: %s='%s' -> '%s', %s='%s' -> '%s'", resource_type, diff --git a/packages/uipath-platform/src/uipath/platform/constants/__init__.py b/packages/uipath-platform/src/uipath/platform/constants/__init__.py index 2e73b7eca..97a1a39b6 100644 --- a/packages/uipath-platform/src/uipath/platform/constants/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/constants/__init__.py @@ -49,6 +49,7 @@ HEADER_JOB_KEY = "x-uipath-jobkey" HEADER_PROCESS_KEY = "x-uipath-processkey" HEADER_TRACE_ID = "x-uipath-traceid" +HEADER_TRACEPARENT_ID = "x-uipath-traceparent-id" HEADER_AGENTHUB_CONFIG = "x-uipath-agenthub-config" AGENTHUB_CONFIG_CODED_AGENTS_PLAYGROUND = "codedagentsplayground" HEADER_GUARDRAILS_SOURCE = "x-uipath-guardrails-source" diff --git a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py new file mode 100644 index 000000000..2b3112eec --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py @@ -0,0 +1,24 @@ +"""Business Rules service error payload extractor. + +Business Rules returns: {"error": {"code": "", "message": ""}, +"meta": {...}}, with the code nested under "error" where the generic extractor +does not look for it. +""" + +from typing import Any + +from .._enriched_exception import ExtractedErrorInfo +from ._generic import extract_generic +from ._helpers import get_field, get_str_field, get_typed_field + + +def extract_businessrules(body: dict[str, Any]) -> ExtractedErrorInfo: + error_envelope = get_field(body, "error") + if not isinstance(error_envelope, dict): + return extract_generic(body) + + return ExtractedErrorInfo( + message=get_typed_field(error_envelope, str, "message"), + error_code=get_str_field(error_envelope, "code"), + trace_id=get_typed_field(body, str, "traceId", "requestId"), + ) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py index 901dbc089..4e52eac5f 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py @@ -14,6 +14,7 @@ from .._enriched_exception import ExtractedErrorInfo from ._agenthub import extract_agenthub from ._apps import extract_apps +from ._businessrules import extract_businessrules from ._datafabric import extract_datafabric from ._elements import extract_elements from ._generic import extract_generic @@ -30,6 +31,7 @@ "agenthub_": extract_agenthub, "agentsruntime_": extract_agenthub, "apps_": extract_apps, + "businessrules_": extract_businessrules, "datafabric_": extract_datafabric, "elements_": extract_elements, "llmopstenant_": extract_llmops, diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_folder_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_folder_service.py index 84c5abdb3..e9ec4b3d3 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_folder_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_folder_service.py @@ -24,7 +24,7 @@ def __init__( ) -> None: super().__init__(config=config, execution_context=execution_context) - def retrieve_folder_key(self, folder_path: str | None) -> str | None: + def retrieve_folder_key(self, folder_path: str | None) -> str: """Resolve a folder path to its corresponding folder key. Args: @@ -34,7 +34,8 @@ def retrieve_folder_key(self, folder_path: str | None) -> str | None: The resolved folder key Raises: - ValueError: If folder_path is None or if folder_path is not found + ValueError: If folder_path is None. + FolderNotFoundException: If no folder matches folder_path. """ if folder_path is None: raise ValueError("Cannot obtain folder_key without providing folder_path") @@ -44,7 +45,7 @@ def retrieve_folder_key(self, folder_path: str | None) -> str | None: raise FolderNotFoundException(folder_path) return resolved_folder_key - async def retrieve_folder_key_async(self, folder_path: str | None) -> str | None: + async def retrieve_folder_key_async(self, folder_path: str | None) -> str: """Asynchronously resolve a folder path to its corresponding folder key. Args: @@ -54,7 +55,8 @@ async def retrieve_folder_key_async(self, folder_path: str | None) -> str | None The resolved folder key Raises: - ValueError: If folder_path is None or if folder_path is not found + ValueError: If folder_path is None. + FolderNotFoundException: If no folder matches folder_path. """ if folder_path is None: raise ValueError("Cannot obtain folder_key without providing folder_path") diff --git a/packages/uipath-platform/tests/common/test_resource_override.py b/packages/uipath-platform/tests/common/test_resource_override.py new file mode 100644 index 000000000..86e701124 --- /dev/null +++ b/packages/uipath-platform/tests/common/test_resource_override.py @@ -0,0 +1,85 @@ +from typing import Iterator, Optional, Tuple + +import pytest + +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + _resource_overwrites, + resource_override, +) + +Folder = Tuple[str, Optional[str], Optional[str]] + + +@resource_override(resource_type="asset") +def retrieve( + name: str, *, folder_key: Optional[str] = None, folder_path: Optional[str] = None +) -> Folder: + return name, folder_key, folder_path + + +@resource_override(resource_type="asset") +async def retrieve_async( + name: str, *, folder_key: Optional[str] = None, folder_path: Optional[str] = None +) -> Folder: + return name, folder_key, folder_path + + +@pytest.fixture +def asset_overwrite() -> Iterator[None]: + overwrite = GenericResourceOverwrite( + resource_type="asset", name="ApiKey EU", folder_path="Finance/EU" + ) + token = _resource_overwrites.set({"asset.ApiKey": overwrite}) + try: + yield + finally: + _resource_overwrites.reset(token) + + +def test_bound_folder_replaces_a_folder_key(asset_overwrite: None) -> None: + assert retrieve("ApiKey", folder_key="callers-key") == ( + "ApiKey EU", + None, + "Finance/EU", + ) + + +async def test_bound_folder_replaces_a_folder_key_async(asset_overwrite: None) -> None: + assert await retrieve_async("ApiKey", folder_key="callers-key") == ( + "ApiKey EU", + None, + "Finance/EU", + ) + + +def test_folder_key_kept_without_a_matching_overwrite(asset_overwrite: None) -> None: + assert retrieve("Other", folder_key="callers-key") == ( + "Other", + "callers-key", + None, + ) + + +def test_other_folder_parameters_are_left_alone() -> None: + @resource_override(resource_type="app", folder_identifier="app_folder_path") + def create( + name: str, + *, + app_folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> Folder: + return name, folder_key, app_folder_path + + overwrite = GenericResourceOverwrite( + resource_type="app", name="Approval EU", folder_path="Apps/EU" + ) + token = _resource_overwrites.set({"app.Approval": overwrite}) + try: + assert create("Approval", folder_key="task-folder") == ( + "Approval EU", + "task-folder", + "Apps/EU", + ) + finally: + _resource_overwrites.reset(token) diff --git a/packages/uipath-platform/tests/errors/test_enriched_exception.py b/packages/uipath-platform/tests/errors/test_enriched_exception.py index e41f41007..241b5a49d 100644 --- a/packages/uipath-platform/tests/errors/test_enriched_exception.py +++ b/packages/uipath-platform/tests/errors/test_enriched_exception.py @@ -9,6 +9,9 @@ _LLM_URL = "https://cloud.uipath.com/org/tenant/orchestrator_/llm/chat" _AGENTHUB_URL = "https://cloud.uipath.com/org/tenant/agenthub_/api/v1" _APPS_URL = "https://cloud.uipath.com/org/tenant/apps_/api/v1" +_BUSINESS_RULES_URL = ( + "https://cloud.uipath.com/org/tenant/businessrules_/v1/business-rules/evaluate" +) _ELEMENTS_URL = "https://cloud.uipath.com/org/tenant/elements_/api/v1" _LLMOPS_URL = "https://cloud.uipath.com/org/tenant/llmopstenant_/api/v1" _GENERIC_URL = "https://cloud.uipath.com/api/test" @@ -239,6 +242,38 @@ def test_error_message_v2_priority(self): assert exc.error_info.message == "Detailed error" +class TestBusinessRulesExtraction: + """Business Rules nests the code and message under "error".""" + + def test_nested_error_code_and_message(self): + body = json.dumps( + { + "businessRuleName": "Missing", + "error": {"code": "RULE_NOT_FOUND", "message": "no such rule"}, + "meta": {"timestamp": "2026-10-07T00:00:00Z"}, + } + ) + exc = EnrichedException(_make_error(404, body=body, url=_BUSINESS_RULES_URL)) + assert exc.error_info is not None + assert exc.error_info.message == "no such rule" + assert exc.error_info.error_code == "RULE_NOT_FOUND" + assert exc.error_info.trace_id is None + + def test_error_without_message(self): + body = json.dumps({"error": {"code": "BATCH_TIMEOUT"}, "meta": {}}) + exc = EnrichedException(_make_error(504, body=body, url=_BUSINESS_RULES_URL)) + assert exc.error_info is not None + assert exc.error_info.error_code == "BATCH_TIMEOUT" + assert exc.error_info.message is None + + def test_other_shapes_fall_back_to_generic(self): + body = json.dumps({"message": "Unauthorized", "errorCode": 1015}) + exc = EnrichedException(_make_error(401, body=body, url=_BUSINESS_RULES_URL)) + assert exc.error_info is not None + assert exc.error_info.message == "Unauthorized" + assert exc.error_info.error_code == "1015" + + class TestElementsExtraction: """Elements has vendor passthrough fields (providerMessage, providerErrorCode).""" diff --git a/packages/uipath-platform/tests/services/test_assets_service.py b/packages/uipath-platform/tests/services/test_assets_service.py index 94f8ab888..4fd0fa99b 100644 --- a/packages/uipath-platform/tests/services/test_assets_service.py +++ b/packages/uipath-platform/tests/services/test_assets_service.py @@ -4,8 +4,16 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + _resource_overwrites, +) from uipath.platform.common.paging import PagedResult -from uipath.platform.constants import HEADER_USER_AGENT +from uipath.platform.constants import ( + HEADER_FOLDER_KEY, + HEADER_FOLDER_PATH, + HEADER_USER_AGENT, +) from uipath.platform.orchestrator import Asset, UserAsset from uipath.platform.orchestrator._assets_service import AssetsService @@ -937,3 +945,35 @@ async def test_update_async_passes_all_kwargs( # Verify positional arg (method) assert call_kwargs.args[0] == "POST" + + +class TestResourceOverride: + def test_bound_folder_replaces_callers_folder_key( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + import json + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={"id": 1, "name": "ApiKey EU", "value": "eu-value"}, + ) + overwrite = GenericResourceOverwrite( + resource_type="asset", name="ApiKey EU", folder_path="Finance/EU" + ) + token = _resource_overwrites.set({"asset.ApiKey": overwrite}) + try: + service.retrieve(name="ApiKey", folder_key="callers-folder-key") + finally: + _resource_overwrites.reset(token) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert json.loads(sent_request.content)["assetName"] == "ApiKey EU" + assert sent_request.headers[HEADER_FOLDER_PATH] == "Finance/EU" + assert HEADER_FOLDER_KEY not in sent_request.headers diff --git a/packages/uipath-platform/tests/services/test_base_service.py b/packages/uipath-platform/tests/services/test_base_service.py index db39ed313..946cb8dad 100644 --- a/packages/uipath-platform/tests/services/test_base_service.py +++ b/packages/uipath-platform/tests/services/test_base_service.py @@ -1,9 +1,11 @@ import pytest +from opentelemetry import trace +from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common._base_service import BaseService -from uipath.platform.constants import HEADER_USER_AGENT +from uipath.platform.common._base_service import BaseService, _inject_trace_context +from uipath.platform.constants import HEADER_TRACEPARENT_ID, HEADER_USER_AGENT from uipath.platform.errors import EnrichedException @@ -389,3 +391,34 @@ async def test_request_async_uses_override_url( assert sent_request.headers["X-UiPath-Internal-TenantId"] == "tenant-123" assert sent_request.headers["X-UiPath-Internal-AccountId"] == "org-456" assert response.status_code == 200 + + +class TestInjectTraceContext: + _SPAN = NonRecordingSpan( + SpanContext( + trace_id=0x4BF92F3577B34DA6A3CE929D0E0E4736, + span_id=0x00F067AA0BA902B7, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + ) + + def test_adds_the_current_span(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + headers: dict[str, str] = {} + + with trace.use_span(self._SPAN): + _inject_trace_context(headers) + + assert headers[HEADER_TRACEPARENT_ID] == ( + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + ) + + def test_keeps_a_trace_header_already_set(self) -> None: + explicit = "00-11111111111111111111111111111111-2222222222222222-01" + headers = {HEADER_TRACEPARENT_ID: explicit} + + with trace.use_span(self._SPAN): + _inject_trace_context(headers) + + assert headers == {HEADER_TRACEPARENT_ID: explicit} diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py new file mode 100644 index 000000000..3b2ff4462 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -0,0 +1,1403 @@ +import json +from typing import Any, Iterator, Optional +from unittest.mock import AsyncMock, Mock + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags +from pydantic import ValidationError +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.business_rules import ( + BusinessRuleCaller, + BusinessRulesService, + BusinessRuleStatus, + BusinessRuleTraceContext, +) +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + ResourceOverwriteParser, + _resource_overwrites, +) +from uipath.platform.common._config import UiPathConfig +from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT +from uipath.platform.errors import EnrichedException, FolderNotFoundException + +FOLDER_KEY = "5f1f1b0e-2b8a-4c1e-9b8e-1a2b3c4d5e6f" +TRACEPARENT = "x-uipath-traceparent-id" +RULE = "Loan Pricing" +JOB_KEY = "9d8c7b6a-5f4e-3d2c-1b0a-9f8e7d6c5b4a" + + +@pytest.fixture +def folders_service() -> Mock: + folders = Mock() + folders.retrieve_folder_key.return_value = FOLDER_KEY + folders.retrieve_folder_key_async = AsyncMock(return_value=FOLDER_KEY) + return folders + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> BusinessRulesService: + monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) + monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + monkeypatch.delenv("UIPATH_PROCESS_UUID", raising=False) + monkeypatch.delenv("UIPATH_PROJECT_ID", raising=False) + # Not a debug session unless a test says so, whatever uipath.json is nearby. + monkeypatch.setattr( + type(UiPathConfig), "is_rooted_to_debug_job", property(lambda self: False) + ) + return BusinessRulesService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + + +@pytest.fixture +def evaluate_url(base_url: str, org: str, tenant: str) -> str: + return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/evaluate" + + +@pytest.fixture +def debug_url(base_url: str, org: str, tenant: str) -> str: + return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/debug/evaluate" + + +def _service_response( + result: dict[str, Any] | None = None, **extra: Any +) -> dict[str, Any]: + response_body: dict[str, Any] = { + "meta": {"timestamp": "2026-09-28T00:00:00Z"}, + **extra, + } + response_body["result"] = {"decisions": []} if result is None else result + return response_body + + +def _single_decision_result(**outputs: Any) -> dict[str, Any]: + return {"decisions": [{"decisionName": "RiskGrade", "outputs": outputs}]} + + +class TestRunContext: + @pytest.mark.parametrize("business_rule_name", ["", " ", "x" * 257]) + def test_rejects_a_blank_or_too_long_name( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + business_rule_name: str, + ) -> None: + with pytest.raises(ValueError, match="name"): + service.run(business_rule_name, {}, folder_key=FOLDER_KEY) + + assert httpx_mock.get_requests() == [] + + @pytest.mark.parametrize( + "business_rule_name", + ["Loan Pricing", "Préstamo Tarifa", "贷款定价", "Loan#1", "a/b", "x" * 256], + ) + def test_leaves_the_name_characters_to_the_service( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + business_rule_name: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(business_rule_name, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == business_rule_name + + @pytest.mark.parametrize("value", [["age", 14], "age=14", 14]) + def test_rejects_non_mapping_input( + self, httpx_mock: HTTPXMock, service: BusinessRulesService, value: Any + ) -> None: + with pytest.raises(ValueError, match="input_arguments must be a mapping"): + service.run(RULE, value, folder_key=FOLDER_KEY) + + assert httpx_mock.get_requests() == [] + + def test_rejects_oversized_input(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="256 keys"): + service.run(RULE, {f"k{i}": i for i in range(257)}, folder_key=FOLDER_KEY) + + +class TestFolder: + def test_resolves_folder_path_to_key( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_path="Finance/Loans") + + folders_service.retrieve_folder_key.assert_called_once_with("Finance/Loans") + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert "x-uipath-folderpath" not in request.headers + + def test_falls_back_to_env_folder_key( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}) + + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" + + def test_falls_back_to_env_folder_path( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}) + + folders_service.retrieve_folder_key.assert_called_once_with("Finance/Loans") + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert "x-uipath-folderpath" not in request.headers + + async def test_falls_back_to_env_folder_path_async( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + await service.run_async(RULE, {}) + + folders_service.retrieve_folder_key_async.assert_awaited_once_with( + "Finance/Loans" + ) + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + def test_env_folder_key_wins_over_env_folder_path( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}) + + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" + + def test_deployed_requires_a_folder( + self, httpx_mock: HTTPXMock, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="deployed business rule"): + service.run(RULE, {}) + + assert httpx_mock.get_requests() == [] + + def test_deployed_rejects_a_blank_folder_key( + self, httpx_mock: HTTPXMock, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="deployed business rule"): + service.run(RULE, {}, folder_key=" ") + + assert httpx_mock.get_requests() == [] + + def test_sends_no_organization_unit_id( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert "x-uipath-organizationunitid" not in request.headers + + def test_rejects_both_folder_key_and_path( + self, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="Only one of"): + service.run(RULE, {}, folder_key=FOLDER_KEY, folder_path="Finance") + + def test_folder_path_that_matches_no_folder_raises_folder_not_found( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + ) -> None: + folders_service.retrieve_folder_key.side_effect = FolderNotFoundException( + "Missing/Folder" + ) + + with pytest.raises(FolderNotFoundException): + service.run(RULE, {}, folder_path="Missing/Folder") + + assert httpx_mock.get_requests() == [] + + async def test_folder_path_that_matches_no_folder_raises_folder_not_found_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + ) -> None: + folders_service.retrieve_folder_key_async.side_effect = FolderNotFoundException( + "Missing/Folder" + ) + + with pytest.raises(FolderNotFoundException): + await service.run_async(RULE, {}, folder_path="Missing/Folder") + + assert httpx_mock.get_requests() == [] + + +class TestDeployed: + def test_sends_single_input_and_maps_decisions( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + method="POST", + json=_service_response( + _single_decision_result(Grade="B", Rate=3.5), + businessRuleName="Loan Pricing", + version="1.0.3", + ), + ) + + result = service.run( + "Loan Pricing", + {"creditScore": 740}, + version="1.0.3", + decision_names=["RiskGrade"], + folder_key=FOLDER_KEY, + ) + + assert result.status is BusinessRuleStatus.SUCCESS + assert result.status == "Success" + assert result.model_dump(mode="json")["status"] == "Success" + assert result.decisions[0].decision_name == "RiskGrade" + assert result.decisions[0].outputs == {"Grade": "B", "Rate": 3.5} + assert result.errors == [] + assert result.top_level_error is None + assert result.business_rule_name == "Loan Pricing" + assert result.version == "1.0.3" + assert result.project_id is None + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content) == { + "businessRuleName": "Loan Pricing", + "version": "1.0.3", + "decisionNames": ["RiskGrade"], + "input": {"creditScore": 740}, + } + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert request.headers["Authorization"] == "Bearer secret" + assert ( + request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.BusinessRulesService.run/{version}" + ) + + def test_omits_optional_fields( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, version=" ", folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body == {"businessRuleName": RULE, "input": {}} + + def test_sends_no_explain_or_batch( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {"a": 1}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "explain" not in body + assert "inputs" not in body + + async def test_run_async_names_itself_in_the_user_agent( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + version: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + await service.run_async(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.BusinessRulesService.run_async/{version}" + ) + + async def test_run_async_resolves_folder_path( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, json=_service_response(_single_decision_result(x=1)) + ) + + result = await service.run_async(RULE, {"a": 1}, folder_path="Finance") + + folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance") + assert result.status == BusinessRuleStatus.SUCCESS + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + +class TestCaller: + def test_defaults_from_the_environment( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + monkeypatch.setenv("UIPATH_FOLDER_KEY", "caller-folder-key") + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "resourceKey": "release-key", + "runKey": JOB_KEY, + "folderKey": "caller-folder-key", + "isDebugRun": False, + } + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + def test_explicit_fields_win_over_the_environment( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run( + RULE, + {}, + folder_key=FOLDER_KEY, + caller=BusinessRuleCaller(run_key="agent-run", folder_key="agent-folder"), + ) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "resourceKey": "release-key", + "runKey": "agent-run", + "folderKey": "agent-folder", + "isDebugRun": False, + } + + def test_blank_fields_are_left_out( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run( + RULE, + {}, + folder_key=FOLDER_KEY, + caller=BusinessRuleCaller(resource_key="release-key", run_key=" "), + ) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "resourceKey": "release-key", + "isDebugRun": False, + } + + def test_no_caller_without_any_value( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert "caller" not in json.loads(request.content) + + def test_studio_web_debug_session_is_a_debug_run( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_PROJECT_ID", "studio-project-id") + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "runKey": JOB_KEY, + "isDebugRun": True, + } + + def test_job_rooted_to_a_debug_session_is_a_debug_run( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr( + type(UiPathConfig), "is_rooted_to_debug_job", property(lambda self: True) + ) + monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "resourceKey": "release-key", + "isDebugRun": True, + } + + def test_explicit_is_debug_run_wins_over_the_session( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_PROJECT_ID", "studio-project-id") + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run( + RULE, + {}, + folder_key=FOLDER_KEY, + caller=BusinessRuleCaller(is_debug_run=False), + ) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "runKey": JOB_KEY, + "isDebugRun": False, + } + + def test_debug_run_alone_is_sent( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run( + RULE, + {}, + folder_key=FOLDER_KEY, + caller=BusinessRuleCaller(is_debug_run=True), + ) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == {"isDebugRun": True} + + +class TestResult: + def test_partial_success_on_207( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_service_response( + { + "decisions": [ + {"decisionName": "RiskGrade", "outputs": {"Grade": "B"}}, + { + "decisionName": "Payment", + "error": { + "code": "DECISION_FAILED", + "message": "null arithmetic", + }, + }, + ], + } + ), + ) + + result = service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert result.status == BusinessRuleStatus.PARTIAL_SUCCESS + assert result.decisions[1].error is not None + assert result.decisions[1].error.code == "DECISION_FAILED" + + def test_every_decision_failing_is_all_failed( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_service_response( + { + "decisions": [ + { + "decisionName": "Payment", + "error": {"code": "DECISION_FAILED", "message": "x"}, + } + ] + } + ), + ) + + result = service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert result.status == BusinessRuleStatus.ALL_FAILED + + def test_input_level_error_is_all_failed( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_service_response( + { + "errors": [ + { + "code": "INPUT_VALIDATION_FAILED", + "message": "creditScore must be a number", + } + ] + } + ), + ) + + result = service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert result.status == BusinessRuleStatus.ALL_FAILED + assert result.errors[0].code == "INPUT_VALIDATION_FAILED" + + def test_carries_the_top_level_error( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_service_response( + {"errors": [{"code": "INPUT_FAILED", "message": "x"}]}, + error={"code": "UPSTREAM_ERROR", "message": "y"}, + ), + ) + + result = service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert result.top_level_error == "UPSTREAM_ERROR" + + def test_raises_when_the_result_is_missing( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, json={"meta": {"timestamp": "2026-09-28T00:00:00Z"}} + ) + + with pytest.raises(ValueError, match="did not include a result"): + service.run(RULE, {}, folder_key=FOLDER_KEY) + + def test_raises_enriched_exception_on_error_envelope( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=404, + json={ + "businessRuleName": "Missing", + "error": {"code": "RULE_NOT_FOUND", "message": "no such rule"}, + "meta": {"timestamp": "2026-09-28T00:00:00Z"}, + }, + ) + + with pytest.raises(EnrichedException) as exc: + service.run("Missing", {}, folder_key=FOLDER_KEY) + + assert exc.value.status_code == 404 + + +EXPLICIT_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736" +EXPLICIT_SPAN_ID = "00f067aa0ba902b7" +AMBIENT_TRACE_ID = "0af7651916cd43dd8448eb211c80319c" +AMBIENT_SPAN_ID = "b7ad6b7169203331" + + +@pytest.fixture +def ambient_span() -> Iterator[None]: + span = NonRecordingSpan( + SpanContext( + trace_id=int(AMBIENT_TRACE_ID, 16), + span_id=int(AMBIENT_SPAN_ID, 16), + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + ) + with trace.use_span(span): + yield + + +@pytest.fixture +def recorded_spans(monkeypatch: pytest.MonkeyPatch) -> Iterator[InMemorySpanExporter]: + """Record every span, including any @traced would open, in memory.""" + provider = TracerProvider() + exporter = InMemorySpanExporter() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setattr(trace, "get_tracer", provider.get_tracer) + yield exporter + provider.shutdown() + + +class TestTraceContext: + def test_explicit_trace_context_wins_over_ambient_span( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + explicit = BusinessRuleTraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + def test_explicit_trace_context_wins_over_uipath_trace_id( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", AMBIENT_TRACE_ID) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + explicit = BusinessRuleTraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + def test_ambient_trace_is_used_without_trace_context( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{AMBIENT_TRACE_ID}-{AMBIENT_SPAN_ID}-01" + ) + + async def test_ambient_trace_is_used_without_trace_context_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + await service.run_async(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{AMBIENT_TRACE_ID}-{AMBIENT_SPAN_ID}-01" + ) + + def test_opens_no_span_of_its_own( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + recorded_spans: InMemorySpanExporter, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response( + url=evaluate_url, json=_service_response(_single_decision_result(x=1)) + ) + + with trace.get_tracer("test").start_as_current_span("caller") as caller_span: + service.run(RULE, {"creditScore": 740}, folder_key=FOLDER_KEY) + caller_context = caller_span.get_span_context() + + assert [span.name for span in recorded_spans.get_finished_spans()] == ["caller"] + request = httpx_mock.get_request() + assert request is not None + assert request.headers[TRACEPARENT] == ( + f"00-{caller_context.trace_id:032x}-{caller_context.span_id:016x}-01" + ) + + def test_sends_no_trace_header_outside_a_trace( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert TRACEPARENT not in request.headers + + def test_override_does_not_leak_into_the_next_call( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + explicit = BusinessRuleTraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + service.run(RULE, {}, folder_key=FOLDER_KEY) + + first, second = httpx_mock.get_requests() + assert EXPLICIT_TRACE_ID in first.headers[TRACEPARENT] + assert second.headers[TRACEPARENT].startswith(f"00-{AMBIENT_TRACE_ID}-") + + async def test_explicit_trace_context_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + explicit = BusinessRuleTraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + await service.run_async(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + def test_explicit_trace_context_survives_a_retry( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, status_code=503) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + explicit = BusinessRuleTraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + + first, second = httpx_mock.get_requests() + expected = f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + assert first.headers[TRACEPARENT] == expected + assert second.headers[TRACEPARENT] == expected + + def test_parent_span_id_accepts_uuid_form(self) -> None: + context = BusinessRuleTraceContext( + trace_id=EXPLICIT_TRACE_ID, + parent_span_id=f"00000000-0000-0000-{EXPLICIT_SPAN_ID[:4]}-{EXPLICIT_SPAN_ID[4:]}", + ) + + assert context.parent_span_id == EXPLICIT_SPAN_ID + + def test_trace_id_accepts_uuid_form_and_upper_case(self) -> None: + context = BusinessRuleTraceContext( + trace_id="4BF92F35-77B3-4DA6-A3CE-929D0E0E4736", + parent_span_id="00F067AA0BA902B7", + ) + + assert context.to_traceparent() == ( + f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + @pytest.mark.parametrize( + ("trace_id", "parent_span_id", "message"), + [ + ("abc", EXPLICIT_SPAN_ID, "trace_id must be 32 hex"), + ("z" * 32, EXPLICIT_SPAN_ID, "trace_id must contain only hex"), + ("0" * 32, EXPLICIT_SPAN_ID, "trace_id must not be all zeros"), + (EXPLICIT_TRACE_ID, "abc", "parent_span_id must be 16 hex"), + (EXPLICIT_TRACE_ID, "z" * 16, "parent_span_id must contain only hex"), + (EXPLICIT_TRACE_ID, "0" * 16, "parent_span_id must not be all zeros"), + ], + ) + def test_rejects_malformed_ids( + self, trace_id: str, parent_span_id: str, message: str + ) -> None: + with pytest.raises(ValidationError, match=message): + BusinessRuleTraceContext(trace_id=trace_id, parent_span_id=parent_span_id) + + +@pytest.fixture +def rule_override() -> Iterator[None]: + overwrite = GenericResourceOverwrite( + resource_type="businessRule", + name="Loan Pricing EU", + folder_path="Finance/EU", + ) + token = _resource_overwrites.set({"businessRule.Loan Pricing": overwrite}) + try: + yield + finally: + _resource_overwrites.reset(token) + + +class TestResourceOverride: + def test_parser_accepts_business_rule_bindings(self) -> None: + overwrite = ResourceOverwriteParser.parse( + "businessRule.Loan Pricing", + {"name": "Loan Pricing EU", "folderPath": "Finance/EU"}, + ) + + assert overwrite.resource_identifier == "Loan Pricing EU" + assert overwrite.folder_identifier == "Finance/EU" + + def test_override_replaces_rule_name_and_folder( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_path="Finance") + + folders_service.retrieve_folder_key.assert_called_once_with("Finance/EU") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + async def test_override_applies_to_run_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + await service.run_async(RULE, {}, folder_path="Finance") + + folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance/EU") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + + def test_override_folder_replaces_callers_folder_key( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_key="callers-folder-key") + + folders_service.retrieve_folder_key.assert_called_once_with("Finance/EU") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + async def test_override_folder_replaces_callers_folder_key_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + await service.run_async(RULE, {}, folder_key="callers-folder-key") + + folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance/EU") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + def test_callers_folder_key_kept_without_a_matching_override( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run("Risk Tier", {}, folder_key="callers-folder-key") + + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "callers-folder-key" + + def test_other_rules_are_not_overridden( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run("Risk Tier", {}, folder_path="Finance") + + folders_service.retrieve_folder_key.assert_called_once_with("Finance") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Risk Tier" + + +PROJECT_ID = "041e6279-8a51-4aec-b2fa-6786704eba19" + + +@pytest.fixture +def studio_debug(monkeypatch: pytest.MonkeyPatch) -> None: + """A Studio debug session: the project being debugged and its job.""" + monkeypatch.setenv("UIPATH_PROJECT_ID", PROJECT_ID) + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + + +@pytest.fixture +def rooted_to_debug_job(monkeypatch: pytest.MonkeyPatch) -> None: + """A deployed process running under a debug session (e.g. a solution debug).""" + monkeypatch.setattr( + type(UiPathConfig), "is_rooted_to_debug_job", property(lambda self: True) + ) + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + + +class TestDebug: + def test_studio_session_runs_the_project_rule( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ) -> None: + httpx_mock.add_response( + url=debug_url, + method="POST", + json=_service_response( + _single_decision_result(x=1), + projectId=PROJECT_ID, + fileName="Business rule.dmn", + ), + ) + + result = service.run(RULE, {"a": 1}, decision_names=["RiskGrade"]) + + assert result.status == BusinessRuleStatus.SUCCESS + assert result.project_id == PROJECT_ID + assert result.file_name == "Business rule.dmn" + assert result.business_rule_name is None + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content) == { + "businessRuleName": RULE, + "decisionNames": ["RiskGrade"], + "input": {"a": 1}, + } + assert request.headers["x-uipath-jobkey"] == JOB_KEY + + def test_job_rooted_to_a_debug_session_runs_the_project_rule( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + rooted_to_debug_job: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_service_response()) + + service.run(RULE, {}) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-jobkey"] == JOB_KEY + + def test_sends_no_folder_version_or_caller( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + debug_url: str, + studio_debug: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") + httpx_mock.add_response(url=debug_url, json=_service_response()) + + service.run( + RULE, + {}, + version="1.0.0", + caller=BusinessRuleCaller(run_key="agent-run"), + ) + + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert HEADER_FOLDER_KEY not in request.headers + assert "x-uipath-organizationunitid" not in request.headers + assert "x-uipath-folderpath" not in request.headers + body = json.loads(request.content) + assert "version" not in body + assert "caller" not in body + + @pytest.mark.parametrize( + ("folder_key", "folder_path"), + [(FOLDER_KEY, None), (None, "Finance")], + ids=["folder_key", "folder_path"], + ) + def test_a_named_folder_runs_the_deployed_rule( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + studio_debug: None, + folder_key: Optional[str], + folder_path: Optional[str], + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_key=folder_key, folder_path=folder_path) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert "x-uipath-jobkey" not in request.headers + + def test_a_blank_folder_does_not_count_as_named( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_service_response()) + + service.run(RULE, {}, folder_key=" ") + + assert httpx_mock.get_request() is not None + + def test_a_session_without_a_job_key_runs_deployed( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_PROJECT_ID", PROJECT_ID) + monkeypatch.setenv("UIPATH_JOB_KEY", " ") + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert httpx_mock.get_request() is not None + + def test_a_deployed_job_runs_deployed( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" + assert "x-uipath-jobkey" not in request.headers + + def test_rejects_too_long_rule_name( + self, httpx_mock: HTTPXMock, service: BusinessRulesService, studio_debug: None + ) -> None: + with pytest.raises(ValueError, match="256 characters"): + service.run("x" * 257, {}) + + assert httpx_mock.get_requests() == [] + + def test_override_applies_to_debug_runs( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_service_response()) + + service.run(RULE, {}) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + assert HEADER_FOLDER_KEY not in request.headers + + def test_retries_like_any_platform_call( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ) -> None: + httpx_mock.add_response(url=debug_url, status_code=503) + httpx_mock.add_response( + url=debug_url, json=_service_response(_single_decision_result(x=1)) + ) + + result = service.run(RULE, {}) + + assert result.status == BusinessRuleStatus.SUCCESS + assert len(httpx_mock.get_requests()) == 2 + + def test_debug_run_opens_no_span_of_its_own( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + recorded_spans: InMemorySpanExporter, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response( + url=debug_url, json=_service_response(_single_decision_result(x=1)) + ) + + with trace.get_tracer("test").start_as_current_span("caller") as caller_span: + service.run(RULE, {"age": 14}) + caller_context = caller_span.get_span_context() + + assert [span.name for span in recorded_spans.get_finished_spans()] == ["caller"] + request = httpx_mock.get_request() + assert request is not None + assert request.headers[TRACEPARENT] == ( + f"00-{caller_context.trace_id:032x}-{caller_context.span_id:016x}-01" + ) + + def test_explicit_trace_context_on_debug_run( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ambient_span: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_service_response()) + explicit = BusinessRuleTraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, trace_context=explicit) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + async def test_run_async_debug( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ) -> None: + httpx_mock.add_response( + url=debug_url, json=_service_response(projectId=PROJECT_ID) + ) + + result = await service.run_async(RULE, {}) + + assert result.project_id == PROJECT_ID + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-jobkey"] == JOB_KEY diff --git a/packages/uipath-platform/tests/services/test_context_grounding_service.py b/packages/uipath-platform/tests/services/test_context_grounding_service.py index c72d593fd..ba890212d 100644 --- a/packages/uipath-platform/tests/services/test_context_grounding_service.py +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -6,6 +6,10 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + _resource_overwrites, +) from uipath.platform.constants import ( ENV_JOB_KEY, HEADER_JOB_KEY, @@ -4418,3 +4422,38 @@ def test_ingest_data_omits_job_key_header_when_env_unset( headers = mock_request.call_args[1]["headers"] assert HEADER_JOB_KEY not in headers + + +class TestResourceOverride: + def test_bound_folder_replaces_callers_folder_key( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'eu-index'&$expand=dataSource", + status_code=200, + json={"value": [{"id": "eu-index-id", "name": "eu-index"}]}, + ) + overwrite = GenericResourceOverwrite( + resource_type="index", name="eu-index", folder_path="Finance/EU" + ) + token = _resource_overwrites.set({"index.test-index": overwrite}) + try: + with patch.object( + service._folders_service, "retrieve_key", return_value="eu-folder-key" + ) as retrieve_key: + index = service.retrieve( + name="test-index", folder_key="callers-folder-key" + ) + finally: + _resource_overwrites.reset(token) + + assert index.id == "eu-index-id" + retrieve_key.assert_called_once_with(folder_path="Finance/EU") + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.headers["x-uipath-folderkey"] == "eu-folder-key" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 99070bbf4..9ada6c4ac 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.36" +version = "0.2.37" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/docs/core/business_rules.md b/packages/uipath/docs/core/business_rules.md new file mode 100644 index 000000000..7ad8977e5 --- /dev/null +++ b/packages/uipath/docs/core/business_rules.md @@ -0,0 +1 @@ +::: uipath.platform.business_rules._business_rules_service \ No newline at end of file diff --git a/packages/uipath/docs/core/business_rules_models.md b/packages/uipath/docs/core/business_rules_models.md new file mode 100644 index 000000000..ef687923e --- /dev/null +++ b/packages/uipath/docs/core/business_rules_models.md @@ -0,0 +1,3 @@ +::: uipath.platform.business_rules.business_rules + options: + show_bases: true diff --git a/packages/uipath/mkdocs.yml b/packages/uipath/mkdocs.yml index 382e0fdd5..f4572c0c5 100644 --- a/packages/uipath/mkdocs.yml +++ b/packages/uipath/mkdocs.yml @@ -67,6 +67,7 @@ nav: - Assets: core/assets.md - Attachments: core/attachments.md - Buckets: core/buckets.md + - Business Rules: core/business_rules.md - Connections: core/connections.md - Context Grounding: core/context_grounding.md - Documents: core/documents.md @@ -79,6 +80,7 @@ nav: - Resource Catalog: core/resource_catalog.md - Tasks: core/tasks.md - Models: + - Business Rules: core/business_rules_models.md - Documents: core/documents_models.md - Evaluations: - Overview: eval/index.md diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 09541f1cc..0c4dcb200 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.36" +version = "0.2.37" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },