diff --git a/i18n_key_sync/cli.py b/i18n_key_sync/cli.py new file mode 100644 index 0000000..27069a9 --- /dev/null +++ b/i18n_key_sync/cli.py @@ -0,0 +1,432 @@ +"""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()