Skip to content

Config

User guide: Config.

Usage

from clak import Parser, XDGConfigMixin

class App(XDGConfigMixin, Parser):
    class Meta:
        app_name = "cool-cli"
        # config_required = True  # fail if file is missing

    def cli_run(self, ctx, **_):
        # dict from file (or {} if missing)
        print(ctx.config)
        # attribute access on the root parser
        print(self.config.get("debug"))

if __name__ == "__main__":
    App()
  • JSON (.json): always available (stdlib).
  • YAML (.yaml / .yml): requires optional extra: pip install 'mrjk.clak[config]'.
  • Missing file → empty config unless Meta.config_required = True.
  • Config is not merged into CLI args; read ctx.config / self.config explicitly.

clak.comp.config

XDG Base Directory path helpers and config-file loading.

Provides XDGConfigMixin so apps can expose standard config/data/cache/log path flags with defaults from Meta.app_name / $XDG_*, and load --conf-file once via cli_hook__config.

XDGConfigMixin

XDG path flags and config-file loading.

Adds: - --conf-file: $XDG_CONFIG_HOME/<app>/config.yaml - --data-dir: $XDG_DATA_HOME/<app> (hidden) - --cache-dir: $XDG_CACHE_HOME/<app> (hidden) - --log-dir: $XDG_CACHE_HOME/<app>/logs (hidden)

<app> comes from Meta.app_name, else the parser name / class name. Defaults respect $XDG_CONFIG_HOME, $XDG_DATA_HOME, and $XDG_CACHE_HOME when set.

On dispatch, cli_hook__config loads --conf-file (JSON always; YAML with the config extra). Missing file yields {} unless Meta.config_required is true. Loaded data is available as ctx.config (dict) and cli_root.config (attribute namespace).

Source code in clak/comp/config.py
class XDGConfigMixin:  # pylint: disable=too-few-public-methods
    """XDG path flags and config-file loading.

    Adds:
    - ``--conf-file``: ``$XDG_CONFIG_HOME/<app>/config.yaml``
    - ``--data-dir``: ``$XDG_DATA_HOME/<app>`` (hidden)
    - ``--cache-dir``: ``$XDG_CACHE_HOME/<app>`` (hidden)
    - ``--log-dir``: ``$XDG_CACHE_HOME/<app>/logs`` (hidden)

    ``<app>`` comes from ``Meta.app_name``, else the parser name / class name.
    Defaults respect ``$XDG_CONFIG_HOME``, ``$XDG_DATA_HOME``, and
    ``$XDG_CACHE_HOME`` when set.

    On dispatch, ``cli_hook__config`` loads ``--conf-file`` (JSON always;
    YAML with the ``config`` extra). Missing file yields ``{}`` unless
    ``Meta.config_required`` is true. Loaded data is available as
    ``ctx.config`` (dict) and ``cli_root.config`` (attribute namespace).
    """

    xdg_config = Argument(
        "--conf-file",
        help="Configuration file to use",
    )
    xdg_data_dir = Argument(
        "--data-dir",
        help=argparse.SUPPRESS,
    )
    xdg_cache_dir = Argument(
        "--cache-dir",
        help=argparse.SUPPRESS,
    )
    xdg_log_dir = Argument(
        "--log-dir",
        help=argparse.SUPPRESS,
    )

    meta__config__config_required = MetaSetting(
        help="If true, missing --conf-file raises ClakUserError",
    )

    _XDG_ARG_DEFAULTS = (
        ("xdg_config", "conf_file"),
        ("xdg_data_dir", "data_dir"),
        ("xdg_cache_dir", "cache_dir"),
        ("xdg_log_dir", "log_dir"),
    )

    def _xdg_app_name(self) -> str:
        """Resolve the application name used in XDG paths."""
        name = self.query_cfg_parents("app_name", default=None)
        if not name:
            name = getattr(self, "name", None) or self.__class__.__name__
        return sanitize_xdg_app_name(name)

    def add_arguments(self, arguments: dict = None):
        """Apply XDG defaults from app name / env, then register arguments."""
        if arguments is None:
            arguments = getattr(self, "meta__arguments_dict", None)
        arguments = dict(arguments or {})
        paths = resolve_xdg_paths(self._xdg_app_name())

        for attr_name, path_key in self._XDG_ARG_DEFAULTS:
            template = getattr(type(self), attr_name, None)
            if not isinstance(template, Argument):
                continue
            if attr_name in arguments:
                continue
            kwargs = dict(template.kwargs)
            kwargs.setdefault("default", paths[path_key])
            arg = Argument(*template.args, **kwargs)
            arg.destination = attr_name
            arguments[attr_name] = arg

        return super().add_arguments(arguments)

    def cli_hook__config(self, instance, ctx, **_):
        """Load ``--conf-file`` once and expose it on ctx / root."""
        if ctx.cli_first:
            path = getattr(ctx.args, "xdg_config", None)
            required = bool(
                self.query_cfg_parents(
                    "config_required", default=False, include_self=True
                )
            )

            if not path:
                data: dict[str, Any] = {}
                if required:
                    raise ClakUserError(
                        "Configuration file path is required",
                        advice="Pass --conf-file PATH",
                    )
            else:
                conf_path = Path(path)
                if not conf_path.is_file():
                    if required:
                        raise ClakUserError(
                            f"Configuration file not found: {conf_path}",
                            advice="Create the file or pass --conf-file PATH",
                        )
                    logger.debug(
                        "Config file missing, using empty config: %s", conf_path
                    )
                    data = {}
                else:
                    data = load_config_file(conf_path)

            ctx.plugins["config"] = data
            ctx.plugins["config_path"] = str(path) if path else None
            ctx.cli_root.config = ObjectNamespace(**data)
            logger.debug(
                "Config loaded for %s from %s (%d keys)",
                instance,
                path,
                len(data),
            )

        # Re-attach each hierarchy step (fresh ObjectNamespace per node)
        ctx.config = ctx.plugins.get("config", {})

