Coverage for src / rhiza / commands / summarise / _render.py: 100%
143 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-07-16 12:51 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-07-16 12:51 +0000
1"""Rendering helpers for the ``summarise`` command.
3Formats gathered change data into markdown, plain text, JSON, or a custom
4Jinja2 template. Beyond reading a user-supplied Jinja2 template file, this
5module performs no git or filesystem inspection — see
6:mod:`rhiza.commands.summarise._gather` for data collection.
7"""
9import json as _json
10from dataclasses import dataclass, field
11from datetime import datetime
12from pathlib import Path
13from typing import Any
15import jinja2
17from ._gather import _TemplateInfo
20@dataclass(kw_only=True)
21class SummariseOptions:
22 """Options controlling the output of :func:`generate_pr_description`.
24 All fields are keyword-only and default to the standard behaviour so
25 callers only need to set the fields they want to override.
26 """
28 include_header: bool = True
29 """Whether to include the header section (markdown / plain formats)."""
31 include_footer: bool = True
32 """Whether to include the footer section (markdown / plain formats)."""
34 include_categories: bool = True
35 """Whether to group changes by category; when ``False`` a flat list is shown."""
37 output_format: str = "markdown"
38 """Output format: ``"markdown"`` (default), ``"plain"``, or ``"json"``."""
40 title: str | None = None
41 """Override the section heading; ``None`` uses the built-in default."""
43 compare_ref: str | None = None
44 """Compare against this git ref instead of the staged index."""
46 jinja2_template: Path | None = field(default=None)
47 """Path to a Jinja2 template file for fully custom output."""
50def _format_file_list(files: list[str], status_emoji: str) -> list[str]:
51 """Format a list of files with the given status emoji.
53 Args:
54 files: List of file paths
55 status_emoji: Emoji to use (✅ for added, 📝 for modified, ❌ for deleted)
57 Returns:
58 List of formatted lines
59 """
60 lines = []
61 for f in sorted(files):
62 lines.append(f"- {status_emoji} `{f}`")
63 return lines
66def _add_category_section(lines: list[str], title: str, count: int, files: list[str], emoji: str) -> None:
67 """Add a collapsible section for a category and change type.
69 Args:
70 lines: List to append lines to
71 title: Section title (e.g., "Added", "Modified")
72 count: Number of files
73 files: List of file paths
74 emoji: Status emoji
75 """
76 if not files:
77 return
79 lines.append("<details>")
80 lines.append(f"<summary>{title} ({count})</summary>")
81 lines.append("")
82 lines.extend(_format_file_list(files, emoji))
83 lines.append("")
84 lines.append("</details>")
85 lines.append("")
88def _build_header(template_repo: str, title: str | None = None) -> list[str]:
89 """Build the PR description header.
91 Args:
92 template_repo: Template repository name
93 title: Optional override for the section heading
95 Returns:
96 List of header lines
97 """
98 header_title = title if title else "## 🔄 Template Synchronization"
99 lines = [header_title, ""]
100 if template_repo:
101 url = f"https://github.com/{template_repo}"
102 repo_link = f"[{template_repo}]({url})"
103 sync_line = f"This PR synchronizes the repository with the {repo_link} template."
104 lines.append(sync_line)
105 else:
106 lines.append("This PR synchronizes the repository with the upstream template.")
107 lines.append("")
108 return lines
111def _build_summary(changes: dict[str, list[str]]) -> list[str]:
112 """Build the change summary section.
114 Args:
115 changes: Dictionary of changes by type
117 Returns:
118 List of summary lines
119 """
120 return [
121 "### 📊 Change Summary",
122 "",
123 f"- **{len(changes['added'])}** files added",
124 f"- **{len(changes['modified'])}** files modified",
125 f"- **{len(changes['deleted'])}** files deleted",
126 "",
127 ]
130def _build_footer(tmpl: _TemplateInfo) -> list[str]:
131 """Build the PR description footer with metadata.
133 Args:
134 tmpl: Template metadata container
136 Returns:
137 List of footer lines
138 """
139 lines = [
140 "---",
141 "",
142 "**🤖 Generated by [rhiza](https://github.com/jebel-quant/rhiza-cli)**",
143 "",
144 ]
145 if tmpl.repo:
146 lines.append(f"- Template: `{tmpl.repo}@{tmpl.branch}`")
147 if tmpl.last_sync:
148 lines.append(f"- Last sync: {tmpl.last_sync}")
149 lines.append(f"- Sync date: {datetime.now().astimezone().isoformat()}")
150 return lines
153def _generate_json_output(
154 changes: dict[str, list[str]],
155 categories: dict[str, list[str]],
156 tmpl: _TemplateInfo,
157) -> str:
158 """Generate a JSON representation of the change data.
160 Args:
161 changes: Dictionary of changes by type
162 categories: Files grouped by category
163 tmpl: Template metadata container
165 Returns:
166 JSON-formatted string
167 """
168 data = {
169 "template_repo": tmpl.repo,
170 "template_branch": tmpl.branch,
171 "last_sync": tmpl.last_sync,
172 "sync_date": datetime.now().astimezone().isoformat(),
173 "changes": changes,
174 "categories": categories,
175 }
176 return _json.dumps(data, indent=2)
179def _plain_file_section(lines: list[str], label: str, files: list[str]) -> None:
180 """Append a labelled block of files to *lines* in plain-text format.
182 Args:
183 lines: List to append lines to
184 label: Section label (e.g. "Added")
185 files: List of file paths
186 """
187 if not files:
188 return
189 lines.append(f"{label}:")
190 lines.extend(f" {f}" for f in sorted(files))
191 lines.append("")
194def _generate_plain_output(
195 changes: dict[str, list[str]],
196 categories: dict[str, list[str]],
197 tmpl: _TemplateInfo,
198 options: SummariseOptions,
199) -> str:
200 """Generate plain-text output from change data.
202 Args:
203 changes: Dictionary of changes by type
204 categories: Files grouped by category
205 tmpl: Template metadata container
206 options: Output customisation options
208 Returns:
209 Plain-text formatted string
210 """
211 lines: list[str] = []
213 _plain_header(lines, tmpl, options)
215 total = sum(len(v) for v in changes.values())
216 if not total:
217 lines.append("No changes detected.")
218 return "\n".join(lines)
220 lines.append(
221 f"Changes: {len(changes['added'])} added, "
222 f"{len(changes['modified'])} modified, "
223 f"{len(changes['deleted'])} deleted",
224 )
225 lines.append("")
227 _plain_change_body(lines, changes, categories, options)
228 _plain_footer(lines, tmpl, options)
230 return "\n".join(lines)
233def _plain_header(lines: list[str], tmpl: _TemplateInfo, options: SummariseOptions) -> None:
234 """Append the plain-text header (title + template line) to *lines*.
236 Args:
237 lines: Output line accumulator, mutated in place.
238 tmpl: Template metadata container.
239 options: Output customisation options.
240 """
241 if not options.include_header:
242 return
243 heading = options.title or "Template Synchronization"
244 lines.extend([heading, "=" * len(heading), ""])
245 if tmpl.repo:
246 lines.append(f"Template: {tmpl.repo}@{tmpl.branch}")
247 lines.append("")
250def _plain_change_body(
251 lines: list[str],
252 changes: dict[str, list[str]],
253 categories: dict[str, list[str]],
254 options: SummariseOptions,
255) -> None:
256 """Append the per-category or per-change-type file listing to *lines*.
258 Args:
259 lines: Output line accumulator, mutated in place.
260 changes: Dictionary of changes by type.
261 categories: Files grouped by category.
262 options: Output customisation options.
263 """
264 if options.include_categories:
265 for category, files in sorted(categories.items()):
266 lines.append(f"{category}:")
267 lines.extend(f" {f}" for f in sorted(files))
268 lines.append("")
269 else:
270 for label, files in [
271 ("Added", changes["added"]),
272 ("Modified", changes["modified"]),
273 ("Deleted", changes["deleted"]),
274 ]:
275 _plain_file_section(lines, label, files)
278def _plain_footer(lines: list[str], tmpl: _TemplateInfo, options: SummariseOptions) -> None:
279 """Append the plain-text footer (last-sync + sync-date) to *lines*.
281 Args:
282 lines: Output line accumulator, mutated in place.
283 tmpl: Template metadata container.
284 options: Output customisation options.
285 """
286 if not options.include_footer:
287 return
288 if tmpl.last_sync:
289 lines.append(f"Last sync: {tmpl.last_sync}")
290 lines.append(f"Sync date: {datetime.now().astimezone().isoformat()}")
293def _generate_jinja2_output(template_path: Path, context: dict[str, Any]) -> str:
294 """Render output using a custom Jinja2 template file.
296 The *context* dict is passed directly to the template. It should contain at
297 minimum: ``template_repo``, ``template_branch``, ``last_sync``, ``sync_date``,
298 ``changes``, ``categories``, and ``title``.
300 Note:
301 Autoescape is disabled because this function generates plain text / Markdown,
302 not HTML. Do **not** use the rendered output directly in a web context without
303 first escaping it, as the template content is not sanitised for HTML.
305 Args:
306 template_path: Path to the Jinja2 template file
307 context: Template context variables
309 Returns:
310 Rendered template string
311 """
312 template_text = template_path.read_text(encoding="utf-8")
313 env = jinja2.Environment( # nosec B701
314 autoescape=False, # noqa: S701
315 loader=jinja2.BaseLoader(),
316 keep_trailing_newline=True,
317 )
318 return env.from_string(template_text).render(**context)
321def _render_category_group(
322 lines: list[str],
323 categories: dict[str, list[str]],
324 changes: dict[str, list[str]],
325) -> None:
326 """Append the per-category collapsible markdown sections to *lines*.
328 Args:
329 lines: List to append lines to
330 categories: Files grouped by category
331 changes: Dictionary of changes by type, used to split each category's
332 files into added / modified / deleted subsections
333 """
334 lines.append("### 📁 Changes by Category")
335 lines.append("")
337 for category, files in sorted(categories.items()):
338 lines.append(f"#### {category}")
339 lines.append("")
341 category_added = [f for f in files if f in changes["added"]]
342 category_modified = [f for f in files if f in changes["modified"]]
343 category_deleted = [f for f in files if f in changes["deleted"]]
345 _add_category_section(lines, "Added", len(category_added), category_added, "✅")
346 _add_category_section(lines, "Modified", len(category_modified), category_modified, "📝")
347 _add_category_section(lines, "Deleted", len(category_deleted), category_deleted, "❌")
350def _render_flat_files(lines: list[str], changes: dict[str, list[str]]) -> None:
351 """Append a flat (uncategorised) markdown file listing to *lines*.
353 Args:
354 lines: List to append lines to
355 changes: Dictionary of changes by type
356 """
357 lines.append("### 📁 Changed Files")
358 lines.append("")
359 _add_category_section(lines, "Added", len(changes["added"]), changes["added"], "✅")
360 _add_category_section(lines, "Modified", len(changes["modified"]), changes["modified"], "📝")
361 _add_category_section(lines, "Deleted", len(changes["deleted"]), changes["deleted"], "❌")
364def _markdown_body(
365 changes: dict[str, list[str]],
366 categories: dict[str, list[str]],
367 tmpl: _TemplateInfo,
368 options: SummariseOptions,
369) -> str:
370 """Build the markdown PR description body.
372 Args:
373 changes: Dictionary of changes by type
374 categories: Files grouped by category
375 tmpl: Template metadata container
376 options: Output customisation options
378 Returns:
379 Markdown-formatted string
380 """
381 lines: list[str] = []
383 if options.include_header:
384 lines.extend(_build_header(tmpl.repo, title=options.title))
386 total_changes = sum(len(files) for files in changes.values())
387 if not total_changes:
388 lines.append("No changes detected.")
389 if options.include_footer:
390 lines.append("")
391 lines.extend(_build_footer(tmpl))
392 return "\n".join(lines)
394 lines.extend(_build_summary(changes))
396 if options.include_categories and categories:
397 _render_category_group(lines, categories, changes)
398 elif not options.include_categories:
399 _render_flat_files(lines, changes)
401 if options.include_footer:
402 lines.extend(_build_footer(tmpl))
404 return "\n".join(lines)