Build configurable CLI tools easily in Python

What is WizLib?
WizLib is a Python framework that simplifies the creation of command-line interface (CLI) applications. It provides a structured approach to building CLI tools with a focus on modularity, testability, and developer experience.
Framework Capabilities
- Easy addition of loosely coupled subcommands: Add new commands by simply creating new files in the
command
directory - Normalized access to configuration files: Access configuration from environment variables or YAML files with a consistent API
- Standardized use of stdin, stdout, and stderr: Simplified I/O handling across your application following Unix-style norms
- Plugin-type system for handling alternate UIs: Support for different user interfaces with a vision of supporting shell, repl, terminal, web, Slack, and MCP server interfaces
- Simple line editor with completion support: Enhanced user input experience
- Abstracts some of the argparse complexity: Simplified command-line argument parsing
- Applies conventions to application code structure: Consistent organization of your application
- Supports test-driven development and CI/CD: Built with testing in mind
Important Documentation Notes
- When developing tests: Always consult the Test helpers documentation for proper mocking and testing of WizLib components
- When working with configuration: Refer to the ConfigHandler documentation for details on how to access and manage configuration values
- When implementing commands: Review the Command documentation for best practices and required methods
Approach
WizLib wraps the built-in ArgumentParser with a set of functions, classes, and conventions.
Commands exist independently. To add a new command, simply add a Python file in the command
directory with a class definition that inherits from the base command. The command will automatically appear as an option in usage, and the implementation has access to handlers for arguments, inputs, user interfaces, and values from a configuration file for the application.
Application Structure
A WizLib application has the following directory structure at a minimum. In this case, the app is called Sample
with the main command sample
and one subcommand doit
.
sample
├─ .git
└─ sample
├─ __init__.py
├─ __main__.py
└─ command
├─ __init__.py
└─ doit_command.py
Quick Start Example
- Define your app in
__init__.py
:
from wizlib.app import WizApp
from sample.command import SampleCommand
class Sample(WizApp):
name = 'sample'
base = SampleCommand
- Create an entry point in
__main__.py
:
from sample import Sample
if __name__ == '__main__':
Sample.main()
- Define your base command in
command/__init__.py
:
from wizlib.command import WizCommand
class SampleCommand(WizCommand):
default = 'doit'
- Create a command in
command/doit_command.py
:
from sample.command import SampleCommand
class DoitCommand(SampleCommand):
name = 'doit'
@classmethod
def add_args(cls, parser):
parser.add_argument('task', nargs='?', default='something')
def execute(self):
return f"Doing {self.task}!"
- Run your app:
python -m sample doit "something important"
Output:
Doing something important!
Core Components
WizLib defines several Python classes and functions for inclusion in projects:
- WizApp - Base class for a WizLib app
- Command - Root class for the app-specific command class, which forms the base class for other commands
- ConfigHandler - Handles configuration, either through environment variables or a YAML configuration file
- StreamHandler - Simplifies handling of input via stdin for non-tty inputs such as pipes
- ClassFamily - A primitive class that loads all subclasses in a directory into a "family" which can be queried for lookup
- SuperWrapper - A primitive class that "wraps" subclass methods, so that the superclass method gets called before and after the subclass method
- UIHandler - Handles user interface interactions
How Components Work Together
- WizApp initializes the application and sets up the command structure
- Command classes define the available commands and their behavior
- Handlers (ConfigHandler, StreamHandler, UIHandler) provide services to commands
- ClassFamily automatically discovers and registers command classes
- SuperWrapper manages method inheritance and execution order
Wizard icon by Freepik-Flaticon
WizApp
The WizApp
class is the root of all WizLib-based CLI applications. It serves as the entry point and orchestrator for your command-line application.
Overview
Applications that use the WizLib framework inherit from WizApp and define required attributes to configure the application behavior. The WizApp class handles argument parsing, command execution, and error handling.
Required Attributes
When creating a class that inherits from WizApp, you need to define these attributes:
Attribute | Description |
---|---|
name | Name of the application, used in argparse and configuration |
base | Base command class, typically defined in command/init.py |
handlers | List of Handler classes used by this app |
Available Methods
WizApp provides the following methods that you can use without overriding:
Method | Description |
---|---|
main() | Class method to call from a __main__ entrypoint |
start(*args, debug=False) | Class method to call from a Python entrypoint |
run(**vals) | Perform a command with the specified values |
parse_run(*args) | For testing, parse just the command part and run |
Note: Typically, you don't need to override these methods. They provide the core functionality of the WizApp framework.
Basic Usage
Here's a simple example of how to define a WizApp application:
# __init__.py
from wizlib.app import WizApp
from wizlib.config_handler import ConfigHandler
from wizlib.stream_handler import StreamHandler
from wizlib.ui_handler import UIHandler
from myapp.command import MyAppCommand
class MyApp(WizApp):
name = 'myapp'
base = MyAppCommand
handlers = [ConfigHandler, StreamHandler, UIHandler]
# __main__.py
from myapp import MyApp
if __name__ == '__main__':
MyApp.main()
Error Handling
WizApp provides built-in error handling through the following mechanisms:
AppCancellation
: A special exception that can be raised to cancel the application with an optional message- Debug mode: When debug=True is passed to start(), exceptions are re-raised for debugging
- Standard error output: When debug=False, errors are printed to stderr with appropriate formatting
Command
In WizLib, "commands" are actually subcommands in the shell sense. A WizLib-based application is a shell command (e.g., myapp
), and the "commands" within it are subcommands (e.g., myapp create ...
). These subcommands define the various actions your application can perform.
Command Organization
Commands live in the command/
directory and inherit from a single base command, which itself inherits from WizCommand
. This structure allows WizLib to automatically discover and register all commands in your application.
The base command class is typically defined in command/__init__.py
, and individual command classes are defined in separate files within the command
directory, following the naming pattern <command_name>_command.py
.
Command Implementation
When implementing a command, you need to:
- Define a
name
attribute that users will type to invoke the command - Override specific methods to define the command's behavior
The three key methods to override are:
add_args
- Define command-line argumentshandle_vals
- Process and validate argument valuesexecute
- Perform the command's action
The add_args
Method
This class method defines the command-line arguments that your command accepts.
Method Signature:
@classmethod
def add_args(cls, parser: WizParser):
# Add arguments here
Example:
@classmethod
def add_args(cls, parser: WizParser):
parser.add_argument('filename', help='File to process')
parser.add_argument('--verbose', '-v', action='store_true', help='Enable verbose output')
This method uses the standard argparse
library syntax for defining arguments. The parser is an instance of WizParser
, which extends ArgumentParser
.
The handle_vals
Method
This method processes and validates the argument values. It's also where you can prompt for missing values or set default values.
Method Signature:
def handle_vals(self):
super().handle_vals()
# Process values here
Example:
def handle_vals(self):
super().handle_vals() # Always call the superclass method first
# Check if a required argument was provided, prompt if not
if not self.provided('filename'):
self.filename = self.app.ui.get_input('Enter filename: ')
# Set a default value for an optional argument
if not hasattr(self, 'format'):
self.format = 'json'
Important: Always call super().handle_vals()
at the beginning of your implementation to ensure proper inheritance.
The provided()
method (which you should not override) helps you check if an argument was provided by the user. It handles various edge cases, such as boolean flags that might be False
.
The execute
Method
This method performs the actual command action and returns a result string.
Method Signature:
@BaseCommand.wrap
def execute(self):
# Perform the command action
return "Result string"
Example:
@MyAppCommand.wrap
def execute(self):
# Process the file
result = process_file(self.filename, verbose=self.verbose)
# Set a status message (printed to stderr)
self.status = f"Processed {self.filename} successfully"
# Return the result (printed to stdout)
return result
Important:
- Use the
@BaseCommand.wrap
decorator (replacingBaseCommand
with your actual base command class name) to ensure proper method wrapping. - The string returned by
execute()
is printed to stdout. - Setting
self.status
will print a message to stderr, following the Unix convention of separating normal output from status messages.
Command Cancellation
If a command needs to be cancelled, it can raise a CommandCancellation
exception:
from wizlib.command import CommandCancellation
def handle_vals(self):
super().handle_vals()
if not self.provided('filename'):
raise CommandCancellation("No filename provided")
Complete Example
Here's a complete example of a command implementation:
from wizlib.parser import WizParser
from wizlib.command import CommandCancellation
from myapp.command import MyAppCommand
class ProcessCommand(MyAppCommand):
"""Process a file with various options"""
name = 'process'
@classmethod
def add_args(cls, parser: WizParser):
parser.add_argument('filename', nargs='?', help='File to process')
parser.add_argument('--format', '-f', choices=['json', 'xml', 'yaml'],
default='json', help='Output format')
parser.add_argument('--verbose', '-v', action='store_true',
help='Enable verbose output')
def handle_vals(self):
super().handle_vals()
# Check if filename was provided, prompt if not
if not self.provided('filename'):
self.filename = self.app.ui.get_input('Enter filename: ')
# Validate the filename
if not os.path.exists(self.filename):
raise CommandCancellation(f"File not found: {self.filename}")
@MyAppCommand.wrap
def execute(self):
# Process the file based on the format
if self.verbose:
print(f"Processing {self.filename} in {self.format} format...",
file=sys.stderr)
result = process_file(self.filename, format=self.format)
self.status = f"Processed {self.filename} successfully"
return result
ConfigHandler
The ConfigHandler
enables flexible configuration management for WizLib applications, providing users with multiple ways to configure the application.
Overview
The ConfigHandler allows commands to retrieve configuration values from:
- Environment variables
- YAML configuration files
- Command-line arguments
This flexibility gives users options for how to provide configuration information based on their preferences and needs.
Accessing Configuration Values
Commands can request values from the configuration using the get()
method:
# In a command's execute() or handle_vals() method
value = self.app.config.get('myapp-host')
The argument to get()
is a hyphen-separated set of words that indicate a path through a hierarchy of possible values. Typically, the first word in the argument is the name of the application.
Configuration Lookup Order
When retrieving a configuration value, the handler follows this order:
- Cache: First checks if the value has already been retrieved
- Environment Variables: Looks for an environment variable by converting the hyphens to underscores and the words to all caps
- YAML Configuration File: Searches for a YAML file in several locations
Environment Variables
For a configuration key like myapp-host
, the handler will look for an environment variable named MYAPP_HOST
.
# Setting a configuration value via environment variable
export MYAPP_HOST=api.example.com
YAML Configuration Files
If no environment variable is found, the handler looks for a YAML configuration file in the following order:
- Path specified by the
--config
/-c
command-line option - Path in the
MYAPP_CONFIG
environment variable .myapp.yml
in the current working directory~/.myapp.yml
in the user's home directory
YAML Configuration Format
Configuration files use YAML format. The structure should mirror the hyphen-separated path used in the get()
method:
myapp:
host: api.example.com
database:
username: dbuser
password: secret
features:
enable_logging: true
With this configuration, you could access values like:
host = self.app.config.get('myapp-host') # "api.example.com"
username = self.app.config.get('myapp-database-username') # "dbuser"
logging = self.app.config.get('myapp-features-enable_logging') # True
Dynamic Values with Command Execution
The YAML configuration file can include dynamic values by executing OS commands using the $(command)
syntax. This is particularly useful for retrieving secrets from password managers:
myapp:
# Static value
host: api.example.com
# Dynamic value from command execution
token: $(op read "op://Private/example/api-key")
When the configuration value is requested, the command inside $()
will be executed and its output will be used as the value.
Example Usage in a Command
Here's how you might use the ConfigHandler in a command:
def handle_vals(self):
super().handle_vals()
# Get API host from config, with a default if not found
if not hasattr(self, 'host'):
self.host = self.app.config.get('myapp-host') or 'api.default.com'
# Get API token from config, prompt if not found
self.token = self.app.config.get('myapp-token')
if not self.token:
self.token = self.app.ui.get_input('Enter API token: ')
Testing with Fake ConfigHandler
For testing, you can create a fake ConfigHandler with predefined values:
from wizlib.config_handler import ConfigHandler
# Create a fake config handler with test values
config = ConfigHandler.fake(
myapp_host='test.example.com',
myapp_token='test-token'
)
# Use in tests
assert config.get('myapp-host') == 'test.example.com'
StreamHandler
The StreamHandler
simplifies handling of input data for WizLib applications, particularly for non-interactive (non-tty) inputs such as pipes or redirected files.
Overview
The StreamHandler provides a consistent way to access input data, whether it comes from:
- Standard input (stdin) via a pipe
- A file specified with the
--stream
option - Any other source that might be added in the future
Despite its name, the StreamHandler doesn't actually process data as a stream. Instead, it reads all input data at once when the command starts, making it available to commands as a single text value.
How It Works
When a WizLib application is initialized with StreamHandler in its handlers
list, the handler:
- Checks if input is being provided via stdin (e.g., through a pipe)
- Checks if a file path was provided via the
--stream
option - Reads all the input data at once
- Makes the data available to commands via
self.app.stream.text
Accessing Stream Data
Commands can access the input data using:
# In a command's execute() or handle_vals() method
input_data = self.app.stream.text
Command-Line Usage
Users can provide input to a WizLib application in several ways:
# Using a pipe
echo "Hello, World!" | myapp process
# Using input redirection
myapp process < input.txt
# Using the --stream option
myapp process --stream input.txt
Example Usage in a Command
Here's how you might use the StreamHandler in a command:
from myapp.command import MyAppCommand
class ProcessCommand(MyAppCommand):
name = 'process'
@MyAppCommand.wrap
def execute(self):
# Get the input data
data = self.app.stream.text
if not data:
return "No input data provided"
# Process the data
lines = data.strip().split('\n')
line_count = len(lines)
self.status = f"Processed {line_count} lines"
return f"Input contained {line_count} lines"
Adding StreamHandler to Your Application
To enable the StreamHandler in your WizLib application, include it in the handlers
list:
from wizlib.app import WizApp
from wizlib.stream_handler import StreamHandler
from myapp.command import MyAppCommand
class MyApp(WizApp):
name = 'myapp'
base = MyAppCommand
handlers = [StreamHandler] # Include StreamHandler
Testing with StreamHandler
For testing commands that use StreamHandler, you can create a fake StreamHandler with predefined content:
from wizlib.stream_handler import StreamHandler
# Create a command with a fake stream handler
command = MyCommand()
command.app = MyApp()
command.app.stream = StreamHandler.fake("Test input data")
# Now command.execute() will use the fake stream data
result = command.execute()
UIHandler
The UIHandler
manages user interactions in WizLib applications, providing a consistent interface for commands to interact with users regardless of the underlying UI implementation.
Overview
The UIHandler serves as a proxy for the UI class family, which drives user interactions during and between command execution. It allows WizLib applications to support different user interfaces (shell, curses, web, etc.) without changing the command implementation.
UI Types
WizLib comes with a default shell-based UI implementation, but the architecture supports multiple UI types:
- shell: Command-line interface with basic input/output capabilities (default)
- Future possibilities: curses, web, Slack, MCP server interfaces, etc.
Accessing the UI
Commands can access the UI through the application instance:
# In a command's execute() or handle_vals() method
self.app.ui.send("Processing data...")
user_input = self.app.ui.get_text("Enter filename: ")
Key UI Methods
The UI interface provides several methods for interacting with users:
Method | Description |
---|---|
send(value, emphasis) | Output text with optional emphasis (INFO, GENERAL, PRINCIPAL, ERROR) |
get_option(chooser) | Present a set of choices and get the user's selection |
get_text(prompt, choices, default) | Get a line of text input from the user, with optional tab completion |
Using Choosers for Options
The Chooser
class provides a way to present a set of options to the user:
from wizlib.ui import Chooser, Choice
# Create a chooser with choices
chooser = Chooser("Select an action", "view", [
Choice("view", "v"),
Choice("edit", "e"),
Choice("delete", "d"),
Choice("cancel", "c")
])
# Get the user's choice
action = self.app.ui.get_option(chooser)
Example Usage in a Command
Here's how you might use the UIHandler in a command:
from myapp.command import MyAppCommand
from wizlib.ui import Chooser, Choice, Emphasis
class ProcessCommand(MyAppCommand):
name = 'process'
def handle_vals(self):
super().handle_vals()
# Prompt for missing filename
if not self.provided('filename'):
self.filename = self.app.ui.get_text("Enter filename: ")
# Ask for confirmation
if not self.provided('confirm'):
chooser = Chooser("Process this file?", "yes", [
Choice("yes", "y"),
Choice("no", "n")
])
self.confirm = self.app.ui.get_option(chooser) == "yes"
@MyAppCommand.wrap
def execute(self):
if not self.confirm:
self.app.ui.send("Operation cancelled", Emphasis.INFO)
return
# Process the file
self.app.ui.send(f"Processing {self.filename}...", Emphasis.GENERAL)
# ... processing logic ...
self.app.ui.send("Processing complete!", Emphasis.PRINCIPAL)
return "Success"
Adding UIHandler to Your Application
To enable the UIHandler in your WizLib application, include it in the handlers
list:
from wizlib.app import WizApp
from wizlib.ui_handler import UIHandler
from myapp.command import MyAppCommand
class MyApp(WizApp):
name = 'myapp'
base = MyAppCommand
handlers = [UIHandler] # Include UIHandler
Custom UI Implementations
To create a custom UI implementation, you need to:
- Create a class that inherits from
UI
- Implement the required methods (
send
,get_option
,get_text
) - Set a unique
name
class attribute
from wizlib.ui import UI, Chooser, Emphasis
class MyCustomUI(UI):
name = "custom"
def send(self, value: str = '', emphasis: Emphasis = Emphasis.GENERAL):
# Custom implementation
pass
def get_option(self, chooser: Chooser):
# Custom implementation
pass
def get_text(self, prompt='', choices=[], default=''):
# Custom implementation
pass
Testing with UI
For testing commands that use UI interactions, you can create a fake UI that returns predefined responses:
from unittest.mock import MagicMock
from wizlib.ui import UI
# Create a mock UI
mock_ui = MagicMock(spec=UI)
mock_ui.get_text.return_value = "test_filename.txt"
mock_ui.get_option.return_value = "yes"
# Attach to command
command = MyCommand()
command.app = MyApp()
command.app.ui = mock_ui
# Now command.execute() will use the mock UI responses
result = command.execute()
ClassFamily
The ClassFamily
is a powerful utility in WizLib that enables automatic discovery and registration of related classes. It creates a queryable "family" of classes that can be looked up by their attributes.
Overview
A class family is a hierarchy of classes with a single super-parent (termed the "atriarch") at the top. Subclasses inherit from this parent or from other subclasses, forming a tree-like structure.
The key features of ClassFamily are:
- Automatic Discovery: Classes are automatically discovered and registered simply by being defined in the right package location
- Attribute-Based Lookup: Classes can be looked up by their attributes (like name, key, etc.)
- Dynamic Instantiation: Classes can be instantiated dynamically without direct references
How It Works
When a class inherits from ClassFamily, it becomes the "atriarch" of a family. All subclasses of this class are automatically added to the family. The atriarch maintains a list of all family members in its family
property.
The ClassFamily system carefully avoids attribute inheritance when querying. It uses __dict__
to ensure it only sees attributes defined directly on each specific class, not those inherited from parent classes.
Key Methods
Method | Description |
---|---|
family_members(attr) | Get all family members that have the specified attribute |
family_member(attr, value) | Find a family member with a specific attribute value |
get_member_attr(attr) | Get the value of an attribute for a specific family member |
Example: Vehicle Catalog System
Imagine you're building a vehicle catalog system for a dealership. You have different types of vehicles, each with specific attributes and behaviors. Using ClassFamily, you can organize these vehicles and look them up dynamically.
from wizlib.class_family import ClassFamily
# Base Vehicle class (the "atriarch")
class Vehicle(ClassFamily):
fuel_type = "unknown"
wheels = 0
def __init__(self, color, year):
self.color = color
self.year = year
def get_description(self):
return f"{self.year} {self.color} {self.get_vehicle_type()}"
def get_vehicle_type(self):
return "Vehicle"
# Intermediate class for wheeled vehicles
class WheeledVehicle(Vehicle):
# Abstract intermediate class, doesn't define a vehicle_type
pass
# Concrete vehicle classes
class Car(WheeledVehicle):
vehicle_type = "car"
wheels = 4
fuel_type = "gasoline"
def get_vehicle_type(self):
return self.vehicle_type
class ElectricCar(Car):
vehicle_type = "electric car"
fuel_type = "electricity"
class Motorcycle(WheeledVehicle):
vehicle_type = "motorcycle"
wheels = 2
fuel_type = "gasoline"
def get_vehicle_type(self):
return self.vehicle_type
class Truck(WheeledVehicle):
vehicle_type = "truck"
wheels = 6
fuel_type = "diesel"
def get_vehicle_type(self):
return self.vehicle_type
class Boat(Vehicle):
vehicle_type = "boat"
propulsion = "motor"
fuel_type = "gasoline"
def get_vehicle_type(self):
return self.vehicle_type
With this structure, you can now use the ClassFamily methods to query and instantiate vehicles:
# Find all vehicle types
vehicle_types = [cls.vehicle_type for cls in Vehicle.family_members('vehicle_type')]
print(f"Available vehicle types: {', '.join(vehicle_types)}")
# Output: Available vehicle types: car, electric car, motorcycle, truck, boat
# Find a specific vehicle by type
car_class = Vehicle.family_member('vehicle_type', 'car')
my_car = car_class(color="red", year=2023)
print(my_car.get_description()) # Output: 2023 red car
# Find all vehicles with a specific fuel type
electric_vehicles = Vehicle.family_members('fuel_type', 'electricity')
for cls in electric_vehicles:
vehicle = cls(color="blue", year=2023)
print(f"{vehicle.get_description()} runs on {cls.fuel_type}")
# Output: 2023 blue electric car runs on electricity
# Find all vehicles with a specific number of wheels
two_wheelers = Vehicle.family_members('wheels', 2)
for cls in two_wheelers:
print(f"A {cls.vehicle_type} has {cls.wheels} wheels")
# Output: A motorcycle has 2 wheels
This example demonstrates how ClassFamily allows you to:
- Organize related classes in a hierarchy
- Query classes based on their attributes
- Instantiate classes dynamically without direct references
- Handle intermediate abstract classes (like WheeledVehicle) that don't define all attributes
Implementation Details
The ClassFamily system works by:
- Tracking all subclasses of the atriarch class
- Storing them in a list property called
family
- Providing methods to query this list based on class attributes
When querying attributes, ClassFamily is careful to only look at attributes defined directly on each class (not inherited attributes). This allows intermediate abstract classes to exist in the hierarchy without affecting the lookup system.
Creating Your Own Class Family
To create your own class family:
- Create a base class that inherits from ClassFamily
- Define subclasses that inherit from this base class
- Use the family_member() and family_members() methods to query the family
This pattern is particularly useful when you have a collection of related types that you want to be able to look up dynamically, without having to maintain explicit registries or mappings.
SuperWrapper
The SuperWrapper
is a utility class that provides a method wrapping mechanism, allowing parent class methods to be executed before and after child class methods. It's like an inversion of the traditional super()
call pattern.
Overview
In traditional inheritance, a child class can call its parent's method using super()
. SuperWrapper flips this around: the parent class method calls the child class method. This creates a nested execution pattern where each parent in the inheritance chain can perform actions before and after the child's method runs.
How It Works
SuperWrapper provides a decorator (@ParentClass.wrap
) that wraps a method so that it's called within the inherited version of that method. This creates a chain of method calls that execute from the top of the inheritance hierarchy down to the most specific implementation, and then back up again.
Key Differences from Traditional Inheritance
-
Execution Order: In traditional inheritance with
super()
, execution flows from child to parent. With SuperWrapper, execution flows from parent to child and back to parent. -
Method Signatures: In SuperWrapper, parent methods have a different signature than child methods. Parent methods receive the child method as their first argument, followed by any arguments passed to the child method.
-
Explicit Method Calling: Parent methods must explicitly call the child method that's passed to them.
Example: Document Processing System
Imagine you're building a document processing system that handles various types of documents (legal contracts, financial reports, technical specifications). Each document type needs specific processing, but all documents share common steps like validation, formatting, and archiving.
from wizlib.super_wrapper import SuperWrapper
class DocumentProcessor(SuperWrapper):
"""Base document processor with common steps for all documents"""
def process(self, method, document, **options):
"""Process any document with common steps before and after"""
print(f"Starting processing of document: {document['title']}")
# Common pre-processing steps
self.validate_metadata(document)
self.backup_original(document)
# Execute the specific document type's processing
result = method(self, document, **options)
# Common post-processing steps
self.format_output(result)
self.archive_document(document, result)
print(f"Completed processing of document: {document['title']}")
return result
def validate_metadata(self, document):
"""Ensure document has required metadata"""
required_fields = ['title', 'author', 'date']
for field in required_fields:
if field not in document:
raise ValueError(f"Document missing required field: {field}")
def backup_original(self, document):
"""Create a backup of the original document"""
print(f"Creating backup of original document")
def format_output(self, result):
"""Apply standard formatting to the output"""
print(f"Applying standard formatting to output")
def archive_document(self, document, result):
"""Archive the document and its processed result"""
print(f"Archiving document and processing results")
class LegalDocumentProcessor(DocumentProcessor):
"""Processor for legal documents with additional compliance checks"""
@DocumentProcessor.wrap
def process(self, method, document, **options):
"""Add legal-specific processing steps"""
print(f"Performing legal compliance check")
# Legal-specific pre-processing
self.check_legal_compliance(document)
# Execute the specific legal document type's processing
result = method(self, document, **options)
# Legal-specific post-processing
self.add_legal_disclaimer(result)
print(f"Legal processing completed")
return result
def check_legal_compliance(self, document):
"""Check document for legal compliance issues"""
print(f"Checking document for legal compliance")
def add_legal_disclaimer(self, result):
"""Add legal disclaimers to the processed document"""
print(f"Adding legal disclaimers to output")
class ContractProcessor(LegalDocumentProcessor):
"""Processor specifically for contract documents"""
@LegalDocumentProcessor.wrap
def process(self, document, **options):
"""Process a contract document"""
print(f"Processing contract-specific elements")
# Extract contract terms
terms = self.extract_contract_terms(document)
# Analyze contract risks
risks = self.analyze_contract_risks(terms)
# Generate contract summary
summary = {
'document': document['title'],
'terms': terms,
'risks': risks,
'recommendation': 'Approve' if not risks else 'Review'
}
print(f"Contract processing completed with recommendation: {summary['recommendation']}")
return summary
def extract_contract_terms(self, document):
"""Extract key terms from the contract"""
print(f"Extracting key terms from contract")
return ['Term 1', 'Term 2', 'Term 3']
def analyze_contract_risks(self, terms):
"""Analyze contract terms for potential risks"""
print(f"Analyzing contract terms for risks")
return []
# Example usage
contract = {
'title': 'Service Agreement',
'author': 'Legal Department',
'date': '2023-03-15',
'content': 'This agreement is made between...'
}
processor = ContractProcessor()
result = processor.process(contract, validate_signatures=True)
print(f"\nFinal result: {result}")
Output:
Starting processing of document: Service Agreement
Performing legal compliance check
Checking document for legal compliance
Processing contract-specific elements
Extracting key terms from contract
Analyzing contract terms for risks
Contract processing completed with recommendation: Approve
Adding legal disclaimers to output
Legal processing completed
Applying standard formatting to output
Archiving document and processing results
Completed processing of document: Service Agreement
Final result: {'document': 'Service Agreement', 'terms': ['Term 1', 'Term 2', 'Term 3'], 'risks': [], 'recommendation': 'Approve'}
Use Cases
SuperWrapper is particularly useful for:
- Setup and Teardown: Performing setup actions before a method runs and cleanup actions afterward
- Logging and Monitoring: Adding logging or monitoring around method execution
- Transaction Management: Starting and committing/rolling back transactions
- Resource Management: Acquiring and releasing resources
- Validation: Validating inputs before execution and outputs after execution
Implementation Requirements
For a method to be "wrappable" with SuperWrapper:
- The parent class method must accept a
method
parameter as its first argument (afterself
) - The parent class method must explicitly call the passed method:
method(self, *args, **kwargs)
- The child class method must use the
@ParentClass.wrap
decorator
Note that this is different from regular inheritance, where the parent class method has the same signature as the child class method.
Testing
WizLib provides several utilities to simplify testing of WizLib applications, making it easier to write comprehensive unit tests for your commands and application logic.
⚠️ IMPORTANT: When testing WizLib applications, always use the provided testing utilities and fake handlers described in this document. Do not attempt to mock WizLib components manually, as this can lead to subtle bugs and test failures.
Overview
Testing WizLib applications can be challenging because they often:
- Interact with users through the UI
- Read from standard input streams
- Write to standard output and error streams
- Access configuration values
- Parse command-line arguments
WizLib's testing utilities help you mock these interactions and focus on testing your application logic.
WizLibTestCase
The WizLibTestCase
class is a subclass of unittest.TestCase
that provides convenience methods for patching inputs and outputs in WizLib applications.
Key Methods
Method | Description |
---|---|
patch_stream(val: str) | Patch stream input such as pipes for StreamHandler |
patch_ttyin(val: str) | Patch input typed by a user in shell UI |
patcherr() | Capture output from standard error |
patchout() | Capture output from standard output |
Example Usage
from wizlib.test_case import WizLibTestCase
from myapp import MyApp
class MyAppTest(WizLibTestCase):
def test_input_handling(self):
# Simulate user input 'laughter' and capture stdout
with self.patch_stream('laughter'), self.patchout() as output:
# Run the application
MyApp.start('dance')
# Check the output
output.seek(0)
self.assertIn('Dancing laughter', output.read())
def test_user_interaction(self):
# Simulate user typing 'y' when prompted and capture stderr
with self.patch_ttyin('y'), self.patcherr() as error:
MyApp.start('delete', 'file.txt')
# Check the error output
error.seek(0)
self.assertIn('File deleted', error.read())
These methods can be combined as needed to simulate complex interactions. You can also use them with Python's standard unittest.mock
library for more advanced patching.
Fake Handlers
WizLib provides "fake" versions of its handlers for testing purposes. These allow you to simulate specific behaviors without setting up the real handlers.
ConfigHandler.fake
Creates a fake configuration handler with predefined values:
from wizlib.config_handler import ConfigHandler
from myapp import MyApp
from myapp.command import ProcessCommand
def test_config_dependent_command(self):
# Create an app with a fake config
app = MyApp()
app.config = ConfigHandler.fake(
myapp_api_url='https://test-api.example.com',
myapp_timeout='30'
)
# Create and run a command that uses config values
command = ProcessCommand(app)
result = command.execute()
# Verify the result
self.assertEqual('Processed data from https://test-api.example.com', result)
⚠️ CRITICAL: Never directly assign a dictionary to
app.config
in tests (e.g.,app.config = {'myapp-api-url': 'value'}
). This will bypass the ConfigHandler's functionality and can cause tests to use real configuration values instead of test values. Always useConfigHandler.fake()
as shown above.
Note: When creating fake config values, use underscores in the parameter names, but the values will be accessed with hyphens in your code:
# In test:
app.config = ConfigHandler.fake(myapp_api_url='https://example.com')
# In application code:
url = self.app.config.get('myapp-api-url')
StreamHandler.fake
Creates a fake stream handler with predefined input:
from wizlib.stream_handler import StreamHandler
from myapp import MyApp
from myapp.command import ParseCommand
def test_stream_processing(self):
# Create an app with fake stream input
app = MyApp()
app.stream = StreamHandler.fake('{"name": "Test", "value": 42}')
# Create and run a command that processes stream input
command = ParseCommand(app)
result = command.execute()
# Verify the result
self.assertEqual('Parsed: Test (42)', result)
Testing Commands
Since commands are the core of WizLib applications, WizLib provides specific utilities for testing them.
Direct Command Testing
To test a command's execution logic without going through argument parsing:
def test_command_execution(self):
# Create an app
app = MyApp()
# Create a command with specific arguments
command = CalculateCommand(app, value=10, operation='square')
# Execute the command
result = command.execute()
# Verify the result
self.assertEqual(100, result)
Testing Command Parsing and Execution
To test both argument parsing and command execution:
def test_command_parsing_and_execution(self):
# Capture stdout
with self.patchout() as output:
# Create an app and run a command with arguments
app = MyApp()
app.parse_run('calculate', '--value', '10', '--operation', 'square')
# Check the output
output.seek(0)
self.assertIn('Result: 100', output.read())
The parse_run
method is a shortcut that parses the arguments and runs the command without going through the full application startup process.
Testing UI Interactions
For testing UI interactions, you can use a combination of the patching methods and Python's mock library:
from unittest.mock import patch
from wizlib.ui import Chooser, Choice
def test_ui_interaction(self):
# Create a chooser that the command will use
chooser = Chooser("Select operation", "add", [
Choice("add", "a"),
Choice("subtract", "s"),
Choice("multiply", "m")
])
# Patch the UI's get_option method to return a specific choice
with patch('myapp.ui.MyAppUI.get_option', return_value="multiply"):
# Create an app
app = MyApp()
# Create and run a command that uses UI
command = InteractiveCalculateCommand(app, value1=5, value2=4)
result = command.execute()
# Verify the result
self.assertEqual(20, result) # 5 * 4 = 20
Best Practices for Testing WizLib Applications
- Use WizLibTestCase: Inherit from WizLibTestCase to get access to the patching methods
- Test Commands Individually: Test each command's functionality in isolation
- Test Command Parsing: Test that arguments are correctly parsed and passed to commands
- Mock External Dependencies: Use Python's mock library to mock external dependencies
- Test Error Handling: Test how your application handles errors and edge cases
- Test UI Interactions: Test how your application interacts with users through the UI
- Use Fake Handlers: Use the fake handlers to simulate specific behaviors
Best Practices
This document outlines best practices, conventions, and tips for developing applications with WizLib that may not be fully covered in the core documentation.
Naming Conventions
-
App Class Naming: Name your app class with the "App" suffix, e.g.,
MyDynamoApp
, not justMyDynamo
.# Recommended class MyDynamoApp(WizApp): name = 'mydynamo' ... # Not recommended class MyDynamo(WizApp): name = 'mydynamo' ...
-
Command Naming: Name your command classes with the "Command" suffix, e.g.,
ListTablesCommand
.
Command-Line Argument Handling
- Framework Arguments Order: Always place framework-level arguments (such as
--config
) before the command name:# Correct python -m myapp --config config.yml command-name # Incorrect python -m myapp command-name --config config.yml
Testability and Dependency Injection
-
Dependency Injection: Make your classes testable by accepting dependencies in the constructor:
# Testable with dependency injection class DynamoOps: def __init__(self, config, resource=None, client=None): self.client = client or boto3.client(...) # Harder to test class DynamoOps: def __init__(self, config): self.client = boto3.client(...)
-
Factory Methods: Consider implementing factory methods for cleaner production code while maintaining testability:
class DynamoOps: def __init__(self, config, resource=None, client=None): # Constructor with injection points @classmethod def create(cls, config): """Factory method for production use.""" return cls(config)
Testing Best Practices
-
Test Coverage for Entry Points: Use
# pragma: nocover
in__main__.py
to exclude the entry point code from coverage reports:if __name__ == '__main__': # pragma: nocover MyApp.main()
-
Initializing Apps in Tests: If you need to improve test coverage for your app class, call the initialize method in your test package's
__init__.py
:# test/__init__.py from myapp import MyApp MyApp.initialize()
-
Mocking External Services: When testing code that interacts with external services (like AWS), mock the client libraries rather than your own classes:
@patch('boto3.client') def test_aws_interaction(self, mock_client): mock_aws_client = MagicMock() mock_client.return_value = mock_aws_client # Test code that uses boto3.client
-
Testing Command Execution: Test commands by:
- Creating a command instance
- Injecting any mocked dependencies
- Calling the appropriate methods
- Verifying results
def test_command_with_mocks(self): # Create app with fake config app = MyApp() app.config = ConfigHandler.fake(myapp_endpoint='http://example.com') # Create command command = MyCommand(app) # Inject mocked service command.service = MockService() # Execute and verify result = command.execute() self.assertEqual("Expected result", result)
Configuration Best Practices
-
Sandbox Configuration: Keep development/test configurations in a
sandbox/
directory:project/ ├── myapp/ └── sandbox/ └── myapp.yml # Dev configuration
-
Configuration Defaults: Always provide sensible defaults when reading configuration values:
endpoint = config.get('myapp-endpoint') or 'http://localhost:8000'
Common Pitfalls
-
Command Registration: Command classes are automatically discovered, you don't need to import them in
__init__.py
. Just ensure they're in the command directory. -
Config Access: Always access configuration through
self.app.config.get('key-name')
, not by direct dictionary access. -
Test Inheritance: When testing WizLib components, you may need to use regular
unittest.TestCase
instead ofWizLibTestCase
to avoid unexpected behaviors, especially when patching core components.
Development Workflow
- Define your app class with a clear name and required attributes
- Create the base command class
- Implement individual commands
- Write unit tests for each component
- Ensure high test coverage (typically 95% or higher)
- Create a sandbox configuration for development testing