1from abc import ABC, abstractmethod
2from typing import Any, List, Optional
3from datetime import datetime
4
5
[docs]
6class Renderer(ABC):
7 """
8 Abstract base class for all renderers.
9
10 Implements the required interface for all renderers as per:
11
12 * /specs/spec_renderer_markdown.md §6
13 * /specs/spec_renderer_quarto.md §6
14 * /specs/spec_model_mapping.md (for data model expectations)
15
16 # TODO: Open questions
17 # 1. Should future renderers (e.g., HTML, PDF) follow this interface? (TBD)
18 # 2. Are there additional common methods that should be included in the base interface? (TBD)
19 # Possible candidates:
20 #
21 # * get_supported_formats(cls) -> List[str]
22 # * validate_output(self, output: str) -> bool
23 # * get_default_config(cls) -> dict
24 """
25
[docs]
26 @abstractmethod
27 def render(self, *args, **kwargs) -> Any:
28 """
29 Render the output for the given data.
30 See /specs/spec_renderer_markdown.md and /specs/spec_renderer_quarto.md for required output.
31 """
32 pass
33
[docs]
34 @abstractmethod
35 def write_files(self, *args, **kwargs) -> None:
36 """
37 Write rendered output to files as specified.
38 See /specs/spec_renderer_markdown.md and /specs/spec_renderer_quarto.md for file output requirements.
39 """
40 pass
41
[docs]
42 @staticmethod
43 def is_placeholder_value(val: Any) -> bool:
44 if val is None or val == "":
45 return True
46 if isinstance(val, str):
47 return val.strip() in ("-", "–", "—")
48 return False
49
[docs]
50 @staticmethod
51 def star_rating(val: Any, max_val: int = 5) -> str:
52 try:
53 rating = int(val)
54 if rating <= 0:
55 return "—"
56 if rating > max_val:
57 rating = max_val
58 return "★" * rating + "☆" * (max_val - rating)
59 except Exception:
60 return "—"
61
[docs]
62 @staticmethod
63 def safe_str(val: Any) -> str:
64 if Renderer.is_placeholder_value(val):
65 return "—"
66 return str(val)
67
80
[docs]
81 @staticmethod
82 def parse_iso_date(dt: Any) -> Optional[datetime]:
83 if dt is None:
84 return None
85 if isinstance(dt, datetime):
86 return dt
87 if isinstance(dt, str):
88 try:
89 dt = dt.replace("Z", "+00:00")
90 return datetime.fromisoformat(dt)
91 except Exception:
92 return None
93 return None
94
[docs]
95 @staticmethod
96 def parse_comma_list(value: Any) -> List[str]:
97 if value is None:
98 return []
99 if isinstance(value, str):
100 return [item.strip() for item in value.split(",") if item.strip()]
101 if isinstance(value, list):
102 return [str(item) for item in value]
103 return []
104
[docs]
105 @staticmethod
106 def safe_int(value: Any, default: int = 0) -> int:
107 if value is None:
108 return default
109 try:
110 return int(value)
111 except (ValueError, TypeError):
112 return default
113
[docs]
114 @staticmethod
115 def safe_float(value: Any, default: float = 0.0) -> float:
116 if value is None:
117 return default
118 try:
119 return float(value)
120 except (ValueError, TypeError):
121 return default
122
[docs]
123 @staticmethod
124 def is_numeric_string(value: Any) -> bool:
125 if value is None:
126 return False
127 try:
128 str_val = str(value)
129 return str_val.replace(".", "", 1).replace("-", "", 1).isdigit()
130 except Exception:
131 return False
132
[docs]
133 @staticmethod
134 def get_with_fallback(data: dict, key: str, fallback: str = "—") -> str:
135 value = data.get(key, fallback)
136 if Renderer.is_placeholder_value(value):
137 return "—"
138 return str(value)
139
153
[docs]
154 @staticmethod
155 def process_readme_lines(content: str, max_lines: int = 12) -> List[str]:
156 if not content:
157 return []
158 lines = content.splitlines()
159 if lines and lines[0].strip() == "---":
160 try:
161 frontmatter_end = lines[1:].index("---")
162 lines = lines[frontmatter_end + 2 :]
163 except ValueError:
164 pass
165 if len(lines) > max_lines:
166 lines = lines[:max_lines]
167 lines.append("…continues…")
168 return lines
169
190
[docs]
191 @staticmethod
192 def strip_trailing_whitespace(text: str) -> str:
193 if not isinstance(text, str):
194 return ""
195 return "\n".join(line.rstrip() for line in text.splitlines())