RootConfig & CLI Integration

nshconfig provides RootConfig, a specialized configuration class that integrates seamlessly with pydantic-settings to load settings from various sources, including environment variables, .env files, secrets directories, and command-line arguments. It also offers the CLI utility to easily turn your configuration models into command-line applications.

RootConfig

RootConfig inherits from both nshconfig.Config and pydantic_settings.BaseSettings, combining the features of both. It’s the recommended base class for your top-level application configuration.

from nshconfig import RootConfig

class AppSettings(RootConfig):
    """My application settings."""
    debug_mode: bool = False
    """Enable debug mode"""
    api_key: str
    """API key for external service"""
    database_url: str = "sqlite:///./default.db"
    """Database connection URL"""

    # You can configure pydantic-settings behavior via model_config
    model_config = {
        "env_prefix": "MYAPP_",  # Environment variables should start with MYAPP_
        "env_file": ".env",      # Load settings from .env file
        "secrets_dir": "/run/secrets", # Load secrets from files in this directory
        "cli_parse_args": True,  # Enable parsing from CLI arguments
        "use_attribute_docstrings": True, # Ensure docstrings are used for descriptions
    }

# Initialize settings using auto_init
# This will load from .env, environment variables (MYAPP_DEBUG_MODE, MYAPP_API_KEY, ...),
# secrets (/run/secrets/api_key), and CLI arguments (--debug-mode, --api-key, ...)
settings = AppSettings.auto_init()

print(settings.model_dump_json(indent=2))

auto_init

Instead of relying on the potentially confusing magic __init__ behavior of pydantic-settings.BaseSettings (which tries to load settings automatically upon instantiation), nshconfig.RootConfig provides the explicit auto_init class method.

auto_init performs the core logic of pydantic-settings: it discovers, loads, and validates configuration values from all configured sources (environment, files, CLI, etc.) and returns a fully validated instance of your RootConfig class.

You can pass keyword arguments to auto_init to provide initial values or override specific settings source parameters (like _env_file, _secrets_dir, _cli_parse_args, etc.).

Controlling __init__ Behavior

By default (unset_magic_init_method=True in RootConfigDict), RootConfig disables the automatic settings loading within the standard __init__ method. This makes __init__ behave like a standard Pydantic BaseModel constructor, improving clarity and compatibility with type checkers and IDEs.

If you need the original pydantic-settings behavior where __init__ also loads settings, you can configure it:

class LegacySettings(RootConfig):
    # ... fields ...

    model_config = {
        "unset_magic_init_method": False, # Re-enable magic __init__
        # ... other settings ...
    }

# Now, __init__ will also load settings (though auto_init is still recommended)
settings = LegacySettings(api_key="provided_at_init")

CLI Utility (CliApp)

The nshconfig.CLI class (also available as nshconfig.CliApp) provides static methods to run your Config or RootConfig classes as command-line applications.

Basic Usage with cli_cmd

To make a configuration class runnable, define a cli_cmd method within it. This method will be executed after the configuration is loaded and validated via CLI.run.

import asyncio
from nshconfig import RootConfig, CLI

class ToolConfig(RootConfig):
    input_file: str
    """Path to the input file"""
    output_file: str = "output.txt"
    """Path to the output file"""
    force: bool = False
    """Overwrite output file if it exists"""

    model_config = {
        "cli_parse_args": True,
        "use_attribute_docstrings": True,
    }

    def cli_cmd(self) -> None:
        """The main logic of the command-line tool."""
        print(f"Processing {self.input_file}...")
        # ... actual processing logic ...
        print(f"Writing output to {self.output_file} (Force: {self.force})")
        print("Done.")

    # Example of an async command
    # async def cli_cmd(self) -> None:
    #     print(f"Processing {self.input_file} asynchronously...")
    #     await asyncio.sleep(1)
    #     print(f"Writing output to {self.output_file} (Force: {self.force})")
    #     print("Done.")


