From 6993b073c3f2bb5c381fb4927a57f8374107fae5 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Mon, 28 Sep 2026 09:46:57 +0530 Subject: [PATCH 01/13] feat(business-rules): add BusinessRulesService with evaluate Adds sdk.business_rules, a client for the Business Rules service that evaluates a DMN business rule deployed to Orchestrator against one input. - evaluate / evaluate_async: single input in, decisions out; the service's batch contract stays internal, matching the .NET client - folder scoping by folder_key or folder_path (resolved to a key, as the service accepts keys only), falling back to UIPATH_FOLDER_KEY/PATH - client-side validation of rule name and input size, mirroring the service and the .NET client - overall status (Success / PartialSuccess / AllFailed) derived from decision- and input-level errors; 207 partial results are returned, error envelopes raise EnrichedException - auth, retry, tenant URL scoping and trace propagation are inherited from BaseService Bumps uipath-platform to 0.2.33. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/CLAUDE.md | 2 + packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/_uipath.py | 5 + .../platform/business_rules/__init__.py | 21 ++ .../business_rules/_business_rules_service.py | 283 +++++++++++++++ .../platform/business_rules/business_rules.py | 83 +++++ .../services/test_business_rules_service.py | 336 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 9 files changed, 733 insertions(+), 3 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/business_rules/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py create mode 100644 packages/uipath-platform/tests/services/test_business_rules_service.py diff --git a/packages/uipath-platform/CLAUDE.md b/packages/uipath-platform/CLAUDE.md index 21896676f..500cc94da 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/` | DMN business rule evaluation for rules deployed to Orchestrator | | `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 762af38c6..1b57a68a4 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.32" +version = "0.2.33" 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..485e0c96e --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -0,0 +1,21 @@ +"""Business Rules service package. + +Provides the ``BusinessRulesService`` client for evaluating DMN decision models +deployed as UiPath Business Rules, and the Pydantic models for its results. +""" + +from ._business_rules_service import BusinessRulesService +from .business_rules import ( + BusinessRuleDecision, + BusinessRuleError, + BusinessRuleEvaluationResult, + BusinessRuleStatus, +) + +__all__ = [ + "BusinessRuleDecision", + "BusinessRuleError", + "BusinessRuleEvaluationResult", + "BusinessRuleStatus", + "BusinessRulesService", +] 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..4ca8c25f6 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py @@ -0,0 +1,283 @@ +"""Business Rules service for UiPath Platform. + +Evaluates DMN decision models deployed to Orchestrator as business rules. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext +from ..common._models import Endpoint, RequestSpec +from ..constants import HEADER_FOLDER_KEY +from ..orchestrator._folder_service import FolderService +from .business_rules import ( + BusinessRuleDecision, + BusinessRuleError, + BusinessRuleEvaluationResult, + BusinessRuleStatus, + _WireResponse, + _WireResult, +) + +_EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/evaluate") + +# The service's contract is a batch; this SDK submits exactly one input under this id. +_SINGLE_INPUT_ID = "input-1" +_MAX_INPUT_KEYS = 256 +_MAX_RULE_NAME_LENGTH = 256 + + +class BusinessRulesService(FolderContext, BaseService): + """Service for evaluating UiPath Business Rules (DMN decision models). + + Each call evaluates one input and returns the decisions it produced. The rule + is resolved from Orchestrator by name within a folder. + """ + + 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 + + @traced(name="business_rules_evaluate", run_type="uipath") + def evaluate( + self, + rule_name: str, + input: Dict[str, Any], + *, + version: Optional[str] = None, + decision_names: Optional[List[str]] = None, + explain: bool = False, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> BusinessRuleEvaluationResult: + """Evaluate a deployed business rule against one input. + + Args: + rule_name: The name of the business rule deployed to Orchestrator. + input: The input to evaluate, keyed by DMN input name. Declared inputs + absent from it bind to null. + version: The rule version to evaluate; defaults to the active version. + decision_names: The decisions to evaluate; defaults to the whole model. + explain: Whether to record condition-level explanations in the trace. + folder_key: The key of the folder the rule is deployed to. + folder_path: The path of the folder the rule is deployed to. Resolved to + a key, since the service accepts folder keys only. + + Returns: + BusinessRuleEvaluationResult: The decisions produced for the input. + + Raises: + ValueError: If the request is invalid or no folder can be determined. + EnrichedException: If the service rejects the request. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + + result = client.business_rules.evaluate( + "Loan Pricing", + {"creditScore": 740, "age": 34}, + folder_path="Finance", + ) + for decision in result.decisions: + print(decision.decision_name, decision.outputs) + ``` + """ + _validate_rule_name(rule_name) + _validate_input(input) + resolved_key = self._resolve_folder_key(folder_key, folder_path) + spec = self._evaluate_spec( + rule_name, input, version, decision_names, explain, resolved_key + ) + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + scoped="tenant", + ) + return _to_evaluation_result(_WireResponse.model_validate(response.json())) + + @traced(name="business_rules_evaluate", run_type="uipath") + async def evaluate_async( + self, + rule_name: str, + input: Dict[str, Any], + *, + version: Optional[str] = None, + decision_names: Optional[List[str]] = None, + explain: bool = False, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> BusinessRuleEvaluationResult: + """Asynchronously evaluate a deployed business rule against one input. + + Args: + rule_name: The name of the business rule deployed to Orchestrator. + input: The input to evaluate, keyed by DMN input name. Declared inputs + absent from it bind to null. + version: The rule version to evaluate; defaults to the active version. + decision_names: The decisions to evaluate; defaults to the whole model. + explain: Whether to record condition-level explanations in the trace. + folder_key: The key of the folder the rule is deployed to. + folder_path: The path of the folder the rule is deployed to. Resolved to + a key, since the service accepts folder keys only. + + Returns: + BusinessRuleEvaluationResult: The decisions produced for the input. + + Raises: + ValueError: If the request is invalid or no folder can be determined. + EnrichedException: If the service rejects the request. + """ + _validate_rule_name(rule_name) + _validate_input(input) + resolved_key = await self._resolve_folder_key_async(folder_key, folder_path) + spec = self._evaluate_spec( + rule_name, input, version, decision_names, explain, resolved_key + ) + response = await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + scoped="tenant", + ) + return _to_evaluation_result(_WireResponse.model_validate(response.json())) + + def _resolve_folder_key( + self, folder_key: Optional[str], folder_path: Optional[str] + ) -> str: + if folder_key and folder_path: + raise ValueError("Only one of folder_key or folder_path can be provided") + if folder_key: + return folder_key + path = folder_path or (None if self._folder_key else self._folder_path) + if path: + return self._folders_service.retrieve_folder_key(path) # type: ignore[return-value] + if self._folder_key: + return self._folder_key + raise _missing_folder() + + async def _resolve_folder_key_async( + self, folder_key: Optional[str], folder_path: Optional[str] + ) -> str: + if folder_key and folder_path: + raise ValueError("Only one of folder_key or folder_path can be provided") + if folder_key: + return folder_key + path = folder_path or (None if self._folder_key else self._folder_path) + if path: + return await self._folders_service.retrieve_folder_key_async(path) # type: ignore[return-value] + if self._folder_key: + return self._folder_key + raise _missing_folder() + + def _evaluate_spec( + self, + rule_name: str, + input: Dict[str, Any], + version: Optional[str], + decision_names: Optional[List[str]], + explain: bool, + folder_key: str, + ) -> RequestSpec: + body: Dict[str, Any] = { + "businessRuleName": rule_name, + "explain": explain, + "inputs": [{"id": _SINGLE_INPUT_ID, "data": input}], + } + if version: + body["version"] = version + if decision_names: + body["decisionNames"] = decision_names + return RequestSpec( + method="POST", + endpoint=_EVALUATE_ENDPOINT, + json=body, + headers={HEADER_FOLDER_KEY: folder_key}, + ) + + +def _missing_folder() -> ValueError: + return ValueError( + "A folder is required to evaluate a deployed business rule: pass folder_key " + "or folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" + ) + + +def _validate_rule_name(rule_name: str) -> None: + if not rule_name or not rule_name.strip(): + raise ValueError("rule_name must be specified") + if len(rule_name) > _MAX_RULE_NAME_LENGTH: + raise ValueError( + f"rule_name must not exceed {_MAX_RULE_NAME_LENGTH} characters" + ) + for forbidden in ("/", "\\", "..", "%"): + if forbidden in rule_name: + raise ValueError(f"rule_name must not contain '{forbidden}'") + if any(not ch.isprintable() for ch in rule_name): + raise ValueError("rule_name must not contain control characters") + + +def _validate_input(input: Dict[str, Any]) -> None: + if input is None: + raise ValueError("input must not be None") + if len(input) > _MAX_INPUT_KEYS: + raise ValueError(f"input must not exceed {_MAX_INPUT_KEYS} keys") + + +def _single_result( + response: _WireResponse, +) -> Tuple[List[BusinessRuleDecision], List[BusinessRuleError], BusinessRuleStatus]: + results = response.results or [] + ours: Optional[_WireResult] = next( + (r for r in results if r.id == _SINGLE_INPUT_ID), None + ) + if ours is None and len(results) == 1: + ours = results[0] + elif ours is None and results: + raise ValueError( + f"The service returned {len(results)} results and none carried the id " + f"'{_SINGLE_INPUT_ID}' this request was submitted under" + ) + if ours is None: + # No result for our input at all: it was never evaluated. + return [], [], BusinessRuleStatus.ALL_FAILED + + decisions = ours.decisions or [] + errors = ours.errors or [] + if errors: + status = BusinessRuleStatus.ALL_FAILED + else: + failed = sum(1 for d in decisions if d.error is not None) + if failed == 0: + status = BusinessRuleStatus.SUCCESS + elif failed == len(decisions): + status = BusinessRuleStatus.ALL_FAILED + else: + status = BusinessRuleStatus.PARTIAL_SUCCESS + return decisions, errors, status + + +def _to_evaluation_result(response: _WireResponse) -> BusinessRuleEvaluationResult: + decisions, errors, status = _single_result(response) + return BusinessRuleEvaluationResult( + status=status, + decisions=decisions, + errors=errors, + top_level_error=response.error.code if response.error else None, + business_rule_name=response.business_rule_name, + version=response.version, + ) 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..6d90cdb0d --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -0,0 +1,83 @@ +"""Models for the UiPath Business Rules service.""" + +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class BusinessRuleStatus(str, Enum): + """The overall outcome of evaluating a business rule against one input.""" + + SUCCESS = "Success" + PARTIAL_SUCCESS = "PartialSuccess" + ALL_FAILED = "AllFailed" + + +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 BusinessRuleEvaluationResult(BaseModel): + """The outcome of evaluating a deployed business rule against one input.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=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 (e.g. BATCH_TIMEOUT), if one was reported.", + ) + business_rule_name: Optional[str] = None + version: Optional[str] = None + + +class _WireResult(BaseModel): + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + id: Optional[str] = None + 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 + error: Optional[BusinessRuleError] = None + results: Optional[List[_WireResult]] = None 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..55538b8a7 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -0,0 +1,336 @@ +import json +from typing import Any +from unittest.mock import AsyncMock, Mock + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.business_rules import ( + BusinessRulesService, + BusinessRuleStatus, +) +from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT +from uipath.platform.errors import EnrichedException + +FOLDER_KEY = "5f1f1b0e-2b8a-4c1e-9b8e-1a2b3c4d5e6f" + + +@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) + 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" + + +def _response(results: list[dict[str, Any]], **extra: Any) -> dict[str, Any]: + return { + "hasPartialSuccess": False, + "stats": {"submitted": 1, "succeeded": 1, "failed": 0, "notEvaluated": 0}, + "results": results, + "meta": {"timestamp": "2026-09-28T00:00:00Z"}, + **extra, + } + + +class TestEvaluate: + 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=_response( + [ + { + "id": "input-1", + "decisions": [ + { + "decisionName": "RiskGrade", + "outputs": {"Grade": "B", "Rate": 3.5}, + } + ], + } + ], + businessRuleName="Loan Pricing", + version="1.0.3", + ), + ) + + result = service.evaluate( + "Loan Pricing", + {"creditScore": 740}, + version="1.0.3", + decision_names=["RiskGrade"], + explain=True, + folder_key=FOLDER_KEY, + ) + + assert result.status == BusinessRuleStatus.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" + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content) == { + "businessRuleName": "Loan Pricing", + "version": "1.0.3", + "decisionNames": ["RiskGrade"], + "explain": True, + "inputs": [{"id": "input-1", "data": {"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.evaluate/{version}" + ) + + def test_omits_optional_fields( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.evaluate("Loan Pricing", {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "version" not in body + assert "decisionNames" not in body + assert body["explain"] is False + + 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=_response([])) + + service.evaluate("Loan Pricing", {}, 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=_response([])) + + service.evaluate("Loan Pricing", {}) + + 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_requires_a_folder(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="folder is required"): + service.evaluate("Loan Pricing", {}) + + def test_rejects_both_folder_key_and_path( + self, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="Only one of"): + service.evaluate( + "Loan Pricing", {}, folder_key=FOLDER_KEY, folder_path="Finance" + ) + + @pytest.mark.parametrize( + "rule_name", + ["", " ", "a/b", "a\\b", "a..b", "a%20b", "a\nb", "x" * 257], + ) + def test_rejects_unsafe_rule_names( + self, service: BusinessRulesService, rule_name: str + ) -> None: + with pytest.raises(ValueError, match="rule_name"): + service.evaluate(rule_name, {}, folder_key=FOLDER_KEY) + + def test_rejects_oversized_input(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="256 keys"): + service.evaluate( + "Loan Pricing", + {f"k{i}": i for i in range(257)}, + folder_key=FOLDER_KEY, + ) + + 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=_response( + [ + { + "id": "input-1", + "decisions": [ + {"decisionName": "RiskGrade", "outputs": {"Grade": "B"}}, + { + "decisionName": "Payment", + "error": { + "code": "DECISION_FAILED", + "message": "null arithmetic", + }, + }, + ], + } + ] + ), + ) + + result = service.evaluate("Loan Pricing", {}, 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_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=_response( + [ + { + "id": "input-1", + "errors": [ + { + "code": "INPUT_VALIDATION_FAILED", + "message": "creditScore must be a number", + } + ], + } + ] + ), + ) + + result = service.evaluate("Loan Pricing", {}, folder_key=FOLDER_KEY) + + assert result.status == BusinessRuleStatus.ALL_FAILED + assert result.errors[0].code == "INPUT_VALIDATION_FAILED" + + def test_not_evaluated_carries_top_level_error( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_response( + [], + notEvaluatedIds=["input-1"], + error={"code": "BATCH_TIMEOUT", "message": "ran out of time"}, + ), + ) + + result = service.evaluate("Loan Pricing", {}, folder_key=FOLDER_KEY) + + assert result.status == BusinessRuleStatus.ALL_FAILED + assert result.decisions == [] + assert result.top_level_error == "BATCH_TIMEOUT" + + 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.evaluate("Missing", {}, folder_key=FOLDER_KEY) + + assert exc.value.status_code == 404 + + async def test_evaluate_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=_response( + [ + { + "id": "input-1", + "decisions": [{"decisionName": "D", "outputs": {"x": 1}}], + } + ] + ), + ) + + result = await service.evaluate_async( + "Loan Pricing", {"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 diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index e084735b1..54ab45e3d 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.32" +version = "0.2.33" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index bc92233bb..9ca759335 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.32" +version = "0.2.33" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From 3795b3061f3b3337a6ad2dfdb6c5727d33e19bb1 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 07:56:29 +0530 Subject: [PATCH 02/13] refactor(business-rules): expose run()/run_async() instead of evaluate() Replaces the public evaluate()/evaluate_async() with run()/run_async(), modelled on the .NET client's RunAsync: the caller passes a run context (DeployedRunContext(rule_name, version)) and never picks an endpoint. The result is BusinessRuleRunResult, stamped with the RunMode that ran. The evaluate request builder stays private, so a debug run context can be added later without changing the public entry point. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/CLAUDE.md | 2 +- .../platform/business_rules/__init__.py | 13 +- .../business_rules/_business_rules_service.py | 193 ++++++++-------- .../platform/business_rules/business_rules.py | 31 ++- .../services/test_business_rules_service.py | 218 +++++++++--------- 5 files changed, 253 insertions(+), 204 deletions(-) diff --git a/packages/uipath-platform/CLAUDE.md b/packages/uipath-platform/CLAUDE.md index 500cc94da..f9c64d5a6 100644 --- a/packages/uipath-platform/CLAUDE.md +++ b/packages/uipath-platform/CLAUDE.md @@ -96,7 +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/` | DMN business rule evaluation for rules deployed to Orchestrator | +| `business_rules/` | DMN business rule runs for rules deployed to Orchestrator, 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/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py index 485e0c96e..ed32805ff 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -1,21 +1,26 @@ """Business Rules service package. -Provides the ``BusinessRulesService`` client for evaluating DMN decision models -deployed as UiPath Business Rules, and the Pydantic models for its results. +Provides the ``BusinessRulesService`` client for running DMN decision models +deployed to Orchestrator as UiPath Business Rules, and the Pydantic models for +its run context and results. """ from ._business_rules_service import BusinessRulesService from .business_rules import ( BusinessRuleDecision, BusinessRuleError, - BusinessRuleEvaluationResult, + BusinessRuleRunResult, BusinessRuleStatus, + DeployedRunContext, + RunMode, ) __all__ = [ "BusinessRuleDecision", "BusinessRuleError", - "BusinessRuleEvaluationResult", + "BusinessRuleRunResult", "BusinessRuleStatus", "BusinessRulesService", + "DeployedRunContext", + "RunMode", ] 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 index 4ca8c25f6..1f55efe74 100644 --- 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 @@ -1,6 +1,6 @@ """Business Rules service for UiPath Platform. -Evaluates DMN decision models deployed to Orchestrator as business rules. +Runs DMN decision models deployed to Orchestrator as business rules. """ from typing import Any, Dict, List, Optional, Tuple @@ -17,8 +17,10 @@ from .business_rules import ( BusinessRuleDecision, BusinessRuleError, - BusinessRuleEvaluationResult, + BusinessRuleRunResult, BusinessRuleStatus, + DeployedRunContext, + RunMode, _WireResponse, _WireResult, ) @@ -32,10 +34,11 @@ class BusinessRulesService(FolderContext, BaseService): - """Service for evaluating UiPath Business Rules (DMN decision models). + """Service for running UiPath Business Rules (DMN decision models). - Each call evaluates one input and returns the decisions it produced. The rule - is resolved from Orchestrator by name within a folder. + Each call runs one input and returns the decisions it produced. Which model + runs is set by the run context; ``deployed`` names a rule deployed to + Orchestrator. The caller never picks a service endpoint. """ def __init__( @@ -47,59 +50,61 @@ def __init__( super().__init__(config=config, execution_context=execution_context) self._folders_service = folders_service - @traced(name="business_rules_evaluate", run_type="uipath") - def evaluate( + @traced(name="business_rules_run", run_type="uipath") + def run( self, - rule_name: str, input: Dict[str, Any], *, - version: Optional[str] = None, + deployed: DeployedRunContext, decision_names: Optional[List[str]] = None, explain: bool = False, folder_key: Optional[str] = None, folder_path: Optional[str] = None, - ) -> BusinessRuleEvaluationResult: - """Evaluate a deployed business rule against one input. + ) -> BusinessRuleRunResult: + """Run a business rule against one input. Args: - rule_name: The name of the business rule deployed to Orchestrator. - input: The input to evaluate, keyed by DMN input name. Declared inputs - absent from it bind to null. - version: The rule version to evaluate; defaults to the active version. + input: The input to run, keyed by DMN input name. Declared inputs absent + from it bind to null. + deployed: The business rule deployed to Orchestrator to run. decision_names: The decisions to evaluate; defaults to the whole model. explain: Whether to record condition-level explanations in the trace. - folder_key: The key of the folder the rule is deployed to. - folder_path: The path of the folder the rule is deployed to. Resolved to - a key, since the service accepts folder keys only. + folder_key: The key of the folder to run in. + folder_path: The path of the folder to run in. Resolved to a key, since + the service accepts folder keys only. + + A folder is required. When neither ``folder_key`` nor ``folder_path`` is given, it falls back to + ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``. Returns: - BusinessRuleEvaluationResult: The decisions produced for the input. + BusinessRuleRunResult: The decisions produced for the input, and the + mode that ran. Raises: - ValueError: If the request is invalid or no folder can be determined. + ValueError: If the request is invalid or a required folder is missing. EnrichedException: If the service rejects the request. Examples: ```python from uipath.platform import UiPath + from uipath.platform.business_rules import DeployedRunContext client = UiPath() - result = client.business_rules.evaluate( - "Loan Pricing", + result = client.business_rules.run( {"creditScore": 740, "age": 34}, + deployed=DeployedRunContext(rule_name="Loan Pricing"), folder_path="Finance", ) for decision in result.decisions: print(decision.decision_name, decision.outputs) ``` """ - _validate_rule_name(rule_name) - _validate_input(input) - resolved_key = self._resolve_folder_key(folder_key, folder_path) - spec = self._evaluate_spec( - rule_name, input, version, decision_names, explain, resolved_key - ) + _validate_run(input, deployed) + key, path = self._folder_source(folder_key, folder_path) + if path: + key = self._folders_service.retrieve_folder_key(path) + mode, spec = self._run_spec(input, deployed, decision_names, explain, key) response = self.request( spec.method, url=spec.endpoint, @@ -107,46 +112,44 @@ def evaluate( headers=spec.headers, scoped="tenant", ) - return _to_evaluation_result(_WireResponse.model_validate(response.json())) + return _to_run_result(mode, _WireResponse.model_validate(response.json())) - @traced(name="business_rules_evaluate", run_type="uipath") - async def evaluate_async( + @traced(name="business_rules_run", run_type="uipath") + async def run_async( self, - rule_name: str, input: Dict[str, Any], *, - version: Optional[str] = None, + deployed: DeployedRunContext, decision_names: Optional[List[str]] = None, explain: bool = False, folder_key: Optional[str] = None, folder_path: Optional[str] = None, - ) -> BusinessRuleEvaluationResult: - """Asynchronously evaluate a deployed business rule against one input. + ) -> BusinessRuleRunResult: + """Asynchronously run a business rule against one input. Args: - rule_name: The name of the business rule deployed to Orchestrator. - input: The input to evaluate, keyed by DMN input name. Declared inputs - absent from it bind to null. - version: The rule version to evaluate; defaults to the active version. + input: The input to run, keyed by DMN input name. Declared inputs absent + from it bind to null. + deployed: The business rule deployed to Orchestrator to run. decision_names: The decisions to evaluate; defaults to the whole model. explain: Whether to record condition-level explanations in the trace. - folder_key: The key of the folder the rule is deployed to. - folder_path: The path of the folder the rule is deployed to. Resolved to - a key, since the service accepts folder keys only. + folder_key: The key of the folder to run in. + folder_path: The path of the folder to run in. Resolved to a key, since + the service accepts folder keys only. Returns: - BusinessRuleEvaluationResult: The decisions produced for the input. + BusinessRuleRunResult: The decisions produced for the input, and the + mode that ran. Raises: - ValueError: If the request is invalid or no folder can be determined. + ValueError: If the request is invalid or a required folder is missing. EnrichedException: If the service rejects the request. """ - _validate_rule_name(rule_name) - _validate_input(input) - resolved_key = await self._resolve_folder_key_async(folder_key, folder_path) - spec = self._evaluate_spec( - rule_name, input, version, decision_names, explain, resolved_key - ) + _validate_run(input, deployed) + key, path = self._folder_source(folder_key, folder_path) + if path: + key = await self._folders_service.retrieve_folder_key_async(path) + mode, spec = self._run_spec(input, deployed, decision_names, explain, key) response = await self.request_async( spec.method, url=spec.endpoint, @@ -154,52 +157,51 @@ async def evaluate_async( headers=spec.headers, scoped="tenant", ) - return _to_evaluation_result(_WireResponse.model_validate(response.json())) + return _to_run_result(mode, _WireResponse.model_validate(response.json())) - def _resolve_folder_key( + def _folder_source( self, folder_key: Optional[str], folder_path: Optional[str] - ) -> str: + ) -> Tuple[Optional[str], Optional[str]]: + """Pick the folder to run in, as a (key, path-still-to-resolve) pair.""" if folder_key and folder_path: raise ValueError("Only one of folder_key or folder_path can be provided") if folder_key: - return folder_key - path = folder_path or (None if self._folder_key else self._folder_path) - if path: - return self._folders_service.retrieve_folder_key(path) # type: ignore[return-value] + return folder_key, None + if folder_path: + return None, folder_path if self._folder_key: - return self._folder_key - raise _missing_folder() + return self._folder_key, None + return None, self._folder_path or None - async def _resolve_folder_key_async( - self, folder_key: Optional[str], folder_path: Optional[str] - ) -> str: - if folder_key and folder_path: - raise ValueError("Only one of folder_key or folder_path can be provided") - if folder_key: - return folder_key - path = folder_path or (None if self._folder_key else self._folder_path) - if path: - return await self._folders_service.retrieve_folder_key_async(path) # type: ignore[return-value] - if self._folder_key: - return self._folder_key - raise _missing_folder() + def _run_spec( + self, + input: Dict[str, Any], + deployed: DeployedRunContext, + decision_names: Optional[List[str]], + explain: bool, + folder_key: Optional[str], + ) -> Tuple[RunMode, RequestSpec]: + if not folder_key: + raise _missing_folder("a deployed business rule") + return RunMode.DEPLOYED, self._evaluate_spec( + input, deployed, decision_names, explain, folder_key + ) def _evaluate_spec( self, - rule_name: str, input: Dict[str, Any], - version: Optional[str], + deployed: DeployedRunContext, decision_names: Optional[List[str]], explain: bool, folder_key: str, ) -> RequestSpec: body: Dict[str, Any] = { - "businessRuleName": rule_name, + "businessRuleName": deployed.rule_name, "explain": explain, "inputs": [{"id": _SINGLE_INPUT_ID, "data": input}], } - if version: - body["version"] = version + if deployed.version: + body["version"] = deployed.version if decision_names: body["decisionNames"] = decision_names return RequestSpec( @@ -210,25 +212,35 @@ def _evaluate_spec( ) -def _missing_folder() -> ValueError: +def _present(value: Optional[str]) -> bool: + # Blank counts as absent, matching how the service reads these fields. + return bool(value and value.strip()) + + +def _missing_folder(needed_for: str) -> ValueError: return ValueError( - "A folder is required to evaluate a deployed business rule: pass folder_key " - "or folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" + f"A folder is required for {needed_for}: pass folder_key or folder_path, " + "or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" ) -def _validate_rule_name(rule_name: str) -> None: - if not rule_name or not rule_name.strip(): - raise ValueError("rule_name must be specified") +def _validate_run(input: Dict[str, Any], deployed: DeployedRunContext) -> None: + if deployed is None: + raise ValueError("deployed must be set") + _validate_rule_name(deployed.rule_name, "deployed.rule_name") + _validate_input(input) + + +def _validate_rule_name(rule_name: str, field: str) -> None: + if not _present(rule_name): + raise ValueError(f"{field} must be specified") if len(rule_name) > _MAX_RULE_NAME_LENGTH: - raise ValueError( - f"rule_name must not exceed {_MAX_RULE_NAME_LENGTH} characters" - ) + raise ValueError(f"{field} must not exceed {_MAX_RULE_NAME_LENGTH} characters") for forbidden in ("/", "\\", "..", "%"): if forbidden in rule_name: - raise ValueError(f"rule_name must not contain '{forbidden}'") + raise ValueError(f"{field} must not contain '{forbidden}'") if any(not ch.isprintable() for ch in rule_name): - raise ValueError("rule_name must not contain control characters") + raise ValueError(f"{field} must not contain control characters") def _validate_input(input: Dict[str, Any]) -> None: @@ -271,9 +283,10 @@ def _single_result( return decisions, errors, status -def _to_evaluation_result(response: _WireResponse) -> BusinessRuleEvaluationResult: +def _to_run_result(mode: RunMode, response: _WireResponse) -> BusinessRuleRunResult: decisions, errors, status = _single_result(response) - return BusinessRuleEvaluationResult( + return BusinessRuleRunResult( + mode=mode, status=status, decisions=decisions, errors=errors, 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 index 6d90cdb0d..97e5ddfd9 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -7,13 +7,29 @@ class BusinessRuleStatus(str, Enum): - """The overall outcome of evaluating a business rule against one input.""" + """The overall outcome of running a business rule against one input.""" SUCCESS = "Success" PARTIAL_SUCCESS = "PartialSuccess" ALL_FAILED = "AllFailed" +class RunMode(str, Enum): + """Which kind of model ran, and so which service endpoint served the run.""" + + DEPLOYED = "Deployed" + + +class DeployedRunContext(BaseModel): + """A business rule deployed to Orchestrator.""" + + rule_name: str = Field(description="The name of the deployed business rule.") + version: Optional[str] = Field( + default=None, + description="The version to run; defaults to the active version.", + ) + + class BusinessRuleError(BaseModel): """A code/message pair describing an input-level or decision-level error.""" @@ -41,8 +57,8 @@ class BusinessRuleDecision(BaseModel): error: Optional[BusinessRuleError] = Field(default=None, alias="error") -class BusinessRuleEvaluationResult(BaseModel): - """The outcome of evaluating a deployed business rule against one input.""" +class BusinessRuleRunResult(BaseModel): + """The outcome of running a business rule against one input.""" model_config = ConfigDict( validate_by_name=True, @@ -50,6 +66,7 @@ class BusinessRuleEvaluationResult(BaseModel): use_enum_values=True, ) + mode: RunMode = Field(description="Which kind of model ran.") status: BusinessRuleStatus decisions: List[BusinessRuleDecision] = Field(default_factory=list) errors: List[BusinessRuleError] = Field( @@ -60,8 +77,12 @@ class BusinessRuleEvaluationResult(BaseModel): default=None, description="The request-level error code (e.g. BATCH_TIMEOUT), if one was reported.", ) - business_rule_name: Optional[str] = None - version: Optional[str] = None + business_rule_name: Optional[str] = Field( + default=None, description="The deployed rule that ran." + ) + version: Optional[str] = Field( + default=None, description="The rule version that ran." + ) class _WireResult(BaseModel): diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 55538b8a7..6b2fa4826 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -9,11 +9,14 @@ from uipath.platform.business_rules import ( BusinessRulesService, BusinessRuleStatus, + DeployedRunContext, + RunMode, ) from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT from uipath.platform.errors import EnrichedException FOLDER_KEY = "5f1f1b0e-2b8a-4c1e-9b8e-1a2b3c4d5e6f" +LOAN_PRICING = DeployedRunContext(rule_name="Loan Pricing") @pytest.fixture @@ -55,7 +58,94 @@ def _response(results: list[dict[str, Any]], **extra: Any) -> dict[str, Any]: } -class TestEvaluate: +def _one_decision(**outputs: Any) -> list[dict[str, Any]]: + return [ + { + "id": "input-1", + "decisions": [{"decisionName": "RiskGrade", "outputs": outputs}], + } + ] + + +class TestRunContext: + @pytest.mark.parametrize( + "rule_name", + ["", " ", "a/b", "a\\b", "a..b", "a%20b", "a\nb", "x" * 257], + ) + def test_rejects_unsafe_rule_names( + self, service: BusinessRulesService, rule_name: str + ) -> None: + with pytest.raises(ValueError, match="deployed.rule_name"): + service.run( + {}, + deployed=DeployedRunContext(rule_name=rule_name), + folder_key=FOLDER_KEY, + ) + + def test_rejects_oversized_input(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="256 keys"): + service.run( + {f"k{i}": i for i in range(257)}, + deployed=LOAN_PRICING, + 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=_response([])) + + service.run({}, deployed=LOAN_PRICING, 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=_response([])) + + service.run({}, deployed=LOAN_PRICING) + + 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, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="deployed business rule"): + service.run({}, deployed=LOAN_PRICING) + + def test_rejects_both_folder_key_and_path( + self, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="Only one of"): + service.run( + {}, + deployed=LOAN_PRICING, + folder_key=FOLDER_KEY, + folder_path="Finance", + ) + + +class TestDeployed: def test_sends_single_input_and_maps_decisions( self, httpx_mock: HTTPXMock, @@ -67,31 +157,21 @@ def test_sends_single_input_and_maps_decisions( url=evaluate_url, method="POST", json=_response( - [ - { - "id": "input-1", - "decisions": [ - { - "decisionName": "RiskGrade", - "outputs": {"Grade": "B", "Rate": 3.5}, - } - ], - } - ], + _one_decision(Grade="B", Rate=3.5), businessRuleName="Loan Pricing", version="1.0.3", ), ) - result = service.evaluate( - "Loan Pricing", + result = service.run( {"creditScore": 740}, - version="1.0.3", + deployed=DeployedRunContext(rule_name="Loan Pricing", version="1.0.3"), decision_names=["RiskGrade"], explain=True, folder_key=FOLDER_KEY, ) + assert result.mode == RunMode.DEPLOYED assert result.status == BusinessRuleStatus.SUCCESS assert result.decisions[0].decision_name == "RiskGrade" assert result.decisions[0].outputs == {"Grade": "B", "Rate": 3.5} @@ -113,7 +193,7 @@ def test_sends_single_input_and_maps_decisions( assert request.headers["Authorization"] == "Bearer secret" assert ( request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.BusinessRulesService.evaluate/{version}" + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.BusinessRulesService.run/{version}" ) def test_omits_optional_fields( @@ -124,7 +204,7 @@ def test_omits_optional_fields( ) -> None: httpx_mock.add_response(url=evaluate_url, json=_response([])) - service.evaluate("Loan Pricing", {}, folder_key=FOLDER_KEY) + service.run({}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY) request = httpx_mock.get_request() assert request is not None @@ -133,73 +213,28 @@ def test_omits_optional_fields( assert "decisionNames" not in body assert body["explain"] is False - def test_resolves_folder_path_to_key( + 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=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response(_one_decision(x=1))) - service.evaluate("Loan Pricing", {}, folder_path="Finance/Loans") + result = await service.run_async( + {"a": 1}, deployed=LOAN_PRICING, folder_path="Finance" + ) - folders_service.retrieve_folder_key.assert_called_once_with("Finance/Loans") + folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance") + assert result.mode == RunMode.DEPLOYED + assert result.status == BusinessRuleStatus.SUCCESS 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=_response([])) - - service.evaluate("Loan Pricing", {}) - - 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_requires_a_folder(self, service: BusinessRulesService) -> None: - with pytest.raises(ValueError, match="folder is required"): - service.evaluate("Loan Pricing", {}) - - def test_rejects_both_folder_key_and_path( - self, service: BusinessRulesService - ) -> None: - with pytest.raises(ValueError, match="Only one of"): - service.evaluate( - "Loan Pricing", {}, folder_key=FOLDER_KEY, folder_path="Finance" - ) - - @pytest.mark.parametrize( - "rule_name", - ["", " ", "a/b", "a\\b", "a..b", "a%20b", "a\nb", "x" * 257], - ) - def test_rejects_unsafe_rule_names( - self, service: BusinessRulesService, rule_name: str - ) -> None: - with pytest.raises(ValueError, match="rule_name"): - service.evaluate(rule_name, {}, folder_key=FOLDER_KEY) - def test_rejects_oversized_input(self, service: BusinessRulesService) -> None: - with pytest.raises(ValueError, match="256 keys"): - service.evaluate( - "Loan Pricing", - {f"k{i}": i for i in range(257)}, - folder_key=FOLDER_KEY, - ) +class TestResult: def test_partial_success_on_207( self, httpx_mock: HTTPXMock, @@ -228,7 +263,7 @@ def test_partial_success_on_207( ), ) - result = service.evaluate("Loan Pricing", {}, folder_key=FOLDER_KEY) + result = service.run({}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY) assert result.status == BusinessRuleStatus.PARTIAL_SUCCESS assert result.decisions[1].error is not None @@ -258,7 +293,7 @@ def test_input_level_error_is_all_failed( ), ) - result = service.evaluate("Loan Pricing", {}, folder_key=FOLDER_KEY) + result = service.run({}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY) assert result.status == BusinessRuleStatus.ALL_FAILED assert result.errors[0].code == "INPUT_VALIDATION_FAILED" @@ -279,7 +314,7 @@ def test_not_evaluated_carries_top_level_error( ), ) - result = service.evaluate("Loan Pricing", {}, folder_key=FOLDER_KEY) + result = service.run({}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY) assert result.status == BusinessRuleStatus.ALL_FAILED assert result.decisions == [] @@ -302,35 +337,10 @@ def test_raises_enriched_exception_on_error_envelope( ) with pytest.raises(EnrichedException) as exc: - service.evaluate("Missing", {}, folder_key=FOLDER_KEY) + service.run( + {}, + deployed=DeployedRunContext(rule_name="Missing"), + folder_key=FOLDER_KEY, + ) assert exc.value.status_code == 404 - - async def test_evaluate_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=_response( - [ - { - "id": "input-1", - "decisions": [{"decisionName": "D", "outputs": {"x": 1}}], - } - ] - ), - ) - - result = await service.evaluate_async( - "Loan Pricing", {"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 From 9fa2e8a10c0682f7e5ab75ac89ac9cfee03687f1 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 09:40:32 +0530 Subject: [PATCH 03/13] test(business-rules): cover the UIPATH_FOLDER_PATH fallback - Add sync and async tests for the UIPATH_FOLDER_PATH env fallback, which resolves the path to a key before sending x-uipath-folderkey, and a test that UIPATH_FOLDER_KEY wins over UIPATH_FOLDER_PATH without a lookup (Copilot review). - Build DeployedRunContext outside pytest.raises so each block has a single call that can raise (Sonar python:S5778). Co-Authored-By: Claude Opus 5.5 --- .../services/test_business_rules_service.py | 81 ++++++++++++++++--- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 6b2fa4826..368623d1b 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -75,12 +75,10 @@ class TestRunContext: def test_rejects_unsafe_rule_names( self, service: BusinessRulesService, rule_name: str ) -> None: + deployed = DeployedRunContext(rule_name=rule_name) + with pytest.raises(ValueError, match="deployed.rule_name"): - service.run( - {}, - deployed=DeployedRunContext(rule_name=rule_name), - folder_key=FOLDER_KEY, - ) + service.run({}, deployed=deployed, folder_key=FOLDER_KEY) def test_rejects_oversized_input(self, service: BusinessRulesService) -> None: with pytest.raises(ValueError, match="256 keys"): @@ -129,6 +127,71 @@ def test_falls_back_to_env_folder_key( 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=_response([])) + + service.run({}, deployed=LOAN_PRICING) + + 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=_response([])) + + await service.run_async({}, deployed=LOAN_PRICING) + + 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=_response([])) + + service.run({}, deployed=LOAN_PRICING) + + 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, service: BusinessRulesService) -> None: with pytest.raises(ValueError, match="deployed business rule"): service.run({}, deployed=LOAN_PRICING) @@ -336,11 +399,9 @@ def test_raises_enriched_exception_on_error_envelope( }, ) + deployed = DeployedRunContext(rule_name="Missing") + with pytest.raises(EnrichedException) as exc: - service.run( - {}, - deployed=DeployedRunContext(rule_name="Missing"), - folder_key=FOLDER_KEY, - ) + service.run({}, deployed=deployed, folder_key=FOLDER_KEY) assert exc.value.status_code == 404 From 9d483533261c4a23550e5b6850e8a28581dd97df Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 11:51:22 +0530 Subject: [PATCH 04/13] feat(business-rules): accept an optional trace_context on run()/run_async() Callers can now pass TraceContext(trace_id, parent_span_id), like the .NET client's TraceContext, to file the run's spans under a trace of their choosing. When it is omitted the header stays automatic: the trace from UIPATH_TRACE_ID and the current span, as for every service. - TraceContext validates at construction: 32-hex (or UUID) trace id, 16-hex parent span id, neither all zeros; ids are normalised. - BaseService always sets the ambient header, so a request hook on this service's own sync and async httpx clients replaces it with the explicit value just before sending. Shared code is unchanged. - The explicit value lives in a ContextVar for the duration of the call, so it never leaks into the next call or across concurrent async runs, and it survives retries. Co-Authored-By: Claude Opus 5.5 --- .../platform/business_rules/__init__.py | 2 + .../business_rules/_business_rules_service.py | 81 +++++++-- .../platform/business_rules/business_rules.py | 44 ++++- .../services/test_business_rules_service.py | 167 +++++++++++++++++- 4 files changed, 276 insertions(+), 18 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py index ed32805ff..bc9907b29 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -13,6 +13,7 @@ BusinessRuleStatus, DeployedRunContext, RunMode, + TraceContext, ) __all__ = [ @@ -23,4 +24,5 @@ "BusinessRulesService", "DeployedRunContext", "RunMode", + "TraceContext", ] 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 index 1f55efe74..dac026599 100644 --- 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 @@ -3,11 +3,14 @@ Runs DMN decision models deployed to Orchestrator as business rules. """ -from typing import Any, Dict, List, Optional, Tuple +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Any, Dict, Iterator, List, Optional, Tuple +from httpx import Request from uipath.core.tracing import traced -from ..common._base_service import BaseService +from ..common._base_service import _TRACE_PARENT_HEADER, BaseService from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext @@ -21,6 +24,7 @@ BusinessRuleStatus, DeployedRunContext, RunMode, + TraceContext, _WireResponse, _WireResult, ) @@ -32,6 +36,13 @@ _MAX_INPUT_KEYS = 256 _MAX_RULE_NAME_LENGTH = 256 +# The caller's explicit trace for the run in progress. BaseService always sets +# the ambient trace header, so a request hook on this service's own clients +# replaces it just before sending. A ContextVar keeps concurrent runs apart. +_explicit_traceparent: ContextVar[Optional[str]] = ContextVar( + "business_rules_traceparent", default=None +) + class BusinessRulesService(FolderContext, BaseService): """Service for running UiPath Business Rules (DMN decision models). @@ -49,6 +60,15 @@ def __init__( ) -> None: super().__init__(config=config, execution_context=execution_context) self._folders_service = folders_service + sync_hooks = self._client.event_hooks + sync_hooks["request"] = [*sync_hooks.get("request", []), _apply_traceparent] + self._client.event_hooks = sync_hooks + async_hooks = self._client_async.event_hooks + async_hooks["request"] = [ + *async_hooks.get("request", []), + _apply_traceparent_async, + ] + self._client_async.event_hooks = async_hooks @traced(name="business_rules_run", run_type="uipath") def run( @@ -60,6 +80,7 @@ def run( explain: bool = False, folder_key: Optional[str] = None, folder_path: Optional[str] = None, + trace_context: Optional[TraceContext] = None, ) -> BusinessRuleRunResult: """Run a business rule against one input. @@ -72,6 +93,8 @@ def run( folder_key: The key of the folder to run in. folder_path: The path of the folder to run in. Resolved to a key, since the service accepts folder keys only. + trace_context: The trace to file the run's spans under. Defaults to + the ambient trace: ``UIPATH_TRACE_ID`` and the current span. A folder is required. When neither ``folder_key`` nor ``folder_path`` is given, it falls back to ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``. @@ -105,13 +128,14 @@ def run( if path: key = self._folders_service.retrieve_folder_key(path) mode, spec = self._run_spec(input, deployed, decision_names, explain, key) - response = self.request( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - scoped="tenant", - ) + with _trace_override(trace_context): + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + scoped="tenant", + ) return _to_run_result(mode, _WireResponse.model_validate(response.json())) @traced(name="business_rules_run", run_type="uipath") @@ -124,6 +148,7 @@ async def run_async( explain: bool = False, folder_key: Optional[str] = None, folder_path: Optional[str] = None, + trace_context: Optional[TraceContext] = None, ) -> BusinessRuleRunResult: """Asynchronously run a business rule against one input. @@ -136,6 +161,8 @@ async def run_async( folder_key: The key of the folder to run in. folder_path: The path of the folder to run in. Resolved to a key, since the service accepts folder keys only. + 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, and the @@ -150,13 +177,14 @@ async def run_async( if path: key = await self._folders_service.retrieve_folder_key_async(path) mode, spec = self._run_spec(input, deployed, decision_names, explain, key) - response = await self.request_async( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - scoped="tenant", - ) + with _trace_override(trace_context): + response = await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + scoped="tenant", + ) return _to_run_result(mode, _WireResponse.model_validate(response.json())) def _folder_source( @@ -212,6 +240,27 @@ def _evaluate_spec( ) +@contextmanager +def _trace_override(trace_context: Optional[TraceContext]) -> Iterator[None]: + token = _explicit_traceparent.set( + trace_context.to_traceparent() if trace_context else None + ) + try: + yield + finally: + _explicit_traceparent.reset(token) + + +def _apply_traceparent(request: Request) -> None: + traceparent = _explicit_traceparent.get() + if traceparent: + request.headers[_TRACE_PARENT_HEADER] = traceparent + + +async def _apply_traceparent_async(request: Request) -> None: + _apply_traceparent(request) + + def _present(value: Optional[str]) -> bool: # Blank counts as absent, matching how the service reads these fields. return bool(value and value.strip()) 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 index 97e5ddfd9..4201ef90c 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -1,9 +1,12 @@ """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 +from pydantic import BaseModel, ConfigDict, Field, field_validator + +_HEX = re.compile(r"^[0-9a-f]+$") class BusinessRuleStatus(str, Enum): @@ -30,6 +33,45 @@ class DeployedRunContext(BaseModel): ) +class TraceContext(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." + ) + + @field_validator("trace_id") + @classmethod + def _normalize_trace_id(cls, value: str) -> str: + normalized = value.replace("-", "").strip().lower() + if len(normalized) != 32 or not _HEX.match(normalized): + raise ValueError("trace_id must be 32 hex characters or a UUID") + if normalized == "0" * 32: + raise ValueError("trace_id must not be all zeros") + return normalized + + @field_validator("parent_span_id") + @classmethod + def _normalize_parent_span_id(cls, value: str) -> str: + normalized = value.strip().lower() + if len(normalized) != 16 or not _HEX.match(normalized): + raise ValueError("parent_span_id must be 16 hex characters") + if normalized == "0" * 16: + raise ValueError("parent_span_id must not be all zeros") + return normalized + + def to_traceparent(self) -> str: + """Return the W3C traceparent value for this context.""" + return f"00-{self.trace_id}-{self.parent_span_id}-01" + + class BusinessRuleError(BaseModel): """A code/message pair describing an input-level or decision-level error.""" diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 368623d1b..39860b3ec 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -1,8 +1,11 @@ import json -from typing import Any +from typing import Any, Iterator from unittest.mock import AsyncMock, Mock import pytest +from opentelemetry import trace +from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags +from pydantic import ValidationError from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext @@ -11,11 +14,13 @@ BusinessRuleStatus, DeployedRunContext, RunMode, + TraceContext, ) from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT from uipath.platform.errors import EnrichedException FOLDER_KEY = "5f1f1b0e-2b8a-4c1e-9b8e-1a2b3c4d5e6f" +TRACEPARENT = "x-uipath-traceparent-id" LOAN_PRICING = DeployedRunContext(rule_name="Loan Pricing") @@ -405,3 +410,163 @@ def test_raises_enriched_exception_on_error_envelope( service.run({}, deployed=deployed, 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 + + +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=_response([])) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run( + {}, deployed=LOAN_PRICING, 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=_response([])) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run( + {}, deployed=LOAN_PRICING, 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=_response([])) + + service.run({}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[TRACEPARENT].startswith(f"00-{AMBIENT_TRACE_ID}-") + + 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=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response([])) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run( + {}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY, trace_context=explicit + ) + service.run({}, deployed=LOAN_PRICING, 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=_response([])) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + await service.run_async( + {}, deployed=LOAN_PRICING, 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_trace_id_accepts_uuid_form_and_upper_case(self) -> None: + context = TraceContext( + 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 be 32 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, "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): + TraceContext(trace_id=trace_id, parent_span_id=parent_span_id) From 8b5c87fb2eb0a0439125450fc7152655b6d03e85 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 18:34:09 +0530 Subject: [PATCH 05/13] feat(business-rules): name-first run() with resource overrides Addresses review on #1912: - run()/run_async() take the rule name as the first parameter, like processes.invoke(name, input_arguments): run("Loan Pricing", {"age": 14}, version=..., folder_path=...). DeployedRunContext is removed (never released); version is a keyword. - @resource_override(resource_type="businessRule") on both, so a solution's bindings can remap the rule name and folder per environment. "businessRule" is the Studio resource kind. - Add "businessRule" to GenericResourceOverwrite so such bindings parse, as done for memorySpace (#1586) and remoteA2aAgent (#1581). Co-Authored-By: Claude Opus 5.5 --- .../platform/business_rules/__init__.py | 2 - .../business_rules/_business_rules_service.py | 67 ++++---- .../platform/business_rules/business_rules.py | 10 -- .../src/uipath/platform/common/_bindings.py | 1 + .../services/test_business_rules_service.py | 155 ++++++++++++------ 5 files changed, 145 insertions(+), 90 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py index bc9907b29..32556d4d0 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -11,7 +11,6 @@ BusinessRuleError, BusinessRuleRunResult, BusinessRuleStatus, - DeployedRunContext, RunMode, TraceContext, ) @@ -22,7 +21,6 @@ "BusinessRuleRunResult", "BusinessRuleStatus", "BusinessRulesService", - "DeployedRunContext", "RunMode", "TraceContext", ] 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 index dac026599..465a1c87f 100644 --- 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 @@ -11,6 +11,7 @@ from uipath.core.tracing import traced from ..common._base_service import _TRACE_PARENT_HEADER, BaseService +from ..common._bindings import resource_override from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext @@ -22,7 +23,6 @@ BusinessRuleError, BusinessRuleRunResult, BusinessRuleStatus, - DeployedRunContext, RunMode, TraceContext, _WireResponse, @@ -47,9 +47,9 @@ class BusinessRulesService(FolderContext, BaseService): """Service for running UiPath Business Rules (DMN decision models). - Each call runs one input and returns the decisions it produced. Which model - runs is set by the run context; ``deployed`` names a rule deployed to - Orchestrator. The caller never picks a service endpoint. + Each call runs one input against a business rule deployed to Orchestrator, + named like any other resource, and returns the decisions it produced. The + caller never picks a service endpoint. """ def __init__( @@ -70,12 +70,14 @@ def __init__( ] self._client_async.event_hooks = async_hooks + @resource_override(resource_type="businessRule") @traced(name="business_rules_run", run_type="uipath") def run( self, + name: str, input: Dict[str, Any], *, - deployed: DeployedRunContext, + version: Optional[str] = None, decision_names: Optional[List[str]] = None, explain: bool = False, folder_key: Optional[str] = None, @@ -85,9 +87,10 @@ def run( """Run a business rule against one input. Args: - input: The input to run, keyed by DMN input name. Declared inputs absent - from it bind to null. - deployed: The business rule deployed to Orchestrator to run. + name: The name of the business rule deployed to Orchestrator. + input: 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. decision_names: The decisions to evaluate; defaults to the whole model. explain: Whether to record condition-level explanations in the trace. folder_key: The key of the folder to run in. @@ -96,8 +99,10 @@ def run( trace_context: The trace to file the run's spans under. Defaults to the ambient trace: ``UIPATH_TRACE_ID`` and the current span. - A folder is required. When neither ``folder_key`` nor ``folder_path`` is given, it falls back to - ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``. + A folder is required. When neither ``folder_key`` nor ``folder_path`` is + given, it falls back to ``UIPATH_FOLDER_KEY`` and then + ``UIPATH_FOLDER_PATH``. ``name`` and ``folder_path`` can be overridden per + environment through the project's ``businessRule`` bindings. Returns: BusinessRuleRunResult: The decisions produced for the input, and the @@ -110,24 +115,23 @@ def run( Examples: ```python from uipath.platform import UiPath - from uipath.platform.business_rules import DeployedRunContext client = UiPath() result = client.business_rules.run( + "Loan Pricing", {"creditScore": 740, "age": 34}, - deployed=DeployedRunContext(rule_name="Loan Pricing"), folder_path="Finance", ) for decision in result.decisions: print(decision.decision_name, decision.outputs) ``` """ - _validate_run(input, deployed) + _validate_run(name, input) key, path = self._folder_source(folder_key, folder_path) if path: key = self._folders_service.retrieve_folder_key(path) - mode, spec = self._run_spec(input, deployed, decision_names, explain, key) + mode, spec = self._run_spec(name, input, version, decision_names, explain, key) with _trace_override(trace_context): response = self.request( spec.method, @@ -138,12 +142,14 @@ def run( ) return _to_run_result(mode, _WireResponse.model_validate(response.json())) + @resource_override(resource_type="businessRule") @traced(name="business_rules_run", run_type="uipath") async def run_async( self, + name: str, input: Dict[str, Any], *, - deployed: DeployedRunContext, + version: Optional[str] = None, decision_names: Optional[List[str]] = None, explain: bool = False, folder_key: Optional[str] = None, @@ -153,9 +159,10 @@ async def run_async( """Asynchronously run a business rule against one input. Args: - input: The input to run, keyed by DMN input name. Declared inputs absent - from it bind to null. - deployed: The business rule deployed to Orchestrator to run. + name: The name of the business rule deployed to Orchestrator. + input: 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. decision_names: The decisions to evaluate; defaults to the whole model. explain: Whether to record condition-level explanations in the trace. folder_key: The key of the folder to run in. @@ -172,11 +179,11 @@ async def run_async( ValueError: If the request is invalid or a required folder is missing. EnrichedException: If the service rejects the request. """ - _validate_run(input, deployed) + _validate_run(name, input) key, path = self._folder_source(folder_key, folder_path) if path: key = await self._folders_service.retrieve_folder_key_async(path) - mode, spec = self._run_spec(input, deployed, decision_names, explain, key) + mode, spec = self._run_spec(name, input, version, decision_names, explain, key) with _trace_override(trace_context): response = await self.request_async( spec.method, @@ -203,8 +210,9 @@ def _folder_source( def _run_spec( self, + name: str, input: Dict[str, Any], - deployed: DeployedRunContext, + version: Optional[str], decision_names: Optional[List[str]], explain: bool, folder_key: Optional[str], @@ -212,24 +220,25 @@ def _run_spec( if not folder_key: raise _missing_folder("a deployed business rule") return RunMode.DEPLOYED, self._evaluate_spec( - input, deployed, decision_names, explain, folder_key + name, input, version, decision_names, explain, folder_key ) def _evaluate_spec( self, + name: str, input: Dict[str, Any], - deployed: DeployedRunContext, + version: Optional[str], decision_names: Optional[List[str]], explain: bool, folder_key: str, ) -> RequestSpec: body: Dict[str, Any] = { - "businessRuleName": deployed.rule_name, + "businessRuleName": name, "explain": explain, "inputs": [{"id": _SINGLE_INPUT_ID, "data": input}], } - if deployed.version: - body["version"] = deployed.version + if version: + body["version"] = version if decision_names: body["decisionNames"] = decision_names return RequestSpec( @@ -273,10 +282,8 @@ def _missing_folder(needed_for: str) -> ValueError: ) -def _validate_run(input: Dict[str, Any], deployed: DeployedRunContext) -> None: - if deployed is None: - raise ValueError("deployed must be set") - _validate_rule_name(deployed.rule_name, "deployed.rule_name") +def _validate_run(name: str, input: Dict[str, Any]) -> None: + _validate_rule_name(name, "name") _validate_input(input) 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 index 4201ef90c..f63fb97f8 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -23,16 +23,6 @@ class RunMode(str, Enum): DEPLOYED = "Deployed" -class DeployedRunContext(BaseModel): - """A business rule deployed to Orchestrator.""" - - rule_name: str = Field(description="The name of the deployed business rule.") - version: Optional[str] = Field( - default=None, - description="The version to run; defaults to the active version.", - ) - - class TraceContext(BaseModel): """An existing trace to file the run's spans under. diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index a93880896..e764ee587 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") diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 39860b3ec..bd724535d 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -12,16 +12,20 @@ from uipath.platform.business_rules import ( BusinessRulesService, BusinessRuleStatus, - DeployedRunContext, RunMode, TraceContext, ) +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + ResourceOverwriteParser, + _resource_overwrites, +) from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT from uipath.platform.errors import EnrichedException FOLDER_KEY = "5f1f1b0e-2b8a-4c1e-9b8e-1a2b3c4d5e6f" TRACEPARENT = "x-uipath-traceparent-id" -LOAN_PRICING = DeployedRunContext(rule_name="Loan Pricing") +RULE = "Loan Pricing" @pytest.fixture @@ -80,18 +84,12 @@ class TestRunContext: def test_rejects_unsafe_rule_names( self, service: BusinessRulesService, rule_name: str ) -> None: - deployed = DeployedRunContext(rule_name=rule_name) - - with pytest.raises(ValueError, match="deployed.rule_name"): - service.run({}, deployed=deployed, folder_key=FOLDER_KEY) + with pytest.raises(ValueError, match="name"): + service.run(rule_name, {}, folder_key=FOLDER_KEY) def test_rejects_oversized_input(self, service: BusinessRulesService) -> None: with pytest.raises(ValueError, match="256 keys"): - service.run( - {f"k{i}": i for i in range(257)}, - deployed=LOAN_PRICING, - folder_key=FOLDER_KEY, - ) + service.run(RULE, {f"k{i}": i for i in range(257)}, folder_key=FOLDER_KEY) class TestFolder: @@ -104,7 +102,7 @@ def test_resolves_folder_path_to_key( ) -> None: httpx_mock.add_response(url=evaluate_url, json=_response([])) - service.run({}, deployed=LOAN_PRICING, folder_path="Finance/Loans") + service.run(RULE, {}, folder_path="Finance/Loans") folders_service.retrieve_folder_key.assert_called_once_with("Finance/Loans") request = httpx_mock.get_request() @@ -125,7 +123,7 @@ def test_falls_back_to_env_folder_key( service = BusinessRulesService(config, execution_context, folders_service) httpx_mock.add_response(url=evaluate_url, json=_response([])) - service.run({}, deployed=LOAN_PRICING) + service.run(RULE, {}) folders_service.retrieve_folder_key.assert_not_called() request = httpx_mock.get_request() @@ -145,7 +143,7 @@ def test_falls_back_to_env_folder_path( service = BusinessRulesService(config, execution_context, folders_service) httpx_mock.add_response(url=evaluate_url, json=_response([])) - service.run({}, deployed=LOAN_PRICING) + service.run(RULE, {}) folders_service.retrieve_folder_key.assert_called_once_with("Finance/Loans") request = httpx_mock.get_request() @@ -166,7 +164,7 @@ async def test_falls_back_to_env_folder_path_async( service = BusinessRulesService(config, execution_context, folders_service) httpx_mock.add_response(url=evaluate_url, json=_response([])) - await service.run_async({}, deployed=LOAN_PRICING) + await service.run_async(RULE, {}) folders_service.retrieve_folder_key_async.assert_awaited_once_with( "Finance/Loans" @@ -190,7 +188,7 @@ def test_env_folder_key_wins_over_env_folder_path( service = BusinessRulesService(config, execution_context, folders_service) httpx_mock.add_response(url=evaluate_url, json=_response([])) - service.run({}, deployed=LOAN_PRICING) + service.run(RULE, {}) folders_service.retrieve_folder_key.assert_not_called() request = httpx_mock.get_request() @@ -199,18 +197,13 @@ def test_env_folder_key_wins_over_env_folder_path( def test_deployed_requires_a_folder(self, service: BusinessRulesService) -> None: with pytest.raises(ValueError, match="deployed business rule"): - service.run({}, deployed=LOAN_PRICING) + service.run(RULE, {}) def test_rejects_both_folder_key_and_path( self, service: BusinessRulesService ) -> None: with pytest.raises(ValueError, match="Only one of"): - service.run( - {}, - deployed=LOAN_PRICING, - folder_key=FOLDER_KEY, - folder_path="Finance", - ) + service.run(RULE, {}, folder_key=FOLDER_KEY, folder_path="Finance") class TestDeployed: @@ -232,8 +225,9 @@ def test_sends_single_input_and_maps_decisions( ) result = service.run( + "Loan Pricing", {"creditScore": 740}, - deployed=DeployedRunContext(rule_name="Loan Pricing", version="1.0.3"), + version="1.0.3", decision_names=["RiskGrade"], explain=True, folder_key=FOLDER_KEY, @@ -272,7 +266,7 @@ def test_omits_optional_fields( ) -> None: httpx_mock.add_response(url=evaluate_url, json=_response([])) - service.run({}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY) + service.run(RULE, {}, folder_key=FOLDER_KEY) request = httpx_mock.get_request() assert request is not None @@ -290,9 +284,7 @@ async def test_run_async_resolves_folder_path( ) -> None: httpx_mock.add_response(url=evaluate_url, json=_response(_one_decision(x=1))) - result = await service.run_async( - {"a": 1}, deployed=LOAN_PRICING, folder_path="Finance" - ) + result = await service.run_async(RULE, {"a": 1}, folder_path="Finance") folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance") assert result.mode == RunMode.DEPLOYED @@ -331,7 +323,7 @@ def test_partial_success_on_207( ), ) - result = service.run({}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY) + result = service.run(RULE, {}, folder_key=FOLDER_KEY) assert result.status == BusinessRuleStatus.PARTIAL_SUCCESS assert result.decisions[1].error is not None @@ -361,7 +353,7 @@ def test_input_level_error_is_all_failed( ), ) - result = service.run({}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY) + result = service.run(RULE, {}, folder_key=FOLDER_KEY) assert result.status == BusinessRuleStatus.ALL_FAILED assert result.errors[0].code == "INPUT_VALIDATION_FAILED" @@ -382,7 +374,7 @@ def test_not_evaluated_carries_top_level_error( ), ) - result = service.run({}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY) + result = service.run(RULE, {}, folder_key=FOLDER_KEY) assert result.status == BusinessRuleStatus.ALL_FAILED assert result.decisions == [] @@ -404,10 +396,8 @@ def test_raises_enriched_exception_on_error_envelope( }, ) - deployed = DeployedRunContext(rule_name="Missing") - with pytest.raises(EnrichedException) as exc: - service.run({}, deployed=deployed, folder_key=FOLDER_KEY) + service.run("Missing", {}, folder_key=FOLDER_KEY) assert exc.value.status_code == 404 @@ -445,9 +435,7 @@ def test_explicit_trace_context_wins_over_ambient_span( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) - service.run( - {}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY, trace_context=explicit - ) + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) request = httpx_mock.get_request() assert request is not None @@ -470,9 +458,7 @@ def test_explicit_trace_context_wins_over_uipath_trace_id( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) - service.run( - {}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY, trace_context=explicit - ) + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) request = httpx_mock.get_request() assert request is not None @@ -492,7 +478,7 @@ def test_ambient_trace_is_used_without_trace_context( monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) httpx_mock.add_response(url=evaluate_url, json=_response([])) - service.run({}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY) + service.run(RULE, {}, folder_key=FOLDER_KEY) request = httpx_mock.get_request() assert request is not None @@ -513,10 +499,8 @@ def test_override_does_not_leak_into_the_next_call( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) - service.run( - {}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY, trace_context=explicit - ) - service.run({}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY) + 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] @@ -534,9 +518,7 @@ async def test_explicit_trace_context_async( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) - await service.run_async( - {}, deployed=LOAN_PRICING, folder_key=FOLDER_KEY, trace_context=explicit - ) + await service.run_async(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) request = httpx_mock.get_request() assert request is not None @@ -570,3 +552,80 @@ def test_rejects_malformed_ids( ) -> None: with pytest.raises(ValidationError, match=message): TraceContext(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=_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=_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_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=_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" From f1d6ea845249e86d66950f63cc4c356777fbc20b Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 18:53:05 +0530 Subject: [PATCH 06/13] docs(business-rules): say business rules, not DMN Addresses review: describe the service in product terms in the package guide and docstrings. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/CLAUDE.md | 2 +- .../src/uipath/platform/business_rules/__init__.py | 6 +++--- .../platform/business_rules/_business_rules_service.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/uipath-platform/CLAUDE.md b/packages/uipath-platform/CLAUDE.md index f9c64d5a6..368b9fa1e 100644 --- a/packages/uipath-platform/CLAUDE.md +++ b/packages/uipath-platform/CLAUDE.md @@ -96,7 +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/` | DMN business rule runs for rules deployed to Orchestrator, behind one `run()` | +| `business_rules/` | Business rule runs for rules deployed to Orchestrator, 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/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py index 32556d4d0..889f86cb3 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -1,8 +1,8 @@ """Business Rules service package. -Provides the ``BusinessRulesService`` client for running DMN decision models -deployed to Orchestrator as UiPath Business Rules, and the Pydantic models for -its run context and results. +Provides the ``BusinessRulesService`` client for running UiPath Business Rules +deployed to Orchestrator, and the Pydantic models for its results and trace +context. """ from ._business_rules_service import BusinessRulesService 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 index 465a1c87f..deb658706 100644 --- 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 @@ -1,6 +1,6 @@ """Business Rules service for UiPath Platform. -Runs DMN decision models deployed to Orchestrator as business rules. +Runs business rules deployed to Orchestrator. """ from contextlib import contextmanager @@ -45,7 +45,7 @@ class BusinessRulesService(FolderContext, BaseService): - """Service for running UiPath Business Rules (DMN decision models). + """Service for running UiPath Business Rules. Each call runs one input against a business rule deployed to Orchestrator, named like any other resource, and returns the decisions it produced. The From b86e1c2dbf6f4cb997b3c9d140a9cbf39c6dbd3a Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 19:05:36 +0530 Subject: [PATCH 07/13] refactor(business-rules): keep an explicit trace without request hooks Fixes Sonar python:S7503 on the async request hook, which had to be `async` for httpx but awaited nothing. The explicit trace_context is now carried in the headers passed to BaseService: a small dict that ignores BaseService's later write of the ambient trace header when an explicit one is set. This removes both httpx request hooks, the async hook function and the ContextVar. Each call gets its own headers, so nothing leaks between calls or across concurrent async runs, and retries reuse them (new test). Shared code is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 83 +++++++------------ .../services/test_business_rules_service.py | 20 +++++ 2 files changed, 52 insertions(+), 51 deletions(-) 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 index deb658706..93910c751 100644 --- 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 @@ -3,11 +3,8 @@ Runs business rules deployed to Orchestrator. """ -from contextlib import contextmanager -from contextvars import ContextVar -from typing import Any, Dict, Iterator, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple -from httpx import Request from uipath.core.tracing import traced from ..common._base_service import _TRACE_PARENT_HEADER, BaseService @@ -36,13 +33,6 @@ _MAX_INPUT_KEYS = 256 _MAX_RULE_NAME_LENGTH = 256 -# The caller's explicit trace for the run in progress. BaseService always sets -# the ambient trace header, so a request hook on this service's own clients -# replaces it just before sending. A ContextVar keeps concurrent runs apart. -_explicit_traceparent: ContextVar[Optional[str]] = ContextVar( - "business_rules_traceparent", default=None -) - class BusinessRulesService(FolderContext, BaseService): """Service for running UiPath Business Rules. @@ -60,15 +50,6 @@ def __init__( ) -> None: super().__init__(config=config, execution_context=execution_context) self._folders_service = folders_service - sync_hooks = self._client.event_hooks - sync_hooks["request"] = [*sync_hooks.get("request", []), _apply_traceparent] - self._client.event_hooks = sync_hooks - async_hooks = self._client_async.event_hooks - async_hooks["request"] = [ - *async_hooks.get("request", []), - _apply_traceparent_async, - ] - self._client_async.event_hooks = async_hooks @resource_override(resource_type="businessRule") @traced(name="business_rules_run", run_type="uipath") @@ -132,14 +113,13 @@ def run( if path: key = self._folders_service.retrieve_folder_key(path) mode, spec = self._run_spec(name, input, version, decision_names, explain, key) - with _trace_override(trace_context): - response = self.request( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - scoped="tenant", - ) + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=_with_trace(spec.headers, trace_context), + scoped="tenant", + ) return _to_run_result(mode, _WireResponse.model_validate(response.json())) @resource_override(resource_type="businessRule") @@ -184,14 +164,13 @@ async def run_async( if path: key = await self._folders_service.retrieve_folder_key_async(path) mode, spec = self._run_spec(name, input, version, decision_names, explain, key) - with _trace_override(trace_context): - response = await self.request_async( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - scoped="tenant", - ) + response = await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=_with_trace(spec.headers, trace_context), + scoped="tenant", + ) return _to_run_result(mode, _WireResponse.model_validate(response.json())) def _folder_source( @@ -249,25 +228,27 @@ def _evaluate_spec( ) -@contextmanager -def _trace_override(trace_context: Optional[TraceContext]) -> Iterator[None]: - token = _explicit_traceparent.set( - trace_context.to_traceparent() if trace_context else None - ) - try: - yield - finally: - _explicit_traceparent.reset(token) +class _TraceHeaders(Dict[str, str]): + """Request headers that keep the caller's explicit trace header. + BaseService writes the ambient trace header into the headers it is given just + before sending; this dict ignores that write when an explicit one is set. + """ -def _apply_traceparent(request: Request) -> None: - traceparent = _explicit_traceparent.get() - if traceparent: - request.headers[_TRACE_PARENT_HEADER] = traceparent + def __setitem__(self, key: str, value: str) -> None: + if key == _TRACE_PARENT_HEADER and key in self: + return + super().__setitem__(key, value) -async def _apply_traceparent_async(request: Request) -> None: - _apply_traceparent(request) +def _with_trace( + headers: Dict[str, str], trace_context: Optional[TraceContext] +) -> Dict[str, str]: + if trace_context is None: + return headers + pinned = _TraceHeaders(headers) + dict.__setitem__(pinned, _TRACE_PARENT_HEADER, trace_context.to_traceparent()) + return pinned def _present(value: Optional[str]) -> bool: diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index bd724535d..78e0ecb3d 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -527,6 +527,26 @@ async def test_explicit_trace_context_async( == 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=_response([])) + explicit = TraceContext( + 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_trace_id_accepts_uuid_form_and_upper_case(self) -> None: context = TraceContext( trace_id="4BF92F35-77B3-4DA6-A3CE-929D0E0E4736", From 162cf34a1759cf9084ca8ab326abae5e4d108b1c Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 30 Sep 2026 09:10:09 +0530 Subject: [PATCH 08/13] fix(business-rules): let a binding's folder replace folder_key; reject non-dict input Addresses Copilot review on #1912: - A businessRule binding supplies its folder as a path. The override decorator on run() replaced folder_path but left the caller's folder_key, so run() saw both and raised "Only one of folder_key or folder_path". The binding is now applied by a private helper (_binding, decorated with resource_override); when it remaps the rule, its folder replaces whichever folder the caller gave, including folder_key. Sync and async tests cover it, plus a non-matching override keeping the caller's key. - input must be a mapping: a list or string is now a ValueError before anything is sent, instead of being posted as inputs[0].data. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 41 ++++++++++-- .../services/test_business_rules_service.py | 62 +++++++++++++++++++ 2 files changed, 99 insertions(+), 4 deletions(-) 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 index 93910c751..b9d507704 100644 --- 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 @@ -3,6 +3,7 @@ Runs business rules deployed to Orchestrator. """ +from collections.abc import Mapping from typing import Any, Dict, List, Optional, Tuple from uipath.core.tracing import traced @@ -51,7 +52,6 @@ def __init__( super().__init__(config=config, execution_context=execution_context) self._folders_service = folders_service - @resource_override(resource_type="businessRule") @traced(name="business_rules_run", run_type="uipath") def run( self, @@ -82,8 +82,9 @@ def run( A folder is required. When neither ``folder_key`` nor ``folder_path`` is given, it falls back to ``UIPATH_FOLDER_KEY`` and then - ``UIPATH_FOLDER_PATH``. ``name`` and ``folder_path`` can be overridden per - environment through the project's ``businessRule`` bindings. + ``UIPATH_FOLDER_PATH``. 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, and the @@ -108,6 +109,9 @@ def run( print(decision.decision_name, decision.outputs) ``` """ + name, folder_key, folder_path = self._apply_binding( + name, folder_key, folder_path + ) _validate_run(name, input) key, path = self._folder_source(folder_key, folder_path) if path: @@ -122,7 +126,6 @@ def run( ) return _to_run_result(mode, _WireResponse.model_validate(response.json())) - @resource_override(resource_type="businessRule") @traced(name="business_rules_run", run_type="uipath") async def run_async( self, @@ -159,6 +162,9 @@ async def run_async( ValueError: If the request is invalid or a required folder is missing. EnrichedException: If the service rejects the request. """ + name, folder_key, folder_path = self._apply_binding( + name, folder_key, folder_path + ) _validate_run(name, input) key, path = self._folder_source(folder_key, folder_path) if path: @@ -173,6 +179,28 @@ async def run_async( ) return _to_run_result(mode, _WireResponse.model_validate(response.json())) + @resource_override(resource_type="businessRule") + def _binding( + self, name: str, folder_path: Optional[str] = None + ) -> Tuple[str, Optional[str]]: + # resource_override swaps these two arguments when the solution's + # bindings remap this rule; the method just returns what it was given. + return name, folder_path + + def _apply_binding( + self, name: str, folder_key: Optional[str], folder_path: Optional[str] + ) -> Tuple[str, Optional[str], Optional[str]]: + """Apply a businessRule binding, if one remaps this rule. + + The binding names a folder by path. When it applies, that folder + replaces whichever folder the caller gave, including a folder_key, which + the override decorator alone would leave in place next to the new path. + """ + bound_name, bound_path = self._binding(name, folder_path=folder_path) + if (bound_name, bound_path) != (name, folder_path) and bound_path: + folder_key = None + return bound_name, folder_key, bound_path + def _folder_source( self, folder_key: Optional[str], folder_path: Optional[str] ) -> Tuple[Optional[str], Optional[str]]: @@ -283,6 +311,11 @@ def _validate_rule_name(rule_name: str, field: str) -> None: def _validate_input(input: Dict[str, Any]) -> None: if input is None: raise ValueError("input must not be None") + if not isinstance(input, Mapping): + raise ValueError( + "input must be a mapping of the rule's input names to values, " + f"not {type(input).__name__}" + ) if len(input) > _MAX_INPUT_KEYS: raise ValueError(f"input must not exceed {_MAX_INPUT_KEYS} keys") diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 78e0ecb3d..4c31a67f3 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -87,6 +87,15 @@ def test_rejects_unsafe_rule_names( with pytest.raises(ValueError, match="name"): service.run(rule_name, {}, folder_key=FOLDER_KEY) + @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 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) @@ -633,6 +642,59 @@ async def test_override_applies_to_run_async( 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=_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=_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=_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, From 64411bcd94a2c80ac0a020c75625789f6795c0b9 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 30 Sep 2026 09:37:11 +0530 Subject: [PATCH 09/13] feat(business-rules): accept organization_unit_id on run() run()/run_async() now take the folder three ways: folder_key (sent as is), folder_path (looked up, sent as the key) or organization_unit_id (sent as-is as x-uipath-organizationunitid). The service accepts the numeric id for a deployed rule and prefers the key when both are sent. - A folder is required in one of those forms; explain=True still needs a folder key, since the numeric id can't stand in for it. - folder_key and folder_path stay exclusive, as elsewhere in the SDK. - The environment's folder only fills a gap: it isn't added when the caller named the folder by its numeric id, except to supply the key that explain=True needs. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 95 ++++++++++--- .../services/test_business_rules_service.py | 127 ++++++++++++++++++ 2 files changed, 201 insertions(+), 21 deletions(-) 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 index b9d507704..808dd257d 100644 --- 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 @@ -29,6 +29,8 @@ _EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/evaluate") +_HEADER_ORGANIZATION_UNIT_ID = "x-uipath-organizationunitid" + # The service's contract is a batch; this SDK submits exactly one input under this id. _SINGLE_INPUT_ID = "input-1" _MAX_INPUT_KEYS = 256 @@ -63,6 +65,7 @@ def run( explain: bool = False, folder_key: Optional[str] = None, folder_path: Optional[str] = None, + organization_unit_id: Optional[int] = None, trace_context: Optional[TraceContext] = None, ) -> BusinessRuleRunResult: """Run a business rule against one input. @@ -75,16 +78,21 @@ def run( decision_names: The decisions to evaluate; defaults to the whole model. explain: Whether to record condition-level explanations in the trace. folder_key: The key of the folder to run in. - folder_path: The path of the folder to run in. Resolved to a key, since - the service accepts folder keys only. + folder_path: The path of the folder to run in. Looked up and sent as + its key. + organization_unit_id: The numeric id of the folder to run in, sent as + given. The service prefers the folder key when both are sent. trace_context: The trace to file the run's spans under. Defaults to the ambient trace: ``UIPATH_TRACE_ID`` and the current span. - A folder is required. When neither ``folder_key`` nor ``folder_path`` is - given, it falls back to ``UIPATH_FOLDER_KEY`` and then - ``UIPATH_FOLDER_PATH``. A ``businessRule`` binding can remap ``name`` and the - folder per environment; its folder then replaces ``folder_key`` or - ``folder_path``. + A folder is required: ``folder_key``, ``folder_path`` or + ``organization_unit_id``, with ``explain=True`` needing a folder key (from + ``folder_key`` or ``folder_path``). ``folder_key`` and ``folder_path`` are + exclusive. When the caller gives no folder at all, it falls back to + ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``; an explicit folder is + never replaced by the environment's. 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, and the @@ -113,10 +121,14 @@ def run( name, folder_key, folder_path ) _validate_run(name, input) - key, path = self._folder_source(folder_key, folder_path) + key, path = self._folder_source( + folder_key, folder_path, use_env=organization_unit_id is None or explain + ) if path: key = self._folders_service.retrieve_folder_key(path) - mode, spec = self._run_spec(name, input, version, decision_names, explain, key) + mode, spec = self._run_spec( + name, input, version, decision_names, explain, key, organization_unit_id + ) response = self.request( spec.method, url=spec.endpoint, @@ -137,6 +149,7 @@ async def run_async( explain: bool = False, folder_key: Optional[str] = None, folder_path: Optional[str] = None, + organization_unit_id: Optional[int] = None, trace_context: Optional[TraceContext] = None, ) -> BusinessRuleRunResult: """Asynchronously run a business rule against one input. @@ -149,8 +162,10 @@ async def run_async( decision_names: The decisions to evaluate; defaults to the whole model. explain: Whether to record condition-level explanations in the trace. folder_key: The key of the folder to run in. - folder_path: The path of the folder to run in. Resolved to a key, since - the service accepts folder keys only. + folder_path: The path of the folder to run in. Looked up and sent as + its key. + organization_unit_id: The numeric id of the folder to run in, sent as + given. The service prefers the folder key when both are sent. trace_context: The trace to file the run's spans under. Defaults to the ambient trace: ``UIPATH_TRACE_ID`` and the current span. @@ -166,10 +181,14 @@ async def run_async( name, folder_key, folder_path ) _validate_run(name, input) - key, path = self._folder_source(folder_key, folder_path) + key, path = self._folder_source( + folder_key, folder_path, use_env=organization_unit_id is None or explain + ) if path: key = await self._folders_service.retrieve_folder_key_async(path) - mode, spec = self._run_spec(name, input, version, decision_names, explain, key) + mode, spec = self._run_spec( + name, input, version, decision_names, explain, key, organization_unit_id + ) response = await self.request_async( spec.method, url=spec.endpoint, @@ -202,15 +221,21 @@ def _apply_binding( return bound_name, folder_key, bound_path def _folder_source( - self, folder_key: Optional[str], folder_path: Optional[str] + self, folder_key: Optional[str], folder_path: Optional[str], use_env: bool ) -> Tuple[Optional[str], Optional[str]]: - """Pick the folder to run in, as a (key, path-still-to-resolve) pair.""" + """Pick the folder key to send, as a (key, path-still-to-resolve) pair. + + ``use_env`` is false when the caller named the folder another way (by its + numeric id), so the environment's folder can't silently replace it. + """ if folder_key and folder_path: raise ValueError("Only one of folder_key or folder_path can be provided") if folder_key: return folder_key, None if folder_path: return None, folder_path + if not use_env: + return None, None if self._folder_key: return self._folder_key, None return None, self._folder_path or None @@ -223,11 +248,20 @@ def _run_spec( decision_names: Optional[List[str]], explain: bool, folder_key: Optional[str], + organization_unit_id: Optional[int], ) -> Tuple[RunMode, RequestSpec]: - if not folder_key: + if explain and not folder_key: + raise _missing_folder_key() + if not folder_key and organization_unit_id is None: raise _missing_folder("a deployed business rule") return RunMode.DEPLOYED, self._evaluate_spec( - name, input, version, decision_names, explain, folder_key + name, + input, + version, + decision_names, + explain, + folder_key, + organization_unit_id, ) def _evaluate_spec( @@ -237,7 +271,8 @@ def _evaluate_spec( version: Optional[str], decision_names: Optional[List[str]], explain: bool, - folder_key: str, + folder_key: Optional[str], + organization_unit_id: Optional[int], ) -> RequestSpec: body: Dict[str, Any] = { "businessRuleName": name, @@ -252,7 +287,7 @@ def _evaluate_spec( method="POST", endpoint=_EVALUATE_ENDPOINT, json=body, - headers={HEADER_FOLDER_KEY: folder_key}, + headers=_folder_headers(folder_key, organization_unit_id), ) @@ -284,10 +319,28 @@ def _present(value: Optional[str]) -> bool: return bool(value and value.strip()) +def _folder_headers( + folder_key: Optional[str], organization_unit_id: Optional[int] +) -> Dict[str, str]: + headers: Dict[str, str] = {} + if folder_key: + headers[HEADER_FOLDER_KEY] = folder_key + if organization_unit_id is not None: + headers[_HEADER_ORGANIZATION_UNIT_ID] = str(organization_unit_id) + return headers + + def _missing_folder(needed_for: str) -> ValueError: return ValueError( - f"A folder is required for {needed_for}: pass folder_key or folder_path, " - "or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" + f"A folder is required for {needed_for}: pass folder_key, folder_path or " + "organization_unit_id, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" + ) + + +def _missing_folder_key() -> ValueError: + return ValueError( + "A folder key is required for explain=True: pass folder_key or " + "folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" ) diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 4c31a67f3..abeb04026 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -204,6 +204,133 @@ def test_env_folder_key_wins_over_env_folder_path( assert request is not None assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" + def test_organization_unit_id_alone_is_sent_as_given( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, organization_unit_id=3373422) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-organizationunitid"] == "3373422" + assert HEADER_FOLDER_KEY not in request.headers + + def test_folder_key_and_organization_unit_id_are_both_sent( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, folder_key=FOLDER_KEY, organization_unit_id=42) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert request.headers["x-uipath-organizationunitid"] == "42" + + def test_folder_path_and_organization_unit_id_are_both_sent( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, folder_path="Finance", organization_unit_id=42) + + folders_service.retrieve_folder_key.assert_called_once_with("Finance") + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert request.headers["x-uipath-organizationunitid"] == "42" + + def test_env_folder_does_not_replace_an_explicit_organization_unit_id( + 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=_response([])) + + service.run(RULE, {}, organization_unit_id=42) + + request = httpx_mock.get_request() + assert request is not None + assert HEADER_FOLDER_KEY not in request.headers + assert request.headers["x-uipath-organizationunitid"] == "42" + + def test_explain_needs_a_folder_key_not_only_an_organization_unit_id( + self, httpx_mock: HTTPXMock, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="folder key is required for explain"): + service.run(RULE, {}, organization_unit_id=42, explain=True) + + assert httpx_mock.get_requests() == [] + + def test_explain_with_organization_unit_id_takes_the_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=_response([])) + + service.run(RULE, {}, organization_unit_id=42, explain=True) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" + assert request.headers["x-uipath-organizationunitid"] == "42" + + def test_explain_with_folder_path_sends_the_looked_up_key( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, folder_path="Finance", explain=True) + + folders_service.retrieve_folder_key.assert_called_once_with("Finance") + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert json.loads(request.content)["explain"] is True + + async def test_organization_unit_id_alone_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + await service.run_async(RULE, {}, organization_unit_id=42) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-organizationunitid"] == "42" + assert HEADER_FOLDER_KEY not in request.headers + def test_deployed_requires_a_folder(self, service: BusinessRulesService) -> None: with pytest.raises(ValueError, match="deployed business rule"): service.run(RULE, {}) From 5b93b51fc22a1927983534a6500a5b29c679825b Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 30 Sep 2026 10:24:21 +0530 Subject: [PATCH 10/13] chore(business-rules): bump uipath-platform to 0.2.34 main released 0.2.33 (#1920), so this PR moves to the next version. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 1b57a68a4..3df1c4def 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" 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/uv.lock b/packages/uipath-platform/uv.lock index 54ab45e3d..fee1df2ad 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.33" +version = "0.2.34" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 00cd8a8f2..c43717048 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From ca881df36a9cbb73aff81846b0ead867749987b8 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 14:07:00 +0530 Subject: [PATCH 11/13] feat(business-rules)!: send input, read result, require the folder key Catches run() up with the service contract from UiPath/business-rules#124, #126 and #130, and with the .NET client 2.0.0 (#131): - One input on the wire: the request sends `input` and the response is read from `result`. The deprecated `inputs` batch and its fixed id are gone, and a successful response without `result` raises ValueError. - No `explain`: the service decides what it traces. - The folder is named by key only. `organization_unit_id` is removed, and a run without a folder key (from folder_key, folder_path, UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH) raises before anything is sent. A blank value counts as not given. - A `caller` is sent for the execution audit. BusinessRuleCaller's fields default to UIPATH_PROCESS_UUID, UIPATH_JOB_KEY and UIPATH_FOLDER_KEY, blank fields are left out, and so is `caller` when every field is blank. - `RunMode` and `BusinessRuleRunResult.mode` are removed. Smoke-tested against alpha. Co-Authored-By: Claude Opus 5.5 --- .../platform/business_rules/__init__.py | 8 +- .../business_rules/_business_rules_service.py | 280 +++++-------- .../platform/business_rules/business_rules.py | 32 +- .../services/test_business_rules_service.py | 395 +++++++++--------- 4 files changed, 339 insertions(+), 376 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py index 889f86cb3..b49fd91cf 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -1,26 +1,26 @@ """Business Rules service package. Provides the ``BusinessRulesService`` client for running UiPath Business Rules -deployed to Orchestrator, and the Pydantic models for its results and trace -context. +deployed to Orchestrator, 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, - RunMode, TraceContext, ) __all__ = [ + "BusinessRuleCaller", "BusinessRuleDecision", "BusinessRuleError", "BusinessRuleRunResult", "BusinessRuleStatus", "BusinessRulesService", - "RunMode", "TraceContext", ] 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 index 808dd257d..65df6bdc8 100644 --- 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 @@ -10,29 +10,24 @@ from ..common._base_service import _TRACE_PARENT_HEADER, BaseService from ..common._bindings import resource_override -from ..common._config import UiPathApiConfig +from ..common._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext from ..common._models import Endpoint, RequestSpec from ..constants import HEADER_FOLDER_KEY from ..orchestrator._folder_service import FolderService from .business_rules import ( + BusinessRuleCaller, BusinessRuleDecision, BusinessRuleError, BusinessRuleRunResult, BusinessRuleStatus, - RunMode, TraceContext, _WireResponse, - _WireResult, ) _EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/evaluate") -_HEADER_ORGANIZATION_UNIT_ID = "x-uipath-organizationunitid" - -# The service's contract is a batch; this SDK submits exactly one input under this id. -_SINGLE_INPUT_ID = "input-1" _MAX_INPUT_KEYS = 256 _MAX_RULE_NAME_LENGTH = 256 @@ -41,8 +36,7 @@ class BusinessRulesService(FolderContext, BaseService): """Service for running UiPath Business Rules. Each call runs one input against a business rule deployed to Orchestrator, - named like any other resource, and returns the decisions it produced. The - caller never picks a service endpoint. + named like any other resource, and returns the decisions it produced. """ def __init__( @@ -62,41 +56,36 @@ def run( *, version: Optional[str] = None, decision_names: Optional[List[str]] = None, - explain: bool = False, folder_key: Optional[str] = None, folder_path: Optional[str] = None, - organization_unit_id: Optional[int] = None, + caller: Optional[BusinessRuleCaller] = None, trace_context: Optional[TraceContext] = None, ) -> BusinessRuleRunResult: """Run a business rule against one input. Args: - name: The name of the business rule deployed to Orchestrator. + name: The name of the business rule. input: 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. decision_names: The decisions to evaluate; defaults to the whole model. - explain: Whether to record condition-level explanations in the trace. - folder_key: The key of the folder to run in. - folder_path: The path of the folder to run in. Looked up and sent as - its key. - organization_unit_id: The numeric id of the folder to run in, sent as - given. The service prefers the folder key when both are sent. + 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. trace_context: The trace to file the run's spans under. Defaults to the ambient trace: ``UIPATH_TRACE_ID`` and the current span. - A folder is required: ``folder_key``, ``folder_path`` or - ``organization_unit_id``, with ``explain=True`` needing a folder key (from - ``folder_key`` or ``folder_path``). ``folder_key`` and ``folder_path`` are - exclusive. When the caller gives no folder at all, it falls back to - ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``; an explicit folder is - never replaced by the environment's. A ``businessRule`` binding can remap - ``name`` and the folder per environment; its folder then replaces - ``folder_key`` or ``folder_path``. + The rule runs in a folder named by its key: ``folder_key`` is sent as is, + and ``folder_path`` is looked up and sent as its key; the two are + exclusive. When the caller gives neither, it falls back to + ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``. 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, and the - mode that ran. + BusinessRuleRunResult: The decisions produced for the input. Raises: ValueError: If the request is invalid or a required folder is missing. @@ -121,14 +110,10 @@ def run( name, folder_key, folder_path ) _validate_run(name, input) - key, path = self._folder_source( - folder_key, folder_path, use_env=organization_unit_id is None or explain - ) + key, path = self._folder_source(folder_key, folder_path) if path: key = self._folders_service.retrieve_folder_key(path) - mode, spec = self._run_spec( - name, input, version, decision_names, explain, key, organization_unit_id - ) + spec = _evaluate_spec(name, input, version, decision_names, key, caller) response = self.request( spec.method, url=spec.endpoint, @@ -136,7 +121,7 @@ def run( headers=_with_trace(spec.headers, trace_context), scoped="tenant", ) - return _to_run_result(mode, _WireResponse.model_validate(response.json())) + return _to_run_result(_WireResponse.model_validate(response.json())) @traced(name="business_rules_run", run_type="uipath") async def run_async( @@ -146,32 +131,29 @@ async def run_async( *, version: Optional[str] = None, decision_names: Optional[List[str]] = None, - explain: bool = False, folder_key: Optional[str] = None, folder_path: Optional[str] = None, - organization_unit_id: Optional[int] = None, + caller: Optional[BusinessRuleCaller] = None, trace_context: Optional[TraceContext] = None, ) -> BusinessRuleRunResult: """Asynchronously run a business rule against one input. Args: - name: The name of the business rule deployed to Orchestrator. + name: The name of the business rule. input: 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. decision_names: The decisions to evaluate; defaults to the whole model. - explain: Whether to record condition-level explanations in the trace. - folder_key: The key of the folder to run in. - folder_path: The path of the folder to run in. Looked up and sent as - its key. - organization_unit_id: The numeric id of the folder to run in, sent as - given. The service prefers the folder key when both are sent. + 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. 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, and the - mode that ran. + BusinessRuleRunResult: The decisions produced for the input. Raises: ValueError: If the request is invalid or a required folder is missing. @@ -181,14 +163,10 @@ async def run_async( name, folder_key, folder_path ) _validate_run(name, input) - key, path = self._folder_source( - folder_key, folder_path, use_env=organization_unit_id is None or explain - ) + key, path = self._folder_source(folder_key, folder_path) if path: key = await self._folders_service.retrieve_folder_key_async(path) - mode, spec = self._run_spec( - name, input, version, decision_names, explain, key, organization_unit_id - ) + spec = _evaluate_spec(name, input, version, decision_names, key, caller) response = await self.request_async( spec.method, url=spec.endpoint, @@ -196,7 +174,7 @@ async def run_async( headers=_with_trace(spec.headers, trace_context), scoped="tenant", ) - return _to_run_result(mode, _WireResponse.model_validate(response.json())) + return _to_run_result(_WireResponse.model_validate(response.json())) @resource_override(resource_type="businessRule") def _binding( @@ -221,74 +199,68 @@ def _apply_binding( return bound_name, folder_key, bound_path def _folder_source( - self, folder_key: Optional[str], folder_path: Optional[str], use_env: bool + self, folder_key: Optional[str], folder_path: Optional[str] ) -> Tuple[Optional[str], Optional[str]]: - """Pick the folder key to send, as a (key, path-still-to-resolve) pair. - - ``use_env`` is false when the caller named the folder another way (by its - numeric id), so the environment's folder can't silently replace it. - """ - if folder_key and folder_path: + """Pick the folder key to send, as a (key, path-still-to-resolve) pair.""" + # Blank counts as not given, so it falls back to the environment. + has_key, has_path = _present(folder_key), _present(folder_path) + if has_key and has_path: raise ValueError("Only one of folder_key or folder_path can be provided") - if folder_key: + if has_key: return folder_key, None - if folder_path: + if has_path: return None, folder_path - if not use_env: - return None, None if self._folder_key: return self._folder_key, None - return None, self._folder_path or None - - def _run_spec( - self, - name: str, - input: Dict[str, Any], - version: Optional[str], - decision_names: Optional[List[str]], - explain: bool, - folder_key: Optional[str], - organization_unit_id: Optional[int], - ) -> Tuple[RunMode, RequestSpec]: - if explain and not folder_key: - raise _missing_folder_key() - if not folder_key and organization_unit_id is None: - raise _missing_folder("a deployed business rule") - return RunMode.DEPLOYED, self._evaluate_spec( - name, - input, - version, - decision_names, - explain, - folder_key, - organization_unit_id, + 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 _evaluate_spec( - self, - name: str, - input: Dict[str, Any], - version: Optional[str], - decision_names: Optional[List[str]], - explain: bool, - folder_key: Optional[str], - organization_unit_id: Optional[int], - ) -> RequestSpec: - body: Dict[str, Any] = { - "businessRuleName": name, - "explain": explain, - "inputs": [{"id": _SINGLE_INPUT_ID, "data": input}], - } - if version: - body["version"] = version - if decision_names: - body["decisionNames"] = decision_names - return RequestSpec( - method="POST", - endpoint=_EVALUATE_ENDPOINT, - json=body, - headers=_folder_headers(folder_key, organization_unit_id), - ) + +def _evaluate_spec( + name: str, + input: Dict[str, Any], + version: Optional[str], + decision_names: Optional[List[str]], + folder_key: Optional[str], + caller: Optional[BusinessRuleCaller], +) -> RequestSpec: + # The service resolves the rule in this folder and files the run's trace and + # audit record under it; a folder path that resolves to nothing lands here. + if not folder_key or not folder_key.strip(): + raise ValueError(f"No folder key was found for business rule '{name}'") + body: Dict[str, Any] = {"businessRuleName": name, "input": input} + if _present(version): + body["version"] = version + if decision_names: + body["decisionNames"] = decision_names + wire_caller = _wire_caller(caller) + if wire_caller: + body["caller"] = wire_caller + return RequestSpec( + method="POST", + endpoint=_EVALUATE_ENDPOINT, + json=body, + headers={HEADER_FOLDER_KEY: folder_key}, + ) + + +def _wire_caller(caller: Optional[BusinessRuleCaller]) -> Dict[str, str]: + given = caller or BusinessRuleCaller() + values = { + "resourceKey": given.resource_key or UiPathConfig.process_uuid, + "runKey": given.run_key or UiPathConfig.job_key, + "folderKey": given.folder_key or UiPathConfig.folder_key, + } + # Blank fields are left out, and a caller with every field blank is no caller. + wire: Dict[str, str] = {} + for key, value in values.items(): + if value and value.strip(): + wire[key] = value + return wire class _TraceHeaders(Dict[str, str]): @@ -319,31 +291,6 @@ def _present(value: Optional[str]) -> bool: return bool(value and value.strip()) -def _folder_headers( - folder_key: Optional[str], organization_unit_id: Optional[int] -) -> Dict[str, str]: - headers: Dict[str, str] = {} - if folder_key: - headers[HEADER_FOLDER_KEY] = folder_key - if organization_unit_id is not None: - headers[_HEADER_ORGANIZATION_UNIT_ID] = str(organization_unit_id) - return headers - - -def _missing_folder(needed_for: str) -> ValueError: - return ValueError( - f"A folder is required for {needed_for}: pass folder_key, folder_path or " - "organization_unit_id, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" - ) - - -def _missing_folder_key() -> ValueError: - return ValueError( - "A folder key is required for explain=True: pass folder_key or " - "folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" - ) - - def _validate_run(name: str, input: Dict[str, Any]) -> None: _validate_rule_name(name, "name") _validate_input(input) @@ -373,44 +320,29 @@ def _validate_input(input: Dict[str, Any]) -> None: raise ValueError(f"input must not exceed {_MAX_INPUT_KEYS} keys") -def _single_result( - response: _WireResponse, -) -> Tuple[List[BusinessRuleDecision], List[BusinessRuleError], BusinessRuleStatus]: - results = response.results or [] - ours: Optional[_WireResult] = next( - (r for r in results if r.id == _SINGLE_INPUT_ID), None - ) - if ours is None and len(results) == 1: - ours = results[0] - elif ours is None and results: - raise ValueError( - f"The service returned {len(results)} results and none carried the id " - f"'{_SINGLE_INPUT_ID}' this request was submitted under" - ) - if ours is None: - # No result for our input at all: it was never evaluated. - return [], [], BusinessRuleStatus.ALL_FAILED - - decisions = ours.decisions or [] - errors = ours.errors or [] +def _status( + decisions: List[BusinessRuleDecision], errors: List[BusinessRuleError] +) -> BusinessRuleStatus: + # An input-level error means the input never evaluated, whatever else came back. if errors: - status = BusinessRuleStatus.ALL_FAILED - else: - failed = sum(1 for d in decisions if d.error is not None) - if failed == 0: - status = BusinessRuleStatus.SUCCESS - elif failed == len(decisions): - status = BusinessRuleStatus.ALL_FAILED - else: - status = BusinessRuleStatus.PARTIAL_SUCCESS - return decisions, errors, status - - -def _to_run_result(mode: RunMode, response: _WireResponse) -> BusinessRuleRunResult: - decisions, errors, status = _single_result(response) + return BusinessRuleStatus.ALL_FAILED + failed = sum(1 for d in decisions if d.error is not None) + if failed == 0: + return BusinessRuleStatus.SUCCESS + if failed == len(decisions): + return BusinessRuleStatus.ALL_FAILED + return BusinessRuleStatus.PARTIAL_SUCCESS + + +def _to_run_result(response: _WireResponse) -> BusinessRuleRunResult: + # A successful response always carries the input's result; one without it is + # not an answer to report as an evaluation. + if response.result is None: + raise ValueError("The business rules response did not include a result") + decisions = response.result.decisions or [] + errors = response.result.errors or [] return BusinessRuleRunResult( - mode=mode, - status=status, + status=_status(decisions, errors), decisions=decisions, errors=errors, top_level_error=response.error.code if response.error else None, 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 index f63fb97f8..44eeb468b 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -17,10 +17,26 @@ class BusinessRuleStatus(str, Enum): ALL_FAILED = "AllFailed" -class RunMode(str, Enum): - """Which kind of model ran, and so which service endpoint served the run.""" +class BusinessRuleCaller(BaseModel): + """Who is running the rule, kept in the deployed run's execution audit. - DEPLOYED = "Deployed" + 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.", + ) class TraceContext(BaseModel): @@ -98,7 +114,6 @@ class BusinessRuleRunResult(BaseModel): use_enum_values=True, ) - mode: RunMode = Field(description="Which kind of model ran.") status: BusinessRuleStatus decisions: List[BusinessRuleDecision] = Field(default_factory=list) errors: List[BusinessRuleError] = Field( @@ -107,20 +122,19 @@ class BusinessRuleRunResult(BaseModel): ) top_level_error: Optional[str] = Field( default=None, - description="The request-level error code (e.g. BATCH_TIMEOUT), if one was reported.", + description="The request-level error code, if one was reported.", ) business_rule_name: Optional[str] = Field( - default=None, description="The deployed rule that ran." + default=None, description="The deployed rule that ran, when reported." ) version: Optional[str] = Field( - default=None, description="The rule version that ran." + default=None, description="The rule version that ran, when reported." ) class _WireResult(BaseModel): model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - id: Optional[str] = None decisions: Optional[List[BusinessRuleDecision]] = None errors: Optional[List[BusinessRuleError]] = None @@ -133,4 +147,4 @@ class _WireResponse(BaseModel): business_rule_name: Optional[str] = Field(default=None, alias="businessRuleName") version: Optional[str] = None error: Optional[BusinessRuleError] = None - results: Optional[List[_WireResult]] = None + result: Optional[_WireResult] = None diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index abeb04026..df91d1d1e 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -10,9 +10,9 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.business_rules import ( + BusinessRuleCaller, BusinessRulesService, BusinessRuleStatus, - RunMode, TraceContext, ) from uipath.platform.common._bindings import ( @@ -26,6 +26,7 @@ FOLDER_KEY = "5f1f1b0e-2b8a-4c1e-9b8e-1a2b3c4d5e6f" TRACEPARENT = "x-uipath-traceparent-id" RULE = "Loan Pricing" +JOB_KEY = "9d8c7b6a-5f4e-3d2c-1b0a-9f8e7d6c5b4a" @pytest.fixture @@ -45,6 +46,8 @@ def service( ) -> 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) return BusinessRulesService( config=config, execution_context=execution_context, @@ -57,23 +60,14 @@ def evaluate_url(base_url: str, org: str, tenant: str) -> str: return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/evaluate" -def _response(results: list[dict[str, Any]], **extra: Any) -> dict[str, Any]: - return { - "hasPartialSuccess": False, - "stats": {"submitted": 1, "succeeded": 1, "failed": 0, "notEvaluated": 0}, - "results": results, - "meta": {"timestamp": "2026-09-28T00:00:00Z"}, - **extra, - } +def _response(result: dict[str, Any] | None = None, **extra: Any) -> dict[str, Any]: + body: dict[str, Any] = {"meta": {"timestamp": "2026-09-28T00:00:00Z"}, **extra} + body["result"] = {"decisions": []} if result is None else result + return body -def _one_decision(**outputs: Any) -> list[dict[str, Any]]: - return [ - { - "id": "input-1", - "decisions": [{"decisionName": "RiskGrade", "outputs": outputs}], - } - ] +def _one_decision(**outputs: Any) -> dict[str, Any]: + return {"decisions": [{"decisionName": "RiskGrade", "outputs": outputs}]} class TestRunContext: @@ -109,7 +103,7 @@ def test_resolves_folder_path_to_key( folders_service: Mock, evaluate_url: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}, folder_path="Finance/Loans") @@ -130,7 +124,7 @@ def test_falls_back_to_env_folder_key( ) -> None: monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}) @@ -150,7 +144,7 @@ def test_falls_back_to_env_folder_path( ) -> None: monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}) @@ -171,7 +165,7 @@ async def test_falls_back_to_env_folder_path_async( ) -> None: monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) await service.run_async(RULE, {}) @@ -195,7 +189,7 @@ def test_env_folder_key_wins_over_env_folder_path( 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=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}) @@ -204,136 +198,35 @@ def test_env_folder_key_wins_over_env_folder_path( assert request is not None assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" - def test_organization_unit_id_alone_is_sent_as_given( - self, - httpx_mock: HTTPXMock, - service: BusinessRulesService, - evaluate_url: str, - ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) - - service.run(RULE, {}, organization_unit_id=3373422) - - request = httpx_mock.get_request() - assert request is not None - assert request.headers["x-uipath-organizationunitid"] == "3373422" - assert HEADER_FOLDER_KEY not in request.headers - - def test_folder_key_and_organization_unit_id_are_both_sent( - self, - httpx_mock: HTTPXMock, - service: BusinessRulesService, - evaluate_url: str, - ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) - - service.run(RULE, {}, folder_key=FOLDER_KEY, organization_unit_id=42) - - request = httpx_mock.get_request() - assert request is not None - assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY - assert request.headers["x-uipath-organizationunitid"] == "42" - - def test_folder_path_and_organization_unit_id_are_both_sent( - self, - httpx_mock: HTTPXMock, - service: BusinessRulesService, - folders_service: Mock, - evaluate_url: str, - ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) - - service.run(RULE, {}, folder_path="Finance", organization_unit_id=42) - - folders_service.retrieve_folder_key.assert_called_once_with("Finance") - request = httpx_mock.get_request() - assert request is not None - assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY - assert request.headers["x-uipath-organizationunitid"] == "42" - - def test_env_folder_does_not_replace_an_explicit_organization_unit_id( - 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=_response([])) - - service.run(RULE, {}, organization_unit_id=42) - - request = httpx_mock.get_request() - assert request is not None - assert HEADER_FOLDER_KEY not in request.headers - assert request.headers["x-uipath-organizationunitid"] == "42" - - def test_explain_needs_a_folder_key_not_only_an_organization_unit_id( + def test_deployed_requires_a_folder( self, httpx_mock: HTTPXMock, service: BusinessRulesService ) -> None: - with pytest.raises(ValueError, match="folder key is required for explain"): - service.run(RULE, {}, organization_unit_id=42, explain=True) + with pytest.raises(ValueError, match="deployed business rule"): + service.run(RULE, {}) assert httpx_mock.get_requests() == [] - def test_explain_with_organization_unit_id_takes_the_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=_response([])) - - service.run(RULE, {}, organization_unit_id=42, explain=True) - - request = httpx_mock.get_request() - assert request is not None - assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" - assert request.headers["x-uipath-organizationunitid"] == "42" - - def test_explain_with_folder_path_sends_the_looked_up_key( - self, - httpx_mock: HTTPXMock, - service: BusinessRulesService, - folders_service: Mock, - evaluate_url: str, + def test_deployed_rejects_a_blank_folder_key( + self, httpx_mock: HTTPXMock, service: BusinessRulesService ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) - - service.run(RULE, {}, folder_path="Finance", explain=True) + with pytest.raises(ValueError, match="deployed business rule"): + service.run(RULE, {}, folder_key=" ") - folders_service.retrieve_folder_key.assert_called_once_with("Finance") - request = httpx_mock.get_request() - assert request is not None - assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY - assert json.loads(request.content)["explain"] is True + assert httpx_mock.get_requests() == [] - async def test_organization_unit_id_alone_async( + 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=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) - await service.run_async(RULE, {}, organization_unit_id=42) + service.run(RULE, {}, folder_key=FOLDER_KEY) request = httpx_mock.get_request() assert request is not None - assert request.headers["x-uipath-organizationunitid"] == "42" - assert HEADER_FOLDER_KEY not in request.headers - - def test_deployed_requires_a_folder(self, service: BusinessRulesService) -> None: - with pytest.raises(ValueError, match="deployed business rule"): - service.run(RULE, {}) + assert "x-uipath-organizationunitid" not in request.headers def test_rejects_both_folder_key_and_path( self, service: BusinessRulesService @@ -365,11 +258,9 @@ def test_sends_single_input_and_maps_decisions( {"creditScore": 740}, version="1.0.3", decision_names=["RiskGrade"], - explain=True, folder_key=FOLDER_KEY, ) - assert result.mode == RunMode.DEPLOYED assert result.status == BusinessRuleStatus.SUCCESS assert result.decisions[0].decision_name == "RiskGrade" assert result.decisions[0].outputs == {"Grade": "B", "Rate": 3.5} @@ -384,8 +275,7 @@ def test_sends_single_input_and_maps_decisions( "businessRuleName": "Loan Pricing", "version": "1.0.3", "decisionNames": ["RiskGrade"], - "explain": True, - "inputs": [{"id": "input-1", "data": {"creditScore": 740}}], + "input": {"creditScore": 740}, } assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY assert request.headers["Authorization"] == "Bearer secret" @@ -400,16 +290,30 @@ def test_omits_optional_fields( service: BusinessRulesService, evaluate_url: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) - service.run(RULE, {}, folder_key=FOLDER_KEY) + service.run(RULE, {}, version=" ", folder_key=FOLDER_KEY) request = httpx_mock.get_request() assert request is not None body = json.loads(request.content) - assert "version" not in body - assert "decisionNames" not in body - assert body["explain"] is False + 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=_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_resolves_folder_path( self, @@ -423,13 +327,97 @@ async def test_run_async_resolves_folder_path( result = await service.run_async(RULE, {"a": 1}, folder_path="Finance") folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance") - assert result.mode == RunMode.DEPLOYED 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=_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", + } + # The rule's folder, not the caller's, scopes the run. + 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=_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", + } + + 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=_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"} + + 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=_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) + + class TestResult: def test_partial_success_on_207( self, @@ -441,21 +429,18 @@ def test_partial_success_on_207( url=evaluate_url, status_code=207, json=_response( - [ - { - "id": "input-1", - "decisions": [ - {"decisionName": "RiskGrade", "outputs": {"Grade": "B"}}, - { - "decisionName": "Payment", - "error": { - "code": "DECISION_FAILED", - "message": "null arithmetic", - }, + { + "decisions": [ + {"decisionName": "RiskGrade", "outputs": {"Grade": "B"}}, + { + "decisionName": "Payment", + "error": { + "code": "DECISION_FAILED", + "message": "null arithmetic", }, - ], - } - ] + }, + ], + } ), ) @@ -465,6 +450,31 @@ def test_partial_success_on_207( 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=_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, @@ -475,17 +485,14 @@ def test_input_level_error_is_all_failed( url=evaluate_url, status_code=207, json=_response( - [ - { - "id": "input-1", - "errors": [ - { - "code": "INPUT_VALIDATION_FAILED", - "message": "creditScore must be a number", - } - ], - } - ] + { + "errors": [ + { + "code": "INPUT_VALIDATION_FAILED", + "message": "creditScore must be a number", + } + ] + } ), ) @@ -494,7 +501,7 @@ def test_input_level_error_is_all_failed( assert result.status == BusinessRuleStatus.ALL_FAILED assert result.errors[0].code == "INPUT_VALIDATION_FAILED" - def test_not_evaluated_carries_top_level_error( + def test_carries_the_top_level_error( self, httpx_mock: HTTPXMock, service: BusinessRulesService, @@ -504,17 +511,27 @@ def test_not_evaluated_carries_top_level_error( url=evaluate_url, status_code=207, json=_response( - [], - notEvaluatedIds=["input-1"], - error={"code": "BATCH_TIMEOUT", "message": "ran out of time"}, + {"errors": [{"code": "INPUT_FAILED", "message": "x"}]}, + error={"code": "UPSTREAM_ERROR", "message": "y"}, ), ) result = service.run(RULE, {}, folder_key=FOLDER_KEY) - assert result.status == BusinessRuleStatus.ALL_FAILED - assert result.decisions == [] - assert result.top_level_error == "BATCH_TIMEOUT" + 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, @@ -566,7 +583,7 @@ def test_explicit_trace_context_wins_over_ambient_span( evaluate_url: str, ambient_span: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -589,7 +606,7 @@ def test_explicit_trace_context_wins_over_uipath_trace_id( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("UIPATH_TRACE_ID", AMBIENT_TRACE_ID) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -612,7 +629,7 @@ def test_ambient_trace_is_used_without_trace_context( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}, folder_key=FOLDER_KEY) @@ -629,8 +646,8 @@ def test_override_does_not_leak_into_the_next_call( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) - httpx_mock.add_response(url=evaluate_url, json=_response([])) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -649,7 +666,7 @@ async def test_explicit_trace_context_async( evaluate_url: str, ambient_span: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -671,7 +688,7 @@ def test_explicit_trace_context_survives_a_retry( ambient_span: None, ) -> None: httpx_mock.add_response(url=evaluate_url, status_code=503) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -742,7 +759,7 @@ def test_override_replaces_rule_name_and_folder( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}, folder_path="Finance") @@ -760,7 +777,7 @@ async def test_override_applies_to_run_async( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) await service.run_async(RULE, {}, folder_path="Finance") @@ -777,7 +794,7 @@ def test_override_folder_replaces_callers_folder_key( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}, folder_key="callers-folder-key") @@ -795,7 +812,7 @@ async def test_override_folder_replaces_callers_folder_key_async( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) await service.run_async(RULE, {}, folder_key="callers-folder-key") @@ -813,7 +830,7 @@ def test_callers_folder_key_kept_without_a_matching_override( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run("Risk Tier", {}, folder_key="callers-folder-key") @@ -830,7 +847,7 @@ def test_other_rules_are_not_overridden( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run("Risk Tier", {}, folder_path="Finance") From fc7498fd11b658a4c2a9f4bd3471370960c9fe15 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 14:24:04 +0530 Subject: [PATCH 12/13] chore(business-rules): bump uipath-platform to 0.2.35 main released 0.2.34, so this PR moves to the next version. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 091bd2284..be322ea1e 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" 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/uv.lock b/packages/uipath-platform/uv.lock index 8d7cccdcc..b02fd2c40 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.34" +version = "0.2.35" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 9a1aa77c6..72e4bda1b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From a8bb4596aed1286951e22070d63eebad6ac4477e Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 15:45:42 +0530 Subject: [PATCH 13/13] feat(business-rules): read the service's error code into EnrichedException.error_info The Business Rules service nests its error under "error": {"error": {"code": "RULE_NOT_FOUND", "message": "..."}}. The generic extractor reads the message from there but looks for the code only at the top level, so error_info.error_code came back None and a caller had to parse the body to branch on the code. A businessrules_ extractor reads both from "error", and falls back to the generic extractor for any other shape, such as an error the gateway answers. Checked against alpha: a 401 from the service now reports error_code="UNAUTHENTICATED". Co-Authored-By: Claude Opus 5.5 --- .../errors/_extractors/_businessrules.py | 25 +++++++++++++ .../platform/errors/_extractors/_router.py | 2 ++ .../tests/errors/test_enriched_exception.py | 36 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py 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..9d0fddf1e --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py @@ -0,0 +1,25 @@ +"""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 = get_field(body, "error") + if not isinstance(error, dict): + # Not the service's envelope, e.g. an error answered by the gateway. + return extract_generic(body) + + return ExtractedErrorInfo( + message=get_typed_field(error, str, "message"), + error_code=get_str_field(error, "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/tests/errors/test_enriched_exception.py b/packages/uipath-platform/tests/errors/test_enriched_exception.py index e41f41007..3ce6e6500 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,39 @@ 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): + # An error answered by the gateway rather than the service. + 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)."""