This commit is contained in:
432
i18n_key_sync/cli.py
Normal file
432
i18n_key_sync/cli.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user