# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Renderer registry for automatic discovery and registration of renderers. Renderers are indexed by (format, outputStyle) so that document generation and code generation each get the correct renderer for the same format. """ import logging import importlib from typing import Dict, Type, List, Optional, Tuple from .documentRendererBaseTemplate import BaseRenderer logger = logging.getLogger(__name__) class RendererRegistry: """Registry for automatic renderer discovery and management. Maintains separate renderer mappings per outputStyle ('document', 'code', etc.) so that document-generation and code-generation paths each resolve to the correct renderer, even when both support the same format (e.g. 'csv'). """ def __init__(self): # Key: (formatName, outputStyle) -> rendererClass self._renderers: Dict[Tuple[str, str], Type[BaseRenderer]] = {} self._format_mappings: Dict[str, str] = {} self._discovered = False def discoverRenderers(self) -> None: """Automatically discover and register all renderers by scanning files.""" if self._discovered: return try: from pathlib import Path currentDir = Path(__file__).parent packageName = __name__.rsplit('.', 1)[0] for filePath in currentDir.glob("*.py"): if filePath.name in ['registry.py', 'documentRendererBaseTemplate.py', 'codeRendererBaseTemplate.py', '__init__.py']: continue moduleName = filePath.stem try: fullModuleName = f"{packageName}.{moduleName}" module = importlib.import_module(fullModuleName) for attrName in dir(module): attr = getattr(module, attrName) if (isinstance(attr, type) and issubclass(attr, BaseRenderer) and attr != BaseRenderer and hasattr(attr, 'getSupportedFormats')): self._registerRendererClass(attr) except Exception as e: logger.warning(f"Could not load renderer from {moduleName}: {str(e)}") continue self._discovered = True except Exception as e: logger.error(f"Error during renderer discovery: {str(e)}") self._discovered = True def _registerRendererClass(self, rendererClass: Type[BaseRenderer]) -> None: """Register a renderer class keyed by (format, outputStyle).""" try: supportedFormats = rendererClass.getSupportedFormats() priority = rendererClass.getPriority() if hasattr(rendererClass, 'getPriority') else 0 for formatName in supportedFormats: formatKey = formatName.lower() # Per-format output style when renderer supports it (e.g. RendererText: txt→document, js→code) if hasattr(rendererClass, 'getOutputStyle'): try: outputStyle = rendererClass.getOutputStyle(formatKey) except TypeError: outputStyle = rendererClass.getOutputStyle() if callable(getattr(rendererClass, 'getOutputStyle')) else 'document' else: outputStyle = 'document' registryKey = (formatKey, outputStyle) if registryKey in self._renderers: existingRenderer = self._renderers[registryKey] existingPriority = existingRenderer.getPriority() if hasattr(existingRenderer, 'getPriority') else 0 if priority > existingPriority: logger.debug(f"Replacing {existingRenderer.__name__} with {rendererClass.__name__} for ({formatKey}, {outputStyle}) (priority {priority} > {existingPriority})") self._renderers[registryKey] = rendererClass else: logger.debug(f"Keeping {existingRenderer.__name__} for ({formatKey}, {outputStyle}) (priority {existingPriority} >= {priority})") else: self._renderers[registryKey] = rendererClass # Register aliases if hasattr(rendererClass, 'getFormatAliases'): aliases = rendererClass.getFormatAliases() for alias in aliases: self._format_mappings[alias.lower()] = formatKey logger.debug(f"Registered {rendererClass.__name__} for formats={supportedFormats}, style={outputStyle}, priority={priority}") except Exception as e: logger.error(f"Error registering renderer {rendererClass.__name__}: {str(e)}") def getRenderer(self, outputFormat: str, services=None, outputStyle: str = None) -> Optional[BaseRenderer]: """Get a renderer instance for the specified format and style. Args: outputFormat: Format name (e.g. 'csv', 'json', 'pdf') services: Services instance passed to renderer constructor outputStyle: 'document' or 'code'. If None, returns the first match with preference: document > code (most callers are document path). """ if not self._discovered: self.discoverRenderers() formatName = outputFormat.lower().strip() if formatName in self._format_mappings: formatName = self._format_mappings[formatName] rendererClass = None if outputStyle: # Exact match by style rendererClass = self._renderers.get((formatName, outputStyle)) else: # No style specified — prefer 'document', then 'code', then any for style in ['document', 'code']: rendererClass = self._renderers.get((formatName, style)) if rendererClass: break # Fallback: check any registered style if not rendererClass: for key, cls in self._renderers.items(): if key[0] == formatName: rendererClass = cls break if rendererClass: try: return rendererClass(services=services) except Exception as e: logger.error(f"Error creating renderer instance for {formatName}: {str(e)}") return None logger.warning(f"No renderer found for format={outputFormat}, style={outputStyle}") return None def getSupportedFormats(self) -> List[str]: """Get list of all supported formats.""" if not self._discovered: self.discoverRenderers() formats = set() for (fmt, _style) in self._renderers.keys(): formats.add(fmt) formats.update(self._format_mappings.keys()) return sorted(formats) def getRendererInfo(self) -> Dict[str, Dict[str, str]]: """Get information about all registered renderers.""" if not self._discovered: self.discoverRenderers() info = {} for (formatName, style), rendererClass in self._renderers.items(): key = f"{formatName}:{style}" info[key] = { 'class_name': rendererClass.__name__, 'module': rendererClass.__module__, 'outputStyle': style, 'description': getattr(rendererClass, '__doc__', 'No description').strip().split('\n')[0] if rendererClass.__doc__ else 'No description' } return info def getOutputStyle(self, outputFormat: str) -> Optional[str]: """ Get the output style classification for a given format. When both 'document' and 'code' renderers exist for a format, returns the default ('document') since this is called during document generation. """ if not self._discovered: self.discoverRenderers() formatName = outputFormat.lower().strip() if formatName in self._format_mappings: formatName = self._format_mappings[formatName] # Check document first, then code for style in ['document', 'code']: rendererClass = self._renderers.get((formatName, style)) if rendererClass: try: return rendererClass.getOutputStyle(formatName) except Exception: pass # Fallback: any style for key, rendererClass in self._renderers.items(): if key[0] == formatName: try: return rendererClass.getOutputStyle(formatName) except Exception: pass logger.warning(f"No renderer found for format: {outputFormat}, cannot determine output style") return None # Global registry instance _registry = RendererRegistry() def getRenderer(outputFormat: str, services=None, outputStyle: str = None) -> Optional[BaseRenderer]: """Get a renderer instance for the specified format and style. Args: outputFormat: Format name (e.g. 'csv', 'json', 'pdf') services: Services instance outputStyle: 'document' or 'code'. If None, prefers document renderer. """ return _registry.getRenderer(outputFormat, services, outputStyle=outputStyle) def getSupportedFormats() -> List[str]: """Get list of all supported formats.""" return _registry.getSupportedFormats() def getRendererInfo() -> Dict[str, Dict[str, str]]: """Get information about all registered renderers.""" return _registry.getRendererInfo() def getOutputStyle(outputFormat: str) -> Optional[str]: """Get the output style classification for a given format.""" return _registry.getOutputStyle(outputFormat)