""" 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 = [] # 실제 escape sequence (\r\n, \n, \r) 개수 확인 # 주의: '\r\n'은 실제 CR+LF 문자 (2바이트), '\\r\\n'은 리터럴 문자열 (4바이트) 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"]}건)')