Source code for varlord.sources.cli
"""
Command-line argument source.
Loads configuration from command-line arguments using argparse.
Only parses arguments for fields defined in the model.
"""
from __future__ import annotations
import argparse
import sys
from typing import Any, List, Mapping, Optional, Type
from varlord.metadata import get_all_field_keys, get_all_fields_info
from varlord.sources.base import Source
[docs]
def normalized_key_to_cli_arg(normalized_key: str) -> str:
"""Convert normalized key to CLI argument format.
Mapping rules:
- Dots (.) become double dashes (--)
- Underscores (_) become single dashes (-)
Examples:
- "host" -> "host"
- "k8s_pod_name" -> "k8s-pod-name"
- "db.host" -> "db--host"
- "aaa.bbb.ccc_dd" -> "aaa--bbb--ccc-dd"
"""
# Replace dots with double dashes, underscores with single dashes
return normalized_key.replace(".", "--").replace("_", "-")
[docs]
def cli_arg_to_normalized_key(cli_arg: str) -> str:
"""Convert CLI argument to normalized key.
Mapping rules:
- Double dashes (--) become dots (.)
- Single dashes (-) become underscores (_)
Examples:
- "host" -> "host"
- "k8s-pod-name" -> "k8s_pod_name"
- "db--host" -> "db.host"
- "aaa--bbb--ccc-dd" -> "aaa.bbb.ccc_dd"
"""
# Split by double dashes first
parts = cli_arg.split("--")
# Replace single dashes with underscores in each part
normalized_parts = [part.replace("-", "_") for part in parts]
# Join with dots
return ".".join(normalized_parts)
[docs]
class CLI(Source):
"""Source that loads configuration from command-line arguments.
Uses argparse to parse command-line arguments. Only adds arguments for
fields defined in the model. Model is required and will be auto-injected by Config.
Mapping rules:
- Double dashes (--) in CLI arguments become dots (.) in normalized keys
- Single dashes (-) in CLI arguments become underscores (_) in normalized keys
Examples:
- --host → host
- --k8s-pod-name → k8s_pod_name
- --db--host → db.host
- --aaa--bbb--ccc-dd → aaa.bbb.ccc_dd
Supports:
- Automatic type inference from model fields
- Boolean flags (--flag / --no-flag)
- Nested keys via double dashes (--db--host maps to db.host)
- Help text from field metadata
Example:
>>> @dataclass
... class Config:
... host: str = field()
>>> # Command line: python app.py --host 0.0.0.0
>>> source = CLI(model=Config)
>>> source.load()
{'host': '0.0.0.0'}
"""
[docs]
def __init__(
self,
model: Optional[Type[Any]] = None,
argv: Optional[List[str]] = None,
source_id: Optional[str] = None,
):
"""Initialize CLI source.
Args:
model: Optional model to filter CLI arguments.
Only arguments that map to model fields will be parsed.
If None, model will be auto-injected by Config when used in Config.
If provided, this model will be used (allows override).
argv: Command-line arguments (default: sys.argv[1:])
source_id: Optional unique identifier (default: "cli")
Note:
- Recommended: Omit model parameter when used in Config (auto-injected).
- Advanced: Provide model explicitly if using source independently.
"""
super().__init__(model=model, source_id=source_id or "cli")
self._argv = argv
@property
def name(self) -> str:
"""Return source name."""
return "cli"
def _generate_id(self) -> str:
"""Generate unique ID for CLI source."""
return "cli"
[docs]
def load(self) -> Mapping[str, Any]:
"""Load configuration from command-line arguments, filtered by model fields.
Returns:
A mapping of normalized keys (using dot notation) to their values.
Only includes arguments for model fields.
Raises:
ValueError: If model is not provided
"""
self._load_status = "unknown"
self._load_error = None
try:
if not self._model:
raise ValueError(
"CLI source requires model. "
"When used in Config, model is auto-injected. "
"When used independently, provide model explicitly: CLI(model=AppConfig)"
)
valid_keys = get_all_field_keys(self._model)
field_info_map = {
info.normalized_key: info for info in get_all_fields_info(self._model)
}
parser = argparse.ArgumentParser(allow_abbrev=False, add_help=False)
for normalized_key in valid_keys:
if normalized_key not in field_info_map:
continue
field_info = field_info_map[normalized_key]
field_type = field_info.type
cli_arg_name = normalized_key_to_cli_arg(normalized_key)
argparse_dest = normalized_key.replace(".", "_")
try:
if field_type is bool:
parser.add_argument(
f"--{cli_arg_name}",
action="store_true",
default=None,
dest=argparse_dest,
required=False,
)
parser.add_argument(
f"--no-{cli_arg_name}",
dest=argparse_dest,
action="store_false",
default=None,
)
else:
def make_type_converter(ftype):
def converter(value):
try:
return ftype(value)
except (ValueError, TypeError):
return value
return converter
parser.add_argument(
f"--{cli_arg_name}",
type=make_type_converter(field_type),
default=None,
dest=argparse_dest,
required=False,
)
except Exception as e:
import logging
logging.debug(f"Failed to add argument for {normalized_key}: {e}")
argv = self._argv if self._argv is not None else sys.argv[1:]
filtered_argv = [arg for arg in argv if arg not in ("--help", "-h")]
try:
args, _ = parser.parse_known_args(filtered_argv)
except SystemExit:
self._load_status = "success"
return {}
result = {}
for normalized_key in valid_keys:
if normalized_key not in field_info_map:
continue
argparse_dest = normalized_key.replace(".", "_")
value = getattr(args, argparse_dest, None)
if value is not None:
result[normalized_key] = value
self._load_status = "success"
return result
except Exception as e:
self._load_status = "failed"
self._load_error = str(e)
raise
[docs]
def format_help(self, prog: Optional[str] = None) -> str:
"""Generate help text for all CLI arguments based on model fields.
This method generates help text without using argparse's built-in help,
giving varlord complete control over the help output format.
Args:
prog: Program name (default: script name from sys.argv[0])
Returns:
Formatted help text string
"""
if not self._model:
return ""
if prog is None:
import os
prog = os.path.basename(sys.argv[0]) if sys.argv else "app.py"
# Get all field info
field_infos = get_all_fields_info(self._model)
# Group fields by category (required vs optional)
required_fields = []
optional_fields = []
for field_info in field_infos:
arg_name = normalized_key_to_cli_arg(field_info.normalized_key)
help_text = field_info.help or field_info.description or ""
type_name = (
field_info.type.__name__
if hasattr(field_info.type, "__name__")
else str(field_info.type)
)
default_str = ""
if field_info.required:
default_str = " (required)"
elif field_info.default is not ...:
if field_info.default is None:
default_str = " (default: None)"
elif isinstance(field_info.default, str):
default_str = f" (default: '{field_info.default}')"
else:
default_str = f" (default: {field_info.default})"
elif field_info.default_factory is not ...:
default_str = " (has default factory)"
field_entry = {
"name": arg_name,
"type": type_name,
"help": help_text,
"default": default_str,
"normalized_key": field_info.normalized_key,
}
if field_info.required:
required_fields.append(field_entry)
else:
optional_fields.append(field_entry)
# Build help text with improved formatting
lines = [f"Usage: {prog} [OPTIONS]"]
lines.append("")
if required_fields:
lines.append("Required Arguments:")
for field in required_fields:
arg_line = f" --{field['name']} {field['type'].upper()}"
if field["help"]:
lines.append(arg_line)
lines.append(f" {field['help']}")
else:
lines.append(arg_line)
lines.append("")
if optional_fields:
lines.append("Optional Arguments:")
for field in optional_fields:
arg_line = f" --{field['name']} {field['type'].upper()}{field['default']}"
if field["help"]:
lines.append(arg_line)
lines.append(f" {field['help']}")
else:
lines.append(arg_line)
lines.append("")
bool_fields = [f for f in field_infos if f.type is bool]
if bool_fields:
lines.append("Boolean Flags:")
for field_info in bool_fields:
arg_name = normalized_key_to_cli_arg(field_info.normalized_key)
help_text = field_info.help or field_info.description or ""
flag_line = f" --{arg_name} / --no-{arg_name}"
if help_text:
lines.append(flag_line)
lines.append(f" {help_text}")
else:
lines.append(flag_line)
lines.append("")
return "\n".join(lines)
[docs]
def get_field_help(self, field_key: str) -> Optional[str]:
"""Get help text for a specific field.
Args:
field_key: Normalized field key (e.g., "docx_path" or "db.host")
Returns:
Help text if found, None otherwise
"""
if not self._model:
return None
from varlord.metadata import get_field_info
field_info = get_field_info(self._model, field_key)
if field_info:
return field_info.help or field_info.description
return None