if __name__ == "__main__":
    # Run the config as a CLI app.
    # CLI.run() will:
    # 1. Instantiate ToolConfig using auto_init (loading from CLI args).
    # 2. Call the cli_cmd() method on the instance.
    config_instance = CLI.run(ToolConfig)

    # config_instance holds the validated configuration used
    print(f"CLI run finished. Final config: {config_instance.input_file=}")

You can then run this script from your terminal:

python your_script.py --input-file data.csv --force
# Output:
# Processing data.csv...
# Writing output to output.txt (Force: True)
# Done.
# CLI run finished. Final config: config_instance.input_file='data.csv'

CLI.run handles parsing arguments (using the underlying pydantic-settings and argparse integration), instantiating the model via auto_init, and then executing the cli_cmd method. It also correctly handles async def cli_cmd methods.

Subcommands

pydantic-settings (and therefore nshconfig) supports defining subcommands using nested models annotated with nshconfig.CliSubCommand.

from nshconfig import RootConfig, Config, CLI, CliSubCommand
from typing import Annotated
from collections.abc import Sequence

class UserConfig(Config):
    name: str
    """User name"""
    email: str | None = None
    """User email"""

    model_config = {"use_attribute_docstrings": True}

    def cli_cmd(self) -> None:
        print(f"Running user command for {self.name}")
        if self.email:
            print(f"Email: {self.email}")

class GroupConfig(Config):
    group_name: str
    """Group name"""
    members: list[str] = []
    """List of members"""

    model_config = {"use_attribute_docstrings": True}

    def cli_cmd(self) -> None:
        print(f"Running group command for {self.group_name}")
        print(f"Members: {', '.join(self.members) or 'None'}")

class AdminTool(RootConfig):
    """Admin tool with user and group subcommands."""
    verbose: bool = False
    """Enable verbose output"""
    user: Annotated[UserConfig | None, CliSubCommand()] = None
    group: Annotated[GroupConfig | None, CliSubCommand()] = None

    model_config = {
        "cli_parse_args": True,
        "use_attribute_docstrings": True,
    }

    # Optional: Define a top-level command if no subcommand is given
    # def cli_cmd(self) -> None:
    #     print("Admin tool main command. Use 'user' or 'group' subcommand.")


if __name__ == "__main__":
    # CLI.run will parse args, identify the subcommand, instantiate it,
    # and run its cli_cmd.
    config_instance = CLI.run(AdminTool)

    # You can also manually run the subcommand if needed
    # active_subcommand_name = config_instance.cli_active_subcommand()
    # if active_subcommand_name:
    #     print(f"Active subcommand: {active_subcommand_name}")
    #     # CLI.run_subcommand(config_instance) # Runs the active subcommand's cli_cmd

Running this script:

# Get help
python admin_tool.py --help

# Run the user subcommand
python admin_tool.py user --name Alice --email [email protected]
# Output:
# Running user command for Alice
# Email: [email protected]

# Run the group subcommand
python admin_tool.py group --group-name admins --members bob carol
# Output:
# Running group command for admins
# Members: bob, carol

CLI.run automatically detects and executes the cli_cmd of the chosen subcommand. You can also use CLI.run_subcommand(instance) to explicitly run the active subcommand’s cli_cmd on an already instantiated model.

CLI Methods on Config

The base nshconfig.Config class also gains a few CLI-related helper methods:

  • config.cli_run_subcommand() -> Config: Runs the cli_cmd of the active subcommand, if any.

  • Config.cli_available_subcommands() -> list[str]: Returns a list of field names that are defined as subcommands.

  • config.cli_active_subcommand() -> str | None: Returns the name of the subcommand field that was activated via CLI arguments, or None.

For more advanced CLI customization options (like argument groups, positional arguments, custom parsers), refer to the pydantic-settings Command Line Support documentation. nshconfig exposes the necessary types like CliPositionalArg, CliExplicitFlag, etc., via from nshconfig import ....