Skip to content

Commit 1d72a3b

Browse files
Valentina Bojanclaude
andcommitted
refactor: scope execution source via ExecutionSourceContext manager
Per review feedback, set the execution source with a context manager that releases the ContextVar token on exit (like ResourceOverwritesContext and the runtime ctx), instead of a bare setter. CLI handlers enter 'with ExecutionSourceContext(ctx.execution_source), ctx:'; dev wraps the console/server run. Adds a dev terminal test covering the scoped run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9be628e commit 1d72a3b

9 files changed

Lines changed: 109 additions & 71 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
)
1717
from ._config import UiPathApiConfig, UiPathConfig
1818
from ._endpoints_manager import EndpointManager
19-
from ._execution_context import UiPathExecutionContext, set_execution_source
19+
from ._execution_context import ExecutionSourceContext, UiPathExecutionContext
2020
from ._external_application_service import ExternalApplicationService
2121
from ._folder_context import FolderContext, header_folder
2222
from ._http_config import get_ca_bundle_path, get_httpx_client_kwargs
@@ -61,7 +61,7 @@
6161
"BaseService",
6262
"UiPathApiConfig",
6363
"UiPathExecutionContext",
64-
"set_execution_source",
64+
"ExecutionSourceContext",
6565
"ExternalApplicationService",
6666
"FolderContext",
6767
"TokenData",

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from contextvars import ContextVar
1+
from contextvars import ContextVar, Token
22
from os import environ as env
33
from typing import Optional
44

@@ -9,15 +9,28 @@
99
)
1010

1111

