"""Main CLI entry point for i18n-key-sync.""" import sys from typing import Optional import click from i18n_key_sync import __version__ from i18n_key_sync.extract import extract_keys from i18n_key_sync.locale import LocaleParser from i18n_key_sync.report import Reporter from i18n_key_sync.sync import Syncer from i18n_key_sync.validate import Validator def print_version(ctx: click.Context, param: click.Option, value: bool) -> Optional[bool]: """Print version and exit.""" if value: click.echo(f"i18n-key-sync v{__version__}") ctx.exit(0) return value @click.group() @click.option( "--locale-dir", type=click.Path(file_okay=False, dir_okay=True, resolve_path=True), default="./locales", help="Directory containing locale files", envvar="I18N_LOCALE_DIR", ) @click.option( "--output-format", type=click.Choice(["rich", "json", "minimal"]), default="rich", help="Output format", envvar="I18N_OUTPUT_FORMAT", ) @click.option( "--verbose", "-v", is_flag=True, default=False, help="Enable verbose output", ) @click.option( "--version", is_flag=True, callback=print_version, expose_value=False, help="Show version and exit", ) @click.pass_context def main( ctx: click.Context, locale_dir: str, output_format: str, verbose: bool, ) -> None: """A CLI tool to extract, validate, and manage i18n translation keys.""" ctx.ensure_object(dict) ctx.obj["locale_dir"] = locale_dir ctx.obj["output_format"] = output_format ctx.obj["verbose"] = verbose @main.command("extract") @click.argument( "paths", type=click.Path(exists=True), nargs=-1, required=True, ) @click.option( "--patterns", default="_,t,i18n.t,gettext,ngettext", help="Comma-separated list of i18n function patterns", ) @click.option( "--file-types", default="py,js,ts,jsx,tsx", help="Comma-separated list of file extensions to scan", ) @click.pass_context def extract( ctx: click.Context, paths: tuple[str, ...], patterns: str, file_types: str, ) -> None: """Extract i18n keys from source files.""" locale_dir = ctx.obj["locale_dir"] output_format = ctx.obj["output_format"] verbose = ctx.obj["verbose"] pattern_list = [p.strip() for p in patterns.split(",")] file_type_list = [t.strip() for t in file_types.split(",")] if verbose: click.echo(f"Scanning paths: {', '.join(paths)}") click.echo(f"Patterns: {', '.join(pattern_list)}") click.echo(f"File types: {', '.join(file_type_list)}") try: keys = extract_keys(paths, pattern_list, file_type_list) if output_format == "json": import json click.echo(json.dumps(sorted(list(keys)), indent=2)) elif output_format == "minimal": for key in sorted(keys): click.echo(key) else: click.echo(f"Found {len(keys)} unique keys:") for key in sorted(keys): click.echo(f" {key}") except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) @main.command("validate") @click.argument( "paths", type=click.Path(exists=True), nargs=-1, required=True, ) @click.option( "--fail-missing", is_flag=True, default=False, help="Exit with code 1 if missing keys are found", ) @click.option( "--fail-unused", is_flag=True, default=False, help="Exit with code 2 if unused keys are found", ) @click.option( "--strict", is_flag=True, default=False, help="Exit with code 3 if any missing or unused keys are found", ) @click.option( "--patterns", default="_,t,i18n.t,gettext,ngettext", help="Comma-separated list of i18n function patterns", ) @click.option( "--file-types", default="py,js,ts,jsx,tsx", help="Comma-separated list of file extensions to scan", ) @click.option( "--locale", multiple=True, help="Specific locale file to validate (default: all)", ) @click.pass_context def validate( ctx: click.Context, paths: tuple[str, ...], fail_missing: bool, fail_unused: bool, strict: bool, patterns: str, file_types: str, locale: tuple[str, ...], ) -> None: """Validate i18n keys against locale files.""" import os locale_dir = ctx.obj["locale_dir"] output_format = ctx.obj["output_format"] verbose = ctx.obj["verbose"] if not os.path.isdir(locale_dir): click.echo(f"Error: Locale directory '{locale_dir}' does not exist.", err=True) sys.exit(1) pattern_list = [p.strip() for p in patterns.split(",")] file_type_list = [t.strip() for t in file_types.split(",")] if verbose: click.echo(f"Scanning paths: {', '.join(paths)}") click.echo(f"Locale directory: {locale_dir}") try: keys = extract_keys(paths, pattern_list, file_type_list) parser = LocaleParser() locale_keys = parser.parse_directory(locale_dir, list(locale) if locale else None) validator = Validator() result = validator.validate(keys, locale_keys) exit_code = 0 if strict: if result.missing_keys or result.unused_keys: exit_code = 3 elif fail_missing and result.missing_keys: exit_code = 1 elif fail_unused and result.unused_keys: exit_code = 2 if output_format == "json": 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), } click.echo(json.dumps(output, indent=2)) else: reporter = Reporter() reporter.print_validation_result(result, output_format) sys.exit(exit_code) except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) @main.command("sync") @click.argument( "paths", type=click.Path(exists=True), nargs=-1, required=True, ) @click.option( "--dry-run", is_flag=True, default=False, help="Show what would be changed without modifying files", ) @click.option( "--locale", multiple=True, help="Specific locale to sync (default: all)", ) @click.option( "--value", default="TODO: Translate this string", help="Placeholder value for new keys", ) @click.option( "--fill-from", help="Use keys from this locale as values for new keys", ) @click.option( "--patterns", default="_,t,i18n.t,gettext,ngettext", help="Comma-separated list of i18n function patterns", ) @click.option( "--file-types", default="py,js,ts,jsx,tsx", help="Comma-separated list of file extensions to scan", ) @click.pass_context def sync( ctx: click.Context, paths: tuple[str, ...], dry_run: bool, locale: tuple[str, ...], value: str, fill_from: str | None, patterns: str, file_types: str, ) -> None: """Sync missing i18n keys to locale files.""" import os locale_dir = ctx.obj["locale_dir"] output_format = ctx.obj["output_format"] verbose = ctx.obj["verbose"] if not os.path.isdir(locale_dir): click.echo(f"Error: Locale directory '{locale_dir}' does not exist.", err=True) sys.exit(1) pattern_list = [p.strip() for p in patterns.split(",")] file_type_list = [t.strip() for t in file_types.split(",")] if verbose: click.echo(f"Scanning paths: {', '.join(paths)}") click.echo(f"Locale directory: {locale_dir}") click.echo(f"Dry run: {dry_run}") try: keys = extract_keys(paths, pattern_list, file_type_list) parser = LocaleParser() syncer = Syncer(parser) changes = syncer.sync_keys( keys=keys, locale_dir=locale_dir, target_locales=list(locale) if locale else None, placeholder_value=value, fill_from_locale=fill_from, dry_run=dry_run, ) if output_format == "json": import json output = { "changes": changes, "total_changes": len(changes), } click.echo(json.dumps(output, indent=2)) else: reporter = Reporter() reporter.print_sync_changes(changes, dry_run) if not changes: if output_format != "json": click.echo("No changes needed.") except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) @main.command("report") @click.argument( "paths", type=click.Path(exists=True), nargs=-1, required=True, ) @click.option( "--output", type=click.Path(dir_okay=False, resolve_path=True), help="Write report to file", ) @click.option( "--format", type=click.Choice(["rich", "json", "markdown"]), default="rich", help="Report format", ) @click.option( "--include-details", is_flag=True, default=False, help="Include detailed key lists in report", ) @click.option( "--coverage-threshold", type=float, default=0.0, help="Coverage threshold for warnings (0-100)", ) @click.option( "--patterns", default="_,t,i18n.t,gettext,ngettext", help="Comma-separated list of i18n function patterns", ) @click.option( "--file-types", default="py,js,ts,jsx,tsx", help="Comma-separated list of file extensions to scan", ) @click.option( "--locale", multiple=True, help="Specific locale to report on (default: all)", ) @click.pass_context def report( ctx: click.Context, paths: tuple[str, ...], output: str | None, format: str, include_details: bool, coverage_threshold: float, patterns: str, file_types: str, locale: tuple[str, ...], ) -> None: """Generate i18n key report.""" import os locale_dir = ctx.obj["locale_dir"] verbose = ctx.obj["verbose"] if not os.path.isdir(locale_dir): click.echo(f"Error: Locale directory '{locale_dir}' does not exist.", err=True) sys.exit(1) pattern_list = [p.strip() for p in patterns.split(",")] file_type_list = [t.strip() for t in file_types.split(",")] if verbose: click.echo(f"Scanning paths: {', '.join(paths)}") click.echo(f"Locale directory: {locale_dir}") try: keys = extract_keys(paths, pattern_list, file_type_list) parser = LocaleParser() locale_keys = parser.parse_directory(locale_dir, list(locale) if locale else None) reporter = Reporter() report_content = reporter.generate_report( extracted_keys=keys, locale_keys=locale_keys, format=format, include_details=include_details, coverage_threshold=coverage_threshold, ) if output: with open(output, "w") as f: f.write(report_content) click.echo(f"Report written to {output}") else: click.echo(report_content) except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) if __name__ == "__main__": main()