"""Configclass decorator for registering dataclasses with Clevis.
This module provides the @configclass decorator that combines @dataclass with
automatic factory registration, enabling seamless integration with Clevis's
configuration system.
The decorator:
- Applies @dataclass to the class
- Registers the class with get_factory()
- Supports subcommand configuration for CLI apps
- Enables TOML section extraction via config parameter
Relationships:
- factory.py: Uses get_factory() to register decorated classes
- __init__.py: Uses @configclass for CLI subcommand configuration
Example:
from clevis import configclass, get_config
# Basic usage
@configclass
class AppConfig:
name: str = "default"
debug: bool = False
# Subcommand support
@configclass(cmd="check", help="Run diagnostics", aliases=["c"])
class CheckConfig:
verbose: bool = False
# TOML section override
@configclass(cmd="cli", config="client")
class CliConfig:
server_url: str = "http://localhost:8000"
"""
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import TypeVar
from clevis.factory import get_factory
logger = logging.getLogger(__name__)
# TypeVar for the decorator pattern.
# Unconstrained (no bound) because the decorator can be applied to any class
# that will become a dataclass. The TypeVar preserves type information so that
# @configclass class X returns type X (not a generic "type" or "object").
T = TypeVar("T")
[docs]
def configclass(
cls: type[T] | None = None,
cmd: str | None = None,
help: str | None = None,
aliases: list[str] | None = None,
config: str | None = None,
default_cmd: bool = False,
) -> type[T] | Callable[[type[T]], type[T]]:
"""
Decorator that registers a dataclass with Clevis's factory system.
Applies @dataclass to the class and registers it with get_factory().
Usage::
@configclass
class MyConfig:
name: str = "default"
This is equivalent to::
@dataclass
class MyConfig:
name: str = "default"
get_factory(MyConfig) # register
For subcommands::
@configclass(cmd="check", help="Run diagnostics", aliases=["c", "chk"])
class CheckConfig:
verbose: bool = False
For TOML section override::
@configclass(cmd="cli", config="client")
class CliConfig:
server_url: str = "http://localhost:8000"
For a default subcommand (runs when no subcommand is given on the CLI)::
@configclass(cmd="chat", default_cmd=True)
class ChatConfig:
model: str = "gpt-4"
Args:
cls: The class to decorate.
cmd: Optional subcommand name for CLI applications with multiple commands.
help: Optional help text for the subcommand (used with cmd parameter).
aliases: Optional list of aliases for the subcommand (used with cmd parameter).
config: Optional TOML extraction key (requires cmd parameter).
default_cmd: If True, this subcommand runs when no subcommand is given on the
CLI. Requires cmd. Only one subcommand per parser may set this (raises
ValueError at configuration time otherwise).
Returns:
The decorated class (now a dataclass).
Raises:
ValueError: If config or default_cmd is provided without cmd parameter.
"""
def decorator(clz: type[T]) -> type[T]:
clz = dataclass(clz)
factory = get_factory(clz) # get_factory upserts if not yet available
# Validate config requires cmd
if config is not None and cmd is None:
raise ValueError(
f"@configclass parameter 'config' requires 'cmd' parameter. "
f"Use @configclass(cmd='name', config='section') instead. "
f"Class: {clz.__name__}"
)
# Validate default_cmd requires cmd
if default_cmd and cmd is None:
raise ValueError(
f"@configclass parameter 'default_cmd' requires 'cmd' parameter. "
f"Use @configclass(cmd='name', default_cmd=True) instead. "
f"Class: {clz.__name__}"
)
# Warn if help/aliases used without cmd
if not cmd:
if help is not None:
logger.warning(
f"@configclass parameter 'help' has no effect without 'cmd' on class {clz.__name__}"
)
if aliases is not None:
logger.warning(
f"@configclass parameter 'aliases' has no effect without 'cmd' on class {clz.__name__}"
)
if cmd:
factory.cmd = cmd
if help is not None:
factory.help = help
if aliases is not None:
factory.aliases = aliases
if config is not None:
factory.config = config
if default_cmd:
factory.default_cmd = default_cmd
return clz
# Decorator return logic:
# Python decorators can be used in two ways:
#
# 1. Without arguments: @configclass
# - cls is passed directly, parameters are None
# - Decorator is called immediately
# - Return the decorated class directly
#
# 2. With arguments: @configclass(cmd="check")
# - cls is None, parameters are provided
# - Decorator must return a function that takes cls
# - Return a lambda that will be called with cls
#
# The condition checks for the "without arguments" case:
# - cls is not None (decorator received class directly)
# - All optional parameters are None/false (no parentheses or empty @configclass())
if cls and not cmd and help is None and aliases is None and config is None and not default_cmd:
# Case 1: @configclass without arguments
# The decorator was used as @configclass, not @configclass(...)
# Apply the decorator immediately and return the result
return decorator(cls)
else:
# Case 2: @configclass with arguments or cls is None
# The decorator was used as @configclass(cmd="check") or similar
# Return a function that takes the class and applies the decorator
return lambda clz: decorator(clz)