번역툴 2.0 업데이트

This commit is contained in:
Gnill82
2025-10-29 13:32:42 +09:00
parent fbef7989e4
commit e189a64631
24 changed files with 2486 additions and 1 deletions

1
lib/__init__.py Normal file
View File

@ -0,0 +1 @@
# DS_L10N Library Modules

87
lib/config_loader.py Normal file
View File

@ -0,0 +1,87 @@
"""
Configuration Loader for DS_L10N
YAML 설정 파일 로더
"""
import yaml
from pathlib import Path
from typing import Dict, Any
class Config:
"""설정 관리 클래스"""
def __init__(self, config_path: Path):
self.config_path = config_path
self.data: Dict[str, Any] = {}
self.base_dir = config_path.parent
self.load()
def load(self):
"""YAML 설정 파일 로드"""
if not self.config_path.exists():
raise FileNotFoundError(f"설정 파일을 찾을 수 없습니다: {self.config_path}")
with open(self.config_path, 'r', encoding='utf-8') as f:
self.data = yaml.safe_load(f)
def get(self, key: str, default=None):
"""설정 값 가져오기 (점 표기법 지원)"""
keys = key.split('.')
value = self.data
for k in keys:
if isinstance(value, dict):
value = value.get(k)
else:
return default
if value is None:
return default
return value
def get_path(self, key: str, default=None) -> Path:
"""경로 설정 가져오기 (상대 경로를 절대 경로로 변환)"""
value = self.get(key, default)
if value is None:
return None
path = Path(value)
# 상대 경로면 base_dir 기준으로 절대 경로 변환
if not path.is_absolute():
path = (self.base_dir / path).resolve()
return path
def get_all_paths(self) -> Dict[str, Path]:
"""모든 경로 설정 가져오기"""
paths_config = self.get('paths', {})
return {key: self.get_path(f'paths.{key}') for key in paths_config.keys()}
def get_languages(self) -> Dict[str, Any]:
"""언어 설정 가져오기"""
return {
'source': self.get('languages.source', 'ko'),
'targets': self.get('languages.targets', [])
}
def get_validation_config(self) -> Dict[str, bool]:
"""검증 설정 가져오기"""
return self.get('validation', {})
def __getitem__(self, key: str):
"""딕셔너리 스타일 접근"""
return self.get(key)
def __repr__(self):
return f"Config(config_path={self.config_path})"
def load_config(config_path: Path = None) -> Config:
"""설정 파일 로드"""
if config_path is None:
# 현재 스크립트 위치 기준으로 config.yaml 찾기
config_path = Path(__file__).parent.parent / 'config.yaml'
return Config(config_path)

161
lib/file_manager.py Normal file
View File

