Why Repeat Yourself? - Define your CLI once with Pydantic models
Project description
wry - Why Repeat Yourself? CLI
wry (Why Repeat Yourself?) is a Python library that combines the power of Pydantic models with Click CLI framework, enabling you to define your CLI arguments and options in one place using type annotations. Following the DRY (Don't Repeat Yourself) principle, it eliminates the repetition of defining arguments, types, and validation rules separately.
Features
Core Features
- 🎯 Single Source of Truth: Define your CLI structure using Pydantic models with type annotations
- 🔍 Type Safety: Full type checking and validation using Pydantic
- 🌍 Multiple Input Sources: Automatically handles CLI arguments, environment variables, and config files
- 📊 Value Source Tracking: Know whether each config value came from CLI, env, config file, or defaults
- 🎨 Auto-Generated CLI: Automatically generates Click options and arguments from your Pydantic models
- 📝 Rich Help Text: Auto-generated help includes type information, constraints, and defaults
- 🔧 Validation: Leverage Pydantic's validation system with helpful error messages
- 🌳 Environment Variable Support: Automatic env var discovery with customizable prefixes
- 📁 Config File Support: Load configuration from JSON files with proper precedence
Installation
pip install wry
Quick Start
The simplest way to use wry is with AutoWryModel, which automatically generates CLI options for all fields:
import click
from pydantic import Field
from wry import AutoWryModel
class AppArgs(AutoWryModel):
"""Configuration for my app."""
name: str = Field(description="Your name")
age: int = Field(default=25, ge=0, le=120, description="Your age")
verbose: bool = Field(default=False, description="Verbose output")
@click.command()
@AppArgs.generate_click_parameters()
def main(**kwargs: Any):
"""My simple CLI application."""
# Create the model instance from kwargs
config = AppArgs(**kwargs)
click.echo(f"Hello {config.name}, you are {config.age} years old!")
if __name__ == "__main__":
main()
See comprehensive examples:
examples/autowrymodel_comprehensive.py- All AutoWryModel features including aliasesexamples/wrymodel_comprehensive.py- WryModel with source trackingexamples/multimodel_comprehensive.py- Multi-model usage
Run it:
$ python app.py --name Alice --age 30 --verbose
Hello Alice, you are 30 years old!
Name was provided via: ValueSource.CLI
# Also supports environment variables
$ export WRY_NAME=Bob
$ python app.py --age 35
Hello Bob, you are 35 years old!
Value Source Tracking
wry tracks where each configuration value came from, supporting all four sources:
- DEFAULT: Values from model field defaults
- ENV: Values from environment variables
- JSON: Values from configuration files (via
--config) - CLI: Values from command-line arguments
Basic Usage (No Source Tracking)
@click.command()
@AppArgs.generate_click_parameters()
def main(**kwargs: Any):
# Simple instantiation - no source tracking
config = AppArgs(**kwargs)
# Works fine, but config.source.* will always show CLI
Full Source Tracking (Recommended)
To enable accurate source tracking, use @click.pass_context and from_click_context():
@click.command()
@AppArgs.generate_click_parameters()
@click.pass_context
def main(ctx: click.Context, **kwargs: Any):
# Full source tracking with context
config = AppArgs.from_click_context(ctx, **kwargs)
# Check individual field sources
print(config.source.name) # ValueSource.CLI
print(config.source.age) # ValueSource.ENV
print(config.source.verbose) # ValueSource.DEFAULT
# Get summary of all sources
summary = config.get_sources_summary()
# {
# ValueSource.CLI: ['name'],
# ValueSource.ENV: ['age'],
# ValueSource.JSON: ['timeout'],
# ValueSource.DEFAULT: ['verbose']
# }
Comprehensive Example
See examples/source_tracking_comprehensive.py for a complete example showing all four sources working together. Run it with:
# With defaults only
python examples/source_tracking_comprehensive.py
# With environment variables
export MYAPP_TIMEOUT=120
export MYAPP_DEBUG=true
python examples/source_tracking_comprehensive.py
# Mix all sources (CLI > ENV > JSON > DEFAULT)
export MYAPP_TIMEOUT=120
python examples/source_tracking_comprehensive.py --config examples/sample_config.json --port 3000
Output shows source for each field:
host = json-server.com [from JSON]
port = 3000 [from CLI] ← CLI overrides JSON
debug = True [from ENV]
timeout = 120 [from ENV]
log_level = DEBUG [from JSON]
Configuration Precedence
Values are resolved in the following order (highest to lowest priority):
- CLI arguments
- Environment variables
- Config file values
- Default values
Environment Variables
wry automatically generates environment variable names from field names:
# Set environment variables
export WRY_NAME="Alice"
export WRY_AGE=25
# These will be picked up automatically
python myapp.py --verbose
View supported environment variables:
python myapp.py --show-env-vars
Config Files
Load configuration from JSON files:
python myapp.py --config settings.json
Where settings.json contains:
{
"name": "Bob",
"age": 35,
"verbose": true
}
Advanced Usage
Multi-Model Commands
Use multiple Pydantic models in a single command:
from typing import Annotated
import click
from wry import WryModel, AutoOption, multi_model, create_models
class ServerConfig(WryModel):
host: Annotated[str, AutoOption] = "localhost"
port: Annotated[int, AutoOption] = 8080
class DatabaseArgs(WryModel):
db_url: Annotated[str, AutoOption] = "sqlite:///app.db"
pool_size: Annotated[int, AutoOption] = 5
@click.command()
@multi_model(ServerConfig, DatabaseConfig)
@click.pass_context
def serve(ctx: click.Context, **kwargs: Any):
# Create model instances
configs = create_models(ctx, kwargs, ServerConfig, DatabaseConfig)
server = configs[ServerConfig]
database = configs[DatabaseConfig]
print(f"Starting server at {server.host}:{server.port}")
print(f"Database: {database.db_url} (pool size: {database.pool_size})")
AutoWryModel - Zero Configuration
Automatically generate options for all fields:
import click
from wry import AutoWryModel
from pydantic import Field
class QuickConfig(AutoWryModel):
"""All fields automatically become CLI options!"""
name: str = Field(description="Your name")
age: int = Field(default=30, ge=0, le=120)
email: str = Field(description="Your email")
@click.command()
@QuickConfig.generate_click_parameters()
def quickstart(config: QuickConfig):
print(f"Hello {config.name}!")
Direct Configuration Creation
Create configs without decorators:
from wry import WryModel
class Config(WryModel):
name: str = "default"
verbose: bool = False
# Create with source tracking
config = Config.create_with_sources(
name="Alice", # Will be tracked as programmatic source
verbose=True
)
# Or from Click context (in a command)
config = Config.from_click_context(ctx, **kwargs)
Advanced Features
Multi-Model Commands
Use multiple configuration models in a single command:
from wry import WryModel, multi_model, create_models
class DatabaseArgs(WryModel):
host: str = Field(default="localhost")
port: int = Field(default=5432)
class AppArgs(WryModel):
debug: bool = Field(default=False)
workers: int = Field(default=4)
@click.command()
@multi_model(DatabaseConfig, AppArgs)
@click.pass_context
def main(ctx: click.Context, **kwargs: Any):
# Automatically splits kwargs between models
configs = create_models(ctx, kwargs, DatabaseConfig, AppArgs)
db_config = configs[DatabaseConfig]
app_config = configs[AppArgs]
click.echo(f"Connecting to {db_config.host}:{db_config.port}")
click.echo(f"Running with {app_config.workers} workers")
Strict Mode (Default)
By default, generate_click_parameters runs in strict mode to prevent common mistakes:
@click.command()
@Config.generate_click_parameters() # strict=True by default
@Config.generate_click_parameters() # ERROR: Duplicate decorator detected!
def main(**kwargs: Any):
pass
To allow multiple decorators (not recommended):
@Config.generate_click_parameters(strict=False)
Manual Field Control
For more control over CLI generation, use the traditional WryModel with annotations:
from typing import Annotated
from wry import WryModel, AutoOption, AutoArgument
class Config(WryModel):
# Environment variable prefix
env_prefix = "MYAPP_"
# Required positional argument
input_file: Annotated[str, AutoArgument] = Field(
description="Input file path"
)
# Optional flag with short option
verbose: Annotated[bool, AutoOption] = Field(
default=False,
description="Enable verbose output"
)
# Option with validation
timeout: Annotated[int, AutoOption] = Field(
default=30,
ge=1,
le=300,
description="Timeout in seconds"
)
Using Pydantic Aliases for Custom CLI Names
New in v0.3.2+: Pydantic field aliases automatically control the generated CLI option names and environment variable names!
This allows you to have concise Python field names while exposing descriptive CLI options:
from pydantic import Field
from wry import AutoWryModel
class DatabaseConfig(AutoWryModel):
env_prefix = "DB_"
# Concise Python field name: db_url
# Alias controls CLI option: --database-url
# Environment variable: DB_DATABASE_URL
db_url: str = Field(
alias="database_url",
default="sqlite:///app.db",
description="Database connection URL"
)
pool_size: int = Field(
alias="connection_pool_size",
default=5,
description="Maximum connection pool size"
)
How it works:
- Python field:
db_url(concise, easy to type) - CLI option:
--database-url(descriptive, user-friendly) - Environment variable:
DB_DATABASE_URL(consistent with CLI) - JSON config: Accepts both
db_urlanddatabase_url
Requirements:
- None!
WryModelautomatically setsvalidate_by_name=Trueandvalidate_by_alias=True- This tells Pydantic to accept both field names and aliases
- No need to configure anything - it just works!
- Aliases automatically control option names, env var names, and help text
Full support (v0.3.2+):
- ✅ Aliases automatically control auto-generated option names
- ✅ Environment variables use alias names (consistent with CLI)
- ✅ Source tracking works correctly
- ✅ JSON config accepts both field names and aliases
Why this feature exists:
Before v0.3.2, if you wanted custom CLI option names, you had to use explicit click.option() decorators for every field. The alias feature eliminates this boilerplate for the common case where you just want different names.
For advanced use cases (short options, custom Click types):
You can still combine aliases with explicit click.option() decorators:
class Config(AutoWryModel):
# Explicit click.option for short option support
verbose: Annotated[int, click.option("-v", "--verbose", count=True)] = Field(default=0)
See examples/autowrymodel_comprehensive.py for examples of explicit Click decorators.
See also:
examples/autowrymodel_comprehensive.py- Complete AutoWryModel example with aliasesexamples/wrymodel_comprehensive.py- WryModel with aliases and source tracking
Development
Prerequisites
- Python 3.10+
- Git with SSH key configured for signing
Setup
# Clone the repository
git clone git@github.com:tahouse/wry.git
cd wry
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install package in development mode with dev dependencies
pip install -e ".[dev]"
# Install pre-commit hooks
pre-commit install
Running Tests
# Run all tests with coverage
pytest
# Run with coverage report
pytest --cov=wry --cov-report=html
# Run specific test
pytest tests/test_core.py::TestWryModel::test_basic_model_creation
# Run all checks (recommended before pushing)
./check.sh
Testing Across Python Versions
wry supports Python 3.10, 3.11, and 3.12. To ensure compatibility:
# Test with all available Python versions locally
./scripts/test_all_versions.sh
# Test in CI-like environment using Docker
./scripts/test_ci_locally.sh
# Run GitHub Actions locally with act (requires act to be installed)
./scripts/test_with_act.sh
Code Quality
This project uses pre-commit hooks to ensure code quality:
- ruff: Linting and code formatting
- mypy: Type checking (pinned to >=1.17.1)
- pytest: Tests with 90% coverage requirement
- bandit: Security checks
- safety: Dependency vulnerability scanning
Pre-commit will run automatically on git commit. To run manually:
pre-commit run --all-files
Version Compatibility
To ensure consistent behavior between local development and CI:
- pydantic: >=2.9.2 (for proper type inference)
- mypy: >=1.17.1 (for accurate type checking)
- Python: 3.10+ (we test against 3.10, 3.11, and 3.12)
Install the exact versions used in CI:
pip install -e ".[dev]" --upgrade
Coverage Requirements
This project enforces 90% code coverage. To check coverage locally:
pytest --cov=wry --cov-report=term-missing --cov-fail-under=90
Release Process
This project uses Git tags and GitHub Actions for releases. Only maintainers can create releases.
Creating a Release
-
Ensure all changes are committed and pushed to
main -
Create and push a signed tag:
git tag -s v0.1.0 -m "Release version 0.1.0" git push origin v0.1.0
-
The CI/CD pipeline will automatically:
- Run all tests
- Build source and wheel distributions
- Upload to PyPI
- Create a GitHub release
- Sign artifacts with Sigstore
Versioning
This project follows Semantic Versioning:
- MAJOR version for incompatible API changes
- MINOR version for new functionality (backwards compatible)
- PATCH version for backwards compatible bug fixes
Version numbers are managed by setuptools-scm and derived from git tags.
Development Releases
Every push to main creates a development release on PyPI:
pip install --pre wry # Install latest dev version
Architecture
Code Organization
The wry codebase is organized into focused modules:
Main Package:
wry/__init__.py: Package exports and version handlingwry/click_integration.py: Click-specific decorators and parameter generationwry/multi_model.py: Support for multiple models in single commandswry/auto_model.py: Zero-configuration model with automatic option generation
Core Subpackage (wry/core/):
model.py: CoreWryModelimplementation with value trackingsources.py: Value source definitions and trackingaccessors.py: Property accessors for field metadatafield_utils.py: Field constraint extraction and utilitiesenv_utils.py: Environment variable handling
Design Principles
- WRY (Why Repeat Yourself?): Define CLI structure once using Pydantic models
- Type Safety: Leverage Python's type system for validation and IDE support
- Explicit is Better: Users must opt-in to features like source tracking via
@click.pass_context - Composability: Mix and match models, decorators, and configurations
- Source Tracking: Always know where configuration values came from
Contributing
We welcome contributions! Please follow these guidelines to ensure a smooth process.
Development Setup
We use standard Python virtual environments and pip for dependency management:
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install package in development mode with all dependencies
pip install -e ".[dev]"
Note: All dependencies are specified in pyproject.toml. The [dev] extra includes testing, linting, and development tools.
Common Development Commands
# Install package in editable mode
pip install -e ".[dev]"
# Update dependencies
pip install -e ".[dev]" --upgrade
# Show installed packages
pip list
# Run tests
pytest
# Run with coverage
pytest --cov=wry
# Run linting
ruff check wry tests
ruff format wry tests
# Run type checking
mypy wry
Getting Started
-
Fork the repository on GitHub
-
Clone your fork locally:
git clone git@github.com:YOUR_USERNAME/wry.git cd wry
-
Add upstream remote:
git remote add upstream git@github.com:tahouse/wry.git
-
Create a feature branch:
git checkout -b feature/your-feature-name
Development Workflow
-
Set up development environment:
python -m venv venv # Create virtual environment source venv/bin/activate # Activate it pip install -e ".[dev]" # Install with dev dependencies pre-commit install # Set up git hooks
-
Make your changes:
- Follow existing code style and patterns
- Add/update tests for new functionality
- Update documentation as needed
- Add docstrings to all new functions/classes
-
Test your changes:
# Run tests pytest # Check coverage (must be ≥90%) pytest --cov=wry --cov-report=term-missing # Run linting pre-commit run --all-files # Or run individual tools: ruff check wry tests mypy wry
-
Commit your changes:
- Use Conventional Commits format
- Examples:
feat: add support for YAML config filesfix: handle empty config files gracefullydocs: update examples for new APItest: add tests for edge casesrefactor: simplify value source tracking
Pull Request Guidelines
-
Update your branch:
git fetch upstream git rebase upstream/main
-
Push to your fork:
git push origin feature/your-feature-name
-
Create Pull Request:
- Use a clear, descriptive title
- Reference any related issues
- Describe what changes you made and why
- Include examples if applicable
- Ensure all CI checks pass
Code Style
- Use type hints for all function arguments and return values
- Follow PEP 8 (enforced by ruff)
- Maximum line length: 88 characters (Black's default)
- Use descriptive variable names
- Add docstrings to all public functions/classes/modules
Testing Guidelines
- Write tests for all new functionality
- Maintain 100% code coverage
- Use pytest fixtures for common test setups
- Test both happy paths and edge cases
- Include tests for error conditions
Documentation
- Update README.md if adding new features
- Add/update docstrings
- Include usage examples in docstrings
- Update type hints
What We're Looking For
- Bug fixes: Always welcome!
- New features: Please open an issue first to discuss
- Documentation: Improvements always appreciated
- Tests: Additional test cases for edge conditions
- Performance: Optimizations with benchmarks
- Examples: More usage examples
Questions?
- Open an issue for bugs or feature requests
- Start a discussion for general questions
- Check existing issues/PRs before creating new ones
License
This project is licensed under the MIT License - see the LICENSE file for details.
Future Features
Automatic Model Instantiation (Proof of Concept)
We're exploring a cleaner API that would automatically instantiate models and pass them to your function. See examples/auto_instantiate_poc.py for a working proof of concept:
# Potential future syntax
@click.command()
@AppConfig.click_command() # or @auto_instantiate(AppConfig)
def main(config: AppConfig):
"""The decorator would handle instantiation automatically."""
click.echo(f"Hello {config.name}!")
# Source tracking would work automatically too!
This would:
- Automatically handle
@click.pass_contextwhen needed for source tracking - Instantiate the model and pass it with the correct parameter name
- Support multiple models in a single command
- Make the API more intuitive and similar to other libraries
If you're interested in this feature, please provide feedback!
Acknowledgments
- Built on top of Click and Pydantic
- Inspired by the DRY (Don't Repeat Yourself) principle
We'd also like to acknowledgme
pydanclick, which uses a similar clean syntax (no kwargs to command functions). The code for this feature will be independently written given thatwrysupports source tracking, constraint help text creation, instantiation from config files, and several other features not supported bypydanclick.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file wry-0.4.0.tar.gz.
File metadata
- Download URL: wry-0.4.0.tar.gz
- Upload date:
- Size: 147.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
263189c6fd6c8b20e7bc27aab03c71df677fcee3c29540baf1507ad3ac16cda6
|
|
| MD5 |
bbb703edeba1dd3dd731b34d9998b995
|
|
| BLAKE2b-256 |
6ee2713d1d2f3a68494ff5e4da605d201db41f5376502bdc0b3f8a61f6977c5d
|
Provenance
The following attestation bundles were made for wry-0.4.0.tar.gz:
Publisher:
ci-cd.yml on tahouse/wry
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
wry-0.4.0.tar.gz -
Subject digest:
263189c6fd6c8b20e7bc27aab03c71df677fcee3c29540baf1507ad3ac16cda6 - Sigstore transparency entry: 584112625
- Sigstore integration time:
-
Permalink:
tahouse/wry@31ca000c2f8dfba147558609200ddecac366289d -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/tahouse
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci-cd.yml@31ca000c2f8dfba147558609200ddecac366289d -
Trigger Event:
push
-
Statement type:
File details
Details for the file wry-0.4.0-py3-none-any.whl.
File metadata
- Download URL: wry-0.4.0-py3-none-any.whl
- Upload date:
- Size: 37.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5729c9d13a8ca2158364b8c9e7b0d1d08c14abc4ee7e51226513f3dc6504661d
|
|
| MD5 |
65064572a2050b75ff6e12a028d5e048
|
|
| BLAKE2b-256 |
3a4eef3f72fd3b7e2df557dd4d610dbd30fad30318bdac47ccfc6862766c5979
|
Provenance
The following attestation bundles were made for wry-0.4.0-py3-none-any.whl:
Publisher:
ci-cd.yml on tahouse/wry
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
wry-0.4.0-py3-none-any.whl -
Subject digest:
5729c9d13a8ca2158364b8c9e7b0d1d08c14abc4ee7e51226513f3dc6504661d - Sigstore transparency entry: 584112626
- Sigstore integration time:
-
Permalink:
tahouse/wry@31ca000c2f8dfba147558609200ddecac366289d -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/tahouse
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci-cd.yml@31ca000c2f8dfba147558609200ddecac366289d -
Trigger Event:
push
-
Statement type: