RustPython/RustPython · error · ValueError

cmd must be a string

Error message

cmd must be a string

What it means

loop.subprocess_shell() only accepts the command as a single str or bytes value, because it is executed via '/bin/sh -c'. Anything else (typically a list) raises ValueError('cmd must be a string') before any process is spawned. Argument lists belong to loop.subprocess_exec(), which takes the executable and argv without a shell — mirroring the stdlib subprocess shell=True convention.

Source

Thrown at Lib/asyncio/base_events.py:1754

        if stdout is not None and stderr == subprocess.STDOUT:
            info.append(f'stdout=stderr={_format_pipe(stdout)}')
        else:
            if stdout is not None:
                info.append(f'stdout={_format_pipe(stdout)}')
            if stderr is not None:
                info.append(f'stderr={_format_pipe(stderr)}')
        logger.debug(' '.join(info))

    async def subprocess_shell(self, protocol_factory, cmd, *,
                               stdin=subprocess.PIPE,
                               stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE,
                               universal_newlines=False,
                               shell=True, bufsize=0,
                               encoding=None, errors=None, text=None,
                               **kwargs):
        if not isinstance(cmd, (bytes, str)):
            raise ValueError("cmd must be a string")
        if universal_newlines:
            raise ValueError("universal_newlines must be False")
        if not shell:
            raise ValueError("shell must be True")
        if bufsize != 0:
            raise ValueError("bufsize must be 0")
        if text:
            raise ValueError("text must be False")
        if encoding is not None:
            raise ValueError("encoding must be None")
        if errors is not None:
            raise ValueError("errors must be None")

        protocol = protocol_factory()
        debug_log = None
        if self._debug:
            # don't log parameters: they may contain sensitive information
            # (password) and may be too long

View on GitHub (pinned to aaeab4f754)

Solutions

  1. Switch to loop.subprocess_exec(protocol_factory, *cmd_list) for list-style commands
  2. Join into one shell string with shlex.join(cmd) and keep subprocess_shell (only if a shell is genuinely required)
  3. Validate the cmd type at the wrapper boundary and select shell vs exec mode accordingly

Example fix

# before
proc = await loop.subprocess_shell(proto, ['ffmpeg', '-i', in_path])

# after
proc = await loop.subprocess_exec(proto, 'ffmpeg', '-i', in_path)
Defensive patterns

Strategy: type-guard

Validate before calling

def spawn(loop, proto_factory, cmd):
    if isinstance(cmd, (list, tuple)):
        return loop.subprocess_exec(proto_factory, *cmd)
    return loop.subprocess_shell(proto_factory, cmd)

Type guard

def is_shell_command(cmd) -> bool:
    return isinstance(cmd, (str, bytes))

Prevention

When it happens

Trigger: loop.subprocess_shell(make_protocol, ['ls', '-l']); forwarding an argv list built for subprocess.run() into the shell API; passing a tuple or other non-str/bytes command object.

Common situations: Porting subprocess.run(cmd_list, shell=True) code to asyncio; generic process wrappers that must serve both shell and exec modes and default to shell; commands assembled at runtime from lists.

Related errors


AI-assisted analysis of RustPython/RustPython@aaeab4f754 (2026-08-17). Data as JSON: /api/errors/8fbe6ec378625030. Report an issue: GitHub.