This commit is contained in:
273
i18n_key_sync/sync.py
Normal file
273
i18n_key_sync/sync.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"""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]]
|
||||||
Reference in New Issue
Block a user