diff --git a/src/conductor/cli/run.py b/src/conductor/cli/run.py index a588af0..b2c97d6 100644 --- a/src/conductor/cli/run.py +++ b/src/conductor/cli/run.py @@ -72,7 +72,7 @@ def init_file_logging(log_path: Path) -> None: """ global _file_console, _file_handle log_path.parent.mkdir(parents=True, exist_ok=True) - _file_handle = open(log_path, "w") # noqa: SIM115 + _file_handle = open(log_path, "w", encoding="utf-8") # noqa: SIM115 _file_console = Console(file=_file_handle, no_color=True, highlight=False, width=200) diff --git a/tests/test_cli/test_logging.py b/tests/test_cli/test_logging.py index 8f93d75..1ddcbda 100644 --- a/tests/test_cli/test_logging.py +++ b/tests/test_cli/test_logging.py @@ -11,9 +11,11 @@ from __future__ import annotations import contextlib +import sys from pathlib import Path from unittest.mock import patch +import pytest from typer.testing import CliRunner from conductor.cli.app import ( @@ -732,7 +734,7 @@ def test_verbose_log_writes_to_file(self, tmp_path: Path) -> None: verbose_log("test file message") close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "test file message" in content finally: verbose_mode.reset(token) @@ -750,7 +752,7 @@ def test_file_output_is_plain_text(self, tmp_path: Path) -> None: verbose_log("styled message", style="bold red") close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "styled message" in content # No ANSI escape sequences ansi_pattern = re.compile(r"\x1b\[[0-9;]*m") @@ -772,7 +774,7 @@ def test_file_gets_untruncated_content(self, tmp_path: Path) -> None: verbose_log_section("Test", long_content) close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") # File should have full content, not truncated assert "truncated" not in content assert content.count("x") == 1000 @@ -797,7 +799,7 @@ def test_file_logging_in_silent_mode(self, tmp_path: Path) -> None: verbose_log_timing("test op", 1.5) close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "silent mode message" in content assert "test op" in content assert "1.50" in content @@ -1023,7 +1025,7 @@ def test_file_gets_sections_in_minimal_mode(self, tmp_path: Path) -> None: verbose_log_section("Prompt", "full prompt content here") close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "Prompt" in content assert "full prompt content here" in content finally: @@ -1078,7 +1080,7 @@ def test_agent_start_writes_to_file(self, tmp_path: Path) -> None: verbose_log_agent_start("test-agent", 1) close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "test-agent" in content assert "iter 1" in content finally: @@ -1101,7 +1103,7 @@ def test_agent_complete_writes_to_file(self, tmp_path: Path) -> None: ) close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "test-agent" in content assert "1.50" in content assert "gpt-4" in content @@ -1120,7 +1122,7 @@ def test_route_writes_to_file(self, tmp_path: Path) -> None: verbose_log_route("next-agent") close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "next-agent" in content finally: verbose_mode.reset(token) @@ -1136,7 +1138,7 @@ def test_route_end_writes_to_file(self, tmp_path: Path) -> None: verbose_log_route("$end") close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "$end" in content finally: verbose_mode.reset(token) @@ -1152,7 +1154,7 @@ def test_timing_writes_to_file(self, tmp_path: Path) -> None: verbose_log_timing("Workflow execution", 3.456) close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "Workflow execution" in content assert "3.46" in content finally: @@ -1173,7 +1175,7 @@ def test_parallel_start_writes_to_file(self, tmp_path: Path) -> None: verbose_log_parallel_start("parallel-group", 3) close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "parallel-group" in content assert "3 agents" in content finally: @@ -1194,7 +1196,7 @@ def test_parallel_agent_complete_writes_to_file(self, tmp_path: Path) -> None: verbose_log_parallel_agent_complete("agent-a", 2.0, model="gpt-4", tokens=200) close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "agent-a" in content assert "2.00" in content finally: @@ -1215,7 +1217,7 @@ def test_parallel_agent_failed_writes_to_file(self, tmp_path: Path) -> None: verbose_log_parallel_agent_failed("agent-b", 0.5, "RuntimeError", "Something broke") close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "agent-b" in content assert "RuntimeError" in content assert "Something broke" in content @@ -1237,7 +1239,7 @@ def test_parallel_summary_writes_to_file(self, tmp_path: Path) -> None: verbose_log_parallel_summary("group1", 2, 1, 3.0) close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "group1" in content assert "2 succeeded" in content assert "1 failed" in content @@ -1259,7 +1261,7 @@ def test_for_each_start_writes_to_file(self, tmp_path: Path) -> None: verbose_log_for_each_start("loop-group", 5, 2, "fail_fast") close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "loop-group" in content assert "5 items" in content finally: @@ -1280,7 +1282,7 @@ def test_for_each_item_complete_writes_to_file(self, tmp_path: Path) -> None: verbose_log_for_each_item_complete("item-0", 1.2, tokens=50) close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "item-0" in content assert "1.20" in content finally: @@ -1301,7 +1303,7 @@ def test_for_each_item_failed_writes_to_file(self, tmp_path: Path) -> None: verbose_log_for_each_item_failed("item-2", 0.3, "ValueError", "bad input") close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "item-2" in content assert "ValueError" in content assert "bad input" in content @@ -1323,7 +1325,7 @@ def test_for_each_summary_writes_to_file(self, tmp_path: Path) -> None: verbose_log_for_each_summary("loop1", 4, 1, 5.0) close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "loop1" in content assert "4 succeeded" in content assert "1 failed" in content @@ -1351,7 +1353,7 @@ def test_display_usage_summary_writes_to_file(self, tmp_path: Path) -> None: ) close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "Token Usage" in content assert "500" in content assert "200" in content @@ -1371,7 +1373,7 @@ def test_section_writes_full_content_to_file_in_silent_mode(self, tmp_path: Path verbose_log_section("Prompt", "full prompt content") close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") assert "Prompt" in content assert "full prompt content" in content finally: @@ -1528,6 +1530,7 @@ def test_log_path_not_printed_when_init_fails(self, tmp_path: Path) -> None: class TestFileLoggingErrorHandling: """Tests for file logging error handling.""" + @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific chmod permissions") def test_init_file_logging_permission_denied(self, tmp_path: Path) -> None: """Test that init_file_logging raises OSError for permission issues.""" import os @@ -1551,6 +1554,7 @@ def test_init_file_logging_permission_denied(self, tmp_path: Path) -> None: # Restore permissions for cleanup os.chmod(readonly_dir, 0o755) + @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific path behavior") def test_run_workflow_handles_log_file_error_gracefully(self, tmp_path: Path) -> None: """Test that run_workflow_async handles log file errors gracefully.""" import asyncio @@ -1635,7 +1639,7 @@ def test_file_output_no_ansi_for_all_styles(self, tmp_path: Path) -> None: verbose_log_timing("operation", 1.5) close_file_logging() - content = log_path.read_text() + content = log_path.read_text(encoding="utf-8") ansi_pattern = re.compile(r"\x1b\[[0-9;]*m") assert not ansi_pattern.search(content), f"ANSI codes found in file output: {content}" finally: diff --git a/tests/test_engine/test_checkpoint.py b/tests/test_engine/test_checkpoint.py index e282334..f672c73 100644 --- a/tests/test_engine/test_checkpoint.py +++ b/tests/test_engine/test_checkpoint.py @@ -20,7 +20,7 @@ import stat import sys from datetime import UTC, datetime -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any from unittest.mock import patch @@ -106,13 +106,13 @@ def test_path(self) -> None: assert _make_json_serializable(p) == str(p) def test_dict_recursive(self) -> None: - d = {"path": Path("/a"), "nested": {"b": b"data"}} + d = {"path": PurePosixPath("/a"), "nested": {"b": b"data"}} result = _make_json_serializable(d) assert result["path"] == "/a" assert result["nested"]["b"] == "data" def test_list_recursive(self) -> None: - result = _make_json_serializable([Path("/a"), 42, [b"x"]]) + result = _make_json_serializable([PurePosixPath("/a"), 42, [b"x"]]) assert result == ["/a", 42, ["x"]] def test_set_converted_to_sorted_list(self) -> None: @@ -244,7 +244,7 @@ def test_handles_non_serializable_inputs(self, tmp_path: Path) -> None: limits = _make_limits() error = RuntimeError("err") - inputs_with_path: dict[str, Any] = {"file": Path("/tmp/x"), "data": b"bytes"} + inputs_with_path: dict[str, Any] = {"file": PurePosixPath("/tmp/x"), "data": b"bytes"} with patch.object(CheckpointManager, "get_checkpoints_dir", return_value=tmp_path): path = CheckpointManager.save_checkpoint(wf, ctx, limits, "a", error, inputs_with_path) diff --git a/tests/test_engine/test_event_emission.py b/tests/test_engine/test_event_emission.py index df45709..574c8d4 100644 --- a/tests/test_engine/test_event_emission.py +++ b/tests/test_engine/test_event_emission.py @@ -7,6 +7,7 @@ from __future__ import annotations +import sys from unittest.mock import MagicMock import pytest @@ -510,8 +511,8 @@ async def test_script_started_and_completed(self) -> None: AgentDef( name="run_echo", type="script", - command="echo", - args=["hello"], + command=sys.executable, + args=["-c", "print('hello')"], routes=[RouteDef(to="$end")], ), ], diff --git a/tests/test_engine/test_script_workflow.py b/tests/test_engine/test_script_workflow.py index c0edb90..a6db9dd 100644 --- a/tests/test_engine/test_script_workflow.py +++ b/tests/test_engine/test_script_workflow.py @@ -15,6 +15,7 @@ from __future__ import annotations +import sys from unittest.mock import MagicMock import pytest @@ -52,8 +53,8 @@ async def test_script_step_runs_to_end(self) -> None: AgentDef( name="run_echo", type="script", - command="echo", - args=["hello world"], + command=sys.executable, + args=["-c", "print('hello world')"], routes=[RouteDef(to="$end")], ), ], @@ -83,8 +84,8 @@ async def test_script_output_in_context(self) -> None: AgentDef( name="checker", type="script", - command="echo", - args=["test output"], + command=sys.executable, + args=["-c", "print('test output')"], routes=[RouteDef(to="processor")], ), AgentDef( @@ -131,7 +132,8 @@ async def test_route_on_exit_code_simpleeval_success(self) -> None: AgentDef( name="checker", type="script", - command="true", + command=sys.executable, + args=["-c", "import sys; sys.exit(0)"], routes=[ RouteDef(to="success_handler", when="exit_code == 0"), RouteDef(to="failure_handler"), @@ -175,7 +177,8 @@ async def test_route_on_exit_code_simpleeval_failure(self) -> None: AgentDef( name="checker", type="script", - command="false", + command=sys.executable, + args=["-c", "import sys; sys.exit(1)"], routes=[ RouteDef(to="success_handler", when="exit_code == 0"), RouteDef(to="failure_handler"), @@ -219,7 +222,8 @@ async def test_route_on_exit_code_jinja2(self) -> None: AgentDef( name="checker", type="script", - command="true", + command=sys.executable, + args=["-c", "import sys; sys.exit(0)"], routes=[ RouteDef(to="success_handler", when="{{ output.exit_code == 0 }}"), RouteDef(to="failure_handler"), @@ -267,15 +271,15 @@ async def test_script_counts_toward_iteration_limit(self) -> None: AgentDef( name="step1", type="script", - command="echo", - args=["step1"], + command=sys.executable, + args=["-c", "print('step1')"], routes=[RouteDef(to="step2")], ), AgentDef( name="step2", type="script", - command="echo", - args=["step2"], + command=sys.executable, + args=["-c", "print('step2')"], routes=[RouteDef(to="$end")], ), ], @@ -303,7 +307,8 @@ async def test_script_non_zero_exit_no_routes_ends(self) -> None: AgentDef( name="failing", type="script", - command="false", + command=sys.executable, + args=["-c", "import sys; sys.exit(1)"], ), ], output={ @@ -337,8 +342,8 @@ async def test_mixed_agent_and_script(self) -> None: AgentDef( name="setup_script", type="script", - command="echo", - args=["setup complete"], + command=sys.executable, + args=["-c", "print('setup complete')"], routes=[RouteDef(to="analyzer")], ), AgentDef( @@ -380,8 +385,8 @@ async def test_script_command_with_workflow_input(self) -> None: AgentDef( name="runner", type="script", - command="echo", - args=["{{ workflow.input.message }}"], + command=sys.executable, + args=["-c", "import sys; print(sys.argv[1])", "{{ workflow.input.message }}"], routes=[RouteDef(to="$end")], ), ], diff --git a/tests/test_executor/test_script.py b/tests/test_executor/test_script.py index eb92d62..9473e00 100644 --- a/tests/test_executor/test_script.py +++ b/tests/test_executor/test_script.py @@ -15,6 +15,7 @@ from __future__ import annotations import os +import sys import tempfile import pytest @@ -46,18 +47,30 @@ class TestScriptExecutorBasic: @pytest.mark.asyncio async def test_simple_echo(self, executor: ScriptExecutor) -> None: - """Test simple echo command captures stdout.""" - agent = AgentDef(name="test_echo", type="script", command="echo", args=["hello"]) + """Test simple command captures stdout.""" + agent = AgentDef( + name="test_echo", + type="script", + command=sys.executable, + args=["-c", "print('hello')"], + ) output = await executor.execute(agent, {}) - assert output.stdout == "hello\n" - assert output.stderr == "" + assert output.stdout.strip() == "hello" assert output.exit_code == 0 @pytest.mark.asyncio async def test_command_with_multiple_args(self, executor: ScriptExecutor) -> None: """Test command with multiple arguments.""" agent = AgentDef( - name="test_printf", type="script", command="printf", args=["%s %s", "hello", "world"] + name="test_printf", + type="script", + command=sys.executable, + args=[ + "-c", + "import sys; print(sys.argv[1] + ' ' + sys.argv[2], end='')", + "hello", + "world", + ], ) output = await executor.execute(agent, {}) assert output.stdout == "hello world" @@ -66,7 +79,12 @@ async def test_command_with_multiple_args(self, executor: ScriptExecutor) -> Non @pytest.mark.asyncio async def test_failing_command_exit_code(self, executor: ScriptExecutor) -> None: """Test that non-zero exit code is captured correctly (not 0).""" - agent = AgentDef(name="test_false", type="script", command="false") + agent = AgentDef( + name="test_false", + type="script", + command=sys.executable, + args=["-c", "import sys; sys.exit(1)"], + ) output = await executor.execute(agent, {}) assert output.exit_code == 1 assert output.exit_code != 0 @@ -77,8 +95,8 @@ async def test_stderr_captured(self, executor: ScriptExecutor) -> None: agent = AgentDef( name="test_stderr", type="script", - command="sh", - args=["-c", "echo out; echo err >&2"], + command=sys.executable, + args=["-c", "import sys; print('out'); print('err', file=sys.stderr)"], ) output = await executor.execute(agent, {}) assert "out" in output.stdout @@ -92,7 +110,11 @@ class TestScriptExecutorTimeout: async def test_timeout_kills_process(self, executor: ScriptExecutor) -> None: """Test that timeout kills process and raises ExecutionError.""" agent = AgentDef( - name="test_timeout", type="script", command="sleep", args=["10"], timeout=1 + name="test_timeout", + type="script", + command=sys.executable, + args=["-c", "import time; time.sleep(10)"], + timeout=1, ) with pytest.raises(ExecutionError, match="timed out after 1s"): await executor.execute(agent, {}) @@ -100,7 +122,12 @@ async def test_timeout_kills_process(self, executor: ScriptExecutor) -> None: @pytest.mark.asyncio async def test_no_timeout_default(self, executor: ScriptExecutor) -> None: """Test that no timeout allows command to complete.""" - agent = AgentDef(name="test_quick", type="script", command="echo", args=["fast"]) + agent = AgentDef( + name="test_quick", + type="script", + command=sys.executable, + args=["-c", "print('fast')"], + ) output = await executor.execute(agent, {}) assert output.exit_code == 0 @@ -114,8 +141,8 @@ async def test_custom_env_passed(self, executor: ScriptExecutor) -> None: agent = AgentDef( name="test_env", type="script", - command="sh", - args=["-c", "echo $MY_TEST_VAR"], + command=sys.executable, + args=["-c", "import os; print(os.environ['MY_TEST_VAR'])"], env={"MY_TEST_VAR": "custom_value"}, ) output = await executor.execute(agent, {}) @@ -127,8 +154,8 @@ async def test_env_merges_with_os_environ(self, executor: ScriptExecutor) -> Non agent = AgentDef( name="test_env_merge", type="script", - command="sh", - args=["-c", "echo $PATH"], + command=sys.executable, + args=["-c", "import os; print(os.environ.get('PATH', ''))"], env={"MY_EXTRA": "val"}, ) output = await executor.execute(agent, {}) @@ -146,8 +173,8 @@ async def test_env_values_not_jinja2_rendered(self, executor: ScriptExecutor) -> agent = AgentDef( name="test_env_no_render", type="script", - command="sh", - args=["-c", "echo $MY_VAR"], + command=sys.executable, + args=["-c", "import os; print(os.environ['MY_VAR'])"], env={"MY_VAR": "{{ literal_braces }}"}, ) output = await executor.execute(agent, {"literal_braces": "should_not_appear"}) @@ -165,7 +192,8 @@ async def test_working_dir_respected(self, executor: ScriptExecutor) -> None: agent = AgentDef( name="test_cwd", type="script", - command="pwd", + command=sys.executable, + args=["-c", "import os; print(os.getcwd())"], working_dir=tmpdir, ) output = await executor.execute(agent, {}) @@ -179,7 +207,8 @@ async def test_working_dir_with_jinja2_template(self, executor: ScriptExecutor) agent = AgentDef( name="test_cwd_tpl", type="script", - command="pwd", + command=sys.executable, + args=["-c", "import os; print(os.getcwd())"], working_dir="{{ target_dir }}", ) output = await executor.execute(agent, {"target_dir": tmpdir}) @@ -196,8 +225,9 @@ async def test_template_in_command(self, executor: ScriptExecutor) -> None: name="test_cmd_tpl", type="script", command="{{ cmd }}", + args=["-c", "print('ok')"], ) - output = await executor.execute(agent, {"cmd": "echo"}) + output = await executor.execute(agent, {"cmd": sys.executable}) assert output.exit_code == 0 @pytest.mark.asyncio @@ -206,8 +236,8 @@ async def test_template_in_args(self, executor: ScriptExecutor) -> None: agent = AgentDef( name="test_args_tpl", type="script", - command="echo", - args=["{{ greeting }}"], + command=sys.executable, + args=["-c", "print('{{ greeting }}')"], ) output = await executor.execute(agent, {"greeting": "hi there"}) assert "hi there" in output.stdout @@ -218,8 +248,8 @@ async def test_template_with_workflow_context(self, executor: ScriptExecutor) -> agent = AgentDef( name="test_ctx_tpl", type="script", - command="echo", - args=["{{ workflow.input.message }}"], + command=sys.executable, + args=["-c", "print('{{ workflow.input.message }}')"], ) context = {"workflow": {"input": {"message": "from workflow"}}} output = await executor.execute(agent, context) @@ -246,8 +276,8 @@ async def test_specific_exit_code(self, executor: ScriptExecutor) -> None: agent = AgentDef( name="test_exit42", type="script", - command="sh", - args=["-c", "exit 42"], + command=sys.executable, + args=["-c", "import sys; sys.exit(42)"], ) output = await executor.execute(agent, {}) assert output.exit_code == 42