# How to Create Custom Renderers
This guide shows you how to create and use custom renderers for FinancialValue instances, allowing you to output values in different formats like HTML, Markdown, JSON, or any custom format.
## Quick Start
### Using Built-in Renderers
Metric Engine comes with three built-in renderers:
```python
from metricengine.factories import money
amount = money(1234.56)
# Text rendering (default)
print(amount.render("text")) # $1,234.56
print(amount.render()) # $1,234.56 (text is default)
# HTML rendering
print(amount.render("html")) # $1,234.56
# Markdown rendering
negative_amount = money(-500)
print(negative_amount.render("markdown")) # **-$500.00** (bold for negatives)
```
## Creating Custom Renderers
### 1. Basic Custom Renderer
Create a class that implements the `Renderer` protocol:
```python
from metricengine.rendering import register_renderer
class CompactRenderer:
"""Renderer that shows values in compact notation (K, M, B)."""
def render(self, fv, *, context=None):
if fv.is_none():
return "N/A"
value = fv.as_decimal()
# Determine if this is a money value for currency symbol
is_money = fv.unit and getattr(fv.unit, '__name__', '') == 'Money'
currency_symbol = "$" if is_money else ""
if abs(value) >= 1_000_000_000:
return f"{currency_symbol}{value / 1_000_000_000:.1f}B"
elif abs(value) >= 1_000_000:
return f"{currency_symbol}{value / 1_000_000:.1f}M"
elif abs(value) >= 1_000:
return f"{currency_symbol}{value / 1_000:.1f}K"
else:
return fv.as_str()
# Register the renderer
register_renderer("compact", CompactRenderer())
# Use it
revenue = money(1_234_567)
print(revenue.render("compact")) # $1.2M
large_amount = money(2_500_000_000)
print(large_amount.render("compact")) # $2.5B
small_amount = money(500)
print(small_amount.render("compact")) # 500.00
```
### 2. JSON Renderer
Create a renderer that outputs structured data:
```python
import json
from metricengine.rendering import register_renderer
class JsonRenderer:
"""Renderer that outputs FinancialValue as JSON."""
def render(self, fv, *, context=None):
data = {
"value": str(fv.as_decimal()) if not fv.is_none() else None,
"formatted": fv.as_str(),
"unit": fv.unit.__name__ if fv.unit else None,
"is_negative": fv.is_negative(),
"is_percentage": fv.is_percentage(),
"is_none": fv.is_none(),
}
# Add context data if provided
if context:
data["context"] = context
return json.dumps(data, indent=2)
register_renderer("json", JsonRenderer())
# Usage
amount = money(1234.56)
print(amount.render("json"))
```
Output:
```json
{
"value": "1234.56",
"formatted": "$1,234.56",
"unit": "Money",
"is_negative": false,
"is_percentage": false,
"is_none": false
}
```
### 3. CSV Renderer
Create a renderer for tabular data:
```python
class CsvRenderer:
"""Renderer that outputs FinancialValue as CSV row."""
def render(self, fv, *, context=None):
context = context or {}
separator = context.get("separator", ",")
include_headers = context.get("include_headers", False)
# Define the fields
fields = [
str(fv.as_decimal()) if not fv.is_none() else "",
fv.as_str(),
fv.unit.__name__ if fv.unit else "",
str(fv.is_negative()).lower(),
str(fv.is_percentage()).lower(),
]
result = separator.join(fields)
if include_headers:
headers = ["raw_value", "formatted", "unit", "is_negative", "is_percentage"]
header_row = separator.join(headers)
result = f"{header_row}\n{result}"
return result
register_renderer("csv", CsvRenderer())
# Usage
amounts = [money(1234.56), money(-500), percent(15.5, input="percent")]
# With headers
print(amounts[0].render("csv", include_headers=True))
# raw_value,formatted,unit,is_negative,is_percentage
# 1234.56,$1,234.56,Money,false,false
# Custom separator
for amount in amounts:
print(amount.render("csv", separator="|"))
```
## Advanced Rendering Techniques
### 1. Context-Aware HTML Renderer
Create a renderer that uses context for advanced styling:
```python
class AdvancedHtmlRenderer:
"""Advanced HTML renderer with context-aware styling."""
def render(self, fv, *, context=None):
context = context or {}
# Base classes
classes = ["financial-value"]
# Add state classes
if fv.is_none():
classes.append("null-value")
elif fv.is_negative():
classes.append("negative")
else:
classes.append("positive")
# Add unit classes
if fv.unit:
unit_name = fv.unit.__name__.lower()
classes.append(f"unit-{unit_name}")
# Add threshold-based classes
threshold = context.get("threshold")
if threshold and not fv.is_none():
value = fv.as_decimal()
if value > threshold:
classes.append("above-threshold")
elif value < -threshold:
classes.append("below-threshold")
# Add custom classes
custom_classes = context.get("css_classes", [])
if isinstance(custom_classes, str):
custom_classes = custom_classes.split()
classes.extend(custom_classes)
# Build attributes
attrs = [f'class="{" ".join(classes)}"']
# Add data attributes
if not fv.is_none():
attrs.append(f'data-raw-value="{fv.as_decimal()}"')
if fv.unit:
attrs.append(f'data-unit="{fv.unit.__name__}"')
# Add custom attributes
custom_attrs = context.get("attributes", {})
for key, value in custom_attrs.items():
attrs.append(f'{key}="{value}"')
# Choose tag
tag = context.get("tag", "span")
# Build final HTML
attr_string = " ".join(attrs)
return f'<{tag} {attr_string}>{fv.as_str()}{tag}>'
register_renderer("advanced_html", AdvancedHtmlRenderer())
# Usage
amount = money(1500)
html = amount.render("advanced_html",
threshold=1000,
css_classes="highlight important",
attributes={"data-id": "revenue-2024"},
tag="div")
print(html)
#
$1,500.00
```
### 2. Template-Based Renderer
Create a renderer that uses string templates:
```python
from string import Template
class TemplateRenderer:
"""Renderer that uses string templates for flexible formatting."""
def __init__(self, template_string=None):
self.default_template = template_string or "${formatted}"
def render(self, fv, *, context=None):
context = context or {}
template_string = context.get("template", self.default_template)
template = Template(template_string)
# Prepare template variables
variables = {
"formatted": fv.as_str(),
"raw": str(fv.as_decimal()) if not fv.is_none() else "null",
"unit": fv.unit.__name__ if fv.unit else "none",
"sign": "-" if fv.is_negative() else "+",
"abs_formatted": fv.abs().as_str() if not fv.is_none() else fv.as_str(),
}
# Add context variables
variables.update(context.get("variables", {}))
return template.substitute(variables)
# Register with different default templates
register_renderer("template", TemplateRenderer())
register_renderer("accounting", TemplateRenderer("${unit}: ${formatted}"))
register_renderer("debug", TemplateRenderer("${formatted} (raw: ${raw}, unit: ${unit})"))
# Usage
amount = money(1234.56)
# Custom template
result = amount.render("template",
template="Amount: ${formatted} [${unit}]")
print(result) # Amount: $1,234.56 [Money]
# With variables
result = amount.render("template",
template="Q${quarter} Revenue: ${formatted}",
variables={"quarter": "4"})
print(result) # Q4 Revenue: $1,234.56
```
## Built-in Renderer Details
### TextRenderer
The simplest renderer that just calls `as_str()`:
```python
amount = money(1234.56)
print(amount.render("text")) # $1,234.56
```
### HtmlRenderer
Generates HTML with CSS classes for styling:
```python
amount = money(-1234.56)
html = amount.render("html")
# -$1,234.56
# With custom options
html = amount.render("html",
css_classes="highlight error",
attributes={"data-field": "balance"},
tag="div")
# -$1,234.56
```
**CSS Classes Added:**
- `fv` - Always present
- `positive` / `negative` / `none` - Based on value state
- `unit-{unitname}` - Based on unit type (e.g., `unit-money`)
- `percentage` - For percentage values
### MarkdownRenderer
Generates Markdown with optional formatting:
```python
negative_amount = money(-500)
percentage = percent(15.5, input="percent")
# Default (bold negatives)
print(negative_amount.render("markdown")) # **-$500.00**
# Custom formatting
print(percentage.render("markdown", italic=True)) # *15.50%*
print(amount.render("markdown", code=True)) # `$1,234.56`
# Combined formatting
print(negative_amount.render("markdown", bold=True, code=True)) # `**-$500.00**`
```
## Renderer Management
### Listing Available Renderers
```python
from metricengine.rendering import list_renderers
print(list_renderers()) # ['text', 'html', 'markdown', 'custom', ...]
```
### Getting Renderer Instances
```python
from metricengine.rendering import get_renderer
html_renderer = get_renderer("html")
result = html_renderer.render(amount, context={"css_classes": "highlight"})
```
### Error Handling
```python
try:
result = amount.render("nonexistent")
except KeyError as e:
print(f"Renderer not found: {e}")
# Fallback to text
result = amount.render("text")
```
## Real-World Examples
### 1. Financial Report Generator
```python
class ReportRenderer:
"""Renderer for financial reports."""
def render(self, fv, *, context=None):
context = context or {}
report_type = context.get("report_type", "summary")
if report_type == "summary":
return f"{fv.as_str()}"
elif report_type == "detailed":
return f"{fv.as_str()} ({fv.unit.__name__ if fv.unit else 'N/A'})"
elif report_type == "audit":
return f"{fv.as_str()} [Raw: {fv.as_decimal()}, Policy: {fv.policy.decimal_places}dp]"
else:
return fv.as_str()
register_renderer("report", ReportRenderer())
# Usage in reports
revenue = money(1500000)
expenses = money(1200000)
profit = revenue - expenses
print("Financial Summary:")
print(f"Revenue: {revenue.render('report', report_type='summary')}")
print(f"Expenses: {expenses.render('report', report_type='summary')}")
print(f"Profit: {profit.render('report', report_type='detailed')}")
```
### 2. Web API Serializer
```python
class ApiRenderer:
"""Renderer for web API responses."""
def render(self, fv, *, context=None):
context = context or {}
data = {
"value": str(fv.as_decimal()) if not fv.is_none() else None,
"display": fv.as_str(),
"currency": None,
"unit_type": fv.unit.__name__ if fv.unit else None,
}
# Add currency info for money values
if fv.unit and fv.unit.__name__ == "Money":
if fv.policy and fv.policy.display:
data["currency"] = fv.policy.display.currency
elif fv.policy and fv.policy.currency_symbol:
data["currency"] = fv.policy.currency_symbol
# Add API metadata
if context.get("include_metadata", False):
data["metadata"] = {
"is_negative": fv.is_negative(),
"is_percentage": fv.is_percentage(),
"decimal_places": fv.policy.decimal_places if fv.policy else None,
}
return json.dumps(data)
register_renderer("api", ApiRenderer())
# Usage
amount = money(1234.56)
api_response = amount.render("api", include_metadata=True)
print(api_response)
```
## Best Practices
1. **Keep renderers focused**: Each renderer should handle one output format well
2. **Use context effectively**: Allow customization through the context parameter
3. **Handle edge cases**: Always check for None values and missing data
4. **Provide sensible defaults**: Make renderers work without requiring context
5. **Document context options**: Clearly document what context parameters your renderer accepts
6. **Test thoroughly**: Test with different value types, units, and edge cases
## Performance Considerations
- Renderers are called frequently, so keep them lightweight
- Cache expensive operations when possible
- Consider using `__slots__` for renderer classes if creating many instances
- For template-based renderers, compile templates once and reuse them
This rendering system gives you complete control over how financial values are displayed across different contexts and output formats.