From 6993b073c3f2bb5c381fb4927a57f8374107fae5 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Mon, 28 Sep 2026 09:46:57 +0530 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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" },