add_arguments(arguments=None)

Apply XDG defaults from app name / env, then register arguments.

Source code in clak/comp/config.py
def add_arguments(self, arguments: dict = None):
    """Apply XDG defaults from app name / env, then register arguments."""
    if arguments is None:
        arguments = getattr(self, "meta__arguments_dict", None)
    arguments = dict(arguments or {})
    paths = resolve_xdg_paths(self._xdg_app_name())

    for attr_name, path_key in self._XDG_ARG_DEFAULTS:
        template = getattr(type(self), attr_name, None)
        if not isinstance(template, Argument):
            continue
        if attr_name in arguments:
            continue
        kwargs = dict(template.kwargs)
        kwargs.setdefault("default", paths[path_key])
        arg = Argument(*template.args, **kwargs)
        arg.destination = attr_name
        arguments[attr_name] = arg

    return super().add_arguments(arguments)

cli_hook__config(instance, ctx, **_)

Load --conf-file once and expose it on ctx / root.

Source code in clak/comp/config.py
def cli_hook__config(self, instance, ctx, **_):
    """Load ``--conf-file`` once and expose it on ctx / root."""
    if ctx.cli_first:
        path = getattr(ctx.args, "xdg_config", None)
        required = bool(
            self.query_cfg_parents(
                "config_required", default=False, include_self=True
            )
        )

        if not path:
            data: dict[str, Any] = {}
            if required:
                raise ClakUserError(
                    "Configuration file path is required",
                    advice="Pass --conf-file PATH",
                )
        else:
            conf_path = Path(path)
            if not conf_path.is_file():
                if required:
                    raise ClakUserError(
                        f"Configuration file not found: {conf_path}",
                        advice="Create the file or pass --conf-file PATH",
                    )
                logger.debug(
                    "Config file missing, using empty config: %s", conf_path
                )
                data = {}
            else:
                data = load_config_file(conf_path)

        ctx.plugins["config"] = data
        ctx.plugins["config_path"] = str(path) if path else None
        ctx.cli_root.config = ObjectNamespace(**data)
        logger.debug(
            "Config loaded for %s from %s (%d keys)",
            instance,
            path,
            len(data),
        )

    # Re-attach each hierarchy step (fresh ObjectNamespace per node)
    ctx.config = ctx.plugins.get("config", {})

