Skip to content

Error handling

Clak wraps the whole CLI in a try/except inside Parser.dispatch() (called automatically when you instantiate a root Parser(), unless parse=False). When anything fails, clean_terminate() walks a handler chain (same idea as Paasify's CatchErrors + clean_terminate in paasify/cli.py). If nothing matches, the user gets an unexpected bug message with a full Python traceback and please report to the developer.

Runnable example: examples/script_exceptions.py.

Handler chain

flowchart TD
    A[Exception in cli_run] --> B[dispatch try/except]
    B --> C{clean_terminate}
    C --> D[Meta.known_exceptions]
    C --> E[Meta.exception_handlers]
    C --> F[ClakError types]
    C --> G[OS errors]
    D --> H[sys.exit rc]
    E --> H
    F --> H
    G --> H
    C -->|no match| I[traceback + bug message]
    I --> J[sys.exit 1]
Step Source Typical use
1 Meta.known_exceptions Your app exception tree (AppError, PaasifyError, …)
2 Meta.exception_handlers Third-party libs (YAML, shell, config backend, …)
3 Built-in ClakParseError, ClakUserError, ClakAppError, …
4 Built-in FileNotFoundError, PermissionError, …
5 Fallback Uncaught bug — traceback + report to developer

You do not wrap each command in try/except. Raise in cli_run; Clak terminates consistently. BrokenPipeError from | head / | tail exits quietly with code 1 (no traceback, no "Exception ignored" flush noise).

Organize app exceptions (Paasify style)

Give each error a stable exit code (rc) and optional advice:

# myapp/errors.py  (pattern from paasify/errors.py)

class AppError(Exception):
    rc = 1
    advice = None

    def __init__(self, message, rc=None, advice=None):
        self.advice = advice
        if rc is not None:
            self.rc = rc
        super().__init__(message)


class AppNotFound(AppError):
    rc = 44


class YAMLError(AppError):
    rc = 42


class ShellCommandFailed(AppError):
    rc = 18

Register the base class once on the root parser (Paasify v4 AppMain):

from myapp.errors import AppError

class AppMain(Parser):
    class Meta:
        known_exceptions = [AppError]

    def cli_run(self, name, **_):
        raise AppNotFound(f"application {name!r} not found")

Clak prints the message, logs advice at WARNING if set, and exits with err.rc.

Custom handler for a known exception

Pass (ExceptionClass, handler) when the default message/rc is not enough:

def handle_custom_error(app, err):
    print(f"Handled: {err}")
    sys.exit(7)

class Meta:
    known_exceptions = [
        AppError,                      # default: message + err.rc
        (SpecialError, handle_custom_error),
    ]

Handler signature: handler(parser_node, err). Prefer calling sys.exit() yourself. If the handler returns an int, Clak exits with that code. If it returns without an int, Clak exits with err.rc (default 1).

Map third-party libraries

Paasify's clean_terminate maps library exceptions before the bug fallback:

Library error User message Exit code
yaml.parser.ParserError YAML syntax / format YAMLError.rc (42)
sh.ErrorReturnCode Shell command failed ShellCommandFailed.rc
CaframException Config backend error ConfigBackendError.rc

In Clak, register the same mappings on Meta.exception_handlers:

import yaml
import sh
from myapp.errors import YAMLError, ShellCommandFailed, ConfigBackendError


def handle_yaml_error(_app, err):
    logger.critical(err)
    logger.critical("Invalid YAML file (syntax or structure)")
    sys.exit(YAMLError.rc)


def handle_shell_error(_app, err):
    logger.critical(err)
    logger.critical("Command failed with exit code %s", err.exit_code)
    sys.exit(ShellCommandFailed.rc)


class AppMain(Parser):
    class Meta:
        known_exceptions = [AppError]
        exception_handlers = [
            (yaml.parser.ParserError, handle_yaml_error),
            (yaml.scanner.ScannerError, handle_yaml_error),
            (sh.ErrorReturnCode, handle_shell_error),
        ]

Handlers run after known_exceptions and before built-in Clak types.

Built-in Clak exceptions

Use these when you do not have an app-specific type:

Type rc When
ClakUserError 1 User mistake (missing arg, bad value)
ClakParseError 2 Invalid CLI (usage printed first)
ClakAppError 30 Application / environment failure
ClakNotImplementedError 31 Stub command
ClakBugError 32 Broken invariant
from clak.exception import ClakUserError

raise ClakUserError(
    "profile 'staging' is not defined",
    advice="Run: myapp app diag APP --profile prod",
)

Unexpected bugs

If the exception is not handled by any step, dispatch():

  1. Logs the full traceback.
  2. Logs: Uncaught error …; this may be a bug! Please report to the developer.
  3. Exits with code 1.
$ python script_exceptions.py broken
Traceback (most recent call last):
  ...
RuntimeError: unexpected failure in demo command

Uncaught error RuntimeError; this may be a bug! Please report to the developer.

For handled app/library errors, use --trace (from LoggingOptMixin) or CLAK_DEBUG=1 to also print the traceback before the handler chain runs. See Logging.

Full minimal app

script_exceptions.py
#!/usr/bin/env python3
"""Demo: Paasify-style CLI error handling with Clak.

The whole program is wrapped in try/except inside ``Parser.dispatch()``.
Errors flow through ``clean_terminate()``:

  1. ``Meta.known_exceptions``     — your app exception tree (with ``rc``)
  2. ``Meta.exception_handlers``   — third-party libs (YAML, shell, …)
  3. Built-in Clak exceptions
  4. OS errors
  5. No match → full traceback + "report to developer"

Try:
  ./script_exceptions.py deploy myapp
  ./script_exceptions.py deploy missing          # rc 44
  ./script_exceptions.py load bad.yaml           # YAML handler, rc 42
  ./script_exceptions.py broken                  # unexpected bug (traceback)
  ./script_exceptions.py --trace broken          # traceback before handler chain
  CLAK_DEBUG=1 ./script_exceptions.py broken     # same as --trace
"""

from __future__ import annotations

import logging
import sys

import yaml

from clak import Argument, Command, LoggingOptMixin, Parser

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# App exception tree (see paasify/errors.py, paasify_v4/exception.py)
# ---------------------------------------------------------------------------


class AppError(Exception):
    "Base application error."

    rc = 1
    advice = None

    def __init__(self, message, rc=None, advice=None):
        self.advice = advice
        if rc is not None:
            self.rc = rc
        super().__init__(message)


class AppNotFound(AppError):
    "Unknown application."

    rc = 44


class InvalidConfig(AppError):
    "Invalid configuration file."

    rc = 36


# ---------------------------------------------------------------------------
# Third-party handlers (see paasify/cli.py clean_terminate)
# ---------------------------------------------------------------------------


def handle_yaml_parser_error(_app, err):
    logger.critical(err)
    logger.critical("Invalid YAML file (syntax or structure)")
    sys.exit(42)


APPS = {"myapp": {"name": "myapp", "config": "app.yml"}}


# ---------------------------------------------------------------------------
# Commands — raise domain errors; no manual sys.exit()
# ---------------------------------------------------------------------------


class DeployCmd(Parser):
    "Deploy one application."

    name = Argument("NAME", help="Application name")

    def cli_run(self, name: str, **_):
        if name not in APPS:
            raise AppNotFound(
                f"application {name!r} not found",
                advice="Run: script_exceptions.py catalog list",
            )
        print(f"Deploying {name}")


class LoadCmd(Parser):
    "Load a YAML config (demo third-party handler)."

    path = Argument("PATH", help="YAML file path")

    def cli_run(self, path: str, **_):
        with open(path, encoding="utf-8") as handle:
            yaml.safe_load(handle)
        print(f"Loaded {path}")


class BrokenCmd(Parser):
    "Trigger an unexpected bug."

    def cli_run(self, **_):
        raise RuntimeError("unexpected failure in demo command")


class CatalogGroup(Parser):
    "Catalog operations."

    class ListCmd(Parser):
        "List known applications."

        def cli_run(self, **_):
            for name in APPS:
                print(name)

    list = Command(ListCmd)


class AppMain(LoggingOptMixin, Parser):
    """Exception-handling demo."""

    class Meta:
        log_prefix = __name__
        known_exceptions = [AppError]
        exception_handlers = [
            (yaml.parser.ParserError, handle_yaml_parser_error),
            (yaml.scanner.ScannerError, handle_yaml_parser_error),
        ]

    deploy = Command(DeployCmd)
    load = Command(LoadCmd)
    broken = Command(BrokenCmd)
    catalog = Command(CatalogGroup)


if __name__ == "__main__":
    AppMain()
$ python script_exceptions.py deploy missing
application 'missing' not found
# exit 44

$ python script_exceptions.py load /tmp/bad.yaml
# YAML handler → exit 42

$ python script_exceptions.py broken
# exit 1, bug message

What to avoid

# Bad: per-command try/except that prints and returns
def cli_run(self, **_):
    try:
        work()
    except Exception as exc:
        print(exc)
        return 1

# Bad: manual sys.exit in business logic
def find_app(name):
    if name not in APPS:
        sys.exit(44)

Raise AppError / ClakUserError and let dispatch() + clean_terminate() handle termination.

Nested commands

Meta.known_exceptions and Meta.exception_handlers are read from the root parser via query_cfg_parents, so they apply to every subcommand.

See also

  • Paasify reference: paasify/cli.pyCatchErrors, clean_terminate, app()
  • Paasify v4: paasify_v4/cli/main.pyknown_exceptions = [PaasifyError]
  • demo108_exceptions.py
  • Logging-v / --trace / CLAK_DEBUG for diagnostics