From 1008d37d86d0a4681b83c38b676db24b30a5938d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 10 Jun 2026 15:24:17 -0700 Subject: [PATCH 1/3] feat(process): add ProcessJsonDecodeError and remove run_sync/pipe_sync - Add ProcessJsonDecodeError(ValueError) to exception.py with stdout and original attributes - Update output_json() in ProcessResult to raise ProcessJsonDecodeError on invalid JSON instead of bare json.JSONDecodeError - Remove run_sync() and pipe_sync() from PendingProcess and Process facade (async-only API) - Remove Sync API mentions from Process class docstring - Delete TestPendingProcessRunSync and TestProcessRunSync test classes - Add TestProcessResultJson with 4 tests covering dict/list parsing and error behaviour - Update test_result.py to expect ProcessJsonDecodeError instead of json.JSONDecodeError Co-Authored-By: Claude Sonnet 4.6 --- .../src/fastapi_startkit/process/exception.py | 9 ++ .../src/fastapi_startkit/process/process.py | 99 ------------------- .../src/fastapi_startkit/process/result.py | 12 ++- .../tests/process/test_pending_process.py | 37 ------- .../tests/process/test_process.py | 48 ++++----- fastapi_startkit/tests/process/test_result.py | 4 +- 6 files changed, 44 insertions(+), 165 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/process/exception.py b/fastapi_startkit/src/fastapi_startkit/process/exception.py index 1b12a5ac..78c9eb5f 100644 --- a/fastapi_startkit/src/fastapi_startkit/process/exception.py +++ b/fastapi_startkit/src/fastapi_startkit/process/exception.py @@ -10,3 +10,12 @@ class ProcessTimedOutException(Exception): def __init__(self, command): self.command = command super().__init__(f"Process [{command}] timed out.") + + +class ProcessJsonDecodeError(ValueError): + def __init__(self, stdout: str, original: Exception) -> None: + self.stdout = stdout + self.original = original + super().__init__( + f"Failed to parse process output as JSON: {original}\nRaw output: {stdout!r}" + ) diff --git a/fastapi_startkit/src/fastapi_startkit/process/process.py b/fastapi_startkit/src/fastapi_startkit/process/process.py index f08866ff..de5b04aa 100644 --- a/fastapi_startkit/src/fastapi_startkit/process/process.py +++ b/fastapi_startkit/src/fastapi_startkit/process/process.py @@ -412,83 +412,6 @@ async def pipe( callback(p) return await self.run(p.to_command(), output_callback) - # ------------------------------------------------------------------ - # Sync execution (for scripts / CLI without an event loop) - # ------------------------------------------------------------------ - - def run_sync(self, command: str, callback: Callable | None = None) -> ProcessResult: - """Run a process synchronously and return a ProcessResult.""" - if self._fake is not None: - return self._fake._handle(command, self) - - if self._tty: - result = subprocess.run( - command, - shell=True, - cwd=self._cwd, - env=self._env, - timeout=self._timeout, - ) - return ProcessResult( - stdout="", - stderr="", - returncode=result.returncode, - args=command, - ) - - if self._quiet: - result = subprocess.run( - command, - shell=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - cwd=self._cwd, - env=self._env, - input=self._input, - timeout=self._timeout, - ) - return ProcessResult( - stdout="", - stderr="", - returncode=result.returncode, - args=command, - ) - - if callback is not None: - # Stream output through callback then return the final result - return self.start(command, callback).wait() - - try: - result = subprocess.run( - command, - shell=True, - capture_output=True, - text=True, - cwd=self._cwd, - env=self._env, - input=self._input, - timeout=self._timeout, - ) - except subprocess.TimeoutExpired: - raise ProcessTimedOutException(command) - - return ProcessResult( - stdout=result.stdout or "", - stderr=result.stderr or "", - returncode=result.returncode, - args=command, - ) - - def pipe_sync( - self, - callback: Callable[["Pipe"], None], - output_callback: Callable | None = None, - ) -> ProcessResult: - """Build a pipeline of commands and run them synchronously.""" - p = Pipe() - callback(p) - return self.run_sync(p.to_command(), output_callback) - # ------------------------------------------------------------------ # Background execution # ------------------------------------------------------------------ @@ -536,10 +459,6 @@ class Process: result = await Process.timeout(30).run('bash script.sh') result = await Process.forever().quietly().run('bash import.sh') - Sync API — for scripts / CLI without an event loop: - result = Process.run_sync('ls -la') - result = Process.timeout(30).run_sync('bash script.sh') - Background execution: process = Process.start('bash long.sh', callback=print) while process.running(): @@ -647,24 +566,6 @@ async def pipe( """Build a pipeline of commands and run them asynchronously.""" return await cls._pending().pipe(callback, output_callback) - # ------------------------------------------------------------------ - # Sync execution shortcuts (for scripts / CLI) - # ------------------------------------------------------------------ - - @classmethod - def run_sync(cls, command: str, callback: Callable | None = None) -> ProcessResult: - """Run a command synchronously (for scripts / CLI without an event loop).""" - return cls._pending().run_sync(command, callback) - - @classmethod - def pipe_sync( - cls, - callback: Callable[["Pipe"], None], - output_callback: Callable | None = None, - ) -> ProcessResult: - """Build a pipeline of commands and run them synchronously.""" - return cls._pending().pipe_sync(callback, output_callback) - # ------------------------------------------------------------------ # Background execution # ------------------------------------------------------------------ diff --git a/fastapi_startkit/src/fastapi_startkit/process/result.py b/fastapi_startkit/src/fastapi_startkit/process/result.py index 880956dd..a10b8d41 100644 --- a/fastapi_startkit/src/fastapi_startkit/process/result.py +++ b/fastapi_startkit/src/fastapi_startkit/process/result.py @@ -3,7 +3,7 @@ import json from typing import Any -from .exception import ProcessFailedException +from .exception import ProcessFailedException, ProcessJsonDecodeError class ProcessResult: @@ -42,8 +42,14 @@ def exit_code(self) -> int: return self._returncode def output_json(self) -> Any: - """Parse stdout as JSON.""" - return json.loads(self._stdout) + """Parse stdout as JSON. + + Raises ProcessJsonDecodeError if stdout is not valid JSON. + """ + try: + return json.loads(self._stdout) + except json.JSONDecodeError as exc: + raise ProcessJsonDecodeError(self._stdout, exc) from exc def throw(self) -> "ProcessResult": """Raise ProcessFailedException if the process failed.""" diff --git a/fastapi_startkit/tests/process/test_pending_process.py b/fastapi_startkit/tests/process/test_pending_process.py index 050a2164..4a1f64b1 100644 --- a/fastapi_startkit/tests/process/test_pending_process.py +++ b/fastapi_startkit/tests/process/test_pending_process.py @@ -169,43 +169,6 @@ async def test_run_uses_registered_fake_result(self): Process.reset_fake() -# --------------------------------------------------------------------------- -# run_sync() — synchronous execution -# --------------------------------------------------------------------------- - - -class TestPendingProcessRunSync: - def test_run_sync_returns_process_result(self, pending): - result = pending.run_sync("echo hi") - assert isinstance(result, ProcessResult) - - def test_run_sync_captures_stdout(self, pending): - result = pending.run_sync("echo hello world") - assert "hello world" in result.output() - - def test_run_sync_uses_fake_when_set(self, fake_pending): - p, fake = fake_pending - result = p.run_sync("echo hello") - assert isinstance(result, ProcessResult) - assert result.successful() is True - - def test_run_sync_timeout_raises(self, pending): - pending.timeout(0.1) - with pytest.raises(ProcessTimedOutException): - pending.run_sync("sleep 5") - - def test_run_sync_quietly_discards_output(self, pending): - pending.quietly() - result = pending.run_sync("echo quiet") - assert result.output() == "" - assert result.successful() is True - - def test_run_sync_with_input(self, pending): - pending.input("hello sync\n") - result = pending.run_sync("cat") - assert "hello sync" in result.output() - - # --------------------------------------------------------------------------- # start() — background invocation # --------------------------------------------------------------------------- diff --git a/fastapi_startkit/tests/process/test_process.py b/fastapi_startkit/tests/process/test_process.py index 4a283933..7707b742 100644 --- a/fastapi_startkit/tests/process/test_process.py +++ b/fastapi_startkit/tests/process/test_process.py @@ -2,8 +2,9 @@ import pytest -from fastapi_startkit.process.exception import ProcessFailedException, ProcessTimedOutException +from fastapi_startkit.process.exception import ProcessFailedException, ProcessJsonDecodeError, ProcessTimedOutException from fastapi_startkit.process.process import Process, ProcessFake +from fastapi_startkit.process.result import ProcessResult @pytest.fixture(autouse=True) @@ -76,6 +77,28 @@ async def test_run_quietly_stderr_empty(self): assert result.failed() is True +class TestProcessResultJson: + def test_output_json_returns_dict(self): + result = ProcessResult(stdout='{"key": "value"}', returncode=0, args="cmd") + assert result.output_json() == {"key": "value"} + + def test_output_json_returns_list(self): + result = ProcessResult(stdout='[1, 2, 3]', returncode=0, args="cmd") + assert result.output_json() == [1, 2, 3] + + def test_output_json_raises_on_invalid_json(self): + result = ProcessResult(stdout="not valid json", returncode=0, args="cmd") + with pytest.raises(ProcessJsonDecodeError): + result.output_json() + + def test_output_json_error_contains_raw_output(self): + raw = "this is not json" + result = ProcessResult(stdout=raw, returncode=0, args="cmd") + with pytest.raises(ProcessJsonDecodeError) as exc_info: + result.output_json() + assert raw in str(exc_info.value) + + class TestProcessTimeout: async def test_timeout_raises_on_slow_command(self): with pytest.raises(ProcessTimedOutException): @@ -90,29 +113,6 @@ async def test_forever_disables_timeout(self): assert result.successful() is True -# --------------------------------------------------------------------------- -# Process.run_sync() — sync execution -# --------------------------------------------------------------------------- - - -class TestProcessRunSync: - def test_run_sync_echo_returns_output(self): - result = Process.run_sync("echo hello") - assert "hello" in result.output() - - def test_run_sync_successful(self): - result = Process.run_sync("echo hi") - assert result.successful() is True - - def test_run_sync_failed_command(self): - result = Process.run_sync("exit 1") - assert result.failed() is True - - def test_run_sync_timeout_raises(self): - with pytest.raises(ProcessTimedOutException): - Process.timeout(0.1).run_sync("sleep 5") - - # --------------------------------------------------------------------------- # Process.fake() — testing infrastructure # --------------------------------------------------------------------------- diff --git a/fastapi_startkit/tests/process/test_result.py b/fastapi_startkit/tests/process/test_result.py index cdb23ef0..aeac5349 100644 --- a/fastapi_startkit/tests/process/test_result.py +++ b/fastapi_startkit/tests/process/test_result.py @@ -4,7 +4,7 @@ import pytest -from fastapi_startkit.process.exception import ProcessFailedException +from fastapi_startkit.process.exception import ProcessFailedException, ProcessJsonDecodeError from fastapi_startkit.process.result import ProcessResult @@ -136,7 +136,7 @@ def test_output_json_parses_list(self): def test_output_json_raises_on_invalid_json(self): r = _make_result(stdout="not valid json") - with pytest.raises(json.JSONDecodeError): + with pytest.raises(ProcessJsonDecodeError): r.output_json() From 0524ad85da293e6f191854eb3a06dffc3e826dbc Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 10 Jun 2026 15:28:09 -0700 Subject: [PATCH 2/3] fix(process): use asyncio.create_subprocess_shell for tty mode Replace the blocking subprocess.run() call in PendingProcess.run() tty branch with asyncio.create_subprocess_shell() passing stdin/stdout/stderr as None so the child inherits the parent's real TTY. Timeout is honoured via asyncio.wait_for(); ProcessTimedOutException is raised on expiry. Co-Authored-By: Claude Sonnet 4.6 --- .../src/fastapi_startkit/process/process.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/process/process.py b/fastapi_startkit/src/fastapi_startkit/process/process.py index de5b04aa..131f4465 100644 --- a/fastapi_startkit/src/fastapi_startkit/process/process.py +++ b/fastapi_startkit/src/fastapi_startkit/process/process.py @@ -356,18 +356,28 @@ async def run(self, command: str, callback: Callable | None = None) -> ProcessRe return self._fake._handle(command, self) if self._tty: - # TTY mode: pass through to terminal, no capture - result = subprocess.run( + # TTY mode: inherit stdin/stdout/stderr from parent process (no capture) + proc = await asyncio.create_subprocess_shell( command, - shell=True, + stdin=None, + stdout=None, + stderr=None, cwd=self._cwd, env=self._env, - timeout=self._timeout, ) + try: + if self._timeout is not None: + await asyncio.wait_for(proc.wait(), timeout=self._timeout) + else: + await proc.wait() + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise ProcessTimedOutException(command) return ProcessResult( stdout="", stderr="", - returncode=result.returncode, + returncode=proc.returncode or 0, args=command, ) From 0afdda039dea294ef96272ed3efff9257df7b574 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 10 Jun 2026 15:31:34 -0700 Subject: [PATCH 3/3] refactor(process): rename output_json() to json() on ProcessResult Co-Authored-By: Claude Sonnet 4.6 --- .../src/fastapi_startkit/process/result.py | 2 +- fastapi_startkit/tests/process/test_process.py | 16 ++++++++-------- fastapi_startkit/tests/process/test_result.py | 14 +++++++------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/process/result.py b/fastapi_startkit/src/fastapi_startkit/process/result.py index a10b8d41..53675c65 100644 --- a/fastapi_startkit/src/fastapi_startkit/process/result.py +++ b/fastapi_startkit/src/fastapi_startkit/process/result.py @@ -41,7 +41,7 @@ def error(self) -> str: def exit_code(self) -> int: return self._returncode - def output_json(self) -> Any: + def json(self) -> Any: """Parse stdout as JSON. Raises ProcessJsonDecodeError if stdout is not valid JSON. diff --git a/fastapi_startkit/tests/process/test_process.py b/fastapi_startkit/tests/process/test_process.py index 7707b742..5a7964c1 100644 --- a/fastapi_startkit/tests/process/test_process.py +++ b/fastapi_startkit/tests/process/test_process.py @@ -78,24 +78,24 @@ async def test_run_quietly_stderr_empty(self): class TestProcessResultJson: - def test_output_json_returns_dict(self): + def test_json_returns_dict(self): result = ProcessResult(stdout='{"key": "value"}', returncode=0, args="cmd") - assert result.output_json() == {"key": "value"} + assert result.json() == {"key": "value"} - def test_output_json_returns_list(self): + def test_json_returns_list(self): result = ProcessResult(stdout='[1, 2, 3]', returncode=0, args="cmd") - assert result.output_json() == [1, 2, 3] + assert result.json() == [1, 2, 3] - def test_output_json_raises_on_invalid_json(self): + def test_json_raises_on_invalid_json(self): result = ProcessResult(stdout="not valid json", returncode=0, args="cmd") with pytest.raises(ProcessJsonDecodeError): - result.output_json() + result.json() - def test_output_json_error_contains_raw_output(self): + def test_json_error_contains_raw_output(self): raw = "this is not json" result = ProcessResult(stdout=raw, returncode=0, args="cmd") with pytest.raises(ProcessJsonDecodeError) as exc_info: - result.output_json() + result.json() assert raw in str(exc_info.value) diff --git a/fastapi_startkit/tests/process/test_result.py b/fastapi_startkit/tests/process/test_result.py index aeac5349..e36dc22d 100644 --- a/fastapi_startkit/tests/process/test_result.py +++ b/fastapi_startkit/tests/process/test_result.py @@ -124,20 +124,20 @@ def test_command_returns_args(self): assert r.command() == "ls -la /tmp" -class TestOutputJson: - def test_output_json_parses_valid_json(self): +class TestJson: + def test_json_parses_valid_json(self): r = _make_result(stdout='{"key": "value", "num": 42}') - data = r.output_json() + data = r.json() assert data == {"key": "value", "num": 42} - def test_output_json_parses_list(self): + def test_json_parses_list(self): r = _make_result(stdout="[1, 2, 3]") - assert r.output_json() == [1, 2, 3] + assert r.json() == [1, 2, 3] - def test_output_json_raises_on_invalid_json(self): + def test_json_raises_on_invalid_json(self): r = _make_result(stdout="not valid json") with pytest.raises(ProcessJsonDecodeError): - r.output_json() + r.json() class TestRepr: