From 6993b073c3f2bb5c381fb4927a57f8374107fae5 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Mon, 28 Sep 2026 09:46:57 +0530 Subject: [PATCH 01/47] 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/47] 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 baa54e0293e7b8a3587c8f1bbdf0c7f62bbf7992 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 07:57:03 +0530 Subject: [PATCH 03/47] feat(business-rules): add debug runs to run() Adds DebugRunContext, the second run context of run()/run_async(), for undeployed DMNs read from a Studio project. As in the .NET client's RunAsync, exactly one of deployed/debug must be set and it selects the endpoint: - DeployedRunContext -> /v1/business-rules/evaluate - DebugRunContext(project_id | rule_name, file_name, job_key, organization_unit_id) -> /v1/business-rules/debug/evaluate A debug run named by rule_name also needs job_key (defaults to UIPATH_JOB_KEY) and organization_unit_id. Following business-rules#104, explain=True needs a folder key in both modes, and debug runs send the key whenever one is known so their spans can be stored. BusinessRuleRunResult gains project_id and file_name for debug runs, and RunMode gains DEBUG. Bumps uipath-platform to 0.2.34. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/CLAUDE.md | 2 +- packages/uipath-platform/pyproject.toml | 2 +- .../platform/business_rules/__init__.py | 8 +- .../business_rules/_business_rules_service.py | 149 ++++++++++++--- .../platform/business_rules/business_rules.py | 43 ++++- .../services/test_business_rules_service.py | 172 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 8 files changed, 350 insertions(+), 30 deletions(-) diff --git a/packages/uipath-platform/CLAUDE.md b/packages/uipath-platform/CLAUDE.md index f9c64d5a6..a807130d1 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/` | DMN business rule runs: deployed rules or undeployed Studio-project DMNs, behind one `run()` | | `chat/` | LLM gateway, conversations, throttling | | `connections/` | External connection management | | `context_grounding/` | RAG services (DeepRAG, batch RAG, ephemeral indexes) | diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 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/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py index ed32805ff..5905d87e0 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 DMN decision models, +either deployed to Orchestrator as UiPath Business Rules or read from a Studio +project, and the Pydantic models for its run contexts and results. """ from ._business_rules_service import BusinessRulesService @@ -11,6 +11,7 @@ BusinessRuleError, BusinessRuleRunResult, BusinessRuleStatus, + DebugRunContext, DeployedRunContext, RunMode, ) @@ -21,6 +22,7 @@ "BusinessRuleRunResult", "BusinessRuleStatus", "BusinessRulesService", + "DebugRunContext", "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 1f55efe74..277c4a02f 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,7 @@ """Business Rules service for UiPath Platform. -Runs DMN decision models deployed to Orchestrator as business rules. +Runs DMN decision models: a business rule deployed to Orchestrator, or an +undeployed DMN read straight from a Studio project. """ from typing import Any, Dict, List, Optional, Tuple @@ -8,7 +9,7 @@ from uipath.core.tracing import traced from ..common._base_service import BaseService -from ..common._config import UiPathApiConfig +from ..common._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext from ..common._models import Endpoint, RequestSpec @@ -19,6 +20,7 @@ BusinessRuleError, BusinessRuleRunResult, BusinessRuleStatus, + DebugRunContext, DeployedRunContext, RunMode, _WireResponse, @@ -26,6 +28,10 @@ ) _EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/evaluate") +_DEBUG_EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/debug/evaluate") + +_HEADER_ORGANIZATION_UNIT_ID = "x-uipath-organizationunitid" +_HEADER_JOB_KEY = "x-uipath-jobkey" # The service's contract is a batch; this SDK submits exactly one input under this id. _SINGLE_INPUT_ID = "input-1" @@ -37,8 +43,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. + runs is set by the run context: ``deployed`` for a rule deployed to + Orchestrator, ``debug`` for an undeployed DMN in a Studio project. The caller + never picks a service endpoint. """ def __init__( @@ -55,7 +62,8 @@ def run( self, input: Dict[str, Any], *, - deployed: DeployedRunContext, + deployed: Optional[DeployedRunContext] = None, + debug: Optional[DebugRunContext] = None, decision_names: Optional[List[str]] = None, explain: bool = False, folder_key: Optional[str] = None, @@ -63,17 +71,23 @@ def run( ) -> BusinessRuleRunResult: """Run a business rule against one input. + Exactly one of ``deployed`` and ``debug`` must be set, and it decides which + model runs. + 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. + deployed: A business rule deployed to Orchestrator. + debug: An undeployed DMN in a Studio project. decision_names: The decisions to evaluate; defaults to the whole model. explain: Whether to record condition-level explanations in the trace. + Requires a folder. 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 + A folder is required for a deployed rule and whenever ``explain`` is set. + When neither ``folder_key`` nor ``folder_path`` is given, it falls back to ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``. Returns: @@ -87,10 +101,14 @@ def run( Examples: ```python from uipath.platform import UiPath - from uipath.platform.business_rules import DeployedRunContext + from uipath.platform.business_rules import ( + DebugRunContext, + DeployedRunContext, + ) client = UiPath() + # A rule deployed to Orchestrator result = client.business_rules.run( {"creditScore": 740, "age": 34}, deployed=DeployedRunContext(rule_name="Loan Pricing"), @@ -98,13 +116,21 @@ def run( ) for decision in result.decisions: print(decision.decision_name, decision.outputs) + + # An undeployed DMN in a Studio project + result = client.business_rules.run( + {"creditScore": 740}, + debug=DebugRunContext(project_id="0a1b2c3d-...", file_name="Loan.dmn"), + ) ``` """ - _validate_run(input, deployed) + _validate_run(input, deployed, debug) 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( + input, deployed, debug, decision_names, explain, key + ) response = self.request( spec.method, url=spec.endpoint, @@ -119,7 +145,8 @@ async def run_async( self, input: Dict[str, Any], *, - deployed: DeployedRunContext, + deployed: Optional[DeployedRunContext] = None, + debug: Optional[DebugRunContext] = None, decision_names: Optional[List[str]] = None, explain: bool = False, folder_key: Optional[str] = None, @@ -127,12 +154,17 @@ async def run_async( ) -> BusinessRuleRunResult: """Asynchronously run a business rule against one input. + Exactly one of ``deployed`` and ``debug`` must be set, and it decides which + model runs. + 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. + deployed: A business rule deployed to Orchestrator. + debug: An undeployed DMN in a Studio project. decision_names: The decisions to evaluate; defaults to the whole model. explain: Whether to record condition-level explanations in the trace. + Requires a folder. 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. @@ -145,11 +177,13 @@ 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(input, deployed, debug) 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( + input, deployed, debug, decision_names, explain, key + ) response = await self.request_async( spec.method, url=spec.endpoint, @@ -176,11 +210,19 @@ def _folder_source( def _run_spec( self, input: Dict[str, Any], - deployed: DeployedRunContext, + deployed: Optional[DeployedRunContext], + debug: Optional[DebugRunContext], decision_names: Optional[List[str]], explain: bool, folder_key: Optional[str], ) -> Tuple[RunMode, RequestSpec]: + if debug is not None: + if explain and not folder_key: + raise _missing_folder("explain=True") + return RunMode.DEBUG, self._debug_spec( + input, debug, decision_names, explain, folder_key + ) + assert deployed is not None if not folder_key: raise _missing_folder("a deployed business rule") return RunMode.DEPLOYED, self._evaluate_spec( @@ -211,6 +253,56 @@ def _evaluate_spec( headers={HEADER_FOLDER_KEY: folder_key}, ) + def _debug_spec( + self, + input: Dict[str, Any], + debug: DebugRunContext, + decision_names: Optional[List[str]], + explain: bool, + folder_key: Optional[str], + ) -> RequestSpec: + job_key = debug.job_key or UiPathConfig.job_key + named = _present(debug.rule_name) + if not _present(debug.project_id) and not _present(job_key): + raise ValueError( + "debug.job_key must be specified when the run is named by rule_name: " + "the service resolves the project from the job's lineage. " + "Set it or UIPATH_JOB_KEY." + ) + if named and not _present(debug.organization_unit_id): + raise ValueError( + "debug.organization_unit_id must be specified when the run is named " + "by rule_name: it is the folder the job's lineage is read under" + ) + + body: Dict[str, Any] = { + "explain": explain, + "inputs": [{"id": _SINGLE_INPUT_ID, "data": input}], + } + if debug.project_id: + body["projectId"] = debug.project_id + if named: + body["businessRuleName"] = debug.rule_name + if debug.file_name: + body["fileName"] = debug.file_name + if decision_names: + body["decisionNames"] = decision_names + + # Folder key: the traces service files the run's spans under it. + headers: Dict[str, str] = {} + if folder_key: + headers[HEADER_FOLDER_KEY] = folder_key + if debug.organization_unit_id: + headers[_HEADER_ORGANIZATION_UNIT_ID] = debug.organization_unit_id + if job_key: + headers[_HEADER_JOB_KEY] = job_key + return RequestSpec( + method="POST", + endpoint=_DEBUG_EVALUATE_ENDPOINT, + json=body, + headers=headers, + ) + def _present(value: Optional[str]) -> bool: # Blank counts as absent, matching how the service reads these fields. @@ -224,10 +316,22 @@ 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( + input: Dict[str, Any], + deployed: Optional[DeployedRunContext], + debug: Optional[DebugRunContext], +) -> None: + if deployed is None and debug is None: + raise ValueError("Exactly one of deployed or debug must be set; neither was") + if deployed is not None and debug is not None: + raise ValueError("Exactly one of deployed or debug must be set; both were") + if deployed is not None: + _validate_rule_name(deployed.rule_name, "deployed.rule_name") + if debug is not None: + if not _present(debug.project_id) and not _present(debug.rule_name): + raise ValueError("debug.project_id or debug.rule_name must be specified") + if _present(debug.rule_name): + _validate_rule_name(debug.rule_name, "debug.rule_name") # type: ignore[arg-type] _validate_input(input) @@ -285,12 +389,15 @@ def _single_result( def _to_run_result(mode: RunMode, response: _WireResponse) -> BusinessRuleRunResult: decisions, errors, status = _single_result(response) + deployed = mode == RunMode.DEPLOYED return BusinessRuleRunResult( mode=mode, 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, + business_rule_name=response.business_rule_name if deployed else None, + version=response.version if deployed else None, + project_id=None if deployed else response.project_id, + file_name=None if deployed else response.file_name, ) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py index 97e5ddfd9..2f7742077 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 @@ -18,6 +18,7 @@ class RunMode(str, Enum): """Which kind of model ran, and so which service endpoint served the run.""" DEPLOYED = "Deployed" + DEBUG = "Debug" class DeployedRunContext(BaseModel): @@ -30,6 +31,35 @@ class DeployedRunContext(BaseModel): ) +class DebugRunContext(BaseModel): + """An undeployed DMN read from a Studio project. + + Name the project with ``project_id``, or name the rule with ``rule_name`` and let + the service resolve the project from the job's lineage. The second way also needs + ``job_key`` (defaults to ``UIPATH_JOB_KEY``) and ``organization_unit_id``. + """ + + project_id: Optional[str] = Field( + default=None, description="The Studio project holding the DMN." + ) + rule_name: Optional[str] = Field( + default=None, + description="The business rule this run stands in for; the alternative to project_id.", + ) + file_name: Optional[str] = Field( + default=None, + description="The .dmn file in the project; defaults to the first one.", + ) + job_key: Optional[str] = Field( + default=None, + description="The job this run belongs to; defaults to UIPATH_JOB_KEY.", + ) + organization_unit_id: Optional[str] = Field( + default=None, + description="The numeric id of the job's folder; required with rule_name.", + ) + + class BusinessRuleError(BaseModel): """A code/message pair describing an input-level or decision-level error.""" @@ -78,10 +108,17 @@ class BusinessRuleRunResult(BaseModel): description="The request-level error code (e.g. BATCH_TIMEOUT), if one was reported.", ) business_rule_name: Optional[str] = Field( - default=None, description="The deployed rule that ran." + default=None, description="The deployed rule that ran. Deployed mode only." ) version: Optional[str] = Field( - default=None, description="The rule version that ran." + default=None, description="The rule version that ran. Deployed mode only." + ) + project_id: Optional[str] = Field( + default=None, description="The Studio project read. Debug mode only." + ) + file_name: Optional[str] = Field( + default=None, + description="The .dmn file actually read, which may differ from the one asked for. Debug mode only.", ) @@ -100,5 +137,7 @@ class _WireResponse(BaseModel): business_rule_name: Optional[str] = Field(default=None, alias="businessRuleName") version: Optional[str] = None + project_id: Optional[str] = Field(default=None, alias="projectId") + file_name: Optional[str] = Field(default=None, alias="fileName") error: Optional[BusinessRuleError] = None 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 index 6b2fa4826..a3ae4d955 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -9,6 +9,7 @@ from uipath.platform.business_rules import ( BusinessRulesService, BusinessRuleStatus, + DebugRunContext, DeployedRunContext, RunMode, ) @@ -16,6 +17,7 @@ from uipath.platform.errors import EnrichedException FOLDER_KEY = "5f1f1b0e-2b8a-4c1e-9b8e-1a2b3c4d5e6f" +JOB_KEY = "9d8c7b6a-5f4e-3d2c-1b0a-9f8e7d6c5b4a" LOAN_PRICING = DeployedRunContext(rule_name="Loan Pricing") @@ -36,6 +38,7 @@ def service( ) -> BusinessRulesService: monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) return BusinessRulesService( config=config, execution_context=execution_context, @@ -48,6 +51,11 @@ def evaluate_url(base_url: str, org: str, tenant: str) -> str: return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/evaluate" +@pytest.fixture +def debug_url(base_url: str, org: str, tenant: str) -> str: + return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/debug/evaluate" + + def _response(results: list[dict[str, Any]], **extra: Any) -> dict[str, Any]: return { "hasPartialSuccess": False, @@ -68,6 +76,19 @@ def _one_decision(**outputs: Any) -> list[dict[str, Any]]: class TestRunContext: + def test_requires_a_run_context(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="neither was"): + service.run({}, folder_key=FOLDER_KEY) + + def test_rejects_both_run_contexts(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="both were"): + service.run( + {}, + deployed=LOAN_PRICING, + debug=DebugRunContext(project_id="proj-1"), + folder_key=FOLDER_KEY, + ) + @pytest.mark.parametrize( "rule_name", ["", " ", "a/b", "a\\b", "a..b", "a%20b", "a\nb", "x" * 257], @@ -179,6 +200,7 @@ def test_sends_single_input_and_maps_decisions( assert result.top_level_error is None assert result.business_rule_name == "Loan Pricing" assert result.version == "1.0.3" + assert result.project_id is None request = httpx_mock.get_request() assert request is not None @@ -234,6 +256,156 @@ async def test_run_async_resolves_folder_path( assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY +class TestDebug: + def test_by_project_id( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + ) -> None: + httpx_mock.add_response( + url=debug_url, + method="POST", + json=_response( + _one_decision(x=1), + projectId="proj-1", + fileName="Rules/Loan.dmn", + traceId="abc123", + ), + ) + + result = service.run( + {"a": 1}, + debug=DebugRunContext(project_id="proj-1", file_name="loan.dmn"), + ) + + assert result.mode == RunMode.DEBUG + assert result.status == BusinessRuleStatus.SUCCESS + assert result.project_id == "proj-1" + assert result.file_name == "Rules/Loan.dmn" + assert result.business_rule_name is None + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content) == { + "projectId": "proj-1", + "fileName": "loan.dmn", + "explain": False, + "inputs": [{"id": "input-1", "data": {"a": 1}}], + } + assert "x-uipath-jobkey" not in request.headers + assert "x-uipath-organizationunitid" not in request.headers + assert HEADER_FOLDER_KEY not in request.headers + + def test_by_rule_name_sends_job_and_folder_headers( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response([])) + + service.run( + {}, + debug=DebugRunContext( + rule_name="Loan Pricing", job_key=JOB_KEY, organization_unit_id="42" + ), + folder_key=FOLDER_KEY, + ) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing" + assert request.headers["x-uipath-jobkey"] == JOB_KEY + assert request.headers["x-uipath-organizationunitid"] == "42" + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + def test_job_key_defaults_from_env( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + httpx_mock.add_response(url=debug_url, json=_response([])) + + service.run( + {}, + debug=DebugRunContext(rule_name="Loan Pricing", organization_unit_id="42"), + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-jobkey"] == JOB_KEY + + def test_explain_requires_a_folder(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="explain=True"): + service.run({}, debug=DebugRunContext(project_id="proj-1"), explain=True) + + def test_explain_sends_resolved_folder_key( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + debug_url: str, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response([])) + + service.run( + {}, + debug=DebugRunContext(project_id="proj-1"), + explain=True, + folder_path="Finance", + ) + + 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 + + def test_requires_project_or_rule_name(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="project_id or debug.rule_name"): + service.run({}, debug=DebugRunContext(file_name="loan.dmn")) + + def test_rule_name_requires_job_key(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="job_key"): + service.run( + {}, + debug=DebugRunContext( + rule_name="Loan Pricing", organization_unit_id="42" + ), + ) + + def test_rule_name_requires_organization_unit( + self, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="organization_unit_id"): + service.run( + {}, debug=DebugRunContext(rule_name="Loan Pricing", job_key=JOB_KEY) + ) + + def test_rejects_unsafe_debug_rule_name( + self, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="debug.rule_name"): + service.run({}, debug=DebugRunContext(rule_name="a/b", job_key=JOB_KEY)) + + async def test_run_async_debug( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response([], projectId="proj-1")) + + result = await service.run_async({}, debug=DebugRunContext(project_id="proj-1")) + + assert result.mode == RunMode.DEBUG + assert result.project_id == "proj-1" + assert result.status == BusinessRuleStatus.ALL_FAILED + + class TestResult: def test_partial_success_on_207( self, 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 9ca759335..0f71f63d9 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From 9fa2e8a10c0682f7e5ab75ac89ac9cfee03687f1 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 09:40:32 +0530 Subject: [PATCH 04/47] 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 b81d965fa1254015496edeb50b31520f3a2df7f1 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 09:55:15 +0530 Subject: [PATCH 05/47] fix(business-rules): type organization_unit_id as int; gate lineage checks Addresses Copilot review on the debug run context: - DebugRunContext.organization_unit_id is now Optional[int], matching the numeric Orchestrator folder id (and Task.organization_unit_id); it is converted to text only for the x-uipath-organizationunitid header. Numeric strings still validate. - job_key and organization_unit_id are required only when the run is named by rule_name without project_id. With a project_id the service uses the project as given and never reads the job's lineage (StudioDmnResolver.projectIdOf), so neither is needed. - Build run contexts outside pytest.raises so each block has a single call that can raise (Sonar python:S5778). Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 30 ++++--- .../platform/business_rules/business_rules.py | 7 +- .../services/test_business_rules_service.py | 85 ++++++++++++++----- 3 files changed, 86 insertions(+), 36 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 277c4a02f..aff0cc926 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 @@ -263,17 +263,21 @@ def _debug_spec( ) -> RequestSpec: job_key = debug.job_key or UiPathConfig.job_key named = _present(debug.rule_name) - if not _present(debug.project_id) and not _present(job_key): - raise ValueError( - "debug.job_key must be specified when the run is named by rule_name: " - "the service resolves the project from the job's lineage. " - "Set it or UIPATH_JOB_KEY." - ) - if named and not _present(debug.organization_unit_id): - raise ValueError( - "debug.organization_unit_id must be specified when the run is named " - "by rule_name: it is the folder the job's lineage is read under" - ) + # A named project is used as given. Only a run named by rule_name alone + # makes the service resolve the project from the job's lineage. + if named and not _present(debug.project_id): + if not _present(job_key): + raise ValueError( + "debug.job_key must be specified when the run is named by " + "rule_name without project_id: the service resolves the project " + "from the job's lineage. Set it or UIPATH_JOB_KEY." + ) + if debug.organization_unit_id is None: + raise ValueError( + "debug.organization_unit_id must be specified when the run is " + "named by rule_name without project_id: it is the folder the " + "job's lineage is read under" + ) body: Dict[str, Any] = { "explain": explain, @@ -292,8 +296,8 @@ def _debug_spec( headers: Dict[str, str] = {} if folder_key: headers[HEADER_FOLDER_KEY] = folder_key - if debug.organization_unit_id: - headers[_HEADER_ORGANIZATION_UNIT_ID] = debug.organization_unit_id + if debug.organization_unit_id is not None: + headers[_HEADER_ORGANIZATION_UNIT_ID] = str(debug.organization_unit_id) if job_key: headers[_HEADER_JOB_KEY] = job_key return RequestSpec( 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 2f7742077..ca1122979 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 @@ -36,7 +36,8 @@ class DebugRunContext(BaseModel): Name the project with ``project_id``, or name the rule with ``rule_name`` and let the service resolve the project from the job's lineage. The second way also needs - ``job_key`` (defaults to ``UIPATH_JOB_KEY``) and ``organization_unit_id``. + ``job_key`` (defaults to ``UIPATH_JOB_KEY``) and ``organization_unit_id``. When + ``project_id`` is set, the project is used as given and neither is needed. """ project_id: Optional[str] = Field( @@ -54,9 +55,9 @@ class DebugRunContext(BaseModel): default=None, description="The job this run belongs to; defaults to UIPATH_JOB_KEY.", ) - organization_unit_id: Optional[str] = Field( + organization_unit_id: Optional[int] = Field( default=None, - description="The numeric id of the job's folder; required with rule_name.", + description="The numeric id of the job's folder; required with rule_name alone.", ) 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 9ed8594c7..a893363db 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -81,13 +81,10 @@ def test_requires_a_run_context(self, service: BusinessRulesService) -> None: service.run({}, folder_key=FOLDER_KEY) def test_rejects_both_run_contexts(self, service: BusinessRulesService) -> None: + debug = DebugRunContext(project_id="proj-1") + with pytest.raises(ValueError, match="both were"): - service.run( - {}, - deployed=LOAN_PRICING, - debug=DebugRunContext(project_id="proj-1"), - folder_key=FOLDER_KEY, - ) + service.run({}, deployed=LOAN_PRICING, debug=debug, folder_key=FOLDER_KEY) @pytest.mark.parametrize( "rule_name", @@ -371,7 +368,7 @@ def test_by_rule_name_sends_job_and_folder_headers( service.run( {}, debug=DebugRunContext( - rule_name="Loan Pricing", job_key=JOB_KEY, organization_unit_id="42" + rule_name="Loan Pricing", job_key=JOB_KEY, organization_unit_id=42 ), folder_key=FOLDER_KEY, ) @@ -395,7 +392,7 @@ def test_job_key_defaults_from_env( service.run( {}, - debug=DebugRunContext(rule_name="Loan Pricing", organization_unit_id="42"), + debug=DebugRunContext(rule_name="Loan Pricing", organization_unit_id=42), ) request = httpx_mock.get_request() @@ -403,8 +400,10 @@ def test_job_key_defaults_from_env( assert request.headers["x-uipath-jobkey"] == JOB_KEY def test_explain_requires_a_folder(self, service: BusinessRulesService) -> None: + debug = DebugRunContext(project_id="proj-1") + with pytest.raises(ValueError, match="explain=True"): - service.run({}, debug=DebugRunContext(project_id="proj-1"), explain=True) + service.run({}, debug=debug, explain=True) def test_explain_sends_resolved_folder_key( self, @@ -428,31 +427,77 @@ def test_explain_sends_resolved_folder_key( assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY def test_requires_project_or_rule_name(self, service: BusinessRulesService) -> None: + debug = DebugRunContext(file_name="loan.dmn") + with pytest.raises(ValueError, match="project_id or debug.rule_name"): - service.run({}, debug=DebugRunContext(file_name="loan.dmn")) + service.run({}, debug=debug) def test_rule_name_requires_job_key(self, service: BusinessRulesService) -> None: + debug = DebugRunContext(rule_name="Loan Pricing", organization_unit_id=42) + with pytest.raises(ValueError, match="job_key"): - service.run( - {}, - debug=DebugRunContext( - rule_name="Loan Pricing", organization_unit_id="42" - ), - ) + service.run({}, debug=debug) + + def test_accepts_organization_unit_id_as_numeric_string( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response([])) + + debug = DebugRunContext.model_validate( + { + "rule_name": "Loan Pricing", + "job_key": JOB_KEY, + "organization_unit_id": "42", + } + ) + assert debug.organization_unit_id == 42 + + service.run({}, debug=debug) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-organizationunitid"] == "42" + + def test_project_id_with_rule_name_needs_no_job_or_folder_id( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + ) -> None: + # The service uses a named project as given; the job lineage is never read. + httpx_mock.add_response(url=debug_url, json=_response([])) + + service.run( + {}, + debug=DebugRunContext(project_id="proj-1", rule_name="Loan Pricing"), + ) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["projectId"] == "proj-1" + assert body["businessRuleName"] == "Loan Pricing" + assert "x-uipath-jobkey" not in request.headers + assert "x-uipath-organizationunitid" not in request.headers def test_rule_name_requires_organization_unit( self, service: BusinessRulesService ) -> None: + debug = DebugRunContext(rule_name="Loan Pricing", job_key=JOB_KEY) + with pytest.raises(ValueError, match="organization_unit_id"): - service.run( - {}, debug=DebugRunContext(rule_name="Loan Pricing", job_key=JOB_KEY) - ) + service.run({}, debug=debug) def test_rejects_unsafe_debug_rule_name( self, service: BusinessRulesService ) -> None: + debug = DebugRunContext(rule_name="a/b", job_key=JOB_KEY) + with pytest.raises(ValueError, match="debug.rule_name"): - service.run({}, debug=DebugRunContext(rule_name="a/b", job_key=JOB_KEY)) + service.run({}, debug=debug) async def test_run_async_debug( self, From 9d483533261c4a23550e5b6850e8a28581dd97df Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 11:51:22 +0530 Subject: [PATCH 06/47] 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 07/47] 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 08/47] 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 09/47] 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 1b1c912dabf6723496ad41848fb558d6512f10c3 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 30 Sep 2026 08:13:08 +0530 Subject: [PATCH 10/47] feat(business-rules): keep the two debug modes separate on the wire The service documents projectId and businessRuleName as alternative ways to find a debug run's project. Each request now carries exactly one: - Project mode (debug.project_id): body has projectId (+ fileName), no businessRuleName; no x-uipath-jobkey or x-uipath-organizationunitid, even when UIPATH_JOB_KEY is set. Setting job_key or organization_unit_id with project_id is a ValueError. - Job-lineage mode (no project_id): body has businessRuleName; the job key (or UIPATH_JOB_KEY) and organization_unit_id are required and sent. The rule name stays run()'s first argument, validated and remappable by businessRule bindings; it goes on the wire only when it decides which project runs. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 78 ++++++++++++------- .../platform/business_rules/business_rules.py | 17 ++-- .../services/test_business_rules_service.py | 46 ++++++++++- 3 files changed, 104 insertions(+), 37 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 268c25c66..07547135b 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 @@ -271,43 +271,23 @@ def _debug_spec( explain: bool, folder_key: Optional[str], ) -> RequestSpec: - job_key = debug.job_key or UiPathConfig.job_key - # A named project is used as given. Without one, the service resolves - # the project from the job's lineage, which needs the job and its folder. - if not _present(debug.project_id): - if not _present(job_key): - raise ValueError( - "debug.job_key must be specified when debug.project_id is not: " - "the service resolves the project from the job's lineage. " - "Set it or UIPATH_JOB_KEY." - ) - if debug.organization_unit_id is None: - raise ValueError( - "debug.organization_unit_id must be specified when " - "debug.project_id is not: it is the folder the job's lineage " - "is read under" - ) + # The service finds the project one of two ways, documented as + # alternatives: by projectId, read as given, or by businessRuleName from + # the running debug job's lineage. Each request carries one mode only. + if _present(debug.project_id): + body, headers = _project_mode(debug) + else: + body, headers = _job_lineage_mode(name, debug) - body: Dict[str, Any] = { - "businessRuleName": name, - "explain": explain, - "inputs": [{"id": _SINGLE_INPUT_ID, "data": input}], - } - if debug.project_id: - body["projectId"] = debug.project_id + body["explain"] = explain + body["inputs"] = [{"id": _SINGLE_INPUT_ID, "data": input}] if debug.file_name: body["fileName"] = debug.file_name if decision_names: body["decisionNames"] = decision_names - # Folder key: the traces service files the run's spans under it. - headers: Dict[str, str] = {} if folder_key: headers[HEADER_FOLDER_KEY] = folder_key - if debug.organization_unit_id is not None: - headers[_HEADER_ORGANIZATION_UNIT_ID] = str(debug.organization_unit_id) - if job_key: - headers[_HEADER_JOB_KEY] = job_key return RequestSpec( method="POST", endpoint=_DEBUG_EVALUATE_ENDPOINT, @@ -316,6 +296,46 @@ def _debug_spec( ) +def _project_mode(debug: DebugRunContext) -> Tuple[Dict[str, Any], Dict[str, str]]: + mixed = [ + field + for field, value in ( + ("job_key", debug.job_key), + ("organization_unit_id", debug.organization_unit_id), + ) + if value is not None + ] + if mixed: + raise ValueError( + f"debug.{' and debug.'.join(mixed)} can't be combined with " + "debug.project_id: a project is read as given, and the job's lineage " + "is only used without one" + ) + return {"projectId": debug.project_id}, {} + + +def _job_lineage_mode( + name: str, debug: DebugRunContext +) -> Tuple[Dict[str, Any], Dict[str, str]]: + job_key = debug.job_key or UiPathConfig.job_key + if not job_key or not job_key.strip(): + raise ValueError( + "debug.job_key must be specified when debug.project_id is not: " + "the service resolves the project from the job's lineage. " + "Set it or UIPATH_JOB_KEY." + ) + if debug.organization_unit_id is None: + raise ValueError( + "debug.organization_unit_id must be specified when debug.project_id " + "is not: it is the folder the job's lineage is read under" + ) + headers = { + _HEADER_JOB_KEY: job_key, + _HEADER_ORGANIZATION_UNIT_ID: str(debug.organization_unit_id), + } + return {"businessRuleName": name}, headers + + class _TraceHeaders(Dict[str, str]): """Request headers that keep the caller's explicit trace header. 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 3039e1fd1..05ecb7baf 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 @@ -27,13 +27,18 @@ class RunMode(str, Enum): class DebugRunContext(BaseModel): """Run the undeployed rule from a Studio project instead of the deployed one. - With ``project_id`` the project is read as given. Without it, the service - resolves the project from the running debug job's lineage, which needs - ``job_key`` (defaults to ``UIPATH_JOB_KEY``) and ``organization_unit_id``. + Two exclusive modes, matching the service: + + - **By project:** set ``project_id`` (and optionally ``file_name``). The project + is read as given; ``job_key`` and ``organization_unit_id`` must not be set. + - **By job lineage:** leave ``project_id`` unset. The service finds the project + from the running debug job and checks the rule name against it, so + ``job_key`` (defaults to ``UIPATH_JOB_KEY``) and ``organization_unit_id`` are + required. """ project_id: Optional[str] = Field( - default=None, description="The Studio project holding the rule." + default=None, description="Project mode: the Studio project holding the rule." ) file_name: Optional[str] = Field( default=None, @@ -41,11 +46,11 @@ class DebugRunContext(BaseModel): ) job_key: Optional[str] = Field( default=None, - description="The debug job this run belongs to; defaults to UIPATH_JOB_KEY.", + description="Job-lineage mode: the running debug job; defaults to UIPATH_JOB_KEY.", ) organization_unit_id: Optional[int] = Field( default=None, - description="The numeric id of the job's folder; required without project_id.", + description="Job-lineage mode: the numeric id of the job's folder.", ) 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 b9e0eb8ed..a9a22250d 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -693,7 +693,6 @@ def test_by_project_id( request = httpx_mock.get_request() assert request is not None assert json.loads(request.content) == { - "businessRuleName": RULE, "projectId": "proj-1", "fileName": "loan.dmn", "explain": False, @@ -761,6 +760,45 @@ def test_accepts_organization_unit_id_as_numeric_string( assert request is not None assert request.headers["x-uipath-organizationunitid"] == "42" + def test_project_mode_sends_no_job_headers_even_with_env_job_key( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + httpx_mock.add_response(url=debug_url, json=_response([])) + + service.run(RULE, {}, debug=DebugRunContext(project_id="proj-1")) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["projectId"] == "proj-1" + assert "businessRuleName" not in body + assert "x-uipath-jobkey" not in request.headers + assert "x-uipath-organizationunitid" not in request.headers + + @pytest.mark.parametrize( + ("fields", "message"), + [ + ({"job_key": JOB_KEY}, "debug.job_key can't be combined"), + ({"organization_unit_id": 42}, "debug.organization_unit_id can't be"), + ( + {"job_key": JOB_KEY, "organization_unit_id": 42}, + "debug.job_key and debug.organization_unit_id can't be", + ), + ], + ) + def test_project_mode_rejects_job_lineage_fields( + self, service: BusinessRulesService, fields: dict[str, Any], message: str + ) -> None: + debug = DebugRunContext(project_id="proj-1", **fields) + + with pytest.raises(ValueError, match=message): + service.run(RULE, {}, debug=debug) + def test_explain_requires_a_folder(self, service: BusinessRulesService) -> None: debug = DebugRunContext(project_id="proj-1") @@ -824,7 +862,11 @@ def test_override_applies_to_debug_runs( ) -> None: httpx_mock.add_response(url=debug_url, json=_response([])) - service.run(RULE, {}, debug=DebugRunContext(project_id="proj-1")) + service.run( + RULE, + {}, + debug=DebugRunContext(job_key=JOB_KEY, organization_unit_id=42), + ) request = httpx_mock.get_request() assert request is not None From 162cf34a1759cf9084ca8ab326abae5e4d108b1c Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 30 Sep 2026 09:10:09 +0530 Subject: [PATCH 11/47] 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 12/47] 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 13/47] chore(business-rules): bump uipath-platform to 0.2.34 main released 0.2.33 (#1920), so this PR moves to the next version. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 1b57a68a4..3df1c4def 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 54ab45e3d..fee1df2ad 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 00cd8a8f2..c43717048 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From b940642cd7d80cddad421dc1816134074fd96d58 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 30 Sep 2026 10:25:04 +0530 Subject: [PATCH 14/47] chore(business-rules): bump uipath-platform to 0.2.35 #1912 now takes 0.2.34 (main released 0.2.33), 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 3df1c4def..1ee12a6c8 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index fee1df2ad..7d91df83e 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index c43717048..d00f06ff7 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From ca881df36a9cbb73aff81846b0ead867749987b8 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 14:07:00 +0530 Subject: [PATCH 15/47] feat(business-rules)!: send input, read result, require the folder key Catches run() up with the service contract from UiPath/business-rules#124, #126 and #130, and with the .NET client 2.0.0 (#131): - One input on the wire: the request sends `input` and the response is read from `result`. The deprecated `inputs` batch and its fixed id are gone, and a successful response without `result` raises ValueError. - No `explain`: the service decides what it traces. - The folder is named by key only. `organization_unit_id` is removed, and a run without a folder key (from folder_key, folder_path, UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH) raises before anything is sent. A blank value counts as not given. - A `caller` is sent for the execution audit. BusinessRuleCaller's fields default to UIPATH_PROCESS_UUID, UIPATH_JOB_KEY and UIPATH_FOLDER_KEY, blank fields are left out, and so is `caller` when every field is blank. - `RunMode` and `BusinessRuleRunResult.mode` are removed. Smoke-tested against alpha. Co-Authored-By: Claude Opus 5.5 --- .../platform/business_rules/__init__.py | 8 +- .../business_rules/_business_rules_service.py | 280 +++++-------- .../platform/business_rules/business_rules.py | 32 +- .../services/test_business_rules_service.py | 395 +++++++++--------- 4 files changed, 339 insertions(+), 376 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py index 889f86cb3..b49fd91cf 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -1,26 +1,26 @@ """Business Rules service package. Provides the ``BusinessRulesService`` client for running UiPath Business Rules -deployed to Orchestrator, and the Pydantic models for its results and trace -context. +deployed to Orchestrator, and the Pydantic models for its caller, results and +trace context. """ from ._business_rules_service import BusinessRulesService from .business_rules import ( + BusinessRuleCaller, BusinessRuleDecision, BusinessRuleError, BusinessRuleRunResult, BusinessRuleStatus, - RunMode, TraceContext, ) __all__ = [ + "BusinessRuleCaller", "BusinessRuleDecision", "BusinessRuleError", "BusinessRuleRunResult", "BusinessRuleStatus", "BusinessRulesService", - "RunMode", "TraceContext", ] diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py index 808dd257d..65df6bdc8 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py @@ -10,29 +10,24 @@ from ..common._base_service import _TRACE_PARENT_HEADER, BaseService from ..common._bindings import resource_override -from ..common._config import UiPathApiConfig +from ..common._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext from ..common._models import Endpoint, RequestSpec from ..constants import HEADER_FOLDER_KEY from ..orchestrator._folder_service import FolderService from .business_rules import ( + BusinessRuleCaller, BusinessRuleDecision, BusinessRuleError, BusinessRuleRunResult, BusinessRuleStatus, - RunMode, TraceContext, _WireResponse, - _WireResult, ) _EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/evaluate") -_HEADER_ORGANIZATION_UNIT_ID = "x-uipath-organizationunitid" - -# The service's contract is a batch; this SDK submits exactly one input under this id. -_SINGLE_INPUT_ID = "input-1" _MAX_INPUT_KEYS = 256 _MAX_RULE_NAME_LENGTH = 256 @@ -41,8 +36,7 @@ class BusinessRulesService(FolderContext, BaseService): """Service for running UiPath Business Rules. Each call runs one input against a business rule deployed to Orchestrator, - named like any other resource, and returns the decisions it produced. The - caller never picks a service endpoint. + named like any other resource, and returns the decisions it produced. """ def __init__( @@ -62,41 +56,36 @@ def run( *, version: Optional[str] = None, decision_names: Optional[List[str]] = None, - explain: bool = False, folder_key: Optional[str] = None, folder_path: Optional[str] = None, - organization_unit_id: Optional[int] = None, + caller: Optional[BusinessRuleCaller] = None, trace_context: Optional[TraceContext] = None, ) -> BusinessRuleRunResult: """Run a business rule against one input. Args: - name: The name of the business rule deployed to Orchestrator. + name: The name of the business rule. input: The input to run, keyed by the rule's input names. Declared inputs absent from it bind to null. version: The rule version to run; defaults to the active version. decision_names: The decisions to evaluate; defaults to the whole model. - explain: Whether to record condition-level explanations in the trace. - folder_key: The key of the folder to run in. - folder_path: The path of the folder to run in. Looked up and sent as - its key. - organization_unit_id: The numeric id of the folder to run in, sent as - given. The service prefers the folder key when both are sent. + folder_key: The key of the folder the rule is deployed in. + folder_path: The path of the folder the rule is deployed in. Looked up + and sent as its key. + caller: Who is running the rule, for the deployed run's audit. + Fields left unset default to the current job's values. trace_context: The trace to file the run's spans under. Defaults to the ambient trace: ``UIPATH_TRACE_ID`` and the current span. - A folder is required: ``folder_key``, ``folder_path`` or - ``organization_unit_id``, with ``explain=True`` needing a folder key (from - ``folder_key`` or ``folder_path``). ``folder_key`` and ``folder_path`` are - exclusive. When the caller gives no folder at all, it falls back to - ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``; an explicit folder is - never replaced by the environment's. A ``businessRule`` binding can remap - ``name`` and the folder per environment; its folder then replaces - ``folder_key`` or ``folder_path``. + The rule runs in a folder named by its key: ``folder_key`` is sent as is, + and ``folder_path`` is looked up and sent as its key; the two are + exclusive. When the caller gives neither, it falls back to + ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``. A ``businessRule`` + binding can remap ``name`` and the folder per environment; its folder then + replaces ``folder_key`` or ``folder_path``. Returns: - BusinessRuleRunResult: The decisions produced for the input, and the - mode that ran. + BusinessRuleRunResult: The decisions produced for the input. Raises: ValueError: If the request is invalid or a required folder is missing. @@ -121,14 +110,10 @@ def run( name, folder_key, folder_path ) _validate_run(name, input) - key, path = self._folder_source( - folder_key, folder_path, use_env=organization_unit_id is None or explain - ) + key, path = self._folder_source(folder_key, folder_path) if path: key = self._folders_service.retrieve_folder_key(path) - mode, spec = self._run_spec( - name, input, version, decision_names, explain, key, organization_unit_id - ) + spec = _evaluate_spec(name, input, version, decision_names, key, caller) response = self.request( spec.method, url=spec.endpoint, @@ -136,7 +121,7 @@ def run( headers=_with_trace(spec.headers, trace_context), scoped="tenant", ) - return _to_run_result(mode, _WireResponse.model_validate(response.json())) + return _to_run_result(_WireResponse.model_validate(response.json())) @traced(name="business_rules_run", run_type="uipath") async def run_async( @@ -146,32 +131,29 @@ async def run_async( *, version: Optional[str] = None, decision_names: Optional[List[str]] = None, - explain: bool = False, folder_key: Optional[str] = None, folder_path: Optional[str] = None, - organization_unit_id: Optional[int] = None, + caller: Optional[BusinessRuleCaller] = None, trace_context: Optional[TraceContext] = None, ) -> BusinessRuleRunResult: """Asynchronously run a business rule against one input. Args: - name: The name of the business rule deployed to Orchestrator. + name: The name of the business rule. input: The input to run, keyed by the rule's input names. Declared inputs absent from it bind to null. version: The rule version to run; defaults to the active version. decision_names: The decisions to evaluate; defaults to the whole model. - explain: Whether to record condition-level explanations in the trace. - folder_key: The key of the folder to run in. - folder_path: The path of the folder to run in. Looked up and sent as - its key. - organization_unit_id: The numeric id of the folder to run in, sent as - given. The service prefers the folder key when both are sent. + folder_key: The key of the folder the rule is deployed in. + folder_path: The path of the folder the rule is deployed in. Looked up + and sent as its key. + caller: Who is running the rule, for the deployed run's audit. + Fields left unset default to the current job's values. trace_context: The trace to file the run's spans under. Defaults to the ambient trace: ``UIPATH_TRACE_ID`` and the current span. Returns: - BusinessRuleRunResult: The decisions produced for the input, and the - mode that ran. + BusinessRuleRunResult: The decisions produced for the input. Raises: ValueError: If the request is invalid or a required folder is missing. @@ -181,14 +163,10 @@ async def run_async( name, folder_key, folder_path ) _validate_run(name, input) - key, path = self._folder_source( - folder_key, folder_path, use_env=organization_unit_id is None or explain - ) + key, path = self._folder_source(folder_key, folder_path) if path: key = await self._folders_service.retrieve_folder_key_async(path) - mode, spec = self._run_spec( - name, input, version, decision_names, explain, key, organization_unit_id - ) + spec = _evaluate_spec(name, input, version, decision_names, key, caller) response = await self.request_async( spec.method, url=spec.endpoint, @@ -196,7 +174,7 @@ async def run_async( headers=_with_trace(spec.headers, trace_context), scoped="tenant", ) - return _to_run_result(mode, _WireResponse.model_validate(response.json())) + return _to_run_result(_WireResponse.model_validate(response.json())) @resource_override(resource_type="businessRule") def _binding( @@ -221,74 +199,68 @@ def _apply_binding( return bound_name, folder_key, bound_path def _folder_source( - self, folder_key: Optional[str], folder_path: Optional[str], use_env: bool + self, folder_key: Optional[str], folder_path: Optional[str] ) -> Tuple[Optional[str], Optional[str]]: - """Pick the folder key to send, as a (key, path-still-to-resolve) pair. - - ``use_env`` is false when the caller named the folder another way (by its - numeric id), so the environment's folder can't silently replace it. - """ - if folder_key and folder_path: + """Pick the folder key to send, as a (key, path-still-to-resolve) pair.""" + # Blank counts as not given, so it falls back to the environment. + has_key, has_path = _present(folder_key), _present(folder_path) + if has_key and has_path: raise ValueError("Only one of folder_key or folder_path can be provided") - if folder_key: + if has_key: return folder_key, None - if folder_path: + if has_path: return None, folder_path - if not use_env: - return None, None if self._folder_key: return self._folder_key, None - return None, self._folder_path or None - - def _run_spec( - self, - name: str, - input: Dict[str, Any], - version: Optional[str], - decision_names: Optional[List[str]], - explain: bool, - folder_key: Optional[str], - organization_unit_id: Optional[int], - ) -> Tuple[RunMode, RequestSpec]: - if explain and not folder_key: - raise _missing_folder_key() - if not folder_key and organization_unit_id is None: - raise _missing_folder("a deployed business rule") - return RunMode.DEPLOYED, self._evaluate_spec( - name, - input, - version, - decision_names, - explain, - folder_key, - organization_unit_id, + if self._folder_path: + return None, self._folder_path + raise ValueError( + "A folder is required for a deployed business rule: pass folder_key or " + "folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" ) - def _evaluate_spec( - self, - name: str, - input: Dict[str, Any], - version: Optional[str], - decision_names: Optional[List[str]], - explain: bool, - folder_key: Optional[str], - organization_unit_id: Optional[int], - ) -> RequestSpec: - body: Dict[str, Any] = { - "businessRuleName": name, - "explain": explain, - "inputs": [{"id": _SINGLE_INPUT_ID, "data": input}], - } - if version: - body["version"] = version - if decision_names: - body["decisionNames"] = decision_names - return RequestSpec( - method="POST", - endpoint=_EVALUATE_ENDPOINT, - json=body, - headers=_folder_headers(folder_key, organization_unit_id), - ) + +def _evaluate_spec( + name: str, + input: Dict[str, Any], + version: Optional[str], + decision_names: Optional[List[str]], + folder_key: Optional[str], + caller: Optional[BusinessRuleCaller], +) -> RequestSpec: + # The service resolves the rule in this folder and files the run's trace and + # audit record under it; a folder path that resolves to nothing lands here. + if not folder_key or not folder_key.strip(): + raise ValueError(f"No folder key was found for business rule '{name}'") + body: Dict[str, Any] = {"businessRuleName": name, "input": input} + if _present(version): + body["version"] = version + if decision_names: + body["decisionNames"] = decision_names + wire_caller = _wire_caller(caller) + if wire_caller: + body["caller"] = wire_caller + return RequestSpec( + method="POST", + endpoint=_EVALUATE_ENDPOINT, + json=body, + headers={HEADER_FOLDER_KEY: folder_key}, + ) + + +def _wire_caller(caller: Optional[BusinessRuleCaller]) -> Dict[str, str]: + given = caller or BusinessRuleCaller() + values = { + "resourceKey": given.resource_key or UiPathConfig.process_uuid, + "runKey": given.run_key or UiPathConfig.job_key, + "folderKey": given.folder_key or UiPathConfig.folder_key, + } + # Blank fields are left out, and a caller with every field blank is no caller. + wire: Dict[str, str] = {} + for key, value in values.items(): + if value and value.strip(): + wire[key] = value + return wire class _TraceHeaders(Dict[str, str]): @@ -319,31 +291,6 @@ def _present(value: Optional[str]) -> bool: return bool(value and value.strip()) -def _folder_headers( - folder_key: Optional[str], organization_unit_id: Optional[int] -) -> Dict[str, str]: - headers: Dict[str, str] = {} - if folder_key: - headers[HEADER_FOLDER_KEY] = folder_key - if organization_unit_id is not None: - headers[_HEADER_ORGANIZATION_UNIT_ID] = str(organization_unit_id) - return headers - - -def _missing_folder(needed_for: str) -> ValueError: - return ValueError( - f"A folder is required for {needed_for}: pass folder_key, folder_path or " - "organization_unit_id, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" - ) - - -def _missing_folder_key() -> ValueError: - return ValueError( - "A folder key is required for explain=True: pass folder_key or " - "folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" - ) - - def _validate_run(name: str, input: Dict[str, Any]) -> None: _validate_rule_name(name, "name") _validate_input(input) @@ -373,44 +320,29 @@ def _validate_input(input: Dict[str, Any]) -> None: raise ValueError(f"input must not exceed {_MAX_INPUT_KEYS} keys") -def _single_result( - response: _WireResponse, -) -> Tuple[List[BusinessRuleDecision], List[BusinessRuleError], BusinessRuleStatus]: - results = response.results or [] - ours: Optional[_WireResult] = next( - (r for r in results if r.id == _SINGLE_INPUT_ID), None - ) - if ours is None and len(results) == 1: - ours = results[0] - elif ours is None and results: - raise ValueError( - f"The service returned {len(results)} results and none carried the id " - f"'{_SINGLE_INPUT_ID}' this request was submitted under" - ) - if ours is None: - # No result for our input at all: it was never evaluated. - return [], [], BusinessRuleStatus.ALL_FAILED - - decisions = ours.decisions or [] - errors = ours.errors or [] +def _status( + decisions: List[BusinessRuleDecision], errors: List[BusinessRuleError] +) -> BusinessRuleStatus: + # An input-level error means the input never evaluated, whatever else came back. if errors: - status = BusinessRuleStatus.ALL_FAILED - else: - failed = sum(1 for d in decisions if d.error is not None) - if failed == 0: - status = BusinessRuleStatus.SUCCESS - elif failed == len(decisions): - status = BusinessRuleStatus.ALL_FAILED - else: - status = BusinessRuleStatus.PARTIAL_SUCCESS - return decisions, errors, status - - -def _to_run_result(mode: RunMode, response: _WireResponse) -> BusinessRuleRunResult: - decisions, errors, status = _single_result(response) + return BusinessRuleStatus.ALL_FAILED + failed = sum(1 for d in decisions if d.error is not None) + if failed == 0: + return BusinessRuleStatus.SUCCESS + if failed == len(decisions): + return BusinessRuleStatus.ALL_FAILED + return BusinessRuleStatus.PARTIAL_SUCCESS + + +def _to_run_result(response: _WireResponse) -> BusinessRuleRunResult: + # A successful response always carries the input's result; one without it is + # not an answer to report as an evaluation. + if response.result is None: + raise ValueError("The business rules response did not include a result") + decisions = response.result.decisions or [] + errors = response.result.errors or [] return BusinessRuleRunResult( - mode=mode, - status=status, + status=_status(decisions, errors), decisions=decisions, errors=errors, top_level_error=response.error.code if response.error else None, diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py index f63fb97f8..44eeb468b 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -17,10 +17,26 @@ class BusinessRuleStatus(str, Enum): ALL_FAILED = "AllFailed" -class RunMode(str, Enum): - """Which kind of model ran, and so which service endpoint served the run.""" +class BusinessRuleCaller(BaseModel): + """Who is running the rule, kept in the deployed run's execution audit. - DEPLOYED = "Deployed" + Each field defaults to the current job's value from the environment. This + describes the calling resource, not the rule: ``folder_key`` here is the + caller's folder, which can differ from the folder the rule runs in. + """ + + resource_key: Optional[str] = Field( + default=None, + description="The calling resource's key; for a process, its release key. Defaults to UIPATH_PROCESS_UUID.", + ) + run_key: Optional[str] = Field( + default=None, + description="The calling run, such as its job key. Defaults to UIPATH_JOB_KEY.", + ) + folder_key: Optional[str] = Field( + default=None, + description="The folder the calling resource runs in. Defaults to UIPATH_FOLDER_KEY.", + ) class TraceContext(BaseModel): @@ -98,7 +114,6 @@ class BusinessRuleRunResult(BaseModel): use_enum_values=True, ) - mode: RunMode = Field(description="Which kind of model ran.") status: BusinessRuleStatus decisions: List[BusinessRuleDecision] = Field(default_factory=list) errors: List[BusinessRuleError] = Field( @@ -107,20 +122,19 @@ class BusinessRuleRunResult(BaseModel): ) top_level_error: Optional[str] = Field( default=None, - description="The request-level error code (e.g. BATCH_TIMEOUT), if one was reported.", + description="The request-level error code, if one was reported.", ) business_rule_name: Optional[str] = Field( - default=None, description="The deployed rule that ran." + default=None, description="The deployed rule that ran, when reported." ) version: Optional[str] = Field( - default=None, description="The rule version that ran." + default=None, description="The rule version that ran, when reported." ) class _WireResult(BaseModel): model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - id: Optional[str] = None decisions: Optional[List[BusinessRuleDecision]] = None errors: Optional[List[BusinessRuleError]] = None @@ -133,4 +147,4 @@ class _WireResponse(BaseModel): business_rule_name: Optional[str] = Field(default=None, alias="businessRuleName") version: Optional[str] = None error: Optional[BusinessRuleError] = None - results: Optional[List[_WireResult]] = None + result: Optional[_WireResult] = None diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index abeb04026..df91d1d1e 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -10,9 +10,9 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.business_rules import ( + BusinessRuleCaller, BusinessRulesService, BusinessRuleStatus, - RunMode, TraceContext, ) from uipath.platform.common._bindings import ( @@ -26,6 +26,7 @@ FOLDER_KEY = "5f1f1b0e-2b8a-4c1e-9b8e-1a2b3c4d5e6f" TRACEPARENT = "x-uipath-traceparent-id" RULE = "Loan Pricing" +JOB_KEY = "9d8c7b6a-5f4e-3d2c-1b0a-9f8e7d6c5b4a" @pytest.fixture @@ -45,6 +46,8 @@ def service( ) -> BusinessRulesService: monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + monkeypatch.delenv("UIPATH_PROCESS_UUID", raising=False) return BusinessRulesService( config=config, execution_context=execution_context, @@ -57,23 +60,14 @@ def evaluate_url(base_url: str, org: str, tenant: str) -> str: return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/evaluate" -def _response(results: list[dict[str, Any]], **extra: Any) -> dict[str, Any]: - return { - "hasPartialSuccess": False, - "stats": {"submitted": 1, "succeeded": 1, "failed": 0, "notEvaluated": 0}, - "results": results, - "meta": {"timestamp": "2026-09-28T00:00:00Z"}, - **extra, - } +def _response(result: dict[str, Any] | None = None, **extra: Any) -> dict[str, Any]: + body: dict[str, Any] = {"meta": {"timestamp": "2026-09-28T00:00:00Z"}, **extra} + body["result"] = {"decisions": []} if result is None else result + return body -def _one_decision(**outputs: Any) -> list[dict[str, Any]]: - return [ - { - "id": "input-1", - "decisions": [{"decisionName": "RiskGrade", "outputs": outputs}], - } - ] +def _one_decision(**outputs: Any) -> dict[str, Any]: + return {"decisions": [{"decisionName": "RiskGrade", "outputs": outputs}]} class TestRunContext: @@ -109,7 +103,7 @@ def test_resolves_folder_path_to_key( folders_service: Mock, evaluate_url: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}, folder_path="Finance/Loans") @@ -130,7 +124,7 @@ def test_falls_back_to_env_folder_key( ) -> None: monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}) @@ -150,7 +144,7 @@ def test_falls_back_to_env_folder_path( ) -> None: monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}) @@ -171,7 +165,7 @@ async def test_falls_back_to_env_folder_path_async( ) -> None: monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) await service.run_async(RULE, {}) @@ -195,7 +189,7 @@ def test_env_folder_key_wins_over_env_folder_path( monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}) @@ -204,136 +198,35 @@ def test_env_folder_key_wins_over_env_folder_path( assert request is not None assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" - def test_organization_unit_id_alone_is_sent_as_given( - self, - httpx_mock: HTTPXMock, - service: BusinessRulesService, - evaluate_url: str, - ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) - - service.run(RULE, {}, organization_unit_id=3373422) - - request = httpx_mock.get_request() - assert request is not None - assert request.headers["x-uipath-organizationunitid"] == "3373422" - assert HEADER_FOLDER_KEY not in request.headers - - def test_folder_key_and_organization_unit_id_are_both_sent( - self, - httpx_mock: HTTPXMock, - service: BusinessRulesService, - evaluate_url: str, - ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) - - service.run(RULE, {}, folder_key=FOLDER_KEY, organization_unit_id=42) - - request = httpx_mock.get_request() - assert request is not None - assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY - assert request.headers["x-uipath-organizationunitid"] == "42" - - def test_folder_path_and_organization_unit_id_are_both_sent( - self, - httpx_mock: HTTPXMock, - service: BusinessRulesService, - folders_service: Mock, - evaluate_url: str, - ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) - - service.run(RULE, {}, folder_path="Finance", organization_unit_id=42) - - folders_service.retrieve_folder_key.assert_called_once_with("Finance") - request = httpx_mock.get_request() - assert request is not None - assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY - assert request.headers["x-uipath-organizationunitid"] == "42" - - def test_env_folder_does_not_replace_an_explicit_organization_unit_id( - self, - httpx_mock: HTTPXMock, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - folders_service: Mock, - evaluate_url: str, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") - service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response([])) - - service.run(RULE, {}, organization_unit_id=42) - - request = httpx_mock.get_request() - assert request is not None - assert HEADER_FOLDER_KEY not in request.headers - assert request.headers["x-uipath-organizationunitid"] == "42" - - def test_explain_needs_a_folder_key_not_only_an_organization_unit_id( + def test_deployed_requires_a_folder( self, httpx_mock: HTTPXMock, service: BusinessRulesService ) -> None: - with pytest.raises(ValueError, match="folder key is required for explain"): - service.run(RULE, {}, organization_unit_id=42, explain=True) + with pytest.raises(ValueError, match="deployed business rule"): + service.run(RULE, {}) assert httpx_mock.get_requests() == [] - def test_explain_with_organization_unit_id_takes_the_env_folder_key( - self, - httpx_mock: HTTPXMock, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - folders_service: Mock, - evaluate_url: str, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") - service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response([])) - - service.run(RULE, {}, organization_unit_id=42, explain=True) - - request = httpx_mock.get_request() - assert request is not None - assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" - assert request.headers["x-uipath-organizationunitid"] == "42" - - def test_explain_with_folder_path_sends_the_looked_up_key( - self, - httpx_mock: HTTPXMock, - service: BusinessRulesService, - folders_service: Mock, - evaluate_url: str, + def test_deployed_rejects_a_blank_folder_key( + self, httpx_mock: HTTPXMock, service: BusinessRulesService ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) - - service.run(RULE, {}, folder_path="Finance", explain=True) + with pytest.raises(ValueError, match="deployed business rule"): + service.run(RULE, {}, folder_key=" ") - folders_service.retrieve_folder_key.assert_called_once_with("Finance") - request = httpx_mock.get_request() - assert request is not None - assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY - assert json.loads(request.content)["explain"] is True + assert httpx_mock.get_requests() == [] - async def test_organization_unit_id_alone_async( + def test_sends_no_organization_unit_id( self, httpx_mock: HTTPXMock, service: BusinessRulesService, evaluate_url: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) - await service.run_async(RULE, {}, organization_unit_id=42) + service.run(RULE, {}, folder_key=FOLDER_KEY) request = httpx_mock.get_request() assert request is not None - assert request.headers["x-uipath-organizationunitid"] == "42" - assert HEADER_FOLDER_KEY not in request.headers - - def test_deployed_requires_a_folder(self, service: BusinessRulesService) -> None: - with pytest.raises(ValueError, match="deployed business rule"): - service.run(RULE, {}) + assert "x-uipath-organizationunitid" not in request.headers def test_rejects_both_folder_key_and_path( self, service: BusinessRulesService @@ -365,11 +258,9 @@ def test_sends_single_input_and_maps_decisions( {"creditScore": 740}, version="1.0.3", decision_names=["RiskGrade"], - explain=True, folder_key=FOLDER_KEY, ) - assert result.mode == RunMode.DEPLOYED assert result.status == BusinessRuleStatus.SUCCESS assert result.decisions[0].decision_name == "RiskGrade" assert result.decisions[0].outputs == {"Grade": "B", "Rate": 3.5} @@ -384,8 +275,7 @@ def test_sends_single_input_and_maps_decisions( "businessRuleName": "Loan Pricing", "version": "1.0.3", "decisionNames": ["RiskGrade"], - "explain": True, - "inputs": [{"id": "input-1", "data": {"creditScore": 740}}], + "input": {"creditScore": 740}, } assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY assert request.headers["Authorization"] == "Bearer secret" @@ -400,16 +290,30 @@ def test_omits_optional_fields( service: BusinessRulesService, evaluate_url: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) - service.run(RULE, {}, folder_key=FOLDER_KEY) + service.run(RULE, {}, version=" ", folder_key=FOLDER_KEY) request = httpx_mock.get_request() assert request is not None body = json.loads(request.content) - assert "version" not in body - assert "decisionNames" not in body - assert body["explain"] is False + assert body == {"businessRuleName": RULE, "input": {}} + + def test_sends_no_explain_or_batch( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {"a": 1}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "explain" not in body + assert "inputs" not in body async def test_run_async_resolves_folder_path( self, @@ -423,13 +327,97 @@ async def test_run_async_resolves_folder_path( result = await service.run_async(RULE, {"a": 1}, folder_path="Finance") folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance") - assert result.mode == RunMode.DEPLOYED assert result.status == BusinessRuleStatus.SUCCESS request = httpx_mock.get_request() assert request is not None assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY +class TestCaller: + def test_defaults_from_the_environment( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + monkeypatch.setenv("UIPATH_FOLDER_KEY", "caller-folder-key") + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "resourceKey": "release-key", + "runKey": JOB_KEY, + "folderKey": "caller-folder-key", + } + # The rule's folder, not the caller's, scopes the run. + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + def test_explicit_fields_win_over_the_environment( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run( + RULE, + {}, + folder_key=FOLDER_KEY, + caller=BusinessRuleCaller(run_key="agent-run", folder_key="agent-folder"), + ) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "resourceKey": "release-key", + "runKey": "agent-run", + "folderKey": "agent-folder", + } + + def test_blank_fields_are_left_out( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run( + RULE, + {}, + folder_key=FOLDER_KEY, + caller=BusinessRuleCaller(resource_key="release-key", run_key=" "), + ) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == {"resourceKey": "release-key"} + + def test_no_caller_without_any_value( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert "caller" not in json.loads(request.content) + + class TestResult: def test_partial_success_on_207( self, @@ -441,21 +429,18 @@ def test_partial_success_on_207( url=evaluate_url, status_code=207, json=_response( - [ - { - "id": "input-1", - "decisions": [ - {"decisionName": "RiskGrade", "outputs": {"Grade": "B"}}, - { - "decisionName": "Payment", - "error": { - "code": "DECISION_FAILED", - "message": "null arithmetic", - }, + { + "decisions": [ + {"decisionName": "RiskGrade", "outputs": {"Grade": "B"}}, + { + "decisionName": "Payment", + "error": { + "code": "DECISION_FAILED", + "message": "null arithmetic", }, - ], - } - ] + }, + ], + } ), ) @@ -465,6 +450,31 @@ def test_partial_success_on_207( assert result.decisions[1].error is not None assert result.decisions[1].error.code == "DECISION_FAILED" + def test_every_decision_failing_is_all_failed( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_response( + { + "decisions": [ + { + "decisionName": "Payment", + "error": {"code": "DECISION_FAILED", "message": "x"}, + } + ] + } + ), + ) + + result = service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert result.status == BusinessRuleStatus.ALL_FAILED + def test_input_level_error_is_all_failed( self, httpx_mock: HTTPXMock, @@ -475,17 +485,14 @@ def test_input_level_error_is_all_failed( url=evaluate_url, status_code=207, json=_response( - [ - { - "id": "input-1", - "errors": [ - { - "code": "INPUT_VALIDATION_FAILED", - "message": "creditScore must be a number", - } - ], - } - ] + { + "errors": [ + { + "code": "INPUT_VALIDATION_FAILED", + "message": "creditScore must be a number", + } + ] + } ), ) @@ -494,7 +501,7 @@ def test_input_level_error_is_all_failed( assert result.status == BusinessRuleStatus.ALL_FAILED assert result.errors[0].code == "INPUT_VALIDATION_FAILED" - def test_not_evaluated_carries_top_level_error( + def test_carries_the_top_level_error( self, httpx_mock: HTTPXMock, service: BusinessRulesService, @@ -504,17 +511,27 @@ def test_not_evaluated_carries_top_level_error( url=evaluate_url, status_code=207, json=_response( - [], - notEvaluatedIds=["input-1"], - error={"code": "BATCH_TIMEOUT", "message": "ran out of time"}, + {"errors": [{"code": "INPUT_FAILED", "message": "x"}]}, + error={"code": "UPSTREAM_ERROR", "message": "y"}, ), ) result = service.run(RULE, {}, folder_key=FOLDER_KEY) - assert result.status == BusinessRuleStatus.ALL_FAILED - assert result.decisions == [] - assert result.top_level_error == "BATCH_TIMEOUT" + assert result.top_level_error == "UPSTREAM_ERROR" + + def test_raises_when_the_result_is_missing( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, json={"meta": {"timestamp": "2026-09-28T00:00:00Z"}} + ) + + with pytest.raises(ValueError, match="did not include a result"): + service.run(RULE, {}, folder_key=FOLDER_KEY) def test_raises_enriched_exception_on_error_envelope( self, @@ -566,7 +583,7 @@ def test_explicit_trace_context_wins_over_ambient_span( evaluate_url: str, ambient_span: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -589,7 +606,7 @@ def test_explicit_trace_context_wins_over_uipath_trace_id( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("UIPATH_TRACE_ID", AMBIENT_TRACE_ID) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -612,7 +629,7 @@ def test_ambient_trace_is_used_without_trace_context( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}, folder_key=FOLDER_KEY) @@ -629,8 +646,8 @@ def test_override_does_not_leak_into_the_next_call( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) - httpx_mock.add_response(url=evaluate_url, json=_response([])) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -649,7 +666,7 @@ async def test_explicit_trace_context_async( evaluate_url: str, ambient_span: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -671,7 +688,7 @@ def test_explicit_trace_context_survives_a_retry( ambient_span: None, ) -> None: httpx_mock.add_response(url=evaluate_url, status_code=503) - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -742,7 +759,7 @@ def test_override_replaces_rule_name_and_folder( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}, folder_path="Finance") @@ -760,7 +777,7 @@ async def test_override_applies_to_run_async( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) await service.run_async(RULE, {}, folder_path="Finance") @@ -777,7 +794,7 @@ def test_override_folder_replaces_callers_folder_key( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run(RULE, {}, folder_key="callers-folder-key") @@ -795,7 +812,7 @@ async def test_override_folder_replaces_callers_folder_key_async( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) await service.run_async(RULE, {}, folder_key="callers-folder-key") @@ -813,7 +830,7 @@ def test_callers_folder_key_kept_without_a_matching_override( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run("Risk Tier", {}, folder_key="callers-folder-key") @@ -830,7 +847,7 @@ def test_other_rules_are_not_overridden( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response()) service.run("Risk Tier", {}, folder_path="Finance") From b6eead93ffb7d01aaf5c1ecf6239bf3514b7c398 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 14:08:03 +0530 Subject: [PATCH 16/47] feat(business-rules)!: the SDK picks the debug endpoint; debug runs name only the job run() no longer asks the caller which endpoint to call, as the .NET client does since UiPath/business-rules#116. The first match wins: 1. folder_key or folder_path given: the rule deployed in that folder. 2. A debug session (UIPATH_PROJECT_ID is set, or the job is rooted to a debug job) with a job key in UIPATH_JOB_KEY: the undeployed rule from the project being debugged, through /debug/evaluate. 3. Otherwise: the rule deployed in the environment's folder. The folder key and job key are in every job's environment, so they cannot decide this on their own; the debug-session signal is the one the SDK already uses for AgentHub licensing. A debug run sends the rule name, the input and x-uipath-jobkey, and no folder header: since UiPath/business-rules#130 the service takes the job's folders, and the trace's folder, from its lineage. version and caller are ignored there rather than rejected, so the same code runs in both kinds of run. debug=, DebugRunContext (with project_id, file_name and job_key) and organization_unit_id are removed. The result reports project_id and file_name when the rule came from a project. Smoke-tested against alpha, deployed and debug. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/CLAUDE.md | 2 +- .../platform/business_rules/__init__.py | 7 +- .../business_rules/_business_rules_service.py | 117 ++++++-- .../platform/business_rules/business_rules.py | 15 +- .../services/test_business_rules_service.py | 258 ++++++++++++++++++ 5 files changed, 371 insertions(+), 28 deletions(-) diff --git a/packages/uipath-platform/CLAUDE.md b/packages/uipath-platform/CLAUDE.md index 0a81b30d9..317f7df9b 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/` | Business rule runs: rules deployed to Orchestrator, or undeployed rules in a Studio project, behind one `run()` | +| `business_rules/` | Business rule runs: rules deployed to Orchestrator, or undeployed rules in a debug job's Studio project, behind one `run()` | | `chat/` | LLM gateway, conversations, throttling | | `connections/` | External connection management | | `context_grounding/` | RAG services (DeepRAG, batch RAG, ephemeral indexes) | diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py index b49fd91cf..7fa5d5abb 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,9 @@ """Business Rules service package. -Provides the ``BusinessRulesService`` client for running UiPath Business Rules -deployed to Orchestrator, and the Pydantic models for its caller, results and -trace context. +Provides the ``BusinessRulesService`` client for running UiPath Business Rules, +either deployed to Orchestrator or, in a debug session, read undeployed from +the project being debugged, and the Pydantic models for its caller, results +and trace context. """ from ._business_rules_service import BusinessRulesService 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 65df6bdc8..14de6a559 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,7 @@ """Business Rules service for UiPath Platform. -Runs business rules deployed to Orchestrator. +Runs business rules: a rule deployed to Orchestrator, or, inside a debug +session, the undeployed rule from the Studio project being debugged. """ from collections.abc import Mapping @@ -14,7 +15,7 @@ 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 ..constants import HEADER_FOLDER_KEY, HEADER_JOB_KEY from ..orchestrator._folder_service import FolderService from .business_rules import ( BusinessRuleCaller, @@ -27,6 +28,7 @@ ) _EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/evaluate") +_DEBUG_EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/debug/evaluate") _MAX_INPUT_KEYS = 256 _MAX_RULE_NAME_LENGTH = 256 @@ -35,8 +37,11 @@ class BusinessRulesService(FolderContext, BaseService): """Service for running UiPath Business Rules. - Each call runs one input against a business rule deployed to Orchestrator, - named like any other resource, and returns the decisions it produced. + Each call runs one input against a business rule, named like any other + resource, and returns the decisions it produced. The service picks the + endpoint from the run itself, so the caller never chooses one: inside a debug + session the undeployed rule from the project being debugged runs, and + otherwise the rule deployed to Orchestrator runs. """ def __init__( @@ -68,21 +73,32 @@ def run( 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. + Ignored on a debug run. decision_names: The decisions to evaluate; defaults to the whole model. folder_key: The key of the folder the rule is deployed in. folder_path: The path of the folder the rule is deployed in. Looked up and sent as its key. caller: Who is running the rule, for the deployed run's audit. - Fields left unset default to the current job's values. + Fields left unset default to the current job's values. Ignored + on a debug run. trace_context: The trace to file the run's spans under. Defaults to the ambient trace: ``UIPATH_TRACE_ID`` and the current span. - The rule runs in a folder named by its key: ``folder_key`` is sent as is, - and ``folder_path`` is looked up and sent as its key; the two are - exclusive. When the caller gives neither, it falls back to - ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``. A ``businessRule`` - binding can remap ``name`` and the folder per environment; its folder then - replaces ``folder_key`` or ``folder_path``. + Which rule runs is decided in this order, first match winning: + + 1. ``folder_key`` or ``folder_path`` given: the rule deployed in that + folder. + 2. Inside a debug session (``UIPATH_PROJECT_ID`` is set, or the job is + rooted to a debug job) with a job key in ``UIPATH_JOB_KEY``: the + undeployed rule from the project being debugged. The service finds the + project and its folders from the job's lineage. + 3. Otherwise: the rule deployed in ``UIPATH_FOLDER_KEY``, or else + ``UIPATH_FOLDER_PATH``. + + A deployed run needs a folder key: ``folder_key`` is sent as is, and + ``folder_path`` is looked up and sent as its key; the two are exclusive. + A ``businessRule`` binding can remap ``name`` and the folder per + environment; its folder then replaces ``folder_key`` or ``folder_path``. Returns: BusinessRuleRunResult: The decisions produced for the input. @@ -97,23 +113,32 @@ def run( client = UiPath() + # In a debug session this runs the rule from the project being + # debugged; deployed, the rule in the job's folder. result = client.business_rules.run( - "Loan Pricing", - {"creditScore": 740, "age": 34}, - folder_path="Finance", + "Loan Pricing", {"creditScore": 740, "age": 34} ) for decision in result.decisions: print(decision.decision_name, decision.outputs) + + # Always the rule deployed in a given folder + result = client.business_rules.run( + "Loan Pricing", {"creditScore": 740}, folder_path="Finance" + ) ``` """ + debug_job_key = _debug_job_key(folder_key, folder_path) 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: - key = self._folders_service.retrieve_folder_key(path) - spec = _evaluate_spec(name, input, version, decision_names, key, caller) + if debug_job_key: + spec = _debug_spec(name, input, debug_job_key, decision_names) + else: + key, path = self._folder_source(folder_key, folder_path) + if path: + key = self._folders_service.retrieve_folder_key(path) + spec = _evaluate_spec(name, input, version, decision_names, key, caller) response = self.request( spec.method, url=spec.endpoint, @@ -143,12 +168,14 @@ async def run_async( 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. + Ignored on a debug run. decision_names: The decisions to evaluate; defaults to the whole model. folder_key: The key of the folder the rule is deployed in. folder_path: The path of the folder the rule is deployed in. Looked up and sent as its key. caller: Who is running the rule, for the deployed run's audit. - Fields left unset default to the current job's values. + Fields left unset default to the current job's values. Ignored + on a debug run. trace_context: The trace to file the run's spans under. Defaults to the ambient trace: ``UIPATH_TRACE_ID`` and the current span. @@ -159,14 +186,18 @@ async def run_async( ValueError: If the request is invalid or a required folder is missing. EnrichedException: If the service rejects the request. """ + debug_job_key = _debug_job_key(folder_key, folder_path) 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: - key = await self._folders_service.retrieve_folder_key_async(path) - spec = _evaluate_spec(name, input, version, decision_names, key, caller) + if debug_job_key: + spec = _debug_spec(name, input, debug_job_key, decision_names) + else: + key, path = self._folder_source(folder_key, folder_path) + if path: + key = await self._folders_service.retrieve_folder_key_async(path) + spec = _evaluate_spec(name, input, version, decision_names, key, caller) response = await self.request_async( spec.method, url=spec.endpoint, @@ -248,6 +279,44 @@ def _evaluate_spec( ) +def _debug_job_key( + folder_key: Optional[str], folder_path: Optional[str] +) -> Optional[str]: + """Return the debug job's key when this run is a debug run, else None. + + A folder the caller names always means a deployed rule. Otherwise a run in a + debug session debugs the project, named by the session's job; without a job + key there is no lineage to resolve the project from, so it runs deployed. + """ + if _present(folder_key) or _present(folder_path): + return None + if not (UiPathConfig.is_studio_project or UiPathConfig.is_rooted_to_debug_job): + return None + job_key = UiPathConfig.job_key + return job_key if job_key and job_key.strip() else None + + +def _debug_spec( + name: str, + input: Dict[str, Any], + job_key: str, + decision_names: Optional[List[str]], +) -> RequestSpec: + # The service finds the project, and the folders the job ran in, from the + # debug job's lineage, and checks the rule name against it. No folder header + # is sent, and neither is a version or caller: an undeployed rule has no + # version, and a debug run is not an audited execution. + body: Dict[str, Any] = {"businessRuleName": name, "input": input} + if decision_names: + body["decisionNames"] = decision_names + return RequestSpec( + method="POST", + endpoint=_DEBUG_EVALUATE_ENDPOINT, + json=body, + headers={HEADER_JOB_KEY: job_key}, + ) + + def _wire_caller(caller: Optional[BusinessRuleCaller]) -> Dict[str, str]: given = caller or BusinessRuleCaller() values = { @@ -348,4 +417,6 @@ def _to_run_result(response: _WireResponse) -> BusinessRuleRunResult: top_level_error=response.error.code if response.error else None, business_rule_name=response.business_rule_name, version=response.version, + project_id=response.project_id, + file_name=response.file_name, ) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py index 44eeb468b..661e7b031 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 @@ -106,7 +106,10 @@ class BusinessRuleDecision(BaseModel): class BusinessRuleRunResult(BaseModel): - """The outcome of running a business rule against one input.""" + """The outcome of running a business rule against one input. + + The shape is the same for deployed and debug runs. + """ model_config = ConfigDict( validate_by_name=True, @@ -130,6 +133,14 @@ class BusinessRuleRunResult(BaseModel): version: Optional[str] = Field( default=None, description="The rule version that ran, when reported." ) + project_id: Optional[str] = Field( + default=None, + description="The Studio project the rule was read from, when it came from one.", + ) + file_name: Optional[str] = Field( + default=None, + description="The rule file read from the project, when it came from one.", + ) class _WireResult(BaseModel): @@ -146,5 +157,7 @@ class _WireResponse(BaseModel): business_rule_name: Optional[str] = Field(default=None, alias="businessRuleName") version: Optional[str] = None + project_id: Optional[str] = Field(default=None, alias="projectId") + file_name: Optional[str] = Field(default=None, alias="fileName") error: Optional[BusinessRuleError] = None result: Optional[_WireResult] = None diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index df91d1d1e..68c2c849f 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -20,6 +20,7 @@ ResourceOverwriteParser, _resource_overwrites, ) +from uipath.platform.common._config import UiPathConfig from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT from uipath.platform.errors import EnrichedException @@ -48,6 +49,11 @@ def service( monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) monkeypatch.delenv("UIPATH_PROCESS_UUID", raising=False) + monkeypatch.delenv("UIPATH_PROJECT_ID", raising=False) + # Not a debug session unless a test says so, whatever uipath.json is nearby. + monkeypatch.setattr( + type(UiPathConfig), "is_rooted_to_debug_job", property(lambda self: False) + ) return BusinessRulesService( config=config, execution_context=execution_context, @@ -60,6 +66,11 @@ def evaluate_url(base_url: str, org: str, tenant: str) -> str: return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/evaluate" +@pytest.fixture +def debug_url(base_url: str, org: str, tenant: str) -> str: + return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/debug/evaluate" + + def _response(result: dict[str, Any] | None = None, **extra: Any) -> dict[str, Any]: body: dict[str, Any] = {"meta": {"timestamp": "2026-09-28T00:00:00Z"}, **extra} body["result"] = {"decisions": []} if result is None else result @@ -268,6 +279,7 @@ def test_sends_single_input_and_maps_decisions( assert result.top_level_error is None assert result.business_rule_name == "Loan Pricing" assert result.version == "1.0.3" + assert result.project_id is None request = httpx_mock.get_request() assert request is not None @@ -855,3 +867,249 @@ def test_other_rules_are_not_overridden( request = httpx_mock.get_request() assert request is not None assert json.loads(request.content)["businessRuleName"] == "Risk Tier" + + +PROJECT_ID = "041e6279-8a51-4aec-b2fa-6786704eba19" + + +@pytest.fixture +def studio_debug(monkeypatch: pytest.MonkeyPatch) -> None: + """A Studio debug session: the project being debugged and its job.""" + monkeypatch.setenv("UIPATH_PROJECT_ID", PROJECT_ID) + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + + +@pytest.fixture +def rooted_to_debug_job(monkeypatch: pytest.MonkeyPatch) -> None: + """A deployed process running under a debug session (e.g. a solution debug).""" + monkeypatch.setattr( + type(UiPathConfig), "is_rooted_to_debug_job", property(lambda self: True) + ) + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + + +class TestDebug: + def test_studio_session_runs_the_project_rule( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ) -> None: + httpx_mock.add_response( + url=debug_url, + method="POST", + json=_response( + _one_decision(x=1), projectId=PROJECT_ID, fileName="Business rule.dmn" + ), + ) + + result = service.run(RULE, {"a": 1}, decision_names=["RiskGrade"]) + + assert result.status == BusinessRuleStatus.SUCCESS + assert result.project_id == PROJECT_ID + assert result.file_name == "Business rule.dmn" + assert result.business_rule_name is None + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content) == { + "businessRuleName": RULE, + "decisionNames": ["RiskGrade"], + "input": {"a": 1}, + } + assert request.headers["x-uipath-jobkey"] == JOB_KEY + + def test_job_rooted_to_a_debug_session_runs_the_project_rule( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + rooted_to_debug_job: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response()) + + service.run(RULE, {}) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-jobkey"] == JOB_KEY + + def test_sends_no_folder_version_or_caller( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + debug_url: str, + studio_debug: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") + httpx_mock.add_response(url=debug_url, json=_response()) + + service.run( + RULE, + {}, + version="1.0.0", + caller=BusinessRuleCaller(run_key="agent-run"), + ) + + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + # The service takes the job's folders from its lineage. + assert HEADER_FOLDER_KEY not in request.headers + assert "x-uipath-organizationunitid" not in request.headers + assert "x-uipath-folderpath" not in request.headers + body = json.loads(request.content) + assert "version" not in body + assert "caller" not in body + + @pytest.mark.parametrize( + "folder", [{"folder_key": FOLDER_KEY}, {"folder_path": "Finance"}] + ) + def test_a_named_folder_runs_the_deployed_rule( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + studio_debug: None, + folder: dict[str, str], + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, **folder) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert "x-uipath-jobkey" not in request.headers + + def test_a_blank_folder_does_not_count_as_named( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response()) + + service.run(RULE, {}, folder_key=" ") + + assert httpx_mock.get_request() is not None + + def test_a_session_without_a_job_key_runs_deployed( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + # Running a project locally: no job, so no lineage to read it from. + monkeypatch.setenv("UIPATH_PROJECT_ID", PROJECT_ID) + monkeypatch.setenv("UIPATH_JOB_KEY", " ") + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert httpx_mock.get_request() is not None + + def test_a_deployed_job_runs_deployed( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" + assert "x-uipath-jobkey" not in request.headers + + def test_rejects_unsafe_rule_name( + self, httpx_mock: HTTPXMock, service: BusinessRulesService, studio_debug: None + ) -> None: + with pytest.raises(ValueError, match="name"): + service.run("a/b", {}) + + assert httpx_mock.get_requests() == [] + + def test_override_applies_to_debug_runs( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response()) + + service.run(RULE, {}) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + assert HEADER_FOLDER_KEY not in request.headers + + def test_retries_like_any_platform_call( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ) -> None: + httpx_mock.add_response(url=debug_url, status_code=503) + httpx_mock.add_response(url=debug_url, json=_response(_one_decision(x=1))) + + result = service.run(RULE, {}) + + assert result.status == BusinessRuleStatus.SUCCESS + assert len(httpx_mock.get_requests()) == 2 + + def test_explicit_trace_context_on_debug_run( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ambient_span: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response()) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, trace_context=explicit) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + async def test_run_async_debug( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response(projectId=PROJECT_ID)) + + result = await service.run_async(RULE, {}) + + assert result.project_id == PROJECT_ID + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-jobkey"] == JOB_KEY From fc7498fd11b658a4c2a9f4bd3471370960c9fe15 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 14:24:04 +0530 Subject: [PATCH 17/47] chore(business-rules): bump uipath-platform to 0.2.35 main released 0.2.34, so this PR moves to the next version. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 091bd2284..be322ea1e 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 8d7cccdcc..b02fd2c40 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 9a1aa77c6..72e4bda1b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From 010006d6386ee8ec756b4841604ef3bb99f28b79 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 14:24:55 +0530 Subject: [PATCH 18/47] chore(business-rules): bump uipath-platform to 0.2.36 #1912 now takes 0.2.35 (main released 0.2.34), so this PR moves to the next version. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index be322ea1e..4699bfeeb 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.35" +version = "0.2.36" 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 b02fd2c40..99070bbf4 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.35" +version = "0.2.36" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 72e4bda1b..fc6abcd7a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.35" +version = "0.2.36" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From a8bb4596aed1286951e22070d63eebad6ac4477e Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 15:45:42 +0530 Subject: [PATCH 19/47] feat(business-rules): read the service's error code into EnrichedException.error_info The Business Rules service nests its error under "error": {"error": {"code": "RULE_NOT_FOUND", "message": "..."}}. The generic extractor reads the message from there but looks for the code only at the top level, so error_info.error_code came back None and a caller had to parse the body to branch on the code. A businessrules_ extractor reads both from "error", and falls back to the generic extractor for any other shape, such as an error the gateway answers. Checked against alpha: a 401 from the service now reports error_code="UNAUTHENTICATED". Co-Authored-By: Claude Opus 5.5 --- .../errors/_extractors/_businessrules.py | 25 +++++++++++++ .../platform/errors/_extractors/_router.py | 2 ++ .../tests/errors/test_enriched_exception.py | 36 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py diff --git a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py new file mode 100644 index 000000000..9d0fddf1e --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py @@ -0,0 +1,25 @@ +"""Business Rules service error payload extractor. + +Business Rules returns: {"error": {"code": "", "message": ""}, +"meta": {...}}, with the code nested under "error" where the generic extractor +does not look for it. +""" + +from typing import Any + +from .._enriched_exception import ExtractedErrorInfo +from ._generic import extract_generic +from ._helpers import get_field, get_str_field, get_typed_field + + +def extract_businessrules(body: dict[str, Any]) -> ExtractedErrorInfo: + error = get_field(body, "error") + if not isinstance(error, dict): + # Not the service's envelope, e.g. an error answered by the gateway. + return extract_generic(body) + + return ExtractedErrorInfo( + message=get_typed_field(error, str, "message"), + error_code=get_str_field(error, "code"), + trace_id=get_typed_field(body, str, "traceId", "requestId"), + ) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py index 901dbc089..4e52eac5f 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py @@ -14,6 +14,7 @@ from .._enriched_exception import ExtractedErrorInfo from ._agenthub import extract_agenthub from ._apps import extract_apps +from ._businessrules import extract_businessrules from ._datafabric import extract_datafabric from ._elements import extract_elements from ._generic import extract_generic @@ -30,6 +31,7 @@ "agenthub_": extract_agenthub, "agentsruntime_": extract_agenthub, "apps_": extract_apps, + "businessrules_": extract_businessrules, "datafabric_": extract_datafabric, "elements_": extract_elements, "llmopstenant_": extract_llmops, diff --git a/packages/uipath-platform/tests/errors/test_enriched_exception.py b/packages/uipath-platform/tests/errors/test_enriched_exception.py index e41f41007..3ce6e6500 100644 --- a/packages/uipath-platform/tests/errors/test_enriched_exception.py +++ b/packages/uipath-platform/tests/errors/test_enriched_exception.py @@ -9,6 +9,9 @@ _LLM_URL = "https://cloud.uipath.com/org/tenant/orchestrator_/llm/chat" _AGENTHUB_URL = "https://cloud.uipath.com/org/tenant/agenthub_/api/v1" _APPS_URL = "https://cloud.uipath.com/org/tenant/apps_/api/v1" +_BUSINESS_RULES_URL = ( + "https://cloud.uipath.com/org/tenant/businessrules_/v1/business-rules/evaluate" +) _ELEMENTS_URL = "https://cloud.uipath.com/org/tenant/elements_/api/v1" _LLMOPS_URL = "https://cloud.uipath.com/org/tenant/llmopstenant_/api/v1" _GENERIC_URL = "https://cloud.uipath.com/api/test" @@ -239,6 +242,39 @@ def test_error_message_v2_priority(self): assert exc.error_info.message == "Detailed error" +class TestBusinessRulesExtraction: + """Business Rules nests the code and message under "error".""" + + def test_nested_error_code_and_message(self): + body = json.dumps( + { + "businessRuleName": "Missing", + "error": {"code": "RULE_NOT_FOUND", "message": "no such rule"}, + "meta": {"timestamp": "2026-10-07T00:00:00Z"}, + } + ) + exc = EnrichedException(_make_error(404, body=body, url=_BUSINESS_RULES_URL)) + assert exc.error_info is not None + assert exc.error_info.message == "no such rule" + assert exc.error_info.error_code == "RULE_NOT_FOUND" + assert exc.error_info.trace_id is None + + def test_error_without_message(self): + body = json.dumps({"error": {"code": "BATCH_TIMEOUT"}, "meta": {}}) + exc = EnrichedException(_make_error(504, body=body, url=_BUSINESS_RULES_URL)) + assert exc.error_info is not None + assert exc.error_info.error_code == "BATCH_TIMEOUT" + assert exc.error_info.message is None + + def test_other_shapes_fall_back_to_generic(self): + # An error answered by the gateway rather than the service. + body = json.dumps({"message": "Unauthorized", "errorCode": 1015}) + exc = EnrichedException(_make_error(401, body=body, url=_BUSINESS_RULES_URL)) + assert exc.error_info is not None + assert exc.error_info.message == "Unauthorized" + assert exc.error_info.error_code == "1015" + + class TestElementsExtraction: """Elements has vendor passthrough fields (providerMessage, providerErrorCode).""" From e29c9161fe0d5628df5e2682221b255a5f743246 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 15:47:51 +0530 Subject: [PATCH 20/47] fix(business-rules): refuse only control characters in a rule name, as .NET does The check used str.isprintable(), which also refuses characters that are not control characters: non-breaking and zero-width spaces, line and paragraph separators. A name with a non-breaking space pasted from a web page or a document passed the .NET client and failed here. It now refuses Unicode category Cc only, the same set as .NET's char.IsControl. Newlines, tabs, NUL and DEL are still refused. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 5 ++- .../services/test_business_rules_service.py | 34 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 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 65df6bdc8..660a86484 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. """ +import unicodedata from collections.abc import Mapping from typing import Any, Dict, List, Optional, Tuple @@ -304,7 +305,9 @@ def _validate_rule_name(rule_name: str, field: str) -> None: for forbidden in ("/", "\\", "..", "%"): if forbidden in rule_name: raise ValueError(f"{field} must not contain '{forbidden}'") - if any(not ch.isprintable() for ch in rule_name): + # Control characters only (Unicode category Cc), as the .NET client checks; + # a non-breaking or zero-width space is allowed in a name. + if any(unicodedata.category(ch) == "Cc" for ch in rule_name): raise ValueError(f"{field} must not contain control characters") 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 df91d1d1e..16427c3fb 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -73,7 +73,18 @@ def _one_decision(**outputs: Any) -> dict[str, Any]: class TestRunContext: @pytest.mark.parametrize( "rule_name", - ["", " ", "a/b", "a\\b", "a..b", "a%20b", "a\nb", "x" * 257], + [ + "", + " ", + "a/b", + "a\\b", + "a..b", + "a%20b", + "a\nb", + "a\x00b", + "a\x7fb", + "x" * 257, + ], ) def test_rejects_unsafe_rule_names( self, service: BusinessRulesService, rule_name: str @@ -81,6 +92,27 @@ def test_rejects_unsafe_rule_names( with pytest.raises(ValueError, match="name"): service.run(rule_name, {}, folder_key=FOLDER_KEY) + @pytest.mark.parametrize( + "rule_name", + ["Loan\u00a0Pricing", "Loan\u200bPricing", "Préstamo Tarifa", "贷款定价"], + ) + def test_accepts_names_the_dotnet_client_accepts( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + rule_name: str, + ) -> None: + # Only control characters are refused, as in the .NET client: a + # non-breaking space pasted into a name is not one. + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(rule_name, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == rule_name + @pytest.mark.parametrize("value", [["age", 14], "age=14", 14]) def test_rejects_non_mapping_input( self, httpx_mock: HTTPXMock, service: BusinessRulesService, value: Any From ce7fe087692b7b7a25bdd1b086535d0b78e11a2b Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 19:08:52 +0530 Subject: [PATCH 21/47] refactor(business-rules): follow the SDK's service conventions; add the docs page - The request builder is a method, self._evaluate_spec(name, input, *, ...), like the _xxx_spec builders across the SDK, instead of a module function. - Folder resolution is _resolve_folder_key / _resolve_folder_key_async, the name the context grounding, memory and AgentHub services use, returning the key. _select_folder picks the source, so the sync and async paths share it. A folder_path that matches no folder now says so ("No folder was found for folder_path '...'") instead of naming the rule. - Clearer names for the helpers and locals: _overridden_resource, _caller_payload, _headers_with_trace / _ExplicitTraceHeaders, _has_value, _validate_run_arguments, _overall_status, and the forbidden rule-name parts as a constant. No behaviour change. - docs/core/business_rules.md and a Business Rules entry under Services in mkdocs.yml, as the other services have, so sdk.business_rules appears in the published SDK docs. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 220 +++++++++++------- .../services/test_business_rules_service.py | 26 +++ packages/uipath/docs/core/business_rules.md | 1 + packages/uipath/mkdocs.yml | 1 + 4 files changed, 165 insertions(+), 83 deletions(-) create mode 100644 packages/uipath/docs/core/business_rules.md 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 660a86484..fffd6d8b8 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 @@ -31,6 +31,7 @@ _MAX_INPUT_KEYS = 256 _MAX_RULE_NAME_LENGTH = 256 +_FORBIDDEN_RULE_NAME_PARTS = ("/", "\\", "..", "%") class BusinessRulesService(FolderContext, BaseService): @@ -110,16 +111,21 @@ def run( 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: - key = self._folders_service.retrieve_folder_key(path) - spec = _evaluate_spec(name, input, version, decision_names, key, caller) + _validate_run_arguments(name, input) + resolved_folder_key = self._resolve_folder_key(folder_key, folder_path) + spec = self._evaluate_spec( + name, + input, + folder_key=resolved_folder_key, + version=version, + decision_names=decision_names, + caller=caller, + ) response = self.request( spec.method, url=spec.endpoint, json=spec.json, - headers=_with_trace(spec.headers, trace_context), + headers=_headers_with_trace(spec.headers, trace_context), scoped="tenant", ) return _to_run_result(_WireResponse.model_validate(response.json())) @@ -163,22 +169,29 @@ async def run_async( 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: - key = await self._folders_service.retrieve_folder_key_async(path) - spec = _evaluate_spec(name, input, version, decision_names, key, caller) + _validate_run_arguments(name, input) + resolved_folder_key = await self._resolve_folder_key_async( + folder_key, folder_path + ) + spec = self._evaluate_spec( + name, + input, + folder_key=resolved_folder_key, + version=version, + decision_names=decision_names, + caller=caller, + ) response = await self.request_async( spec.method, url=spec.endpoint, json=spec.json, - headers=_with_trace(spec.headers, trace_context), + headers=_headers_with_trace(spec.headers, trace_context), scoped="tenant", ) return _to_run_result(_WireResponse.model_validate(response.json())) @resource_override(resource_type="businessRule") - def _binding( + def _overridden_resource( self, name: str, folder_path: Optional[str] = None ) -> Tuple[str, Optional[str]]: # resource_override swaps these two arguments when the solution's @@ -194,22 +207,30 @@ def _apply_binding( 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: + bound_name, bound_folder_path = self._overridden_resource( + name, folder_path=folder_path + ) + is_remapped = (bound_name, bound_folder_path) != (name, folder_path) + if is_remapped and bound_folder_path: folder_key = None - return bound_name, folder_key, bound_path + return bound_name, folder_key, bound_folder_path - def _folder_source( + def _select_folder( self, folder_key: Optional[str], folder_path: Optional[str] ) -> Tuple[Optional[str], Optional[str]]: - """Pick the folder key to send, as a (key, path-still-to-resolve) pair.""" - # Blank counts as not given, so it falls back to the environment. - has_key, has_path = _present(folder_key), _present(folder_path) - if has_key and has_path: + """Pick where the folder key comes from, as a ``(key, path)`` pair. + + Exactly one of the two is set: a key ready to send, or a path still to + look up. Blank values count as not given, so they fall back to the + environment. + """ + has_folder_key = _has_value(folder_key) + has_folder_path = _has_value(folder_path) + if has_folder_key and has_folder_path: raise ValueError("Only one of folder_key or folder_path can be provided") - if has_key: + if has_folder_key: return folder_key, None - if has_path: + if has_folder_path: return None, folder_path if self._folder_key: return self._folder_key, None @@ -220,51 +241,81 @@ def _folder_source( "folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" ) + def _resolve_folder_key( + self, folder_key: Optional[str], folder_path: Optional[str] + ) -> str: + """Return the key of the folder the rule runs in, looking up a path.""" + selected_key, selected_path = self._select_folder(folder_key, folder_path) + if selected_path: + selected_key = self._folders_service.retrieve_folder_key(selected_path) + return _require_folder_key(selected_key, selected_path) + + async def _resolve_folder_key_async( + self, folder_key: Optional[str], folder_path: Optional[str] + ) -> str: + """Asynchronously return the key of the folder the rule runs in.""" + selected_key, selected_path = self._select_folder(folder_key, folder_path) + if selected_path: + selected_key = await self._folders_service.retrieve_folder_key_async( + selected_path + ) + return _require_folder_key(selected_key, selected_path) -def _evaluate_spec( - name: str, - input: Dict[str, Any], - version: Optional[str], - decision_names: Optional[List[str]], - folder_key: Optional[str], - caller: Optional[BusinessRuleCaller], -) -> RequestSpec: - # The service resolves the rule in this folder and files the run's trace and - # audit record under it; a folder path that resolves to nothing lands here. - if not folder_key or not folder_key.strip(): - raise ValueError(f"No folder key was found for business rule '{name}'") - body: Dict[str, Any] = {"businessRuleName": name, "input": input} - if _present(version): - body["version"] = version - if decision_names: - body["decisionNames"] = decision_names - wire_caller = _wire_caller(caller) - if wire_caller: - body["caller"] = wire_caller - return RequestSpec( - method="POST", - endpoint=_EVALUATE_ENDPOINT, - json=body, - headers={HEADER_FOLDER_KEY: folder_key}, - ) + def _evaluate_spec( + self, + name: str, + input: Dict[str, Any], + *, + folder_key: str, + version: Optional[str] = None, + decision_names: Optional[List[str]] = None, + caller: Optional[BusinessRuleCaller] = None, + ) -> RequestSpec: + # The service resolves the rule in this folder and files the run's trace + # and audit record under it. + body: Dict[str, Any] = {"businessRuleName": name, "input": input} + if _has_value(version): + body["version"] = version + if decision_names: + body["decisionNames"] = decision_names + caller_payload = _caller_payload(caller) + if caller_payload: + body["caller"] = caller_payload + return RequestSpec( + method="POST", + endpoint=_EVALUATE_ENDPOINT, + json=body, + headers={HEADER_FOLDER_KEY: folder_key}, + ) -def _wire_caller(caller: Optional[BusinessRuleCaller]) -> Dict[str, str]: - given = caller or BusinessRuleCaller() - values = { - "resourceKey": given.resource_key or UiPathConfig.process_uuid, - "runKey": given.run_key or UiPathConfig.job_key, - "folderKey": given.folder_key or UiPathConfig.folder_key, +def _require_folder_key(folder_key: Optional[str], folder_path: Optional[str]) -> str: + # A folder path that matches no folder looks up to None. + if folder_key and folder_key.strip(): + return folder_key + raise ValueError(f"No folder was found for folder_path '{folder_path}'") + + +def _caller_payload(caller: Optional[BusinessRuleCaller]) -> Dict[str, str]: + """Return the caller as the service names its fields, defaulting from the job. + + Blank fields are left out, and a caller with every field blank is returned + empty, so the request carries no caller at all. + """ + explicit = caller or BusinessRuleCaller() + fields = { + "resourceKey": explicit.resource_key or UiPathConfig.process_uuid, + "runKey": explicit.run_key or UiPathConfig.job_key, + "folderKey": explicit.folder_key or UiPathConfig.folder_key, } - # Blank fields are left out, and a caller with every field blank is no caller. - wire: Dict[str, str] = {} - for key, value in values.items(): + payload: Dict[str, str] = {} + for field_name, value in fields.items(): if value and value.strip(): - wire[key] = value - return wire + payload[field_name] = value + return payload -class _TraceHeaders(Dict[str, str]): +class _ExplicitTraceHeaders(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 @@ -277,38 +328,41 @@ def __setitem__(self, key: str, value: str) -> None: super().__setitem__(key, value) -def _with_trace( +def _headers_with_trace( headers: Dict[str, str], trace_context: Optional[TraceContext] ) -> Dict[str, str]: + """Return the headers, pinned to ``trace_context`` when the caller gave one.""" if trace_context is None: return headers - pinned = _TraceHeaders(headers) - dict.__setitem__(pinned, _TRACE_PARENT_HEADER, trace_context.to_traceparent()) - return pinned + pinned_headers = _ExplicitTraceHeaders(headers) + dict.__setitem__( + pinned_headers, _TRACE_PARENT_HEADER, trace_context.to_traceparent() + ) + return pinned_headers -def _present(value: Optional[str]) -> bool: +def _has_value(value: Optional[str]) -> bool: # Blank counts as absent, matching how the service reads these fields. return bool(value and value.strip()) -def _validate_run(name: str, input: Dict[str, Any]) -> None: - _validate_rule_name(name, "name") +def _validate_run_arguments(name: str, input: Dict[str, Any]) -> None: + _validate_rule_name(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"{field} must not exceed {_MAX_RULE_NAME_LENGTH} characters") - for forbidden in ("/", "\\", "..", "%"): - if forbidden in rule_name: - raise ValueError(f"{field} must not contain '{forbidden}'") +def _validate_rule_name(name: str) -> None: + if not _has_value(name): + raise ValueError("name must be specified") + if len(name) > _MAX_RULE_NAME_LENGTH: + raise ValueError(f"name must not exceed {_MAX_RULE_NAME_LENGTH} characters") + for forbidden_part in _FORBIDDEN_RULE_NAME_PARTS: + if forbidden_part in name: + raise ValueError(f"name must not contain '{forbidden_part}'") # Control characters only (Unicode category Cc), as the .NET client checks; # a non-breaking or zero-width space is allowed in a name. - if any(unicodedata.category(ch) == "Cc" for ch in rule_name): - raise ValueError(f"{field} must not contain control characters") + if any(unicodedata.category(character) == "Cc" for character in name): + raise ValueError("name must not contain control characters") def _validate_input(input: Dict[str, Any]) -> None: @@ -323,16 +377,16 @@ def _validate_input(input: Dict[str, Any]) -> None: raise ValueError(f"input must not exceed {_MAX_INPUT_KEYS} keys") -def _status( +def _overall_status( decisions: List[BusinessRuleDecision], errors: List[BusinessRuleError] ) -> BusinessRuleStatus: # An input-level error means the input never evaluated, whatever else came back. if errors: return BusinessRuleStatus.ALL_FAILED - failed = sum(1 for d in decisions if d.error is not None) - if failed == 0: + failed_count = sum(1 for decision in decisions if decision.error is not None) + if failed_count == 0: return BusinessRuleStatus.SUCCESS - if failed == len(decisions): + if failed_count == len(decisions): return BusinessRuleStatus.ALL_FAILED return BusinessRuleStatus.PARTIAL_SUCCESS @@ -345,7 +399,7 @@ def _to_run_result(response: _WireResponse) -> BusinessRuleRunResult: decisions = response.result.decisions or [] errors = response.result.errors or [] return BusinessRuleRunResult( - status=_status(decisions, errors), + status=_overall_status(decisions, errors), decisions=decisions, errors=errors, top_level_error=response.error.code if response.error else None, diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 16427c3fb..16a3ac7a0 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -266,6 +266,32 @@ def test_rejects_both_folder_key_and_path( with pytest.raises(ValueError, match="Only one of"): service.run(RULE, {}, folder_key=FOLDER_KEY, folder_path="Finance") + def test_rejects_a_folder_path_that_matches_no_folder( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + ) -> None: + folders_service.retrieve_folder_key.return_value = None + + with pytest.raises(ValueError, match="No folder was found for folder_path"): + service.run(RULE, {}, folder_path="Missing/Folder") + + assert httpx_mock.get_requests() == [] + + async def test_rejects_a_folder_path_that_matches_no_folder_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + ) -> None: + folders_service.retrieve_folder_key_async.return_value = None + + with pytest.raises(ValueError, match="No folder was found for folder_path"): + await service.run_async(RULE, {}, folder_path="Missing/Folder") + + assert httpx_mock.get_requests() == [] + class TestDeployed: def test_sends_single_input_and_maps_decisions( diff --git a/packages/uipath/docs/core/business_rules.md b/packages/uipath/docs/core/business_rules.md new file mode 100644 index 000000000..7ad8977e5 --- /dev/null +++ b/packages/uipath/docs/core/business_rules.md @@ -0,0 +1 @@ +::: uipath.platform.business_rules._business_rules_service \ No newline at end of file diff --git a/packages/uipath/mkdocs.yml b/packages/uipath/mkdocs.yml index 382e0fdd5..651d40d48 100644 --- a/packages/uipath/mkdocs.yml +++ b/packages/uipath/mkdocs.yml @@ -67,6 +67,7 @@ nav: - Assets: core/assets.md - Attachments: core/attachments.md - Buckets: core/buckets.md + - Business Rules: core/business_rules.md - Connections: core/connections.md - Context Grounding: core/context_grounding.md - Documents: core/documents.md From 2c8bcb032db262d467fa62a4889025e4c5da2d43 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 19:36:29 +0530 Subject: [PATCH 22/47] feat(business-rules): open no client span; the service traces the run run() and run_async() were @traced, so every run opened a business_rules_run span that only wrapped the decision and rule spans the service already records, and stored the rule's input and outputs on the client. The .NET client opens no spans and never records input data. The decorator is removed. The trace still reaches the service: x-uipath-traceparent-id names the caller's current span (or an explicit trace_context), so the service's spans nest directly under the caller's. Outside any trace no header is sent, and the service starts a trace of its own, as it does for a .NET caller that passes no trace. A test records spans with a real tracer provider and checks that only the caller's span exists and that it is the header's parent; with the decorator in place it fails on the extra span. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 7 +- .../services/test_business_rules_service.py | 85 ++++++++++++++++++- 2 files changed, 87 insertions(+), 5 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 fffd6d8b8..228f73b9a 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 @@ -7,8 +7,6 @@ from collections.abc import Mapping from typing import Any, Dict, List, Optional, Tuple -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, UiPathConfig @@ -50,7 +48,9 @@ def __init__( super().__init__(config=config, execution_context=execution_context) self._folders_service = folders_service - @traced(name="business_rules_run", run_type="uipath") + # Not @traced: the service records the run's decision spans under the + # caller's span, so a client span would only duplicate them and record the + # rule's input and outputs, which the .NET client never does either. def run( self, name: str, @@ -130,7 +130,6 @@ def run( ) return _to_run_result(_WireResponse.model_validate(response.json())) - @traced(name="business_rules_run", run_type="uipath") async def run_async( self, name: str, 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 16a3ac7a0..c6d9e1259 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -4,6 +4,11 @@ import pytest from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags from pydantic import ValidationError from pytest_httpx import HTTPXMock @@ -633,6 +638,17 @@ def ambient_span() -> Iterator[None]: yield +@pytest.fixture +def recorded_spans(monkeypatch: pytest.MonkeyPatch) -> Iterator[InMemorySpanExporter]: + """Record every span, including any @traced would open, in memory.""" + provider = TracerProvider() + exporter = InMemorySpanExporter() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setattr(trace, "get_tracer", provider.get_tracer) + yield exporter + provider.shutdown() + + class TestTraceContext: def test_explicit_trace_context_wins_over_ambient_span( self, @@ -693,7 +709,74 @@ def test_ambient_trace_is_used_without_trace_context( request = httpx_mock.get_request() assert request is not None - assert request.headers[TRACEPARENT].startswith(f"00-{AMBIENT_TRACE_ID}-") + # The caller's own span is the parent: the SDK opens no span of its own, + # so the service's spans nest directly under the caller's. + assert ( + request.headers[TRACEPARENT] + == f"00-{AMBIENT_TRACE_ID}-{AMBIENT_SPAN_ID}-01" + ) + + async def test_ambient_trace_is_used_without_trace_context_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=evaluate_url, json=_response()) + + await service.run_async(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{AMBIENT_TRACE_ID}-{AMBIENT_SPAN_ID}-01" + ) + + def test_opens_no_span_of_its_own( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + recorded_spans: InMemorySpanExporter, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + # The service records the run's spans itself, so the SDK adds none: the + # caller's span stays the parent, and the rule's input and outputs are not + # recorded on the client. + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=evaluate_url, json=_response(_one_decision(x=1))) + + with trace.get_tracer("test").start_as_current_span("caller") as caller_span: + service.run(RULE, {"creditScore": 740}, folder_key=FOLDER_KEY) + caller_context = caller_span.get_span_context() + + assert [span.name for span in recorded_spans.get_finished_spans()] == ["caller"] + request = httpx_mock.get_request() + assert request is not None + assert request.headers[TRACEPARENT] == ( + f"00-{caller_context.trace_id:032x}-{caller_context.span_id:016x}-01" + ) + + def test_sends_no_trace_header_outside_a_trace( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + # No current span and no trace_context: the service starts its own trace. + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert TRACEPARENT not in request.headers def test_override_does_not_leak_into_the_next_call( self, From a2f14f25f8882abeec574267a0c01a7186c9ae38 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 19:38:01 +0530 Subject: [PATCH 23/47] test(business-rules): a debug run opens no client span either Records spans with a real tracer provider and checks that a debug run, like a deployed one, leaves only the caller's span and names it in the trace header. Removing @traced also exposed run()'s real signature to mypy, which flagged a test passing **dict[str, str]; it now passes folder_key and folder_path explicitly. Co-Authored-By: Claude Opus 5.5 --- .../services/test_business_rules_service.py | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 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 35287de1a..7acbd33ae 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -1,5 +1,5 @@ import json -from typing import Any, Iterator +from typing import Any, Iterator, Optional from unittest.mock import AsyncMock, Mock import pytest @@ -1108,7 +1108,9 @@ def test_sends_no_folder_version_or_caller( assert "caller" not in body @pytest.mark.parametrize( - "folder", [{"folder_key": FOLDER_KEY}, {"folder_path": "Finance"}] + ("folder_key", "folder_path"), + [(FOLDER_KEY, None), (None, "Finance")], + ids=["folder_key", "folder_path"], ) def test_a_named_folder_runs_the_deployed_rule( self, @@ -1116,11 +1118,12 @@ def test_a_named_folder_runs_the_deployed_rule( service: BusinessRulesService, evaluate_url: str, studio_debug: None, - folder: dict[str, str], + folder_key: Optional[str], + folder_path: Optional[str], ) -> None: httpx_mock.add_response(url=evaluate_url, json=_response()) - service.run(RULE, {}, **folder) + service.run(RULE, {}, folder_key=folder_key, folder_path=folder_path) request = httpx_mock.get_request() assert request is not None @@ -1217,6 +1220,29 @@ def test_retries_like_any_platform_call( assert result.status == BusinessRuleStatus.SUCCESS assert len(httpx_mock.get_requests()) == 2 + def test_debug_run_opens_no_span_of_its_own( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + recorded_spans: InMemorySpanExporter, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=debug_url, json=_response(_one_decision(x=1))) + + with trace.get_tracer("test").start_as_current_span("caller") as caller_span: + service.run(RULE, {"age": 14}) + caller_context = caller_span.get_span_context() + + assert [span.name for span in recorded_spans.get_finished_spans()] == ["caller"] + request = httpx_mock.get_request() + assert request is not None + assert request.headers[TRACEPARENT] == ( + f"00-{caller_context.trace_id:032x}-{caller_context.span_id:016x}-01" + ) + def test_explicit_trace_context_on_debug_run( self, httpx_mock: HTTPXMock, From fa84d3969bec75174495f3c6434fde62653ffa15 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 19:50:01 +0530 Subject: [PATCH 24/47] refactor(business-rules): share run() and run_async()'s steps The two methods repeated the same body. What they share now lives in one place, and only the folder lookup and the request differ: - _prepare_run() applies a binding, validates the arguments and picks the folder, returning a _RunTarget: the rule name, and a folder key or a path still to look up. - _resolve_folder_key() / _resolve_folder_key_async() take that target. - _request_options() builds the request() arguments, and _to_run_result() parses the response body. The request() call itself stays in run() and run_async(): BaseService names the x-uipath-user-agent after the method that calls it, so moving it into a helper would report "_send" instead of the public method. A new test pins run_async's user agent, as one already did for run's. No behaviour change. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 116 +++++++++++------- .../services/test_business_rules_service.py | 18 +++ 2 files changed, 88 insertions(+), 46 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 228f73b9a..bf4a99946 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 @@ -5,6 +5,7 @@ import unicodedata from collections.abc import Mapping +from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple from ..common._base_service import _TRACE_PARENT_HEADER, BaseService @@ -32,6 +33,19 @@ _FORBIDDEN_RULE_NAME_PARTS = ("/", "\\", "..", "%") +@dataclass(frozen=True) +class _RunTarget: + """Where a run goes, settled before anything is sent. + + Exactly one of ``folder_key`` and ``folder_path`` is set: a key ready to + send, or a path still to look up. + """ + + name: str + folder_key: Optional[str] + folder_path: Optional[str] + + class BusinessRulesService(FolderContext, BaseService): """Service for running UiPath Business Rules. @@ -108,27 +122,19 @@ def run( print(decision.decision_name, decision.outputs) ``` """ - name, folder_key, folder_path = self._apply_binding( - name, folder_key, folder_path - ) - _validate_run_arguments(name, input) - resolved_folder_key = self._resolve_folder_key(folder_key, folder_path) + target = self._prepare_run(name, input, folder_key, folder_path) spec = self._evaluate_spec( - name, + target.name, input, - folder_key=resolved_folder_key, + folder_key=self._resolve_folder_key(target), version=version, decision_names=decision_names, caller=caller, ) - response = self.request( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=_headers_with_trace(spec.headers, trace_context), - scoped="tenant", - ) - return _to_run_result(_WireResponse.model_validate(response.json())) + # Called here, not in a helper: BaseService names the user agent after + # the method that calls request(), which must be the public run(). + response = self.request(spec.method, **_request_options(spec, trace_context)) + return _to_run_result(response.json()) async def run_async( self, @@ -165,29 +171,38 @@ 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_arguments(name, input) - resolved_folder_key = await self._resolve_folder_key_async( - folder_key, folder_path - ) + target = self._prepare_run(name, input, folder_key, folder_path) spec = self._evaluate_spec( - name, + target.name, input, - folder_key=resolved_folder_key, + folder_key=await self._resolve_folder_key_async(target), version=version, decision_names=decision_names, caller=caller, ) response = await self.request_async( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=_headers_with_trace(spec.headers, trace_context), - scoped="tenant", + spec.method, **_request_options(spec, trace_context) ) - return _to_run_result(_WireResponse.model_validate(response.json())) + return _to_run_result(response.json()) + + def _prepare_run( + self, + name: str, + input: Dict[str, Any], + folder_key: Optional[str], + folder_path: Optional[str], + ) -> _RunTarget: + """Do what run() and run_async() share before the first network call. + + Applies a binding, validates the arguments and picks the folder, so only + the folder lookup and the request itself differ between the two. + """ + name, folder_key, folder_path = self._apply_binding( + name, folder_key, folder_path + ) + _validate_run_arguments(name, input) + selected_key, selected_path = self._select_folder(folder_key, folder_path) + return _RunTarget(name=name, folder_key=selected_key, folder_path=selected_path) @resource_override(resource_type="businessRule") def _overridden_resource( @@ -240,25 +255,21 @@ def _select_folder( "folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" ) - def _resolve_folder_key( - self, folder_key: Optional[str], folder_path: Optional[str] - ) -> str: + def _resolve_folder_key(self, target: _RunTarget) -> str: """Return the key of the folder the rule runs in, looking up a path.""" - selected_key, selected_path = self._select_folder(folder_key, folder_path) - if selected_path: - selected_key = self._folders_service.retrieve_folder_key(selected_path) - return _require_folder_key(selected_key, selected_path) + folder_key = target.folder_key + if target.folder_path: + folder_key = self._folders_service.retrieve_folder_key(target.folder_path) + return _require_folder_key(folder_key, target.folder_path) - async def _resolve_folder_key_async( - self, folder_key: Optional[str], folder_path: Optional[str] - ) -> str: + async def _resolve_folder_key_async(self, target: _RunTarget) -> str: """Asynchronously return the key of the folder the rule runs in.""" - selected_key, selected_path = self._select_folder(folder_key, folder_path) - if selected_path: - selected_key = await self._folders_service.retrieve_folder_key_async( - selected_path + folder_key = target.folder_key + if target.folder_path: + folder_key = await self._folders_service.retrieve_folder_key_async( + target.folder_path ) - return _require_folder_key(selected_key, selected_path) + return _require_folder_key(folder_key, target.folder_path) def _evaluate_spec( self, @@ -288,6 +299,18 @@ def _evaluate_spec( ) +def _request_options( + spec: RequestSpec, trace_context: Optional[TraceContext] +) -> Dict[str, Any]: + """Return the request() arguments for a spec, besides its method.""" + return { + "url": spec.endpoint, + "json": spec.json, + "headers": _headers_with_trace(spec.headers, trace_context), + "scoped": "tenant", + } + + def _require_folder_key(folder_key: Optional[str], folder_path: Optional[str]) -> str: # A folder path that matches no folder looks up to None. if folder_key and folder_key.strip(): @@ -390,7 +413,8 @@ def _overall_status( return BusinessRuleStatus.PARTIAL_SUCCESS -def _to_run_result(response: _WireResponse) -> BusinessRuleRunResult: +def _to_run_result(response_body: Any) -> BusinessRuleRunResult: + response = _WireResponse.model_validate(response_body) # A successful response always carries the input's result; one without it is # not an answer to report as an evaluation. if response.result is None: 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 c6d9e1259..bf8c4dea7 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -378,6 +378,24 @@ def test_sends_no_explain_or_batch( assert "explain" not in body assert "inputs" not in body + async def test_run_async_names_itself_in_the_user_agent( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + version: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + await service.run_async(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.BusinessRulesService.run_async/{version}" + ) + async def test_run_async_resolves_folder_path( self, httpx_mock: HTTPXMock, From 39b9ea6741ca7904efd585ce1ae8ea8b40d579bd Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 20:08:09 +0530 Subject: [PATCH 25/47] refactor(business-rules): readable names for locals and internal parameters Renames only, no behaviour change: - run() / run_async(): target -> run_target, spec -> request_spec, response -> http_response. - _RunTarget.name -> rule_name; internal helpers take rule_name and rule_input. The public run(name, input) and the error messages that name those arguments are unchanged. - Request builders: body -> request_body. _caller_payload: given_caller, caller_fields, non_blank_fields, field_value. _ExplicitTraceHeaders: header_name / header_value. _to_run_result: wire_response. _has_value(text). - Models: _HEX -> _HEX_DIGITS, normalized -> normalized_trace_id / normalized_span_id. Error extractor: error -> error_envelope. - Test helpers: _response -> _service_response, _one_decision -> _single_decision_result. _overridden_resource keeps (name, folder_path): resource_override binds those parameters by name. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 173 +++++++++--------- .../platform/business_rules/business_rules.py | 18 +- .../errors/_extractors/_businessrules.py | 8 +- .../services/test_business_rules_service.py | 84 +++++---- 4 files changed, 149 insertions(+), 134 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 bf4a99946..73d6367c1 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 @@ -41,7 +41,7 @@ class _RunTarget: send, or a path still to look up. """ - name: str + rule_name: str folder_key: Optional[str] folder_path: Optional[str] @@ -122,19 +122,21 @@ def run( print(decision.decision_name, decision.outputs) ``` """ - target = self._prepare_run(name, input, folder_key, folder_path) - spec = self._evaluate_spec( - target.name, + run_target = self._prepare_run(name, input, folder_key, folder_path) + request_spec = self._evaluate_spec( + run_target.rule_name, input, - folder_key=self._resolve_folder_key(target), + folder_key=self._resolve_folder_key(run_target), version=version, decision_names=decision_names, caller=caller, ) # Called here, not in a helper: BaseService names the user agent after # the method that calls request(), which must be the public run(). - response = self.request(spec.method, **_request_options(spec, trace_context)) - return _to_run_result(response.json()) + http_response = self.request( + request_spec.method, **_request_options(request_spec, trace_context) + ) + return _to_run_result(http_response.json()) async def run_async( self, @@ -171,24 +173,24 @@ async def run_async( ValueError: If the request is invalid or a required folder is missing. EnrichedException: If the service rejects the request. """ - target = self._prepare_run(name, input, folder_key, folder_path) - spec = self._evaluate_spec( - target.name, + run_target = self._prepare_run(name, input, folder_key, folder_path) + request_spec = self._evaluate_spec( + run_target.rule_name, input, - folder_key=await self._resolve_folder_key_async(target), + folder_key=await self._resolve_folder_key_async(run_target), version=version, decision_names=decision_names, caller=caller, ) - response = await self.request_async( - spec.method, **_request_options(spec, trace_context) + http_response = await self.request_async( + request_spec.method, **_request_options(request_spec, trace_context) ) - return _to_run_result(response.json()) + return _to_run_result(http_response.json()) def _prepare_run( self, - name: str, - input: Dict[str, Any], + rule_name: str, + rule_input: Dict[str, Any], folder_key: Optional[str], folder_path: Optional[str], ) -> _RunTarget: @@ -197,12 +199,14 @@ def _prepare_run( Applies a binding, validates the arguments and picks the folder, so only the folder lookup and the request itself differ between the two. """ - name, folder_key, folder_path = self._apply_binding( - name, folder_key, folder_path + rule_name, folder_key, folder_path = self._apply_binding( + rule_name, folder_key, folder_path ) - _validate_run_arguments(name, input) + _validate_run_arguments(rule_name, rule_input) selected_key, selected_path = self._select_folder(folder_key, folder_path) - return _RunTarget(name=name, folder_key=selected_key, folder_path=selected_path) + return _RunTarget( + rule_name=rule_name, folder_key=selected_key, folder_path=selected_path + ) @resource_override(resource_type="businessRule") def _overridden_resource( @@ -213,7 +217,7 @@ def _overridden_resource( return name, folder_path def _apply_binding( - self, name: str, folder_key: Optional[str], folder_path: Optional[str] + self, rule_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. @@ -221,13 +225,13 @@ def _apply_binding( 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_folder_path = self._overridden_resource( - name, folder_path=folder_path + bound_rule_name, bound_folder_path = self._overridden_resource( + rule_name, folder_path=folder_path ) - is_remapped = (bound_name, bound_folder_path) != (name, folder_path) + is_remapped = (bound_rule_name, bound_folder_path) != (rule_name, folder_path) if is_remapped and bound_folder_path: folder_key = None - return bound_name, folder_key, bound_folder_path + return bound_rule_name, folder_key, bound_folder_path def _select_folder( self, folder_key: Optional[str], folder_path: Optional[str] @@ -255,26 +259,28 @@ def _select_folder( "folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" ) - def _resolve_folder_key(self, target: _RunTarget) -> str: + def _resolve_folder_key(self, run_target: _RunTarget) -> str: """Return the key of the folder the rule runs in, looking up a path.""" - folder_key = target.folder_key - if target.folder_path: - folder_key = self._folders_service.retrieve_folder_key(target.folder_path) - return _require_folder_key(folder_key, target.folder_path) + resolved_folder_key = run_target.folder_key + if run_target.folder_path: + resolved_folder_key = self._folders_service.retrieve_folder_key( + run_target.folder_path + ) + return _require_folder_key(resolved_folder_key, run_target.folder_path) - async def _resolve_folder_key_async(self, target: _RunTarget) -> str: + async def _resolve_folder_key_async(self, run_target: _RunTarget) -> str: """Asynchronously return the key of the folder the rule runs in.""" - folder_key = target.folder_key - if target.folder_path: - folder_key = await self._folders_service.retrieve_folder_key_async( - target.folder_path + resolved_folder_key = run_target.folder_key + if run_target.folder_path: + resolved_folder_key = await self._folders_service.retrieve_folder_key_async( + run_target.folder_path ) - return _require_folder_key(folder_key, target.folder_path) + return _require_folder_key(resolved_folder_key, run_target.folder_path) def _evaluate_spec( self, - name: str, - input: Dict[str, Any], + rule_name: str, + rule_input: Dict[str, Any], *, folder_key: str, version: Optional[str] = None, @@ -283,30 +289,33 @@ def _evaluate_spec( ) -> RequestSpec: # The service resolves the rule in this folder and files the run's trace # and audit record under it. - body: Dict[str, Any] = {"businessRuleName": name, "input": input} + request_body: Dict[str, Any] = { + "businessRuleName": rule_name, + "input": rule_input, + } if _has_value(version): - body["version"] = version + request_body["version"] = version if decision_names: - body["decisionNames"] = decision_names + request_body["decisionNames"] = decision_names caller_payload = _caller_payload(caller) if caller_payload: - body["caller"] = caller_payload + request_body["caller"] = caller_payload return RequestSpec( method="POST", endpoint=_EVALUATE_ENDPOINT, - json=body, + json=request_body, headers={HEADER_FOLDER_KEY: folder_key}, ) def _request_options( - spec: RequestSpec, trace_context: Optional[TraceContext] + request_spec: RequestSpec, trace_context: Optional[TraceContext] ) -> Dict[str, Any]: """Return the request() arguments for a spec, besides its method.""" return { - "url": spec.endpoint, - "json": spec.json, - "headers": _headers_with_trace(spec.headers, trace_context), + "url": request_spec.endpoint, + "json": request_spec.json, + "headers": _headers_with_trace(request_spec.headers, trace_context), "scoped": "tenant", } @@ -324,17 +333,17 @@ def _caller_payload(caller: Optional[BusinessRuleCaller]) -> Dict[str, str]: Blank fields are left out, and a caller with every field blank is returned empty, so the request carries no caller at all. """ - explicit = caller or BusinessRuleCaller() - fields = { - "resourceKey": explicit.resource_key or UiPathConfig.process_uuid, - "runKey": explicit.run_key or UiPathConfig.job_key, - "folderKey": explicit.folder_key or UiPathConfig.folder_key, + given_caller = caller or BusinessRuleCaller() + caller_fields = { + "resourceKey": given_caller.resource_key or UiPathConfig.process_uuid, + "runKey": given_caller.run_key or UiPathConfig.job_key, + "folderKey": given_caller.folder_key or UiPathConfig.folder_key, } - payload: Dict[str, str] = {} - for field_name, value in fields.items(): - if value and value.strip(): - payload[field_name] = value - return payload + non_blank_fields: Dict[str, str] = {} + for field_name, field_value in caller_fields.items(): + if field_value and field_value.strip(): + non_blank_fields[field_name] = field_value + return non_blank_fields class _ExplicitTraceHeaders(Dict[str, str]): @@ -344,10 +353,10 @@ class _ExplicitTraceHeaders(Dict[str, str]): before sending; this dict ignores that write when an explicit one is set. """ - def __setitem__(self, key: str, value: str) -> None: - if key == _TRACE_PARENT_HEADER and key in self: + def __setitem__(self, header_name: str, header_value: str) -> None: + if header_name == _TRACE_PARENT_HEADER and header_name in self: return - super().__setitem__(key, value) + super().__setitem__(header_name, header_value) def _headers_with_trace( @@ -363,39 +372,39 @@ def _headers_with_trace( return pinned_headers -def _has_value(value: Optional[str]) -> bool: +def _has_value(text: Optional[str]) -> bool: # Blank counts as absent, matching how the service reads these fields. - return bool(value and value.strip()) + return bool(text and text.strip()) -def _validate_run_arguments(name: str, input: Dict[str, Any]) -> None: - _validate_rule_name(name) - _validate_input(input) +def _validate_run_arguments(rule_name: str, rule_input: Dict[str, Any]) -> None: + _validate_rule_name(rule_name) + _validate_input(rule_input) -def _validate_rule_name(name: str) -> None: - if not _has_value(name): +def _validate_rule_name(rule_name: str) -> None: + if not _has_value(rule_name): raise ValueError("name must be specified") - if len(name) > _MAX_RULE_NAME_LENGTH: + if len(rule_name) > _MAX_RULE_NAME_LENGTH: raise ValueError(f"name must not exceed {_MAX_RULE_NAME_LENGTH} characters") for forbidden_part in _FORBIDDEN_RULE_NAME_PARTS: - if forbidden_part in name: + if forbidden_part in rule_name: raise ValueError(f"name must not contain '{forbidden_part}'") # Control characters only (Unicode category Cc), as the .NET client checks; # a non-breaking or zero-width space is allowed in a name. - if any(unicodedata.category(character) == "Cc" for character in name): + if any(unicodedata.category(character) == "Cc" for character in rule_name): raise ValueError("name must not contain control characters") -def _validate_input(input: Dict[str, Any]) -> None: - if input is None: +def _validate_input(rule_input: Dict[str, Any]) -> None: + if rule_input is None: raise ValueError("input must not be None") - if not isinstance(input, Mapping): + if not isinstance(rule_input, Mapping): raise ValueError( "input must be a mapping of the rule's input names to values, " - f"not {type(input).__name__}" + f"not {type(rule_input).__name__}" ) - if len(input) > _MAX_INPUT_KEYS: + if len(rule_input) > _MAX_INPUT_KEYS: raise ValueError(f"input must not exceed {_MAX_INPUT_KEYS} keys") @@ -414,18 +423,18 @@ def _overall_status( def _to_run_result(response_body: Any) -> BusinessRuleRunResult: - response = _WireResponse.model_validate(response_body) + wire_response = _WireResponse.model_validate(response_body) # A successful response always carries the input's result; one without it is # not an answer to report as an evaluation. - if response.result is None: + if wire_response.result is None: raise ValueError("The business rules response did not include a result") - decisions = response.result.decisions or [] - errors = response.result.errors or [] + decisions = wire_response.result.decisions or [] + errors = wire_response.result.errors or [] return BusinessRuleRunResult( status=_overall_status(decisions, errors), decisions=decisions, errors=errors, - top_level_error=response.error.code if response.error else None, - business_rule_name=response.business_rule_name, - version=response.version, + top_level_error=wire_response.error.code if wire_response.error else None, + business_rule_name=wire_response.business_rule_name, + version=wire_response.version, ) 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 44eeb468b..bc0fec093 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 @@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -_HEX = re.compile(r"^[0-9a-f]+$") +_HEX_DIGITS = re.compile(r"^[0-9a-f]+$") class BusinessRuleStatus(str, Enum): @@ -56,22 +56,22 @@ class TraceContext(BaseModel): @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): + normalized_trace_id = value.replace("-", "").strip().lower() + if len(normalized_trace_id) != 32 or not _HEX_DIGITS.match(normalized_trace_id): raise ValueError("trace_id must be 32 hex characters or a UUID") - if normalized == "0" * 32: + if normalized_trace_id == "0" * 32: raise ValueError("trace_id must not be all zeros") - return normalized + return normalized_trace_id @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): + normalized_span_id = value.strip().lower() + if len(normalized_span_id) != 16 or not _HEX_DIGITS.match(normalized_span_id): raise ValueError("parent_span_id must be 16 hex characters") - if normalized == "0" * 16: + if normalized_span_id == "0" * 16: raise ValueError("parent_span_id must not be all zeros") - return normalized + return normalized_span_id def to_traceparent(self) -> str: """Return the W3C traceparent value for this context.""" diff --git a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py index 9d0fddf1e..6f7251eb7 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py @@ -13,13 +13,13 @@ def extract_businessrules(body: dict[str, Any]) -> ExtractedErrorInfo: - error = get_field(body, "error") - if not isinstance(error, dict): + error_envelope = get_field(body, "error") + if not isinstance(error_envelope, dict): # Not the service's envelope, e.g. an error answered by the gateway. return extract_generic(body) return ExtractedErrorInfo( - message=get_typed_field(error, str, "message"), - error_code=get_str_field(error, "code"), + message=get_typed_field(error_envelope, str, "message"), + error_code=get_str_field(error_envelope, "code"), trace_id=get_typed_field(body, str, "traceId", "requestId"), ) diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index bf8c4dea7..056d25936 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -65,13 +65,15 @@ def evaluate_url(base_url: str, org: str, tenant: str) -> str: return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/evaluate" -def _response(result: dict[str, Any] | None = None, **extra: Any) -> dict[str, Any]: +def _service_response( + result: dict[str, Any] | None = None, **extra: Any +) -> dict[str, Any]: body: dict[str, Any] = {"meta": {"timestamp": "2026-09-28T00:00:00Z"}, **extra} body["result"] = {"decisions": []} if result is None else result return body -def _one_decision(**outputs: Any) -> dict[str, Any]: +def _single_decision_result(**outputs: Any) -> dict[str, Any]: return {"decisions": [{"decisionName": "RiskGrade", "outputs": outputs}]} @@ -110,7 +112,7 @@ def test_accepts_names_the_dotnet_client_accepts( ) -> None: # Only control characters are refused, as in the .NET client: a # non-breaking space pasted into a name is not one. - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(rule_name, {}, folder_key=FOLDER_KEY) @@ -140,7 +142,7 @@ def test_resolves_folder_path_to_key( folders_service: Mock, evaluate_url: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {}, folder_path="Finance/Loans") @@ -161,7 +163,7 @@ def test_falls_back_to_env_folder_key( ) -> None: monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {}) @@ -181,7 +183,7 @@ def test_falls_back_to_env_folder_path( ) -> None: monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {}) @@ -202,7 +204,7 @@ async def test_falls_back_to_env_folder_path_async( ) -> None: monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) await service.run_async(RULE, {}) @@ -226,7 +228,7 @@ def test_env_folder_key_wins_over_env_folder_path( monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") service = BusinessRulesService(config, execution_context, folders_service) - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {}) @@ -257,7 +259,7 @@ def test_sends_no_organization_unit_id( service: BusinessRulesService, evaluate_url: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {}, folder_key=FOLDER_KEY) @@ -309,8 +311,8 @@ def test_sends_single_input_and_maps_decisions( httpx_mock.add_response( url=evaluate_url, method="POST", - json=_response( - _one_decision(Grade="B", Rate=3.5), + json=_service_response( + _single_decision_result(Grade="B", Rate=3.5), businessRuleName="Loan Pricing", version="1.0.3", ), @@ -353,7 +355,7 @@ def test_omits_optional_fields( service: BusinessRulesService, evaluate_url: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {}, version=" ", folder_key=FOLDER_KEY) @@ -368,7 +370,7 @@ def test_sends_no_explain_or_batch( service: BusinessRulesService, evaluate_url: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {"a": 1}, folder_key=FOLDER_KEY) @@ -385,7 +387,7 @@ async def test_run_async_names_itself_in_the_user_agent( evaluate_url: str, version: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) await service.run_async(RULE, {}, folder_key=FOLDER_KEY) @@ -403,7 +405,9 @@ async def test_run_async_resolves_folder_path( folders_service: Mock, evaluate_url: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response(_one_decision(x=1))) + httpx_mock.add_response( + url=evaluate_url, json=_service_response(_single_decision_result(x=1)) + ) result = await service.run_async(RULE, {"a": 1}, folder_path="Finance") @@ -425,7 +429,7 @@ def test_defaults_from_the_environment( monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) monkeypatch.setenv("UIPATH_FOLDER_KEY", "caller-folder-key") - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {}, folder_key=FOLDER_KEY) @@ -448,7 +452,7 @@ def test_explicit_fields_win_over_the_environment( ) -> None: monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run( RULE, @@ -471,7 +475,7 @@ def test_blank_fields_are_left_out( service: BusinessRulesService, evaluate_url: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run( RULE, @@ -490,7 +494,7 @@ def test_no_caller_without_any_value( service: BusinessRulesService, evaluate_url: str, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {}, folder_key=FOLDER_KEY) @@ -509,7 +513,7 @@ def test_partial_success_on_207( httpx_mock.add_response( url=evaluate_url, status_code=207, - json=_response( + json=_service_response( { "decisions": [ {"decisionName": "RiskGrade", "outputs": {"Grade": "B"}}, @@ -540,7 +544,7 @@ def test_every_decision_failing_is_all_failed( httpx_mock.add_response( url=evaluate_url, status_code=207, - json=_response( + json=_service_response( { "decisions": [ { @@ -565,7 +569,7 @@ def test_input_level_error_is_all_failed( httpx_mock.add_response( url=evaluate_url, status_code=207, - json=_response( + json=_service_response( { "errors": [ { @@ -591,7 +595,7 @@ def test_carries_the_top_level_error( httpx_mock.add_response( url=evaluate_url, status_code=207, - json=_response( + json=_service_response( {"errors": [{"code": "INPUT_FAILED", "message": "x"}]}, error={"code": "UPSTREAM_ERROR", "message": "y"}, ), @@ -675,7 +679,7 @@ def test_explicit_trace_context_wins_over_ambient_span( evaluate_url: str, ambient_span: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -698,7 +702,7 @@ def test_explicit_trace_context_wins_over_uipath_trace_id( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("UIPATH_TRACE_ID", AMBIENT_TRACE_ID) - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -721,7 +725,7 @@ def test_ambient_trace_is_used_without_trace_context( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {}, folder_key=FOLDER_KEY) @@ -743,7 +747,7 @@ async def test_ambient_trace_is_used_without_trace_context_async( 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=_service_response()) await service.run_async(RULE, {}, folder_key=FOLDER_KEY) @@ -766,7 +770,9 @@ def test_opens_no_span_of_its_own( # caller's span stays the parent, and the rule's input and outputs are not # recorded on the client. monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) - httpx_mock.add_response(url=evaluate_url, json=_response(_one_decision(x=1))) + httpx_mock.add_response( + url=evaluate_url, json=_service_response(_single_decision_result(x=1)) + ) with trace.get_tracer("test").start_as_current_span("caller") as caller_span: service.run(RULE, {"creditScore": 740}, folder_key=FOLDER_KEY) @@ -788,7 +794,7 @@ def test_sends_no_trace_header_outside_a_trace( ) -> None: # No current span and no trace_context: the service starts its own trace. monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {}, folder_key=FOLDER_KEY) @@ -805,8 +811,8 @@ def test_override_does_not_leak_into_the_next_call( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) - httpx_mock.add_response(url=evaluate_url, json=_response()) - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -825,7 +831,7 @@ async def test_explicit_trace_context_async( evaluate_url: str, ambient_span: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -847,7 +853,7 @@ def test_explicit_trace_context_survives_a_retry( ambient_span: None, ) -> None: httpx_mock.add_response(url=evaluate_url, status_code=503) - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) explicit = TraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -918,7 +924,7 @@ def test_override_replaces_rule_name_and_folder( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {}, folder_path="Finance") @@ -936,7 +942,7 @@ async def test_override_applies_to_run_async( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) await service.run_async(RULE, {}, folder_path="Finance") @@ -953,7 +959,7 @@ def test_override_folder_replaces_callers_folder_key( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(RULE, {}, folder_key="callers-folder-key") @@ -971,7 +977,7 @@ async def test_override_folder_replaces_callers_folder_key_async( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) await service.run_async(RULE, {}, folder_key="callers-folder-key") @@ -989,7 +995,7 @@ def test_callers_folder_key_kept_without_a_matching_override( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run("Risk Tier", {}, folder_key="callers-folder-key") @@ -1006,7 +1012,7 @@ def test_other_rules_are_not_overridden( evaluate_url: str, rule_override: None, ) -> None: - httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run("Risk Tier", {}, folder_path="Finance") From 1fb0b6ead10cfec6af7bae76acd81f573166dcb1 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 20:10:00 +0530 Subject: [PATCH 26/47] test(business-rules): name the test helper's response body Co-Authored-By: Claude Opus 5.5 --- .../tests/services/test_business_rules_service.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 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 056d25936..b1b269e58 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -68,9 +68,12 @@ def evaluate_url(base_url: str, org: str, tenant: str) -> str: def _service_response( result: dict[str, Any] | None = None, **extra: Any ) -> dict[str, Any]: - body: dict[str, Any] = {"meta": {"timestamp": "2026-09-28T00:00:00Z"}, **extra} - body["result"] = {"decisions": []} if result is None else result - return body + response_body: dict[str, Any] = { + "meta": {"timestamp": "2026-09-28T00:00:00Z"}, + **extra, + } + response_body["result"] = {"decisions": []} if result is None else result + return response_body def _single_decision_result(**outputs: Any) -> dict[str, Any]: From 4ea997f8dd933e98bcd74bc2dc066ffcb188005b Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 20:14:04 +0530 Subject: [PATCH 27/47] refactor(business-rules): rule_name -> business_rule_name Internal parameters, locals, _RunTarget's field, the name validator (_validate_business_rule_name) and its constants now say business_rule_name, matching the wire's businessRuleName and BusinessRuleRunResult.business_rule_name. The public run(name, input) and its error messages are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 64 +++++++++++-------- .../services/test_business_rules_service.py | 14 ++-- 2 files changed, 45 insertions(+), 33 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 73d6367c1..3d6d78003 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,8 +29,8 @@ _EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/evaluate") _MAX_INPUT_KEYS = 256 -_MAX_RULE_NAME_LENGTH = 256 -_FORBIDDEN_RULE_NAME_PARTS = ("/", "\\", "..", "%") +_MAX_BUSINESS_RULE_NAME_LENGTH = 256 +_FORBIDDEN_BUSINESS_RULE_NAME_PARTS = ("/", "\\", "..", "%") @dataclass(frozen=True) @@ -41,7 +41,7 @@ class _RunTarget: send, or a path still to look up. """ - rule_name: str + business_rule_name: str folder_key: Optional[str] folder_path: Optional[str] @@ -124,7 +124,7 @@ def run( """ run_target = self._prepare_run(name, input, folder_key, folder_path) request_spec = self._evaluate_spec( - run_target.rule_name, + run_target.business_rule_name, input, folder_key=self._resolve_folder_key(run_target), version=version, @@ -175,7 +175,7 @@ async def run_async( """ run_target = self._prepare_run(name, input, folder_key, folder_path) request_spec = self._evaluate_spec( - run_target.rule_name, + run_target.business_rule_name, input, folder_key=await self._resolve_folder_key_async(run_target), version=version, @@ -189,7 +189,7 @@ async def run_async( def _prepare_run( self, - rule_name: str, + business_rule_name: str, rule_input: Dict[str, Any], folder_key: Optional[str], folder_path: Optional[str], @@ -199,13 +199,15 @@ def _prepare_run( Applies a binding, validates the arguments and picks the folder, so only the folder lookup and the request itself differ between the two. """ - rule_name, folder_key, folder_path = self._apply_binding( - rule_name, folder_key, folder_path + business_rule_name, folder_key, folder_path = self._apply_binding( + business_rule_name, folder_key, folder_path ) - _validate_run_arguments(rule_name, rule_input) + _validate_run_arguments(business_rule_name, rule_input) selected_key, selected_path = self._select_folder(folder_key, folder_path) return _RunTarget( - rule_name=rule_name, folder_key=selected_key, folder_path=selected_path + business_rule_name=business_rule_name, + folder_key=selected_key, + folder_path=selected_path, ) @resource_override(resource_type="businessRule") @@ -217,7 +219,10 @@ def _overridden_resource( return name, folder_path def _apply_binding( - self, rule_name: str, folder_key: Optional[str], folder_path: Optional[str] + self, + business_rule_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. @@ -225,13 +230,16 @@ def _apply_binding( 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_rule_name, bound_folder_path = self._overridden_resource( - rule_name, folder_path=folder_path + bound_business_rule_name, bound_folder_path = self._overridden_resource( + business_rule_name, folder_path=folder_path + ) + is_remapped = (bound_business_rule_name, bound_folder_path) != ( + business_rule_name, + folder_path, ) - is_remapped = (bound_rule_name, bound_folder_path) != (rule_name, folder_path) if is_remapped and bound_folder_path: folder_key = None - return bound_rule_name, folder_key, bound_folder_path + return bound_business_rule_name, folder_key, bound_folder_path def _select_folder( self, folder_key: Optional[str], folder_path: Optional[str] @@ -279,7 +287,7 @@ async def _resolve_folder_key_async(self, run_target: _RunTarget) -> str: def _evaluate_spec( self, - rule_name: str, + business_rule_name: str, rule_input: Dict[str, Any], *, folder_key: str, @@ -290,7 +298,7 @@ def _evaluate_spec( # The service resolves the rule in this folder and files the run's trace # and audit record under it. request_body: Dict[str, Any] = { - "businessRuleName": rule_name, + "businessRuleName": business_rule_name, "input": rule_input, } if _has_value(version): @@ -377,22 +385,26 @@ def _has_value(text: Optional[str]) -> bool: return bool(text and text.strip()) -def _validate_run_arguments(rule_name: str, rule_input: Dict[str, Any]) -> None: - _validate_rule_name(rule_name) +def _validate_run_arguments( + business_rule_name: str, rule_input: Dict[str, Any] +) -> None: + _validate_business_rule_name(business_rule_name) _validate_input(rule_input) -def _validate_rule_name(rule_name: str) -> None: - if not _has_value(rule_name): +def _validate_business_rule_name(business_rule_name: str) -> None: + if not _has_value(business_rule_name): raise ValueError("name must be specified") - if len(rule_name) > _MAX_RULE_NAME_LENGTH: - raise ValueError(f"name must not exceed {_MAX_RULE_NAME_LENGTH} characters") - for forbidden_part in _FORBIDDEN_RULE_NAME_PARTS: - if forbidden_part in rule_name: + if len(business_rule_name) > _MAX_BUSINESS_RULE_NAME_LENGTH: + raise ValueError( + f"name must not exceed {_MAX_BUSINESS_RULE_NAME_LENGTH} characters" + ) + for forbidden_part in _FORBIDDEN_BUSINESS_RULE_NAME_PARTS: + if forbidden_part in business_rule_name: raise ValueError(f"name must not contain '{forbidden_part}'") # Control characters only (Unicode category Cc), as the .NET client checks; # a non-breaking or zero-width space is allowed in a name. - if any(unicodedata.category(character) == "Cc" for character in rule_name): + if any(unicodedata.category(character) == "Cc" for character in business_rule_name): raise ValueError("name must not contain control characters") 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 b1b269e58..8345f53a5 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -82,7 +82,7 @@ def _single_decision_result(**outputs: Any) -> dict[str, Any]: class TestRunContext: @pytest.mark.parametrize( - "rule_name", + "business_rule_name", [ "", " ", @@ -97,13 +97,13 @@ class TestRunContext: ], ) def test_rejects_unsafe_rule_names( - self, service: BusinessRulesService, rule_name: str + self, service: BusinessRulesService, business_rule_name: str ) -> None: with pytest.raises(ValueError, match="name"): - service.run(rule_name, {}, folder_key=FOLDER_KEY) + service.run(business_rule_name, {}, folder_key=FOLDER_KEY) @pytest.mark.parametrize( - "rule_name", + "business_rule_name", ["Loan\u00a0Pricing", "Loan\u200bPricing", "Préstamo Tarifa", "贷款定价"], ) def test_accepts_names_the_dotnet_client_accepts( @@ -111,17 +111,17 @@ def test_accepts_names_the_dotnet_client_accepts( httpx_mock: HTTPXMock, service: BusinessRulesService, evaluate_url: str, - rule_name: str, + business_rule_name: str, ) -> None: # Only control characters are refused, as in the .NET client: a # non-breaking space pasted into a name is not one. httpx_mock.add_response(url=evaluate_url, json=_service_response()) - service.run(rule_name, {}, folder_key=FOLDER_KEY) + service.run(business_rule_name, {}, folder_key=FOLDER_KEY) request = httpx_mock.get_request() assert request is not None - assert json.loads(request.content)["businessRuleName"] == rule_name + assert json.loads(request.content)["businessRuleName"] == business_rule_name @pytest.mark.parametrize("value", [["age", 14], "age=14", 14]) def test_rejects_non_mapping_input( From be42dc66826cac29b6d02f459cabc5145e242f91 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 20:19:30 +0530 Subject: [PATCH 28/47] refactor(business-rules): name the rule's input input throughout The internal helpers take it as input, the same name as run()'s parameter. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 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 3d6d78003..7a138f8bc 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 @@ -190,7 +190,7 @@ async def run_async( def _prepare_run( self, business_rule_name: str, - rule_input: Dict[str, Any], + input: Dict[str, Any], folder_key: Optional[str], folder_path: Optional[str], ) -> _RunTarget: @@ -202,7 +202,7 @@ def _prepare_run( business_rule_name, folder_key, folder_path = self._apply_binding( business_rule_name, folder_key, folder_path ) - _validate_run_arguments(business_rule_name, rule_input) + _validate_run_arguments(business_rule_name, input) selected_key, selected_path = self._select_folder(folder_key, folder_path) return _RunTarget( business_rule_name=business_rule_name, @@ -288,7 +288,7 @@ async def _resolve_folder_key_async(self, run_target: _RunTarget) -> str: def _evaluate_spec( self, business_rule_name: str, - rule_input: Dict[str, Any], + input: Dict[str, Any], *, folder_key: str, version: Optional[str] = None, @@ -299,7 +299,7 @@ def _evaluate_spec( # and audit record under it. request_body: Dict[str, Any] = { "businessRuleName": business_rule_name, - "input": rule_input, + "input": input, } if _has_value(version): request_body["version"] = version @@ -385,11 +385,9 @@ def _has_value(text: Optional[str]) -> bool: return bool(text and text.strip()) -def _validate_run_arguments( - business_rule_name: str, rule_input: Dict[str, Any] -) -> None: +def _validate_run_arguments(business_rule_name: str, input: Dict[str, Any]) -> None: _validate_business_rule_name(business_rule_name) - _validate_input(rule_input) + _validate_input(input) def _validate_business_rule_name(business_rule_name: str) -> None: @@ -408,15 +406,15 @@ def _validate_business_rule_name(business_rule_name: str) -> None: raise ValueError("name must not contain control characters") -def _validate_input(rule_input: Dict[str, Any]) -> None: - if rule_input is None: +def _validate_input(input: Dict[str, Any]) -> None: + if input is None: raise ValueError("input must not be None") - if not isinstance(rule_input, Mapping): + if not isinstance(input, Mapping): raise ValueError( "input must be a mapping of the rule's input names to values, " - f"not {type(rule_input).__name__}" + f"not {type(input).__name__}" ) - if len(rule_input) > _MAX_INPUT_KEYS: + if len(input) > _MAX_INPUT_KEYS: raise ValueError(f"input must not exceed {_MAX_INPUT_KEYS} keys") From 5e22ba7fe91ab5026aace3600da486114a390ca1 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 20:22:04 +0530 Subject: [PATCH 29/47] refactor(business-rules): http_response -> response Co-Authored-By: Claude Opus 5.5 --- .../platform/business_rules/_business_rules_service.py | 8 ++++---- 1 file changed, 4 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 089b1c595..bf457f7f8 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 @@ -156,10 +156,10 @@ def run( ) # Called here, not in a helper: BaseService names the user agent after # the method that calls request(), which must be the public run(). - http_response = self.request( + response = self.request( request_spec.method, **_request_options(request_spec, trace_context) ) - return _to_run_result(http_response.json()) + return _to_run_result(response.json()) async def run_async( self, @@ -207,10 +207,10 @@ async def run_async( decision_names=decision_names, caller=caller, ) - http_response = await self.request_async( + response = await self.request_async( request_spec.method, **_request_options(request_spec, trace_context) ) - return _to_run_result(http_response.json()) + return _to_run_result(response.json()) def _prepare_run( self, From df38c886fa81ebd138094320fdf3fa96dd0deadb Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 20:22:34 +0530 Subject: [PATCH 30/47] refactor(business-rules): http_response -> response Co-Authored-By: Claude Opus 5.5 --- .../platform/business_rules/_business_rules_service.py | 8 ++++---- 1 file changed, 4 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 7a138f8bc..a58c2975f 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 @@ -133,10 +133,10 @@ def run( ) # Called here, not in a helper: BaseService names the user agent after # the method that calls request(), which must be the public run(). - http_response = self.request( + response = self.request( request_spec.method, **_request_options(request_spec, trace_context) ) - return _to_run_result(http_response.json()) + return _to_run_result(response.json()) async def run_async( self, @@ -182,10 +182,10 @@ async def run_async( decision_names=decision_names, caller=caller, ) - http_response = await self.request_async( + response = await self.request_async( request_spec.method, **_request_options(request_spec, trace_context) ) - return _to_run_result(http_response.json()) + return _to_run_result(response.json()) def _prepare_run( self, From 4a2c2ec5ef309c10ec2fd6bb0edb4457d4695f37 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 21:14:41 +0530 Subject: [PATCH 31/47] fix(business-rules): check rule names with the service's own allowlist Answers the review question of whether the name check matches what the platform enforces: - Orchestrator only bounds a name when the rule is created: [Required], [MaxLength(100)] (LengthRestrictions.BusinessRuleName), no character rules. - The business-rules service, on both the deployed and the debug path, runs BusinessRuleNames.requireSafe: letters and numbers in any script (\p{L}, \p{N}), space and '._()[]{}+,&@!~=:;-, at most 256, and never "..". The SDK used a blocklist (/ \ .. % and control characters), looser than the service, so names like "Loan#1", "Rule?" or one with a non-breaking space passed it and then failed at the service with a 400. It now applies the service's allowlist, so both accept exactly the same names and a bad one fails before anything is sent. The error names the characters it refused. Checked against alpha: 24 names sent straight to the service, skipping the SDK check, and the SDK agreed with the service on every one. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 35 ++++++++++---- .../services/test_business_rules_service.py | 46 +++++++++++++++---- 2 files changed, 65 insertions(+), 16 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 a58c2975f..b42e88cf7 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 @@ -30,7 +30,9 @@ _MAX_INPUT_KEYS = 256 _MAX_BUSINESS_RULE_NAME_LENGTH = 256 -_FORBIDDEN_BUSINESS_RULE_NAME_PARTS = ("/", "\\", "..", "%") +# The service's own rule (BusinessRuleNames in the business-rules service): a +# name may hold letters, numbers and these marks, and never "..". +_ALLOWED_BUSINESS_RULE_NAME_MARKS = frozenset(" '._()[]{}+,&@!~=:;-") @dataclass(frozen=True) @@ -397,13 +399,30 @@ def _validate_business_rule_name(business_rule_name: str) -> None: raise ValueError( f"name must not exceed {_MAX_BUSINESS_RULE_NAME_LENGTH} characters" ) - for forbidden_part in _FORBIDDEN_BUSINESS_RULE_NAME_PARTS: - if forbidden_part in business_rule_name: - raise ValueError(f"name must not contain '{forbidden_part}'") - # Control characters only (Unicode category Cc), as the .NET client checks; - # a non-breaking or zero-width space is allowed in a name. - if any(unicodedata.category(character) == "Cc" for character in business_rule_name): - raise ValueError("name must not contain control characters") + disallowed_characters = sorted( + { + character + for character in business_rule_name + if not _is_allowed_business_rule_name_character(character) + } + ) + if disallowed_characters: + raise ValueError( + "name may contain only letters, numbers, spaces and " + f"{''.join(sorted(_ALLOWED_BUSINESS_RULE_NAME_MARKS - {' '}))}; " + f"found {', '.join(repr(character) for character in disallowed_characters)}" + ) + if ".." in business_rule_name: + raise ValueError("name must not contain '..'") + + +def _is_allowed_business_rule_name_character(character: str) -> bool: + # Letters and numbers in any script (Unicode categories L* and N*), as the + # service's \p{L} and \p{N} match them. + return ( + unicodedata.category(character)[0] in ("L", "N") + or character in _ALLOWED_BUSINESS_RULE_NAME_MARKS + ) def _validate_input(input: Dict[str, Any]) -> None: diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 8345f53a5..a4721b57e 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -86,35 +86,65 @@ class TestRunContext: [ "", " ", + "x" * 257, + "a..b", + # Outside the service's allowlist "a/b", "a\\b", - "a..b", "a%20b", + "Loan#1", + "Rule?", + "Rate*2", + "Loan$", + 'a"b', + "a|b", + "Loan \U0001f680", + "Loan\u00a0Pricing", + "Loan\u200bPricing", "a\nb", "a\x00b", "a\x7fb", - "x" * 257, ], ) - def test_rejects_unsafe_rule_names( - self, service: BusinessRulesService, business_rule_name: str + def test_rejects_names_the_service_rejects( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + business_rule_name: str, ) -> None: with pytest.raises(ValueError, match="name"): service.run(business_rule_name, {}, folder_key=FOLDER_KEY) + assert httpx_mock.get_requests() == [] + + def test_names_the_disallowed_characters( + self, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match=r"found '#', '\?'"): + service.run("Loan#1?", {}, folder_key=FOLDER_KEY) + @pytest.mark.parametrize( "business_rule_name", - ["Loan\u00a0Pricing", "Loan\u200bPricing", "Préstamo Tarifa", "贷款定价"], + [ + "Loan Pricing", + "Loan-Pricing_v2.1", + "Loan (EU)", + "Rule [v2] {a} +b, c & d @e !f ~g =h :i ;j 'k'", + "Préstamo Tarifa", + "贷款定价", + "Kredit ٣", + "x" * 256, + ], ) - def test_accepts_names_the_dotnet_client_accepts( + def test_accepts_names_the_service_accepts( self, httpx_mock: HTTPXMock, service: BusinessRulesService, evaluate_url: str, business_rule_name: str, ) -> None: - # Only control characters are refused, as in the .NET client: a - # non-breaking space pasted into a name is not one. + # The SDK checks names with the service's own allowlist, so it sends + # exactly the names the service accepts. httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(business_rule_name, {}, folder_key=FOLDER_KEY) From f27ea295e1a2d28b62e5c4b4b79ac83ef7d99c4f Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 23:28:24 +0530 Subject: [PATCH 32/47] docs(business-rules): add the models page Lists the Business Rules models under Models, as Documents does, so the service page links BusinessRuleRunResult and its other types. Co-Authored-By: Claude Opus 5.5 --- packages/uipath/docs/core/business_rules_models.md | 3 +++ packages/uipath/mkdocs.yml | 1 + 2 files changed, 4 insertions(+) create mode 100644 packages/uipath/docs/core/business_rules_models.md diff --git a/packages/uipath/docs/core/business_rules_models.md b/packages/uipath/docs/core/business_rules_models.md new file mode 100644 index 000000000..ef687923e --- /dev/null +++ b/packages/uipath/docs/core/business_rules_models.md @@ -0,0 +1,3 @@ +::: uipath.platform.business_rules.business_rules + options: + show_bases: true diff --git a/packages/uipath/mkdocs.yml b/packages/uipath/mkdocs.yml index 651d40d48..f4572c0c5 100644 --- a/packages/uipath/mkdocs.yml +++ b/packages/uipath/mkdocs.yml @@ -80,6 +80,7 @@ nav: - Resource Catalog: core/resource_catalog.md - Tasks: core/tasks.md - Models: + - Business Rules: core/business_rules_models.md - Documents: core/documents_models.md - Evaluations: - Overview: eval/index.md From 2f2584696743a9a448b595bfd40ef13fc1e3bdd3 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 11:31:07 +0530 Subject: [PATCH 33/47] refactor(business-rules): mark debug runs with an is_debug flag The run target now records whether a run is a debug run as a boolean, decided once from the folder arguments and the debug session, instead of carrying the job key. The debug request takes its job key header from header_job_key(), as the other services do. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 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 5ea9df1e3..475297706 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 @@ -14,8 +14,9 @@ from ..common._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext +from ..common._job_context import header_job_key from ..common._models import Endpoint, RequestSpec -from ..constants import HEADER_FOLDER_KEY, HEADER_JOB_KEY +from ..constants import HEADER_FOLDER_KEY from ..orchestrator._folder_service import FolderService from .business_rules import ( BusinessRuleCaller, @@ -41,7 +42,7 @@ class _RunTarget: """Where a run goes, settled before anything is sent. - A debug run carries ``debug_job_key`` and no folder. A deployed run carries + A debug run sets ``is_debug`` and carries no folder. A deployed run carries exactly one of ``folder_key`` and ``folder_path``: a key ready to send, or a path still to look up. """ @@ -49,7 +50,7 @@ class _RunTarget: business_rule_name: str folder_key: Optional[str] = None folder_path: Optional[str] = None - debug_job_key: Optional[str] = None + is_debug: bool = False class BusinessRulesService(FolderContext, BaseService): @@ -228,15 +229,13 @@ def _prepare_run( folder lookup and the request itself differ between the two. """ # Decided on the caller's own folder arguments, before a binding swaps them. - debug_job_key = _resolve_debug_job_key(folder_key, folder_path) + is_debug = _is_debug_run(folder_key, folder_path) business_rule_name, folder_key, folder_path = self._apply_binding( business_rule_name, folder_key, folder_path ) _validate_run_arguments(business_rule_name, input) - if debug_job_key: - return _RunTarget( - business_rule_name=business_rule_name, debug_job_key=debug_job_key - ) + if is_debug: + return _RunTarget(business_rule_name=business_rule_name, is_debug=True) selected_key, selected_path = self._select_folder(folder_key, folder_path) return _RunTarget( business_rule_name=business_rule_name, @@ -330,11 +329,10 @@ def _run_spec( caller: Optional[BusinessRuleCaller] = None, ) -> RequestSpec: """Build the request for the endpoint the run target names.""" - if run_target.debug_job_key: + if run_target.is_debug: return self._debug_evaluate_spec( run_target.business_rule_name, input, - job_key=run_target.debug_job_key, decision_names=decision_names, ) return self._evaluate_spec( @@ -381,7 +379,6 @@ def _debug_evaluate_spec( business_rule_name: str, input: Dict[str, Any], *, - job_key: str, decision_names: Optional[List[str]] = None, ) -> RequestSpec: # The service finds the project, and the folders the job ran in, from the @@ -398,25 +395,22 @@ def _debug_evaluate_spec( method="POST", endpoint=_DEBUG_EVALUATE_ENDPOINT, json=request_body, - headers={HEADER_JOB_KEY: job_key}, + headers=header_job_key(), ) -def _resolve_debug_job_key( - folder_key: Optional[str], folder_path: Optional[str] -) -> Optional[str]: - """Return the debug job's key when this run is a debug run, else None. +def _is_debug_run(folder_key: Optional[str], folder_path: Optional[str]) -> bool: + """Return whether this run debugs the project instead of a deployed rule. A folder the caller names always means a deployed rule. Otherwise a run in a debug session debugs the project, named by the session's job; without a job key there is no lineage to resolve the project from, so it runs deployed. """ if _has_value(folder_key) or _has_value(folder_path): - return None + return False if not (UiPathConfig.is_studio_project or UiPathConfig.is_rooted_to_debug_job): - return None - debug_job_key = UiPathConfig.job_key - return debug_job_key if debug_job_key and debug_job_key.strip() else None + return False + return _has_value(UiPathConfig.job_key) def _request_options( From f8dc747be12e6dd7e210c45bf0cb210a68fbe7af Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 12:37:39 +0530 Subject: [PATCH 34/47] feat(business-rules): say on the caller when the run is a debug session The service now takes caller.isDebugRun: a traced call from a debug session, such as a process debugged in Studio Web, may have no resource key, and the flag exempts it from the caller check. BusinessRuleCaller gains is_debug_run, which defaults to whether the current job is a debug session; it is sent with every caller, as the .NET client does. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 31 ++++-- .../platform/business_rules/business_rules.py | 9 ++ .../services/test_business_rules_service.py | 101 +++++++++++++++++- 3 files changed, 131 insertions(+), 10 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 b42e88cf7..8dbe33093 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 @@ -337,23 +337,36 @@ def _require_folder_key(folder_key: Optional[str], folder_path: Optional[str]) - raise ValueError(f"No folder was found for folder_path '{folder_path}'") -def _caller_payload(caller: Optional[BusinessRuleCaller]) -> Dict[str, str]: +def _caller_payload(caller: Optional[BusinessRuleCaller]) -> Dict[str, Any]: """Return the caller as the service names its fields, defaulting from the job. - Blank fields are left out, and a caller with every field blank is returned - empty, so the request carries no caller at all. + Blank fields are left out. ``isDebugRun`` goes with every caller, false unless + the run is a debug session; a caller with every key blank and no debug run + names no one, so it is returned empty and the request carries no caller. """ given_caller = caller or BusinessRuleCaller() - caller_fields = { + caller_keys = { "resourceKey": given_caller.resource_key or UiPathConfig.process_uuid, "runKey": given_caller.run_key or UiPathConfig.job_key, "folderKey": given_caller.folder_key or UiPathConfig.folder_key, } - non_blank_fields: Dict[str, str] = {} - for field_name, field_value in caller_fields.items(): - if field_value and field_value.strip(): - non_blank_fields[field_name] = field_value - return non_blank_fields + caller_fields: Dict[str, Any] = {} + for field_name, field_value in caller_keys.items(): + if _has_value(field_value): + caller_fields[field_name] = field_value + is_debug_run = given_caller.is_debug_run + if is_debug_run is None: + is_debug_run = _is_debug_session() + if not caller_fields and not is_debug_run: + return {} + caller_fields["isDebugRun"] = is_debug_run + return caller_fields + + +def _is_debug_session() -> bool: + # Studio Web sets the project id when it debugs; a job started from a + # solution debug, such as Maestro's, carries isDebug in its arguments. + return UiPathConfig.is_studio_project or UiPathConfig.is_rooted_to_debug_job class _ExplicitTraceHeaders(Dict[str, str]): 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 bc0fec093..2e46a8060 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 @@ -37,6 +37,15 @@ class BusinessRuleCaller(BaseModel): default=None, description="The folder the calling resource runs in. Defaults to UIPATH_FOLDER_KEY.", ) + is_debug_run: Optional[bool] = Field( + default=None, + description=( + "Whether the calling run is a debug session, such as a process debugged " + "in Studio Web; such a run may have no resource key to send. This is " + "about the caller, not the rule: the deployed rule still runs. Defaults " + "to whether the current job is a debug session." + ), + ) class TraceContext(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 a4721b57e..1a6aab4a5 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -25,6 +25,7 @@ ResourceOverwriteParser, _resource_overwrites, ) +from uipath.platform.common._config import UiPathConfig from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT from uipath.platform.errors import EnrichedException @@ -53,6 +54,11 @@ def service( monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) monkeypatch.delenv("UIPATH_PROCESS_UUID", raising=False) + monkeypatch.delenv("UIPATH_PROJECT_ID", raising=False) + # Not a debug session unless a test says so, whatever uipath.json is nearby. + monkeypatch.setattr( + type(UiPathConfig), "is_rooted_to_debug_job", property(lambda self: False) + ) return BusinessRulesService( config=config, execution_context=execution_context, @@ -472,6 +478,7 @@ def test_defaults_from_the_environment( "resourceKey": "release-key", "runKey": JOB_KEY, "folderKey": "caller-folder-key", + "isDebugRun": False, } # The rule's folder, not the caller's, scopes the run. assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY @@ -500,6 +507,7 @@ def test_explicit_fields_win_over_the_environment( "resourceKey": "release-key", "runKey": "agent-run", "folderKey": "agent-folder", + "isDebugRun": False, } def test_blank_fields_are_left_out( @@ -519,7 +527,10 @@ def test_blank_fields_are_left_out( request = httpx_mock.get_request() assert request is not None - assert json.loads(request.content)["caller"] == {"resourceKey": "release-key"} + assert json.loads(request.content)["caller"] == { + "resourceKey": "release-key", + "isDebugRun": False, + } def test_no_caller_without_any_value( self, @@ -535,6 +546,94 @@ def test_no_caller_without_any_value( assert request is not None assert "caller" not in json.loads(request.content) + def test_studio_web_debug_session_is_a_debug_run( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + # A process debugged in Studio Web has no release, so no resource key; + # the flag tells the service the caller is a debug session. + monkeypatch.setenv("UIPATH_PROJECT_ID", "studio-project-id") + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "runKey": JOB_KEY, + "isDebugRun": True, + } + + def test_job_rooted_to_a_debug_session_is_a_debug_run( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr( + type(UiPathConfig), "is_rooted_to_debug_job", property(lambda self: True) + ) + monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "resourceKey": "release-key", + "isDebugRun": True, + } + + def test_explicit_is_debug_run_wins_over_the_session( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_PROJECT_ID", "studio-project-id") + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run( + RULE, + {}, + folder_key=FOLDER_KEY, + caller=BusinessRuleCaller(is_debug_run=False), + ) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "runKey": JOB_KEY, + "isDebugRun": False, + } + + def test_debug_run_alone_is_sent( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_service_response()) + + service.run( + RULE, + {}, + folder_key=FOLDER_KEY, + caller=BusinessRuleCaller(is_debug_run=True), + ) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == {"isDebugRun": True} + class TestResult: def test_partial_success_on_207( From 7b886993f34bc88494395c3ae5ec7bc30ae65441 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 12:51:26 +0530 Subject: [PATCH 35/47] refactor(business-rules): name the rule's input input_arguments run() and run_async() took the rule's input as `input`, which shadows the builtin; they now take `input_arguments`, as processes.invoke does. The request still sends it as `input`. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 46 ++++++++++--------- .../services/test_business_rules_service.py | 2 +- 2 files changed, 25 insertions(+), 23 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 8dbe33093..0a114c543 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 @@ -70,7 +70,7 @@ def __init__( def run( self, name: str, - input: Dict[str, Any], + input_arguments: Dict[str, Any], *, version: Optional[str] = None, decision_names: Optional[List[str]] = None, @@ -83,7 +83,7 @@ def run( Args: name: The name of the business rule. - input: The input to run, keyed by the rule's input names. Declared + input_arguments: The input to run, keyed by the rule's input names. Declared inputs absent from it bind to null. version: The rule version to run; defaults to the active version. decision_names: The decisions to evaluate; defaults to the whole model. @@ -124,10 +124,10 @@ def run( print(decision.decision_name, decision.outputs) ``` """ - run_target = self._prepare_run(name, input, folder_key, folder_path) + run_target = self._prepare_run(name, input_arguments, folder_key, folder_path) request_spec = self._evaluate_spec( run_target.business_rule_name, - input, + input_arguments, folder_key=self._resolve_folder_key(run_target), version=version, decision_names=decision_names, @@ -143,7 +143,7 @@ def run( async def run_async( self, name: str, - input: Dict[str, Any], + input_arguments: Dict[str, Any], *, version: Optional[str] = None, decision_names: Optional[List[str]] = None, @@ -156,7 +156,7 @@ async def run_async( Args: name: The name of the business rule. - input: The input to run, keyed by the rule's input names. Declared + input_arguments: The input to run, keyed by the rule's input names. Declared inputs absent from it bind to null. version: The rule version to run; defaults to the active version. decision_names: The decisions to evaluate; defaults to the whole model. @@ -175,10 +175,10 @@ async def run_async( ValueError: If the request is invalid or a required folder is missing. EnrichedException: If the service rejects the request. """ - run_target = self._prepare_run(name, input, folder_key, folder_path) + run_target = self._prepare_run(name, input_arguments, folder_key, folder_path) request_spec = self._evaluate_spec( run_target.business_rule_name, - input, + input_arguments, folder_key=await self._resolve_folder_key_async(run_target), version=version, decision_names=decision_names, @@ -192,7 +192,7 @@ async def run_async( def _prepare_run( self, business_rule_name: str, - input: Dict[str, Any], + input_arguments: Dict[str, Any], folder_key: Optional[str], folder_path: Optional[str], ) -> _RunTarget: @@ -204,7 +204,7 @@ def _prepare_run( business_rule_name, folder_key, folder_path = self._apply_binding( business_rule_name, folder_key, folder_path ) - _validate_run_arguments(business_rule_name, input) + _validate_run_arguments(business_rule_name, input_arguments) selected_key, selected_path = self._select_folder(folder_key, folder_path) return _RunTarget( business_rule_name=business_rule_name, @@ -290,7 +290,7 @@ async def _resolve_folder_key_async(self, run_target: _RunTarget) -> str: def _evaluate_spec( self, business_rule_name: str, - input: Dict[str, Any], + input_arguments: Dict[str, Any], *, folder_key: str, version: Optional[str] = None, @@ -301,7 +301,7 @@ def _evaluate_spec( # and audit record under it. request_body: Dict[str, Any] = { "businessRuleName": business_rule_name, - "input": input, + "input": input_arguments, } if _has_value(version): request_body["version"] = version @@ -400,9 +400,11 @@ def _has_value(text: Optional[str]) -> bool: return bool(text and text.strip()) -def _validate_run_arguments(business_rule_name: str, input: Dict[str, Any]) -> None: +def _validate_run_arguments( + business_rule_name: str, input_arguments: Dict[str, Any] +) -> None: _validate_business_rule_name(business_rule_name) - _validate_input(input) + _validate_input_arguments(input_arguments) def _validate_business_rule_name(business_rule_name: str) -> None: @@ -438,16 +440,16 @@ def _is_allowed_business_rule_name_character(character: str) -> bool: ) -def _validate_input(input: Dict[str, Any]) -> None: - if input is None: - raise ValueError("input must not be None") - if not isinstance(input, Mapping): +def _validate_input_arguments(input_arguments: Dict[str, Any]) -> None: + if input_arguments is None: + raise ValueError("input_arguments must not be None") + if not isinstance(input_arguments, Mapping): raise ValueError( - "input must be a mapping of the rule's input names to values, " - f"not {type(input).__name__}" + "input_arguments must be a mapping of the rule's input names to values, " + f"not {type(input_arguments).__name__}" ) - if len(input) > _MAX_INPUT_KEYS: - raise ValueError(f"input must not exceed {_MAX_INPUT_KEYS} keys") + if len(input_arguments) > _MAX_INPUT_KEYS: + raise ValueError(f"input_arguments must not exceed {_MAX_INPUT_KEYS} keys") def _overall_status( 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 1a6aab4a5..15d1759d2 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -163,7 +163,7 @@ def test_accepts_names_the_service_accepts( 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"): + with pytest.raises(ValueError, match="input_arguments must be a mapping"): service.run(RULE, value, folder_key=FOLDER_KEY) assert httpx_mock.get_requests() == [] From eed1e96b16d5ab8fbf7f5f940d67a5c2271df98b Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 14:50:22 +0530 Subject: [PATCH 36/47] fix(business-rules): let a missing folder raise FolderNotFoundException retrieve_folder_key raises FolderNotFoundException for a path that matches no folder and never returns None, so the run's own "no folder was found" check could not fire. Drop it, declare the lookup's return type as str, and test the exception that actually reaches the caller. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 34 ++++++++----------- .../platform/orchestrator/_folder_service.py | 10 +++--- .../services/test_business_rules_service.py | 18 ++++++---- 3 files changed, 31 insertions(+), 31 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 0a114c543..c357b7de8 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 @@ -107,6 +107,7 @@ def run( Raises: ValueError: If the request is invalid or a required folder is missing. + FolderNotFoundException: If no folder matches ``folder_path``. EnrichedException: If the service rejects the request. Examples: @@ -173,6 +174,7 @@ async def run_async( Raises: ValueError: If the request is invalid or a required folder is missing. + FolderNotFoundException: If no folder matches ``folder_path``. EnrichedException: If the service rejects the request. """ run_target = self._prepare_run(name, input_arguments, folder_key, folder_path) @@ -270,22 +272,21 @@ def _select_folder( ) def _resolve_folder_key(self, run_target: _RunTarget) -> str: - """Return the key of the folder the rule runs in, looking up a path.""" - resolved_folder_key = run_target.folder_key - if run_target.folder_path: - resolved_folder_key = self._folders_service.retrieve_folder_key( - run_target.folder_path - ) - return _require_folder_key(resolved_folder_key, run_target.folder_path) + """Return the key of the folder the rule runs in, looking up a path. + + A path that matches no folder raises FolderNotFoundException. + """ + if run_target.folder_key: + return run_target.folder_key + return self._folders_service.retrieve_folder_key(run_target.folder_path) async def _resolve_folder_key_async(self, run_target: _RunTarget) -> str: """Asynchronously return the key of the folder the rule runs in.""" - resolved_folder_key = run_target.folder_key - if run_target.folder_path: - resolved_folder_key = await self._folders_service.retrieve_folder_key_async( - run_target.folder_path - ) - return _require_folder_key(resolved_folder_key, run_target.folder_path) + if run_target.folder_key: + return run_target.folder_key + return await self._folders_service.retrieve_folder_key_async( + run_target.folder_path + ) def _evaluate_spec( self, @@ -330,13 +331,6 @@ def _request_options( } -def _require_folder_key(folder_key: Optional[str], folder_path: Optional[str]) -> str: - # A folder path that matches no folder looks up to None. - if folder_key and folder_key.strip(): - return folder_key - raise ValueError(f"No folder was found for folder_path '{folder_path}'") - - def _caller_payload(caller: Optional[BusinessRuleCaller]) -> Dict[str, Any]: """Return the caller as the service names its fields, defaulting from the job. diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_folder_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_folder_service.py index 84c5abdb3..e9ec4b3d3 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_folder_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_folder_service.py @@ -24,7 +24,7 @@ def __init__( ) -> None: super().__init__(config=config, execution_context=execution_context) - def retrieve_folder_key(self, folder_path: str | None) -> str | None: + def retrieve_folder_key(self, folder_path: str | None) -> str: """Resolve a folder path to its corresponding folder key. Args: @@ -34,7 +34,8 @@ def retrieve_folder_key(self, folder_path: str | None) -> str | None: The resolved folder key Raises: - ValueError: If folder_path is None or if folder_path is not found + ValueError: If folder_path is None. + FolderNotFoundException: If no folder matches folder_path. """ if folder_path is None: raise ValueError("Cannot obtain folder_key without providing folder_path") @@ -44,7 +45,7 @@ def retrieve_folder_key(self, folder_path: str | None) -> str | None: raise FolderNotFoundException(folder_path) return resolved_folder_key - async def retrieve_folder_key_async(self, folder_path: str | None) -> str | None: + async def retrieve_folder_key_async(self, folder_path: str | None) -> str: """Asynchronously resolve a folder path to its corresponding folder key. Args: @@ -54,7 +55,8 @@ async def retrieve_folder_key_async(self, folder_path: str | None) -> str | None The resolved folder key Raises: - ValueError: If folder_path is None or if folder_path is not found + ValueError: If folder_path is None. + FolderNotFoundException: If no folder matches folder_path. """ if folder_path is None: raise ValueError("Cannot obtain folder_key without providing folder_path") diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 15d1759d2..f2663626a 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -27,7 +27,7 @@ ) from uipath.platform.common._config import UiPathConfig from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT -from uipath.platform.errors import EnrichedException +from uipath.platform.errors import EnrichedException, FolderNotFoundException FOLDER_KEY = "5f1f1b0e-2b8a-4c1e-9b8e-1a2b3c4d5e6f" TRACEPARENT = "x-uipath-traceparent-id" @@ -312,28 +312,32 @@ def test_rejects_both_folder_key_and_path( with pytest.raises(ValueError, match="Only one of"): service.run(RULE, {}, folder_key=FOLDER_KEY, folder_path="Finance") - def test_rejects_a_folder_path_that_matches_no_folder( + def test_folder_path_that_matches_no_folder_raises_folder_not_found( self, httpx_mock: HTTPXMock, service: BusinessRulesService, folders_service: Mock, ) -> None: - folders_service.retrieve_folder_key.return_value = None + folders_service.retrieve_folder_key.side_effect = FolderNotFoundException( + "Missing/Folder" + ) - with pytest.raises(ValueError, match="No folder was found for folder_path"): + with pytest.raises(FolderNotFoundException): service.run(RULE, {}, folder_path="Missing/Folder") assert httpx_mock.get_requests() == [] - async def test_rejects_a_folder_path_that_matches_no_folder_async( + async def test_folder_path_that_matches_no_folder_raises_folder_not_found_async( self, httpx_mock: HTTPXMock, service: BusinessRulesService, folders_service: Mock, ) -> None: - folders_service.retrieve_folder_key_async.return_value = None + folders_service.retrieve_folder_key_async.side_effect = FolderNotFoundException( + "Missing/Folder" + ) - with pytest.raises(ValueError, match="No folder was found for folder_path"): + with pytest.raises(FolderNotFoundException): await service.run_async(RULE, {}, folder_path="Missing/Folder") assert httpx_mock.get_requests() == [] From 7487fe5805d1f92f12f88fd7111bb46eaff2a68e Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 15:06:20 +0530 Subject: [PATCH 37/47] refactor(business-rules): let BaseService keep a trace header already set An explicit trace_context was kept by a dict subclass that ignored the ambient header BaseService writes, which depended on how that write is made. _inject_trace_context now leaves a trace header the caller already set, so the service sends a plain dict, and the header is a public constant, HEADER_TRACEPARENT_ID. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 29 ++++----------- .../uipath/platform/common/_base_service.py | 12 +++--- .../src/uipath/platform/constants/__init__.py | 1 + .../tests/services/test_base_service.py | 37 ++++++++++++++++++- 4 files changed, 51 insertions(+), 28 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 c357b7de8..1fcdffbc4 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 @@ -8,13 +8,13 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple -from ..common._base_service import _TRACE_PARENT_HEADER, BaseService +from ..common._base_service import BaseService from ..common._bindings import resource_override from ..common._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext from ..common._models import Endpoint, RequestSpec -from ..constants import HEADER_FOLDER_KEY +from ..constants import HEADER_FOLDER_KEY, HEADER_TRACEPARENT_ID from ..orchestrator._folder_service import FolderService from .business_rules import ( BusinessRuleCaller, @@ -363,30 +363,17 @@ def _is_debug_session() -> bool: return UiPathConfig.is_studio_project or UiPathConfig.is_rooted_to_debug_job -class _ExplicitTraceHeaders(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 __setitem__(self, header_name: str, header_value: str) -> None: - if header_name == _TRACE_PARENT_HEADER and header_name in self: - return - super().__setitem__(header_name, header_value) - - def _headers_with_trace( headers: Dict[str, str], trace_context: Optional[TraceContext] ) -> Dict[str, str]: - """Return the headers, pinned to ``trace_context`` when the caller gave one.""" + """Return the headers, pinned to ``trace_context`` when the caller gave one. + + BaseService adds the ambient trace header only when none is set, so an + explicit one set here is what gets sent. + """ if trace_context is None: return headers - pinned_headers = _ExplicitTraceHeaders(headers) - dict.__setitem__( - pinned_headers, _TRACE_PARENT_HEADER, trace_context.to_traceparent() - ) - return pinned_headers + return {**headers, HEADER_TRACEPARENT_ID: trace_context.to_traceparent()} def _has_value(text: Optional[str]) -> bool: diff --git a/packages/uipath-platform/src/uipath/platform/common/_base_service.py b/packages/uipath-platform/src/uipath/platform/common/_base_service.py index 756c2be67..ee1b17e9a 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_base_service.py +++ b/packages/uipath-platform/src/uipath/platform/common/_base_service.py @@ -21,7 +21,7 @@ stop_after_attempt, ) -from uipath.platform.constants import HEADER_USER_AGENT +from uipath.platform.constants import HEADER_TRACEPARENT_ID, HEADER_USER_AGENT from ..errors import EnrichedException from ._config import UiPathApiConfig @@ -65,9 +65,6 @@ def _get_caller_component() -> str: return "" -_TRACE_PARENT_HEADER = "x-uipath-traceparent-id" - - def resolve_trace_id(fallback: str | None = None) -> str | None: """Resolve the current UiPath trace id as a 32-char hex string. @@ -129,7 +126,12 @@ def _inject_trace_context(headers: dict[str, str]) -> None: remapping the LLMOps exporter applies), falling back to the OTEL trace ID. Span ID: uses the LLMOps tool span (via external span provider) so the span ID matches what's visible in the LLMOps trace UI. + + A trace header already in ``headers`` is left as is: the caller chose it. """ + if HEADER_TRACEPARENT_ID in headers: + return + from uipath.core.tracing.span_utils import UiPathSpanUtils from ._config import UiPathConfig @@ -148,7 +150,7 @@ def _inject_trace_context(headers: dict[str, str]) -> None: else format_trace_id(ctx.trace_id) ) span_id = format_span_id(ctx.span_id) - headers[_TRACE_PARENT_HEADER] = f"00-{trace_id}-{span_id}-01" + headers[HEADER_TRACEPARENT_ID] = f"00-{trace_id}-{span_id}-01" class BaseService: diff --git a/packages/uipath-platform/src/uipath/platform/constants/__init__.py b/packages/uipath-platform/src/uipath/platform/constants/__init__.py index 2e73b7eca..97a1a39b6 100644 --- a/packages/uipath-platform/src/uipath/platform/constants/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/constants/__init__.py @@ -49,6 +49,7 @@ HEADER_JOB_KEY = "x-uipath-jobkey" HEADER_PROCESS_KEY = "x-uipath-processkey" HEADER_TRACE_ID = "x-uipath-traceid" +HEADER_TRACEPARENT_ID = "x-uipath-traceparent-id" HEADER_AGENTHUB_CONFIG = "x-uipath-agenthub-config" AGENTHUB_CONFIG_CODED_AGENTS_PLAYGROUND = "codedagentsplayground" HEADER_GUARDRAILS_SOURCE = "x-uipath-guardrails-source" diff --git a/packages/uipath-platform/tests/services/test_base_service.py b/packages/uipath-platform/tests/services/test_base_service.py index db39ed313..946cb8dad 100644 --- a/packages/uipath-platform/tests/services/test_base_service.py +++ b/packages/uipath-platform/tests/services/test_base_service.py @@ -1,9 +1,11 @@ import pytest +from opentelemetry import trace +from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common._base_service import BaseService -from uipath.platform.constants import HEADER_USER_AGENT +from uipath.platform.common._base_service import BaseService, _inject_trace_context +from uipath.platform.constants import HEADER_TRACEPARENT_ID, HEADER_USER_AGENT from uipath.platform.errors import EnrichedException @@ -389,3 +391,34 @@ async def test_request_async_uses_override_url( assert sent_request.headers["X-UiPath-Internal-TenantId"] == "tenant-123" assert sent_request.headers["X-UiPath-Internal-AccountId"] == "org-456" assert response.status_code == 200 + + +class TestInjectTraceContext: + _SPAN = NonRecordingSpan( + SpanContext( + trace_id=0x4BF92F3577B34DA6A3CE929D0E0E4736, + span_id=0x00F067AA0BA902B7, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + ) + + def test_adds_the_current_span(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + headers: dict[str, str] = {} + + with trace.use_span(self._SPAN): + _inject_trace_context(headers) + + assert headers[HEADER_TRACEPARENT_ID] == ( + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + ) + + def test_keeps_a_trace_header_already_set(self) -> None: + explicit = "00-11111111111111111111111111111111-2222222222222222-01" + headers = {HEADER_TRACEPARENT_ID: explicit} + + with trace.use_span(self._SPAN): + _inject_trace_context(headers) + + assert headers == {HEADER_TRACEPARENT_ID: explicit} From d7e652cf0edec9056b0f577f51563cc50ea63f19 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 15:22:07 +0530 Subject: [PATCH 38/47] refactor(business-rules): leave a rule name's characters to the service The SDK copied the service's character allowlist, a second list to keep in sync. It now only checks that a name is not blank and is at most 256 characters; the service answers any other name it rejects with INVALID_REQUEST. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 30 +--------- .../services/test_business_rules_service.py | 57 +++---------------- 2 files changed, 11 insertions(+), 76 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 0d3b71237..91ab7f063 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 @@ -4,7 +4,6 @@ session, the undeployed rule from the Studio project being debugged. """ -import unicodedata from collections.abc import Mapping from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple @@ -33,9 +32,6 @@ _MAX_INPUT_KEYS = 256 _MAX_BUSINESS_RULE_NAME_LENGTH = 256 -# The service's own rule (BusinessRuleNames in the business-rules service): a -# name may hold letters, numbers and these marks, and never "..". -_ALLOWED_BUSINESS_RULE_NAME_MARKS = frozenset(" '._()[]{}+,&@!~=:;-") @dataclass(frozen=True) @@ -472,36 +468,14 @@ def _validate_run_arguments( def _validate_business_rule_name(business_rule_name: str) -> None: + # Only what a name can never be; which characters a name may hold is the + # service's to decide, and it rejects the rest with INVALID_REQUEST. if not _has_value(business_rule_name): raise ValueError("name must be specified") if len(business_rule_name) > _MAX_BUSINESS_RULE_NAME_LENGTH: raise ValueError( f"name must not exceed {_MAX_BUSINESS_RULE_NAME_LENGTH} characters" ) - disallowed_characters = sorted( - { - character - for character in business_rule_name - if not _is_allowed_business_rule_name_character(character) - } - ) - if disallowed_characters: - raise ValueError( - "name may contain only letters, numbers, spaces and " - f"{''.join(sorted(_ALLOWED_BUSINESS_RULE_NAME_MARKS - {' '}))}; " - f"found {', '.join(repr(character) for character in disallowed_characters)}" - ) - if ".." in business_rule_name: - raise ValueError("name must not contain '..'") - - -def _is_allowed_business_rule_name_character(character: str) -> bool: - # Letters and numbers in any script (Unicode categories L* and N*), as the - # service's \p{L} and \p{N} match them. - return ( - unicodedata.category(character)[0] in ("L", "N") - or character in _ALLOWED_BUSINESS_RULE_NAME_MARKS - ) def _validate_input_arguments(input_arguments: Dict[str, Any]) -> None: diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 91c57d895..2040c8566 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -92,32 +92,8 @@ def _single_decision_result(**outputs: Any) -> dict[str, Any]: class TestRunContext: - @pytest.mark.parametrize( - "business_rule_name", - [ - "", - " ", - "x" * 257, - "a..b", - # Outside the service's allowlist - "a/b", - "a\\b", - "a%20b", - "Loan#1", - "Rule?", - "Rate*2", - "Loan$", - 'a"b', - "a|b", - "Loan \U0001f680", - "Loan\u00a0Pricing", - "Loan\u200bPricing", - "a\nb", - "a\x00b", - "a\x7fb", - ], - ) - def test_rejects_names_the_service_rejects( + @pytest.mark.parametrize("business_rule_name", ["", " ", "x" * 257]) + def test_rejects_a_blank_or_too_long_name( self, httpx_mock: HTTPXMock, service: BusinessRulesService, @@ -128,34 +104,19 @@ def test_rejects_names_the_service_rejects( assert httpx_mock.get_requests() == [] - def test_names_the_disallowed_characters( - self, service: BusinessRulesService - ) -> None: - with pytest.raises(ValueError, match=r"found '#', '\?'"): - service.run("Loan#1?", {}, folder_key=FOLDER_KEY) - @pytest.mark.parametrize( "business_rule_name", - [ - "Loan Pricing", - "Loan-Pricing_v2.1", - "Loan (EU)", - "Rule [v2] {a} +b, c & d @e !f ~g =h :i ;j 'k'", - "Préstamo Tarifa", - "贷款定价", - "Kredit ٣", - "x" * 256, - ], + ["Loan Pricing", "Préstamo Tarifa", "贷款定价", "Loan#1", "a/b", "x" * 256], ) - def test_accepts_names_the_service_accepts( + def test_leaves_the_name_characters_to_the_service( self, httpx_mock: HTTPXMock, service: BusinessRulesService, evaluate_url: str, business_rule_name: str, ) -> None: - # The SDK checks names with the service's own allowlist, so it sends - # exactly the names the service accepts. + # The service decides which characters a name may hold, and answers a + # name it rejects with INVALID_REQUEST; the SDK sends it as given. httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(business_rule_name, {}, folder_key=FOLDER_KEY) @@ -1336,11 +1297,11 @@ def test_a_deployed_job_runs_deployed( assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" assert "x-uipath-jobkey" not in request.headers - def test_rejects_unsafe_rule_name( + def test_rejects_too_long_rule_name( self, httpx_mock: HTTPXMock, service: BusinessRulesService, studio_debug: None ) -> None: - with pytest.raises(ValueError, match="name"): - service.run("a/b", {}) + with pytest.raises(ValueError, match="256 characters"): + service.run("x" * 257, {}) assert httpx_mock.get_requests() == [] From bf911e9b32e3d8c0d19650148e89d4786f9eab6f Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 15:22:07 +0530 Subject: [PATCH 39/47] chore(platform): bump uipath-platform to 0.2.35 The Business Rules SDK now ships as one PR, so uipath-platform takes a single bump from 0.2.34. 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 4699bfeeb..be322ea1e 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.36" +version = "0.2.35" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 99070bbf4..b02fd2c40 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.36" +version = "0.2.35" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 09541f1cc..9887fc03a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.36" +version = "0.2.35" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From 9319513c37bd3eef85279c3200be771d39a410f3 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 15:31:55 +0530 Subject: [PATCH 40/47] refactor(business-rules): reuse the span normalizers for the trace context The trace context's validators now use _SpanUtils.normalize_trace_id and normalize_span_id, keeping only the hex and non-zero checks on the model, and the model is BusinessRuleTraceContext, in line with the other Business Rules models. Co-Authored-By: Claude Opus 5.5 --- .../platform/business_rules/__init__.py | 4 +- .../business_rules/_business_rules_service.py | 10 ++--- .../platform/business_rules/business_rules.py | 44 +++++++++++++------ .../services/test_business_rules_service.py | 30 ++++++++----- 4 files changed, 57 insertions(+), 31 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 7fa5d5abb..c9327b2ea 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -13,7 +13,7 @@ BusinessRuleError, BusinessRuleRunResult, BusinessRuleStatus, - TraceContext, + BusinessRuleTraceContext, ) __all__ = [ @@ -23,5 +23,5 @@ "BusinessRuleRunResult", "BusinessRuleStatus", "BusinessRulesService", - "TraceContext", + "BusinessRuleTraceContext", ] diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py index 91ab7f063..327aa8d30 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 @@ -23,7 +23,7 @@ BusinessRuleError, BusinessRuleRunResult, BusinessRuleStatus, - TraceContext, + BusinessRuleTraceContext, _WireResponse, ) @@ -81,7 +81,7 @@ def run( folder_key: Optional[str] = None, folder_path: Optional[str] = None, caller: Optional[BusinessRuleCaller] = None, - trace_context: Optional[TraceContext] = None, + trace_context: Optional[BusinessRuleTraceContext] = None, ) -> BusinessRuleRunResult: """Run a business rule against one input. @@ -178,7 +178,7 @@ async def run_async( folder_key: Optional[str] = None, folder_path: Optional[str] = None, caller: Optional[BusinessRuleCaller] = None, - trace_context: Optional[TraceContext] = None, + trace_context: Optional[BusinessRuleTraceContext] = None, ) -> BusinessRuleRunResult: """Asynchronously run a business rule against one input. @@ -399,7 +399,7 @@ def _is_debug_run(folder_key: Optional[str], folder_path: Optional[str]) -> bool def _request_options( - request_spec: RequestSpec, trace_context: Optional[TraceContext] + request_spec: RequestSpec, trace_context: Optional[BusinessRuleTraceContext] ) -> Dict[str, Any]: """Return the request() arguments for a spec, besides its method.""" return { @@ -443,7 +443,7 @@ def _is_debug_session() -> bool: def _headers_with_trace( - headers: Dict[str, str], trace_context: Optional[TraceContext] + headers: Dict[str, str], trace_context: Optional[BusinessRuleTraceContext] ) -> Dict[str, str]: """Return the headers, pinned to ``trace_context`` when the caller gave one. 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 cbe43fb86..5b003f483 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 @@ -6,6 +6,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator +from ..common._span_utils import _SpanUtils + _HEX_DIGITS = re.compile(r"^[0-9a-f]+$") @@ -48,7 +50,7 @@ class BusinessRuleCaller(BaseModel): ) -class TraceContext(BaseModel): +class BusinessRuleTraceContext(BaseModel): """An existing trace to file the run's spans under. Optional on ``run()``: when omitted, the SDK takes the trace from @@ -59,34 +61,48 @@ class TraceContext(BaseModel): 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." + description=( + "The span the run's spans nest under: 16 hex characters, or a UUID " + "whose last 16 are used." + ) ) @field_validator("trace_id") @classmethod def _normalize_trace_id(cls, value: str) -> str: - normalized_trace_id = value.replace("-", "").strip().lower() - if len(normalized_trace_id) != 32 or not _HEX_DIGITS.match(normalized_trace_id): - raise ValueError("trace_id must be 32 hex characters or a UUID") - if normalized_trace_id == "0" * 32: - raise ValueError("trace_id must not be all zeros") - return normalized_trace_id + try: + trace_id = _SpanUtils.normalize_trace_id(value.strip()) + except ValueError: + raise ValueError("trace_id must be 32 hex characters or a UUID") from None + _require_nonzero_hex(trace_id, "trace_id") + return trace_id @field_validator("parent_span_id") @classmethod def _normalize_parent_span_id(cls, value: str) -> str: - normalized_span_id = value.strip().lower() - if len(normalized_span_id) != 16 or not _HEX_DIGITS.match(normalized_span_id): - raise ValueError("parent_span_id must be 16 hex characters") - if normalized_span_id == "0" * 16: - raise ValueError("parent_span_id must not be all zeros") - return normalized_span_id + try: + span_id = _SpanUtils.normalize_span_id(value.strip()) + except ValueError: + raise ValueError( + "parent_span_id must be 16 hex characters or a UUID" + ) from None + _require_nonzero_hex(span_id, "parent_span_id") + return span_id def to_traceparent(self) -> str: """Return the W3C traceparent value for this context.""" return f"00-{self.trace_id}-{self.parent_span_id}-01" +def _require_nonzero_hex(trace_part: str, field_name: str) -> None: + # _SpanUtils normalizes the form; the W3C traceparent also needs hex + # digits, and an all-zero id is invalid there. + if not _HEX_DIGITS.match(trace_part): + raise ValueError(f"{field_name} must contain only hex characters") + if not trace_part.strip("0"): + raise ValueError(f"{field_name} must not be all zeros") + + class BusinessRuleError(BaseModel): """A code/message pair describing an input-level or decision-level error.""" 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 2040c8566..7e398a4d1 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -18,7 +18,7 @@ BusinessRuleCaller, BusinessRulesService, BusinessRuleStatus, - TraceContext, + BusinessRuleTraceContext, ) from uipath.platform.common._bindings import ( GenericResourceOverwrite, @@ -783,7 +783,7 @@ def test_explicit_trace_context_wins_over_ambient_span( ambient_span: None, ) -> None: httpx_mock.add_response(url=evaluate_url, json=_service_response()) - explicit = TraceContext( + explicit = BusinessRuleTraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -806,7 +806,7 @@ def test_explicit_trace_context_wins_over_uipath_trace_id( ) -> None: monkeypatch.setenv("UIPATH_TRACE_ID", AMBIENT_TRACE_ID) httpx_mock.add_response(url=evaluate_url, json=_service_response()) - explicit = TraceContext( + explicit = BusinessRuleTraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -916,7 +916,7 @@ def test_override_does_not_leak_into_the_next_call( monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) httpx_mock.add_response(url=evaluate_url, json=_service_response()) httpx_mock.add_response(url=evaluate_url, json=_service_response()) - explicit = TraceContext( + explicit = BusinessRuleTraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -935,7 +935,7 @@ async def test_explicit_trace_context_async( ambient_span: None, ) -> None: httpx_mock.add_response(url=evaluate_url, json=_service_response()) - explicit = TraceContext( + explicit = BusinessRuleTraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -957,7 +957,7 @@ def test_explicit_trace_context_survives_a_retry( ) -> None: httpx_mock.add_response(url=evaluate_url, status_code=503) httpx_mock.add_response(url=evaluate_url, json=_service_response()) - explicit = TraceContext( + explicit = BusinessRuleTraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) @@ -968,8 +968,17 @@ def test_explicit_trace_context_survives_a_retry( assert first.headers[TRACEPARENT] == expected assert second.headers[TRACEPARENT] == expected + def test_parent_span_id_accepts_uuid_form(self) -> None: + # As _SpanUtils.normalize_span_id reads a UUID: its last 16 hex digits. + context = BusinessRuleTraceContext( + trace_id=EXPLICIT_TRACE_ID, + parent_span_id=f"00000000-0000-0000-{EXPLICIT_SPAN_ID[:4]}-{EXPLICIT_SPAN_ID[4:]}", + ) + + assert context.parent_span_id == EXPLICIT_SPAN_ID + def test_trace_id_accepts_uuid_form_and_upper_case(self) -> None: - context = TraceContext( + context = BusinessRuleTraceContext( trace_id="4BF92F35-77B3-4DA6-A3CE-929D0E0E4736", parent_span_id="00F067AA0BA902B7", ) @@ -982,9 +991,10 @@ def test_trace_id_accepts_uuid_form_and_upper_case(self) -> None: ("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"), + ("z" * 32, EXPLICIT_SPAN_ID, "trace_id must contain only hex"), ("0" * 32, EXPLICIT_SPAN_ID, "trace_id must not be all zeros"), (EXPLICIT_TRACE_ID, "abc", "parent_span_id must be 16 hex"), + (EXPLICIT_TRACE_ID, "z" * 16, "parent_span_id must contain only hex"), (EXPLICIT_TRACE_ID, "0" * 16, "parent_span_id must not be all zeros"), ], ) @@ -992,7 +1002,7 @@ 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) + BusinessRuleTraceContext(trace_id=trace_id, parent_span_id=parent_span_id) @pytest.fixture @@ -1373,7 +1383,7 @@ def test_explicit_trace_context_on_debug_run( ambient_span: None, ) -> None: httpx_mock.add_response(url=debug_url, json=_service_response()) - explicit = TraceContext( + explicit = BusinessRuleTraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID ) From 01379ecaf4268095dc97de012b7a3dda18a8feee Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 16:04:07 +0530 Subject: [PATCH 41/47] fix(business-rules): keep the run status as the enum it is typed as With use_enum_values the result's status was a plain str at runtime, though typed as BusinessRuleStatus. It is now the enum member; being a str enum, it still equals the service's text. Co-Authored-By: Claude Opus 5.5 --- .../src/uipath/platform/business_rules/business_rules.py | 1 - .../tests/services/test_business_rules_service.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 5b003f483..95ae5fe35 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 @@ -139,7 +139,6 @@ class BusinessRuleRunResult(BaseModel): model_config = ConfigDict( validate_by_name=True, validate_by_alias=True, - use_enum_values=True, ) status: BusinessRuleStatus 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 7e398a4d1..b729d865c 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -335,7 +335,10 @@ def test_sends_single_input_and_maps_decisions( folder_key=FOLDER_KEY, ) - assert result.status == BusinessRuleStatus.SUCCESS + # The enum member, as typed, and still equal to the service's text. + assert result.status is BusinessRuleStatus.SUCCESS + assert result.status == "Success" + assert result.model_dump(mode="json")["status"] == "Success" assert result.decisions[0].decision_name == "RiskGrade" assert result.decisions[0].outputs == {"Grade": "B", "Rate": 3.5} assert result.errors == [] From ba21cfef2c61aed3d548e4eec591a5d57142913b Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 16:12:46 +0530 Subject: [PATCH 42/47] fix(bindings): let a bound folder replace the caller's folder_key When a binding set a folder path, resource_override left the caller's folder_key in place. Services that pass both to header_folder() raised, and those that prefer the key looked the bound name up in the caller's folder. The decorator now clears the other folder argument when a binding names a folder. Co-Authored-By: Claude Opus 5.5 --- .../src/uipath/platform/common/_bindings.py | 18 ++++ .../tests/common/test_resource_override.py | 89 +++++++++++++++++++ .../tests/services/test_assets_service.py | 44 ++++++++- .../test_context_grounding_service.py | 41 +++++++++ 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 packages/uipath-platform/tests/common/test_resource_override.py diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index e764ee587..fdf985050 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -216,6 +216,11 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): _resource_overwrites.reset(self._token) +# A method's two ways of naming one folder: an overwrite that sets one clears +# the other. +_OTHER_FOLDER_IDENTIFIER = {"folder_path": "folder_key", "folder_key": "folder_path"} + + def resource_override( resource_type: str, resource_identifier: str = "name", @@ -284,6 +289,19 @@ def process_args(args, kwargs) -> dict[str, Any]: all_args[folder_identifier] = ( matched_overwrite.folder_identifier ) + # The overwrite's folder replaces the caller's: a key + # left beside a bound path (or a path beside a bound + # key) would name two folders, which header_folder() + # refuses. + other_folder_identifier = _OTHER_FOLDER_IDENTIFIER.get( + folder_identifier + ) + if ( + other_folder_identifier is not None + and other_folder_identifier in sig.parameters + and matched_overwrite.folder_identifier + ): + all_args[other_folder_identifier] = None logger.debug( "Resource overwrite applied for %s on %s: %s='%s' -> '%s', %s='%s' -> '%s'", resource_type, diff --git a/packages/uipath-platform/tests/common/test_resource_override.py b/packages/uipath-platform/tests/common/test_resource_override.py new file mode 100644 index 000000000..056f47695 --- /dev/null +++ b/packages/uipath-platform/tests/common/test_resource_override.py @@ -0,0 +1,89 @@ +from typing import Iterator, Optional, Tuple + +import pytest + +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + _resource_overwrites, + resource_override, +) + +Folder = Tuple[str, Optional[str], Optional[str]] + + +@resource_override(resource_type="asset") +def retrieve( + name: str, *, folder_key: Optional[str] = None, folder_path: Optional[str] = None +) -> Folder: + return name, folder_key, folder_path + + +@resource_override(resource_type="asset") +async def retrieve_async( + name: str, *, folder_key: Optional[str] = None, folder_path: Optional[str] = None +) -> Folder: + return name, folder_key, folder_path + + +@pytest.fixture +def asset_overwrite() -> Iterator[None]: + overwrite = GenericResourceOverwrite( + resource_type="asset", name="ApiKey EU", folder_path="Finance/EU" + ) + token = _resource_overwrites.set({"asset.ApiKey": overwrite}) + try: + yield + finally: + _resource_overwrites.reset(token) + + +def test_bound_folder_replaces_a_folder_key(asset_overwrite: None) -> None: + # Left beside the bound path, the key would name a second folder, which + # header_folder() refuses. + assert retrieve("ApiKey", folder_key="callers-key") == ( + "ApiKey EU", + None, + "Finance/EU", + ) + + +async def test_bound_folder_replaces_a_folder_key_async(asset_overwrite: None) -> None: + assert await retrieve_async("ApiKey", folder_key="callers-key") == ( + "ApiKey EU", + None, + "Finance/EU", + ) + + +def test_folder_key_kept_without_a_matching_overwrite(asset_overwrite: None) -> None: + assert retrieve("Other", folder_key="callers-key") == ( + "Other", + "callers-key", + None, + ) + + +def test_other_folder_parameters_are_left_alone() -> None: + # A method whose overwrite targets a differently named folder (as Action + # Center's app_folder_path) keeps its own folder_key. + @resource_override(resource_type="app", folder_identifier="app_folder_path") + def create( + name: str, + *, + app_folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> Folder: + return name, folder_key, app_folder_path + + overwrite = GenericResourceOverwrite( + resource_type="app", name="Approval EU", folder_path="Apps/EU" + ) + token = _resource_overwrites.set({"app.Approval": overwrite}) + try: + assert create("Approval", folder_key="task-folder") == ( + "Approval EU", + "task-folder", + "Apps/EU", + ) + finally: + _resource_overwrites.reset(token) diff --git a/packages/uipath-platform/tests/services/test_assets_service.py b/packages/uipath-platform/tests/services/test_assets_service.py index 94f8ab888..a118901e8 100644 --- a/packages/uipath-platform/tests/services/test_assets_service.py +++ b/packages/uipath-platform/tests/services/test_assets_service.py @@ -4,8 +4,16 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + _resource_overwrites, +) from uipath.platform.common.paging import PagedResult -from uipath.platform.constants import HEADER_USER_AGENT +from uipath.platform.constants import ( + HEADER_FOLDER_KEY, + HEADER_FOLDER_PATH, + HEADER_USER_AGENT, +) from uipath.platform.orchestrator import Asset, UserAsset from uipath.platform.orchestrator._assets_service import AssetsService @@ -937,3 +945,37 @@ async def test_update_async_passes_all_kwargs( # Verify positional arg (method) assert call_kwargs.args[0] == "POST" + + +class TestResourceOverride: + def test_bound_folder_replaces_callers_folder_key( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + # Before resource_override cleared the caller's folder_key, the key and + # the bound path both reached header_folder(), which raised. + import json + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={"id": 1, "name": "ApiKey EU", "value": "eu-value"}, + ) + overwrite = GenericResourceOverwrite( + resource_type="asset", name="ApiKey EU", folder_path="Finance/EU" + ) + token = _resource_overwrites.set({"asset.ApiKey": overwrite}) + try: + service.retrieve(name="ApiKey", folder_key="callers-folder-key") + finally: + _resource_overwrites.reset(token) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert json.loads(sent_request.content)["assetName"] == "ApiKey EU" + assert sent_request.headers[HEADER_FOLDER_PATH] == "Finance/EU" + assert HEADER_FOLDER_KEY not in sent_request.headers diff --git a/packages/uipath-platform/tests/services/test_context_grounding_service.py b/packages/uipath-platform/tests/services/test_context_grounding_service.py index c72d593fd..5c0140595 100644 --- a/packages/uipath-platform/tests/services/test_context_grounding_service.py +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -6,6 +6,10 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + _resource_overwrites, +) from uipath.platform.constants import ( ENV_JOB_KEY, HEADER_JOB_KEY, @@ -4418,3 +4422,40 @@ def test_ingest_data_omits_job_key_header_when_env_unset( headers = mock_request.call_args[1]["headers"] assert HEADER_JOB_KEY not in headers + + +class TestResourceOverride: + def test_bound_folder_replaces_callers_folder_key( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + # The caller's folder_key used to win over the binding's folder, so the + # bound index name was looked up in the caller's folder. + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'eu-index'&$expand=dataSource", + status_code=200, + json={"value": [{"id": "eu-index-id", "name": "eu-index"}]}, + ) + overwrite = GenericResourceOverwrite( + resource_type="index", name="eu-index", folder_path="Finance/EU" + ) + token = _resource_overwrites.set({"index.test-index": overwrite}) + try: + with patch.object( + service._folders_service, "retrieve_key", return_value="eu-folder-key" + ) as retrieve_key: + index = service.retrieve( + name="test-index", folder_key="callers-folder-key" + ) + finally: + _resource_overwrites.reset(token) + + assert index.id == "eu-index-id" + retrieve_key.assert_called_once_with(folder_path="Finance/EU") + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.headers["x-uipath-folderkey"] == "eu-folder-key" From a01396df30302eefdd114f1484a5c6fc1e57ec8a Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 16:12:46 +0530 Subject: [PATCH 43/47] refactor(business-rules): drop the binding folder workaround resource_override now replaces the caller's folder_key itself, so the service no longer does it. The decorator stays on a private helper: the endpoint depends on whether the caller named a folder, which is decided before a binding swaps it. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 39 ++++++------------- 1 file changed, 11 insertions(+), 28 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 327aa8d30..f661a8af7 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 @@ -242,8 +242,8 @@ def _prepare_run( """ # Decided on the caller's own folder arguments, before a binding swaps them. is_debug = _is_debug_run(folder_key, folder_path) - business_rule_name, folder_key, folder_path = self._apply_binding( - business_rule_name, folder_key, folder_path + business_rule_name, folder_key, folder_path = self._overridden_resource( + business_rule_name, folder_key=folder_key, folder_path=folder_path ) _validate_run_arguments(business_rule_name, input_arguments) if is_debug: @@ -255,36 +255,19 @@ def _prepare_run( folder_path=selected_path, ) + # On a private helper, not on run(): which endpoint a run takes depends on + # whether the caller named a folder, so _prepare_run decides that before a + # binding swaps the caller's folder for its own. @resource_override(resource_type="businessRule") def _overridden_resource( - 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, - business_rule_name: str, - folder_key: Optional[str], - folder_path: Optional[str], + name: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, ) -> 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_business_rule_name, bound_folder_path = self._overridden_resource( - business_rule_name, folder_path=folder_path - ) - is_remapped = (bound_business_rule_name, bound_folder_path) != ( - business_rule_name, - folder_path, - ) - if is_remapped and bound_folder_path: - folder_key = None - return bound_business_rule_name, folder_key, bound_folder_path + # resource_override swaps these arguments when the solution's bindings + # remap this rule; the method just returns what it was given. + return name, folder_key, folder_path def _select_folder( self, folder_key: Optional[str], folder_path: Optional[str] From 7774dfd011d9e4d2379893cabe4cae96adcdd4db Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 16:15:23 +0530 Subject: [PATCH 44/47] docs(business-rules): say a client span would wrap the service's spans Co-Authored-By: Claude Opus 5.5 --- .../uipath/platform/business_rules/_business_rules_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 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 f661a8af7..45b6eef56 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 @@ -69,8 +69,8 @@ def __init__( self._folders_service = folders_service # Not @traced: the service records the run's decision spans under the - # caller's span, so a client span would only duplicate them and record the - # rule's input and outputs, which the .NET client never does either. + # caller's span, so a client span would only wrap them; the .NET client + # opens none either. def run( self, name: str, From 5ab923004a905dd9ca1f60cc6e6247a7ab9f2ce1 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 16:20:04 +0530 Subject: [PATCH 45/47] chore(business-rules): drop comments that restate the code Keeps only the comments that give a reason the code can't show. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 23 +++---------------- .../platform/business_rules/business_rules.py | 3 +-- .../src/uipath/platform/common/_bindings.py | 7 +----- .../errors/_extractors/_businessrules.py | 1 - .../tests/common/test_resource_override.py | 4 ---- .../tests/errors/test_enriched_exception.py | 1 - .../tests/services/test_assets_service.py | 2 -- .../services/test_business_rules_service.py | 15 ------------ .../test_context_grounding_service.py | 2 -- 9 files changed, 5 insertions(+), 53 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 45b6eef56..6ceafc857 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 @@ -240,7 +240,6 @@ def _prepare_run( the arguments and, for a deployed run, picks the folder, so only the folder lookup and the request itself differ between the two. """ - # Decided on the caller's own folder arguments, before a binding swaps them. is_debug = _is_debug_run(folder_key, folder_path) business_rule_name, folder_key, folder_path = self._overridden_resource( business_rule_name, folder_key=folder_key, folder_path=folder_path @@ -255,9 +254,8 @@ def _prepare_run( folder_path=selected_path, ) - # On a private helper, not on run(): which endpoint a run takes depends on - # whether the caller named a folder, so _prepare_run decides that before a - # binding swaps the caller's folder for its own. + # Not on run(): routing depends on whether the caller named a folder, so + # _prepare_run decides it before a binding swaps the folder. @resource_override(resource_type="businessRule") def _overridden_resource( self, @@ -265,8 +263,6 @@ def _overridden_resource( folder_key: Optional[str] = None, folder_path: Optional[str] = None, ) -> Tuple[str, Optional[str], Optional[str]]: - # resource_override swaps these arguments when the solution's bindings - # remap this rule; the method just returns what it was given. return name, folder_key, folder_path def _select_folder( @@ -322,8 +318,6 @@ def _evaluate_spec( decision_names: Optional[List[str]] = None, caller: Optional[BusinessRuleCaller] = None, ) -> RequestSpec: - # The service resolves the rule in this folder and files the run's trace - # and audit record under it. request_body: Dict[str, Any] = { "businessRuleName": business_rule_name, "input": input_arguments, @@ -349,10 +343,6 @@ def _debug_evaluate_spec( *, decision_names: Optional[List[str]] = None, ) -> RequestSpec: - # The service finds the project, and the folders the job ran in, from the - # debug job's lineage, and checks the rule name against it. No folder - # header is sent, and neither is a version or caller: an undeployed rule - # has no version, and a debug run is not an audited execution. request_body: Dict[str, Any] = { "businessRuleName": business_rule_name, "input": input_arguments, @@ -420,8 +410,6 @@ def _caller_payload(caller: Optional[BusinessRuleCaller]) -> Dict[str, Any]: def _is_debug_session() -> bool: - # Studio Web sets the project id when it debugs; a job started from a - # solution debug, such as Maestro's, carries isDebug in its arguments. return UiPathConfig.is_studio_project or UiPathConfig.is_rooted_to_debug_job @@ -439,7 +427,6 @@ def _headers_with_trace( def _has_value(text: Optional[str]) -> bool: - # Blank counts as absent, matching how the service reads these fields. return bool(text and text.strip()) @@ -451,8 +438,7 @@ def _validate_run_arguments( def _validate_business_rule_name(business_rule_name: str) -> None: - # Only what a name can never be; which characters a name may hold is the - # service's to decide, and it rejects the rest with INVALID_REQUEST. + # Character rules are left to the service, which answers INVALID_REQUEST. if not _has_value(business_rule_name): raise ValueError("name must be specified") if len(business_rule_name) > _MAX_BUSINESS_RULE_NAME_LENGTH: @@ -476,7 +462,6 @@ def _validate_input_arguments(input_arguments: Dict[str, Any]) -> None: def _overall_status( decisions: List[BusinessRuleDecision], errors: List[BusinessRuleError] ) -> BusinessRuleStatus: - # An input-level error means the input never evaluated, whatever else came back. if errors: return BusinessRuleStatus.ALL_FAILED failed_count = sum(1 for decision in decisions if decision.error is not None) @@ -489,8 +474,6 @@ def _overall_status( def _to_run_result(response_body: Any) -> BusinessRuleRunResult: wire_response = _WireResponse.model_validate(response_body) - # A successful response always carries the input's result; one without it is - # not an answer to report as an evaluation. if wire_response.result is None: raise ValueError("The business rules response did not include a result") decisions = wire_response.result.decisions or [] 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 95ae5fe35..6dbf421f1 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 @@ -95,8 +95,7 @@ def to_traceparent(self) -> str: def _require_nonzero_hex(trace_part: str, field_name: str) -> None: - # _SpanUtils normalizes the form; the W3C traceparent also needs hex - # digits, and an all-zero id is invalid there. + # Checks _SpanUtils leaves out; W3C traceparent requires both. if not _HEX_DIGITS.match(trace_part): raise ValueError(f"{field_name} must contain only hex characters") if not trace_part.strip("0"): diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index fdf985050..11f5142a2 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -216,8 +216,6 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): _resource_overwrites.reset(self._token) -# A method's two ways of naming one folder: an overwrite that sets one clears -# the other. _OTHER_FOLDER_IDENTIFIER = {"folder_path": "folder_key", "folder_key": "folder_path"} @@ -289,10 +287,7 @@ def process_args(args, kwargs) -> dict[str, Any]: all_args[folder_identifier] = ( matched_overwrite.folder_identifier ) - # The overwrite's folder replaces the caller's: a key - # left beside a bound path (or a path beside a bound - # key) would name two folders, which header_folder() - # refuses. + # Otherwise header_folder() gets both and raises. other_folder_identifier = _OTHER_FOLDER_IDENTIFIER.get( folder_identifier ) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py index 6f7251eb7..2b3112eec 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py @@ -15,7 +15,6 @@ def extract_businessrules(body: dict[str, Any]) -> ExtractedErrorInfo: error_envelope = get_field(body, "error") if not isinstance(error_envelope, dict): - # Not the service's envelope, e.g. an error answered by the gateway. return extract_generic(body) return ExtractedErrorInfo( diff --git a/packages/uipath-platform/tests/common/test_resource_override.py b/packages/uipath-platform/tests/common/test_resource_override.py index 056f47695..86e701124 100644 --- a/packages/uipath-platform/tests/common/test_resource_override.py +++ b/packages/uipath-platform/tests/common/test_resource_override.py @@ -38,8 +38,6 @@ def asset_overwrite() -> Iterator[None]: def test_bound_folder_replaces_a_folder_key(asset_overwrite: None) -> None: - # Left beside the bound path, the key would name a second folder, which - # header_folder() refuses. assert retrieve("ApiKey", folder_key="callers-key") == ( "ApiKey EU", None, @@ -64,8 +62,6 @@ def test_folder_key_kept_without_a_matching_overwrite(asset_overwrite: None) -> def test_other_folder_parameters_are_left_alone() -> None: - # A method whose overwrite targets a differently named folder (as Action - # Center's app_folder_path) keeps its own folder_key. @resource_override(resource_type="app", folder_identifier="app_folder_path") def create( name: str, diff --git a/packages/uipath-platform/tests/errors/test_enriched_exception.py b/packages/uipath-platform/tests/errors/test_enriched_exception.py index 3ce6e6500..241b5a49d 100644 --- a/packages/uipath-platform/tests/errors/test_enriched_exception.py +++ b/packages/uipath-platform/tests/errors/test_enriched_exception.py @@ -267,7 +267,6 @@ def test_error_without_message(self): assert exc.error_info.message is None def test_other_shapes_fall_back_to_generic(self): - # An error answered by the gateway rather than the service. body = json.dumps({"message": "Unauthorized", "errorCode": 1015}) exc = EnrichedException(_make_error(401, body=body, url=_BUSINESS_RULES_URL)) assert exc.error_info is not None diff --git a/packages/uipath-platform/tests/services/test_assets_service.py b/packages/uipath-platform/tests/services/test_assets_service.py index a118901e8..4fd0fa99b 100644 --- a/packages/uipath-platform/tests/services/test_assets_service.py +++ b/packages/uipath-platform/tests/services/test_assets_service.py @@ -956,8 +956,6 @@ def test_bound_folder_replaces_callers_folder_key( org: str, tenant: str, ) -> None: - # Before resource_override cleared the caller's folder_key, the key and - # the bound path both reached header_folder(), which raised. import json httpx_mock.add_response( 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 b729d865c..3b2ff4462 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -115,8 +115,6 @@ def test_leaves_the_name_characters_to_the_service( evaluate_url: str, business_rule_name: str, ) -> None: - # The service decides which characters a name may hold, and answers a - # name it rejects with INVALID_REQUEST; the SDK sends it as given. httpx_mock.add_response(url=evaluate_url, json=_service_response()) service.run(business_rule_name, {}, folder_key=FOLDER_KEY) @@ -335,7 +333,6 @@ def test_sends_single_input_and_maps_decisions( folder_key=FOLDER_KEY, ) - # The enum member, as typed, and still equal to the service's text. assert result.status is BusinessRuleStatus.SUCCESS assert result.status == "Success" assert result.model_dump(mode="json")["status"] == "Success" @@ -454,7 +451,6 @@ def test_defaults_from_the_environment( "folderKey": "caller-folder-key", "isDebugRun": False, } - # The rule's folder, not the caller's, scopes the run. assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY def test_explicit_fields_win_over_the_environment( @@ -527,8 +523,6 @@ def test_studio_web_debug_session_is_a_debug_run( evaluate_url: str, monkeypatch: pytest.MonkeyPatch, ) -> None: - # A process debugged in Studio Web has no release, so no resource key; - # the flag tells the service the caller is a debug session. monkeypatch.setenv("UIPATH_PROJECT_ID", "studio-project-id") monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) httpx_mock.add_response(url=evaluate_url, json=_service_response()) @@ -837,8 +831,6 @@ def test_ambient_trace_is_used_without_trace_context( request = httpx_mock.get_request() assert request is not None - # The caller's own span is the parent: the SDK opens no span of its own, - # so the service's spans nest directly under the caller's. assert ( request.headers[TRACEPARENT] == f"00-{AMBIENT_TRACE_ID}-{AMBIENT_SPAN_ID}-01" @@ -872,9 +864,6 @@ def test_opens_no_span_of_its_own( recorded_spans: InMemorySpanExporter, monkeypatch: pytest.MonkeyPatch, ) -> None: - # The service records the run's spans itself, so the SDK adds none: the - # caller's span stays the parent, and the rule's input and outputs are not - # recorded on the client. monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) httpx_mock.add_response( url=evaluate_url, json=_service_response(_single_decision_result(x=1)) @@ -898,7 +887,6 @@ def test_sends_no_trace_header_outside_a_trace( evaluate_url: str, monkeypatch: pytest.MonkeyPatch, ) -> None: - # No current span and no trace_context: the service starts its own trace. monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) httpx_mock.add_response(url=evaluate_url, json=_service_response()) @@ -972,7 +960,6 @@ def test_explicit_trace_context_survives_a_retry( assert second.headers[TRACEPARENT] == expected def test_parent_span_id_accepts_uuid_form(self) -> None: - # As _SpanUtils.normalize_span_id reads a UUID: its last 16 hex digits. context = BusinessRuleTraceContext( trace_id=EXPLICIT_TRACE_ID, parent_span_id=f"00000000-0000-0000-{EXPLICIT_SPAN_ID[:4]}-{EXPLICIT_SPAN_ID[4:]}", @@ -1229,7 +1216,6 @@ def test_sends_no_folder_version_or_caller( folders_service.retrieve_folder_key.assert_not_called() request = httpx_mock.get_request() assert request is not None - # The service takes the job's folders from its lineage. assert HEADER_FOLDER_KEY not in request.headers assert "x-uipath-organizationunitid" not in request.headers assert "x-uipath-folderpath" not in request.headers @@ -1280,7 +1266,6 @@ def test_a_session_without_a_job_key_runs_deployed( evaluate_url: str, monkeypatch: pytest.MonkeyPatch, ) -> None: - # Running a project locally: no job, so no lineage to read it from. monkeypatch.setenv("UIPATH_PROJECT_ID", PROJECT_ID) monkeypatch.setenv("UIPATH_JOB_KEY", " ") httpx_mock.add_response(url=evaluate_url, json=_service_response()) diff --git a/packages/uipath-platform/tests/services/test_context_grounding_service.py b/packages/uipath-platform/tests/services/test_context_grounding_service.py index 5c0140595..ba890212d 100644 --- a/packages/uipath-platform/tests/services/test_context_grounding_service.py +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -4433,8 +4433,6 @@ def test_bound_folder_replaces_callers_folder_key( org: str, tenant: str, ) -> None: - # The caller's folder_key used to win over the binding's folder, so the - # bound index name was looked up in the caller's folder. httpx_mock.add_response( url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'eu-index'&$expand=dataSource", status_code=200, From 5bdbe187976e42c256dffebc968dea41178c0ff6 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Mon, 12 Oct 2026 14:44:29 +0530 Subject: [PATCH 46/47] chore(platform): bump uipath-platform to 0.2.36 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 be322ea1e..4699bfeeb 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.35" +version = "0.2.36" 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 b02fd2c40..99070bbf4 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.35" +version = "0.2.36" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 9887fc03a..09541f1cc 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.35" +version = "0.2.36" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From 864421a7ba836bf66c769e4225371b0df1f4e8e8 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Mon, 12 Oct 2026 18:40:13 +0530 Subject: [PATCH 47/47] chore(platform): bump uipath-platform to 0.2.37 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 4699bfeeb..df2ba2732 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.36" +version = "0.2.37" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 99070bbf4..9ada6c4ac 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.36" +version = "0.2.37" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 09541f1cc..0c4dcb200 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.36" +version = "0.2.37" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },