Source code for viewtext.formatters

"""
Formatter registry for text output formatting.

This module provides the FormatterRegistry class with built-in formatters
for text, numbers, prices, dates, relative times, and template strings.
"""

from datetime import datetime
from typing import Any, Callable


[docs] class FormatterRegistry: """ Registry for value formatting functions. The formatter registry manages formatter functions that transform values into formatted strings. Includes built-in formatters for common use cases. Attributes ---------- _formatters : dict[str, Callable] Internal dictionary mapping formatter names to functions Examples -------- >>> registry = FormatterRegistry() >>> formatter = registry.get("price") >>> formatter(123.45, symbol="$", decimals=2) '$123.45' """
[docs] def __init__(self) -> None: """Initialize the formatter registry with built-in formatters.""" self._formatters: dict[str, Callable] = {} self._register_builtin_formatters()
def _register_builtin_formatters(self) -> None: """Register all built-in formatters.""" self.register("text", self._format_text) self.register("text_uppercase", self._format_text_uppercase) self.register("price", self._format_price) self.register("number", self._format_number) self.register("datetime", self._format_datetime) self.register("relative_time", self._format_relative_time) self.register("template", self._format_template)
[docs] def register(self, name: str, formatter: Callable) -> None: """ Register a formatter function. Parameters ---------- name : str The formatter name to register formatter : Callable A callable that takes a value and keyword arguments and returns a formatted string Examples -------- >>> registry = FormatterRegistry() >>> def custom_formatter(value, **kwargs): ... return f"Custom: {value}" >>> registry.register("custom", custom_formatter) """ self._formatters[name] = formatter
[docs] def get(self, name: str) -> Callable: """ Retrieve a registered formatter function. Parameters ---------- name : str The formatter name to retrieve Returns ------- Callable The formatter function Raises ------ ValueError If the formatter name is not registered Examples -------- >>> registry = FormatterRegistry() >>> formatter = registry.get("text") >>> formatter("hello", prefix=">> ") '>> hello' """ if name not in self._formatters: raise ValueError(f"Unknown formatter: {name}") return self._formatters[name]
@staticmethod def _format_text(value: Any, **kwargs: Any) -> str: """ Format value as text with optional prefix and suffix. Parameters ---------- value : Any The value to format **kwargs : Any prefix : str, optional String to prepend (default: "") suffix : str, optional String to append (default: "") Returns ------- str Formatted text string Examples -------- >>> FormatterRegistry._format_text("hello", prefix=">> ", suffix="!") '>> hello!' """ prefix = kwargs.get("prefix", "") suffix = kwargs.get("suffix", "") return f"{prefix}{str(value)}{suffix}" @staticmethod def _format_text_uppercase(value: Any, **kwargs: Any) -> str: """ Format value as uppercase text. Parameters ---------- value : Any The value to format **kwargs : Any Unused, provided for consistency Returns ------- str Uppercase text string Examples -------- >>> FormatterRegistry._format_text_uppercase("hello") 'HELLO' """ return str(value).upper() @staticmethod def _format_price(value: Any, **kwargs: Any) -> str: """ Format value as a price with currency symbol. Parameters ---------- value : Any The numeric value to format **kwargs : Any symbol : str, optional Currency symbol (default: "") decimals : int, optional Number of decimal places (default: 2) thousands_sep : str, optional Thousands separator (default: "") decimal_sep : str, optional Decimal separator (default: ".") symbol_position : str, optional "prefix" or "suffix" (default: "prefix") Returns ------- str Formatted price string, or empty string if value is None Examples -------- >>> FormatterRegistry._format_price(1234.56, symbol="$", decimals=2) '$1234.56' >>> FormatterRegistry._format_price(1234.56, symbol="€", decimals=2, ... symbol_position="suffix") '1234.56€' >>> FormatterRegistry._format_price(1234567.89, symbol="€", decimals=2, ... thousands_sep=".", decimal_sep=",") '€1.234.567,89' """ symbol = kwargs.get("symbol", "") decimals = kwargs.get("decimals", 2) thousands_sep = kwargs.get("thousands_sep", "") decimal_sep = kwargs.get("decimal_sep", ".") if value is None: return "" try: num_val = float(value) except (ValueError, TypeError): return str(value) if thousands_sep or decimal_sep != ".": formatted = f"{num_val:,.{decimals}f}" if decimal_sep != ".": formatted = formatted.replace(".", "\x00") if thousands_sep: formatted = formatted.replace(",", thousands_sep) else: formatted = formatted.replace(",", "") if decimal_sep != ".": formatted = formatted.replace("\x00", decimal_sep) else: formatted = f"{num_val:.{decimals}f}" if symbol: symbol_position = kwargs.get("symbol_position", "prefix") if symbol_position == "suffix": return f"{formatted}{symbol}" else: return f"{symbol}{formatted}" return formatted @staticmethod def _format_number(value: Any, **kwargs: Any) -> str: """ Format value as a number with optional prefix, suffix, and separators. Parameters ---------- value : Any The numeric value to format **kwargs : Any prefix : str, optional String to prepend (default: "") suffix : str, optional String to append (default: "") decimals : int, optional Number of decimal places (default: 0) thousands_sep : str, optional Thousands separator (default: "") decimal_sep : str, optional Decimal separator (default: ".") Returns ------- str Formatted number string, or empty string if value is None Examples -------- >>> FormatterRegistry._format_number(1234567, thousands_sep=",") '1,234,567' >>> FormatterRegistry._format_number(23.456, decimals=1, suffix="°C") '23.5°C' >>> FormatterRegistry._format_number(1234567.89, decimals=2, ... thousands_sep=".", decimal_sep=",") '1.234.567,89' """ prefix = kwargs.get("prefix", "") suffix = kwargs.get("suffix", "") decimals = kwargs.get("decimals", 0) thousands_sep = kwargs.get("thousands_sep", "") decimal_sep = kwargs.get("decimal_sep", ".") if value is None: return "" try: num_val = float(value) except (ValueError, TypeError): return str(value) if thousands_sep or decimal_sep != ".": formatted = f"{num_val:,.{decimals}f}" if decimal_sep != ".": formatted = formatted.replace(".", "\x00") if thousands_sep: formatted = formatted.replace(",", thousands_sep) else: formatted = formatted.replace(",", "") if decimal_sep != ".": formatted = formatted.replace("\x00", decimal_sep) else: formatted = f"{num_val:.{decimals}f}" return f"{prefix}{formatted}{suffix}" @staticmethod def _format_datetime(value: Any, **kwargs: Any) -> str: """ Format value as a datetime string. Parameters ---------- value : Any The datetime value to format (datetime, timestamp, or string) **kwargs : Any format : str, optional strftime format string (default: "%Y-%m-%d %H:%M:%S") Returns ------- str Formatted datetime string, or empty string if value is None Examples -------- >>> from datetime import datetime >>> dt = datetime(2023, 12, 25, 15, 30) >>> FormatterRegistry._format_datetime(dt, format="%Y-%m-%d") '2023-12-25' >>> FormatterRegistry._format_datetime(1703516400, format="%Y-%m-%d") '2023-12-25' """ format_str = kwargs.get("format", "%Y-%m-%d %H:%M:%S") if value is None: return "" if isinstance(value, datetime): return value.strftime(format_str) elif isinstance(value, (int, float)): return datetime.fromtimestamp(value).strftime(format_str) elif isinstance(value, str): return value return str(value) @staticmethod def _format_relative_time(value: Any, **kwargs: Any) -> str: """ Format value as a relative time string (e.g., "5m ago"). Parameters ---------- value : Any The time value in seconds **kwargs : Any format : str, optional "short" or "long" format (default: "short") Returns ------- str Formatted relative time string, or empty string if value is None Examples -------- >>> FormatterRegistry._format_relative_time(45, format="short") '45s ago' >>> FormatterRegistry._format_relative_time(3600, format="long") '1 hours ago' >>> FormatterRegistry._format_relative_time(86400, format="short") '1d ago' """ format_type = kwargs.get("format", "short") if value is None: return "" try: seconds = int(value) except (ValueError, TypeError): return str(value) if seconds < 60: return ( f"{seconds}s ago" if format_type == "short" else f"{seconds} seconds ago" ) elif seconds < 3600: minutes = seconds // 60 return ( f"{minutes}m ago" if format_type == "short" else f"{minutes} minutes ago" ) elif seconds < 86400: hours = seconds // 3600 return f"{hours}h ago" if format_type == "short" else f"{hours} hours ago" else: days = seconds // 86400 return f"{days}d ago" if format_type == "short" else f"{days} days ago" @staticmethod def _format_template(value: Any, **kwargs: Any) -> str: """ Format value using a template string with field substitution. Parameters ---------- value : Any Dictionary containing field values, or any value if context provided **kwargs : Any template : str, optional Template string with {field} placeholders (default: "{}") fields : list[str], optional List of field paths to extract (default: []) field_formatters : dict[str, dict|str], optional Dictionary mapping field names to formatter config or preset name Format: {"field_name": {"type": "formatter_name", "param1": value1, ...}} Or: {"field_name": "preset_name"} _context : dict, optional Context dictionary for resolving fields from engine _engine : LayoutEngine, optional Engine instance for resolving fields _loader : LayoutLoader, optional Layout loader for resolving formatter presets Returns ------- str Formatted template string, or error message if formatting fails Examples -------- >>> value = {"name": "John", "age": 30} >>> FormatterRegistry._format_template( ... value, ... template="{name} is {age} years old", ... fields=["name", "age"] ... ) 'John is 30 years old' >>> # With inline formatters >>> FormatterRegistry._format_template( ... value, ... template="{name} is {age} years old", ... fields=["name", "age"], ... field_formatters={"age": {"type": "number", "suffix": " yrs"}} ... ) 'John is 30 yrs years old' >>> # With preset reference >>> FormatterRegistry._format_template( ... value, ... template="{name} is {age} years old", ... fields=["name", "age"], ... field_formatters={"age": "number_with_suffix"} ... ) 'John is 30 yrs years old' """ template = str(kwargs.get("template", "{}")) fields = kwargs.get("fields", []) field_formatters = kwargs.get("field_formatters", {}) context = kwargs.get("_context") engine = kwargs.get("_engine") loader = kwargs.get("_loader") if context is not None and engine is not None: field_values: dict[str, Any] = {} for field_name in fields: val = engine._get_field_value(field_name, context) if field_name in field_formatters: formatter_config = field_formatters[field_name] if isinstance(formatter_config, str): if loader is not None: preset = loader.get_formatter_preset(formatter_config) if preset is not None: formatter_config = preset else: formatter_config = {"type": "text"} else: formatter_config = {"type": "text"} formatter_type = formatter_config.get("type", "text") formatter_params = { k: v for k, v in formatter_config.items() if k != "type" } try: formatter = engine.formatter_registry.get(formatter_type) val = formatter(val, **formatter_params) except (ValueError, Exception): val = str(val) if val is not None else "" else: if val is not None: if isinstance(val, float): if val == int(val): val = int(val) val = str(val) else: val = "" field_values[field_name] = val if val is not None else "" try: return str(template.format(**field_values)) except (KeyError, ValueError) as e: return f"Template error: {e}" if not isinstance(value, dict): return str(value) field_values = {} for field_path in fields: field_val: Any = value for key in field_path.split("."): if isinstance(field_val, dict): field_val = field_val.get(key) if field_val is None: break else: field_val = None break field_name = field_path.replace(".", "_") field_values[field_name] = field_val if field_val is not None else "" try: return str(template.format(**field_values)) except (KeyError, ValueError) as e: return f"Template error: {e}"
_global_formatter_registry = FormatterRegistry()
[docs] def get_formatter_registry() -> FormatterRegistry: """ Get the global formatter registry instance. Returns ------- FormatterRegistry The global formatter registry Examples -------- >>> from viewtext import get_formatter_registry >>> registry = get_formatter_registry() >>> formatter = registry.get("price") """ return _global_formatter_registry