Type Annotations¶
params-proto v3 supports rich type annotations for parameters, providing type safety and automatic CLI help generation.
Known Type System Issues¶
✅ All major types are now fully supported in v3.0.0-rc24!
Previously broken types now working:
Literal[...]- Full validation of allowed valuesEnum- Conversion to enum members (case-insensitive matching)Path- Automatic instantiation viapathlib.Path()dict- Safe parsing usingast.literal_eval
Required Parameters and Callable Types¶
Key Design Principle: For required parameters (those without default values), params-proto always calls the type hint as a constructor.
This is the foundation for Union types and potential subcommand support.
How It Works¶
When a parameter has no default value, its type hint must be callable:
from dataclasses import dataclass
from params_proto import proto
@dataclass
class Params:
lr: float = 0.001
batch_size: int = 32
@proto.cli
def train(
config: Params, # Required parameter - Params will be called as Params()
epochs: int = 100, # Optional parameter with default
):
"""Train with configuration."""
print(f"Using lr={config.lr}, batch_size={config.batch_size}")
CLI Usage:
# params-proto calls Params() to instantiate it
python train.py params --lr 0.01 --batch-size 64
Union Types as Subcommands¶
This pattern enables Union types to act like subcommands:
from dataclasses import dataclass
@dataclass
class Perspect:
"""Perspective camera configuration."""
fov: float = 60.0 # Field of view in degrees
near: float = 0.1 # Near clipping plane
far: float = 100.0 # Far clipping plane
@dataclass
class Orthographic:
"""Orthographic camera configuration."""
zoom: float = 1.0 # Zoom level
near: float = 0.1 # Near clipping plane
far: float = 100.0 # Far clipping plane
@proto.cli
def render(
camera: Perspect | Orthographic, # Required - user must choose which type
output: str = "output.png",
):
"""Render scene with camera configuration."""
print(f"Using camera: {camera}")
CLI Usage:
# Choose perspective camera (calls Perspect())
python render.py perspect --fov 45.0 --near 0.1
# Choose orthographic camera (calls Orthographic())
python render.py orthographic --zoom 2.0 --near 0.5
# Get help for specific camera type
python render.py perspect --help
python render.py orthographic --help
The first positional argument selects which type to instantiate, and subsequent arguments configure that instance.
Why This Matters¶
This design enables:
Type-safe subcommand patterns without special subcommand syntax
Polymorphic configurations - choose between different config types at runtime
Composable types - any callable (dataclass, class, function) works as a type hint
Automatic CLI generation - params-proto generates appropriate help text for each union member
Required vs Optional¶
The callable type instantiation only applies to required parameters:
@proto.cli
def train(
# Required: Type hint MUST be callable, will be instantiated
config: Params,
# Optional: Uses the default value, type hint is for validation
epochs: int = 100,
lr: float = 0.001,
):
pass
For optional parameters (with defaults), the type hint is used for type conversion and validation, not instantiation.
Class Name to CLI Command Conversion¶
Important: When using Union types, class names are converted to lowercase CLI commands:
from dataclasses import dataclass
@dataclass
class Perspect: # Python: PascalCase
fov: float = 60.0
@dataclass
class Orthographic:
zoom: float = 1.0
@proto.cli
def render(camera: Perspect | Orthographic):
"""Render with camera."""
pass
CLI usage:
# Class names become kebab-case commands
python render.py perspect --fov 45.0 # Not Perspect or PERSPECT
python render.py orthographic --zoom 2.0 # Not Orthographic
Conversion rule: PascalCase → kebab-case (e.g., HTTPServer → http-server, MLModel → ml-model)
Best Practices for Union Type Names¶
Use simple single-word names:
# ✓ Recommended
class Train: # → train
class Evaluate: # → evaluate
class Export: # → export
Acronyms and multi-word names now convert properly:
# ✓ Good: acronyms now convert to kebab-case
class HTTPServer: # → http-server
class MLModel: # → ml-model
class DeepQNetwork: # → deep-q-network
# ✓ Also good: simple alternatives remain clear
class Server: # → server
class Model: # → model
class Network: # → network
Basic Types¶
Primitive Types¶
from params_proto import proto
@proto.cli
def example(
count: int = 10, # Integer parameter
rate: float = 0.5, # Floating point parameter
name: str = "default", # String parameter
enabled: bool = True, # Boolean flag
):
"""Example with basic types."""
pass
CLI usage:
python example.py --count 20 --rate 0.75 --name custom --enabled
python example.py --no-enabled # Set to False
Type Conversion¶
Values are automatically converted to the annotated type:
@proto.cli
def train(
lr: float = 0.001, # Converts "0.01" → 0.01
epochs: int = 100, # Converts "50" → 50
debug: bool = False, # Converts "true" → True
):
pass
Boolean conversion:
True:"true","1","yes","on"(case-insensitive)False:"false","0","no","off", or flag--no-{param}
Optional Types¶
Using | None (Python 3.10+)¶
@proto.cli
def process(
config_file: str | None = None, # Optional string
max_items: int | None = None, # Optional integer
):
"""Process with optional parameters."""
if config_file:
print(f"Using config: {config_file}")
else:
print("Using defaults")
Using Optional (Python 3.9+)¶
from typing import Optional
@proto.cli
def process(
config_file: Optional[str] = None,
max_items: Optional[int] = None,
):
"""Same as above, alternative syntax."""
pass
Union Types¶
Multiple Accepted Types¶
@proto.cli
def train(
lr: int | float = 0.001, # Accepts either int or float
seed: int | None = None, # Accepts int or None
):
"""Training with union types."""
pass
CLI usage:
python train.py --lr 0.01 # float
python train.py --lr 1 # int (also valid)
Literal Types¶
Literal types restrict parameters to a specific set of allowed values with runtime validation.
from typing import Literal
@proto.cli
def train(
# String literals
optimizer: Literal["adam", "sgd", "rmsprop"] = "adam",
device: Literal["cuda", "cpu", "mps"] = "cuda",
# Numeric literals
precision: Literal[16, 32, 64] = 32,
# Mixed literals
mode: Literal["auto", 1, 2, 3] = "auto",
):
"""Training with literal types."""
print(f"Using {optimizer} on {device}, precision={precision}")
CLI usage:
# Valid values are accepted
python train.py --optimizer sgd --device cpu
# Invalid values are rejected with clear error
python train.py --optimizer invalid
# error: value must be one of ('adam', 'sgd', 'rmsprop'), got 'invalid'
# Numeric literals work too
python train.py --precision 16
Help text shows allowed values:
--optimizer {adam,sgd,rmsprop} Optimizer (default: adam)
--precision {16,32,64} Precision (default: 32)
Key features:
Validates input against allowed values
Works with strings, numbers, or mixed types
Clear error messages showing valid options
Enum Types¶
Enums are converted to enum members with automatic case-insensitive name matching.
from enum import Enum, auto
from params_proto import proto
class Optimizer(Enum):
"""Optimizer choices."""
ADAM = auto()
SGD = auto()
RMSPROP = auto()
class Device(Enum):
"""Hardware device."""
CUDA = "cuda"
CPU = "cpu"
MPS = "mps"
@proto.cli
def train(
optimizer: Optimizer = Optimizer.ADAM, # Enum with auto()
device: Device = Device.CUDA, # Enum with custom values
):
"""Training with enum types."""
print(f"Optimizer: {optimizer.name}") # ADAM
print(f"Optimizer value: {optimizer.value}") # 1
print(f"Device: {device.name}") # CUDA
print(f"Device value: {device.value}") # cuda
CLI usage:
# Exact case matching
python train.py --optimizer SGD --device CPU
# Case-insensitive matching (CLI convenience)
python train.py --optimizer adam --device cuda
# Mixed case also works
python train.py --optimizer Sgd --device Mps
Help text shows member names:
--optimizer {ADAM,SGD,RMSPROP} Optimizer (default: ADAM)
--device {CUDA,CPU,MPS} Hardware device (default: CUDA)
Key features:
Converts CLI strings to enum members
Case-insensitive name matching for user convenience
Shows available options in help text
Works with both
auto()and custom values
Collection Types¶
List Types¶
List types allow collecting multiple values from the CLI. Each element is converted to the specified type.
from typing import List
@proto.cli
def process(
files: List[str] = ["input.txt"], # List of strings
dimensions: List[int] = [128, 256], # List of integers
ratios: List[float] = [0.5, 0.3], # List of floats
):
"""Process multiple files."""
print(f"Files: {files}") # e.g., ['a.txt', 'b.txt', 'c.txt']
print(f"Dimensions: {dimensions}") # e.g., [512, 1024]
print(f"Ratios: {ratios}") # e.g., [0.8, 0.2]
CLI usage:
# Multiple string values
python process.py --files a.txt b.txt c.txt
# Multiple integer values (type-converted)
python process.py --dimensions 512 1024
# Multiple float values
python process.py --ratios 0.8 0.2
# Combine with other arguments
python process.py --files x.txt y.txt --dimensions 256 256 --ratios 0.5
Help text shows list notation:
--files [STR] List of input files (default: ['input.txt']) --dimensions [INT] Dimensions to process (default: [128, 256]) --ratios [FLOAT] Aspect ratios (default: [0.5, 0.3])
How it works:
Arguments after
--flagare collected until the next flag or end of argumentsEach value is converted to the element type (e.g.,
"256"→256forList[int])The result is always a list, even with a single value
Tuple Types¶
Tuple types allow collecting multiple values from the CLI with automatic element type conversion. Both variable-length and fixed-size tuples are supported.
from typing import Tuple
@proto.cli
def train(
# Variable-length tuple: Tuple[T, ...] collects values into a tuple
learning_schedule: Tuple[float, ...] = (0.1, 0.01),
# Fixed-size tuple: Tuple[T1, T2, T3] has specific type for each position
image_size: Tuple[int, int] = (224, 224),
# Mixed types in fixed-size tuple
config: Tuple[int, str, float] = (10, "default", 0.5),
):
"""Training with tuple types."""
print(f"Learning schedule: {learning_schedule}") # e.g., (0.5, 0.1, 0.01)
print(f"Image size: {image_size[0]}x{image_size[1]}") # e.g., 256x256
print(f"Config: {config}") # e.g., (42, 'custom', 0.75)
CLI usage:
# Variable-length tuple - collects all values
python train.py --learning-schedule 0.5 0.1 0.01
# Fixed-size tuple with specific position types
python train.py --image-size 256 256
# Mixed type fixed-size tuple
python train.py --config 42 custom 0.75
# Combine with other arguments
python train.py --learning-schedule 0.2 0.02 --image-size 512 512
Help text notation:
Variable-length:
--param (INT,...)Fixed-size:
--param (INT,STR,FLOAT)
How it works:
Arguments after
--flagare collected until the next flag or end of argumentsEach value is converted to its corresponding type position
For
Tuple[T, ...], all values get converted to element type TFor fixed-size tuples, each value gets the type from its position
Path Types¶
pathlib.Path objects are automatically instantiated from string arguments.
from pathlib import Path
from params_proto import proto
@proto.cli
def process(
input_dir: Path = Path("./data"), # Directory path
output_file: Path = Path("output.txt"), # File path
config: Path = Path.home() / ".config", # Home-relative path
):
"""Process files with Path types."""
print(f"Reading from: {input_dir}")
print(f"Writing to: {output_file}")
print(f"Config at: {config}")
# Paths are ready to use with pathlib methods
input_dir.mkdir(parents=True, exist_ok=True)
CLI usage:
# Relative paths
python process.py --input-dir ./data --output-file results.txt
# Absolute paths
python process.py --input-dir /var/data --output-file /tmp/results.txt
# Paths with special characters
python process.py --input-dir ~/Documents/project --output-file ./output/final.txt
Key features:
Strings automatically converted to Path objects
Works with relative and absolute paths
Ready to use with pathlib methods (
mkdir(),exists(), etc.)Help text shows
PATHtype
Dictionary Types¶
Dictionary types are parsed safely using Python’s ast.literal_eval - no code execution risk.
from typing import Dict
from params_proto import proto
@proto.cli
def config(
# Generic dict (any keys/values)
params: Dict = None,
# Typed dict (documentation)
settings: Dict[str, int] = None,
# Complex nested structures
model_config: Dict[str, float] = None,
):
"""Configure with dictionaries."""
print(f"params: {params}")
print(f"settings: {settings}")
if model_config:
for key, value in model_config.items():
print(f" {key}: {value}")
CLI usage:
# Simple dict with strings and numbers
python config.py --params '{"lr": 0.01, "batch_size": 32}'
# Nested structures
python config.py --settings '{"depth": 3, "width": 128, "dropout": 0}'
# Complex dict with various types
python config.py --model-config '{
"learning_rate": 0.001,
"hidden_sizes": [256, 128, 64],
"activation": "relu",
"use_batch_norm": true
}'
# Dict with lists and nested dicts
python config.py --params '{"data": {"train": 0.8, "val": 0.1, "test": 0.1}, "tags": ["ml", "v2"]}'
Security note:
Uses
ast.literal_evalwhich only evaluates Python literal structuresSafe for untrusted input - will never execute arbitrary code
Supports: strings, numbers, lists, dicts, tuples, booleans, None
Key features:
Safe parsing with
ast.literal_evalSupports nested structures and mixed types
Works with both single and double quotes:
{'a': 1}or{"a": 1}Help text shows dict type:
{STR:INT}or{KEY:VALUE}No code execution - purely data literal evaluation
Complex Types¶
Nested Unions¶
@proto.cli
def train(
lr: int | float | None = 0.001, # Multiple options
layers: List[int] | None = None, # Optional list
):
"""Complex union types."""
pass
Literal with Union¶
@proto.cli
def process(
format: Literal["json", "yaml"] | None = None, # Optional literal
precision: Literal[16, 32, 64] = 32, # Numeric literal
):
"""Literal with union types."""
pass
Type Support Matrix¶
Type |
CLI Support |
Help Display |
Notes |
|---|---|---|---|
|
✅ Full |
|
Fully working |
|
✅ Full |
|
Fully working |
|
✅ Full |
|
Fully working |
|
✅ Full |
(flag) |
Supports |
|
✅ Full |
|
Ambiguous unions work |
|
✅ Full |
|
Correctly unwraps to inner type |
|
✅ Full |
|
Full validation of allowed values |
|
✅ Full |
|
Enum member conversion (case-insensitive) |
|
✅ Full |
|
Fully working with element type conversion |
|
✅ Full |
|
Fully working - variable and fixed-size tuples |
|
✅ Full |
|
Automatic conversion to Path objects |
|
✅ Full |
|
Safe parsing with ast.literal_eval |
|
✅ Full |
Subcommand |
Works as pseudo-subcommands |
Custom classes |
❌ Broken |
- |
Must be dataclasses with Union context |
Type Validation¶
Runtime Checks¶
@proto.cli
def train(lr: float = 0.001):
"""Training function."""
# Type already converted by params-proto
assert isinstance(lr, float), "lr must be float"
# Add value validation
if lr <= 0 or lr >= 1:
raise ValueError("lr must be in (0, 1)")
Using Dataclass Validation¶
from dataclasses import dataclass
from params_proto import proto
@proto
@dataclass
class Params: lr: float = 0.001
batch_size: int = 32
def __post_init__(self):
"""Validate after initialization."""
if self.lr <= 0:
raise ValueError("lr must be positive")
if self.batch_size < 1:
raise ValueError("batch_size must be >= 1")
Type Hints Best Practices¶
1. Be Specific¶
# ✓ Good: specific literal
@proto.cli
def train(optimizer: Literal["adam", "sgd"] = "adam"):
pass
# ✗ Avoid: too general
@proto.cli
def train(optimizer: str = "adam"):
pass
2. Use Optional for Nullable Values¶
# ✓ Good: explicit None
@proto.cli
def process(config: str | None = None):
pass
# ✗ Avoid: implicit None
@proto.cli
def process(config: str = None): # Type mismatch
pass
3. Document Type Constraints¶
# ✓ Good: documented constraints
@proto.cli
def train(
lr: float = 0.001, # Learning rate in range (0, 1)
epochs: int = 100, # Number of epochs (positive)
):
"""Training with documented types."""
pass
4. Use Enums for Fixed Sets¶
from enum import Enum
# ✓ Good: enum for options
class Model(Enum):
RESNET = "resnet50"
VIT = "vit-base"
@proto.cli
def train(model: Model = Model.RESNET):
pass
# ✗ Avoid: magic strings
@proto.cli
def train(model: str = "resnet50"):
# User could pass any string
pass
Advanced Type Patterns¶
Generic Types¶
from typing import TypeVar, Generic, List
T = TypeVar('T')
@proto
class Config(Generic[T]):
"""Generic configuration."""
items: List[T]
default: T
# Usage (type parameter determined at runtime)
int_config = Config[int](items=[1, 2, 3], default=0)
NewType for Documentation¶
from typing import NewType
# Create semantic types
LearningRate = NewType('LearningRate', float)
BatchSize = NewType('BatchSize', int)
@proto.cli
def train(
lr: LearningRate = 0.001, # Type hint is self-documenting
batch_size: BatchSize = 32,
):
"""Training with semantic types."""
pass
Type Aliases¶
from typing import Union, List
# Define complex types once
Numeric = Union[int, float]
PathLike = Union[str, Path]
StringList = List[str]
@proto.cli
def process(
threshold: Numeric = 0.5,
input_path: PathLike = "data",
files: StringList = ["a.txt"],
):
"""Using type aliases."""
pass
Environment Variable Types¶
Type conversion also works with environment variables:
from params_proto import proto, EnvVar
@proto.cli
def train(
# Environment variables are type-converted
lr: float = EnvVar @ "LEARNING_RATE" | 0.001,
batch_size: int = EnvVar @ "BATCH_SIZE" | 32,
use_cuda: bool = EnvVar @ "USE_CUDA" | True,
):
"""Types work with EnvVar."""
pass
Usage:
LEARNING_RATE=0.01 BATCH_SIZE=64 python train.py
# lr will be float(0.01), batch_size will be int(64)
Troubleshooting¶
Type Mismatch Errors¶
# Problem: None as default for non-optional type
@proto.cli
def bad(count: int = None): # Type error!
pass
# Solution: Use Optional
@proto.cli
def good(count: int | None = None):
pass
Union Type Ambiguity¶
# Problem: Ambiguous union
@proto.cli
def ambiguous(value: int | str = 1):
# CLI string "42" - is it int or str?
pass
# Solution: Use Literal or Enum for clarity
@proto.cli
def clear(value: Literal[1, 2, 3] = 1):
pass