12-
def set_execution_source(execution_source: Optional[str]) -> None:
13-
"""Set the execution source for the current run.
12+
class ExecutionSourceContext:
13+
"""Scope the execution source for the duration of a run.
1414
1515
Carries the source (e.g. ``runtime``/``playground``/``eval``) via a context
16-
variable instead of a process-global env var. The CLI sets this from
16+
variable instead of a process-global env var, and releases it on exit so it
17+
stays correctly scoped in concurrent runs. The CLI enters this with
1718
``UiPathRuntimeContext.execution_source`` so platform clients can read it
1819
via :attr:`UiPathExecutionContext.execution_source`.
1920
"""
20-
_execution_source.set(execution_source)
21+
22+
def __init__(self, execution_source: Optional[str]) -> None:
23+
self._execution_source = execution_source
24+
self._token: Optional[Token[Optional[str]]] = None
25+
26+
def __enter__(self) -> "ExecutionSourceContext":
27+
self._token = _execution_source.set(self._execution_source)
28+
return self
29+
30+
def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
31+
if self._token is not None:
32+
_execution_source.reset(self._token)
33+
self._token = None
2134

2235

2336
class UiPathExecutionContext:
Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,26 @@
1-
import pytest
2-
3-
from uipath.platform.common import UiPathExecutionContext, set_execution_source
4-
5-
6-
@pytest.fixture(autouse=True)
7-
def _reset_execution_source() -> object:
8-
set_execution_source(None)
9-
yield
10-
set_execution_source(None)
1+
from uipath.platform.common import ExecutionSourceContext, UiPathExecutionContext
112

123

134
def test_execution_source_none_by_default() -> None:
145
assert UiPathExecutionContext().execution_source is None
156

167

17-
def test_execution_source_reflects_set_value() -> None:
8+
def test_execution_source_set_within_context() -> None:
189
ctx = UiPathExecutionContext()
1910

20-
set_execution_source("runtime")
11+
with ExecutionSourceContext("runtime"):
12+
assert ctx.execution_source == "runtime"
2113

22-
assert ctx.execution_source == "runtime"
14+
assert ctx.execution_source is None
2315

2416

25-
def test_execution_source_can_be_cleared() -> None:
17+
def test_execution_source_context_restores_previous_value() -> None:
2618
ctx = UiPathExecutionContext()
2719

28-
set_execution_source("eval")
29-
assert ctx.execution_source == "eval"
20+
with ExecutionSourceContext("eval"):
21+
assert ctx.execution_source == "eval"
22+
with ExecutionSourceContext("playground"):
23+
assert ctx.execution_source == "playground"
24+
assert ctx.execution_source == "eval"
3025

31-
set_execution_source(None)
3226
assert ctx.execution_source is None

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

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
)
1111

1212
from uipath.platform import UiPathApiConfig, UiPathExecutionContext
13-
from uipath.platform.common import set_execution_source
13+
from uipath.platform.common import ExecutionSourceContext
1414
from uipath.platform.guardrails import (
1515
BuiltInValidatorGuardrail,
1616
EnumListParameterValue,
@@ -19,13 +19,6 @@
1919
)
2020

2121

22-
@pytest.fixture(autouse=True)
23-
def _reset_execution_source() -> object:
24-
set_execution_source(None)
25-
yield
26-
set_execution_source(None)
27-
28-
2922
@pytest.fixture
3023
def service(
3124
config: UiPathApiConfig,
@@ -405,8 +398,8 @@ def capture_request(request):
405398
validator_parameters=[],
406399
)
407400

408-
set_execution_source("runtime")
409-
service.evaluate_guardrail("test input", pii_guardrail)
401+
with ExecutionSourceContext("runtime"):
402+
service.evaluate_guardrail("test input", pii_guardrail)
410403

411404
assert captured_request is not None
412405
headers = dict(captured_request.headers)

packages/uipath/src/uipath/_cli/cli_debug.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
from uipath.eval.mocks import UiPathMockRuntime
1313
from uipath.eval.mocks._mock_runtime import load_simulation_config
1414
from uipath.platform.common import (
15+
ExecutionSourceContext,
1516
ResourceOverwritesContext,
1617
UiPathConfig,
17-
set_execution_source,
1818
)
1919
from uipath.runtime import (
2020
UiPathExecuteOptions,
@@ -134,8 +134,7 @@ async def execute_debug_runtime():
134134
trace_manager=trace_manager,
135135
command="debug",
136136
)
137-
set_execution_source(ctx.execution_source)
138-
with ctx:
137+
with ExecutionSourceContext(ctx.execution_source), ctx:
139138
factory: UiPathRuntimeFactoryProtocol | None = None
140139

141140
try:

packages/uipath/src/uipath/_cli/cli_dev.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,18 @@
77
from uipath._cli._utils._debug import setup_debugging
88
from uipath._cli.middlewares import Middlewares
99
from uipath.core.tracing import UiPathTraceManager
10-
from uipath.platform.common import set_execution_source
10+
from uipath.platform.common import ExecutionSourceContext
1111
from uipath.runtime import UiPathRuntimeContext, UiPathRuntimeFactoryRegistry
1212

1313
from ._telemetry import track_command
1414

1515
console = ConsoleLogger()
1616

1717

18-
def _create_dev_factory(trace_manager: UiPathTraceManager):
19-
"""Build the dev runtime factory and propagate the execution source."""
18+
def _create_dev_context_and_factory(trace_manager: UiPathTraceManager):
19+
"""Build the dev runtime context and its factory."""
2020
context = UiPathRuntimeContext(trace_manager=trace_manager, command="dev")
21-
set_execution_source(context.execution_source)
22-
return UiPathRuntimeFactoryRegistry.get(context=context)
21+
return context, UiPathRuntimeFactoryRegistry.get(context=context)
2322

2423

2524
def _check_dev_dependency(interface: str) -> None:
@@ -88,13 +87,14 @@ async def run_terminal() -> None:
8887
factory = None
8988
try:
9089
trace_manager = UiPathTraceManager()
91-
factory = _create_dev_factory(trace_manager)
90+
context, factory = _create_dev_context_and_factory(trace_manager)
9291

9392
app = UiPathDeveloperConsole(
9493
runtime_factory=factory, trace_manager=trace_manager
9594
)
9695

97-
await app.run_async()
96+
with ExecutionSourceContext(context.execution_source):
97+
await app.run_async()
9898

9999
except KeyboardInterrupt:
100100
console.info("Debug session interrupted by user")
@@ -128,7 +128,7 @@ def signal_handler(sig, frame):
128128

129129
try:
130130
trace_manager = UiPathTraceManager()
131-
factory = _create_dev_factory(trace_manager)
131+
context, factory = _create_dev_context_and_factory(trace_manager)
132132

133133
app = UiPathDeveloperServer(
134134
runtime_factory=factory,
@@ -140,13 +140,17 @@ def signal_handler(sig, frame):
140140
),
141141
)
142142

143-
server_task = asyncio.create_task(app.run_async())
144-
shutdown_task = asyncio.create_task(shutdown_event.wait())
145-
146-
# Wait for either server to complete or shutdown signal
147-
done, pending = await asyncio.wait(
148-
{server_task, shutdown_task}, return_when=asyncio.FIRST_COMPLETED
149-
)
143+
# Enter the execution source context before creating the server
144+
# task so request tasks spawned during the run inherit it.
145+
with ExecutionSourceContext(context.execution_source):
146+
server_task = asyncio.create_task(app.run_async())
147+
shutdown_task = asyncio.create_task(shutdown_event.wait())
148+
149+
# Wait for either server to complete or shutdown signal
150+
done, pending = await asyncio.wait(
151+
{server_task, shutdown_task},
152+
return_when=asyncio.FIRST_COMPLETED,
153+
)
150154

151155
for task in pending:
152156
task.cancel()

packages/uipath/src/uipath/_cli/cli_eval.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
from uipath.eval.runtime import UiPathEvalContext, evaluate
2323
from uipath.platform.chat import set_llm_concurrency
2424
from uipath.platform.common import (
25+
ExecutionSourceContext,
2526
ResourceOverwritesContext,
2627
UiPathConfig,
27-
set_execution_source,
2828
)
2929
from uipath.runtime import (
3030
UiPathRuntimeContext,
@@ -319,8 +319,7 @@ async def execute_eval():
319319
command="eval",
320320
resume=resume,
321321
)
322-
set_execution_source(ctx.execution_source)
323-
with ctx:
322+
with ExecutionSourceContext(ctx.execution_source), ctx:
324323
# Set job_id in eval context for single runtime runs
325324
eval_context.job_id = ctx.job_id
326325

packages/uipath/src/uipath/_cli/cli_run.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
from uipath.core.tracing import UiPathTraceManager
1111
from uipath.eval.mocks import SimulationConfig, UiPathMockRuntime, build_mocking_context
1212
from uipath.platform.common import (
13+
ExecutionSourceContext,
1314
ResourceOverwritesContext,
1415
UiPathConfig,
15-
set_execution_source,
1616
)
1717
from uipath.runtime import (
1818
UiPathExecuteOptions,
@@ -205,8 +205,6 @@ async def execute() -> None:
205205
trace_manager=trace_manager,
206206
)
207207

208-
set_execution_source(ctx.execution_source)
209-
210208
if ctx.trace_file:
211209
trace_manager.add_span_exporter(
212210
JsonLinesFileExporter(ctx.trace_file)
@@ -215,7 +213,7 @@ async def execute() -> None:
215213
async with ResourceOverwritesContext(
216214
lambda: read_resource_overwrites_from_file(ctx.runtime_dir)
217215
):
218-
with ctx:
216+
with ExecutionSourceContext(ctx.execution_source), ctx:
219217
base_runtime: UiPathRuntimeProtocol | None = None
220218
runtime: UiPathRuntimeProtocol | None = None
221219
chat_runtime: UiPathRuntimeProtocol | None = None
Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,70 @@
1-
from unittest.mock import MagicMock
1+
import sys
2+
import types
3+
from unittest.mock import AsyncMock, MagicMock
24

35
import pytest
6+
from click.testing import CliRunner
47

5-
from uipath._cli import cli_dev
8+
from uipath._cli import cli, cli_dev
9+
from uipath._cli.middlewares import MiddlewareResult
10+
from uipath.platform.common import UiPathExecutionContext
611

712

8-
def test_create_dev_factory_propagates_execution_source(
13+
def test_create_dev_context_and_factory_uses_dev_command(
914
monkeypatch: pytest.MonkeyPatch,
1015
) -> None:
11-
"""_create_dev_factory sets the 'playground' source and returns the factory."""
12-
captured: dict[str, object] = {}
13-
14-
def fake_set_execution_source(value: object) -> None:
15-
captured["source"] = value
16-
16+
"""The helper builds a 'dev' context (source 'playground') and its factory."""
1717
sentinel_factory = MagicMock(name="factory")
18+
captured: dict[str, object] = {}
1819

1920
def fake_get(context: object) -> object:
2021
captured["command"] = context.command # type: ignore[attr-defined]
2122
return sentinel_factory
2223

23-
monkeypatch.setattr(cli_dev, "set_execution_source", fake_set_execution_source)
2424
monkeypatch.setattr(
2525
"uipath._cli.cli_dev.UiPathRuntimeFactoryRegistry.get", fake_get
2626
)
2727

28-
factory = cli_dev._create_dev_factory(None) # type: ignore[arg-type]
28+
context, factory = cli_dev._create_dev_context_and_factory(None) # type: ignore[arg-type]
2929

3030
assert factory is sentinel_factory
3131
assert captured["command"] == "dev"
32-
assert captured["source"] == "playground"
32+
assert context.execution_source == "playground"
33+
34+
35+
def test_dev_terminal_sets_execution_source_during_run(
36+
monkeypatch: pytest.MonkeyPatch,
37+
) -> None:
38+
"""Running `dev terminal` scopes the execution source to 'playground'."""
39+
seen: dict[str, object] = {}
40+
41+
async def fake_run_async() -> None:
42+
seen["source"] = UiPathExecutionContext().execution_source
43+
44+
fake_console = MagicMock()
45+
fake_console.run_async = fake_run_async
46+
47+
fake_module = types.ModuleType("uipath.dev")
48+
fake_module.UiPathDeveloperConsole = MagicMock(return_value=fake_console) # type: ignore[attr-defined]
49+
monkeypatch.setitem(sys.modules, "uipath.dev", fake_module)
50+
51+
mock_factory = MagicMock()
52+
mock_factory.dispose = AsyncMock()
53+
54+
monkeypatch.setattr(cli_dev, "_check_dev_dependency", lambda interface: None)
55+
monkeypatch.setattr(cli_dev, "setup_debugging", lambda debug, port: True)
56+
monkeypatch.setattr(
57+
"uipath._cli.cli_dev.Middlewares.next",
58+
lambda *a, **k: MiddlewareResult(should_continue=True),
59+
)
60+
monkeypatch.setattr(
61+
"uipath._cli.cli_dev.UiPathRuntimeFactoryRegistry.get",
62+
lambda context: mock_factory,
63+
)
64+
65+
result = CliRunner().invoke(cli, ["dev", "terminal"])
66+
67+
assert result.exit_code == 0, result.output
68+
assert seen["source"] == "playground"
69+
# token released once the run completes
70+
assert UiPathExecutionContext().execution_source is None

0 commit comments

Comments
 (0)