diff --git a/pyproject.toml b/pyproject.toml index 84af178..8b5e5dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 575db03..f2610e8 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -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", @@ -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): diff --git a/tests/test_context.py b/tests/test_context.py index d04de55..e437091 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -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( diff --git a/uv.lock b/uv.lock index 1350d74..4aff675 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-08-18T18:26:47.92988Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.13.1" +version = "0.13.2" source = { editable = "." } dependencies = [ { name = "chardet" },