Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-runtime"
version = "0.13.1"
version = "0.13.2"
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
44 changes: 43 additions & 1 deletion src/uipath/runtime/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@

OUTPUT_ARGUMENTS_SUFFIX = ".args"

# Spellings a stringified boolean actually arrives as. Some producers deliver
# fpsProperties as a string->string map, so a JSON boolean becomes text. Kept
# deliberately narrow -- only what a serializer emits for a bool, plus the
# empty string -- so nothing else gets second-guessed.
_FALSE_STRINGS = frozenset({"false", ""})
_TRUE_STRINGS = frozenset({"true"})


def _parse_bool_like(value: str) -> bool | None:
"""Parse a stringified boolean, returning None when it isn't one."""
token = value.strip().lower()
if token in _FALSE_STRINGS:
return False
if token in _TRUE_STRINGS:
return True
return None


_EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = {
"run": "runtime",
"debug": "playground",
Expand Down Expand Up @@ -486,8 +504,32 @@ def from_config(
# Handle fpsProperties mapping
for config_key, attr_name in fps_mappings.items():
if config_key in fps_config and hasattr(instance, attr_name):
value = fps_config[config_key]
field = cls.model_fields.get(attr_name)
# setattr bypasses validation, so a stringified boolean would be
# stored as-is. "false" is a truthy non-empty string, which
# silently inverts every guard reading the field.
if (
isinstance(value, str)
and field is not None
and field.annotation is bool
):
parsed = _parse_bool_like(value)
if parsed is None:
# Not a spelling we recognize. Pass it through
# untouched rather than guessing at intent. The
# value is external input, so it is kept out of
# the log.
logger.warning(
"fpsProperties[%s] is not a recognizable boolean "
"for %s; leaving it unchanged.",
config_key,
attr_name,
)
else:
value = parsed
attributes_set.add(attr_name)
setattr(instance, attr_name, fps_config[config_key])
setattr(instance, attr_name, value)

for _, attr_name in mapping.items():
if attr_name in kwargs and hasattr(instance, attr_name):
Expand Down
112 changes: 112 additions & 0 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,118 @@ def test_end_exchange_defaults_true_when_fps_property_absent(tmp_path: Path) ->
assert ctx.end_exchange is True


@pytest.mark.parametrize(
("raw", "expected"),
[
("false", False),
("False", False),
("FALSE", False),
("", False),
("true", True),
("True", True),
("TRUE", True),
],
)
def test_from_config_coerces_stringified_bool_fps_property(
tmp_path: Path, raw: str, expected: bool
) -> None:
"""Stringified booleans must be parsed, not stored raw.

Some producers deliver fpsProperties as a string->string map, so a boolean
false arrives as "false". Stored raw on a bool field it stays a non-empty
string, which is truthy — silently inverting every guard that reads it.
"""
cfg = {
"fpsProperties": {
"conversationalService.conversationId": "conv-123",
"conversationalService.endExchange": raw,
}
}
config_path = tmp_path / "uipath.json"
config_path.write_text(json.dumps(cfg))

ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))

assert ctx.end_exchange is expected


def test_from_config_coerces_every_stringified_bool_fps_property(
tmp_path: Path,
) -> None:
"""The coercion covers all bool-typed fps keys, not just endExchange."""
cfg = {
"fpsProperties": {
"conversationalService.endExchange": "false",
"conversationalService.enableOutputs": "false",
"conversationalService.runAsMe": "false",
}
}
config_path = tmp_path / "uipath.json"
config_path.write_text(json.dumps(cfg))

ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))

assert ctx.end_exchange is False
assert ctx.conversational_outputs_enabled is False
assert ctx.conversational_run_as_me is False


def test_from_config_leaves_non_bool_fps_properties_untouched(tmp_path: Path) -> None:
"""Only bool-typed targets are coerced; str fields keep their raw value."""
cfg = {
"fpsProperties": {
"conversationalService.conversationId": "false",
"conversationalService.exchangeId": "0",
}
}
config_path = tmp_path / "uipath.json"
config_path.write_text(json.dumps(cfg))

ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))

assert ctx.conversation_id == "false"
assert ctx.exchange_id == "0"


@pytest.mark.parametrize("raw", ["banana", "0", "1", "yes", "no", "off", "on"])
def test_from_config_passes_through_unrecognized_bool_fps_property(
tmp_path: Path, raw: str
) -> None:
"""Only "true"/"false"/"" are parsed; anything else is left untouched.

Coercing spellings a serializer never emits for a boolean would be guessing
at intent, so unrecognized values keep the behavior they have always had.
"""
cfg = {
"fpsProperties": {
"conversationalService.endExchange": raw,
}
}
config_path = tmp_path / "uipath.json"
config_path.write_text(json.dumps(cfg))

ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))

assert ctx.end_exchange == raw


def test_from_config_still_accepts_real_bool_fps_property(tmp_path: Path) -> None:
"""A genuine JSON boolean keeps working unchanged."""
cfg = {
"fpsProperties": {
"conversationalService.endExchange": False,
"conversationalService.enableOutputs": True,
}
}
config_path = tmp_path / "uipath.json"
config_path.write_text(json.dumps(cfg))

ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))

assert ctx.end_exchange is False
assert ctx.conversational_outputs_enabled is True


def test_result_file_written_on_faulted_trigger_error(tmp_path: Path) -> None:
runtime_dir = tmp_path / "runtime"
ctx = UiPathRuntimeContext(
Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading