From 752737fc9242ba87e732b5550ac523eb9f6fe6ce Mon Sep 17 00:00:00 2001 From: Akshaya Shanbhogue Date: Wed, 22 Apr 2026 08:17:01 -0700 Subject: [PATCH] fix(mocks): pass invocation as a tuple to avoid arg name collisions A function decorated with `@mockable` previously errored if it had an argument named `func` or `params`, because those names collided with positional parameters on the internal `response()` and `get_mocked_response()` signatures when user kwargs were spread through. Bundle `(args, kwargs)` into a single `invocation` positional argument so user kwargs are never spread across internal signatures. This removes the collision entirely for any user argument name. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/uipath/pyproject.toml | 2 +- .../src/uipath/eval/mocks/_llm_mocker.py | 7 +- .../src/uipath/eval/mocks/_mock_context.py | 6 +- .../uipath/src/uipath/eval/mocks/_mocker.py | 3 +- .../src/uipath/eval/mocks/_mockito_mocker.py | 7 +- .../uipath/src/uipath/eval/mocks/mockable.py | 2 +- .../eval/mocks/test_mockable_arg_collision.py | 107 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 8 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 packages/uipath/tests/cli/eval/mocks/test_mockable_arg_collision.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index b80902532..ae68beed7 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.52" +version = "2.10.53" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py index 3715ac226..d1fd2a1c9 100644 --- a/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py @@ -96,11 +96,16 @@ def __init__(self, context: MockingContext): @traced(name="__mocker__", recording=False) async def response( - self, func: Callable[[T], R], params: dict[str, Any], *args: T, **kwargs + self, + func: Callable[[T], R], + params: dict[str, Any], + invocation: tuple[tuple[Any, ...], dict[str, Any]], ) -> R: """Respond with mocked response generated by an LLM.""" assert isinstance(self.context.strategy, LLMMockingStrategy) + args, kwargs = invocation + function_name = params.get("name") or func.__name__ if function_name in [x.name for x in self.context.strategy.tools_to_simulate]: uipath = UiPath() diff --git a/packages/uipath/src/uipath/eval/mocks/_mock_context.py b/packages/uipath/src/uipath/eval/mocks/_mock_context.py index c2335544f..a50df9cba 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mock_context.py +++ b/packages/uipath/src/uipath/eval/mocks/_mock_context.py @@ -64,11 +64,13 @@ def is_tool_simulated(tool_name: str) -> bool: async def get_mocked_response( - func: Callable[[Any], Any], params: dict[str, Any], *args, **kwargs + func: Callable[[Any], Any], + params: dict[str, Any], + invocation: tuple[tuple[Any, ...], dict[str, Any]], ) -> Any: """Get a mocked response.""" mocker = mocker_context.get() if mocker is None: raise UiPathNoMockFoundError() else: - return await mocker.response(func, params, *args, **kwargs) + return await mocker.response(func, params, invocation) diff --git a/packages/uipath/src/uipath/eval/mocks/_mocker.py b/packages/uipath/src/uipath/eval/mocks/_mocker.py index 57cb8bcc3..99e5da1b2 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_mocker.py @@ -16,8 +16,7 @@ async def response( self, func: Callable[[T], R], params: dict[str, Any], - *args: T, - **kwargs, + invocation: tuple[tuple[Any, ...], dict[str, Any]], ) -> R: """Respond with mocked response.""" raise NotImplementedError() diff --git a/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py b/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py index 041478baf..a9b30230f 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py @@ -99,12 +99,17 @@ def __init__(self, context: MockingContext): stubbed = stubbed.thenRaise(_resolve_value(answer_dict["value"])) async def response( - self, func: Callable[[T], R], params: dict[str, Any], *args: T, **kwargs + self, + func: Callable[[T], R], + params: dict[str, Any], + invocation: tuple[tuple[Any, ...], dict[str, Any]], ) -> R: """Return mocked response or raise appropriate errors.""" if not isinstance(self.context.strategy, MockitoMockingStrategy): raise UiPathMockResponseGenerationError("Mocking strategy misconfigured.") + args, kwargs = invocation + # No behavior configured → call real function is_mocked = any( behavior.function == params["name"] diff --git a/packages/uipath/src/uipath/eval/mocks/mockable.py b/packages/uipath/src/uipath/eval/mocks/mockable.py index 254f88b89..3e9a324b9 100644 --- a/packages/uipath/src/uipath/eval/mocks/mockable.py +++ b/packages/uipath/src/uipath/eval/mocks/mockable.py @@ -39,7 +39,7 @@ def mocked_response_decorator(func, params: dict[str, Any]): """Mocked response decorator.""" async def mock_response_generator(*args, **kwargs): - mocked_response = await get_mocked_response(func, params, *args, **kwargs) + mocked_response = await get_mocked_response(func, params, (args, kwargs)) # Mocking successful. context = UiPathSpanUtils.get_parent_context() diff --git a/packages/uipath/tests/cli/eval/mocks/test_mockable_arg_collision.py b/packages/uipath/tests/cli/eval/mocks/test_mockable_arg_collision.py new file mode 100644 index 000000000..838e9d835 --- /dev/null +++ b/packages/uipath/tests/cli/eval/mocks/test_mockable_arg_collision.py @@ -0,0 +1,107 @@ +"""Regression tests: @mockable must not collide with user args named `func`/`params`.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from uipath.eval.mocks import mockable +from uipath.eval.mocks._mock_runtime import ( + clear_execution_context, + set_execution_context, +) +from uipath.eval.mocks._types import MockingContext +from uipath.eval.models.evaluation_set import EvaluationItem + +_mock_span_collector = MagicMock() + + +def _build_evaluation( + function_name: str, kwargs: dict[str, Any], value: Any +) -> EvaluationItem: + evaluation_item: dict[str, Any] = { + "id": "evaluation-id", + "name": "Test evaluation", + "inputs": {}, + "evaluationCriterias": {"ExactMatchEvaluator": None}, + "mockingStrategy": { + "type": "mockito", + "behaviors": [ + { + "function": function_name, + "arguments": {"args": [], "kwargs": kwargs}, + "then": [{"type": "return", "value": value}], + } + ], + }, + } + return EvaluationItem(**evaluation_item) + + +class TestMockableArgCollision: + """Ensure `@mockable` works when the wrapped function has args named `func` or `params`.""" + + def test_sync_function_with_func_and_params_args(self): + """A sync mockable function that takes `func` and `params` kwargs should not raise.""" + + @mockable() + def test_function(func: str, params: dict[str, Any]) -> str: + raise NotImplementedError() + + evaluation = _build_evaluation( + "test_function", + kwargs={"func": "some_func", "params": {"k": "v"}}, + value="mocked_result", + ) + + set_execution_context( + MockingContext( + strategy=evaluation.mocking_strategy, + name=evaluation.name, + inputs=evaluation.inputs, + ), + _mock_span_collector, + "test-execution-id", + ) + + try: + with patch("uipath.eval.mocks.mockable.UiPathSpanUtils"): + with patch("uipath.eval.mocks.mockable.trace"): + result = test_function(func="some_func", params={"k": "v"}) + + assert result == "mocked_result" + finally: + clear_execution_context() + + @pytest.mark.asyncio + async def test_async_function_with_func_and_params_args(self): + """An async mockable function that takes `func` and `params` kwargs should not raise.""" + + @mockable() + async def test_function(func: str, params: dict[str, Any]) -> str: + raise NotImplementedError() + + evaluation = _build_evaluation( + "test_function", + kwargs={"func": "some_func", "params": {"k": "v"}}, + value="mocked_result", + ) + + set_execution_context( + MockingContext( + strategy=evaluation.mocking_strategy, + name=evaluation.name, + inputs=evaluation.inputs, + ), + _mock_span_collector, + "test-execution-id", + ) + + try: + with patch("uipath.eval.mocks.mockable.UiPathSpanUtils"): + with patch("uipath.eval.mocks.mockable.trace"): + result = await test_function(func="some_func", params={"k": "v"}) + + assert result == "mocked_result" + finally: + clear_execution_context() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e167bb65c..d922431c1 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.52" +version = "2.10.53" source = { editable = "." } dependencies = [ { name = "applicationinsights" },