# Quick Start Get started with argclass in 5 minutes. ## Installation ```console pip install argclass ``` ## Basic Usage Define a class with type hints to create a CLI parser: ```python import argclass class Greeter(argclass.Parser): name: str # Required argument count: int = 1 # Optional with default if __name__ == "__main__": greeter = Greeter() greeter.parse_args() print(f"Hello, {greeter.name}!" * greeter.count) ``` ```console $ python greeter.py --name World --count 3 Hello, World!Hello, World!Hello, World! ``` ## Examples ### Required and Optional Arguments Arguments without defaults are required - the parser will exit with an error if they're not provided. Arguments with defaults are optional. Type hints determine how values are parsed and validated. ```python import argclass class Greeter(argclass.Parser): name: str count: int = 1 greeter = Greeter() greeter.parse_args(["--name", "World", "--count", "3"]) assert greeter.name == "World" assert greeter.count == 3 ``` ### Required vs Positional Arguments **Important:** Required arguments are *not* the same as positional arguments. By default, all argclass arguments are **named** - they use `--name value` syntax, even when required: ```console $ python greeter.py --name World # Named argument (default behavior) $ python greeter.py World # Positional argument (must be explicit) ``` To create a **positional argument** (no `--` prefix, identified by position), pass the argument name without dashes as the first parameter to `Argument()`: ```python import argclass class Greeter(argclass.Parser): name: str = argclass.Argument("name", help="Name to greet") # Positional count: int = 1 # Named optional (--count) greeter = Greeter() greeter.parse_args(["World"]) # No --name needed assert greeter.name == "World" assert greeter.count == 1 ``` Usage comparison: | Definition | CLI Usage | Type | |------------|-----------|------| | `name: str` | `--name World` | Named required | | `name: str = "default"` | `--name World` | Named optional | | `Argument("name")` | `World` | Positional | ### Help Text Use `argclass.Argument()` to add help text that appears in `--help` output. Good help text makes your CLI self-documenting and easier for users to learn. ```python import argclass class Greeter(argclass.Parser): name: str = argclass.Argument(help="Name to greet") count: int = argclass.Argument(default=1, help="Number of times to greet") greeter = Greeter() greeter.parse_args(["--name", "Alice"]) assert greeter.name == "Alice" assert greeter.count == 1 ``` ### Short Aliases Add short single-letter aliases like `-n` for frequently used arguments. Users can then choose between `--name World` or the shorter `-n World`. ```python import argclass class Greeter(argclass.Parser): name: str = argclass.Argument("-n", "--name", help="Name to greet") count: int = argclass.Argument("-c", "--count", default=1) greeter = Greeter() greeter.parse_args(["-n", "World", "-c", "3"]) assert greeter.name == "World" assert greeter.count == 3 ``` Usage: `python greeter.py -n World -c 3` ### Boolean Flags Boolean arguments with `False` defaults become flags: `--verbose` sets the value to `True`. No value is needed - the flag's presence is enough. ```python import argclass class App(argclass.Parser): verbose: bool = False debug: bool = False app = App() app.parse_args(["--verbose", "--debug"]) assert app.verbose is True assert app.debug is True ``` Usage: `python app.py --verbose --debug` ### Multiple Values Use `list[T]` type hints to accept multiple values. Users can provide several values after the flag: `--files a.txt b.txt c.txt`. ```python import argclass class FileProcessor(argclass.Parser): files: list[str] exclude: list[str] = [] processor = FileProcessor() processor.parse_args(["--files", "a.txt", "b.txt", "c.txt", "--exclude", "b.txt"]) assert processor.files == ["a.txt", "b.txt", "c.txt"] assert processor.exclude == ["b.txt"] ``` Usage: `python processor.py --files a.txt b.txt c.txt --exclude b.txt` ### Environment Variables Read defaults from environment variables with `env_var`. This is useful for containerized deployments where configuration comes from the environment. :::{tip} For secrets, use `argclass.Secret(env_var="...")` and call `parser.sanitize_env()` after parsing. This removes secrets from the environment, preventing child processes from accessing them. See [Secrets](secrets.md). ::: ```python import os import argclass os.environ["TEST_DB_HOST"] = "prod.example.com" os.environ["TEST_DB_PORT"] = "5432" class Database(argclass.Parser): host: str = argclass.Argument(env_var="TEST_DB_HOST", default="localhost") port: int = argclass.Argument(env_var="TEST_DB_PORT", default=5432) db = Database() db.parse_args([]) assert db.host == "prod.example.com" assert db.port == 5432 # Cleanup del os.environ["TEST_DB_HOST"] del os.environ["TEST_DB_PORT"] ``` Usage: `TEST_DB_HOST=prod.example.com python app.py` ## Quick Reference | Pattern | Syntax | Result | |---------|--------|--------| | Required arg | `name: str` | `--name` (required) | | Optional arg | `name: str = "default"` | `--name` (optional) | | Positional arg | `argclass.Argument("name")` | `value` (by position) | | Boolean flag | `debug: bool = False` | `--debug` | | Multiple values | `files: list[str]` | `--files a b c` | | Help text | `argclass.Argument(help="...")` | Shows in `--help` | | Short alias | `argclass.Argument("-n", "--name")` | `-n` or `--name` | | Env variable | `argclass.Argument(env_var="VAR")` | Reads from `$VAR` | ## Next Steps ::::{grid} 2 :gutter: 3 :::{grid-item-card} Tutorial :link: tutorial :link-type: doc Complete walkthrough of all features with practical examples. ::: :::{grid-item-card} Config Files :link: config-files :link-type: doc Load defaults from INI, JSON, or TOML configuration files. ::: :::{grid-item-card} Arguments :link: arguments :link-type: doc Detailed argument configuration: types, choices, validators. ::: :::{grid-item-card} API Reference :link: api :link-type: doc Complete API documentation for all classes and functions. ::: :::: --- # Tutorial This tutorial walks through building a complete CLI application with argclass. ## Building a File Backup Tool We'll build a backup utility that demonstrates all major argclass features. ### Basic Structure Every argclass application starts with a Parser class. Define your arguments as class attributes with type hints. Required arguments have no default value, and `Path` types are automatically converted from strings. ```python import argclass from pathlib import Path class BackupTool(argclass.Parser): """Backup files to a destination directory.""" source: Path destination: Path backup = BackupTool() backup.parse_args(["--source", "/data", "--destination", "/backup"]) assert backup.source == Path("/data") assert backup.destination == Path("/backup") ``` :::{note} **Named vs Positional:** By default, arguments use `--name value` syntax (named arguments). For positional arguments like `backup /data /backup`, use `Argument("name")` without dashes. See [Quick Start](quickstart.md#required-vs-positional-arguments). ::: ### Using Positional Arguments For commands where argument order is obvious (like `cp source dest`), positional arguments provide a cleaner interface. Pass the name without dashes to `Argument()`: ```python import argclass from pathlib import Path class BackupTool(argclass.Parser): """Backup files to a destination directory.""" source: Path = argclass.Argument("source", help="Source directory") destination: Path = argclass.Argument("destination", help="Destination directory") compress: bool = False # Still a named flag: --compress backup = BackupTool() backup.parse_args(["/data", "/backup", "--compress"]) assert backup.source == Path("/data") assert backup.destination == Path("/backup") assert backup.compress is True ``` Usage: `python backup.py /data /backup --compress` This combines positional arguments for the main operands with named flags for options. ### Adding Options Make arguments optional by providing default values. Boolean arguments with `False` defaults become flags - users pass `--compress` without a value to enable the feature. Integer and other typed defaults work the same way. ```python import argclass from pathlib import Path class BackupTool(argclass.Parser): """Backup files to a destination directory.""" source: Path destination: Path compress: bool = False verbose: bool = False max_size: int = 100 # MB backup = BackupTool() backup.parse_args([ "--source", "/data", "--destination", "/backup", "--compress", "--max-size", "500" ]) assert backup.source == Path("/data") assert backup.compress is True assert backup.max_size == 500 ``` Note: `bool = False` is a shortcut that creates a flag-style argument (using `action="store_true"` internally). The flag `--compress` sets the value to `True`. ### Help Text and Aliases Use `argclass.Argument()` to add help text and short aliases like `-c` for `--compress`. Help text appears in `--help` output, making your CLI self-documenting. **Important:** When using `argclass.Argument()` for booleans, you must explicitly specify the `action` parameter. Without it, the argument would expect a value like `--compress true` instead of working as a flag: ```python import argclass from pathlib import Path class BackupTool(argclass.Parser): """Backup files to a destination directory.""" source: Path = argclass.Argument(help="Source directory to backup") destination: Path = argclass.Argument( "-d", "--destination", help="Destination directory" ) compress: bool = argclass.Argument( "-c", "--compress", default=False, action=argclass.Actions.STORE_TRUE, help="Compress backup files" ) verbose: bool = argclass.Argument( "-v", "--verbose", default=False, action=argclass.Actions.STORE_TRUE, help="Enable verbose output" ) backup = BackupTool() backup.parse_args(["--source", "/data", "-d", "/backup", "-c", "-v"]) assert backup.source == Path("/data") assert backup.destination == Path("/backup") assert backup.compress is True assert backup.verbose is True ``` ### Using Argument Groups Groups bundle related arguments under a common prefix. Here, compression settings become `--compression-enabled`, `--compression-level`, etc. Groups keep your CLI organized and can be reused across different parsers. ```python import argclass from pathlib import Path class CompressionOptions(argclass.Group): """Compression settings.""" enabled: bool = False level: int = argclass.Argument(default=6, help="Compression level (1-9)") format: str = argclass.Argument( default="gzip", choices=["gzip", "bzip2", "lzma"], help="Compression format" ) class BackupTool(argclass.Parser): """Backup files to a destination directory.""" source: Path destination: Path verbose: bool = False compression = CompressionOptions() backup = BackupTool() backup.parse_args([ "--source", "/data", "--destination", "/backup", "--compression-enabled", "--compression-level", "9", "--compression-format", "lzma" ]) assert backup.source == Path("/data") assert backup.compression.enabled is True assert backup.compression.level == 9 assert backup.compression.format == "lzma" ``` Groups can also contain other Groups, which is handy when settings have their own sub-settings (for example, encryption inside compression). Names join with `-` for CLI, `_` for env vars, and `.` for INI/TOML sections: ```python import argclass from pathlib import Path class EncryptionOptions(argclass.Group): """Encryption settings, nested under compression.""" enabled: bool = False algorithm: str = "aes-256" class CompressionOptions(argclass.Group): """Compression settings with nested encryption.""" enabled: bool = False level: int = 6 encryption: EncryptionOptions = EncryptionOptions() class BackupTool(argclass.Parser): source: Path destination: Path compression: CompressionOptions = CompressionOptions() backup = BackupTool() backup.parse_args([ "--source", "/data", "--destination", "/backup", "--compression-enabled", "--compression-encryption-enabled", "--compression-encryption-algorithm", "chacha20", ]) assert backup.compression.enabled is True assert backup.compression.encryption.enabled is True assert backup.compression.encryption.algorithm == "chacha20" ``` See [Groups → Nested Groups](groups.md#nested-groups) for the full rules on config files and environment variables. ### Adding Subcommands For tools with multiple operations, use subcommands. Each subcommand is a separate Parser class with its own arguments. Implement `__call__` to define what happens when that command runs. The root parser dispatches to the selected subcommand automatically. ```python import argclass from pathlib import Path class BackupCommand(argclass.Parser): """Create a new backup.""" source: Path destination: Path compress: bool = False def __call__(self) -> int: return 0 class RestoreCommand(argclass.Parser): """Restore from a backup.""" backup: Path destination: Path overwrite: bool = False def __call__(self) -> int: return 0 class BackupTool(argclass.Parser): """Backup management tool.""" verbose: bool = False backup = BackupCommand() restore = RestoreCommand() tool = BackupTool() tool.parse_args(["backup", "--source", "/data", "--destination", "/backup", "--compress"]) assert tool.verbose is False assert tool.backup.source == Path("/data") assert tool.backup.destination == Path("/backup") assert tool.backup.compress is True assert tool() == 0 ``` ### Configuration Files Ship default configurations in INI, JSON, or TOML files. Users can override these defaults with CLI arguments. This is useful for deployment-specific settings or user preferences that shouldn't be hardcoded. ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class BackupTool(argclass.Parser): source: Path | None = None destination: Path | None = None compress: bool = False max_size: int = 100 # Create a temporary config file with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write("[DEFAULT]\n") f.write("destination = /backup\n") f.write("compress = true\n") f.write("max_size = 500\n") config_path = f.name tool = BackupTool(config_files=[config_path]) tool.parse_args([]) assert tool.destination == Path("/backup") assert tool.compress is True assert tool.max_size == 500 # Cleanup Path(config_path).unlink() ``` ### Environment Variables Read configuration from environment variables - essential for containerized deployments and 12-factor apps. Use `env_var` to bind specific arguments to environment variables. Values from environment override config files but are overridden by CLI arguments. :::{warning} Environment variables are inherited by child processes. If your app spawns subprocesses, call `parser.sanitize_env()` after parsing to remove secrets. See [Secrets](secrets.md) for details. ::: ```python import os import argclass from pathlib import Path os.environ["BACKUP_DESTINATION"] = "/backup" os.environ["BACKUP_COMPRESS"] = "true" class BackupTool(argclass.Parser): source: Path | None = None destination: Path = argclass.Argument( env_var="BACKUP_DESTINATION", default=Path("/default") ) compress: bool = argclass.Argument( env_var="BACKUP_COMPRESS", default=False ) tool = BackupTool() tool.parse_args([]) assert tool.destination == Path("/backup") assert tool.compress is True # Cleanup del os.environ["BACKUP_DESTINATION"] del os.environ["BACKUP_COMPRESS"] ``` ### Complete Example This example combines everything: subcommands for different operations, groups for organizing related settings, list arguments for multiple values, and a global `--verbose` flag. This pattern scales well for real-world CLI applications. ```python import argclass from pathlib import Path class CompressionGroup(argclass.Group): enabled: bool = False level: int = 6 format: str = argclass.Argument( default="gzip", choices=["gzip", "bzip2", "lzma"] ) class BackupCommand(argclass.Parser): """Create a new backup.""" source: Path = argclass.Argument(help="Source directory") destination: Path = argclass.Argument(help="Destination directory") exclude: list[str] = argclass.Argument( nargs="*", default=[], help="Patterns to exclude" ) compression = CompressionGroup() def __call__(self) -> int: return 0 class RestoreCommand(argclass.Parser): """Restore from a backup.""" backup: Path = argclass.Argument(help="Backup file to restore") destination: Path = argclass.Argument(help="Restore destination") overwrite: bool = False def __call__(self) -> int: return 0 class BackupTool(argclass.Parser): """Backup management tool.""" verbose: bool = False backup = BackupCommand() restore = RestoreCommand() tool = BackupTool() tool.parse_args([ "--verbose", "backup", "--source", "/data", "--destination", "/backup", "--exclude", "*.tmp", "*.log", "--compression-enabled", "--compression-level", "9" ]) assert tool.verbose is True assert tool.backup.source == Path("/data") assert tool.backup.destination == Path("/backup") assert tool.backup.exclude == ["*.tmp", "*.log"] assert tool.backup.compression.enabled is True assert tool.backup.compression.level == 9 assert tool() == 0 ``` ## Next Steps - [Arguments](arguments.md) - All argument options - [Groups](groups.md) - Organizing arguments - [Subparsers](subparsers.md) - Multi-command CLIs - [Config Files](config-files.md) - Configuration file formats --- # Arguments This guide covers all ways to define and configure arguments in argclass. ## Basic Arguments The simplest way to define an argument is with a type annotation. Arguments without defaults are required; arguments with defaults are optional. The type hint determines how string values from the command line are converted. ```python import argclass class Parser(argclass.Parser): name: str # Required string count: int = 10 # Optional integer with default ratio: float = 0.5 # Optional float with default parser = Parser() parser.parse_args(["--name", "test", "--count", "5"]) assert parser.name == "test" assert parser.count == 5 assert parser.ratio == 0.5 ``` ## Type Annotations ### Supported Types argclass automatically handles these types: | Type | Description | Example | |------|-------------|---------| | `str` | String value | `"hello"` | | `int` | Integer value | `42` | | `float` | Floating point | `3.14` | | `bool` | Boolean flag | `True` | | `Path` | File path | `Path("/tmp")` | | `list[T]` | List of values | `["a", "b"]` | | `set[T]` | Unique values | `{1, 2, 3}` | | `frozenset[T]` | Immutable set | `frozenset([1, 2])` | ### Optional Types Use `Optional[T]` or `T | None` for arguments that may not be provided. Unlike required arguments, these default to `None` when not specified, allowing you to detect whether the user provided a value. ```python import argclass from typing import Optional class Parser(argclass.Parser): required_arg: str optional_arg: Optional[str] optional_with_default: Optional[int] = 42 parser = Parser() parser.parse_args(["--required-arg", "value"]) assert parser.required_arg == "value" assert parser.optional_arg is None assert parser.optional_with_default == 42 ``` ### Collection Types Collection types accept multiple values after the flag. Use `list[T]` to preserve order and duplicates, `set[T]` for unique values, or `frozenset[T]` for immutable unique values. argclass automatically configures `nargs`. ```python import argclass class Parser(argclass.Parser): files: list[str] numbers: set[int] tags: frozenset[str] parser = Parser() parser.parse_args([ "--files", "a.txt", "b.txt", "--numbers", "1", "2", "2", "3", "--tags", "web", "api", "web" ]) assert parser.files == ["a.txt", "b.txt"] assert parser.numbers == {1, 2, 3} assert parser.tags == frozenset(["web", "api"]) ``` ### Literal Types Use `Literal[...]` to restrict an argument to specific values. This is a type-safe alternative to `choices` - the allowed values are defined in the type annotation itself, making them visible to static type checkers. ```python import argclass from typing import Literal class Parser(argclass.Parser): mode: Literal["ro", "rw"] = "ro" level: Literal[1, 2, 3] = 1 parser = Parser() parser.parse_args(["--mode", "rw", "--level", "2"]) assert parser.mode == "rw" assert parser.level == 2 ``` Literal types work with `Optional` for arguments that may not be provided: ```python import argclass from typing import Literal, Optional class Parser(argclass.Parser): env: Optional[Literal["dev", "staging", "prod"]] parser = Parser() parser.parse_args([]) assert parser.env is None parser.parse_args(["--env", "prod"]) assert parser.env == "prod" ``` You can also use Literal types in argument groups: ```python import argclass from typing import Literal class StorageGroup(argclass.Group): type: Literal["s3", "posix"] path: str = "/data" class Parser(argclass.Parser): storage = StorageGroup() parser = Parser() parser.parse_args(["--storage-type", "s3"]) assert parser.storage.type == "s3" assert parser.storage.path == "/data" ``` ## Using Argument() Use `argclass.Argument()` when you need more control: short aliases like `-n`, help text for `--help` output, custom metavars, or other argparse options. The first positional arguments define flag names. ```python import argclass class Parser(argclass.Parser): name: str = argclass.Argument( "-n", "--name", help="User name", metavar="NAME", ) count: int = argclass.Argument(default=1) parser = Parser() parser.parse_args(["-n", "Alice", "--count", "5"]) assert parser.name == "Alice" assert parser.count == 5 ``` ## Typed Argument Functions For better IDE support and type checking, use the typed variants. These provide exact return type information to static analyzers like mypy and enable precise autocompletion in your editor. ### ArgumentSingle Use `ArgumentSingle` for arguments that accept exactly one value. Specify the `type` parameter explicitly for proper type inference in your IDE. ```python import argclass class Parser(argclass.Parser): count: int = argclass.ArgumentSingle(type=int, default=10) name: str = argclass.ArgumentSingle(type=str) parser = Parser() parser.parse_args(["--name", "test", "--count", "42"]) assert parser.count == 42 assert parser.name == "test" ``` ### ArgumentSequence Use `ArgumentSequence` for arguments that accept multiple values. The result is always a list. Use `nargs="*"` for zero-or-more, `nargs="+"` for one-or-more. ```python import argclass class Parser(argclass.Parser): numbers: list[int] = argclass.ArgumentSequence(type=int) files: list[str] = argclass.ArgumentSequence(type=str, nargs="*", default=[]) parser = Parser() parser.parse_args(["--numbers", "1", "2", "3"]) assert parser.numbers == [1, 2, 3] assert parser.files == [] ``` ## Boolean Flags Boolean arguments have special handling in argclass. **Shortcut syntax** - Using `bool = False` or `bool = True` directly: ```python import argclass class Parser(argclass.Parser): # bool = False is a shortcut for action="store_true" # The flag --debug sets it to True debug: bool = False # bool = True is a shortcut for action="store_false" # The flag --no-cache sets it to False cache: bool = True parser = Parser() parser.parse_args(["--debug", "--cache"]) assert parser.debug is True assert parser.cache is False ``` **Explicit syntax** - When using `argclass.Argument()` for booleans (e.g., to add help text or aliases), you **must** explicitly specify the `action` parameter: ```python import argclass class Parser(argclass.Parser): # Using Argument() requires explicit action verbose: bool = argclass.Argument( "-v", "--verbose", action=argclass.Actions.STORE_TRUE, # Required! default=False, help="Enable verbose output" ) # Can also use string literal quiet: bool = argclass.Argument( "-q", "--quiet", action="store_true", # String literal works too default=False, help="Suppress output" ) parser = Parser() parser.parse_args(["-v", "-q"]) assert parser.verbose is True assert parser.quiet is True ``` Without `action`, the boolean argument expects a value like `--verbose true`. ## Actions Actions define how argument values are stored. Use `STORE_TRUE` for flags that enable features, `STORE_FALSE` for flags that disable features, and `COUNT` for arguments that can be repeated (like `-vvv` for verbosity). ```python import argclass class Parser(argclass.Parser): verbose: bool = argclass.Argument( "-v", action=argclass.Actions.STORE_TRUE, default=False ) no_cache: bool = argclass.Argument( action=argclass.Actions.STORE_FALSE, default=True ) verbosity: int = argclass.Argument( action=argclass.Actions.COUNT, default=0 ) parser = Parser() parser.parse_args(["-v", "--no-cache", "--verbosity", "--verbosity"]) assert parser.verbose is True assert parser.no_cache is False assert parser.verbosity == 2 ``` ## Nargs The `nargs` parameter controls how many values an argument consumes. Use `?` for zero-or-one, `*` for zero-or-more, `+` for one-or-more, or an integer for an exact count. This is essential for multi-value arguments. ```python import argclass class Parser(argclass.Parser): output: str = argclass.Argument(nargs="?", default="out.txt") extras: list[str] = argclass.Argument(nargs="*", default=[]) files: list[str] = argclass.Argument(nargs="+") point: list[float] = argclass.Argument(nargs=3) parser = Parser() parser.parse_args([ "--files", "a.txt", "b.txt", "--point", "1.0", "2.0", "3.0" ]) assert parser.output == "out.txt" assert parser.extras == [] assert parser.files == ["a.txt", "b.txt"] assert parser.point == [1.0, 2.0, 3.0] ``` For better readability, use the `Nargs` enum instead of string literals: ```python import argclass class Parser(argclass.Parser): optional: str | None = argclass.Argument( nargs=argclass.Nargs.ZERO_OR_ONE, default=None ) multiple: list[str] = argclass.Argument( nargs=argclass.Nargs.ZERO_OR_MORE, default=[] ) parser = Parser() parser.parse_args(["--multiple", "a", "b", "c"]) assert parser.optional is None assert parser.multiple == ["a", "b", "c"] ``` ## Choices Use `choices` to restrict an argument to a predefined set of values. The parser will reject any value not in the list and display valid options in the error message and `--help` output. ```python import argclass class Parser(argclass.Parser): log_level: str = argclass.Argument( choices=["debug", "info", "warning", "error"], default="info" ) format: str = argclass.Argument( choices=["json", "yaml", "toml"], default="json" ) parser = Parser() parser.parse_args(["--log-level", "debug", "--format", "yaml"]) assert parser.log_level == "debug" assert parser.format == "yaml" ``` ## Enum Arguments For type-safe choices, use `EnumArgument` with Python's `Enum` classes. This provides compile-time safety and IDE autocompletion while automatically generating valid choices. ```python import argclass from enum import Enum class Color(Enum): RED = "red" GREEN = "green" BLUE = "blue" class Parser(argclass.Parser): color: Color = argclass.EnumArgument(Color, default="RED") parser = Parser() parser.parse_args([]) assert parser.color == Color.RED parser.parse_args(["--color", "BLUE"]) assert parser.color == Color.BLUE ``` The default can be either an enum member or a string name: ```python import argclass from enum import IntEnum class Priority(IntEnum): LOW = 1 MEDIUM = 2 HIGH = 3 class Parser(argclass.Parser): # String default - validated at class definition time level1: Priority = argclass.EnumArgument(Priority, default="MEDIUM") # Enum member default level2: Priority = argclass.EnumArgument(Priority, default=Priority.HIGH) parser = Parser() parser.parse_args([]) assert parser.level1 == Priority.MEDIUM assert parser.level2 == Priority.HIGH ``` ### Lowercase Choices Use `lowercase=True` for user-friendly lowercase input: ```python import argclass from enum import Enum class Environment(Enum): DEVELOPMENT = "dev" STAGING = "stg" PRODUCTION = "prod" class Parser(argclass.Parser): env: Environment = argclass.EnumArgument( Environment, default="development", lowercase=True ) parser = Parser() parser.parse_args(["--env", "production"]) assert parser.env == Environment.PRODUCTION ``` ### Using Enum Values By default, `EnumArgument` returns the enum member. Use `use_value=True` to get the enum's value instead: ```python import argclass from enum import IntEnum class LogLevel(IntEnum): DEBUG = 10 INFO = 20 WARNING = 30 class Parser(argclass.Parser): # Returns enum member level: LogLevel = argclass.EnumArgument(LogLevel, default="INFO") # Returns enum value (int) level_int: int = argclass.EnumArgument( LogLevel, default="INFO", use_value=True ) parser = Parser() parser.parse_args([]) assert parser.level == LogLevel.INFO assert parser.level_int == 20 # The integer value ``` ## Type vs Converter These two parameters serve different purposes in the parsing pipeline: - **`type`**: Called for each input string during parsing (before collecting) - **`converter`**: Called once on the final collected result (after parsing) Use `type` for per-value conversion, `converter` for post-processing the result. ```python import argclass class Parser(argclass.Parser): # type=int converts each string to int numbers: list[int] = argclass.Argument(nargs="+", type=int) # type=int converts each string, then converter=set deduplicates unique: set[int] = argclass.Argument(nargs="+", type=int, converter=set) # Single converter function unique_alt: set[int] = argclass.Argument( nargs="+", converter=lambda vals: set(map(int, vals)) ) parser = Parser() parser.parse_args([ "--numbers", "1", "2", "3", "--unique", "1", "2", "2", "3", "--unique-alt", "4", "5", "4" ]) assert parser.numbers == [1, 2, 3] assert parser.unique == {1, 2, 3} assert parser.unique_alt == {4, 5} ``` ## Custom Types Any callable that takes a string and returns the desired type works as a type converter. This enables parsing dates, URLs, custom formats, or any domain-specific value types your application needs. ```python import argclass from datetime import datetime from pathlib import Path def parse_date(s: str) -> datetime: return datetime.strptime(s, "%Y-%m-%d") class Parser(argclass.Parser): date: datetime = argclass.Argument(type=parse_date) output: Path = argclass.Argument(type=Path, default=Path(".")) parser = Parser() parser.parse_args(["--date", "2024-01-15", "--output", "/tmp/output"]) assert parser.date == datetime(2024, 1, 15) assert parser.output == Path("/tmp/output") ``` ## Argparse Passthrough Kwargs `Argument()`, `ArgumentSingle()`, and `ArgumentSequence()` accept arbitrary extra keyword arguments via `**kwargs` and forward them as-is to `argparse.ArgumentParser.add_argument()`. This lets you use argparse features that argclass doesn't model explicitly — most commonly the `version` parameter for `action=Actions.VERSION`. ```python import argclass class CLI(argclass.Parser): version = argclass.Argument( "-V", "--version", action=argclass.Actions.VERSION, version="myapp/1.2.3", ) # argparse's version action prints the version and exits the process try: CLI().parse_args(["--version"]) except SystemExit as exc: assert exc.code == 0 ``` The same passthrough works for `Actions.HELP` and any custom argparse `Action` subclass that takes constructor kwargs beyond argclass's built-in set. argclass automatically strips the `type` parameter for `VERSION`, `HELP`, `STORE_TRUE`, `STORE_FALSE`, and `COUNT` actions, since argparse rejects `type=` for them. Extra kwargs are stored as an immutable `MappingProxyType` on the resulting argument and merged into the kwargs passed to `add_argument()` at parser construction time. ### Custom Actions with passthrough kwargs The passthrough mechanism is what lets you ship custom `argparse.Action` subclasses that take their own constructor parameters. The action receives whatever extra kwargs you pass through `Argument(...)` directly in its `__init__`. Here is a self-contained example: a `--check-updates` flag that queries PyPI for the latest version of a configurable package and prints whether an update is available. ```python import json import urllib.request from importlib.metadata import PackageNotFoundError from importlib.metadata import version as get_installed_version import argclass class CheckPyPIUpdateAction(argclass.NonConfigAction): """Query PyPI for the latest version of ``package_name``. ``package_name`` is supplied by argclass via passthrough kwargs. The action behaves like a flag (``nargs=0``) and stores the result on the parsed namespace. It inherits ``NonConfigAction`` because this runtime check should not appear in generated config files. """ def __init__(self, option_strings, dest, package_name, **kwargs): kwargs.setdefault("nargs", 0) kwargs.setdefault( "help", f"Check PyPI for updates to {package_name}", ) self.package_name = package_name super().__init__(option_strings, dest, **kwargs) def __call__(self, parser, namespace, values, option_string=None): try: current = get_installed_version(self.package_name) except PackageNotFoundError: current = None url = f"https://pypi.org/pypi/{self.package_name}/json" try: with urllib.request.urlopen(url, timeout=5) as resp: latest = json.load(resp)["info"]["version"] except Exception as exc: parser.exit(2, f"PyPI check failed: {exc}\n") setattr( namespace, self.dest, { "current": current, "latest": latest, "up_to_date": current == latest, }, ) class CLI(argclass.Parser): # --check-updates is auto-derived from the attribute name check_updates = argclass.Argument( action=CheckPyPIUpdateAction, package_name="argclass", # passthrough kwarg ) parser = CLI() parser.parse_args(["--check-updates"]) assert parser.check_updates["current"] == get_installed_version("argclass") assert isinstance(parser.check_updates["latest"], str) assert isinstance(parser.check_updates["up_to_date"], bool) assert "check_updates" not in argclass.INIConfigGenerator().dump_to_string( parser, ) ``` Run `python myapp.py --check-updates` to perform the live PyPI check. The pattern generalises to any custom action: declare your own constructor parameters, consume them in `__init__` before calling `super().__init__`, and pass them through `argclass.Argument(action=YourAction, your_param=...)`. The same passthrough drives argclass's built-in `GenerateConfigAction`, which takes a `generator=` kwarg and dumps the current parser state to a file (or stdout). See [Generating Config Files](config-generation.md) for the full guide. ### Custom Actions and config generation If your custom action is the "fire and exit" kind — `--version`, `--check-updates`, `--health`, anything that prints something and calls `parser.exit()` — argclass's config generators must skip it from dumps. Otherwise it would end up as a noisy empty entry. Two equivalent opt-outs: 1. **Inherit from `argclass.NonConfigAction`** — cleanest if you're defining a new action from scratch. The base class just sets the `__emit_config__ = False` marker. 2. **Set `__emit_config__ = False` on the action class directly** — useful if you already inherit from something else (a third-party action, your own base). ```python import argparse, argclass # Option 1: subclass NonConfigAction. class CheckUpdatesAction(argclass.NonConfigAction): ... # Option 2: mark an existing action class. class CheckUpdatesAction(argparse.Action): __emit_config__ = False ... ``` argclass's built-in `--help` and `--version` (`Actions.HELP` / `Actions.VERSION`) are recognised automatically and skipped without either marker. Stateful custom actions (counters, accumulators, etc.) are kept in dumps — only "fire and exit" style actions need to opt out. See [Excluding arguments from dumps](config-generation.md#excluding-arguments-from-dumps) for the full discussion. --- # Argument Groups Groups organize related arguments together and enable reuse across parsers. They provide logical structure to your CLI, make `--help` output more readable, and allow you to define common argument sets once and reuse them in multiple parsers. ## The Mental Model: a Group Instance Is a Declaration, not a Parser A `Group()` you assign on a parser class — `db = DatabaseGroup()` or `db: DatabaseGroup = DatabaseGroup(defaults={...})` — is a **declaration of structure and per-instance defaults**, not a runtime parser. You don't call methods on it; you don't use it to read CLI arguments. Its job at class-definition time is to: - name a slot in the parsed result (`parser.db`), - describe which arguments belong to that slot (via its annotations), - optionally override defaults for this particular slot (`defaults=`, `title=`, `prefix=`). When you call `Parser().parse_args(...)`, argclass walks these declarations, builds an `argparse` parser from them, parses the command line, and then writes the parsed values back into the same group instance so you can read them as `parser.db.host`. The instance is a write target during parsing, not an active participant in it. Two consequences worth internalising: 1. **Don't call parser methods on a `Group` instance.** It has no `parse_args()`. Groups are not standalone parsers. 2. **Don't share one `Group` instance across two attributes.** Since the instance holds the parsed state for *its* slot, assigning the same instance to `primary = shared` and `secondary = shared` would make them aliases of one another — argclass raises `ArgclassError` at parse time to prevent this. Construct one `Group()` per slot. (Using the same Group *class* twice is fine — only sharing a single constructed instance is not.) :::{note} **Groups vs. subparsers.** This "instance-is-a-declaration" rule is specific to `Group`. **Subparsers are different**: a subparser is a `Parser` subclass instance assigned to an attribute (e.g. `serve = Serve()`), and at runtime the selected subparser really does parse its own slice of `sys.argv` — it has a working `parse_args()`, its own `__call__`, its own subparsers. Subparsers are real parsers chosen by name from the CLI; groups are namespaced collections of arguments declared upfront. If you want a runnable sub-command, use a subparser. If you want to bundle related options under a prefix, use a group. See [Subparsers](subparsers.md) for the runtime contract. ::: ## Basic Groups Create a group by inheriting from `argclass.Group`. When you add a group to a parser, its arguments are prefixed with the attribute name. Here, `database` becomes the prefix, so arguments become `--database-host`, `--database-port`, etc. ```python import argclass class DatabaseGroup(argclass.Group): host: str = "localhost" port: int = 5432 user: str = "admin" class Parser(argclass.Parser): verbose: bool = False database = DatabaseGroup() parser = Parser() parser.parse_args(["--database-host", "db.example.com", "--database-port", "3306"]) assert parser.verbose is False assert parser.database.host == "db.example.com" assert parser.database.port == 3306 assert parser.database.user == "admin" ``` ## Group Titles Add a descriptive title that appears in `--help` output. This makes the help more readable by clearly labeling each section of related arguments. ```python import argclass class DatabaseGroup(argclass.Group): host: str = "localhost" port: int = 5432 class Parser(argclass.Parser): database = DatabaseGroup(title="Database connection") parser = Parser() parser.parse_args(["--database-host", "db.example.com"]) assert parser.database.host == "db.example.com" assert parser.database.port == 5432 ``` ## Custom Prefixes Override the default prefix with `prefix=`. Use an empty string to add arguments without any prefix. This is useful when you want short argument names or when the group represents the main configuration. ```python import argclass class ConnectionGroup(argclass.Group): host: str = "localhost" port: int = 8080 class Parser(argclass.Parser): # Custom prefix: --api-host, --api-port api = ConnectionGroup(prefix="api") # No prefix: --host, --port server = ConnectionGroup(prefix="") parser = Parser() parser.parse_args([ "--api-host", "api.example.com", "--api-port", "9000", "--host", "server.example.com", "--port", "3000" ]) assert parser.api.host == "api.example.com" assert parser.api.port == 9000 assert parser.server.host == "server.example.com" assert parser.server.port == 3000 ``` ## Reusing Groups The same group class can be instantiated multiple times with different settings. Use `defaults=` to override default values for each instance. This avoids duplicating group definitions for similar configurations. ```python import argclass class HostPort(argclass.Group): host: str = "localhost" port: int class Parser(argclass.Parser): api = HostPort(title="API Server", defaults={"port": 8080}) metrics = HostPort(title="Metrics Server", defaults={"port": 9090}) database = HostPort(title="Database", defaults={"port": 5432}) parser = Parser() parser.parse_args([ "--api-host", "0.0.0.0", "--metrics-port", "9999" ]) assert parser.api.host == "0.0.0.0" assert parser.api.port == 8080 assert parser.metrics.host == "localhost" assert parser.metrics.port == 9999 assert parser.database.port == 5432 ``` ## Group Defaults Use `defaults=` to provide instance-specific default values. This is useful for deployment presets like production vs development configurations, where the same group structure needs different default values. ```python import argclass class ServerGroup(argclass.Group): host: str = "localhost" port: int = 8080 ssl: bool = False class Parser(argclass.Parser): prod = ServerGroup(defaults={ "host": "0.0.0.0", "port": 443, "ssl": True, }) parser = Parser() parser.parse_args([]) assert parser.prod.host == "0.0.0.0" assert parser.prod.port == 443 assert parser.prod.ssl is True ``` ## Inheriting from Groups Parsers can inherit from groups as mixins to include arguments directly at the top level (without a prefix). This is useful for common arguments like logging or verbosity that you want available in multiple parsers. ```python import argclass class LoggingMixin(argclass.Group): log_level: str = "info" log_file: str | None = None class VerboseMixin(argclass.Group): verbose: bool = False quiet: bool = False class Parser(argclass.Parser, LoggingMixin, VerboseMixin): name: str parser = Parser() parser.parse_args(["--name", "test", "--log-level", "debug", "--verbose"]) assert parser.name == "test" assert parser.log_level == "debug" assert parser.verbose is True assert parser.quiet is False ``` ## Accessing Group Values After parsing, access group values through the group attribute. Groups behave like regular Python objects - use dot notation to read the parsed values. ```python import argclass class DatabaseGroup(argclass.Group): host: str = "localhost" port: int = 5432 class Parser(argclass.Parser): database = DatabaseGroup() parser = Parser() parser.parse_args(["--database-host", "db.example.com"]) # Access via group assert parser.database.host == "db.example.com" assert parser.database.port == 5432 ``` ## Nested Groups Groups can contain other Groups as fields. This works for any depth and keeps `Parser → Group → Group → ...` cleanly modelled in code. Names are built by joining the attribute path with the appropriate separator for each source: | Source | Separator | Example | |--------|-----------|-----------------------------------------------| | CLI | `-` | `--endpoint-credentials-username` | | ENV | `_` | `ENDPOINT_CREDENTIALS_USERNAME` | | INI | `.` | `[endpoint.credentials]` section, `username` | | JSON | nested | `{"endpoint": {"credentials": {"username":…}}}` | | TOML | `.` | `[endpoint.credentials]` table, `username` | ```python import argclass class Credentials(argclass.Group): username: str = "admin" password: str = "secret" class Endpoint(argclass.Group): host: str = "localhost" port: int = 8080 credentials: Credentials = Credentials() class Parser(argclass.Parser): endpoint: Endpoint = Endpoint() parser = Parser() parser.parse_args([ "--endpoint-host", "api.example.com", "--endpoint-credentials-username", "root", "--endpoint-credentials-password", "hunter2", ]) assert parser.endpoint.host == "api.example.com" assert parser.endpoint.credentials.username == "root" assert parser.endpoint.credentials.password == "hunter2" ``` Nested groups appear as separate sections in `--help`, titled with their dotted attribute path (e.g. `endpoint.credentials`). Set `title=` on a group to override the default title for that one level. ### Nested groups in config files INI sections use a dotted section name; JSON/TOML use natural nesting. ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Credentials(argclass.Group): username: str = "admin" password: str = "secret" class Endpoint(argclass.Group): host: str = "localhost" credentials: Credentials = Credentials() class Parser(argclass.Parser): endpoint: Endpoint = Endpoint() CONFIG = """ [endpoint] host = api.example.com [endpoint.credentials] username = root password = hunter2 """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG) config_path = f.name parser = Parser(config_files=[config_path]) parser.parse_args([]) assert parser.endpoint.host == "api.example.com" assert parser.endpoint.credentials.username == "root" assert parser.endpoint.credentials.password == "hunter2" Path(config_path).unlink() ``` ### `prefix=` and nested groups `Group(prefix=...)` overrides only the CLI/ENV segment for that group. It does **not** affect the INI/TOML section name — config sections always follow the attribute path. This keeps section names predictable and prevents CLI prefixes from silently desyncing from config layout. ### Group fields with type annotations A group attribute can be declared with a type annotation. When the annotation refers to a `Group` subclass, argclass enforces these rules at class definition time: | Form | Behaviour | |-------------------------------------|---------------------------------| | `g: G` | Auto-instantiated as `G()` | | `g: G = G()` | Uses the provided instance | | `g: G = ...` (Ellipsis sentinel) | Auto-instantiated as `G()` | | `g = G()` (no annotation) | Uses the provided instance | | `g: G \| None = None` | **Rejected** (Group can't be None) | | `g: G = None` | **Rejected** (Group can't be None) | | `g: G = G2()` (wrong Group class) | **Rejected** | | `g: G = "anything-not-a-G"` | **Rejected** | ```python import argclass class DatabaseGroup(argclass.Group): host: str = "localhost" port: int = 5432 class Parser(argclass.Parser): # No explicit default — argclass instantiates DatabaseGroup() for you database: DatabaseGroup parser = Parser() parser.parse_args(["--database-host", "db.example.com"]) assert parser.database.host == "db.example.com" assert parser.database.port == 5432 ``` Rejected forms raise `ArgumentDefinitionError` immediately when the parser class is defined, with a hint suggesting the correct form. ### Reusing a group instance is an error Passing the same `Group` instance to two different attributes raises `ArgclassError`, because that group's parsed state would otherwise be shared between locations. Instantiate a separate group per attribute, or subclass `Group` to define a dedicated type: ```python import argclass class Credentials(argclass.Group): username: str = "admin" class Auth(argclass.Group): primary: Credentials = Credentials() # separate instance secondary: Credentials = Credentials() # separate instance class Parser(argclass.Parser): auth: Auth = Auth() parser = Parser() parser.parse_args([ "--auth-primary-username", "alice", "--auth-secondary-username", "bob", ]) assert parser.auth.primary.username == "alice" assert parser.auth.secondary.username == "bob" ``` ## Groups in Config Files Groups map to INI sections. The section name matches the group attribute name. Top-level parser arguments go in `[DEFAULT]`, while each group gets its own section named after the attribute. ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class ConnectionGroup(argclass.Group): host: str = "localhost" port: int = 8080 class Parser(argclass.Parser): verbose: bool = False database = ConnectionGroup() cache = ConnectionGroup() CONFIG = """ [DEFAULT] verbose = true [database] host = db.example.com port = 5432 [cache] host = redis.example.com port = 6379 """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG) config_path = f.name parser = Parser(config_files=[config_path]) parser.parse_args([]) assert parser.verbose is True assert parser.database.host == "db.example.com" assert parser.database.port == 5432 assert parser.cache.host == "redis.example.com" assert parser.cache.port == 6379 Path(config_path).unlink() ``` --- # Subparsers Subparsers enable multi-command CLIs like `git commit`, `docker run`, etc. ## Design Philosophy Many CLI tools need multiple related commands under a single entry point. Instead of creating separate scripts (`myapp-init`, `myapp-build`, `myapp-deploy`), subparsers let you organize them as `myapp init`, `myapp build`, `myapp deploy`. **argclass subparser design principles:** - **Composition over inheritance**: Each subcommand is a standalone Parser class that can be tested and reused independently - **Type-safe access**: Parsed values are accessed as typed attributes, not dictionary lookups - **Hierarchical structure**: Subcommands can have their own subcommands, enabling deep command trees like `kubectl get pods` - **Shared context**: Parent parser arguments (like `--verbose`) are accessible from subcommands via `__parent__` - **Callable dispatch**: Implement `__call__` on subcommands to define their behavior; calling the root parser automatically dispatches to the selected subcommand ## Basic Subcommands Define subcommands by assigning Parser instances as class attributes. Each nested parser becomes a subcommand with its own arguments. The parent parser can have global options that apply before any subcommand. ```python import argclass class AddCommand(argclass.Parser): """Add a new item.""" name: str value: int = 0 class RemoveCommand(argclass.Parser): """Remove an item.""" name: str force: bool = False class CLI(argclass.Parser): """Item management tool.""" verbose: bool = False add = AddCommand() remove = RemoveCommand() cli = CLI() cli.parse_args(["add", "--name", "foo", "--value", "42"]) assert cli.verbose is False assert cli.add.name == "foo" assert cli.add.value == 42 ``` ## Executing Commands Implement `__call__` to make your parser executable. When you call `parser()` on the root parser, it automatically dispatches to the selected subcommand's `__call__` method. Return an integer exit code. ```python import argclass class StartCommand(argclass.Parser): """Start the server.""" port: int = 8080 def __call__(self) -> int: return self.port class StopCommand(argclass.Parser): """Stop the server.""" def __call__(self) -> int: return 0 class Server(argclass.Parser): start = StartCommand() stop = StopCommand() server = Server() server.parse_args(["start", "--port", "9000"]) result = server() assert result == 9000 ``` ## Accessing Parent Parser Subcommands can access their parent parser via the `__parent__` attribute. This is useful when subcommands need to read global options like `--verbose` or `--debug` that were defined on the parent. ```python import argclass class DeployCommand(argclass.Parser): environment: str = "staging" def __call__(self) -> bool: return self.__parent__.verbose class CLI(argclass.Parser): verbose: bool = False deploy = DeployCommand() cli = CLI() cli.parse_args(["--verbose", "deploy", "--environment", "production"]) assert cli.verbose is True assert cli.deploy.environment == "production" assert cli.deploy() is True # Returns parent's verbose ``` ## Nested Subcommands For complex CLIs, subcommands can have their own subcommands, creating a hierarchy like `docker image pull` or `kubectl get pods`. Each level can define its own arguments and behavior. ```python import argclass class ListImages(argclass.Parser): """List container images.""" all: bool = False def __call__(self) -> str: return "list" class PullImage(argclass.Parser): """Pull an image.""" name: str def __call__(self) -> str: return f"pull:{self.name}" class ImageCommand(argclass.Parser): """Manage images.""" list = ListImages() pull = PullImage() class Docker(argclass.Parser): """Docker-like CLI.""" image = ImageCommand() cli = Docker() cli.parse_args(["image", "pull", "--name", "ubuntu:latest"]) assert cli.image.pull.name == "ubuntu:latest" assert cli() == "pull:ubuntu:latest" ``` ## Current Subparser After parsing, use `current_subparsers` to get a list of the selected subcommand chain. This is useful for conditional logic based on which command was invoked, especially with nested subcommands. ```python import argclass class Sub1(argclass.Parser): value: int = 1 def __call__(self) -> int: return self.value class Sub2(argclass.Parser): value: int = 2 def __call__(self) -> int: return self.value class CLI(argclass.Parser): sub1 = Sub1() sub2 = Sub2() cli = CLI() cli.parse_args(["sub1", "--value", "10"]) assert len(cli.current_subparsers) == 1 assert cli.current_subparsers[0].value == 10 assert cli() == 10 ``` ## Shared Arguments with Groups When multiple subcommands need the same options (like output format or verbosity settings), define them in a Group and include it in each subcommand. This avoids duplication and ensures consistency. ```python import argclass class OutputOptions(argclass.Group): format: str = argclass.Argument( choices=["json", "yaml", "table"], default="table" ) output: str | None = None class ListCommand(argclass.Parser): output = OutputOptions() def __call__(self) -> str: return self.output.format class GetCommand(argclass.Parser): name: str output = OutputOptions() def __call__(self) -> str: return f"{self.name}:{self.output.format}" class CLI(argclass.Parser): list = ListCommand() get = GetCommand() cli = CLI() cli.parse_args(["get", "--name", "item1", "--output-format", "json"]) assert cli.get.name == "item1" assert cli.get.output.format == "json" assert cli() == "item1:json" ``` ## Multiple Subcommands Selection When a parser has multiple subcommands, exactly one is selected per invocation. The selected subcommand's arguments are parsed and populated, while other subcommands retain their default values. ```python import argclass class CreateCommand(argclass.Parser): name: str def __call__(self) -> str: return f"create:{self.name}" class DeleteCommand(argclass.Parser): name: str force: bool = False def __call__(self) -> str: return f"delete:{self.name}" class CLI(argclass.Parser): verbose: bool = False create = CreateCommand() delete = DeleteCommand() # Test create command cli = CLI() cli.parse_args(["--verbose", "create", "--name", "myitem"]) assert cli.verbose is True assert cli() == "create:myitem" # Test delete command cli2 = CLI() cli2.parse_args(["delete", "--name", "myitem", "--force"]) assert cli2.delete.force is True assert cli2() == "delete:myitem" ``` --- # Configuration Files Load default values for CLI arguments from configuration files. Useful for site-specific defaults, deployment configurations, and separating configuration from code. ## Quick Start ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): host: str = "localhost" port: int = 8080 debug: bool = False # Config file content CONFIG_CONTENT = """ [DEFAULT] host = example.com port = 9000 debug = true """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG_CONTENT) config_path = f.name parser = Parser(config_files=[config_path]) parser.parse_args([]) assert parser.host == "example.com" assert parser.port == 9000 assert parser.debug is True Path(config_path).unlink() ``` ## Supported Formats ::::{grid} 3 :gutter: 3 :::{grid-item-card} INI (Default) :class-card: sd-rounded-3 ```ini [DEFAULT] host = localhost port = 8080 ``` Use `INIDefaultsParser` (default) ::: :::{grid-item-card} JSON :class-card: sd-rounded-3 ```json { "host": "localhost", "port": 8080 } ``` Use `JSONDefaultsParser` ::: :::{grid-item-card} TOML :class-card: sd-rounded-3 ```toml host = "localhost" port = 8080 ``` Use `TOMLDefaultsParser` ::: :::: ### Format Comparison | Format | Complex Types | Native Types | Parser Class | |--------|--------------|--------------|--------------| | **INI** | `ast.literal_eval` syntax | All strings | `INIDefaultsParser` | | **JSON** | Native arrays/objects | int, float, bool, null | `JSONDefaultsParser` | | **TOML** | Native arrays/tables | int, float, bool, datetime | `TOMLDefaultsParser` | All parsers validate that config values match expected types. If a value doesn't match (e.g., a string where a list is expected), `UnexpectedConfigValue` is raised. ### INI Complex Types All INI values are strings. For lists, use Python literal syntax: ```ini [DEFAULT] ports = [8080, 8081, 8082] hosts = ["primary.example.com", "backup.example.com"] ``` These are parsed using `ast.literal_eval` when the argument type requires it. ### Type Conversion Type converters specified with `type=` are automatically applied to values loaded from config files. This ensures config values are converted the same way as CLI arguments: ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): # type=Path converts config string to Path object data_dir: Path = argclass.Argument(type=Path) # type applies to each list item ports: list = argclass.Argument( nargs=argclass.Nargs.ONE_OR_MORE, type=int, ) CONFIG_CONTENT = """ [DEFAULT] data_dir = /var/data ports = ["8080", "8081", "8082"] """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG_CONTENT) config_path = f.name parser = Parser(config_files=[config_path]) parser.parse_args([]) assert isinstance(parser.data_dir, Path) assert parser.data_dir == Path("/var/data") assert parser.ports == [8080, 8081, 8082] assert all(isinstance(p, int) for p in parser.ports) Path(config_path).unlink() ``` **Type vs Converter:** | Parameter | Applied to | Use Case | |-----------|-----------|----------| | `type` | Each value (CLI or config) | Convert int, float, Path, URL | | `converter` | Final result after parsing | Convert list→set, aggregate | **Error handling:** Type conversion errors propagate immediately: ```python # Config: port = "not_a_number" # Raises: ValueError: invalid literal for int() with base 10 ``` ### Using JSON ```python import argclass import json from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): host: str = "localhost" port: int = 8080 debug: bool = False CONFIG_DATA = { "host": "json.example.com", "port": 9000, "debug": True } with NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(CONFIG_DATA, f) config_path = f.name parser = Parser( config_files=[config_path], config_parser_class=argclass.JSONDefaultsParser, ) parser.parse_args([]) assert parser.host == "json.example.com" assert parser.port == 9000 assert parser.debug is True Path(config_path).unlink() ``` ### Using TOML :::{note} TOML support uses the standard library `tomllib` module (Python 3.11+). For Python 3.10, install the `tomli` package as a fallback: ```console pip install tomli ``` argclass automatically uses `tomllib` when available, falling back to `tomli`. ::: ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): host: str = "localhost" port: int = 8080 debug: bool = False CONFIG_CONTENT = ''' host = "toml.example.com" port = 9000 debug = true ''' with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: f.write(CONFIG_CONTENT) config_path = f.name parser = Parser( config_files=[config_path], config_parser_class=argclass.TOMLDefaultsParser, ) parser.parse_args([]) assert parser.host == "toml.example.com" assert parser.port == 9000 assert parser.debug is True Path(config_path).unlink() ``` ### Custom Format Subclass `AbstractDefaultsParser` for other formats (e.g., YAML): ```python import argclass from typing import Any, Mapping class YAMLDefaultsParser(argclass.AbstractDefaultsParser): def parse(self) -> Mapping[str, Any]: import yaml result: dict[str, Any] = {} for path in self._filter_readable_paths(): with path.open() as f: data = yaml.safe_load(f) if isinstance(data, dict): result.update(data) self._loaded_files = (path,) self._values = result # Required for get_value() to work return result class Parser(argclass.Parser): host: str = "localhost" parser = Parser( config_files=["config.yaml"], config_parser_class=YAMLDefaultsParser, ) ``` #### Type-Aware Value Loading The `AbstractDefaultsParser` provides a `get_value()` method that handles type conversion and validation based on `ValueKind`: | ValueKind | Description | INI Behavior | JSON/TOML Behavior | |-----------|-------------|--------------|-------------------| | `STRING` | Default, no conversion | Return as-is | Return as-is | | `SEQUENCE` | Lists/tuples or any iterable | `ast.literal_eval` | Validate is list | | `BOOL` | Boolean values | String → bool | Validate is bool | For formats with native types (JSON, TOML, YAML), the base class validates that the value matches the expected kind. For string-based formats (INI), override `_convert()` to parse strings: ```python import ast import argclass from typing import Any, Mapping class CustomParser(argclass.AbstractDefaultsParser): def parse(self) -> Mapping[str, Any]: result: dict[str, Any] = {} # ... load data into result dict ... self._values = result return result def _convert( self, key: str, value: Any, kind: argclass.ValueKind, ) -> Any: """Convert string values based on expected kind.""" if not isinstance(value, str): return value # Already correct type if kind == argclass.ValueKind.SEQUENCE: return ast.literal_eval(value) if kind == argclass.ValueKind.BOOL: return value.lower() in ('true', 'yes', '1') return value ``` If a value doesn't match the expected kind after conversion, `UnexpectedConfigValue` is raised automatically by the base class. ### Strict Mode Use `strict_config=True` to raise errors on configuration problems: ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): host: str = "localhost" # Config with duplicate keys (invalid in strict mode) CONFIG_CONTENT = """ [DEFAULT] host = first.example.com host = second.example.com """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG_CONTENT) config_path = f.name # Non-strict mode (default): last value wins, no error parser1 = Parser(config_files=[config_path], strict_config=False) parser1.parse_args([]) assert parser1.host == "second.example.com" # Strict mode: raises DuplicateOptionError try: parser2 = Parser(config_files=[config_path], strict_config=True) assert False, "Should have raised" except Exception as e: assert "DuplicateOptionError" in type(e).__name__ Path(config_path).unlink() ``` **Behavior by format:** | Format | `strict_config=False` (default) | `strict_config=True` | |--------|--------------------------------|---------------------| | **INI** | Duplicate keys: last wins | Raises `DuplicateOptionError` | | **JSON** | Parse errors: silently skipped | Raises `JSONDecodeError` | | **TOML** | Parse errors: silently skipped | Raises parse exception | :::{tip} Use `strict_config=True` in development to catch configuration errors early. Use `strict_config=False` (default) in production for resilience. ::: ## Loading Behavior ### File Search Specify multiple paths - all readable files are merged: ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): value: str = "default" # Config file content CONFIG_CONTENT = """ [DEFAULT] value = from_config """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG_CONTENT) config_path = f.name # First existing file is used parser = Parser(config_files=[ "/nonexistent/config.ini", config_path, ]) parser.parse_args([]) assert parser.value == "from_config" Path(config_path).unlink() ``` ### Dynamic Paths Use `os.getenv()` to allow users to override config file locations: ```python import os import argclass class Parser(argclass.Parser): host: str = "localhost" port: int = 8080 parser = Parser(config_files=[ # Environment variable takes priority if set os.getenv("MYAPP_CONFIG", "/etc/myapp/config.ini"), # Fallback locations "/etc/myapp.ini", "~/.config/myapp.ini", "./config.ini", ]) ``` **This pattern allows:** | Location | Purpose | |----------|---------| | `$MYAPP_CONFIG` | Operator override | | `/etc/myapp.ini` | System-wide defaults | | `~/.config/myapp.ini` | User preferences | | `./config.ini` | Local development | ### Multi-File Merging Multiple config files are **merged together** - later files override earlier ones: :::{card} Example: Global + User Config ```ini # /etc/myapp.ini (global defaults) [DEFAULT] log_level = warning max_connections = 100 [database] host = db.production.example.com ``` ```ini # ~/.config/myapp.ini (user overrides) [DEFAULT] log_level = debug ``` **Result:** `log_level = debug`, `max_connections = 100`, `database.host = db.production.example.com` ::: ```python import os import argclass class DatabaseGroup(argclass.Group): host: str = "localhost" port: int = 5432 class Parser(argclass.Parser): log_level: str = "info" max_connections: int = 10 database = DatabaseGroup() parser = Parser(config_files=[ "/etc/myapp.ini", os.path.expanduser("~/.config/myapp.ini"), ]) ``` ### Value Priority Values are applied in order (later overrides earlier): :::{card} 1. **Class defaults** → 2. **Config files** → 3. **Environment variables** → 4. **CLI arguments** ::: **Override Matrix:** | Source | Overrides | Overridden by | |--------|-----------|---------------| | Class default | — | Config, Env, CLI | | Config file | Class default | Env, CLI | | Environment variable | Class default, Config | CLI | | CLI argument | All | — | ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): port: int = 8080 # Class default # Config file content CONFIG_CONTENT = """ [DEFAULT] port = 9000 """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG_CONTENT) config_path = f.name # Config overrides class default parser1 = Parser(config_files=[config_path]) parser1.parse_args([]) assert parser1.port == 9000 # CLI overrides config parser2 = Parser(config_files=[config_path]) parser2.parse_args(["--port", "3000"]) assert parser2.port == 3000 Path(config_path).unlink() ``` **End-to-end example with all sources:** ```python import os import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): host: str = "default-host" # 1. Class default port: int = 8080 # 1. Class default debug: bool = False # 1. Class default timeout: int = 30 # 1. Class default # 2. Config file sets host and port CONFIG_CONTENT = """ [DEFAULT] host = config-host port = 9000 """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG_CONTENT) config_path = f.name # 3. Environment sets port and debug os.environ["APP_PORT"] = "9500" os.environ["APP_DEBUG"] = "true" parser = Parser( config_files=[config_path], auto_env_var_prefix="APP_" ) # 4. CLI sets only timeout parser.parse_args(["--timeout", "60"]) # Final values: assert parser.host == "config-host" # From config (no env/cli) assert parser.port == 9500 # From env (overrides config) assert parser.debug is True # From env (overrides default) assert parser.timeout == 60 # From CLI (overrides default) # Cleanup del os.environ["APP_PORT"] del os.environ["APP_DEBUG"] Path(config_path).unlink() ``` --- ## Syntax Reference ### Group Sections Groups map to INI sections: ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class ServerGroup(argclass.Group): host: str = "localhost" port: int = 8080 class DatabaseGroup(argclass.Group): host: str = "localhost" port: int = 5432 class Parser(argclass.Parser): verbose: bool = False server = ServerGroup() database = DatabaseGroup() # Config file content CONFIG_CONTENT = """ [DEFAULT] verbose = true [server] host = 0.0.0.0 port = 9000 [database] host = db.example.com port = 3306 """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG_CONTENT) config_path = f.name parser = Parser(config_files=[config_path]) parser.parse_args([]) assert parser.verbose is True assert parser.server.host == "0.0.0.0" assert parser.server.port == 9000 assert parser.database.host == "db.example.com" assert parser.database.port == 3306 Path(config_path).unlink() ``` #### Nested Groups in Config Files A group inside a group becomes a dotted INI section, a nested JSON/TOML table, or a child object — depending on the format. INI uses the dotted section name verbatim: ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Credentials(argclass.Group): username: str = "admin" password: str = "secret" class Endpoint(argclass.Group): host: str = "localhost" credentials: Credentials = Credentials() class Parser(argclass.Parser): endpoint: Endpoint = Endpoint() CONFIG = """ [endpoint] host = api.example.com [endpoint.credentials] username = root password = hunter2 """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG) config_path = f.name parser = Parser(config_files=[config_path]) parser.parse_args([]) assert parser.endpoint.host == "api.example.com" assert parser.endpoint.credentials.username == "root" assert parser.endpoint.credentials.password == "hunter2" Path(config_path).unlink() ``` JSON uses natural nesting — each group level is a nested object: ```json { "endpoint": { "host": "api.example.com", "credentials": { "username": "root", "password": "hunter2" } } } ``` TOML uses dotted table headers, just like INI: ```toml [endpoint] host = "api.example.com" [endpoint.credentials] username = "root" password = "hunter2" ``` :::{note} The section name in config files always follows the attribute path, even when a group has `prefix=` set. `prefix=` only renames the CLI/env segment for that group. ::: ### Boolean Values | True values | False values | |-------------|--------------| | `true`, `yes`, `on`, `1`, `enable`, `enabled`, `t`, `y` | Any other value | :::{note} For INI files, boolean conversion is case-insensitive (`TRUE`, `True`, `true` all work). JSON and TOML use native boolean types (`true`/`false`). ::: ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): flag1: bool = False flag2: bool = False flag3: bool = True flag4: bool = True # Config file content CONFIG_CONTENT = """ [DEFAULT] flag1 = yes flag2 = 1 flag3 = no flag4 = off """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG_CONTENT) config_path = f.name parser = Parser(config_files=[config_path]) parser.parse_args([]) assert parser.flag1 is True assert parser.flag2 is True assert parser.flag3 is False assert parser.flag4 is False Path(config_path).unlink() ``` ### CLI Override Command-line arguments always override config file values: ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): host: str = "localhost" port: int = 8080 # Config file content CONFIG_CONTENT = """ [DEFAULT] host = config.example.com port = 9000 """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG_CONTENT) config_path = f.name parser = Parser(config_files=[config_path]) parser.parse_args(["--port", "3000"]) assert parser.host == "config.example.com" # From config assert parser.port == 3000 # From CLI (override) Path(config_path).unlink() ``` ## Config as Argument Value :::{note} This is a **separate feature** from `config_files`. Instead of presetting CLI argument defaults, this adds a `--config` argument that loads structured data for your application to use programmatically. ::: Useful when your application needs complex nested structures, arrays, or application-specific data that doesn't map to CLI arguments. ### Built-in Config Types ```python import argclass class Parser(argclass.Parser): # JSON config file argument json_config = argclass.Config(config_class=argclass.JSONConfig) # INI config file argument ini_config = argclass.Config(config_class=argclass.INIConfig) # TOML config file argument (Python 3.11+ or tomli package) toml_config = argclass.Config(config_class=argclass.TOMLConfig) ``` ### JSON Example ```python import argclass import json from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): config = argclass.Config(config_class=argclass.JSONConfig) # Config file content CONFIG_DATA = { "database": { "host": "localhost", "port": 5432, "replicas": ["replica1.db", "replica2.db"] }, "features": ["auth", "logging"] } with NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(CONFIG_DATA, f) config_path = f.name parser = Parser() parser.parse_args(["--config", config_path]) # Access nested data assert parser.config["database"]["host"] == "localhost" assert parser.config["database"]["replicas"] == ["replica1.db", "replica2.db"] assert parser.config["features"] == ["auth", "logging"] Path(config_path).unlink() ``` ### TOML Example :::{note} Requires `tomllib` (Python 3.11+) or `tomli` package for Python 3.10. ::: ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): config = argclass.Config(config_class=argclass.TOMLConfig) # Config file content CONFIG_CONTENT = """ [database] host = "localhost" port = 5432 replicas = ["replica1.db", "replica2.db"] [features] enabled = ["auth", "logging"] """ with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: f.write(CONFIG_CONTENT) config_path = f.name parser = Parser() parser.parse_args(["--config", config_path]) # Access nested data assert parser.config["database"]["host"] == "localhost" assert parser.config["database"]["replicas"] == ["replica1.db", "replica2.db"] assert parser.config["features"]["enabled"] == ["auth", "logging"] Path(config_path).unlink() ``` ### Custom Config Parsers For other formats like YAML, extend `ConfigAction`: ```python from pathlib import Path from typing import Mapping, Any import argclass import yaml class YAMLConfigAction(argclass.ConfigAction): def parse_file(self, file: Path) -> Mapping[str, Any]: with file.open("r") as fp: return yaml.safe_load(fp) class YAMLConfig(argclass.ConfigArgument): action = YAMLConfigAction class Parser(argclass.Parser): config = argclass.Config(config_class=YAMLConfig) ``` ### Key Difference | Feature | `config_files=[...]` | `argclass.Config()` | |---------|---------------------|---------------------| | **Purpose** | Preset CLI argument defaults | Load structured data | | **Access** | Via parser attributes | Via dict-like access | | **Use case** | Site configuration | Application data | --- # Generating Config Files argclass can WRITE config files for a parser — the symmetric inverse of the [config-file reading](config-files.md) covered elsewhere. The expected end-user workflow is "run the app with a flag and get a config file out"; everything else (programmatic dumps, custom formats) builds on that. ## Add `--generate-config` to your CLI Most users only need this: ```python import argclass class CLI(argclass.Parser): host: str = "localhost" port: int = 8080 generate_config = argclass.Argument( action=argclass.GenerateConfigAction, generator=argclass.INIConfigGenerator, metavar="FILE", ) ``` The attribute name `generate_config` auto-derives `--generate-config`; end users then run: ``` myapp --generate-config /etc/myapp.ini # write a file myapp --generate-config - # print to stdout ``` The action writes the file (or stdout) and exits with status 0. If you want to ship multiple formats, declare one attribute per generator — the flag names follow the attribute names: ```python class CLI(argclass.Parser): host: str = "localhost" generate_ini = argclass.Argument( action=argclass.GenerateConfigAction, generator=argclass.INIConfigGenerator, metavar="FILE", ) generate_toml = argclass.Argument( action=argclass.GenerateConfigAction, generator=argclass.TOMLConfigGenerator, metavar="FILE", ) generate_env = argclass.Argument( action=argclass.GenerateConfigAction, generator=argclass.EnvConfigGenerator, metavar="FILE", ) ``` This is exactly the pattern the interactive demo uses; try it with: ``` python -m argclass genconfig --generate-ini - python -m argclass genconfig --generate-toml - DEMO_HOST=prod python -m argclass genconfig --generate-env - ``` ## Picking a format | Generator | Output | Help comments | Pick when… | |--------------------------|---------|---------------|---------------------------------------------| | `INIConfigGenerator` | INI | `; ...` | legacy ecosystems, stdlib-only stack | | `TOMLConfigGenerator` | TOML | `# ...` | comments + nested sections, modern default | | `JSONConfigGenerator` | JSON | (dropped) | machine consumption / pipelines | | `EnvConfigGenerator` | `.env` | `# ...` | Docker, systemd, CI, secret managers | All four are interchangeable from the user's perspective — switch `generator=...` and rerun. ## What lands in the dump The dump reflects the parser's CURRENT resolved state at the moment `--generate-config` fires. argclass's usual priority applies (`defaults < config files < env vars < CLI args`), so all four sources can shape the output. ### CLI flags CLI args parsed BEFORE `--generate-config` end up in the dump (argparse processes flags left-to-right; the action exits before later flags are seen): ``` myapp --host=10.0.0.1 --port=9090 --generate-config - ``` produces a config with `host = 10.0.0.1` and `port = 9090`. Putting `--generate-config` last is the safe convention. ### Environment variables When the parser uses `auto_env_var_prefix=` (or arguments declare explicit `env_var=`), values from `os.environ` reach the dump too: ``` APP_HOST=prod.example.com APP_PORT=9999 myapp --generate-config - ``` writes `host = prod.example.com`, `port = 9999`. ### Config-file defaults A parser instantiated with `config_files=[…]` loads those values during `parse_args`. Whatever the file contained ends up in the dump alongside any CLI overrides. This is the building block for **format conversion** (next section). ## Converting between config formats Format conversion happens through your own parser class — load through reader X, dump through generator Y. There's no built-in `--config` flag in argclass that loads a config file, so this is best expressed as a small script (or a `__main__` entry point): ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Database(argclass.Group): host: str = "localhost" port: int = 5432 class App(argclass.Parser): debug: bool = False name: str = "myapp" db: Database = Database() # Existing INI (shipped with the app in real life). with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write( "[DEFAULT]\n" "debug = true\n" "name = prod\n" "\n" "[db]\n" "host = db.prod.example.com\n" "port = 6432\n", ) ini_path = f.name # Load through the same parser class. parser = App(config_files=[ini_path]) parser.parse_args([]) # Dump as TOML. toml_path = Path(ini_path).with_suffix(".toml") argclass.TOMLConfigGenerator().dump(parser, toml_path) # Round-trip check: reload the TOML, confirm the same state. reloaded = App( config_files=[toml_path], config_parser_class=argclass.TOMLDefaultsParser, ) reloaded.parse_args([]) assert reloaded.debug is True assert reloaded.name == "prod" assert reloaded.db.host == "db.prod.example.com" assert reloaded.db.port == 6432 Path(ini_path).unlink() toml_path.unlink() ``` Bulk conversion is the same in a loop: ```python import argclass from pathlib import Path for src in Path("configs").glob("*.ini"): parser = App(config_files=[src]) parser.parse_args([]) dst = src.with_suffix(".toml") argclass.TOMLConfigGenerator().dump(parser, dst) ``` The conversion is **schema-validated**: keys in the source that have no corresponding argument in the parser are silently dropped from the output, and missing keys fall back to argument defaults. That's usually what you want for a migration script — anything weird in the source surfaces as a missing field in the dump. ## Generating env-var listings `EnvConfigGenerator` emits one `KEY=value` line per argument, using the env var name argclass would read (explicit `env_var=` or computed from `auto_env_var_prefix=`). Arguments without a resolvable env var are skipped — set `auto_env_var_prefix=` on the parser to cover everything. ```python import argclass class Database(argclass.Group): host: str = "localhost" port: int = 5432 class CLI(argclass.Parser): debug: bool = False db: Database = Database() parser = CLI(auto_env_var_prefix="APP_") text = argclass.EnvConfigGenerator().dump_to_string(parser) assert "APP_DEBUG=false" in text assert "APP_DB_HOST=localhost" in text assert "APP_DB_PORT=5432" in text ``` Lists serialise to Python literal syntax so argclass can `ast.literal_eval` them on read: ``` APP_TAGS=['alpha', 'beta', 'gamma'] ``` Strings get quoted only when they contain whitespace, `=`, `#`, control chars, or other shell-significant characters; newlines and tabs are escaped (`\n`, `\t`) so each entry stays on one line. ## Excluding arguments from dumps Some arguments make no sense in a config file — `--version`, `--generate-config` itself, `--check-updates`, anything else that "fires and exits". argclass needs to know about them so they stay out of generated configs. argparse's built-in `--help` and `--version` actions are recognised and skipped automatically. For your own custom `argparse.Action` subclasses, pick one of two equivalent opt-outs: ### Option 1 — inherit from `argclass.NonConfigAction` The cleanest choice for a new action. `NonConfigAction` sets `__emit_config__ = False` for you and keeps intent visible at the class declaration: ```python import argparse import argclass class PingAction(argclass.NonConfigAction): def __init__(self, option_strings, dest, **kw): kw.setdefault("nargs", 0) kw.setdefault("default", argparse.SUPPRESS) super().__init__(option_strings, dest, **kw) def __call__(self, parser, namespace, values, option_string=None): parser.exit(0, "pong\n") class CLI(argclass.Parser): host: str = "localhost" ping = argclass.Argument(action=PingAction) text = argclass.INIConfigGenerator().dump_to_string(CLI()) assert "host = localhost" in text assert "ping" not in text ``` ### Option 2 — set `__emit_config__ = False` on an existing action Useful when you already inherit from a third-party `argparse.Action` and would rather not add another base class: ```python import argparse import argclass class PingAction(argparse.Action): __emit_config__ = False # opt out, equivalent to NonConfigAction def __init__(self, option_strings, dest, **kw): kw.setdefault("nargs", 0) kw.setdefault("default", argparse.SUPPRESS) super().__init__(option_strings, dest, **kw) def __call__(self, parser, namespace, values, option_string=None): parser.exit(0, "pong\n") class CLI(argclass.Parser): host: str = "localhost" ping = argclass.Argument(action=PingAction) text = argclass.INIConfigGenerator().dump_to_string(CLI()) assert "ping" not in text ``` Both forms are honoured the same way. If you forget both, the action shows up in dumps as an empty value — that's the smell test that tells you to opt out. ## Dumping from code The CLI-level `--generate-config` flag is the right entry point for end users. If you're writing tests, a migration script, or a hook that uses the generators directly, use `dump_to_string(parser)` or `dump(parser, dest)`: ```python import argclass class Database(argclass.Group): host: str = "localhost" port: int = 5432 class CLI(argclass.Parser): debug: bool = False name: str = argclass.Argument(default="app", help="App name") db: Database = Database() parser = CLI() ini_text = argclass.INIConfigGenerator().dump_to_string(parser) assert "[DEFAULT]" in ini_text assert "name = app" in ini_text assert "[db]" in ini_text assert "host = localhost" in ini_text ``` `dump(parser, dest)` accepts a path (`str` or `pathlib.Path`), a file-like object, or the string `"-"` for stdout: ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class CLI(argclass.Parser): host: str = "localhost" port: int = 8080 with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: config_path = f.name argclass.TOMLConfigGenerator().dump(CLI(), config_path) loaded = CLI( config_files=[config_path], config_parser_class=argclass.TOMLDefaultsParser, ) loaded.parse_args([]) assert loaded.host == "localhost" assert loaded.port == 8080 Path(config_path).unlink() ``` You can also pass a generator INSTANCE instead of a class when you need to construct it with extra arguments (e.g. `generator=argclass.JSONConfigGenerator()`). ## Custom formats A `ConfigGenerator` walks the parser tree once and yields `ConfigField` records (one per leaf argument) containing the current value, attribute path, help text, and env var metadata. Subclasses consume that stream and produce text — that's the only thing you need to override: ```python import argclass from typing import Sequence class KeyValueGenerator(argclass.ConfigGenerator): """Flat KEY=VALUE format with dotted paths for nested groups.""" extension = ".kv" def render(self, fields: Sequence[argclass.ConfigField]) -> str: lines = [] for field in fields: if field.value is None: continue key = ".".join(field.attr_path) lines.append(f"{key}={field.value}") return "\n".join(lines) + "\n" class CLI(argclass.Parser): host: str = "localhost" port: int = 8080 text = KeyValueGenerator().dump_to_string(CLI()) assert "host=localhost" in text assert "port=8080" in text ``` Field records already carry env var names, so even `.env`-style formats are typically a one-method override. See `argclass.ConfigField` in the [API reference](api.md) for the full record shape. ## Security note Generators emit values as-is, including those marked [`Secret()`](secrets.md). A dumped config can therefore contain credentials. Treat the output file like any credential-bearing file: - Set restrictive permissions when writing to disk. - Avoid dumping to shared locations or to stdout in contexts where logs may be captured. - Prefer `EnvConfigGenerator` when you want to record config in a way that's easy to load via a secret-manager wrapper. ## Limitations - **Subparsers are skipped.** They represent runtime branches, not config-time state. Dump each subparser separately by passing its instance to the generator. - **JSON has no comments.** Help text is dropped in JSON output; INI, TOML, and `.env` formats include it. - **Mid-parse ordering.** CLI flags appearing AFTER `--generate-config` are not reflected in the dump — argparse invokes the action synchronously and the action exits. Put overrides before the generation flag. - **TOML is emitted by a minimal hand-rolled writer.** It covers the types argclass supports (`str`, `int`, `float`, `bool`, `list`, `None`). Exotic values fall back to `str()`. --- # Environment Variables argclass can read default values from environment variables. :::{warning} **Security Risk:** Environment variables are inherited by all child processes. Any subprocess your application spawns (shell commands, external tools, other scripts) can read secrets from environment variables. Always call `sanitize_env()` after parsing to remove sensitive values. See [Sanitizing Environment](#sanitizing-environment). ::: ## Per-Argument Environment Variables Specify an environment variable for a single argument: ```python import os import argclass os.environ["DATABASE_URL"] = "postgres://localhost/db" os.environ["API_KEY"] = "secret123" class Parser(argclass.Parser): database_url: str = argclass.Argument( env_var="DATABASE_URL", default="sqlite:///app.db" ) api_key: str = argclass.Argument(env_var="API_KEY") parser = Parser() parser.parse_args([]) assert parser.database_url == "postgres://localhost/db" assert parser.api_key == "secret123" del os.environ["DATABASE_URL"] del os.environ["API_KEY"] ``` ## Auto Environment Prefix Automatically create environment variables for all arguments: ```python import os import argclass os.environ["APP_HOST"] = "0.0.0.0" os.environ["APP_PORT"] = "9000" os.environ["APP_DEBUG"] = "true" class Parser(argclass.Parser): host: str = "localhost" port: int = 8080 debug: bool = False parser = Parser(auto_env_var_prefix="APP_") parser.parse_args([]) assert parser.host == "0.0.0.0" assert parser.port == 9000 assert parser.debug is True del os.environ["APP_HOST"] del os.environ["APP_PORT"] del os.environ["APP_DEBUG"] ``` ## Group Environment Variables Groups use their prefix in environment variable names: ```python import os import argclass os.environ["APP_DATABASE_HOST"] = "db.example.com" os.environ["APP_DATABASE_PORT"] = "3306" class DatabaseGroup(argclass.Group): host: str = "localhost" port: int = 5432 class Parser(argclass.Parser): database = DatabaseGroup() parser = Parser(auto_env_var_prefix="APP_") parser.parse_args([]) assert parser.database.host == "db.example.com" assert parser.database.port == 3306 del os.environ["APP_DATABASE_HOST"] del os.environ["APP_DATABASE_PORT"] ``` ### Nested Groups Nested group fields use the full attribute path joined with `_`, then uppercased: ```python import os import argclass os.environ["APP_ENDPOINT_HOST"] = "api.example.com" os.environ["APP_ENDPOINT_CREDENTIALS_USERNAME"] = "root" os.environ["APP_ENDPOINT_CREDENTIALS_PASSWORD"] = "hunter2" class Credentials(argclass.Group): username: str = "admin" password: str = "secret" class Endpoint(argclass.Group): host: str = "localhost" credentials: Credentials = Credentials() class Parser(argclass.Parser): endpoint: Endpoint = Endpoint() parser = Parser(auto_env_var_prefix="APP_") parser.parse_args([]) assert parser.endpoint.host == "api.example.com" assert parser.endpoint.credentials.username == "root" assert parser.endpoint.credentials.password == "hunter2" del os.environ["APP_ENDPOINT_HOST"] del os.environ["APP_ENDPOINT_CREDENTIALS_USERNAME"] del os.environ["APP_ENDPOINT_CREDENTIALS_PASSWORD"] ``` ## Priority Environment variables override config files but are overridden by CLI arguments: 1. Class defaults 2. Config file values 3. **Environment variables** 4. Command-line arguments ```python import os import argclass from pathlib import Path from tempfile import NamedTemporaryFile # Create config file with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write("[DEFAULT]\n") f.write("port = 9000\n") config_path = f.name os.environ["APP_PORT"] = "9500" class Parser(argclass.Parser): port: int = 8080 # Default # CLI wins over env parser1 = Parser( config_files=[config_path], auto_env_var_prefix="APP_" ) parser1.parse_args(["--port", "3000"]) assert parser1.port == 3000 # Without CLI, env wins over config parser2 = Parser( config_files=[config_path], auto_env_var_prefix="APP_" ) parser2.parse_args([]) assert parser2.port == 9500 del os.environ["APP_PORT"] # Without env, config wins over default parser3 = Parser(config_files=[config_path]) parser3.parse_args([]) assert parser3.port == 9000 Path(config_path).unlink() ``` ## Sanitizing Environment :::{danger} **Child processes inherit environment variables.** When your application runs shell commands, spawns subprocesses, or calls external tools, those processes receive a copy of all environment variables - including your secrets. **Example attack scenario:** 1. Your app reads `DB_PASSWORD` from environment 2. Your app runs `subprocess.run(["backup-tool", ...])` 3. `backup-tool` (or malicious code in it) reads `DB_PASSWORD` 4. Your secret is compromised **Solution:** Call `parser.sanitize_env()` immediately after parsing to remove sensitive environment variables before spawning any child processes. ::: Remove sensitive variables after parsing: ```python import os import argclass os.environ["API_KEY"] = "secret_key" os.environ["DB_PASSWORD"] = "secret_pass" class Parser(argclass.Parser): api_key: str = argclass.Secret(env_var="API_KEY") password: str = argclass.Secret(env_var="DB_PASSWORD") parser = Parser() parser.parse_args([]) # Values are parsed assert str(parser.api_key) == "secret_key" assert str(parser.password) == "secret_pass" # Remove used environment variables parser.sanitize_env() # These are now unset assert "API_KEY" not in os.environ assert "DB_PASSWORD" not in os.environ ``` ### Automatic Sanitization During Parsing Use `sanitize_secrets=True` in `parse_args()` to automatically remove secret environment variables immediately after parsing: ```python import os import argclass os.environ["API_KEY"] = "secret_key" os.environ["APP_HOST"] = "localhost" class Parser(argclass.Parser): api_key: str = argclass.Secret(env_var="API_KEY") host: str = argclass.Argument(env_var="APP_HOST") parser = Parser() parser.parse_args([], sanitize_secrets=True) # Secret env var removed automatically assert "API_KEY" not in os.environ # Non-secret env var preserved assert os.environ["APP_HOST"] == "localhost" del os.environ["APP_HOST"] ``` ### Selective Sanitization Use `sanitize_env(only_secrets=True)` to remove only secret environment variables while preserving non-secret ones: ```python import os import argclass os.environ["API_KEY"] = "secret_key" os.environ["APP_HOST"] = "localhost" class Parser(argclass.Parser): api_key: str = argclass.Secret(env_var="API_KEY") host: str = argclass.Argument(env_var="APP_HOST") parser = Parser() parser.parse_args([]) # Remove only secret env vars parser.sanitize_env(only_secrets=True) # Secret env var removed assert "API_KEY" not in os.environ # Non-secret env var preserved assert os.environ["APP_HOST"] == "localhost" del os.environ["APP_HOST"] ``` ## Boolean Environment Variables These values are recognized as `True`: - `y`, `yes`, `true`, `t` - `enable`, `enabled` - `1`, `on` These values are recognized as `False`: - `n`, `no`, `false`, `f` - `disable`, `disabled` - `0`, `off` ```python import os import argclass class Parser(argclass.Parser): flag1: bool = False flag2: bool = False flag3: bool = True flag4: bool = True os.environ["APP_FLAG1"] = "yes" os.environ["APP_FLAG2"] = "1" os.environ["APP_FLAG3"] = "no" os.environ["APP_FLAG4"] = "off" parser = Parser(auto_env_var_prefix="APP_") parser.parse_args([]) assert parser.flag1 is True assert parser.flag2 is True assert parser.flag3 is False assert parser.flag4 is False del os.environ["APP_FLAG1"] del os.environ["APP_FLAG2"] del os.environ["APP_FLAG3"] del os.environ["APP_FLAG4"] ``` ## Combining with Config Files Use environment variables to point to config files: ```python import os import argclass from pathlib import Path from tempfile import NamedTemporaryFile # Create config file with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write("[DEFAULT]\n") f.write("host = config.example.com\n") f.write("port = 9000\n") config_path = f.name os.environ["APP_CONFIG"] = config_path class Parser(argclass.Parser): host: str = "localhost" port: int = 8080 config_file = os.environ.get("APP_CONFIG", "config.ini") parser = Parser(config_files=[config_file]) parser.parse_args([]) assert parser.host == "config.example.com" assert parser.port == 9000 del os.environ["APP_CONFIG"] Path(config_path).unlink() ``` --- # Secrets argclass provides built-in support for handling sensitive values securely. ## Secret Arguments Use `argclass.Secret()` to mark sensitive arguments: ```python import argclass class Parser(argclass.Parser): username: str password: str = argclass.Secret(help="Database password") api_key: str = argclass.Secret() parser = Parser() parser.parse_args(["--username", "admin", "--password", "secret123", "--api-key", "key456"]) assert parser.username == "admin" assert str(parser.password) == "secret123" assert str(parser.api_key) == "key456" ``` Or use the `secret=True` parameter: ```python import argclass class Parser(argclass.Parser): password: str = argclass.Argument( secret=True, help="Database password" ) parser = Parser() parser.parse_args(["--password", "supersecret"]) assert str(parser.password) == "supersecret" ``` ## SecretString Type Secret string values are wrapped in `SecretString`: ```python import argclass from argclass import SecretString class Parser(argclass.Parser): password: str = argclass.Secret() parser = Parser() parser.parse_args(["--password", "supersecret"]) # Value is wrapped assert isinstance(parser.password, SecretString) assert repr(parser.password) == "'******'" assert str(parser.password) == "supersecret" ``` ## Preventing Accidental Logging `SecretString` prevents accidental exposure when used with logging: ```python from argclass import SecretString password = SecretString("supersecret") # repr always shows masked value assert repr(password) == "'******'" # Use !r in f-strings for safe output assert f"{password!r}" == "'******'" # str() returns the actual value - use with caution actual = str(password) assert actual == "supersecret" ``` ## Secrets from Environment Combine secrets with environment variables: ```python import os import argclass os.environ["DB_PASSWORD"] = "secret_db_pass" os.environ["API_KEY"] = "key123" class Parser(argclass.Parser): db_password: str = argclass.Secret( env_var="DB_PASSWORD", help="Database password" ) api_key: str = argclass.Secret( env_var="API_KEY", help="API authentication key" ) parser = Parser() parser.parse_args([]) assert str(parser.db_password) == "secret_db_pass" assert str(parser.api_key) == "key123" # Clean up environment after parsing parser.sanitize_env() assert "DB_PASSWORD" not in os.environ assert "API_KEY" not in os.environ ``` ## Secrets from Config Files Secrets can also come from config files (use with caution): ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class DatabaseGroup(argclass.Group): host: str = "localhost" password: str = argclass.Secret() class Parser(argclass.Parser): api_key: str = argclass.Secret() database = DatabaseGroup() # Create config file with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write("[DEFAULT]\n") f.write("api_key = your-secret-key\n\n") f.write("[database]\n") f.write("password = db-password\n") config_path = f.name parser = Parser(config_files=[config_path]) parser.parse_args([]) assert str(parser.api_key) == "your-secret-key" assert str(parser.database.password) == "db-password" Path(config_path).unlink() ``` ## Secret Groups Group related secrets: ```python import argclass class CredentialsGroup(argclass.Group): username: str password: str = argclass.Secret() token: str = argclass.Secret() class Parser(argclass.Parser): credentials = CredentialsGroup() parser = Parser() parser.parse_args([ "--credentials-username", "admin", "--credentials-password", "secret123", "--credentials-token", "token456" ]) assert parser.credentials.username == "admin" assert str(parser.credentials.password) == "secret123" assert str(parser.credentials.token) == "token456" ``` ## Comparison Methods `SecretString` supports comparison without exposing values: ```python from argclass import SecretString secret1 = SecretString("password123") secret2 = SecretString("password123") secret3 = SecretString("different") # Comparisons work assert secret1 == secret2 assert secret1 != secret3 assert secret1 == "password123" # repr is always safe assert repr(secret1) == "'******'" ``` ## Best Practices ### 1. Use Environment Variables Prefer environment variables over config files for secrets: ```python import os import argclass os.environ["API_KEY"] = "from_environment" class Parser(argclass.Parser): # Good: from environment api_key: str = argclass.Secret(env_var="API_KEY") parser = Parser() parser.parse_args([]) assert str(parser.api_key) == "from_environment" del os.environ["API_KEY"] ``` ### 2. Sanitize After Parsing :::{danger} **Critical:** Environment variables are inherited by child processes. If your application spawns subprocesses, runs shell commands, or calls external tools, those processes can read your secrets from the environment. Call `sanitize_env()` **immediately after parsing** and **before any subprocess calls**. ::: Remove secrets from the environment: ```python import os import argclass os.environ["SECRET_VALUE"] = "sensitive" class Parser(argclass.Parser): secret: str = argclass.Secret(env_var="SECRET_VALUE") parser = Parser() parser.parse_args([]) assert "SECRET_VALUE" in os.environ parser.sanitize_env() # Removes all used env vars assert "SECRET_VALUE" not in os.environ ``` #### Automatic Sanitization During Parsing Use `sanitize_secrets=True` to automatically remove secret environment variables during parsing: ```python import os import argclass os.environ["SECRET_VALUE"] = "sensitive" os.environ["PUBLIC_VALUE"] = "not_secret" class Parser(argclass.Parser): secret: str = argclass.Secret(env_var="SECRET_VALUE") public: str = argclass.Argument(env_var="PUBLIC_VALUE") parser = Parser() parser.parse_args([], sanitize_secrets=True) # Secret env var is removed automatically assert "SECRET_VALUE" not in os.environ # Non-secret env var remains assert os.environ["PUBLIC_VALUE"] == "not_secret" # Values are still accessible assert str(parser.secret) == "sensitive" assert parser.public == "not_secret" del os.environ["PUBLIC_VALUE"] ``` #### Selective Sanitization Use `sanitize_env(only_secrets=True)` to remove only secret environment variables while keeping non-secret ones: ```python import os import argclass os.environ["SECRET_VALUE"] = "sensitive" os.environ["PUBLIC_VALUE"] = "not_secret" class Parser(argclass.Parser): secret: str = argclass.Secret(env_var="SECRET_VALUE") public: str = argclass.Argument(env_var="PUBLIC_VALUE") parser = Parser() parser.parse_args([]) # Remove only secret env vars parser.sanitize_env(only_secrets=True) # Secret env var is removed assert "SECRET_VALUE" not in os.environ # Non-secret env var remains assert os.environ["PUBLIC_VALUE"] == "not_secret" del os.environ["PUBLIC_VALUE"] ``` ### 3. Use repr for Safe Logging Always use `!r` in f-strings for safe output: ```python import argclass class Parser(argclass.Parser): password: str = argclass.Secret() parser = Parser() parser.parse_args(["--password", "supersecret"]) # Safe - use !r format specifier safe_output = f"Password: {parser.password!r}" assert "supersecret" not in safe_output assert "******" in safe_output ``` --- # API Reference Complete API documentation for argclass. ## Quick Reference Most users only need these: | What you want | What to use | |---------------|-------------| | Create a CLI parser | `class MyApp(argclass.Parser)` | | Group related arguments | `class MyGroup(argclass.Group)` | | Customize an argument | `argclass.Argument(...)` | | Handle sensitive values | `argclass.Secret(...)` or `argclass.Argument(..., secret=True)` | | Load config file argument | `argclass.Config(...)` | | Set log level argument | `argclass.LogLevel` | ## Primary API These are the main classes and functions you'll use in most applications. ### Parser The base class for creating CLI parsers. Define arguments as class attributes with type hints. ```python import argclass class MyApp(argclass.Parser): name: str # Required argument count: int = 1 # Optional with default verbose: bool = False # Boolean flag app = MyApp() app.parse_args(["--name", "test"]) assert app.name == "test" assert app.count == 1 assert app.verbose is False ``` ```{eval-rst} .. autoclass:: argclass.Parser :members: :show-inheritance: ``` ### Group Bundle related arguments under a common prefix. Groups can be reused across multiple parsers. ```python import argclass class DatabaseGroup(argclass.Group): host: str = "localhost" port: int = 5432 class MyApp(argclass.Parser): db = DatabaseGroup() # Arguments: --db-host, --db-port app = MyApp() app.parse_args(["--db-host", "prod.example.com"]) assert app.db.host == "prod.example.com" assert app.db.port == 5432 ``` ```{eval-rst} .. autoclass:: argclass.Group :members: :show-inheritance: ``` ### Argument Customize argument behavior: add help text, aliases, choices, and more. ```python import argclass class MyApp(argclass.Parser): name: str = argclass.Argument( "-n", "--name", help="User name", ) level: str = argclass.Argument( default="info", choices=["debug", "info", "warning", "error"], ) app = MyApp() app.parse_args(["-n", "Alice", "--level", "debug"]) assert app.name == "Alice" assert app.level == "debug" ``` ```{eval-rst} .. autofunction:: argclass.Argument ``` ### Secret Handle sensitive values that should be masked in logs and removed from environment after parsing. ```python import os import argclass os.environ["TEST_API_KEY"] = "secret123" class MyApp(argclass.Parser): api_key: str = argclass.Secret(env_var="TEST_API_KEY") app = MyApp() # Use sanitize_secrets=True to auto-remove secret env vars during parsing app.parse_args([], sanitize_secrets=True) assert repr(app.api_key) == "'******'" # Masked in repr / logs assert app.api_key == "secret123" # But actual value is accessible assert "TEST_API_KEY" not in os.environ # Already removed ``` Alternatively, call `sanitize_env()` manually after parsing: ```python import os import argclass os.environ["TEST_API_KEY"] = "secret123" class MyApp(argclass.Parser): api_key: str = argclass.Secret(env_var="TEST_API_KEY") app = MyApp() app.parse_args([]) app.sanitize_env() # Remove all used env vars assert "TEST_API_KEY" not in os.environ # Or use: app.sanitize_env(only_secrets=True) to keep non-secret env vars ``` ```{eval-rst} .. autofunction:: argclass.Secret ``` ### SecretString The type returned for `Secret` arguments. Masks value in `str()` and `repr()`. ```{eval-rst} .. autoclass:: argclass.SecretString :members: :special-members: __str__, __repr__, __eq__ ``` --- ## Configuration Files Load defaults from INI, JSON, or TOML configuration files. ### Config Add a `--config` argument that loads structured data from a file. Access values via dict-like interface (`parser.config["key"]`). ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class MyApp(argclass.Parser): config = argclass.Config(config_class=argclass.JSONConfig) verbose: bool = False # Create a temporary config file with NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: f.write('{"database": {"host": "example.com", "port": 9000}}') config_path = f.name app = MyApp() app.parse_args(["--config", config_path]) # Access config data via dict-like interface assert app.config["database"]["host"] == "example.com" assert app.config["database"]["port"] == 9000 Path(config_path).unlink() ``` :::{tip} For loading defaults into parser attributes, use `config_files` parameter instead. See [Config File Parsers](#config-file-parsers). ::: ```{eval-rst} .. autofunction:: argclass.Config ``` ### Config Argument Classes | Class | Format | Usage | |-------|--------|-------| | `INIConfig` | INI files | `config_class=argclass.INIConfig` | | `JSONConfig` | JSON files | `config_class=argclass.JSONConfig` | | `TOMLConfig` | TOML files | `config_class=argclass.TOMLConfig` | ```{eval-rst} .. autoclass:: argclass.INIConfig :members: :show-inheritance: ``` ```{eval-rst} .. autoclass:: argclass.JSONConfig :members: :show-inheritance: ``` :::{note} TOML requires `tomllib` (Python 3.11+) or `tomli` package (Python 3.10). ::: ```{eval-rst} .. autoclass:: argclass.TOMLConfig :members: :show-inheritance: ``` ### Config File Parsers Used with `config_parser_class` parameter in `Parser()` to load defaults from config files at parser initialization. ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class MyApp(argclass.Parser): host: str = "localhost" port: int = 8080 # Create a temporary config file with NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: f.write('{"host": "db.example.com", "port": 5432}') config_path = f.name app = MyApp( config_files=[config_path], config_parser_class=argclass.JSONDefaultsParser, ) app.parse_args([]) assert app.host == "db.example.com" assert app.port == 5432 Path(config_path).unlink() ``` | Class | Format | |-------|--------| | `INIDefaultsParser` | INI files (default) | | `JSONDefaultsParser` | JSON files | | `TOMLDefaultsParser` | TOML files | ```{eval-rst} .. autoclass:: argclass.INIDefaultsParser :members: :show-inheritance: ``` ```{eval-rst} .. autoclass:: argclass.JSONDefaultsParser :members: :show-inheritance: ``` ```{eval-rst} .. autoclass:: argclass.TOMLDefaultsParser :members: :show-inheritance: ``` --- ## Pre-built Arguments ### LogLevel A pre-configured argument for log levels. Accepts level names and returns the corresponding `logging` module constant. ```python import argclass import logging class MyApp(argclass.Parser): log_level: int = argclass.LogLevel app = MyApp() app.parse_args(["--log-level", "debug"]) assert app.log_level == logging.DEBUG app.parse_args(["--log-level", "warning"]) assert app.log_level == logging.WARNING ``` Accepts the lowercase enum member names: `debug`, `info`, `warning`, `error`, `critical`, `notset`. ```{eval-rst} .. autoclass:: argclass.LogLevelEnum :members: :undoc-members: ``` --- ## Enums and Constants ### Actions Argument actions (mirrors `argparse` actions). | Action | Description | |--------|-------------| | `STORE` | Store the value (default) | | `STORE_TRUE` | Store `True` when flag is present | | `STORE_FALSE` | Store `False` when flag is present | | `APPEND` | Append value to a list | | `COUNT` | Count occurrences | ```{eval-rst} .. autoclass:: argclass.Actions :members: :undoc-members: ``` ### Nargs Number of arguments constants. | Value | Meaning | |-------|---------| | `ZERO_OR_ONE` (`?`) | Zero or one argument | | `ZERO_OR_MORE` (`*`) | Zero or more arguments | | `ONE_OR_MORE` (`+`) | One or more arguments | ```{eval-rst} .. autoclass:: argclass.Nargs :members: :undoc-members: ``` --- ## Utility Functions ### parse_bool Parse boolean strings from environment variables or config files. ```python from argclass import parse_bool assert parse_bool("true") is True assert parse_bool("yes") is True assert parse_bool("1") is True assert parse_bool("on") is True assert parse_bool("false") is False assert parse_bool("no") is False assert parse_bool("0") is False assert parse_bool("off") is False ``` ```{eval-rst} .. autofunction:: argclass.parse_bool ``` ### read_configs Read and merge multiple configuration files. ```{eval-rst} .. autofunction:: argclass.read_configs ``` --- ## Exceptions argclass provides a hierarchy of typed exceptions for debugging configuration and parsing errors. All exceptions include structured context attributes. ### ArgclassError Base exception for all argclass errors. Provides `field_name` and `hint` attributes for debugging context. ```{eval-rst} .. autoclass:: argclass.ArgclassError :members: :show-inheritance: ``` ### ArgumentDefinitionError Raised when an argument cannot be added to the parser due to invalid configuration, such as conflicting option strings or invalid argparse kwargs. ```{eval-rst} .. autoclass:: argclass.ArgumentDefinitionError :members: :show-inheritance: ``` ### TypeConversionError Raised when a value cannot be converted to the expected type. Includes the original `value` and `target_type` for debugging. ```{eval-rst} .. autoclass:: argclass.TypeConversionError :members: :show-inheritance: ``` ### ConfigurationError Raised when a configuration file cannot be parsed or contains values that don't match expected types. Includes `file_path` and `section` attributes. ```{eval-rst} .. autoclass:: argclass.ConfigurationError :members: :show-inheritance: ``` ### EnumValueError Raised when an enum default or parsed value is not a valid member of the specified enum class. Includes `enum_class` and `valid_values` for diagnostics. ```{eval-rst} .. autoclass:: argclass.EnumValueError :members: :show-inheritance: ``` ### ComplexTypeError Raised when a type annotation is too complex to be automatically handled (e.g., `Union[str, int]`) and requires an explicit converter. ```{eval-rst} .. autoclass:: argclass.ComplexTypeError :members: :show-inheritance: ``` --- ## Advanced / Internal These classes are primarily for advanced use cases or extending argclass. ### Base Abstract base class for both `Parser` and `Group`. ```{eval-rst} .. autoclass:: argclass.Base :members: :show-inheritance: ``` ### AbstractDefaultsParser Base class for implementing custom config file parsers. ```{eval-rst} .. autoclass:: argclass.AbstractDefaultsParser :members: :show-inheritance: ``` ### ValueKind Enum specifying expected value types for config file loading. | Value | Description | |-------|-------------| | `STRING` | Default, no conversion | | `SEQUENCE` | Lists/tuples or any iterable container | | `BOOL` | Boolean values | ```{eval-rst} .. autoclass:: argclass.ValueKind :members: :undoc-members: ``` ### UnexpectedConfigValue Exception raised when a config value doesn't match the expected type. ```{eval-rst} .. autoclass:: argclass.UnexpectedConfigValue :members: :show-inheritance: ``` ### ConfigArgument Base class for config file argument types. ```{eval-rst} .. autoclass:: argclass.ConfigArgument :members: :show-inheritance: ``` ### ConfigAction Action class for config file arguments. ```{eval-rst} .. autoclass:: argclass.ConfigAction :members: ``` ### TypedArgument Internal class representing a typed argument. ```{eval-rst} .. autoclass:: argclass.TypedArgument :members: :show-inheritance: ``` ### Store Internal storage for argument metadata. ```{eval-rst} .. autoclass:: argclass.Store :members: ``` ### Specialized Argument Functions ```{eval-rst} .. autofunction:: argclass.ArgumentSingle ``` ```{eval-rst} .. autofunction:: argclass.ArgumentSequence ``` ### EnumArgument Create arguments from Enum classes with automatic choice validation. ```python import argclass from enum import IntEnum class Priority(IntEnum): LOW = 1 MEDIUM = 2 HIGH = 3 class MyApp(argclass.Parser): # Default can be enum member or string name priority: Priority = argclass.EnumArgument( Priority, default="MEDIUM" ) app = MyApp() app.parse_args([]) assert app.priority == Priority.MEDIUM app.parse_args(["--priority", "HIGH"]) assert app.priority == Priority.HIGH ``` Use `lowercase=True` for case-insensitive input: ```python import argclass from enum import IntEnum class Level(IntEnum): DEBUG = 10 INFO = 20 WARNING = 30 class MyApp(argclass.Parser): level: Level = argclass.EnumArgument( Level, default="info", lowercase=True ) app = MyApp() app.parse_args([]) assert app.level == Level.INFO # Accepts lowercase input app.parse_args(["--level", "debug"]) assert app.level == Level.DEBUG ``` ```{eval-rst} .. autofunction:: argclass.EnumArgument ``` ## Config Generation Classes for writing config files from a parser (the inverse of `config_files=` reading). See [Generating Config Files](config-generation.md) for the user guide. ### ConfigGenerator ```{eval-rst} .. autoclass:: argclass.ConfigGenerator :members: ``` ### ConfigField The record type that custom ``render`` implementations consume. One ``ConfigField`` is yielded per leaf argument in the parser tree, carrying everything a renderer needs (path, dest, value, env var, help) so subclasses never have to walk the tree themselves. ```{eval-rst} .. autoclass:: argclass.ConfigField :members: ``` ### INIConfigGenerator ```{eval-rst} .. autoclass:: argclass.INIConfigGenerator :members: ``` ### JSONConfigGenerator ```{eval-rst} .. autoclass:: argclass.JSONConfigGenerator :members: ``` ### TOMLConfigGenerator ```{eval-rst} .. autoclass:: argclass.TOMLConfigGenerator :members: ``` ### EnvConfigGenerator ```{eval-rst} .. autoclass:: argclass.EnvConfigGenerator :members: ``` ### GenerateConfigAction ```{eval-rst} .. autoclass:: argclass.GenerateConfigAction ``` ### NonConfigAction ```{eval-rst} .. autoclass:: argclass.NonConfigAction ``` --- # Examples Gallery Copy-pastable examples for common CLI patterns. Each example is self-contained and demonstrates specific argclass features you can adapt for your own projects. ## Simple CLI Tool This example shows the fundamental building blocks of any CLI application. It demonstrates how to create a parser class with different argument types: a required positional-style argument, an optional argument with a default, and a boolean flag. **Key features demonstrated:** - Required arguments (`name: str`) - Optional arguments with defaults (`count: int = 1`) - Boolean flags (`loud: bool = False`) - Short aliases (`-c` for `--count`) - Implementing `__call__` for executable parsers ```python import argclass class Greeter(argclass.Parser): """Greet someone.""" name: str = argclass.Argument(help="Name to greet") count: int = argclass.Argument("-c", "--count", default=1, help="Times to greet") loud: bool = False def __call__(self) -> int: greeting = f"Hello, {self.name}!" if self.loud: greeting = greeting.upper() for _ in range(self.count): print(greeting) return 0 greeter = Greeter() greeter.parse_args(["--name", "World", "-c", "2", "--loud"]) assert greeter.name == "World" assert greeter.count == 2 assert greeter.loud is True ``` ## Subcommand CLI (git-style) Many professional CLI tools use subcommands to organize functionality: `git commit`, `docker run`, `kubectl apply`. This pattern makes complex tools intuitive by grouping related operations under descriptive command names. This example shows how to build a project management tool with `init`, `build`, and `deploy` subcommands. Each subcommand is a separate parser class with its own arguments, while the parent parser holds global options like `--verbose`. **Key features demonstrated:** - Subcommand pattern for multi-command CLIs - Global options available to all subcommands - `choices` parameter for constrained values - `Path` type for file system arguments - Each subcommand as a callable with its own `__call__` method ```python import argclass from pathlib import Path class InitCommand(argclass.Parser): """Initialize a new project.""" name: str = argclass.Argument(help="Project name") template: str = argclass.Argument(default="basic", choices=["basic", "full"]) def __call__(self) -> int: print(f"Initializing {self.name} with {self.template} template") return 0 class BuildCommand(argclass.Parser): """Build the project.""" output: Path = argclass.Argument("-o", "--output", default=Path("dist")) release: bool = False def __call__(self) -> int: mode = "release" if self.release else "debug" print(f"Building to {self.output} ({mode})") return 0 class DeployCommand(argclass.Parser): """Deploy to production.""" target: str = argclass.Argument(choices=["staging", "production"]) dry_run: bool = False def __call__(self) -> int: if self.dry_run: print(f"Would deploy to {self.target}") else: print(f"Deploying to {self.target}") return 0 class CLI(argclass.Parser): """Project management tool.""" verbose: bool = argclass.Argument("-v", "--verbose", default=False, action=argclass.Actions.STORE_TRUE) init = InitCommand() build = BuildCommand() deploy = DeployCommand() cli = CLI() cli.parse_args(["build", "--output", "/tmp/out", "--release"]) assert cli.build.output == Path("/tmp/out") assert cli.build.release is True ``` ## Config + Env + CLI Real-world applications often need configuration from multiple sources: config files for deployment defaults, environment variables for container orchestration, and CLI arguments for ad-hoc overrides. argclass handles all three with a clear priority order: CLI > environment > config file. This example demonstrates the complete configuration stack. It shows how defaults in a config file can be overridden by environment variables, which can in turn be overridden by command-line arguments. It also demonstrates secret handling with `argclass.Secret` and the `sanitize_env()` method. **Key features demonstrated:** - Config file loading with `config_files` parameter - Automatic environment variable binding with `auto_env_var_prefix` - Priority order: CLI arguments override env vars override config - Secret masking with `argclass.Secret` - `sanitize_env()` to remove secrets from environment - Argument groups for organizing related settings ```python import os import argclass from pathlib import Path from tempfile import NamedTemporaryFile class DatabaseGroup(argclass.Group): """Database connection settings.""" host: str = "localhost" port: int = 5432 name: str = "app" password: str = argclass.Secret(env_var="DB_PASSWORD") class ServerGroup(argclass.Group): """HTTP server settings.""" host: str = "127.0.0.1" port: int = 8080 workers: int = 4 class App(argclass.Parser): """Application server.""" debug: bool = False log_level: str = argclass.Argument( default="info", choices=["debug", "info", "warning", "error"] ) database = DatabaseGroup() server = ServerGroup() # Config file (lowest priority) CONFIG = """ [DEFAULT] log_level = warning [database] host = db.example.com port = 5432 name = production [server] host = 0.0.0.0 port = 80 workers = 8 """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG) config_path = f.name # Environment variables (override config) os.environ["APP_DEBUG"] = "true" os.environ["APP_SERVER_PORT"] = "9000" os.environ["DB_PASSWORD"] = "secret123" app = App( config_files=[config_path], auto_env_var_prefix="APP_" ) # CLI arguments (override everything) app.parse_args(["--log-level", "debug"]) # Sanitize secrets app.sanitize_env() # Results assert app.debug is True # From env assert app.log_level == "debug" # From CLI assert app.database.host == "db.example.com" # From config assert app.database.port == 5432 # From config assert str(app.database.password) == "secret123" # From env assert app.server.host == "0.0.0.0" # From config assert app.server.port == 9000 # From env (overrides config) assert app.server.workers == 8 # From config # Cleanup (use pop since sanitize_env may have removed some) os.environ.pop("APP_DEBUG", None) os.environ.pop("APP_SERVER_PORT", None) Path(config_path).unlink() ``` ## Configuration Formats argclass supports multiple configuration file formats out of the box. Each format can be used to provide default values that are overridden by environment variables and CLI arguments. Here are examples for each supported format. ### INI Configuration INI is the simplest format, ideal for flat configurations. Sections map to argument groups. Boolean values support various formats: `true/false`, `yes/no`, `on/off`, `1/0`. ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class DatabaseGroup(argclass.Group): host: str = "localhost" port: int = 5432 name: str = "mydb" class Parser(argclass.Parser): debug: bool = False workers: int = 4 database = DatabaseGroup() CONFIG_INI = """ [DEFAULT] debug = yes workers = 8 [database] host = db.example.com port = 5432 name = production """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(CONFIG_INI) config_path = f.name parser = Parser(config_files=[config_path]) parser.parse_args([]) assert parser.debug is True assert parser.workers == 8 assert parser.database.host == "db.example.com" assert parser.database.name == "production" Path(config_path).unlink() ``` ### JSON Configuration JSON is useful when you need structured data or when your config is generated programmatically. Nested objects map to argument groups. ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class ServerGroup(argclass.Group): host: str = "127.0.0.1" port: int = 8080 class Parser(argclass.Parser): debug: bool = False log_level: str = "info" server = ServerGroup() CONFIG_JSON = """ { "debug": true, "log_level": "debug", "server": { "host": "0.0.0.0", "port": 9000 } } """ with NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: f.write(CONFIG_JSON) config_path = f.name parser = Parser( config_files=[config_path], config_parser_class=argclass.JSONDefaultsParser, ) parser.parse_args([]) assert parser.debug is True assert parser.log_level == "debug" assert parser.server.host == "0.0.0.0" assert parser.server.port == 9000 Path(config_path).unlink() ``` ### TOML Configuration TOML provides a clean syntax popular in modern Python projects (like `pyproject.toml`). It has native support for different data types. :::{note} TOML requires Python 3.11+ (stdlib `tomllib`) or the `tomli` package for Python 3.10. ::: ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class CacheGroup(argclass.Group): enabled: bool = True ttl: int = 300 backend: str = "memory" class Parser(argclass.Parser): name: str = "myapp" version: str = "1.0.0" cache = CacheGroup() CONFIG_TOML = """ name = "production-app" version = "2.1.0" [cache] enabled = true ttl = 3600 backend = "redis" """ with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: f.write(CONFIG_TOML) config_path = f.name parser = Parser( config_files=[config_path], config_parser_class=argclass.TOMLDefaultsParser, ) parser.parse_args([]) assert parser.name == "production-app" assert parser.version == "2.1.0" assert parser.cache.enabled is True assert parser.cache.ttl == 3600 assert parser.cache.backend == "redis" Path(config_path).unlink() ``` ### Multiple Config Files with Fallback You can specify multiple config files. argclass reads them in order, with later files overriding earlier ones. Missing files are silently ignored, making this perfect for layered configuration. ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): host: str = "localhost" port: int = 8080 debug: bool = False # System-wide defaults SYSTEM_CONFIG = """ [DEFAULT] host = 0.0.0.0 port = 80 """ # User overrides USER_CONFIG = """ [DEFAULT] port = 8080 debug = true """ with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(SYSTEM_CONFIG) system_path = f.name with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f: f.write(USER_CONFIG) user_path = f.name parser = Parser(config_files=[ "/etc/myapp/config.ini", # Missing - ignored system_path, # Provides host=0.0.0.0, port=80 user_path, # Overrides port=8080, adds debug=true ]) parser.parse_args([]) assert parser.host == "0.0.0.0" # From system config assert parser.port == 8080 # Overridden by user config assert parser.debug is True # From user config Path(system_path).unlink() Path(user_path).unlink() ``` ## File Processing Tool File processing utilities are one of the most common CLI applications. This example shows how to build a tool that accepts multiple input files, an output directory, and various processing options. The pattern demonstrated here is useful for any batch processing tool: image converters, log analyzers, code formatters, or data transformers. The `--dry-run` flag is a best practice that lets users preview changes before committing to them. **Key features demonstrated:** - Multiple input files with `nargs="+"` and `list[Path]` - `Path` type for automatic path handling - `--dry-run` pattern for safe previews - Boolean flags with explicit `action=STORE_TRUE` - Combining short (`-n`) and long (`--dry-run`) aliases ```python import argclass from pathlib import Path class FileProcessor(argclass.Parser): """Process files in batch.""" input: list[Path] = argclass.Argument( "-i", "--input", nargs="+", help="Input files to process" ) output: Path = argclass.Argument( "-o", "--output", default=Path("output"), help="Output directory" ) pattern: str = argclass.Argument( "-p", "--pattern", default="*", help="Glob pattern to match" ) recursive: bool = False dry_run: bool = argclass.Argument( "-n", "--dry-run", default=False, action=argclass.Actions.STORE_TRUE, help="Show what would be done" ) def __call__(self) -> int: for path in self.input: if self.dry_run: print(f"Would process: {path}") else: print(f"Processing: {path}") return 0 processor = FileProcessor() processor.parse_args([ "-i", "file1.txt", "file2.txt", "-o", "/tmp/out", "--recursive", "--dry-run" ]) assert processor.input == [Path("file1.txt"), Path("file2.txt")] assert processor.output == Path("/tmp/out") assert processor.recursive is True assert processor.dry_run is True ``` ## HTTP Client CLI API clients often need flexible authentication and request configuration. This example shows how to build a curl-like tool with support for different HTTP methods, custom headers, request bodies, and authentication options. The argument groups (`AuthGroup`, `RequestOptions`) keep related settings organized and make the `--help` output more readable. Using `argclass.Secret` for tokens and passwords ensures they won't appear in logs or process listings. **Key features demonstrated:** - Curl-like interface with `-X`, `-H`, `-d` options - Multiple headers with `nargs="*"` and `list[str]` - Optional arguments with `str | None` type - Secrets for sensitive authentication data - Grouped settings for better organization - `choices` for HTTP methods ```python import os import argclass class AuthGroup(argclass.Group): """Authentication settings.""" token: str = argclass.Secret(env_var="API_TOKEN") username: str | None = None password: str = argclass.Secret() class RequestOptions(argclass.Group): """Request configuration.""" timeout: int = 30 retries: int = 3 verify_ssl: bool = True class HTTPClient(argclass.Parser): """HTTP API client.""" base_url: str = argclass.Argument(help="API base URL") method: str = argclass.Argument( "-X", "--method", default="GET", choices=["GET", "POST", "PUT", "DELETE", "PATCH"] ) headers: list[str] = argclass.Argument( "-H", "--header", nargs="*", default=[], help="Headers in 'Key: Value' format" ) data: str | None = argclass.Argument( "-d", "--data", default=None, help="Request body" ) auth = AuthGroup() options = RequestOptions() os.environ["API_TOKEN"] = "secret_token" client = HTTPClient() client.parse_args([ "--base-url", "https://api.example.com", "-X", "POST", "-H", "Content-Type: application/json", "-d", '{"key": "value"}', "--options-timeout", "60" ]) client.sanitize_env() assert client.base_url == "https://api.example.com" assert client.method == "POST" assert client.headers == ["Content-Type: application/json"] assert client.data == '{"key": "value"}' assert str(client.auth.token) == "secret_token" assert client.options.timeout == 60 ``` ## Database Migration Tool Database migration tools like Alembic, Flyway, or Django migrations use subcommands for different operations: applying migrations, rolling back, checking status. This example shows how to structure such a tool with argclass. The parent parser holds connection settings that apply to all subcommands, while each subcommand has its own specific options. This pattern is ideal for any tool that performs multiple related operations on a shared resource. **Key features demonstrated:** - Multiple subcommands (`up`, `down`, `status`) - Shared parent options (`--database`, `--migrations`) - Environment variable fallback with `env_var` parameter - Optional target specification with `str | None` - `--fake` flag for marking migrations without running them ```python import argclass from pathlib import Path class MigrateUp(argclass.Parser): """Apply pending migrations.""" target: str | None = argclass.Argument( default=None, help="Target migration (default: latest)" ) fake: bool = argclass.Argument( default=False, action=argclass.Actions.STORE_TRUE, help="Mark as applied without running" ) def __call__(self) -> int: target = self.target or "latest" print(f"Migrating up to {target}") return 0 class MigrateDown(argclass.Parser): """Rollback migrations.""" steps: int = argclass.Argument( default=1, help="Number of migrations to rollback" ) def __call__(self) -> int: print(f"Rolling back {self.steps} migration(s)") return 0 class MigrateStatus(argclass.Parser): """Show migration status.""" def __call__(self) -> int: print("Showing migration status") return 0 class MigrateCLI(argclass.Parser): """Database migration tool.""" database_url: str = argclass.Argument( "-d", "--database", env_var="DATABASE_URL", default="sqlite:///app.db" ) migrations_dir: Path = argclass.Argument( "-m", "--migrations", default=Path("migrations") ) up = MigrateUp() down = MigrateDown() status = MigrateStatus() cli = MigrateCLI() cli.parse_args(["--database", "postgres://localhost/app", "up", "--target", "002"]) assert cli.database_url == "postgres://localhost/app" assert cli.up.target == "002" ``` ## Daemon/Service Configuration Long-running services like web servers, message brokers, or background workers need extensive configuration: logging levels, metrics endpoints, process management options. This example shows how to organize these settings into logical groups. The grouped approach (`LoggingGroup`, `MetricsGroup`) makes configuration manageable and the resulting `--help` output organized by category. This pattern works well for any application with many configuration options. **Key features demonstrated:** - Complex configuration with multiple groups - Daemon-style options (`--daemonize`, `--pid-file`, `--user`) - Logging configuration with level and format choices - Metrics/monitoring endpoint configuration - `Path | None` for optional file paths - Short flags for common operations (`-D`, `-c`) ```python import argclass from pathlib import Path class LoggingGroup(argclass.Group): """Logging configuration.""" level: str = argclass.Argument( default="info", choices=["debug", "info", "warning", "error"] ) format: str = argclass.Argument( default="text", choices=["text", "json"] ) file: Path | None = None class MetricsGroup(argclass.Group): """Metrics and monitoring.""" enabled: bool = True port: int = 9090 path: str = "/metrics" class Daemon(argclass.Parser): """Background service daemon.""" config: Path | None = argclass.Argument( "-c", "--config", default=None, help="Config file path" ) pid_file: Path = argclass.Argument( default=Path("/var/run/myapp.pid") ) daemonize: bool = argclass.Argument( "-D", "--daemonize", default=False, action=argclass.Actions.STORE_TRUE ) user: str | None = None group: str | None = None logging = LoggingGroup() metrics = MetricsGroup() daemon = Daemon() daemon.parse_args([ "--daemonize", "--logging-level", "debug", "--logging-file", "/var/log/myapp.log", "--metrics-port", "8080" ]) assert daemon.daemonize is True assert daemon.logging.level == "debug" assert daemon.logging.file == Path("/var/log/myapp.log") assert daemon.metrics.port == 8080 ``` ## Testing Your CLI Testing CLI parsers is straightforward because argclass parsers are just Python classes. You can instantiate them, call `parse_args()` with test arguments, and assert on the resulting attribute values. The examples below show common testing patterns using pytest. Each pattern addresses a specific testing need: basic argument parsing, environment variable handling, config file loading, and subcommand dispatch. ### Basic Test The simplest test pattern: create a parser instance, parse known arguments, and verify the results. This works for any parser and catches regressions in argument definitions. ```python import pytest import argclass class Parser(argclass.Parser): name: str count: int = 1 def test_parser_defaults(): parser = Parser() parser.parse_args(["--name", "test"]) assert parser.name == "test" assert parser.count == 1 def test_parser_all_args(): parser = Parser() parser.parse_args(["--name", "test", "--count", "5"]) assert parser.name == "test" assert parser.count == 5 ``` ### Testing with Environment Use pytest's `monkeypatch` fixture to set environment variables for tests. This isolates each test and ensures environment changes don't leak between tests. ```python import argclass def test_with_env(monkeypatch): monkeypatch.setenv("APP_HOST", "test-host") class Parser(argclass.Parser): host: str = argclass.Argument(env_var="APP_HOST", default="localhost") parser = Parser() parser.parse_args([]) assert parser.host == "test-host" ``` ### Testing with Config Files Use pytest's `tmp_path` fixture to create temporary config files. This ensures tests are isolated and don't depend on files in your filesystem. ```python import argclass def test_with_config(tmp_path): config_file = tmp_path / "config.ini" config_file.write_text("[DEFAULT]\nport = 9000\n") class Parser(argclass.Parser): port: int = 8080 parser = Parser(config_files=[str(config_file)]) parser.parse_args([]) assert parser.port == 9000 ``` ### Testing Subcommands Test subcommand dispatch by parsing arguments that include the subcommand name, then call the parser to execute the selected subcommand. ```python import argclass def test_subcommand(): class Sub(argclass.Parser): value: int = 1 def __call__(self): return self.value class CLI(argclass.Parser): sub = Sub() cli = CLI() cli.parse_args(["sub", "--value", "42"]) assert cli() == 42 ``` --- # Error Handling argclass inherits error handling from Python's `argparse`. ## Exit Codes | Exit Code | Meaning | Triggered By | |-----------|---------|--------------| | `0` | Success | `__call__` returns `0` | | `1` | Application error | `__call__` returns non-zero | | `2` | Syntax error | Invalid arguments, missing required args, type errors | ## Validation Rules ### Built-in Validation argparse validates automatically: - **Required arguments**: No default → argument is required - **Type conversion**: Type annotation determines converter (`int`, `float`, `Path`, etc.) - **Choices**: Values must match `choices` list if specified - **Nargs**: Correct number of values must be provided All validation errors exit with code 2. ### Custom Validation Two approaches: **1. Type converter** — validates during parsing: ```python import argparse import argclass def positive_int(value: str) -> int: num = int(value) if num <= 0: raise argparse.ArgumentTypeError(f"{value} must be positive") return num class Parser(argclass.Parser): count: int = argclass.Argument(default=1, type=positive_int) parser = Parser() parser.parse_args(["--count", "5"]) assert parser.count == 5 ``` **2. Post-parse validation** — validates after all arguments are parsed: ```python import argclass class Parser(argclass.Parser): start: int = 0 end: int = 100 def validate(self) -> None: if self.start >= self.end: raise ValueError("start must be less than end") parser = Parser() parser.parse_args(["--start", "50", "--end", "25"]) try: parser.validate() except ValueError as e: assert "start must be less than end" in str(e) ``` ## Application Exit Codes Use `__call__` to return application-level exit codes: ```python import argclass class Parser(argclass.Parser): config: str | None = None def __call__(self) -> int: if self.config is None: print("Error: --config is required") return 1 return 0 parser = Parser() parser.parse_args([]) exit_code = parser() assert exit_code == 1 ``` --- ## Customization ### Program Name ```python import argclass parser = argclass.Parser(prog="myapp") # Errors show "myapp: error:" instead of script name ``` ### Custom Error Handler Override `error()` for custom error formatting: ```python import sys import argclass class Parser(argclass.Parser): def error(self, message: str) -> None: sys.stderr.write(f"ERROR: {message}\n") sys.exit(2) ``` ## Config and Environment Errors | Source | Behavior | |--------|----------| | Missing config file | Silently ignored (unless `strict_config=True`) | | Malformed config | Silently ignored (unless `strict_config=True`) | | Invalid env var value | Same as CLI — exits with code 2 | | Config provides value | Does NOT make argument "provided" — CLI can still override | ## Testing Catch `SystemExit` to test error handling: ```python import argclass import sys from io import StringIO class Parser(argclass.Parser): count: int = 1 parser = Parser() old_stderr = sys.stderr sys.stderr = StringIO() try: parser.parse_args(["--count", "invalid"]) except SystemExit as e: assert e.code == 2 assert "invalid int value" in sys.stderr.getvalue() finally: sys.stderr = old_stderr ``` For pytest, use `capsys` fixture instead of manual stderr capture. --- ## argclass Exceptions argclass provides typed exceptions with structured context for debugging configuration and type errors at definition time. ### Exception Hierarchy All exceptions inherit from `ArgclassError`, which provides common attributes for debugging: | Exception | Raised When | Key Attributes | |-----------|-------------|----------------| | `ArgclassError` | Base exception for all argclass errors | `field_name`, `hint` | | `ArgumentDefinitionError` | Argument conflicts with argparse or invalid configuration | `aliases`, `kwargs` | | `TypeConversionError` | Converter function fails during parsing | `value`, `target_type` | | `ConfigurationError` | Config file cannot be parsed or contains invalid values | `file_path`, `section` | | `EnumValueError` | Invalid enum default or value provided | `enum_class`, `valid_values` | | `ComplexTypeError` | Unsupported type annotation that requires explicit converter | `typespec` | ### Catching Exceptions Use specific exception types to handle different error categories: ```python import argclass class Parser(argclass.Parser): count: int = 1 parser = Parser() try: parser.parse_args(["--count", "abc"]) except SystemExit: # argparse handles type conversion errors with SystemExit pass ``` For definition-time errors (raised when the parser class is constructed): ```python import argclass from enum import Enum class Color(Enum): RED = "red" GREEN = "green" try: # This would raise EnumValueError if "yellow" is not a valid Color class Parser(argclass.Parser): color: Color = argclass.EnumArgument(Color, default=Color.RED) except argclass.EnumValueError as e: print(f"Invalid enum: {e.valid_values}") ``` ### Exception Attributes Each exception type includes contextual attributes for debugging: ```python import argclass # ArgclassError base attributes (available on all exceptions): # - field_name: str | None - The field that caused the error # - hint: str | None - Suggestion for fixing the error # - message: str - The error message # ArgumentDefinitionError adds: # - aliases: tuple[str, ...] | None - Argument aliases that conflicted # - kwargs: dict | None - The kwargs passed to argparse # TypeConversionError adds: # - value: Any - The value that failed conversion # - target_type: type | None - The type we tried to convert to # ConfigurationError adds: # - file_path: str | None - Path to the config file # - section: str | None - Config section with the error # EnumValueError adds: # - enum_class: type | None - The enum class # - valid_values: tuple[str, ...] | None - Valid enum member names # ComplexTypeError adds: # - typespec: Any - The type annotation that couldn't be handled ``` ### When Exceptions Are Raised | Phase | Exception Types | Example | |-------|-----------------|---------| | Class definition | `ArgumentDefinitionError`, `EnumValueError`, `ComplexTypeError` | Invalid default for enum | | Config loading | `ConfigurationError` | Malformed INI file | | Argument parsing | `TypeConversionError` (wrapped by argparse) | `--count abc` for `int` field | :::{note} During argument parsing, most type conversion errors are caught by argparse and converted to `SystemExit(2)`. The `TypeConversionError` is primarily raised during config file value conversion or custom converter failures. ::: --- # Common Pitfalls Quick reference for common mistakes and their solutions. These are the issues most frequently encountered when building CLI applications with argclass. ## Boolean Flags Boolean arguments are the most common source of confusion. The shortcut syntax `bool = False` automatically creates a flag, but using `Argument()` requires explicit action configuration. | Syntax | Behavior | |--------|----------| | `flag: bool = False` | `--flag` sets to `True` (recommended) | | `flag: bool = True` | `--flag` sets to `False` (toggles) | | `Argument(default=False)` without action | Expects value like `--flag true` (wrong) | | `Argument(default=False, action=Actions.STORE_TRUE)` | Works as flag | **Rule:** Use simple `bool = False` syntax. Only use `Argument()` for booleans when you need help text or aliases, and always include `action=Actions.STORE_TRUE`. :::{warning} A common mistake is `bool = True` expecting `--flag` to enable a feature. Instead, `--flag` will *disable* it (set to `False`). If you want a feature enabled by default that users can disable, name it `--no-feature` with `bool = True`. ::: ```python import argclass class Parser(argclass.Parser): feature: bool = True # --feature toggles to False parser = Parser() parser.parse_args(["--feature"]) assert parser.feature is False ``` ## Environment Variables Environment variables are strings, so argclass must parse them into the appropriate types. Boolean parsing is particularly tricky because there's no universal standard for representing true/false in environment variables. | Issue | Solution | |-------|----------| | Boolean strings | See table below (case-insensitive) | | Spaces preserved | Trim in application logic: `value.strip()` | | Type errors | Same rules as CLI — invalid values exit with code 2 | ### Boolean String Parsing argclass recognizes common conventions for boolean environment variables. Values are case-insensitive. | Parsed as `True` | Parsed as `False` | |------------------|-------------------| | `1`, `y`, `yes`, `t`, `true` | Everything else | | `on`, `enable`, `enabled` | `0`, `n`, `no`, `f`, `false`, `off`, `disable`, etc. | ```python import os import argclass os.environ["TEST_FLAG"] = "yes" # Also: true, 1, on, enable class Parser(argclass.Parser): flag: bool = argclass.Argument(env_var="TEST_FLAG", default=False) parser = Parser() parser.parse_args([]) assert parser.flag is True del os.environ["TEST_FLAG"] ``` ```python import os import argclass os.environ["TEST_FLAG"] = "no" # Also: false, 0, off, disable, or any other string class Parser(argclass.Parser): flag: bool = argclass.Argument(env_var="TEST_FLAG", default=False) parser = Parser() parser.parse_args([]) assert parser.flag is False os.environ.pop("TEST_FLAG", None) ``` ## Lists List arguments have subtle behavior differences depending on `nargs` configuration. The most common mistake is using `nargs="+"` when you want to allow empty lists. | Issue | Solution | |-------|----------| | `--files` without values errors | Use `nargs="*"` for zero-or-more | | Comma-separated values | CLI uses spaces: `--files a.txt b.txt` | | Default `[]` with `nargs="+"` | Requires at least one value when flag is used | :::{tip} Use `nargs="*"` if the flag can appear with zero values (`--files` alone is valid). Use `nargs="+"` if at least one value is required when the flag is used. ::: ```python import argclass class Parser(argclass.Parser): files: list[str] = argclass.Argument(nargs="*", default=[]) parser = Parser() parser.parse_args(["--files"]) # Zero values OK with nargs="*" assert parser.files == [] ``` ## Type Hints Type hints determine whether arguments are required or optional. A common surprise is that `T | None` without a default value implies `default=None`, making the argument optional rather than required. | Hint | Behavior | |------|----------| | `name: str` | Required argument | | `name: str = "default"` | Optional with default | | `name: str \| None` | Optional, defaults to `None` | | `name: Path` | Auto-converts string to `Path` | :::{note} The `| None` union type automatically sets `default=None`. If you want a required argument that can accept `None` as a valid CLI value, you'll need custom handling. ::: ```python import argclass class Parser(argclass.Parser): config: str | None # Implies default=None, NOT required parser = Parser() parser.parse_args([]) assert parser.config is None ``` --- ## Config Files (INI) INI config files have specific formatting requirements. Section names must exactly match group attribute names (case-sensitive), and complex types like lists use Python literal syntax, not comma-separated values. | Issue | Solution | |-------|----------| | Section name mismatch | Section must match group attribute name (lowercase) | | Lists as comma-separated | Use Python literal: `ports = [8080, 8081]` | | Strings in lists | Quote them: `hosts = ["a.com", "b.com"]` | ```ini # Group attribute: database = DatabaseGroup() [database] # RIGHT - matches attribute name host = db.example.com [Database] # WRONG - case mismatch (won't be loaded) ``` :::{warning} INI section names are case-sensitive in argclass. `[Database]` and `[database]` are different sections. Always use lowercase to match Python attribute names. ::: ## Groups When you add a group to a parser, all its arguments get prefixed with the group's attribute name. This is a common source of confusion when users expect unprefixed argument names. ```text class Parser(argclass.Parser): database = DatabaseGroup() # prefix is "database" # CLI usage: --database-host value # RIGHT --host value # WRONG - no such argument ``` :::{tip} To add group arguments without a prefix, use `prefix=""`: ```python import argclass class DatabaseGroup(argclass.Group): host: str = "localhost" port: int = 5432 class Parser(argclass.Parser): database = DatabaseGroup(prefix="") # Arguments: --host, --port ``` ::: ### Reusing a Group instance across attributes Each `Group` instance owns the parsed state for one location in the parser tree. Assigning the **same instance** to two attributes raises `ArgclassError`, because parsed values would otherwise be shared between both locations. ```python import argclass class Credentials(argclass.Group): username: str = "admin" shared = Credentials() class Parser(argclass.Parser): primary = shared # OK on its own — single binding secondary = shared # Together with `primary`, raises ArgclassError # at parse_args time (same instance bound twice) ``` A single attribute bound to an externally-created Group is fine; the error only fires when the **same instance** is reachable through two or more attributes at parse time. Create a separate instance for each attribute instead: ```python class Parser(argclass.Parser): primary = Credentials() # RIGHT - distinct instances secondary = Credentials() ``` Using the same `Group` **class** twice (with different `Group()` calls) is fully supported — only sharing one already-constructed instance is disallowed. --- ## Subcommands When using subcommands, only the selected subcommand's arguments are parsed and populated. Other subcommands retain their default values. Don't assume all subcommand attributes are populated after parsing. ```python import argclass class Serve(argclass.Parser): port: int = 8080 class Build(argclass.Parser): output: str = "dist" class CLI(argclass.Parser): serve = Serve() build = Build() cli = CLI() cli.parse_args(["serve", "--port", "9000"]) assert cli.serve.port == 9000 # cli.build.output is still default ``` :::{tip} Use `cli.current_subparsers` to check which subcommand was selected, or implement `__call__` on each subcommand and call `cli()` to dispatch automatically to the selected command. ::: --- ## Exception-Raising Patterns These patterns will raise specific argclass exceptions at parser definition or parsing time. ### ComplexTypeError: Unsupported Union Types Union types like `str | int` cannot be automatically converted because argclass doesn't know which type to try first. You must provide an explicit converter. | Pattern | Result | |---------|--------| | `field: str \| int` | `ComplexTypeError` at definition time | | `field: str \| None` | OK — `None` is handled specially | | `field: list[str] \| None` | OK — `None` is handled specially | ```python import argclass # This works - Optional types are supported class WorkingParser(argclass.Parser): name: str | None # OK: Union with None parser = WorkingParser() parser.parse_args([]) assert parser.name is None ``` To fix union types, provide an explicit converter: ```python import argclass def flexible_int(value: str) -> int | str: try: return int(value) except ValueError: return value class Parser(argclass.Parser): count: int | str = argclass.Argument(type=flexible_int, default=0) ``` ### EnumValueError: Invalid Enum Defaults When using `EnumArgument`, the default must be a valid enum member or its string name. Providing an invalid default raises `EnumValueError`. ```python import argclass from enum import Enum class Color(Enum): RED = "red" GREEN = "green" BLUE = "blue" # Correct: default is a valid enum member name class Parser(argclass.Parser): color: Color = argclass.EnumArgument(Color, default="RED") parser = Parser() parser.parse_args([]) assert parser.color == Color.RED ``` ### ArgumentDefinitionError: Conflicting Aliases If you define an alias that conflicts with another argument or a reserved argparse option, `ArgumentDefinitionError` is raised. ```python import argclass # This works - no conflicts class Parser(argclass.Parser): verbose: bool = argclass.Argument("-v", default=False) output: str = argclass.Argument("-o", default="out.txt") parser = Parser() parser.parse_args(["-v", "-o", "result.txt"]) assert parser.verbose is True assert parser.output == "result.txt" ``` ### TypeConversionError: Converter Failures When a custom converter raises an exception, argclass wraps it in `TypeConversionError` with context about what value failed and the target type. ```python import argclass def positive_int(value: str) -> int: num = int(value) if num <= 0: raise ValueError(f"{value} must be positive") return num class Parser(argclass.Parser): count: int = argclass.Argument(type=positive_int, default=1) parser = Parser() parser.parse_args(["--count", "5"]) assert parser.count == 5 ``` ### ConfigurationError: Invalid Config Files When loading config files with `config_files` parameter, malformed files or type mismatches raise `ConfigurationError`. | Issue | Result | |-------|--------| | Malformed INI/JSON/TOML | `ConfigurationError` with file path | | Value doesn't match type | `ConfigurationError` with field and section | | Missing file | Silently ignored (unless `strict_config=True`) | ```python import argclass from pathlib import Path from tempfile import NamedTemporaryFile class Parser(argclass.Parser): host: str = "localhost" port: int = 8080 # Create a valid config file with NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: f.write('{"host": "example.com", "port": 9000}') config_path = f.name parser = Parser( config_files=[config_path], config_parser_class=argclass.JSONDefaultsParser, ) parser.parse_args([]) assert parser.host == "example.com" assert parser.port == 9000 Path(config_path).unlink() ``` --- # Third-Party Integrations **argclass** builds on the standard library's `argparse`, so many argparse extensions work with argclass. ## Rich Help Output Use `rich_argparse` for beautiful help formatting: ```python import argclass class Parser(argclass.Parser): verbose: bool = False output: str = "result.txt" # Requires: pip install rich-argparse # from rich_argparse import RawTextRichHelpFormatter # parser = Parser(formatter_class=RawTextRichHelpFormatter) # parser.print_help() ``` ![Help Output](_static/rich_example.png) --- ## Logging Configuration argclass provides a pre-built `LogLevel` argument for easy logging integration. It accepts level names (`debug`, `info`, `warning`, `error`, `critical`) case-insensitively and returns the corresponding `logging` module constant. ### Using the Built-in LogLevel ```python import argclass import logging class Parser(argclass.Parser): log_level: int = argclass.LogLevel parser = Parser() parser.parse_args(["--log-level", "debug"]) assert parser.log_level == logging.DEBUG logging.basicConfig(level=parser.log_level) ``` ### Custom Logging Setup For more control, combine `LogLevel` with additional options: ```python import argclass import logging class Parser(argclass.Parser): log_level: int = argclass.LogLevel log_file: str | None = argclass.Argument( "--log-file", default=None, help="Log to file instead of stderr" ) def configure_logging(self) -> None: handlers = [] if self.log_file: handlers.append(logging.FileHandler(self.log_file)) else: handlers.append(logging.StreamHandler()) logging.basicConfig( level=self.log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=handlers, force=True, ) parser = Parser() parser.parse_args([]) parser.configure_logging() ``` ## pytest Integration Test your CLI with pytest: ```python import pytest import argclass class Parser(argclass.Parser): name: str count: int = 1 @pytest.fixture() def parser(): parser = Parser() parser.parse_args(["--name", "test"]) return parser def test_parser_defaults(parser): assert parser.name == "test" assert parser.count == 1 ``` --- ## Accessing the Underlying ArgumentParser Use `create_parser()` to get the underlying `argparse.ArgumentParser` instance for integrations that need direct access to the parser structure: ```python import argclass class Parser(argclass.Parser): """My application.""" name: str = argclass.Argument(help="User name") verbose: bool = False parser = Parser() argparse_parser = parser.create_parser() # Inspect parser structure assert len(argparse_parser._actions) > 0 # Access help text help_text = argparse_parser.format_help() assert "User name" in help_text ``` This is useful for tools that inspect argument structure, generate documentation, or need argparse compatibility. :::{warning} `create_parser()` does **not** back populate parser attributes. Always use `parse_args()` to actually parse command-line arguments. ::: --- # Security Checklist This page provides security best practices for handling secrets and sensitive data in CLI applications built with argclass. ## Quick Checklist - [ ] Use `argclass.Secret()` for sensitive arguments - [ ] Sanitize secrets after parsing (use `parse_args(sanitize_secrets=True)` or call `parser.sanitize_env()`) - [ ] Use `{secret!r}` in f-strings (never `{secret}`) - [ ] Prefer environment variables over config files for secrets - [ ] If using config files for secrets, verify `chmod 600` permissions - [ ] Use `logging.info("value: %s", secret)` (not f-strings) for safe logging - [ ] Never pass secrets via command-line arguments in production ## Threat Model ### What `sanitize_env()` Protects Against `sanitize_env()` **only** prevents accidental secret leakage via environment variable inheritance to child processes. It removes environment variables from the current process after parsing, so that subprocesses spawned afterward do not inherit them. There are two ways to sanitize: - `parse_args(sanitize_secrets=True)` - automatically removes only secret env vars during parsing - `sanitize_env()` - removes all used env vars after parsing - `sanitize_env(only_secrets=True)` - removes only secret env vars after parsing | Protected | Not Protected | |-----------|---------------| | Environment variable inheritance to child processes | Secrets in process memory | | Accidental leakage to trusted tools | Exfiltration by malicious code | | Defense-in-depth for well-behaved subprocesses | Command-line argument visibility (`ps`, `/proc`) | | | Secrets in config files, logs, crash dumps | | | Network-based exfiltration | | | Filesystem access by child processes | ### What `sanitize_env()` Does NOT Do :::{danger} **`sanitize_env()` is NOT a sandbox.** It does not prevent malicious or compromised code from accessing secrets. Any code running in your process can read memory, inspect stack frames, or extract secrets before sanitization occurs. Do not run untrusted code. ::: If you need to run untrusted code, use proper isolation: - **Containers** (Docker, Podman) with restricted capabilities - **Virtual machines** for complete isolation - **OS-level sandboxing** (seccomp, AppArmor, SELinux) - **Separate user accounts** with minimal privileges These are outside the scope of argclass. ### Why Sanitization Is Not Enabled by Default Sanitization is explicitly opt-in (`sanitize_secrets=False` by default) because argclass cannot know how your application will be used: **Process lifecycle considerations:** - Your application may re-execute itself (e.g., for privilege escalation via `sudo`, or restarting with different permissions) - Your application may `fork()` without `exec()`, expecting child processes to inherit the full environment - Your application may be a wrapper that intentionally passes secrets to child processes - Your application may need environment variables for later phases of execution **Principle of least surprise:** A library should not implicitly modify global process state. Removing environment variables is a side effect that could break legitimate use cases. For example, an application that re-executes itself with elevated privileges (via `sudo -E`) expects the environment to be preserved. If sanitization were automatic, the re-executed process would silently lose its secrets. **Explicit is better than implicit:** By requiring explicit opt-in, argclass ensures that: 1. Developers consciously decide when sanitization is appropriate 2. Code review can verify that sanitization matches the application's needs 3. No unexpected behavior occurs in edge cases **Best practice:** Use `sanitize_secrets=True` for most applications—it removes only secret environment variables while preserving non-secret configuration: ```python import os import argclass os.environ["SECRET_KEY"] = "secret" os.environ["APP_PORT"] = "8080" class Parser(argclass.Parser): secret_key: str = argclass.Secret(env_var="SECRET_KEY") port: int = argclass.Argument(env_var="APP_PORT") parser = Parser() parser.parse_args([], sanitize_secrets=True) # Recommended for most cases assert "SECRET_KEY" not in os.environ # Secret removed assert os.environ["APP_PORT"] == "8080" # Non-secret preserved del os.environ["APP_PORT"] ``` Use `sanitize_env()` when you need to remove ALL configuration-related environment variables (both secrets and non-secrets): ```python import os import argclass os.environ["SECRET_KEY"] = "secret" os.environ["APP_PORT"] = "8080" class Parser(argclass.Parser): secret_key: str = argclass.Secret(env_var="SECRET_KEY") port: int = argclass.Argument(env_var="APP_PORT") parser = Parser() parser.parse_args([]) parser.sanitize_env() # Removes all env vars used during parsing assert "SECRET_KEY" not in os.environ assert "APP_PORT" not in os.environ ``` --- ## Preventing Environment Leakage to Child Processes ### The Problem: Environment Variable Inheritance When your application spawns subprocesses, runs shell commands, or calls external tools, those processes inherit a copy of ALL environment variables— including your secrets. **Leakage scenario:** ``` 1. Your app reads DB_PASSWORD from environment 2. Your app calls subprocess.run(["backup-tool", ...]) 3. backup-tool inherits DB_PASSWORD in its environment 4. Secret is exposed (intentionally or via logging/crash dumps) ``` ### Solution: Sanitize Environment for Trusted Subprocesses This pattern is for cases where you run **trusted** subprocesses but want to prevent accidental secret inheritance: ```python import os import subprocess import argclass os.environ["DB_PASSWORD"] = "secret123" os.environ["API_KEY"] = "key456" class Parser(argclass.Parser): db_password: str = argclass.Secret(env_var="DB_PASSWORD") api_key: str = argclass.Secret(env_var="API_KEY") parser = Parser() parser.parse_args([]) # Secrets are parsed and stored in parser assert str(parser.db_password) == "secret123" # BEFORE sanitizing: subprocess inherits the secret leaked = subprocess.check_output( "echo $DB_PASSWORD", shell=True, text=True ).strip() assert leaked == "secret123" # Secret visible to child process # Sanitize environment parser.sanitize_env() # AFTER sanitizing: subprocess cannot see the secret via env clean_output = subprocess.check_output( "echo $DB_PASSWORD", shell=True, text=True ).strip() assert clean_output == "" # Environment variable removed # Your application still has access to the parsed value assert str(parser.db_password) == "secret123" ``` ### Recommended Flow ```python import argclass import subprocess class Parser(argclass.Parser): api_key: str = argclass.Secret(env_var="API_KEY") database_url: str = argclass.Secret(env_var="DATABASE_URL") def main(): # Step 1: Parse arguments and sanitize secrets in one call parser = Parser() parser.parse_args(sanitize_secrets=True) # Step 2: Extract secrets into local variables if needed api_key = str(parser.api_key) db_url = str(parser.database_url) # Step 3: Use secrets in your application connect_to_db(db_url) # Step 4: Spawn trusted subprocesses (they won't inherit secrets) subprocess.run(["backup-tool", "--compress"]) if __name__ == "__main__": main() ``` Alternatively, sanitize manually after parsing: ```python def main(): parser = Parser() parser.parse_args() # Sanitize only secrets, keep other env vars parser.sanitize_env(only_secrets=True) # Or sanitize all used env vars # parser.sanitize_env() subprocess.run(["backup-tool", "--compress"]) ``` ## Other Secret Leakage Channels Even with `sanitize_env()`, secrets can leak through other channels: ### Command-Line Arguments Secrets in command-line arguments are visible to all users via `ps`: ```console # Anyone on the system can see this: $ ps aux | grep python user 1234 python app.py --password=supersecret ``` **Mitigation:** Never pass secrets via command-line arguments. Use environment variables or config files with restricted permissions. ### Log Files Secrets can be accidentally logged when using f-strings: ```python import logging import argclass api_key = argclass.SecretString("LEAK ME") # WRONG: F-string evaluates str() before logging logging.info(f"Connecting with key: {api_key}") # RIGHT: %-formatting lets logging call repr() logging.info("Connecting with key: %s", api_key) ``` **Mitigation:** Use `logging.info("msg: %s", secret)` instead of f-strings. The logging module calls `repr()` on SecretString, which returns `'******'`. ### Config Files Config files persist on disk and may be readable by other users: ```console # Check permissions $ ls -la config.ini -rw-r--r-- 1 user user ... config.ini # WRONG: world-readable ``` **Mitigation:** Use `chmod 600` for config files containing secrets. Prefer environment variables for secrets. ### Crash Dumps and Core Files Secrets in memory may appear in crash dumps: ```console # Core dumps may contain secrets $ ulimit -c unlimited # Enables core dumps ``` **Mitigation:** Disable core dumps in production (`ulimit -c 0`), or ensure core dump directories have restricted permissions. ### Process Memory Any code running in your process can inspect memory: ```python import argclass import os class Parser(argclass.Parser): api_key: str = argclass.Secret(env_var="API_KEY") os.environ["API_KEY"] = "supersecret" parser = Parser() parser.parse_args([]) # Malicious code can extract secrets from parser object secret_value = str(parser.api_key) ``` **Mitigation:** Do not run untrusted code. There is no library-level protection against code running in the same process. --- ## SecretString Guarantees ### What SecretString Protects Against | Scenario | Protected? | Details | |----------|------------|---------| | `repr(secret)` | Yes | Returns `'******'` | | `f"{secret!r}"` | Yes | Uses repr, shows `'******'` | | `log.info("x: %s", secret)` | Yes | Logging uses repr, shows `'******'` | | `print(secret)` | **No** | Uses str, shows actual value | | `f"{secret}"` | **No** | Uses str, shows actual value | | `log.info(f"x: {secret}")` | **No** | F-string uses str before logging | | `str(secret)` | **No** | Returns actual value (intended) | ### Safe Logging ```python import argclass import logging class Parser(argclass.Parser): password: str = argclass.Secret() parser = Parser() parser.parse_args(["--password", "supersecret"]) log_output = "" # Add a custom logging handler to capture log output for # this example will demonstrate safe logging practices. class LogCaptureHandler(logging.Handler): def emit(self, record): global log_output log_output += self.format(record) + "\n" logger = logging.getLogger() logger.setLevel(logging.INFO) logger.addHandler(LogCaptureHandler()) # SAFE - logging with %s uses repr() logging.info("Password: %s", parser.password) assert "supersecret" not in log_output # SAFE - f-string with !r also works safe_fstring = f"Password: {parser.password!r}" assert "supersecret" not in safe_fstring assert "******" in safe_fstring # UNSAFE - f-string without !r exposes the secret assert f"Password: {parser.password}" == "Password: supersecret" ``` **Recommended logging pattern:** ```python import logging from argclass import SecretString secret = SecretString("api_key_value") # SAFE: Use %-formatting, logging calls repr() on SecretString # logging.info("Connecting with API key: %s", secret) # Shows '******' ``` ### Comparison Without Exposure SecretString supports equality comparison without exposing the value: ```python from argclass import SecretString secret1 = SecretString("password123") secret2 = SecretString("password123") # Comparisons work without exposing values assert secret1 == secret2 assert secret1 == "password123" # repr never exposes the value assert repr(secret1) == "'******'" ``` ## Config File Security ### File Permissions If you choose to store secrets in config files, you **must** verify file permissions: ```console # Restrict permissions (Unix/Linux/macOS) chmod 600 /path/to/config.ini # Owner can read/write, no one else # -rw------- 1 user user ... config.ini ``` ### Prefer Environment Variables Environment variables are often more secure than config files because: - Config files can be accidentally committed to version control - Config files persist on disk and can be read by other users - Environment variables are process-scoped and not persisted ```python import os # Store secrets in environment, not in config.ini os.environ["API_KEY"] = "your_secret" # Set by deployment system ``` Here is the recommended pattern: ```python import os import argclass # RECOMMENDED: Use environment variables for secrets os.environ["API_KEY"] = "secret_from_env" class Parser(argclass.Parser): # Secret from environment - not stored in files api_key: str = argclass.Secret(env_var="API_KEY") # Non-secrets can come from config log_level: str = "info" max_retries: int = 3 parser = Parser(config_files=["config.ini"]) # Config for non-secrets only parser.parse_args([]) parser.sanitize_env() # Removes API_KEY from environment ``` --- ## Passing Secrets to Subprocesses ### Passing Secrets via Command Line is Insecure Not passing secrets via command-line arguments is critical, as they are visible to all users on the system: ```python import subprocess # WRONG - secret visible in process listing subprocess.check_output( ["ps aux | grep python | grep --password"], shell=True, text=True, ) ``` ## Common Mistakes ### Don't Do This Notice the mistakes in this example: ```python import os import argclass import logging import subprocess class Parser(argclass.Parser): api_key: str = argclass.Secret(env_var="API_KEY") os.environ["API_KEY"] = "supersecret" parser = Parser() parser.parse_args([]) # WRONG: F-string logs the actual secret print(f"Using API key: {parser.api_key}") # WRONG: Includes secret in exception raise ValueError(f"Invalid key: {parser.api_key}") # WRONG: Forgets to sanitize before subprocess print(subprocess.check_output("echo $API_KEY", shell=True, text=True).strip()) ``` ### Do This Instead ```python import argclass import logging import subprocess class Parser(argclass.Parser): api_key: str = argclass.Secret(env_var="API_KEY") parser = Parser() parser.parse_args([]) # IMMEDIATELY sanitize parser.sanitize_env() # RIGHT: %-formatting uses repr() - shows '******' logging.info("Using API key: %s", parser.api_key) # RIGHT: Just indicate presence print("API key configured: Yes") # RIGHT: Don't include secret in errors raise ValueError("Invalid API key provided") # RIGHT: Environment sanitized before subprocess subprocess.run(["some-tool"]) ``` ## FAQ ### If I sanitize env, can I safely run third-party scripts? **No.** Sanitizing environment variables only removes secrets from the environment that child processes inherit. It does not make it safe to run untrusted or third-party code. Untrusted code running in your process (before or after sanitization) can: - Read secrets from the parser object or local variables - Inspect process memory or stack frames - Access secrets before `sanitize_env()` is called - Read secrets from config files on disk - Intercept secrets passed to functions If you need to run untrusted code, you must use proper isolation mechanisms (containers, VMs, separate user accounts) that are outside the scope of this library. `sanitize_env()` is a defense-in-depth measure for preventing accidental leakage to well-behaved, trusted subprocesses—not a security boundary against malicious code.