408
i18n_key_sync/report.py
Normal file
408
i18n_key_sync/report.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""Reporting for i18n key analysis."""
|
||||
|
||||
from typing import Dict, Set
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from i18n_key_sync.validate import ValidationResult
|
||||
|
||||
|
||||
class Reporter:
|
||||
"""Generate reports for i18n key analysis."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the reporter."""
|
||||
self.console = Console()
|
||||
|
||||
def print_validation_result(
|
||||
self,
|
||||
result: ValidationResult,
|
||||
format: str = "rich",
|
||||
) -> None:
|
||||
"""Print validation result to console.
|
||||
|
||||
Args:
|
||||
result: ValidationResult to display.
|
||||
format: Output format (rich, minimal, json).
|
||||
|
||||
"""
|
||||
if format == "json":
|
||||
self._print_json_result(result)
|
||||
elif format == "minimal":
|
||||
self._print_minimal_result(result)
|
||||
else:
|
||||
self._print_rich_result(result)
|
||||
|
||||
def _print_rich_result(self, result: ValidationResult) -> None:
|
||||
"""Print rich-formatted validation result.
|
||||
|
||||
Args:
|
||||
result: ValidationResult to display.
|
||||
|
||||
"""
|
||||
if result.missing_keys:
|
||||
table = Table(title="Missing Keys (in code but not in locale)")
|
||||
table.add_column("Key", style="red")
|
||||
for key in sorted(result.missing_keys):
|
||||
table.add_row(key)
|
||||
self.console.print(table)
|
||||
self.console.print(f"\n[red]Total missing: {len(result.missing_keys)}[/red]\n")
|
||||
|
||||
if result.unused_keys:
|
||||
table = Table(title="Unused Keys (in locale but not in code)")
|
||||
table.add_column("Key", style="yellow")
|
||||
for key in sorted(result.unused_keys):
|
||||
table.add_row(key)
|
||||
self.console.print(table)
|
||||
self.console.print(f"\n[yellow]Total unused: {len(result.unused_keys)}[/yellow]\n")
|
||||
|
||||
if result.matching_keys:
|
||||
self.console.print(f"[green]Matching keys: {len(result.matching_keys)}[/green]")
|
||||
|
||||
if result.is_valid:
|
||||
self.console.print("[green]✓ All keys are valid![/green]")
|
||||
|
||||
def _print_json_result(self, result: ValidationResult) -> None:
|
||||
"""Print JSON-formatted validation result.
|
||||
|
||||
Args:
|
||||
result: ValidationResult to display.
|
||||
|
||||
"""
|
||||
import json
|
||||
|
||||
output = {
|
||||
"missing_keys": sorted(list(result.missing_keys)),
|
||||
"unused_keys": sorted(list(result.unused_keys)),
|
||||
"matching_keys": sorted(list(result.matching_keys)),
|
||||
"missing_count": len(result.missing_keys),
|
||||
"unused_count": len(result.unused_keys),
|
||||
"matching_count": len(result.matching_keys),
|
||||
"is_valid": result.is_valid,
|
||||
}
|
||||
self.console.print(json.dumps(output, indent=2))
|
||||
|
||||
def _print_minimal_result(self, result: ValidationResult) -> None:
|
||||
"""Print minimal validation result.
|
||||
|
||||
Args:
|
||||
result: ValidationResult to display.
|
||||
|
||||
"""
|
||||
if result.missing_keys:
|
||||
self.console.print(f"MISSING: {', '.join(sorted(result.missing_keys))}")
|
||||
if result.unused_keys:
|
||||
self.console.print(f"UNUSED: {', '.join(sorted(result.unused_keys))}")
|
||||
if result.is_valid:
|
||||
self.console.print("OK")
|
||||
|
||||
def print_sync_changes(
|
||||
self,
|
||||
changes: list,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""Print sync changes to console.
|
||||
|
||||
Args:
|
||||
changes: List of SyncChange objects.
|
||||
dry_run: Whether this was a dry run.
|
||||
|
||||
"""
|
||||
if dry_run:
|
||||
self.console.print("[yellow]DRY RUN - No files were modified[/yellow]\n")
|
||||
|
||||
if not changes:
|
||||
self.console.print("No changes needed.")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
self.console.print(f"Would make {len(changes)} change(s):\n")
|
||||
else:
|
||||
self.console.print(f"Made {len(changes)} change(s):\n")
|
||||
|
||||
changes_by_locale: dict = {}
|
||||
for change in changes:
|
||||
if change.locale not in changes_by_locale:
|
||||
changes_by_locale[change.locale] = []
|
||||
changes_by_locale[change.locale].append(change)
|
||||
|
||||
for locale, locale_changes in sorted(changes_by_locale.items()):
|
||||
table = Table(title=f"Locale: {locale}")
|
||||
table.add_column("Key")
|
||||
table.add_column("Action")
|
||||
table.add_column("Value")
|
||||
|
||||
for change in locale_changes:
|
||||
action_style = "green" if change.action == "add" else "yellow"
|
||||
value = str(change.new_value) if change.new_value else "(removed)"
|
||||
table.add_row(
|
||||
change.key,
|
||||
Text(change.action.upper(), style=action_style),
|
||||
value,
|
||||
)
|
||||
|
||||
self.console.print(table)
|
||||
self.console.print()
|
||||
|
||||
def generate_report(
|
||||
self,
|
||||
extracted_keys: Set[str],
|
||||
locale_keys: Dict[str, Set[str]],
|
||||
format: str = "rich",
|
||||
include_details: bool = False,
|
||||
coverage_threshold: float = 0.0,
|
||||
) -> str:
|
||||
"""Generate a full i18n report.
|
||||
|
||||
Args:
|
||||
extracted_keys: Set of keys extracted from source code.
|
||||
locale_keys: Dictionary mapping locales to their key sets.
|
||||
format: Output format (rich, json, markdown).
|
||||
include_details: Whether to include detailed key lists.
|
||||
coverage_threshold: Coverage threshold for warnings.
|
||||
|
||||
Returns:
|
||||
Report content as string.
|
||||
|
||||
"""
|
||||
if format == "json":
|
||||
return self._generate_json_report(
|
||||
extracted_keys,
|
||||
locale_keys,
|
||||
include_details,
|
||||
coverage_threshold,
|
||||
)
|
||||
elif format == "markdown":
|
||||
return self._generate_markdown_report(
|
||||
extracted_keys,
|
||||
locale_keys,
|
||||
include_details,
|
||||
coverage_threshold,
|
||||
)
|
||||
else:
|
||||
return self._generate_rich_report(
|
||||
extracted_keys,
|
||||
locale_keys,
|
||||
include_details,
|
||||
coverage_threshold,
|
||||
)
|
||||
|
||||
def _generate_rich_report(
|
||||
self,
|
||||
extracted_keys: Set[str],
|
||||
locale_keys: Dict[str, Set[str]],
|
||||
include_details: bool,
|
||||
coverage_threshold: float,
|
||||
) -> str:
|
||||
"""Generate a rich-formatted report.
|
||||
|
||||
Args:
|
||||
extracted_keys: Set of keys extracted from source code.
|
||||
locale_keys: Dictionary mapping locales to their key sets.
|
||||
include_details: Whether to include detailed key lists.
|
||||
coverage_threshold: Coverage threshold for warnings.
|
||||
|
||||
Returns:
|
||||
Report content as string.
|
||||
|
||||
"""
|
||||
from io import StringIO
|
||||
|
||||
output = StringIO()
|
||||
console = Console(file=output, force_terminal=True)
|
||||
|
||||
console.print("\n[bold]i18n Key Report[/bold]\n")
|
||||
|
||||
total_code_keys = len(extracted_keys)
|
||||
|
||||
summary_table = Table(title="Summary")
|
||||
summary_table.add_column("Locale")
|
||||
summary_table.add_column("Keys", justify="right")
|
||||
summary_table.add_column("Missing", justify="right")
|
||||
summary_table.add_column("Unused", justify="right")
|
||||
summary_table.add_column("Coverage", justify="right")
|
||||
|
||||
all_missing = set()
|
||||
all_unused = set()
|
||||
|
||||
for locale, keys in sorted(locale_keys.items()):
|
||||
missing = extracted_keys - keys
|
||||
unused = keys - extracted_keys
|
||||
all_missing.update(missing)
|
||||
all_unused.update(unused)
|
||||
|
||||
if total_code_keys > 0:
|
||||
coverage = (len(keys & extracted_keys) / total_code_keys) * 100
|
||||
coverage_str = f"{coverage:.1f}%"
|
||||
if coverage < coverage_threshold:
|
||||
coverage_str = f"[red]{coverage_str}[/red]"
|
||||
else:
|
||||
coverage_str = "N/A"
|
||||
|
||||
summary_table.add_row(
|
||||
locale,
|
||||
str(len(keys)),
|
||||
str(len(missing)),
|
||||
str(len(unused)),
|
||||
coverage_str,
|
||||
)
|
||||
|
||||
console.print(summary_table)
|
||||
|
||||
if include_details:
|
||||
if all_missing:
|
||||
console.print("\n[red bold]Missing Keys[/red bold]")
|
||||
for locale, keys in sorted(locale_keys.items()):
|
||||
missing = extracted_keys - keys
|
||||
if missing:
|
||||
console.print(f"\n[red]{locale}:[/red]")
|
||||
for key in sorted(missing):
|
||||
console.print(f" - {key}")
|
||||
|
||||
if all_unused:
|
||||
console.print("\n[yellow bold]Unused Keys[/yellow bold]")
|
||||
for locale, keys in sorted(locale_keys.items()):
|
||||
unused = keys - extracted_keys
|
||||
if unused:
|
||||
console.print(f"\n[yellow]{locale}:[/yellow]")
|
||||
for key in sorted(unused):
|
||||
console.print(f" - {key}")
|
||||
|
||||
console.print(f"\nTotal keys in code: {total_code_keys}")
|
||||
console.print(f"Total missing across all locales: {len(all_missing)}")
|
||||
console.print(f"Total unused across all locales: {len(all_unused)}")
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
def _generate_json_report(
|
||||
self,
|
||||
extracted_keys: Set[str],
|
||||
locale_keys: Dict[str, Set[str]],
|
||||
include_details: bool,
|
||||
coverage_threshold: float,
|
||||
) -> str:
|
||||
"""Generate a JSON-formatted report.
|
||||
|
||||
Args:
|
||||
extracted_keys: Set of keys extracted from source code.
|
||||
locale_keys: Dictionary mapping locales to their key sets.
|
||||
include_details: Whether to include detailed key lists.
|
||||
coverage_threshold: Coverage threshold for warnings.
|
||||
|
||||
Returns:
|
||||
Report content as string.
|
||||
|
||||
"""
|
||||
import json
|
||||
|
||||
total_code_keys = len(extracted_keys)
|
||||
all_missing = set()
|
||||
all_unused = set()
|
||||
|
||||
locales_data = {}
|
||||
for locale, keys in sorted(locale_keys.items()):
|
||||
missing = extracted_keys - keys
|
||||
unused = keys - extracted_keys
|
||||
all_missing.update(missing)
|
||||
all_unused.update(unused)
|
||||
|
||||
if total_code_keys > 0:
|
||||
coverage = (len(keys & extracted_keys) / total_code_keys) * 100
|
||||
else:
|
||||
coverage = 100.0
|
||||
|
||||
locales_data[locale] = {
|
||||
"total_keys": len(keys),
|
||||
"missing_keys": sorted(list(missing)),
|
||||
"unused_keys": sorted(list(unused)),
|
||||
"coverage_percent": round(coverage, 2),
|
||||
}
|
||||
|
||||
output = {
|
||||
"summary": {
|
||||
"total_keys_in_code": total_code_keys,
|
||||
"total_locales": len(locale_keys),
|
||||
"total_missing": len(all_missing),
|
||||
"total_unused": len(all_unused),
|
||||
"locales": locales_data,
|
||||
},
|
||||
}
|
||||
|
||||
if include_details:
|
||||
output["details"] = {
|
||||
"all_missing_keys": sorted(list(all_missing)),
|
||||
"all_unused_keys": sorted(list(all_unused)),
|
||||
}
|
||||
|
||||
return json.dumps(output, indent=2)
|
||||
|
||||
def _generate_markdown_report(
|
||||
self,
|
||||
extracted_keys: Set[str],
|
||||
locale_keys: Dict[str, Set[str]],
|
||||
include_details: bool,
|
||||
coverage_threshold: float,
|
||||
) -> str:
|
||||
"""Generate a markdown-formatted report.
|
||||
|
||||
Args:
|
||||
extracted_keys: Set of keys extracted from source code.
|
||||
locale_keys: Dictionary mapping locales to their key sets.
|
||||
include_details: Whether to include detailed key lists.
|
||||
coverage_threshold: Coverage threshold for warnings.
|
||||
|
||||
Returns:
|
||||
Report content as string.
|
||||
|
||||
"""
|
||||
lines = ["# i18n Key Report\n"]
|
||||
|
||||
total_code_keys = len(extracted_keys)
|
||||
all_missing = set()
|
||||
all_unused = set()
|
||||
|
||||
lines.append("## Summary\n")
|
||||
lines.append("| Locale | Keys | Missing | Unused | Coverage |")
|
||||
lines.append("|--------|------|---------|--------|----------|")
|
||||
|
||||
for locale, keys in sorted(locale_keys.items()):
|
||||
missing = extracted_keys - keys
|
||||
unused = keys - extracted_keys
|
||||
all_missing.update(missing)
|
||||
all_unused.update(unused)
|
||||
|
||||
if total_code_keys > 0:
|
||||
coverage = (len(keys & extracted_keys) / total_code_keys) * 100
|
||||
coverage_str = f"{coverage:.1f}%"
|
||||
else:
|
||||
coverage_str = "N/A"
|
||||
|
||||
lines.append(f"| {locale} | {len(keys)} | {len(missing)} | {len(unused)} | {coverage_str} |")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"- **Total keys in code**: {total_code_keys}")
|
||||
lines.append(f"- **Total missing across all locales**: {len(all_missing)}")
|
||||
lines.append(f"- **Total unused across all locales**: {len(all_unused)}")
|
||||
|
||||
if include_details:
|
||||
if all_missing:
|
||||
lines.append("\n## Missing Keys\n")
|
||||
for locale, keys in sorted(locale_keys.items()):
|
||||
missing = extracted_keys - keys
|
||||
if missing:
|
||||
lines.append(f"\n### {locale}\n")
|
||||
for key in sorted(missing):
|
||||
lines.append(f"- `{key}`")
|
||||
|
||||
if all_unused:
|
||||
lines.append("\n## Unused Keys\n")
|
||||
for locale, keys in sorted(locale_keys.items()):
|
||||
unused = keys - extracted_keys
|
||||
if unused:
|
||||
lines.append(f"\n### {locale}\n")
|
||||
for key in sorted(unused):
|
||||
lines.append(f"- `{key}`")
|
||||
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user