Command

Command tasks run subprocesses. The simplest way is to use quickie.command() decorator to define a task. The decorated function should return either a string of command and arguments in POSIX shell format, or a list of strings.

from quickie import command

@command
def command_from_string():
    return "my_command arg1 arg2"

@command
def command_from_list():
    return ["my_command", "arg1", "arg2"]

To create a command from a class, inherit from quickie.tasks.Command and replace the quickie.tasks.Command.get_cmd() method. Or, replace the quickie.tasks.Command.get_binary() and quickie.tasks.Command.get_cmd_args() methods.

For example the followings are all equivalent:

from quickie import Command

@task
class SomeCommand(Command):
    def get_cmd(self):
        return "my_command arg1 arg2"  # or ["my_command", "arg1", "arg2"]

@task
class SomeCommand(Command):
    def get_binary(self):
        return "my_command"

    def get_cmd_args(self):
        return "arg1 arg2"  # or ["arg1", "arg2"]

Environment variables

Pass per-task environment variables with env:

from quickie import command

@command(env={"API_KEY": "secret"})
def deploy():
    return ["my_deploy_tool"]

To load variables from a .env file, use env_file:

from quickie import command

@command(env_file=".env")
def deploy():
    return ["my_deploy_tool"]

Relative paths are resolved from the quickie tasks root directory (the folder that contains _qk/). Absolute paths are used as-is.

When both env and env_file are supplied, values in env take precedence over values loaded from the file:

@command(env_file=".env", env={"API_KEY": "override"})
def deploy():
    return ["my_deploy_tool"]

The same attributes are available on subclasses:

class Deploy(Command):
    env_file = ".env"
    env = {"API_KEY": "override"}

You can also import a .env file as a plain dict independently of any task, for example to inspect values or set a global context from your _qk/__init__.py:

from quickie import load_env_file, Context, app

env = load_env_file(".env")          # returns dict[str, str]

# Or build a Context from a .env file and apply it globally:
app.set_context(Context.from_env_file(".env"))

Exit codes

Command tasks validate subprocess exit codes strictly by default. Exit code 0 is accepted, and any other exit code raises an error that exits the CLI with the same code.

If a command is expected to return a non-zero code, allow it explicitly with expected_exit_codes:

from quickie import command

@command(expected_exit_codes=(0, 2))
def grep_with_no_match_allowed():
    return ["grep", "-q", "pattern", "file.txt"]

The same option is available on subclasses by setting the expected_exit_codes attribute.

To disable exit-code validation completely, set expected_exit_codes to None or ().

Timeout

To limit how long a single subprocess attempt may run, set timeout (in seconds):

from quickie import command

@command(timeout=30)
def long_running():
    return ["my_tool", "--process"]

If the subprocess does not finish within the allotted time, a SubprocessTimeoutError is raised (exit code 124). The same attribute is available on subclasses.

Retry

To automatically retry a command after a transient failure, set retries to the number of additional attempts:

from quickie import command

@command(retries=3)
def flaky_network_call():
    return ["curl", "https://example.com/api"]

An optional retry_delay (in seconds) can be added to wait between attempts:

@command(retries=3, retry_delay=2.0)
def flaky_network_call():
    return ["curl", "https://example.com/api"]

Retries are triggered by both SubprocessExitCodeError and SubprocessTimeoutError. If all attempts fail, the last exception is re-raised. A warning is logged for each failed attempt and each retry. Both attributes are available on subclasses.

Capturing output

By default, command output streams directly to the terminal. Use output_mode to control this behaviour:

  • "stream" (default) — output goes to the terminal; nothing is captured.

  • "capture" — output is captured as bytes; nothing is printed.

  • "tee" — output streams to the terminal and is captured.

Both OutputMode enum values and plain string literals are accepted interchangeably:

from quickie import command, task, OutputMode

# Using the enum
@command(output_mode=OutputMode.CAPTURE)
def get_version_enum():
    return ["my_tool", "--version"]

# Using a string literal — equivalent to the above
@command(output_mode="capture")
def get_version_str():
    return ["my_tool", "--version"]

# Tee — terminal sees output and it is also available afterwards
@command(output_mode="tee")
def verbose_build():
    return ["make", "all"]

@task
def print_version():
    result = get_version_str()      # CompletedProcess.stdout is bytes
    print(result.stdout.decode())

When subclassing, set output_mode as a class attribute using either form:

class MyCommand(Command):
    output_mode = OutputMode.CAPTURE  # or output_mode = "capture"

See also Calling tasks from within run() for the task composition pattern.