load_config_file(path)

Load a mapping from a JSON or YAML config file.

Format is detected from the file suffix (.json, .yaml, .yml). YAML requires the optional config extra (PyYAML).

Raises:

Type Description
ClakUserError

Unknown suffix, missing PyYAML, I/O/parse error, or non-mapping root document.

Source code in clak/comp/config.py
def load_config_file(path: str | Path) -> dict[str, Any]:
    """Load a mapping from a JSON or YAML config file.

    Format is detected from the file suffix (``.json``, ``.yaml``, ``.yml``).
    YAML requires the optional ``config`` extra (PyYAML).

    Raises:
        ClakUserError: Unknown suffix, missing PyYAML, I/O/parse error, or
            non-mapping root document.
    """
    conf_path = Path(path)
    suffix = conf_path.suffix.lower()

    if suffix in _JSON_SUFFIXES:
        try:
            with conf_path.open(encoding="utf-8") as handle:
                data = json.load(handle)
        except OSError as err:
            raise ClakUserError(
                f"Could not read config file: {conf_path}",
                advice=str(err),
            ) from err
        except json.JSONDecodeError as err:
            raise ClakUserError(
                f"Invalid JSON in config file: {conf_path}",
                advice=str(err),
            ) from err
    elif suffix in _YAML_SUFFIXES:
        if _yaml is None:
            raise ClakUserError(
                f"YAML config requires PyYAML ({conf_path})",
                advice=f"Install with: {_YAML_INSTALL_HINT}",
            )
        try:
            with conf_path.open(encoding="utf-8") as handle:
                data = _yaml.safe_load(handle)
        except OSError as err:
            raise ClakUserError(
                f"Could not read config file: {conf_path}",
                advice=str(err),
            ) from err
        except _yaml.YAMLError as err:
            raise ClakUserError(
                f"Invalid YAML in config file: {conf_path}",
                advice=str(err),
            ) from err
    else:
        raise ClakUserError(
            f"Unsupported config format: {conf_path}",
            advice="Use a .json, .yaml, or .yml file",
        )

    if data is None:
        return {}
    if not isinstance(data, Mapping):
        raise ClakUserError(
            f"Config root must be a mapping/object: {conf_path}",
            advice=f"Got {type(data).__name__}",
        )
    return dict(data)

resolve_xdg_paths(app_name)

Build conf/data/cache/log paths for app_name under XDG bases.

Source code in clak/comp/config.py
def resolve_xdg_paths(app_name: str) -> dict[str, str]:
    """Build conf/data/cache/log paths for ``app_name`` under XDG bases."""
    safe_name = sanitize_xdg_app_name(app_name)
    config_home = xdg_dir("XDG_CONFIG_HOME")
    data_home = xdg_dir("XDG_DATA_HOME")
    cache_home = xdg_dir("XDG_CACHE_HOME")
    return {
        "conf_file": os.path.join(config_home, safe_name, "config.yaml"),
        "data_dir": os.path.join(data_home, safe_name),
        "cache_dir": os.path.join(cache_home, safe_name),
        "log_dir": os.path.join(cache_home, safe_name, "logs"),
    }

sanitize_xdg_app_name(name)

Turn an app name into a safe path segment under XDG directories.

Source code in clak/comp/config.py
def sanitize_xdg_app_name(name: str) -> str:
    """Turn an app name into a safe path segment under XDG directories."""
    cleaned = _UNSAFE_APP_NAME.sub("_", str(name).strip()).strip("._-")
    return cleaned or "app"

xdg_dir(env_var, default=None)

Resolve an XDG base directory from the environment.

Uses $env_var when set and non-empty; otherwise expands default (or the XDG Base Directory default for that variable).

Source code in clak/comp/config.py
def xdg_dir(env_var: str, default: str | None = None) -> str:
    """Resolve an XDG base directory from the environment.

    Uses ``$env_var`` when set and non-empty; otherwise expands ``default``
    (or the XDG Base Directory default for that variable).
    """
    value = os.environ.get(env_var)
    if value:
        return value
    if default is None:
        default = _DEFAULT_XDG[env_var]
    return os.path.expanduser(default)