From 565946643b4bd6508453a285c3ef9ea2b01d168d Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Tue, 3 Mar 2026 08:34:27 +0100 Subject: [PATCH] jw.pkg.*.run_xxx(): Return exit status Most run_xxx() return stdout and stderr. There's no way, really, for the caller to get hold of the exit code of the spawned executable. It can pass throw=true, catch, and assume a non-zero exit status. But that's not semantically clean, since the spawned function can well be a test function which is expected to return a non-zero status code, and the caller might be interested in what code that was, exactly. The clearest way to solve this is to return the exit code as well. This commit does that. Signed-off-by: Jan Lindemann --- src/python/jw/pkg/cmds/distro/lib/rpm.py | 4 +-- src/python/jw/pkg/cmds/projects/CmdBuild.py | 2 +- .../jw/pkg/cmds/projects/CmdGetAuthInfo.py | 2 +- .../jw/pkg/cmds/projects/CmdListRepos.py | 4 +-- src/python/jw/pkg/lib/SSHClient.py | 2 +- src/python/jw/pkg/lib/util.py | 36 +++++++++---------- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/python/jw/pkg/cmds/distro/lib/rpm.py b/src/python/jw/pkg/cmds/distro/lib/rpm.py index f7537353..509cc15f 100644 --- a/src/python/jw/pkg/cmds/distro/lib/rpm.py +++ b/src/python/jw/pkg/cmds/distro/lib/rpm.py @@ -24,7 +24,7 @@ async def all_installed_packages() -> Iterable[Package]: # export opts.append('--queryformat') tag_str = '|'.join([f'%{{{tag}}}' for tag in query_tags]) + r'\n' opts.append(tag_str) - package_list_str, stderr = await run_rpm(['-qa', *opts], sudo=False) + package_list_str, stderr, status = await run_rpm(['-qa', *opts], sudo=False) for package_str in package_list_str.splitlines(): tags = package_str.split('|') ret.append(Package(name=tags[0], vendor=tags[1], packager=tags[2], url=tags[3])) @@ -32,5 +32,5 @@ async def all_installed_packages() -> Iterable[Package]: # export async def list_files(pkg: str) -> list[str]: # export opts: list[str] = [] - file_list_str, stderr = await run_rpm(['-ql', pkg, *opts], sudo=False) + file_list_str, stderr, status = await run_rpm(['-ql', pkg, *opts], sudo=False) return file_list_str.splitlines() diff --git a/src/python/jw/pkg/cmds/projects/CmdBuild.py b/src/python/jw/pkg/cmds/projects/CmdBuild.py index e8e20e28..782211f5 100644 --- a/src/python/jw/pkg/cmds/projects/CmdBuild.py +++ b/src/python/jw/pkg/cmds/projects/CmdBuild.py @@ -115,7 +115,7 @@ class CmdBuild(Cmd): # export env = await get_profile_env(keep=keep) try: - stdout, stderr = await run_cmd( + stdout, stderr, status = await run_cmd( make_cmd, wd=wd, throw=True, diff --git a/src/python/jw/pkg/cmds/projects/CmdGetAuthInfo.py b/src/python/jw/pkg/cmds/projects/CmdGetAuthInfo.py index 489b4f95..1401e59e 100644 --- a/src/python/jw/pkg/cmds/projects/CmdGetAuthInfo.py +++ b/src/python/jw/pkg/cmds/projects/CmdGetAuthInfo.py @@ -35,7 +35,7 @@ class CmdGetAuthInfo(Cmd): # export if not os.path.isdir(jw_pkg_dir + '/.git'): log(DEBUG, f'jw-pkg directory is not a Git repo: {jw_pkg_dir}') return - remotes, stderr = await run_cmd(['git', '-C', jw_pkg_dir, 'remote', '-v']) + remotes, stderr, status = await run_cmd(['git', '-C', jw_pkg_dir, 'remote', '-v']) result: dict[str, str] = {} for line in remotes.splitlines(): name, url, typ = re.split(r'\s+', line) diff --git a/src/python/jw/pkg/cmds/projects/CmdListRepos.py b/src/python/jw/pkg/cmds/projects/CmdListRepos.py index 8755007c..fa8811f9 100644 --- a/src/python/jw/pkg/cmds/projects/CmdListRepos.py +++ b/src/python/jw/pkg/cmds/projects/CmdListRepos.py @@ -55,7 +55,7 @@ class CmdListRepos(Cmd): # export cmd_input = (f'-u {username}:{password}').encode('utf-8') curl_args.extend(['-K-']) curl_args.append(f'https://api.github.com/users/{args.from_owner}/repos') - repos = await run_curl(curl_args, cmd_input=cmd_input) + repos, stderr, status = await run_curl(curl_args, cmd_input=cmd_input) for repo in repos: print(repo['name']) return @@ -71,7 +71,7 @@ class CmdListRepos(Cmd): # export api_url = f'{args.base_url}/api/v1/{entities_dir}/{args.from_owner}/repos' try: tried.append(api_url) - repos = await run_curl(curl_args + [api_url], cmd_input=cmd_input) + repos, stderr, status = await run_curl(curl_args + [api_url], cmd_input=cmd_input) for repo in repos: print(repo['name']) break diff --git a/src/python/jw/pkg/lib/SSHClient.py b/src/python/jw/pkg/lib/SSHClient.py index 8790677a..873f563f 100644 --- a/src/python/jw/pkg/lib/SSHClient.py +++ b/src/python/jw/pkg/lib/SSHClient.py @@ -97,5 +97,5 @@ class SSHClientCmd(SSHClient): # export self.__init_askpass() cmd_arr = ['ssh'] cmd_arr.append(self.hostname) - stdout, stderr = await run_cmd(['ssh', self.hostname, cmd]) + stdout, stderr, status = await run_cmd(['ssh', self.hostname, cmd]) return stdout diff --git a/src/python/jw/pkg/lib/util.py b/src/python/jw/pkg/lib/util.py index 8ccef402..e327bfa8 100644 --- a/src/python/jw/pkg/lib/util.py +++ b/src/python/jw/pkg/lib/util.py @@ -76,13 +76,13 @@ async def run_cmd( def __make_pty_reader(collector: list[bytes], enc_for_verbose: str): def _read(fd): - data = os.read(fd, 1024) - if not data: - return data - collector.append(data) + ret = os.read(fd, 1024) + if not ret: + return ret + collector.append(ret) if verbose: - __log(NOTICE, data.decode(enc_for_verbose, errors="replace").rstrip("\n")) - return data + __log(NOTICE, ret.decode(enc_for_verbose, errors="replace").rstrip("\n")) + return ret return _read if verbose: @@ -128,16 +128,17 @@ async def run_cmd( os.environ.update(old_env) return pty.spawn(args, master_read=reader) - __check_exit_code(await asyncio.to_thread(_spawn)) + exit_code = await asyncio.to_thread(_spawn) + __check_exit_code(exit_code) # PTY merges stdout/stderr stdout_b = b"".join(stdout_chunks_b) if stdout_chunks_b else None if want_bytes: - return stdout_b, None + return stdout_b, None, exit_code stdout_dec_enc = (sys.stdout.encoding or "utf-8") if output_encoding is None else output_encoding stdout_s = stdout_b.decode(stdout_dec_enc, errors="replace") if stdout_b is not None else None - return stdout_s, None + return stdout_s, None, exit_code # -- non-interactive mode stdin = ( @@ -197,7 +198,7 @@ async def run_cmd( stderr_b = b"".join(stderr_parts_b) if stderr_parts_b else None if want_bytes: - return stdout_b, stderr_b + return stdout_b, stderr_b, exit_code if output_encoding is None: stdout_dec_enc = sys.stdout.encoding or "utf-8" @@ -212,7 +213,7 @@ async def run_cmd( if not want_bytes: __check_exit_code(exit_code, stdout=stdout_s, stderr=stderr_s) - return stdout_s, stderr_s + return stdout_s, stderr_s, exit_code finally: if cwd is not None: @@ -225,10 +226,10 @@ async def run_curl(args: list[str], parse_json: bool=True, wd=None, throw=None, if not verbose: cmd.append('-s') cmd.extend(args) - ret, stderr = await run_cmd(cmd, wd=wd, throw=throw, verbose=verbose, cmd_input=cmd_input) + ret, stderr, status = await run_cmd(cmd, wd=wd, throw=throw, verbose=verbose, cmd_input=cmd_input) if parse_json: try: - return json.loads(ret) + ret = json.loads(ret) except Exception as e: size = 'unknown number of' try: @@ -238,7 +239,7 @@ async def run_curl(args: list[str], parse_json: bool=True, wd=None, throw=None, print(f'Failed to parse {size} bytes output of command ' + f'>{pretty_cmd(cmd, wd)}< ({str(e)}): "{ret}"', file=sys.stderr) raise - return ret + return ret, stderr, status async def run_askpass(askpass_env: list[str], key: AskpassKey, host: str|None=None): assert host is None # Currently unsupported @@ -260,7 +261,7 @@ async def run_askpass(askpass_env: list[str], key: AskpassKey, host: str|None=No continue # Can't get user name from SSH_ASKPASS case AskpassKey.Password: exe_arg += 'Password' - ret, stderr = await run_cmd([exe, exe_arg], throw=False) + ret, stderr, status = await run_cmd([exe, exe_arg], throw=False) if ret is not None: return ret return None @@ -280,8 +281,7 @@ async def run_sudo(cmd: list[str], mod_env: dict[str, str] = {}, opts: list[str] cmdline.extend(cmd) if interactive: cmd_input = "mode:interactive" - stdout, stderr = await run_cmd(cmdline, throw=True, verbose=verbose, env=env, cmd_input=cmd_input) - return stdout, stderr + return await run_cmd(cmdline, throw=True, verbose=verbose, env=env, cmd_input=cmd_input) async def get_username(args: Namespace|None=None, url: str|None=None, askpass_env: list[str]=[]) -> str: # export url_user = None if url is None else urlparse(url).username @@ -329,7 +329,7 @@ async def get_profile_env(throw: bool=True, keep: Iterable[str]|bool=False) -> d } # Run bash as a login shell, which sources /etc/profile, then print environment as NUL-separated key=value pairs cmd = ['/usr/bin/env', '-i', '/bin/bash', '-lc', 'env -0'] - stdout, stderr = await run_cmd(cmd, throw=throw, output_encoding="bytes", verbose=True, env=env) + stdout, stderr, status = await run_cmd(cmd, throw=throw, output_encoding="bytes", verbose=True, env=env) ret: dict[str, str] = {} for entry in stdout.split(b"\0"): if not entry: