Skip to content

Commit 41f54ec

Browse files
Merge branch 'feat/business-rules-evaluate' into feat/business-rules-run
Brings in #1912's shared run()/run_async() steps. The debug path joins them: _RunTarget also carries the debug job key (and no folder), decided in _prepare_run() before the binding, and _run_spec() builds the request for the endpoint the target names. run() and run_async() stay three steps each. No behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents a2f14f2 + fa84d39 commit 41f54ec

2 files changed

Lines changed: 132 additions & 64 deletions

File tree

‎packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py‎

Lines changed: 114 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import unicodedata
88
from collections.abc import Mapping
9+
from dataclasses import dataclass
910
from typing import Any, Dict, List, Optional, Tuple
1011

1112
from ..common._base_service import _TRACE_PARENT_HEADER, BaseService
@@ -34,6 +35,21 @@
3435
_FORBIDDEN_RULE_NAME_PARTS = ("/", "\\", "..", "%")
3536

3637

38+
@dataclass(frozen=True)
39+
class _RunTarget:
40+
"""Where a run goes, settled before anything is sent.
41+
42+
A debug run carries ``debug_job_key`` and no folder. A deployed run carries
43+
exactly one of ``folder_key`` and ``folder_path``: a key ready to send, or a
44+
path still to look up.
45+
"""
46+
47+
name: str
48+
folder_key: Optional[str] = None
49+
folder_path: Optional[str] = None
50+
debug_job_key: Optional[str] = None
51+
52+
3753
class BusinessRulesService(FolderContext, BaseService):
3854
"""Service for running UiPath Business Rules.
3955
@@ -129,32 +145,19 @@ def run(
129145
)
130146
```
131147
"""
132-
debug_job_key = _resolve_debug_job_key(folder_key, folder_path)
133-
name, folder_key, folder_path = self._apply_binding(
134-
name, folder_key, folder_path
135-
)
136-
_validate_run_arguments(name, input)
137-
if debug_job_key:
138-
spec = self._debug_evaluate_spec(
139-
name, input, job_key=debug_job_key, decision_names=decision_names
140-
)
141-
else:
142-
spec = self._evaluate_spec(
143-
name,
144-
input,
145-
folder_key=self._resolve_folder_key(folder_key, folder_path),
146-
version=version,
147-
decision_names=decision_names,
148-
caller=caller,
149-
)
150-
response = self.request(
151-
spec.method,
152-
url=spec.endpoint,
153-
json=spec.json,
154-
headers=_headers_with_trace(spec.headers, trace_context),
155-
scoped="tenant",
148+
target = self._prepare_run(name, input, folder_key, folder_path)
149+
spec = self._run_spec(
150+
target,
151+
input,
152+
folder_key=self._resolve_folder_key(target),
153+
version=version,
154+
decision_names=decision_names,
155+
caller=caller,
156156
)
157-
return _to_run_result(_WireResponse.model_validate(response.json()))
157+
# Called here, not in a helper: BaseService names the user agent after
158+
# the method that calls request(), which must be the public run().
159+
response = self.request(spec.method, **_request_options(spec, trace_context))
160+
return _to_run_result(response.json())
158161

159162
async def run_async(
160163
self,
@@ -193,34 +196,43 @@ async def run_async(
193196
ValueError: If the request is invalid or a required folder is missing.
194197
EnrichedException: If the service rejects the request.
195198
"""
199+
target = self._prepare_run(name, input, folder_key, folder_path)
200+
spec = self._run_spec(
201+
target,
202+
input,
203+
folder_key=await self._resolve_folder_key_async(target),
204+
version=version,
205+
decision_names=decision_names,
206+
caller=caller,
207+
)
208+
response = await self.request_async(
209+
spec.method, **_request_options(spec, trace_context)
210+
)
211+
return _to_run_result(response.json())
212+
213+
def _prepare_run(
214+
self,
215+
name: str,
216+
input: Dict[str, Any],
217+
folder_key: Optional[str],
218+
folder_path: Optional[str],
219+
) -> _RunTarget:
220+
"""Do what run() and run_async() share before the first network call.
221+
222+
Decides between a debug and a deployed run, applies a binding, validates
223+
the arguments and, for a deployed run, picks the folder, so only the
224+
folder lookup and the request itself differ between the two.
225+
"""
226+
# Decided on the caller's own folder arguments, before a binding swaps them.
196227
debug_job_key = _resolve_debug_job_key(folder_key, folder_path)
197228
name, folder_key, folder_path = self._apply_binding(
198229
name, folder_key, folder_path
199230
)
200231
_validate_run_arguments(name, input)
201232
if debug_job_key:
202-
spec = self._debug_evaluate_spec(
203-
name, input, job_key=debug_job_key, decision_names=decision_names
204-
)
205-
else:
206-
spec = self._evaluate_spec(
207-
name,
208-
input,
209-
folder_key=await self._resolve_folder_key_async(
210-
folder_key, folder_path
211-
),
212-
version=version,
213-
decision_names=decision_names,
214-
caller=caller,
215-
)
216-
response = await self.request_async(
217-
spec.method,
218-
url=spec.endpoint,
219-
json=spec.json,
220-
headers=_headers_with_trace(spec.headers, trace_context),
221-
scoped="tenant",
222-
)
223-
return _to_run_result(_WireResponse.model_validate(response.json()))
233+
return _RunTarget(name=name, debug_job_key=debug_job_key)
234+
selected_key, selected_path = self._select_folder(folder_key, folder_path)
235+
return _RunTarget(name=name, folder_key=selected_key, folder_path=selected_path)
224236

225237
@resource_override(resource_type="businessRule")
226238
def _overridden_resource(
@@ -273,25 +285,50 @@ def _select_folder(
273285
"folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH"
274286
)
275287

276-
def _resolve_folder_key(
277-
self, folder_key: Optional[str], folder_path: Optional[str]
278-
) -> str:
279-
"""Return the key of the folder the rule runs in, looking up a path."""
280-
selected_key, selected_path = self._select_folder(folder_key, folder_path)
281-
if selected_path:
282-
selected_key = self._folders_service.retrieve_folder_key(selected_path)
283-
return _require_folder_key(selected_key, selected_path)
288+
def _resolve_folder_key(self, target: _RunTarget) -> Optional[str]:
289+
"""Return the key of the folder the rule runs in, looking up a path.
284290
285-
async def _resolve_folder_key_async(
286-
self, folder_key: Optional[str], folder_path: Optional[str]
287-
) -> str:
291+
None for a debug run, which names no folder, and for a path that
292+
matches no folder, which _run_spec refuses.
293+
"""
294+
if target.folder_path:
295+
return self._folders_service.retrieve_folder_key(target.folder_path)
296+
return target.folder_key
297+
298+
async def _resolve_folder_key_async(self, target: _RunTarget) -> Optional[str]:
288299
"""Asynchronously return the key of the folder the rule runs in."""
289-
selected_key, selected_path = self._select_folder(folder_key, folder_path)
290-
if selected_path:
291-
selected_key = await self._folders_service.retrieve_folder_key_async(
292-
selected_path
300+
if target.folder_path:
301+
return await self._folders_service.retrieve_folder_key_async(
302+
target.folder_path
293303
)
294-
return _require_folder_key(selected_key, selected_path)
304+
return target.folder_key
305+
306+
def _run_spec(
307+
self,
308+
target: _RunTarget,
309+
input: Dict[str, Any],
310+
*,
311+
folder_key: Optional[str],
312+
version: Optional[str] = None,
313+
decision_names: Optional[List[str]] = None,
314+
caller: Optional[BusinessRuleCaller] = None,
315+
) -> RequestSpec:
316+
"""Build the request for the endpoint the target names."""
317+
if target.debug_job_key:
318+
return self._debug_evaluate_spec(
319+
target.name,
320+
input,
321+
job_key=target.debug_job_key,
322+
decision_names=decision_names,
323+
)
324+
return self._evaluate_spec(
325+
target.name,
326+
input,
327+
folder_key=_require_folder_key(folder_key, target.folder_path),
328+
version=version,
329+
decision_names=decision_names,
330+
caller=caller,
331+
)
295332

296333
def _evaluate_spec(
297334
self,
@@ -360,6 +397,18 @@ def _resolve_debug_job_key(
360397
return job_key if job_key and job_key.strip() else None
361398

362399

400+
def _request_options(
401+
spec: RequestSpec, trace_context: Optional[TraceContext]
402+
) -> Dict[str, Any]:
403+
"""Return the request() arguments for a spec, besides its method."""
404+
return {
405+
"url": spec.endpoint,
406+
"json": spec.json,
407+
"headers": _headers_with_trace(spec.headers, trace_context),
408+
"scoped": "tenant",
409+
}
410+
411+
363412
def _require_folder_key(folder_key: Optional[str], folder_path: Optional[str]) -> str:
364413
# A folder path that matches no folder looks up to None.
365414
if folder_key and folder_key.strip():
@@ -462,7 +511,8 @@ def _overall_status(
462511
return BusinessRuleStatus.PARTIAL_SUCCESS
463512

464513

465-
def _to_run_result(response: _WireResponse) -> BusinessRuleRunResult:
514+
def _to_run_result(response_body: Any) -> BusinessRuleRunResult:
515+
response = _WireResponse.model_validate(response_body)
466516
# A successful response always carries the input's result; one without it is
467517
# not an answer to report as an evaluation.
468518
if response.result is None:

‎packages/uipath-platform/tests/services/test_business_rules_service.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,24 @@ def test_sends_no_explain_or_batch(
390390
assert "explain" not in body
391391
assert "inputs" not in body
392392

393+
async def test_run_async_names_itself_in_the_user_agent(
394+
self,
395+
httpx_mock: HTTPXMock,
396+
service: BusinessRulesService,
397+
evaluate_url: str,
398+
version: str,
399+
) -> None:
400+
httpx_mock.add_response(url=evaluate_url, json=_response())
401+
402+
await service.run_async(RULE, {}, folder_key=FOLDER_KEY)
403+
404+
request = httpx_mock.get_request()
405+
assert request is not None
406+
assert (
407+
request.headers[HEADER_USER_AGENT]
408+
== f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.BusinessRulesService.run_async/{version}"
409+
)
410+
393411
async def test_run_async_resolves_folder_path(
394412
self,
395413
httpx_mock: HTTPXMock,

0 commit comments

Comments
 (0)