"""Custom rendering system for FinancialValue instances.
This module provides a pluggable rendering system that allows FinancialValue
instances to be rendered in different formats (HTML, Markdown, plaintext, etc.)
without coupling the core library to any specific renderer.
The rendering system is unit-aware and can include currency symbols, unit codes,
and unit-specific formatting based on the FinancialValue's unit type.
Example:
>>> from metricengine.factories import money
>>> from metricengine.rendering import register_renderer
>>>
>>> # Register a custom HTML renderer
>>> class HtmlRenderer:
... def render(self, fv, *, context=None):
... cls = "negative" if fv.is_negative() else "positive"
... return f'<span class="amount {cls}">{fv.as_str()}</span>'
>>>
>>> register_renderer("html", HtmlRenderer())
>>>
>>> # Use the renderer
>>> amount = money(1234.56)
>>> html_output = amount.render("html")
>>> print(html_output) # <span class="amount positive">$1,234.56</span>
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
from .value import FinancialValue
__all__ = [
"Renderer",
"register_renderer",
"get_renderer",
"list_renderers",
"TextRenderer",
"HtmlRenderer",
"MarkdownRenderer",
"get_currency_symbol",
"get_unit_display_info",
]
# Currency symbol mapping for Money units
CURRENCY_SYMBOLS = {
"USD": "$",
"EUR": "€",
"GBP": "£",
"JPY": "¥",
"CNY": "¥",
"KRW": "₩",
"INR": "₹",
"CAD": "C$",
"AUD": "A$",
"CHF": "CHF",
"SEK": "kr",
"NOK": "kr",
"DKK": "kr",
"PLN": "zł",
"CZK": "Kč",
"HUF": "Ft",
"RUB": "₽",
"BRL": "R$",
"MXN": "$",
"ZAR": "R",
"SGD": "S$",
"HKD": "HK$",
"NZD": "NZ$",
"THB": "฿",
"TRY": "₺",
"ILS": "₪",
"AED": "د.إ",
"SAR": "﷼",
}
def get_currency_symbol(currency_code: str) -> str:
"""Get the currency symbol for a given currency code.
Args:
currency_code: ISO 4217 currency code (e.g., "USD", "EUR")
Returns:
Currency symbol if known, otherwise the currency code itself
Example:
>>> get_currency_symbol("USD")
'$'
>>> get_currency_symbol("XYZ") # Unknown currency
'XYZ'
"""
return CURRENCY_SYMBOLS.get(currency_code.upper(), currency_code)
def get_unit_display_info(fv: FinancialValue) -> dict[str, Any]:
"""Extract unit display information from a FinancialValue.
Args:
fv: FinancialValue instance to extract unit info from
Returns:
Dictionary containing unit display information:
- unit_type: "money", "quantity", "percent", or None
- unit_code: The unit code (e.g., "USD", "kg", "ratio")
- unit_category: The unit category (e.g., "Money", "Quantity")
- symbol: Currency symbol for money units, unit code for others
- css_class: CSS class name for the unit type
Example:
>>> from metricengine.units import MoneyUnit
>>> fv = FinancialValue(100, unit=MoneyUnit("USD"))
>>> info = get_unit_display_info(fv)
>>> info["symbol"]
'$'
>>> info["unit_type"]
'money'
"""
if not hasattr(fv, "unit") or fv.unit is None:
return {
"unit_type": None,
"unit_code": None,
"unit_category": None,
"symbol": None,
"css_class": None,
}
# Handle new Unit system (NewUnit dataclass)
if hasattr(fv.unit, "category") and hasattr(fv.unit, "code"):
category = fv.unit.category
code = fv.unit.code
if category == "Money":
return {
"unit_type": "money",
"unit_code": code,
"unit_category": category,
"symbol": get_currency_symbol(code),
"css_class": "money",
}
elif category == "Quantity":
return {
"unit_type": "quantity",
"unit_code": code,
"unit_category": category,
"symbol": code,
"css_class": "quantity",
}
elif category == "Percent":
return {
"unit_type": "percent",
"unit_code": code,
"unit_category": category,
"symbol": "%" if code == "ratio" else code,
"css_class": "percent",
}
else:
return {
"unit_type": "custom",
"unit_code": code,
"unit_category": category,
"symbol": code,
"css_class": "custom",
}
# Handle legacy unit system (class-based)
unit_name = getattr(fv.unit, "__name__", str(fv.unit)).lower()
if "money" in unit_name:
# Try to get currency code from unit
currency_code = getattr(fv.unit, "code", "USD")
return {
"unit_type": "money",
"unit_code": currency_code,
"unit_category": "Money",
"symbol": get_currency_symbol(currency_code),
"css_class": "money",
}
elif "percent" in unit_name:
return {
"unit_type": "percent",
"unit_code": "percent",
"unit_category": "Percent",
"symbol": "%",
"css_class": "percent",
}
elif "ratio" in unit_name:
return {
"unit_type": "ratio",
"unit_code": "ratio",
"unit_category": "Ratio",
"symbol": "",
"css_class": "ratio",
}
else:
return {
"unit_type": "dimensionless",
"unit_code": unit_name,
"unit_category": "Dimensionless",
"symbol": "",
"css_class": "dimensionless",
}
[docs]
@runtime_checkable
class Renderer(Protocol):
"""Protocol for custom FinancialValue renderers.
Renderers must implement a render method that takes a FinancialValue
and optional context, returning a string representation.
"""
[docs]
def render(
self, fv: FinancialValue, *, context: dict[str, Any] | None = None
) -> str:
"""Render a FinancialValue to a string.
Args:
fv: The FinancialValue to render
context: Optional context dictionary for rendering customization
Returns:
String representation of the FinancialValue
"""
...
# Global renderer registry
_renderers: dict[str, Renderer] = {}
[docs]
def register_renderer(name: str, renderer: Renderer) -> None:
"""Register a renderer with the given name.
Args:
name: Unique name for the renderer (e.g., "html", "markdown")
renderer: Renderer instance implementing the Renderer protocol
Raises:
TypeError: If renderer doesn't implement the Renderer protocol
Example:
>>> class CustomRenderer:
... def render(self, fv, *, context=None):
... return f"Custom: {fv.as_str()}"
>>> register_renderer("custom", CustomRenderer())
"""
if not isinstance(renderer, Renderer):
raise TypeError(
f"Renderer must implement the Renderer protocol, got {type(renderer)}"
)
_renderers[name] = renderer
[docs]
def get_renderer(name: str) -> Renderer:
"""Get a registered renderer by name.
Args:
name: Name of the renderer to retrieve
Returns:
The registered renderer instance
Raises:
KeyError: If no renderer is registered with the given name
Example:
>>> renderer = get_renderer("html")
>>> output = renderer.render(my_value)
"""
if name not in _renderers:
raise KeyError(
f"No renderer registered with name '{name}'. Available: {list(_renderers.keys())}"
)
return _renderers[name]
[docs]
def list_renderers() -> list[str]:
"""List all registered renderer names.
Returns:
List of registered renderer names
Example:
>>> list_renderers()
['text', 'html', 'markdown']
"""
return list(_renderers.keys())
# Built-in renderers
class TextRenderer:
"""Default text renderer that uses the standard as_str() method with optional unit symbols."""
def render(
self, fv: FinancialValue, *, context: dict[str, Any] | None = None
) -> str:
"""Render as plain text with optional unit symbol inclusion.
The context can contain:
- 'include_symbol': Whether to include currency/unit symbols (default: False)
- 'symbol_position': 'prefix' or 'suffix' for symbol placement (default: 'prefix')
"""
context = context or {}
base_text = fv.as_str()
# Check if we should include unit symbols
if not context.get("include_symbol", False):
return base_text
# Get unit display information
unit_info = get_unit_display_info(fv)
if unit_info["symbol"] and unit_info["unit_type"] == "money":
symbol = unit_info["symbol"]
position = context.get("symbol_position", "prefix")
# Don't add symbol if it's already in the formatted text
if symbol in base_text:
return base_text
if position == "prefix":
return f"{symbol}{base_text}"
else:
return f"{base_text} {symbol}"
return base_text
class HtmlRenderer:
"""HTML renderer that wraps values in styled spans with unit-aware attributes."""
def render(
self, fv: FinancialValue, *, context: dict[str, Any] | None = None
) -> str:
"""Render as HTML with CSS classes and data attributes for styling.
The context can contain:
- 'css_classes': Additional CSS classes to add
- 'attributes': Additional HTML attributes as a dict
- 'tag': HTML tag to use (default: 'span')
- 'include_symbol': Whether to include currency/unit symbols in display (default: False)
- 'symbol_position': 'prefix' or 'suffix' for symbol placement (default: 'prefix')
"""
context = context or {}
# Get unit display information
unit_info = get_unit_display_info(fv)
# Determine base CSS classes
classes = ["fv"]
if fv.is_none():
classes.append("none")
else:
# Check if negative by comparing decimal value
decimal_val = fv.as_decimal()
if decimal_val is not None and decimal_val < 0:
classes.append("negative")
else:
classes.append("positive")
# Add unit-specific classes
if unit_info["css_class"]:
classes.append(f"unit-{unit_info['css_class']}")
# Add percentage class if applicable
if fv.is_percentage() or unit_info["unit_type"] == "percent":
classes.append("percentage")
# Add custom classes from context
if "css_classes" in context:
if isinstance(context["css_classes"], str):
classes.extend(context["css_classes"].split())
elif isinstance(context["css_classes"], (list, tuple)):
classes.extend(context["css_classes"])
# Build data attributes
attrs = []
# Add unit information as data attributes
if unit_info["unit_type"]:
attrs.append(f'data-unit-type="{unit_info["unit_type"]}"')
if unit_info["unit_code"]:
attrs.append(f'data-unit-code="{unit_info["unit_code"]}"')
if unit_info["unit_category"]:
attrs.append(f'data-unit-category="{unit_info["unit_category"]}"')
# Add currency-specific data attributes for money values
if unit_info["unit_type"] == "money":
# Prioritize policy currency over unit currency for legacy compatibility
currency_code = unit_info["unit_code"]
currency_symbol = unit_info["symbol"]
# Check if policy has currency information and override if present
if fv.unit and hasattr(fv, "policy") and fv.policy:
if hasattr(fv.policy, "display") and fv.policy.display:
currency_code = fv.policy.display.currency
currency_symbol = get_currency_symbol(currency_code)
elif (
hasattr(fv.policy, "currency_symbol") and fv.policy.currency_symbol
):
currency_symbol = fv.policy.currency_symbol
attrs.append(f'data-currency="{currency_code}"')
if currency_symbol:
attrs.append(f'data-currency-symbol="{currency_symbol}"')
# Also add unit-specific currency info if different from policy
if unit_info["unit_code"] != currency_code:
attrs.append(f'data-unit-currency="{unit_info["unit_code"]}"')
# Legacy support: Add additional policy attributes
if fv.unit and hasattr(fv, "policy") and fv.policy:
if hasattr(fv.policy, "display") and fv.policy.display:
attrs.append(f'data-policy-currency="{fv.policy.display.currency}"')
elif hasattr(fv.policy, "currency_symbol") and fv.policy.currency_symbol:
attrs.append(
f'data-policy-currency-symbol="{fv.policy.currency_symbol}"'
)
# Add custom attributes from context
if "attributes" in context:
for key, value in context["attributes"].items():
attrs.append(f'{key}="{value}"')
# Determine tag
tag = context.get("tag", "span")
# Get display text (potentially with symbol)
display_text = self._get_display_text(fv, unit_info, context)
# Build the HTML
class_attr = f'class="{" ".join(classes)}"'
all_attrs = " ".join([class_attr] + attrs)
return f"<{tag} {all_attrs}>{display_text}</{tag}>"
def _get_display_text(
self, fv: FinancialValue, unit_info: dict[str, Any], context: dict[str, Any]
) -> str:
"""Get the display text for the FinancialValue, optionally including unit symbols."""
base_text = fv.as_str()
# Check if we should include unit symbols
if not context.get("include_symbol", False):
return base_text
# Only add symbols for money units to avoid duplication
if unit_info["unit_type"] == "money" and unit_info["symbol"]:
symbol = unit_info["symbol"]
position = context.get("symbol_position", "prefix")
# Don't add symbol if it's already in the formatted text
if symbol in base_text:
return base_text
if position == "prefix":
return f"{symbol}{base_text}"
else:
return f"{base_text} {symbol}"
return base_text
class MarkdownRenderer:
"""Markdown renderer for financial values with unit-aware formatting."""
def render(
self, fv: FinancialValue, *, context: dict[str, Any] | None = None
) -> str:
"""Render as Markdown with optional formatting.
The context can contain:
- 'bold': Make negative values bold (default: True)
- 'italic': Make percentage values italic (default: False)
- 'code': Wrap in code blocks (default: False)
- 'include_symbol': Whether to include currency/unit symbols (default: False)
- 'symbol_position': 'prefix' or 'suffix' for symbol placement (default: 'prefix')
"""
context = context or {}
# Get unit display information
unit_info = get_unit_display_info(fv)
# Get base text (potentially with symbol)
text = self._get_display_text(fv, unit_info, context)
# Apply formatting based on context and value properties
if context.get("code", False):
text = f"`{text}`"
# Make percentages italic if requested
if context.get("italic", False) and (
fv.is_percentage() or unit_info["unit_type"] == "percent"
):
text = f"*{text}*"
# Check if negative by comparing decimal value
if context.get("bold", True) and not fv.is_none():
decimal_val = fv.as_decimal()
if decimal_val is not None and decimal_val < 0:
text = f"**{text}**"
return text
def _get_display_text(
self, fv: FinancialValue, unit_info: dict[str, Any], context: dict[str, Any]
) -> str:
"""Get the display text for the FinancialValue, optionally including unit symbols."""
base_text = fv.as_str()
# Check if we should include unit symbols
if not context.get("include_symbol", False):
return base_text
# Only add symbols for money units to avoid duplication
if unit_info["unit_type"] == "money" and unit_info["symbol"]:
symbol = unit_info["symbol"]
position = context.get("symbol_position", "prefix")
# Don't add symbol if it's already in the formatted text
if symbol in base_text:
return base_text
if position == "prefix":
return f"{symbol}{base_text}"
else:
return f"{base_text} {symbol}"
return base_text
# Register built-in renderers
register_renderer("text", TextRenderer())
register_renderer("html", HtmlRenderer())
register_renderer("markdown", MarkdownRenderer())