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

  1. Define your app in __init__.py:
from wizlib.app import WizApp
from sample.command import SampleCommand

class Sample(WizApp):
    name = 'sample'
    base = SampleCommand
  1. Create an entry point in __main__.py:
from sample import Sample

if __name__ == '__main__':
    Sample.main()
  1. Define your base command in command/__init__.py:
from wizlib.command import WizCommand

class SampleCommand(WizCommand):
    default = 'doit'
  1. 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}!"
  1. 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

  1. WizApp initializes the application and sets up the command structure
  2. Command classes define the available commands and their behavior
  3. Handlers (ConfigHandler, StreamHandler, UIHandler) provide services to commands
  4. ClassFamily automatically discovers and registers command classes
  5. 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:

AttributeDescription
nameName of the application, used in argparse and configuration
baseBase command class, typically defined in command/init.py
handlersList of Handler classes used by this app

Available Methods

WizApp provides the following methods that you can use without overriding:

MethodDescription
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:

  1. Define a name attribute that users will type to invoke the command
  2. Override specific methods to define the command's behavior

The three key methods to override are:

  1. add_args - Define command-line arguments
  2. handle_vals - Process and validate argument values
  3. execute - 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:

  1. Use the @BaseCommand.wrap decorator (replacing BaseCommand with your actual base command class name) to ensure proper method wrapping.
  2. The string returned by execute() is printed to stdout.
  3. 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:

  1. Environment variables
  2. YAML configuration files
  3. 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:

  1. Cache: First checks if the value has already been retrieved
  2. Environment Variables: Looks for an environment variable by converting the hyphens to underscores and the words to all caps
  3. 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:

  1. Path specified by the --config / -c command-line option
  2. Path in the MYAPP_CONFIG environment variable
  3. .myapp.yml in the current working directory
  4. ~/.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:

  1. Checks if input is being provided via stdin (e.g., through a pipe)
  2. Checks if a file path was provided via the --stream option
  3. Reads all the input data at once
  4. 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:

MethodDescription
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:

  1. Create a class that inherits from UI
  2. Implement the required methods (send, get_option, get_text)
  3. 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:

  1. Automatic Discovery: Classes are automatically discovered and registered simply by being defined in the right package location
  2. Attribute-Based Lookup: Classes can be looked up by their attributes (like name, key, etc.)
  3. 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

MethodDescription
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:

  1. Organize related classes in a hierarchy
  2. Query classes based on their attributes
  3. Instantiate classes dynamically without direct references
  4. Handle intermediate abstract classes (like WheeledVehicle) that don't define all attributes

Implementation Details

The ClassFamily system works by:

  1. Tracking all subclasses of the atriarch class
  2. Storing them in a list property called family
  3. 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:

  1. Create a base class that inherits from ClassFamily
  2. Define subclasses that inherit from this base class
  3. 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

  1. 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.

  2. 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.

  3. 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:

  1. Setup and Teardown: Performing setup actions before a method runs and cleanup actions afterward
  2. Logging and Monitoring: Adding logging or monitoring around method execution
  3. Transaction Management: Starting and committing/rolling back transactions
  4. Resource Management: Acquiring and releasing resources
  5. Validation: Validating inputs before execution and outputs after execution

Implementation Requirements

For a method to be "wrappable" with SuperWrapper:

  1. The parent class method must accept a method parameter as its first argument (after self)
  2. The parent class method must explicitly call the passed method: method(self, *args, **kwargs)
  3. 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:

  1. Interact with users through the UI
  2. Read from standard input streams
  3. Write to standard output and error streams
  4. Access configuration values
  5. 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

MethodDescription
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 use ConfigHandler.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

  1. Use WizLibTestCase: Inherit from WizLibTestCase to get access to the patching methods
  2. Test Commands Individually: Test each command's functionality in isolation
  3. Test Command Parsing: Test that arguments are correctly parsed and passed to commands
  4. Mock External Dependencies: Use Python's mock library to mock external dependencies
  5. Test Error Handling: Test how your application handles errors and edge cases
  6. Test UI Interactions: Test how your application interacts with users through the UI
  7. 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 just MyDynamo.

    # 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:

    1. Creating a command instance
    2. Injecting any mocked dependencies
    3. Calling the appropriate methods
    4. 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 of WizLibTestCase to avoid unexpected behaviors, especially when patching core components.

Development Workflow

  1. Define your app class with a clear name and required attributes
  2. Create the base command class
  3. Implement individual commands
  4. Write unit tests for each component
  5. Ensure high test coverage (typically 95% or higher)
  6. Create a sandbox configuration for development testing