Publishing Guide: How to Expose Your Library with smpub
This guide shows you how to take an existing Python library and expose it via CLI and API using smpub.
Table of Contents
Overview
Goal: Take an existing library and make it accessible via:
Command-line interface (CLI)
HTTP API with OpenAPI documentation (planned)
Interactive parameter prompting
The smpub Pattern:
Wrap your library in Handler classes
Create a Publisher application that registers handlers
Choose exposure modes (CLI, API, or both)
Prerequisites
Your existing library should have:
Well-defined functions/methods with clear responsibilities
Type hints on parameters (for automatic validation)
Docstrings (for auto-generated help)
Example library to publish (mylib/calculator.py):
"""Calculator library - business logic."""
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
def multiply(x: float, y: float, precision: int = 2) -> float:
"""Multiply two numbers with optional precision."""
result = x * y
return round(result, precision)
def divide(numerator: float, denominator: float) -> float:
"""Divide two numbers."""
if denominator == 0:
raise ValueError("Cannot divide by zero")
return numerator / denominator
Step-by-Step Guide
Step 1: Install smpub
pip install smartpublisher
# Optional: for interactive mode
brew install dialog # macOS
Step 2: Create Handler Classes
Create handlers that wrap your library’s functionality. Place them in a handlers/ directory.
File: myapp/handlers/calculator_handler.py
"""Handler that exposes calculator functions via smpub."""
from smartroute import Router, route
from smartpublisher import PublishedClass
# Import your existing library
from mylib import calculator
class CalculatorHandler(PublishedClass):
"""
Handler for calculator operations.
Wraps the calculator library and exposes it via CLI/API.
"""
# Define slots (optional, but recommended for memory efficiency)
__slots__ = ('history',)
# Create API router
api = Router(name='calc')
def __init__(self):
"""Initialize handler with empty history."""
self.history = []
@api
def calc_add(self, a: int, b: int) -> int:
"""
Add two integers.
Args:
a: First number
b: Second number
Returns:
Sum of a and b
"""
result = calculator.add(a, b)
self.history.append(f"add({a}, {b}) = {result}")
return result
@api
def calc_multiply(self, x: float, y: float, precision: int = 2) -> float:
"""
Multiply two numbers with optional precision.
Args:
x: First number
y: Second number
precision: Decimal places (default: 2)
Returns:
Product rounded to precision
"""
result = calculator.multiply(x, y, precision)
self.history.append(f"multiply({x}, {y}) = {result}")
return result
@api
def calc_divide(self, numerator: float, denominator: float) -> float:
"""
Divide two numbers.
Args:
numerator: Number to divide
denominator: Number to divide by
Returns:
Result of division
Raises:
ValueError: If denominator is zero
"""
result = calculator.divide(numerator, denominator)
self.history.append(f"divide({numerator}, {denominator}) = {result}")
return result
@api
def calc_history(self) -> list:
"""
Get calculation history.
Returns:
List of previous calculations
"""
return self.history
@api
def calc_clear(self) -> str:
"""
Clear calculation history.
Returns:
Confirmation message
"""
self.history.clear()
return "History cleared"
Step 3: Create Publisher Application
File: myapp/main.py
"""Main application that publishes handlers."""
from smartpublisher import Publisher
from myapp.handlers.calculator_handler import CalculatorHandler
class CalculatorApp(Publisher):
"""
Calculator application.
Exposes calculator functionality via CLI and API.
"""
def initialize(self):
"""
Initialize and register handlers.
This method is called automatically by Publisher.__init__()
"""
# Create handler instances
self.calculator = CalculatorHandler()
# Publish handlers with exposure control
self.publish(
'calc', # Handler name (used in CLI)
self.calculator, # Handler instance
cli=True, # Enable CLI access
openapi=True, # Enable API access (future)
cli_name='calc', # Optional: override CLI name
http_path='/api/calc' # Optional: API path
)
def main():
"""Entry point for the application."""
app = CalculatorApp()
app.run() # Auto-detects CLI or HTTP mode
if __name__ == "__main__":
main()
Step 4: Create Entry Point
File: pyproject.toml (add to your project)
[project.scripts]
mycalc = "myapp.main:main"
Or create a simple script:
File: mycalc (make executable with chmod +x mycalc)
#!/usr/bin/env python
"""Calculator CLI entry point."""
from myapp.main import main
if __name__ == "__main__":
main()
Step 5: Test Your CLI
# Install in development mode
pip install -e .
# Test the CLI
mycalc --help
# Shows: CalculatorApp with 'calc' handler
mycalc calc --help
# Shows: All calculator methods with parameters
mycalc calc add 10 20
# Output: 30
mycalc calc multiply 3.5 2.0
# Output: 7.0
mycalc calc multiply 3.5 2.0 3
# Output: 7.000 (3 decimal places)
# Interactive mode
mycalc calc divide --interactive
# Prompts for numerator and denominator
# Check history
mycalc calc history
# Output: List of calculations
Handler Requirements
Required Elements
Router API definition:
api = Router(name='handler')
Type-hinted methods:
@api def handler_method(self, param: int, optional: str = "default") -> str: """Docstring for help text.""" pass
Clear docstrings (for auto-generated help):
""" One-line summary. Longer description (optional). Args: param: Description Returns: Description """
Optional but Recommended
Use PublishedClass when using
__slots__:from smartpublisher import PublishedClass class MyHandler(PublishedClass): __slots__ = ('data', 'config') # Your slots # 'publisher' slot provided by PublishedClass
Prefix convention: Use prefix that matches handler purpose:
user_for user operationsfile_for file operationsdb_for database operations
State management: Store handler state in instance variables:
def __init__(self): self.cache = {} self.connections = []
Best Practices
1. Separation of Concerns
✅ Good: Thin handlers that delegate to business logic
class DataHandler(PublishedClass):
api = Router(name='data')
def __init__(self):
self.processor = DataProcessor() # Your library
@api
def data_process(self, input_file: str, format: str = 'json'):
"""Process data file."""
# Validate with Pydantic (automatic)
# Delegate to business logic
result = self.processor.process(input_file, format)
return result
❌ Bad: Business logic mixed in handler
class DataHandler(PublishedClass):
api = Router(name='data')
@api
def data_process(self, input_file: str, format: str = 'json'):
# Avoid putting complex logic here
with open(input_file) as f:
data = json.load(f)
# ... complex processing ...
return processed_data
2. Type Hints for Free Validation
✅ Good: Use specific types
@api
def calc_power(self, base: float, exponent: int, modulo: int = None) -> float:
"""Calculate power with optional modulo."""
result = pow(base, exponent, modulo)
return result
Benefits:
Automatic validation:
"10"→10(int)Clear error messages on invalid input
Type information in help text
3. Clear Documentation
✅ Good: Comprehensive docstrings
@api
def user_create(self, username: str, email: str, role: str = 'user') -> dict:
"""
Create a new user account.
Creates a user with the specified credentials and assigns
the given role. Email must be unique.
Args:
username: Unique username (3-20 chars)
email: Valid email address
role: User role (default: 'user', options: 'user', 'admin')
Returns:
Dictionary with user details and ID
Raises:
ValueError: If email already exists
Example:
>>> user_create("alice", "alice@example.com", "admin")
{"id": 1, "username": "alice", "role": "admin"}
"""
return self.user_service.create(username, email, role)
4. Error Handling
✅ Good: Let exceptions bubble up with clear messages
@api
def file_read(self, path: str) -> str:
"""Read file contents."""
if not os.path.exists(path):
raise FileNotFoundError(f"File not found: {path}")
try:
with open(path) as f:
return f.read()
except PermissionError:
raise PermissionError(f"Cannot read file: {path} (permission denied)")
smpub will catch and display exceptions properly in CLI.
5. Handler Organization
For simple apps: Single handler file
myapp/
├── main.py
└── handler.py
For complex apps: Multiple handlers by domain
myapp/
├── main.py
└── handlers/
├── __init__.py
├── user_handler.py
├── file_handler.py
└── database_handler.py
6. Publisher Best Practices
✅ Good: Descriptive names and exposure control
def initialize(self):
# Public handlers (CLI + API)
self.users = UserHandler()
self.publish('users', self.users, cli=True, openapi=True)
# Internal handlers (CLI only)
self.admin = AdminHandler()
self.publish('admin', self.admin, cli=True, openapi=False)
# Monitoring (neither CLI nor API)
self.metrics = MetricsHandler()
self.publish('metrics', self.metrics, cli=False, openapi=False)
Common Patterns
Pattern 1: Database Operations
class DatabaseHandler(PublishedClass):
__slots__ = ('db', 'connection')
api = Router(name='db')
def __init__(self, connection_string: str):
self.connection_string = connection_string
self.connection = None
@api
def db_connect(self) -> str:
"""Connect to database."""
self.connection = create_connection(self.connection_string)
return "Connected"
@api
def db_query(self, sql: str, params: str = None) -> list:
"""Execute SQL query."""
if not self.connection:
raise RuntimeError("Not connected. Run 'db connect' first")
return self.connection.execute(sql, params)
Pattern 2: File Operations
class FileHandler(PublishedClass):
__slots__ = ('current_dir',)
api = Router(name='file')
def __init__(self):
self.current_dir = os.getcwd()
@api
def file_list(self, pattern: str = '*') -> list:
"""List files matching pattern."""
return glob.glob(os.path.join(self.current_dir, pattern))
@api
def file_read(self, filename: str, encoding: str = 'utf-8') -> str:
"""Read file contents."""
path = os.path.join(self.current_dir, filename)
with open(path, encoding=encoding) as f:
return f.read()
Pattern 3: Service Integration
class APIHandler(PublishedClass):
__slots__ = ('client', 'api_key')
api = Router(name='api')
def __init__(self, api_key: str):
self.api_key = api_key
self.client = APIClient(api_key)
@api
def api_get(self, endpoint: str, params: str = None) -> dict:
"""Make GET request to API."""
return self.client.get(endpoint, params)
@api
def api_post(self, endpoint: str, data: str) -> dict:
"""Make POST request to API."""
return self.client.post(endpoint, json.loads(data))
Complete Example
Here’s a complete working example that wraps a hypothetical data processing library:
File: myapp/handlers/data_handler.py
"""Handler for data processing operations."""
from smartroute import Router, route
from smartpublisher import PublishedClass
from mylib.processor import DataProcessor
from mylib.validators import SchemaValidator
class DataHandler(PublishedClass):
"""
Data processing handler.
Provides CLI/API access to data processing operations.
"""
__slots__ = ('processor', 'validator', 'cache')
api = Router(name='data')
def __init__(self, cache_size: int = 100):
"""
Initialize data handler.
Args:
cache_size: Maximum number of cached results
"""
self.processor = DataProcessor()
self.validator = SchemaValidator()
self.cache = {}
@api
def data_load(self, filename: str, format: str = 'json') -> dict:
"""
Load data file.
Args:
filename: Path to data file
format: File format (json, csv, xml)
Returns:
Parsed data as dictionary
"""
if filename in self.cache:
return self.cache[filename]
data = self.processor.load(filename, format)
self.cache[filename] = data
return data
@api
def data_transform(self, input_file: str, output_file: str,
operations: str) -> str:
"""
Transform data file.
Args:
input_file: Source file
output_file: Destination file
operations: Comma-separated operations (e.g., "sort,dedupe,validate")
Returns:
Success message with statistics
"""
data = self.data_load(input_file)
ops = [op.strip() for op in operations.split(',')]
for op in ops:
data = self.processor.apply_operation(data, op)
self.processor.save(data, output_file)
return f"Processed {len(data)} records with {len(ops)} operations"
@api
def data_validate(self, filename: str, schema: str) -> dict:
"""
Validate data against schema.
Args:
filename: Data file to validate
schema: Path to schema file
Returns:
Validation results with errors and warnings
"""
data = self.data_load(filename)
results = self.validator.validate(data, schema)
return {
'valid': results.is_valid,
'errors': results.errors,
'warnings': results.warnings
}
@api
def data_stats(self, filename: str) -> dict:
"""
Get statistics about data file.
Args:
filename: Data file to analyze
Returns:
Statistics dictionary
"""
data = self.data_load(filename)
return {
'records': len(data),
'fields': list(data[0].keys()) if data else [],
'size_mb': self.processor.get_size(filename) / (1024 * 1024)
}
File: myapp/main.py
"""Main application."""
from smartpublisher import Publisher
from myapp.handlers.data_handler import DataHandler
class DataApp(Publisher):
"""Data processing application."""
def initialize(self):
"""Register handlers."""
self.data = DataHandler(cache_size=50)
self.publish('data', self.data, cli=True, openapi=True)
def main():
"""Entry point."""
app = DataApp()
app.run()
if __name__ == "__main__":
main()
Usage:
# Load and process data
dataapp data load input.json
dataapp data transform input.json output.json "sort,dedupe,validate"
dataapp data validate output.json schema.json
dataapp data stats output.json
# Interactive mode
dataapp data transform --interactive
# Prompts for: input_file, output_file, operations
Summary
Key Takeaways:
Handlers wrap business logic - Keep them thin
Use type hints - Get free validation
Write clear docstrings - Auto-generated help
Choose exposure carefully - CLI, API, or both
Let smpub handle - Validation, errors, help generation
Next Steps:
Read the Architecture documentation
Check the test files for implementation examples
See examples for working code
Questions? Open an issue at https://github.com/genropy/smartpublisher/issues