Files
i18n-key-sync/i18n_key_sync/cli.py
7000pctAUTO ea6883dbd6
Some checks failed
CI / test (push) Has been cancelled
Add source files
2026-02-02 03:56:41 +00:00

433 lines
11 KiB
Python

"""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()