Skip to content

Commit 5573ed6

Browse files
refactor: decouple guardrails from _uipath to avoid cyclic dependency
1 parent baeb82b commit 5573ed6

8 files changed

Lines changed: 235 additions & 41 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,8 @@ type = "layers"
138138
containers = ["uipath.platform"]
139139
exhaustive = true
140140
layers = [
141-
"_uipath : guardrails : resume_triggers",
142-
"agenthub : automation_ops : automation_tracker : connections : context_grounding : entities : external_applications : governance : memory : pii_detection : portal : resource_catalog : semantic_proxy",
141+
"_uipath : resume_triggers",
142+
"agenthub : automation_ops : automation_tracker : connections : context_grounding : entities : external_applications : governance : guardrails : memory : pii_detection : portal : resource_catalog : semantic_proxy",
143143
"orchestrator",
144144
"action_center : attachments : chat : documents : identity",
145145
"common",

packages/uipath-platform/src/uipath/platform/_uipath.py

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
from functools import cached_property
22
from typing import Optional
33

4-
from pydantic import ValidationError
5-
64
from uipath.platform.automation_tracker import AutomationTrackerService
75

86
from .action_center import TasksService
@@ -12,15 +10,13 @@
1210
from .chat import ConversationsService, UiPathLlmChatService, UiPathOpenAIService
1311
from .common import (
1412
ApiClient,
15-
UiPathApiConfig,
1613
UiPathExecutionContext,
1714
)
18-
from .common.auth import resolve_config_from_env
15+
from .common.auth import build_api_config, resolve_config_from_env
1916
from .connections import ConnectionsService
2017
from .context_grounding import ContextGroundingService
2118
from .documents import DocumentsService
2219
from .entities import EntitiesService
23-
from .errors import BaseUrlMissingError, SecretMissingError
2420
from .external_applications import ExternalApplicationService
2521
from .governance import GovernanceService
2622
from .guardrails import GuardrailsService
@@ -60,25 +56,15 @@ def __init__(
6056
scope: Optional[str] = None,
6157
debug: bool = False,
6258
) -> None:
63-
try:
64-
if _has_valid_client_credentials(client_id, client_secret):
65-
assert client_id and client_secret
66-
service = ExternalApplicationService(base_url)
67-
token_data = service.get_token_data(client_id, client_secret, scope)
68-
base_url, secret = service._base_url, token_data.access_token
69-
else:
70-
base_url, secret = resolve_config_from_env(base_url, secret)
71-
72-
self._config = UiPathApiConfig(
73-
base_url=base_url,
74-
secret=secret,
75-
)
76-
except ValidationError as e:
77-
for error in e.errors():
78-
if error["loc"][0] == "base_url":
79-
raise BaseUrlMissingError() from e
80-
elif error["loc"][0] == "secret":
81-
raise SecretMissingError() from e
59+
if _has_valid_client_credentials(client_id, client_secret):
60+
assert client_id and client_secret
61+
service = ExternalApplicationService(base_url)
62+
token_data = service.get_token_data(client_id, client_secret, scope)
63+
base_url, secret = service._base_url, token_data.access_token
64+
else:
65+
base_url, secret = resolve_config_from_env(base_url, secret)
66+
67+
self._config = build_api_config(base_url, secret)
8268
self._execution_context = UiPathExecutionContext()
8369

8470
@property

packages/uipath-platform/src/uipath/platform/common/auth.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
from os import environ as env
44
from typing import Optional
55

6-
from pydantic import BaseModel
6+
from pydantic import BaseModel, ValidationError
77

88
from uipath.platform.constants import (
99
ENV_BASE_URL,
1010
ENV_UIPATH_ACCESS_TOKEN,
1111
ENV_UNATTENDED_USER_ACCESS_TOKEN,
1212
)
13+
from uipath.platform.errors import BaseUrlMissingError, SecretMissingError
14+
15+
from ._config import UiPathApiConfig
1316

1417

1518
class TokenData(BaseModel):
@@ -35,3 +38,24 @@ def resolve_config_from_env(
3538
or env.get(ENV_UIPATH_ACCESS_TOKEN)
3639
)
3740
return base_url_value, secret_value
41+
42+
43+
def build_api_config(
44+
base_url: Optional[str],
45+
secret: Optional[str],
46+
) -> UiPathApiConfig:
47+
"""Build a validated API config, translating missing fields into typed errors.
48+
49+
Raises:
50+
BaseUrlMissingError: If ``base_url`` is missing.
51+
SecretMissingError: If ``secret`` is missing.
52+
"""
53+
try:
54+
return UiPathApiConfig(base_url=base_url, secret=secret) # type: ignore[arg-type]
55+
except ValidationError as e:
56+
for error in e.errors():
57+
if error["loc"][0] == "base_url":
58+
raise BaseUrlMissingError() from e
59+
elif error["loc"][0] == "secret":
60+
raise SecretMissingError() from e
61+
raise
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Default env-driven guardrail evaluator."""
2+
3+
from typing import Any
4+
5+
from uipath.core.guardrails import GuardrailValidationResult
6+
7+
from ..common import UiPathExecutionContext
8+
from ..common.auth import build_api_config, resolve_config_from_env
9+
from ._guardrails_service import GuardrailsService
10+
from .guardrails import BuiltInValidatorGuardrail
11+
12+
13+
class DefaultGuardrailEvaluator:
14+
"""Evaluates guardrails via a ``GuardrailsService`` configured from env vars.
15+
16+
Raises:
17+
BaseUrlMissingError: If the base URL is not configured.
18+
SecretMissingError: If the access token is not configured.
19+
"""
20+
21+
def __init__(self) -> None:
22+
base_url, secret = resolve_config_from_env(None, None)
23+
self._service = GuardrailsService(
24+
build_api_config(base_url, secret), UiPathExecutionContext()
25+
)
26+
27+
def evaluate_guardrail(
28+
self,
29+
input_data: str | dict[str, Any],
30+
guardrail: BuiltInValidatorGuardrail,
31+
) -> GuardrailValidationResult:
32+
"""Validate input data using the provided guardrail."""
33+
return self._service.evaluate_guardrail(input_data, guardrail)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Internal protocol for guardrail evaluation capabilities."""
2+
3+
from typing import Any, Protocol
4+
5+
from uipath.core.guardrails import GuardrailValidationResult
6+
7+
from .guardrails import BuiltInValidatorGuardrail
8+
9+
10+
class GuardrailEvaluator(Protocol):
11+
"""Capability to evaluate a built-in guardrail against input data."""
12+
13+
def evaluate_guardrail(
14+
self,
15+
input_data: str | dict[str, Any],
16+
guardrail: BuiltInValidatorGuardrail,
17+
) -> GuardrailValidationResult:
18+
"""Validate input data using the provided guardrail."""
19+
...

packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/_base.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from uipath.core.guardrails import GuardrailValidationResult
77

8+
from uipath.platform.guardrails._default_evaluator import DefaultGuardrailEvaluator
9+
from uipath.platform.guardrails._evaluator import GuardrailEvaluator
810
from uipath.platform.guardrails.guardrails import BuiltInValidatorGuardrail
911

1012
from .._enums import GuardrailExecutionStage
@@ -78,6 +80,8 @@ def get_built_in_guardrail(self, name, description, enabled_for_evals):
7880
)
7981
"""
8082

83+
_evaluator: GuardrailEvaluator | None = None
84+
8185
@abstractmethod
8286
def get_built_in_guardrail(
8387
self,
@@ -109,15 +113,13 @@ def run(
109113
) -> GuardrailValidationResult:
110114
"""Evaluate via the UiPath Guardrails API.
111115
112-
Lazily initialises the ``UiPath`` client on the first call and reuses
113-
it for all subsequent invocations.
116+
Lazily initialises the guardrail evaluator on the first call and
117+
reuses it for all subsequent invocations.
114118
"""
115119
built_in = self.get_built_in_guardrail(name, description, enabled_for_evals)
116-
if not hasattr(self, "_uipath"):
117-
from uipath.platform import UiPath
118-
119-
self._uipath: Any = UiPath()
120-
return self._uipath.guardrails.evaluate_guardrail(data, built_in)
120+
if self._evaluator is None:
121+
self._evaluator = DefaultGuardrailEvaluator()
122+
return self._evaluator.evaluate_guardrail(data, built_in)
121123

122124

123125
class CustomGuardrailValidator(GuardrailValidatorBase, ABC):
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Tests for config helpers in uipath.platform.common.auth."""
2+
3+
from unittest.mock import patch
4+
5+
import pytest
6+
from pydantic import BaseModel, ValidationError
7+
8+
from uipath.platform.common import UiPathApiConfig
9+
from uipath.platform.common.auth import build_api_config
10+
from uipath.platform.errors import BaseUrlMissingError, SecretMissingError
11+
12+
13+
class TestBuildApiConfig:
14+
def test_returns_config_when_both_values_present(self):
15+
config = build_api_config("https://test.uipath.com", "token")
16+
assert isinstance(config, UiPathApiConfig)
17+
assert config.base_url == "https://test.uipath.com"
18+
assert config.secret == "token"
19+
20+
def test_missing_base_url_raises_typed_error(self):
21+
with pytest.raises(BaseUrlMissingError):
22+
build_api_config(None, "token")
23+
24+
def test_missing_secret_raises_typed_error(self):
25+
with pytest.raises(SecretMissingError):
26+
build_api_config("https://test.uipath.com", None)
27+
28+
def test_both_missing_raises_base_url_error_first(self):
29+
with pytest.raises(BaseUrlMissingError):
30+
build_api_config(None, None)
31+
32+
def test_unmatched_validation_error_is_reraised(self):
33+
class _Other(BaseModel):
34+
other: str
35+
36+
with pytest.raises(ValidationError) as exc_info:
37+
_Other.model_validate({})
38+
unmatched = exc_info.value
39+
40+
with patch(
41+
"uipath.platform.common.auth.UiPathApiConfig", side_effect=unmatched
42+
):
43+
with pytest.raises(ValidationError):
44+
build_api_config("https://test.uipath.com", "token")

