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