Script

Script tasks are similar to command tasks, but they will run the script in the shell, instead of running a subprocess. This allows for more complex scripts, and for the use of shell features such as pipes, redirections, etc, if the shell supports them.

The shell used to run the script is platform dependent by default, but can be changed by setting the executable attribute of the task.

Default shells per platform:

  • Windows: cmd.exe

  • Linux: /bin/sh

  • MacOS: /bin/sh

Warning

Passing arguments to scripts can be dangerous, as they can be used to inject code. Be careful when using them.

Tip

Python supports many of the features of shell scripts, and is usually cross platform and safer. If you can, try to use Python code instead of shell scripts.

from quickie import script

@script
def hello_script():
    return "echo 'Hello, World!'"

This will return a quickie.tasks.Script instance, equivalent to:

from quickie import Script

class HelloScript(Script):
    def get_script(self):
        return "echo 'Hello, World!'"

To pass arguments you can simply use string formatting:

from quickie import script, Arg

@script(args=[
    Arg("name"),
])
def hello_script(name):
    return f"echo 'Hello, {name}!'"

Setting the executable attribute of the task will change the shell used to run the script:

from quickie import script

@script(executable="python")
def hello_script():
    return "print('Hello, World!')"

Environment variables

Pass per-task environment variables with env:

from quickie import script

@script(env={"API_KEY": "secret"})
def deploy():
    return "my_deploy_tool.sh"

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

from quickie import script

@script(env_file=".env")
def deploy():
    return "my_deploy_tool.sh"

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:

@script(env_file=".env", env={"API_KEY": "override"})
def deploy():
    return "my_deploy_tool.sh"

The same attributes are available on subclasses:

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

Exit codes

Script tasks validate shell 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 script intentionally uses a non-zero exit code, allow it explicitly with expected_exit_codes:

from quickie import script

@script(expected_exit_codes=(0, 5))
def script_with_expected_failure():
    return "exit 5"

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 script attempt may run, set timeout (in seconds):

from quickie import script

@script(timeout=30)
def long_script():
    return "my_tool --process"

If the script 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 script after a transient failure, set retries to the number of additional attempts:

from quickie import script

@script(retries=3)
def flaky_script():
    return "curl https://example.com/api"

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

@script(retries=3, retry_delay=2.0)
def flaky_script():
    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, script 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 script, task, OutputMode

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

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

# Tee — terminal sees output and it is also available afterwards
@script(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 MyScript(Script):
    output_mode = OutputMode.CAPTURE  # or output_mode = "capture"

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