packages/uipath-platform/tests/services/test_guardrails_decorators.py

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -989,8 +989,8 @@ def test_custom_validator_path_delegates_to_run(self):
989989
)
990990
assert result == _PASSED
991991

992-
def test_built_in_validator_path_lazy_initializes_uipath(self):
993-
"""BuiltInGuardrailValidator.run() lazily creates UiPath() and calls API."""
992+
def test_built_in_validator_uses_injected_evaluator(self):
993+
"""BuiltInGuardrailValidator.run() delegates to an injected evaluator."""
994994
from uipath.platform.guardrails.decorators.validators import (
995995
BuiltInGuardrailValidator,
996996
)
@@ -1003,16 +1003,102 @@ def get_built_in_guardrail(self, name, description, enabled_for_evals):
10031003
return mock_built_in
10041004

10051005
validator = _TestBuiltIn()
1006+
fake_evaluator = MagicMock()
1007+
fake_evaluator.evaluate_guardrail.return_value = _PASSED
1008+
validator._evaluator = fake_evaluator
10061009
evaluator = _make_evaluator(validator, "G", None, True)
10071010

1008-
mock_uipath = MagicMock()
1009-
mock_uipath.guardrails.evaluate_guardrail.return_value = _PASSED
1010-
with patch("uipath.platform.UiPath", return_value=mock_uipath):
1011+
result = evaluator({"text": "hello"}, GuardrailExecutionStage.PRE, None, None)
1012+
1013+
fake_evaluator.evaluate_guardrail.assert_called_once_with(
1014+
{"text": "hello"}, mock_built_in
1015+
)
1016+
assert result == _PASSED
1017+
1018+
def test_built_in_validator_path_lazy_initializes_default_evaluator(self):
1019+
"""BuiltInGuardrailValidator.run() creates the default evaluator once."""
1020+
from uipath.platform.guardrails.decorators.validators import (
1021+
BuiltInGuardrailValidator,
1022+
)
1023+
from uipath.platform.guardrails.guardrails import BuiltInValidatorGuardrail
1024+
1025+
mock_built_in = MagicMock(spec=BuiltInValidatorGuardrail)
1026+
1027+
class _TestBuiltIn(BuiltInGuardrailValidator):
1028+
def get_built_in_guardrail(self, name, description, enabled_for_evals):
1029+
return mock_built_in
1030+
1031+
validator = _TestBuiltIn()
1032+
evaluator = _make_evaluator(validator, "G", None, True)
1033+
1034+
with patch(
1035+
"uipath.platform.guardrails.decorators.validators._base.DefaultGuardrailEvaluator"
1036+
) as mock_default:
1037+
mock_default.return_value.evaluate_guardrail.return_value = _PASSED
1038+
evaluator({"text": "hello"}, GuardrailExecutionStage.PRE, None, None)
1039+
evaluator({"text": "hello"}, GuardrailExecutionStage.PRE, None, None)
1040+
1041+
mock_default.assert_called_once_with()
1042+
assert mock_default.return_value.evaluate_guardrail.call_count == 2
1043+
1044+
def test_built_in_validator_without_env_config_raises_missing_errors(
1045+
self, monkeypatch
1046+
):
1047+
"""Missing env credentials surface as typed errors from the decorator path."""
1048+
from uipath.platform.errors import BaseUrlMissingError, SecretMissingError
1049+
from uipath.platform.guardrails.decorators.validators import (
1050+
BuiltInGuardrailValidator,
1051+
)
1052+
from uipath.platform.guardrails.guardrails import BuiltInValidatorGuardrail
1053+
1054+
class _TestBuiltIn(BuiltInGuardrailValidator):
1055+
def get_built_in_guardrail(self, name, description, enabled_for_evals):
1056+
return MagicMock(spec=BuiltInValidatorGuardrail)
1057+
1058+
for var in (
1059+
"UIPATH_URL",
1060+
"UNATTENDED_USER_ACCESS_TOKEN",
1061+
"UIPATH_ACCESS_TOKEN",
1062+
):
1063+
monkeypatch.delenv(var, raising=False)
1064+
1065+
evaluator = _make_evaluator(_TestBuiltIn(), "G", None, True)
1066+
with pytest.raises(BaseUrlMissingError):
10111067
evaluator({"text": "hello"}, GuardrailExecutionStage.PRE, None, None)
1068+
1069+
monkeypatch.setenv("UIPATH_URL", "https://test.uipath.com")
1070+
with pytest.raises(SecretMissingError):
10121071
evaluator({"text": "hello"}, GuardrailExecutionStage.PRE, None, None)
10131072

