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:
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")) # <span class="fv positive unit-money">$1,234.56</span>
# 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:
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:
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:
{
"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:
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:
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)
# <div class="financial-value positive unit-money above-threshold highlight important"
# data-raw-value="1500" data-unit="Money" data-id="revenue-2024">$1,500.00</div>
2. Template-Based Renderer
Create a renderer that uses string templates:
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():
amount = money(1234.56)
print(amount.render("text")) # $1,234.56
HtmlRenderer
Generates HTML with CSS classes for styling:
amount = money(-1234.56)
html = amount.render("html")
# <span class="fv negative unit-money">-$1,234.56</span>
# With custom options
html = amount.render("html",
css_classes="highlight error",
attributes={"data-field": "balance"},
tag="div")
# <div class="fv negative unit-money highlight error" data-field="balance">-$1,234.56</div>
CSS Classes Added:
fv- Always presentpositive/negative/none- Based on value stateunit-{unitname}- Based on unit type (e.g.,unit-money)percentage- For percentage values
MarkdownRenderer
Generates Markdown with optional formatting:
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
from metricengine.rendering import list_renderers
print(list_renderers()) # ['text', 'html', 'markdown', 'custom', ...]
Getting Renderer Instances
from metricengine.rendering import get_renderer
html_renderer = get_renderer("html")
result = html_renderer.render(amount, context={"css_classes": "highlight"})
Error Handling
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
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
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
Keep renderers focused: Each renderer should handle one output format well
Use context effectively: Allow customization through the context parameter
Handle edge cases: Always check for None values and missing data
Provide sensible defaults: Make renderers work without requiring context
Document context options: Clearly document what context parameters your renderer accepts
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 instancesFor 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.