@ -0,0 +1,161 @@
"""
File Manager for DS_L10N
파일 자동 관리 및 정리
"""
import shutil
from pathlib import Path
from datetime import datetime, timedelta
from typing import List, Tuple
class FileManager:
"""파일 관리자"""
def __init__(self, config: dict, logger):
self.config = config
self.logger = logger
self.cleanup_config = config.get('cleanup', {})
def cleanup_old_files(self, target_dir: Path) -> Tuple[int, int]:
"""
오래된 파일 정리
Returns:
(archived_count, deleted_count)
"""
if not self.cleanup_config.get('auto_archive', True):
self.logger.info('자동 정리가 비활성화되어 있습니다.')
return 0, 0
keep_recent = self.cleanup_config.get('keep_recent_files', 5)
archive_patterns = self.cleanup_config.get('archive_patterns', [])
archive_dir = Path(self.config.get('paths', {}).get('archive_dir', './archive'))
archived_count = 0
self.logger.info(f'파일 정리 시작: {target_dir}')
self.logger.info(f'최근 {keep_recent}개 파일 유지, 나머지는 보관')
for pattern in archive_patterns:
files = sorted(target_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
if len(files) <= keep_recent:
continue
# 오래된 파일들
old_files = files[keep_recent:]
for file_path in old_files:
archived = self._archive_file(file_path, archive_dir)
if archived:
archived_count += 1
# 오래된 로그 파일 삭제
logs_dir = Path(self.config.get('paths', {}).get('logs_dir', './logs'))
deleted_count = self._delete_old_logs(logs_dir)
if archived_count > 0:
self.logger.success(f'{archived_count}개 파일 보관 완료')
if deleted_count > 0:
self.logger.success(f'{deleted_count}개 오래된 로그 파일 삭제')
return archived_count, deleted_count
def _archive_file(self, file_path: Path, archive_dir: Path) -> bool:
"""파일을 archive 폴더로 이동"""
try:
archive_dir.mkdir(parents=True, exist_ok=True)
# 날짜별 하위 폴더
date_folder = archive_dir / datetime.now().strftime('%Y%m')
date_folder.mkdir(parents=True, exist_ok=True)
# 대상 경로
dest_path = date_folder / file_path.name
# 이미 존재하면 타임스탬프 추가
if dest_path.exists():
timestamp = datetime.now().strftime('%H%M%S')
dest_path = date_folder / f"{file_path.stem}_{timestamp}{file_path.suffix}"
shutil.move(str(file_path), str(dest_path))
self.logger.info(f' 📦 보관: {file_path.name}{dest_path.relative_to(archive_dir)}')
return True
except Exception as e:
self.logger.warning(f'파일 보관 실패: {file_path.name} - {e}')
return False
def _delete_old_logs(self, logs_dir: Path) -> int:
"""오래된 로그 파일 삭제"""
if not logs_dir.exists():
return 0
delete_days = self.cleanup_config.get('delete_old_logs_days', 30)
cutoff_date = datetime.now() - timedelta(days=delete_days)
deleted_count = 0
for log_file in logs_dir.glob('*.log'):
try:
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
if mtime < cutoff_date:
log_file.unlink()
self.logger.debug(f' 🗑️ 삭제: {log_file.name}')
deleted_count += 1
except Exception as e:
self.logger.warning(f'로그 파일 삭제 실패: {log_file.name} - {e}')
return deleted_count
def delete_old_backups(self, localization_root: Path) -> int:
"""오래된 백업 파일 삭제"""
keep_days = self.config.get('backup', {}).get('keep_backups_days', 7)
cutoff_date = datetime.now() - timedelta(days=keep_days)
deleted_count = 0
self.logger.info(f'백업 파일 정리 중 ({keep_days}일 이상 된 파일 삭제)...')
for lang_folder in localization_root.iterdir():
if not lang_folder.is_dir():
continue
for backup_file in lang_folder.glob('*.backup_*.po'):
try:
mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
if mtime < cutoff_date:
backup_file.unlink()
self.logger.debug(f' 🗑️ 백업 삭제: {backup_file.name}')
deleted_count += 1
except Exception as e:
self.logger.warning(f'백업 파일 삭제 실패: {backup_file.name} - {e}')
if deleted_count > 0:
self.logger.success(f'{deleted_count}개 백업 파일 삭제 완료')
return deleted_count
def ensure_directories(self):
"""필요한 디렉토리 생성"""
paths = [
Path(self.config.get('paths', {}).get('output_dir', './output')),
Path(self.config.get('paths', {}).get('logs_dir', './logs')),
Path(self.config.get('paths', {}).get('archive_dir', './archive')),
Path(self.config.get('paths', {}).get('temp_dir', './temp')),
]
for path in paths:
if not path.is_absolute():
# 상대 경로를 절대 경로로 변환
base_dir = Path(__file__).parent.parent
path = (base_dir / path).resolve()
path.mkdir(parents=True, exist_ok=True)
self.logger.debug(f'디렉토리 확인: {path}')

193
lib/logger.py Normal file
View File

@ -0,0 +1,193 @@
"""
Colored Console Logger for DS_L10N
컬러 콘솔 로깅 시스템
"""
import logging
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
# Windows에서 ANSI 컬러 지원 활성화
if sys.platform == 'win32':
import os
os.system('') # ANSI escape sequences 활성화
# ANSI 컬러 코드
class Colors:
RESET = '\033[0m'
BOLD = '\033[1m'
# 기본 색상
BLACK = '\033[30m'
RED = '\033[31m'
GREEN = '\033[32m'
YELLOW = '\033[33m'
BLUE = '\033[34m'
MAGENTA = '\033[35m'
CYAN = '\033[36m'
WHITE = '\033[37m'
# 밝은 색상
BRIGHT_RED = '\033[91m'
BRIGHT_GREEN = '\033[92m'
BRIGHT_YELLOW = '\033[93m'
BRIGHT_BLUE = '\033[94m'
BRIGHT_MAGENTA = '\033[95m'
BRIGHT_CYAN = '\033[96m'
BRIGHT_WHITE = '\033[97m'
# 이모지 아이콘
class Icons:
SUCCESS = ''
ERROR = ''
WARNING = '⚠️'
INFO = ''
ROCKET = '🚀'
GEAR = '⚙️'
FILE = '📄'
FOLDER = '📁'
CHART = '📊'
SEARCH = '🔍'
CLEAN = '🧹'
BACKUP = '💾'
CLOCK = '⏱️'
class ColoredFormatter(logging.Formatter):
"""컬러 로그 포매터"""
LEVEL_COLORS = {
'DEBUG': Colors.CYAN,
'INFO': Colors.BLUE,
'WARNING': Colors.YELLOW,
'ERROR': Colors.RED,
'CRITICAL': Colors.BRIGHT_RED + Colors.BOLD,
'SUCCESS': Colors.GREEN,
}
LEVEL_ICONS = {
'DEBUG': Icons.GEAR,
'INFO': Icons.INFO,
'WARNING': Icons.WARNING,
'ERROR': Icons.ERROR,
'CRITICAL': Icons.ERROR,
'SUCCESS': Icons.SUCCESS,
}
def __init__(self, colored=True):
super().__init__()
self.colored = colored
def format(self, record):
if self.colored:
level_color = self.LEVEL_COLORS.get(record.levelname, '')
level_icon = self.LEVEL_ICONS.get(record.levelname, '')
reset = Colors.RESET
# 타임스탬프
timestamp = datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S')
# 레벨명 포맷
level_name = f"{level_color}{record.levelname:<8}{reset}"
# 메시지
message = record.getMessage()
return f"[{timestamp}] {level_icon} {level_name} {message}"
else:
timestamp = datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S')
return f"[{timestamp}] [{record.levelname:<8}] {record.getMessage()}"
class DSLogger:
"""DS_L10N 전용 로거"""
def __init__(self, name: str = 'DS_L10N',
console_level: str = 'INFO',
file_level: str = 'DEBUG',
log_file: Optional[Path] = None,
colored: bool = True):
self.logger = logging.getLogger(name)
self.logger.setLevel(logging.DEBUG)
self.logger.handlers.clear() # 기존 핸들러 제거
# 콘솔 핸들러
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(getattr(logging, console_level.upper()))
console_handler.setFormatter(ColoredFormatter(colored=colored))
self.logger.addHandler(console_handler)
# 파일 핸들러
if log_file:
log_file.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setLevel(getattr(logging, file_level.upper()))
file_handler.setFormatter(ColoredFormatter(colored=False))
self.logger.addHandler(file_handler)
# SUCCESS 레벨 추가 (INFO와 WARNING 사이)
logging.addLevelName(25, 'SUCCESS')
def debug(self, message: str):
self.logger.debug(message)
def info(self, message: str):
self.logger.info(message)
def success(self, message: str):
self.logger.log(25, message)
def warning(self, message: str):
self.logger.warning(message)
def error(self, message: str):
self.logger.error(message)
def critical(self, message: str):
self.logger.critical(message)
def separator(self, char: str = '=', length: int = 80):
"""구분선 출력"""
self.logger.info(char * length)
def section(self, title: str, icon: str = Icons.ROCKET):
"""섹션 제목 출력"""
self.separator()
self.logger.info(f"{icon} {title}")
self.separator()
def stats(self, **kwargs):
"""통계 정보 출력"""
self.logger.info(f"{Icons.CHART} 통계:")
for key, value in kwargs.items():
self.logger.info(f" - {key}: {value}")
# 전역 로거 인스턴스
_global_logger: Optional[DSLogger] = None
def get_logger() -> DSLogger:
"""전역 로거 가져오기"""
global _global_logger
if _global_logger is None:
_global_logger = DSLogger()
return _global_logger
def init_logger(config: dict, log_file: Optional[Path] = None) -> DSLogger:
"""로거 초기화"""
global _global_logger
logging_config = config.get('logging', {})
_global_logger = DSLogger(
console_level=logging_config.get('console_level', 'INFO'),
file_level=logging_config.get('file_level', 'DEBUG'),
log_file=log_file,
colored=logging_config.get('colored_output', True)
)
return _global_logger

387
lib/po_handler.py Normal file
View File

@ -0,0 +1,387 @@
"""
PO File Handler for DS_L10N
polib 기반의 안정적인 PO 파일 처리
"""
import polib
import csv
from pathlib import Path
from typing import Dict, List, Tuple, Optional
from datetime import datetime
from dataclasses import dataclass
@dataclass
class POUpdateResult:
"""PO 업데이트 결과"""
total: int
updated: int
failed: int
skipped: int
errors: List[Tuple[str, str]] # (msgctxt, error_message)
class POHandler:
"""PO 파일 핸들러"""
def __init__(self, config: dict, logger):
self.config = config
self.logger = logger
self.po_filename = config.get('files', {}).get('po_filename', 'LocalExport.po')
def load_po_file(self, po_path: Path) -> Optional[polib.POFile]:
"""PO 파일 로드"""
try:
if not po_path.exists():
self.logger.error(f'PO 파일을 찾을 수 없습니다: {po_path}')
return None
po = polib.pofile(str(po_path), encoding='utf-8')
return po
except Exception as e:
self.logger.error(f'PO 파일 로드 실패: {po_path} - {e}')
return None
def extract_untranslated(self, po_path: Path, output_path: Path) -> int:
"""
미번역 항목 추출
Returns:
추출된 항목 개수
"""
self.logger.info(f'PO 파일 로드 중: {po_path.name}')
po = self.load_po_file(po_path)
if po is None:
return 0
# 미번역 항목 필터링
untranslated = [entry for entry in po if not entry.msgstr.strip()]
if not untranslated:
self.logger.info('미번역 항목이 없습니다.')
return 0
self.logger.info(f'미번역 항목 {len(untranslated)}건 발견')
# TSV 파일로 저장
self._save_to_tsv(untranslated, output_path)
self.logger.success(f'미번역 항목 추출 완료: {output_path}')
return len(untranslated)
def merge_to_csv(self, localization_root: Path, output_path: Path) -> int:
"""
여러 언어의 PO 파일을 하나의 CSV로 병합
Returns:
병합된 항목 개수
"""
self.logger.info(f'언어 폴더 탐색 중: {localization_root}')
# 언어 폴더 찾기
lang_folders = []
for item in localization_root.iterdir():
if item.is_dir():
po_file = item / self.po_filename
if po_file.exists():
lang_folders.append(item.name)
if not lang_folders:
self.logger.error(f'{self.po_filename} 파일을 포함하는 언어 폴더를 찾을 수 없습니다.')
return 0
self.logger.info(f'탐지된 언어: {", ".join(lang_folders)}')
# 각 언어별 PO 파일 파싱
merged_data = {}
for lang_code in lang_folders:
po_file_path = localization_root / lang_code / self.po_filename
self.logger.info(f' - {lang_code} 처리 중...')
po = self.load_po_file(po_file_path)
if po is None:
continue
for entry in po:
# msgctxt 추출 (언리얼 해시 키)
msgctxt = entry.msgctxt if entry.msgctxt else 'NoContext'
# SourceLocation 추출
source_location = entry.occurrences[0][0] if entry.occurrences else 'NoSourceLocation'
# 줄바꿈 문자를 문자열로 치환
msgctxt_escaped = self._escape_newlines(msgctxt)
msgid_escaped = self._escape_newlines(entry.msgid)
msgstr_escaped = self._escape_newlines(entry.msgstr)
source_location_escaped = self._escape_newlines(source_location)
# 키 생성 (msgctxt + SourceLocation)
key = (msgctxt_escaped, source_location_escaped)
if key not in merged_data:
merged_data[key] = {
'msgid': msgid_escaped,
'msgid_ko': None, # ko의 msgid를 별도로 저장
}
# ko 언어의 msgid는 별도로 저장
if lang_code == 'ko':
merged_data[key]['msgid_ko'] = msgid_escaped
# 언어별 번역문 저장
merged_data[key][lang_code] = msgstr_escaped
# CSV 레코드 생성
records = []
for (msgctxt, source_location), data in merged_data.items():
# ko의 msgid가 있으면 우선 사용
msgid = data.get('msgid_ko') if data.get('msgid_ko') else data.get('msgid')
record = {
'msgctxt': msgctxt,
'SourceLocation': source_location,
'msgid': msgid,
}
# 언어별 번역 추가
for key, value in data.items():
if key not in ['msgid', 'msgid_ko']:
record[key] = value
records.append(record)
if not records:
self.logger.error('병합할 데이터가 없습니다.')
return 0
# 언어 컬럼 정렬
all_langs = set()
for record in records:
all_langs.update(record.keys())
all_langs -= {'msgctxt', 'SourceLocation', 'msgid'}
# 선호 순서
preferred_order = self.config.get('languages', {}).get('targets', [])
ordered_langs = [lang for lang in preferred_order if lang in all_langs]
other_langs = sorted([lang for lang in all_langs if lang not in preferred_order])
final_langs = ordered_langs + other_langs
# CSV 저장
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
fieldnames = ['msgctxt', 'SourceLocation', 'msgid'] + final_langs
writer = csv.DictWriter(f, fieldnames=fieldnames, quoting=csv.QUOTE_ALL)
writer.writeheader()
writer.writerows(records)
self.logger.success(f'CSV 병합 완료: {output_path}')
self.logger.info(f'{len(records)}개 항목, {len(final_langs)}개 언어')
return len(records)
def update_from_tsv(self, tsv_path: Path, localization_root: Path,
backup: bool = True, dry_run: bool = False) -> Dict[str, POUpdateResult]:
"""
TSV 파일로 PO 파일 업데이트 (polib 사용)
Args:
tsv_path: 번역 TSV 파일 경로
localization_root: 언어 폴더들의 루트
backup: 백업 생성 여부
dry_run: 실제 파일 수정 없이 시뮬레이션
Returns:
언어별 업데이트 결과
"""
self.logger.info(f'TSV 파일 로드 중: {tsv_path}')
# TSV 파일 읽기
translations_by_lang = self._load_tsv(tsv_path)
if not translations_by_lang:
self.logger.error('TSV 파일에서 번역 데이터를 읽을 수 없습니다.')
return {}
self.logger.info(f'업데이트 대상 언어: {", ".join(translations_by_lang.keys())}')
results = {}
# 언어별로 PO 파일 업데이트
for lang_code, translations in translations_by_lang.items():
self.logger.info(f'\n언어 처리 중: {lang_code}')
lang_folder = localization_root / lang_code
if not lang_folder.is_dir():
self.logger.warning(f' 언어 폴더를 찾을 수 없습니다: {lang_folder}')
continue
po_path = lang_folder / self.po_filename
if not po_path.exists():
self.logger.warning(f' PO 파일을 찾을 수 없습니다: {po_path}')
continue
# PO 파일 업데이트
result = self._update_po_file(po_path, translations, backup, dry_run)
results[lang_code] = result
# 결과 출력
self._print_update_result(lang_code, result)
return results
def _update_po_file(self, po_path: Path, translations: Dict[str, str],
backup: bool, dry_run: bool) -> POUpdateResult:
"""단일 PO 파일 업데이트"""
result = POUpdateResult(
total=len(translations),
updated=0,
failed=0,
skipped=0,
errors=[]
)
# 백업 생성
if backup and not dry_run:
backup_path = self._create_backup(po_path)
if backup_path:
self.logger.info(f' 백업 생성: {backup_path.name}')
# PO 파일 로드
po = self.load_po_file(po_path)
if po is None:
result.failed = result.total
result.errors.append(('ALL', 'PO 파일 로드 실패'))
return result
# msgctxt로 인덱싱
po_index = {}
for entry in po:
if entry.msgctxt:
po_index[entry.msgctxt] = entry
# 번역문 업데이트
for msgctxt, new_msgstr in translations.items():
if msgctxt not in po_index:
result.failed += 1
result.errors.append((msgctxt, 'PO 파일에서 msgctxt를 찾을 수 없음'))
continue
entry = po_index[msgctxt]
current_msgstr = entry.msgstr
# 변경사항 없으면 스킵
if current_msgstr == new_msgstr:
result.skipped += 1
continue
# msgstr 업데이트
if not dry_run:
entry.msgstr = new_msgstr
result.updated += 1
# 파일 저장
if not dry_run and result.updated > 0:
try:
po.save(str(po_path))
except Exception as e:
self.logger.error(f' PO 파일 저장 실패: {e}')
result.errors.append(('SAVE', str(e)))
return result
def _load_tsv(self, tsv_path: Path) -> Dict[str, Dict[str, str]]:
"""TSV 파일 로드"""
translations_by_lang = {}
try:
with open(tsv_path, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f, delimiter='\t')
# 컬럼 확인
if not reader.fieldnames or len(reader.fieldnames) <= 1:
self.logger.error('TSV 파일이 탭(tab)으로 구분되지 않았습니다.')
return {}
# 제외할 컬럼
exclude_columns = {'msgctxt', 'SourceLocation', 'msgid'}
lang_codes = [col for col in reader.fieldnames if col not in exclude_columns]
# 언어별 딕셔너리 초기화
for lang in lang_codes:
translations_by_lang[lang] = {}
# 행 읽기
for row in reader:
msgctxt = row.get('msgctxt')
if not msgctxt:
continue
for lang in lang_codes:
msgstr = row.get(lang, '')
if msgstr: # 빈 문자열이 아니면 저장
translations_by_lang[lang][msgctxt] = msgstr
except Exception as e:
self.logger.error(f'TSV 파일 읽기 실패: {e}')
return {}
return translations_by_lang
def _save_to_tsv(self, entries: List[polib.POEntry], output_path: Path):
"""POEntry 리스트를 TSV로 저장"""
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.writer(f, delimiter='\t', quoting=csv.QUOTE_ALL)
# 헤더
writer.writerow(['msgctxt', 'SourceLocation', 'msgid'])
# 데이터
for entry in entries:
msgctxt = self._escape_newlines(entry.msgctxt or '')
source_location = entry.occurrences[0][0] if entry.occurrences else ''
msgid = self._escape_newlines(entry.msgid)
writer.writerow([msgctxt, source_location, msgid])
def _escape_newlines(self, text: str) -> str:
"""줄바꿈 문자를 문자열로 치환"""
return text.replace('\r', '\\r').replace('\n', '\\n')
def _unescape_newlines(self, text: str) -> str:
"""문자열 줄바꿈을 실제 문자로 변환"""
return text.replace('\\r', '\r').replace('\\n', '\n')
def _create_backup(self, po_path: Path) -> Optional[Path]:
"""백업 파일 생성"""
try:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = po_path.with_suffix(f'.backup_{timestamp}.po')
backup_path.write_bytes(po_path.read_bytes())
return backup_path
except Exception as e:
self.logger.warning(f'백업 생성 실패: {e}')
return None
def _print_update_result(self, lang_code: str, result: POUpdateResult):
"""업데이트 결과 출력"""
if result.updated > 0:
self.logger.success(f'{lang_code}: {result.updated}건 업데이트')
if result.skipped > 0:
self.logger.info(f' ⏭️ {lang_code}: {result.skipped}건 스킵 (변경사항 없음)')
if result.failed > 0:
self.logger.error(f'{lang_code}: {result.failed}건 실패')
# 실패 이유 출력 (최대 5개)
for msgctxt, error in result.errors[:5]:
self.logger.error(f' - {msgctxt}: {error}')
if len(result.errors) > 5:
self.logger.error(f' ... 외 {len(result.errors) - 5}건 더 있음')

284
lib/validator.py Normal file
View File

@ -0,0 +1,284 @@
"""
Translation Validator for DS_L10N
번역 검증 시스템
"""
import re
from typing import List, Dict, Tuple, Optional
from dataclasses import dataclass
from enum import Enum
class ValidationLevel(Enum):
"""검증 레벨"""
ERROR = 'ERROR' # 치명적 오류
WARNING = 'WARNING' # 경고
INFO = 'INFO' # 정보
@dataclass
class ValidationIssue:
"""검증 이슈"""
level: ValidationLevel
msgctxt: str
lang: str
category: str
message: str
original: str
translation: str
def __str__(self):
icon = {
ValidationLevel.ERROR: '',
ValidationLevel.WARNING: '⚠️',
ValidationLevel.INFO: ''
}[self.level]
return (f"{icon} [{self.level.value}] {self.lang}: {self.msgctxt}\n"
f" 분류: {self.category}\n"
f" 문제: {self.message}\n"
f" 원문: {self.original}\n"
f" 번역: {self.translation}")
class TranslationValidator:
"""번역 검증기"""
def __init__(self, config: dict):
self.config = config
self.validation_config = config.get('validation', {})
# 변수 패턴 컴파일
self.variable_pattern = re.compile(r'\{[A-Za-z0-9_]+\}')
# 리치 텍스트 태그 패턴
self.tag_patterns = config.get('rich_text', {}).get('tag_patterns', [])
def validate_entry(self, msgctxt: str, original: str, translation: str, lang: str) -> List[ValidationIssue]:
"""단일 항목 검증"""
issues = []
# 빈 번역 확인
if self.validation_config.get('check_empty_translations', True):
if not translation or not translation.strip():
issues.append(ValidationIssue(
level=ValidationLevel.WARNING,
msgctxt=msgctxt,
lang=lang,
category='빈 번역',
message='번역문이 비어있습니다',
original=original,
translation=translation
))
return issues # 빈 번역이면 다른 검증 건너뛰기
# 변수 일치 확인
if self.validation_config.get('check_variables', True):
var_issues = self._check_variables(msgctxt, original, translation, lang)
issues.extend(var_issues)
# 리치 텍스트 태그 확인
if self.validation_config.get('check_rich_text_tags', True):
tag_issues = self._check_rich_text_tags(msgctxt, original, translation, lang)
issues.extend(tag_issues)
# 줄바꿈 확인
if self.validation_config.get('check_newlines', True):
newline_issues = self._check_newlines(msgctxt, original, translation, lang)
issues.extend(newline_issues)
# 최대 길이 확인
max_length = self.validation_config.get('max_length_warning', 200)
if len(translation) > max_length:
issues.append(ValidationIssue(
level=ValidationLevel.INFO,
msgctxt=msgctxt,
lang=lang,
category='길이 초과',
message=f'번역문이 {max_length}자를 초과합니다 (현재: {len(translation)}자)',
original=original,
translation=translation
))
return issues
def _check_variables(self, msgctxt: str, original: str, translation: str, lang: str) -> List[ValidationIssue]:
"""변수 일치 확인"""
issues = []
orig_vars = set(self.variable_pattern.findall(original))
trans_vars = set(self.variable_pattern.findall(translation))
# 누락된 변수
missing_vars = orig_vars - trans_vars
if missing_vars:
issues.append(ValidationIssue(
level=ValidationLevel.ERROR,
msgctxt=msgctxt,
lang=lang,
category='변수 누락',
message=f'누락된 변수: {", ".join(sorted(missing_vars))}',
original=original,
translation=translation
))
# 추가된 변수
extra_vars = trans_vars - orig_vars
if extra_vars:
issues.append(ValidationIssue(
level=ValidationLevel.WARNING,
msgctxt=msgctxt,
lang=lang,
category='추가 변수',
message=f'원문에 없는 변수: {", ".join(sorted(extra_vars))}',
original=original,
translation=translation
))
return issues
def _check_rich_text_tags(self, msgctxt: str, original: str, translation: str, lang: str) -> List[ValidationIssue]:
"""리치 텍스트 태그 일치 확인"""
issues = []
# 여는 태그와 닫는 태그 개수 확인
for tag in self.tag_patterns:
if tag == '</>':
continue # 닫는 태그는 별도 처리
orig_count = original.count(tag)
trans_count = translation.count(tag)
if orig_count != trans_count:
issues.append(ValidationIssue(
level=ValidationLevel.ERROR,
msgctxt=msgctxt,
lang=lang,
category='태그 불일치',
message=f'태그 {tag} 개수 불일치 (원문: {orig_count}, 번역: {trans_count})',
original=original,
translation=translation
))
# 닫는 태그 </> 개수 확인
orig_close = original.count('</>')
trans_close = translation.count('</>')
if orig_close != trans_close:
issues.append(ValidationIssue(
level=ValidationLevel.ERROR,
msgctxt=msgctxt,
lang=lang,
category='태그 불일치',
message=f'닫는 태그 </> 개수 불일치 (원문: {orig_close}, 번역: {trans_close})',
original=original,
translation=translation
))
return issues
def _check_newlines(self, msgctxt: str, original: str, translation: str, lang: str) -> List[ValidationIssue]:
"""줄바꿈 문자 확인"""
issues = []
# \r\n, \n, \r 개수 확인
orig_crlf = original.count('\\r\\n')
trans_crlf = translation.count('\\r\\n')
orig_lf = original.count('\\n') - orig_crlf # \\r\\n에 포함된 \\n 제외
trans_lf = translation.count('\\n') - trans_crlf
orig_cr = original.count('\\r') - orig_crlf # \\r\\n에 포함된 \\r 제외
trans_cr = translation.count('\\r') - trans_crlf
if orig_crlf != trans_crlf or orig_lf != trans_lf or orig_cr != trans_cr:
issues.append(ValidationIssue(
level=ValidationLevel.WARNING,
msgctxt=msgctxt,
lang=lang,
category='줄바꿈 불일치',
message=f'줄바꿈 문자 개수 불일치 (원문: \\r\\n={orig_crlf}, \\n={orig_lf}, \\r={orig_cr} / '
f'번역: \\r\\n={trans_crlf}, \\n={trans_lf}, \\r={trans_cr})',
original=original,
translation=translation
))
return issues
def validate_batch(self, entries: List[Dict]) -> Tuple[List[ValidationIssue], Dict[str, int]]:
"""
여러 항목 일괄 검증
Args:
entries: [{'msgctxt': ..., 'msgid': ..., 'lang': ..., 'msgstr': ...}, ...]
Returns:
(issues, stats)
"""
all_issues = []
for entry in entries:
msgctxt = entry.get('msgctxt', '')
original = entry.get('msgid', '')
translation = entry.get('msgstr', '')
lang = entry.get('lang', '')
issues = self.validate_entry(msgctxt, original, translation, lang)
all_issues.extend(issues)
# 통계 생성
stats = {
'total': len(entries),
'errors': sum(1 for issue in all_issues if issue.level == ValidationLevel.ERROR),
'warnings': sum(1 for issue in all_issues if issue.level == ValidationLevel.WARNING),
'info': sum(1 for issue in all_issues if issue.level == ValidationLevel.INFO),
'passed': len(entries) - len(set(issue.msgctxt for issue in all_issues if issue.level == ValidationLevel.ERROR))
}
return all_issues, stats
def print_validation_report(self, issues: List[ValidationIssue], stats: Dict[str, int], logger):
"""검증 리포트 출력"""
logger.section('🔍 번역 검증 결과')
if not issues:
logger.success('✅ 모든 검증 통과!')
logger.stats(**stats)
return
# 레벨별로 그룹화
errors = [i for i in issues if i.level == ValidationLevel.ERROR]
warnings = [i for i in issues if i.level == ValidationLevel.WARNING]
infos = [i for i in issues if i.level == ValidationLevel.INFO]
# 오류 출력
if errors:
logger.error(f'\n❌ 오류 ({len(errors)}건):')
for issue in errors[:10]: # 최대 10개만 출력
logger.error(f'\n{issue}')
if len(errors) > 10:
logger.error(f'\n... 외 {len(errors) - 10}건 더 있음')
# 경고 출력
if warnings:
logger.warning(f'\n⚠️ 경고 ({len(warnings)}건):')
for issue in warnings[:10]: # 최대 10개만 출력
logger.warning(f'\n{issue}')
if len(warnings) > 10:
logger.warning(f'\n... 외 {len(warnings) - 10}건 더 있음')
# 정보 출력
if infos:
logger.info(f'\n 정보 ({len(infos)}건):')
for issue in infos[:5]: # 최대 5개만 출력
logger.info(f'\n{issue}')
if len(infos) > 5:
logger.info(f'\n... 외 {len(infos) - 5}건 더 있음')
# 통계 출력
logger.separator()
logger.stats(**stats)
if errors:
logger.error(f'\n검증 실패: {stats["errors"]}개의 오류가 발견되었습니다.')
else:
logger.success(f'\n검증 완료: {stats["passed"]}건 통과 (경고 {stats["warnings"]}건)')