1014-
# UiPath() should be created only once despite two calls
1015-
assert mock_uipath.guardrails.evaluate_guardrail.call_count == 2
1073+
1074+
# ---------------------------------------------------------------------------
1075+
# 15b. DefaultGuardrailEvaluator
1076+
# ---------------------------------------------------------------------------
1077+
1078+
1079+
class TestDefaultGuardrailEvaluator:
1080+
def test_constructs_service_from_env_and_delegates(self, monkeypatch):
1081+
"""The default evaluator builds a GuardrailsService from env config."""
1082+
from uipath.platform.guardrails._default_evaluator import (
1083+
DefaultGuardrailEvaluator,
1084+
)
1085+
from uipath.platform.guardrails._guardrails_service import GuardrailsService
1086+
from uipath.platform.guardrails.guardrails import BuiltInValidatorGuardrail
1087+
1088+
monkeypatch.setenv("UIPATH_URL", "https://test.uipath.com")
1089+
monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token")
1090+
1091+
evaluator = DefaultGuardrailEvaluator()
1092+
assert isinstance(evaluator._service, GuardrailsService)
1093+
1094+
mock_built_in = MagicMock(spec=BuiltInValidatorGuardrail)
1095+
with patch.object(
1096+
evaluator._service, "evaluate_guardrail", return_value=_PASSED
1097+
) as mock_evaluate:
1098+
result = evaluator.evaluate_guardrail("hello", mock_built_in)
1099+
1100+
mock_evaluate.assert_called_once_with("hello", mock_built_in)
1101+
assert result == _PASSED
10161102

10171103

10181104
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)