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

274 lines
8.3 KiB
Python

"""Synchronization of i18n keys with locale files."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
@dataclass
class SyncChange:
"""Represents a single sync change."""
locale: str
key: str
old_value: Optional[Any] = None
new_value: Any = None
action: str = "add"
@dataclass
class SyncResult:
"""Result of a sync operation."""
changes: List[SyncChange] = field(default_factory=list)
locales_updated: List[str] = field(default_factory=list)
@property
def total_changes(self) -> int:
"""Total number of changes."""
return len(self.changes)
class Syncer:
"""Synchronize i18n keys with locale files."""
def __init__(self, locale_parser):
"""Initialize the syncer.
Args:
locale_parser: LocaleParser instance.
"""
self.parser = locale_parser
def sync_keys(
self,
keys: set,
locale_dir: str,
target_locales: Optional[List[str]] = None,
placeholder_value: str = "TODO: Translate this string",
fill_from_locale: Optional[str] = None,
dry_run: bool = False,
) -> List[SyncChange]:
"""Sync missing keys to locale files.
Args:
keys: Set of keys extracted from source code.
locale_dir: Directory containing locale files.
target_locales: Specific locales to sync (None for all).
placeholder_value: Value to use for new keys.
fill_from_locale: Locale to copy values from.
dry_run: If True, don't actually modify files.
Returns:
List of SyncChange objects.
"""
locale_dir_path = Path(locale_dir)
locale_keys = self.parser.parse_directory(locale_dir, target_locales)
changes = []
if fill_from_locale:
source_data = self._get_locale_data(locale_dir, fill_from_locale)
else:
source_data = None
locales_to_update = set(locale_keys.keys()) if locale_keys else set()
if target_locales:
locales_to_update = set(target_locales) & locales_to_update
for locale in locales_to_update:
existing_keys = locale_keys.get(locale, set())
missing_keys = keys - existing_keys
if missing_keys:
locale_file = locale_dir_path / f"{locale}.json"
if not locale_file.exists():
locale_file = locale_dir_path / f"{locale}.yaml"
if locale_file.exists():
data = self.parser.parse_file(locale_file)
else:
data = {}
for key in sorted(missing_keys):
if source_data:
new_value = self.parser.get_value(source_data, key)
if new_value is None:
new_value = placeholder_value
else:
new_value = placeholder_value
old_value = self.parser.get_value(data, key)
change = SyncChange(
locale=locale,
key=key,
old_value=old_value,
new_value=new_value,
action="add" if old_value is None else "update",
)
changes.append(change)
if not dry_run:
self.parser.add_key(data, key, new_value)
if not dry_run:
format = "yaml" if locale_file.suffix in [".yaml", ".yml"] else "json"
self.parser.write_file(locale_file, data, format)
return changes
def _get_locale_data(self, locale_dir: str, locale: str) -> Dict[str, Any]:
"""Get data for a specific locale.
Args:
locale_dir: Directory containing locale files.
locale: Locale name.
Returns:
Dictionary of locale data.
"""
locale_dir_path = Path(locale_dir)
locale_file = locale_dir_path / f"{locale}.json"
if not locale_file.exists():
locale_file = locale_dir_path / f"{locale}.yaml"
if locale_file.exists():
return self.parser.parse_file(locale_file)
return {}
def sync_to_new_locale(
self,
keys: set,
locale_dir: str,
new_locale: str,
source_locale: Optional[str] = None,
placeholder_value: str = "TODO: Translate this string",
dry_run: bool = False,
) -> List[SyncChange]:
"""Create a new locale file with the given keys.
Args:
keys: Set of keys to include.
locale_dir: Directory for locale files.
new_locale: Name of the new locale.
source_locale: Locale to copy values from.
placeholder_value: Default value for missing translations.
dry_run: If True, don't actually create files.
Returns:
List of SyncChange objects.
"""
changes = []
data: Dict[str, Any] = {}
if source_locale:
source_data = self._get_locale_data(locale_dir, source_locale)
else:
source_data = None
for key in sorted(keys):
if source_data:
value = self.parser.get_value(source_data, key)
if value is None:
value = placeholder_value
else:
value = placeholder_value
change = SyncChange(
locale=new_locale,
key=key,
old_value=None,
new_value=value,
action="add",
)
changes.append(change)
if not dry_run:
self.parser.add_key(data, key, value)
if not dry_run and changes:
locale_file = Path(locale_dir) / f"{new_locale}.json"
self.parser.write_file(locale_file, data, "json")
return changes
def remove_unused_keys(
self,
locale_keys: Dict[str, set],
used_keys: set,
locale_dir: str,
dry_run: bool = False,
) -> List[SyncChange]:
"""Remove keys from locale files that are not used in code.
Args:
locale_keys: Dictionary mapping locales to their key sets.
used_keys: Set of keys used in source code.
locale_dir: Directory containing locale files.
dry_run: If True, don't actually modify files.
Returns:
List of SyncChange objects.
"""
changes = []
locale_dir_path = Path(locale_dir)
for locale, keys in locale_keys.items():
unused_keys = keys - used_keys
if not unused_keys:
continue
locale_file = locale_dir_path / f"{locale}.json"
if not locale_file.exists():
locale_file = locale_dir_path / f"{locale}.yaml"
if locale_file.exists():
data = self.parser.parse_file(locale_file)
for key in sorted(unused_keys):
old_value = self.parser.get_value(data, key)
if old_value is not None:
change = SyncChange(
locale=locale,
key=key,
old_value=old_value,
new_value=None,
action="remove",
)
changes.append(change)
if not dry_run:
self._remove_key_from_data(data, key)
if not dry_run and changes:
format = "yaml" if locale_file.suffix in [".yaml", ".yml"] else "json"
self.parser.write_file(locale_file, data, format)
return changes
def _remove_key_from_data(self, data: Dict[str, Any], key: str) -> None:
"""Remove a key from nested data structure.
Args:
data: Nested dictionary.
key: Key in dot notation.
"""
parts = key.split(".")
current = data
for part in parts[:-1]:
if part in current:
current = current[part]
else:
return
if parts[-1] in current:
del current[parts[-1]]