Skip to content

Commit 711246a

Browse files
Valentina Bojanclaude
andcommitted
refactor(context): derive execution_source via model_validator
Move derivation out of with_defaults into a model_validator so it fires on the plain-constructor path too (e.g. dev/init), and never assign None for unmapped commands so the field stays absent under model_dump(exclude_unset=True). with_defaults re-applies it since it mutates command via setattr after construction. Addresses Copilot review feedback on PR #132. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9b2abc6 commit 711246a

2 files changed

Lines changed: 50 additions & 25 deletions

File tree

‎src/uipath/runtime/context.py‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pathlib import Path
88
from typing import Any, Literal
99

10-
from pydantic import BaseModel, ConfigDict, Field
10+
from pydantic import BaseModel, ConfigDict, Field, model_validator
1111
from uipath.core.errors import UiPathFaultedTriggerError
1212
from uipath.core.tracing import UiPathTraceManager
1313

@@ -23,8 +23,6 @@
2323

2424
logger = logging.getLogger(__name__)
2525

26-
# Maps the executing command to an execution source. Commands that do not run
27-
# an agent are absent, leaving execution_source unset.
2826
_EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = {
2927
"run": "runtime",
3028
"debug": "playground",
@@ -41,12 +39,7 @@ class UiPathRuntimeContext(BaseModel):
4139
resume: bool = False
4240
command: str | None = None
4341
execution_source: str | None = Field(
44-
None,
45-
description=(
46-
"Execution source derived from the command "
47-
"(runtime/playground/eval). Propagated to platform clients so "
48-
"downstream calls (e.g. guardrails) can identify the run context."
49-
),
42+
None, description="Execution source derived from the command."
5043
)
5144
job_id: str | None = None
5245
conversation_id: str | None = Field(
@@ -113,6 +106,28 @@ class UiPathRuntimeContext(BaseModel):
113106

114107
model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow")
115108

109+
def _apply_execution_source(self) -> None:
110+
"""Derive execution_source from the command, if not already set.
111+
112+
Only assigns a mapped value, so the field stays unset (absent under
113+
``model_dump(exclude_unset=True)``) for unmapped commands, and an
114+
explicitly-provided value is never overwritten.
115+
"""
116+
if self.execution_source is None and self.command is not None:
117+
source = _EXECUTION_SOURCE_BY_COMMAND.get(self.command)
118+
if source is not None:
119+
self.execution_source = source
120+
121+
@model_validator(mode="after")
122+
def _derive_execution_source(self) -> "UiPathRuntimeContext":
123+
"""Derive execution_source on the constructor path (e.g. dev/init).
124+
125+
``with_defaults`` mutates ``command`` via ``setattr`` after construction,
126+
so it re-applies the derivation itself.
127+
"""
128+
self._apply_execution_source()
129+
return self
130+
116131
def get_input(self) -> dict[str, Any] | None:
117132
"""Get parsed input data.
118133
@@ -362,9 +377,8 @@ def with_defaults(
362377
for k, v in kwargs.items():
363378
setattr(base, k, v)
364379

365-
# Derive the execution source from the command unless explicitly set.
366-
if base.execution_source is None and base.command is not None:
367-
base.execution_source = _EXECUTION_SOURCE_BY_COMMAND.get(base.command)
380+
# setattr does not re-run the validator, so derive explicitly.
381+
base._apply_execution_source()
368382

369383
return base
370384

‎tests/test_context.py‎

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -329,34 +329,45 @@ def test_string_output_wrapped_in_dict() -> None:
329329
("eval", "eval"),
330330
],
331331
)
332+
def test_constructor_derives_execution_source(command: str, expected: str) -> None:
333+
"""execution_source is derived from the command on the plain constructor path."""
334+
ctx = UiPathRuntimeContext(command=command)
335+
336+
assert ctx.execution_source == expected
337+
338+
339+
@pytest.mark.parametrize(
340+
"command,expected",
341+
[
342+
("run", "runtime"),
343+
("eval", "eval"),
344+
],
345+
)
332346
def test_with_defaults_derives_execution_source(
333347
command: str, expected: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
334348
) -> None:
335-
"""execution_source is derived from the command when not provided."""
349+
"""execution_source is also derived via with_defaults."""
336350
monkeypatch.chdir(tmp_path)
337351

338352
ctx = UiPathRuntimeContext.with_defaults(command=command)
339353

340354
assert ctx.execution_source == expected
341355

342356

343-
def test_with_defaults_execution_source_none_for_unmapped_command(
344-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
345-
) -> None:
346-
"""Commands that do not run an agent leave execution_source unset."""
347-
monkeypatch.chdir(tmp_path)
357+
def test_execution_source_unset_for_unmapped_command() -> None:
358+
"""Commands that do not run an agent leave execution_source unset.
348359
349-
ctx = UiPathRuntimeContext.with_defaults(command="pack")
360+
The field must remain unset (not explicitly None) so it is absent from
361+
model_dump(exclude_unset=True).
362+
"""
363+
ctx = UiPathRuntimeContext(command="pack")
350364

351365
assert ctx.execution_source is None
366+
assert "execution_source" not in ctx.model_dump(exclude_unset=True)
352367

353368

354-
def test_with_defaults_explicit_execution_source_not_overwritten(
355-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
356-
) -> None:
369+
def test_explicit_execution_source_not_overwritten() -> None:
357370
"""An explicitly provided execution_source takes precedence over the command."""
358-
monkeypatch.chdir(tmp_path)
359-
360-
ctx = UiPathRuntimeContext.with_defaults(command="run", execution_source="custom")
371+
ctx = UiPathRuntimeContext(command="run", execution_source="custom")
361372

362373
assert ctx.execution_source == "custom"

0 commit comments

Comments
 (0)