OpenStack Weblate Migration - 2. Weblate Migration

이전 글 에서 작업 디렉토리를 구성하고 마이그레이션에 필요한 번역 파일을 준비했습니다. 지금부터 번역 파일을 가지고 어떻게 Weblate 번역 프로젝트를 구성했는지 설명하겠습니다.

전체적인 흐름


전체적인 흐름은 아래와 같습니다.

Weblate 구성 요소에 대해 알아보자! 을 읽어보시는 것을 추천드립니다.

  1. 프로젝트 생성
  2. 카테고리 생성
  3. 컴포넌트 생성
  4. 번역 생성
  5. 번역에 번역 파일 업로드

구체적으로 어떻게 설정했는지 아래에서 설명하겠습니다.

목표 설정


마이그레이션의 목표는 Zanata의 구조와 최대한 유사하게 이전하는 것입니다.

Zanata와 Weblate 모두 번역 플랫폼이므로 전반적으로 번역에 필요한 기능은 모두 가지고 있습니다. 그러나 파일 경로 설정 등 세부적인 내용에서 차이점이 존재합니다. 최대한 Weblate의 특성을 살리면서 Zanata의 구조를 유지하는 것이 이번 마이그레이션의 핵심 과제일 것입니다.

프로젝트 구조 설계


Zanata와 Weblate 플랫폼의 프로젝트의 일부 구성은 기능은 동일하지만 명칭이 다릅니다. 그러므로 기능별로 구성요소를 매핑하여 프로젝트 구조를 구성했습니다.

일부 기능은 Weblate에 존재하지 않아 대체 기능을 찾아 이를 보완했습니다.

  • Weblate에서는 version 기능이 따로 없어, 여러개의 컴포넌트를 묶을 수 있는 Category를 사용했습니다.

마이그레이션 방법 - 자동화 스크립트 제작


모든 오픈스택 프로젝트 문서가 마이그레이션 대상입니다. 전체 과정을 수작업으로 하는 것은 일관성이 떨어지고 많은 시간이 소요될 것입니다.
Weblate는 모든 구성요소에 대해 API를 제공합니다. 그러므로 효율적으로 마이그레이션을 위해 자동화 스크립트를 제작하기로 결정했습니다. Bash 스크립트를 기본적으로 작성하돼, 섬세한 예외처리가 필요한 API의 경우 Python 스크립트로 제작했습니다.

마이그레이션 과정


1. 프로젝트(Project) 생성

프로젝트(Project)는 최상단의 프로젝트 관리 단위로 번역할 프로젝트를 의미합니다.

기본적으로 아래의 속성이 필요합니다.

  • website를 opendev 주소로 설정했습니다.
속성 설명 값 (예시)
name 프로젝트 이름 horizon
slug URL에 나타낼 프로젝트 slug horizon
website 프로젝트에 대한 URL openstack/horizon: OpenStack Dashboard (Horizon) - horizon - OpenDev: Free Software Needs Free Tools

프로젝트가 존재하는지 사전에 검증하고, 존재하지 않으면 새로운 프로젝트를 생성했습니다.

def create_project(self, project_name:str):
"""Create a new project

If the project does not exist, create a new one.
        
:param project_name: The name of the project
"""
        
path = f'projects/{sanitize_slug(project_name)}/'
url = urljoin(self.base_url, path)
response = self._get(url)

if response.status_code == 200:
    print("[DEBUG] Project already exists: ", project_name)
elif response.status_code == 404:
    print("[DEBUG] Project does not exist: ", project_name)
    
    path = 'projects/'
    url = urljoin(self.base_url, path)
    data = {
        'name': project_name,
        'slug': sanitize_slug(project_name),
        'website': f'https://opendev.org/openstack/{project_name}',
    }
    _ = self._post(url=url, data=data, raise_error=True)
    
    print("[DEBUG] Project created: ", project_name)
else:
    print("[ERROR] Failed to create project: ", json.dumps(response.json(), indent=4))

2. 카테고리(Category) 생성

카테고리(Category)는 여러개의 컴포넌트를 하나로 묶어서 관리할 수 있는 분류 단위입니다.
Zanata에서는 Version을 활용해서 오픈스택의 릴리즈 버전을 나타냅니다. 그러나 Weblate에서는 버전을 표시해주는 별도의 기능이 없습니다.
Zanata에서 Version의 구조를 분석해보니 모두 동일한 Document으로 구성되어 있었습니다. 그러므로 이와 비슷한 기능인 카테고리를 활용하여 Version을 구현했습니다.

카테고리를 생성하기 위해서는 아래의 속성이 필요합니다.

속성 설명 값 (예시)
name 카테고리 이름 2025.02
slug URL에 나타낼 프로젝트 slug 2025-02
project 프로젝트 URL https://<weblate.url>/api/projects/horizon

카테고리의 유무를 검증하고, 만약에 존재하지 않으면 카테고리를 생성했습니다.

path = 'categories/'
url = urljoin(self.base_url, path)
data = {
    'name': category_name,
    'slug': sanitize_slug(category_name),
    'project': urljoin(self.base_url, f'projects/{sanitize_slug(project_name)}/'),
}
_ = self._post(url=url, data=data, raise_error=True) 

3. 컴포넌트(Component) 생성

컴포넌트(Component)는 번역(Translation) 파일의 모음으로 주로 번역 대상을 지정하고 번역 파일 위치, 포맷 등에 대한 설정을 수행합니다. 번역본의 템플릿을 정하고, 번역 파일의 설정을 구성하는 중요한 구성요소입니다.

컴포넌트에서 사용되는 속성은 아래와 같습니다.

속성 설명 값 (예시)
name 컴포넌트 이름 django
slug URL에 나타낼 프로젝트 slug django
file_format 파일 형식 po
repo 번역 파일이 저장될 레포지토리 종류 local:
filemask 번역 파일 검색 및 배치 규칙을 정의하는 기준 locale/*/LC_MESSAGES/django.po
new_base 신규 언어 번역 추가 시 기준이 되는 원본 파일 위치 또는 경로 django.pot
category 카테고리 URL https://<weblate.url>/api/categories/2025-02
is_glossary glossary 여부 True
zipfile 레포지토리를 초기화하는데 사용되는 파일 django.pot(django.pot를 압축한 파일)
source_language 기준이 되는 언어 en_US
가장 중요하다고 생각하는 속성만 설명하겠습니다.

name

Zanata는 폴더 경로가 다르다면 동일한 컴포넌트 이름을 사용해도 됩니다. 반면에 Weblate는 기본적으로 프로젝트/카테고리/컴포넌트 구조로 폴더를 구성하여 번역 파일을 관리하여 동일한 컴포넌트 이름을 사용할 수 없습니다.

Weblate가 로컬 레포지토리를 사용할 경우 기본적으로 프로젝트/카테고리/컴포넌트 형태를 지닙니다. 그러나 원격 레포지토리를 사용할 경우 자유롭게 경로를 설정할 수 있습니다.

예로 Horizon은 서로 다른 경로로 구성된 djangojs Document를 가지고 있습니다.

서로 다른 경로에서 번역 파일이 저장되어 관리되어 djangojs 이름이 동일하더라도 서로 다르게 인식됩니다.구체적인 경로는 zanata.xml 내 rule를 통해 번역 파일 저장 규칙을 확인할 수 있습니다.

<rules>
  <rule pattern="**/*.pot">{path}/{locale_with_underscore}/LC_MESSAGES/{filename}.po</rule>
</rules>

규칙을 토대로 번역 파일이 아래와 같은 경로로 저장됩니다.

  • horizon/locale/ko_KR/LC_MESSAGES/djangojs.po
  • openstack_dashboard/locale/ko_KR/LC_MESSAGES/djangojs.po

반면 Weblate의 경우 기본적으로 프로젝트/카테고리/컴포넌트/파일마스크 형태로 번역파일이 관리됩니다.

  • horizon/master/djangojs/locale/ko_KR/LC_MESSAGES/djangojs.po

하나의 프로젝트/카테고리 내에는 하나의 djangojs 만 생성할 수 있으며, 모듈(horizon, openstack_dashboard)를 나타낼 수 있는 방안이 필요합니다. 그러므로 모듈이 여러개인 경우 <모듈 이름>-django/djangojs 로 컴포넌트 이름을 짓기로 결정했습니다.

file_format
Weblate에서는 PO,POT 뿐만 아니라 json 등 다양한 파일 형식도 지원됩니다. 그러나 저희는 Zanata에 있는 구조를 그대로 사용하므로 PO 형식을 사용합니다.

Weblate에서는 po, po-mono 형식 2가지를 지원합니다.

  • po : 번역 문자열에 원본 텍스트와 번역 텍스트가 쌍으로 저장됩니다.
  • po-mono: 원본 문자열과 번역 문자열을 별도로 관리합니다.

po에는 번역 문자열과 원본 텍스트가 쌍으로 구성되어 있기 때문에 한번에 번역 상태를 쉽게 파악하고 관리할 수 있습니다. 그래서 다양한 국가의 번역을 다루는 국제화에서는 po 형식을 주로 채택합니다.

repository
레포지토리는 번역 파일이 저장될 레포지토리 종류를 의미합니다.
Weblate에서는 크게 local, remote 방식으로 레포지토리를 관리할 수 있습니다. 그러나 저희 프로젝트에서는 로컬 스토리지를 사용하기로 결정했습니다.

filemask
파일 마스크는 신번역 파일 검색 및 배치 규칙을 정의하는 기준입니다.

번역 플랫폼은 새로운 번역 생성 시 * 에 국가 이름을 삽입합니다.
예로 */django.po 를 filemask로 설정했다고 가정해봅시다. 번역 플랫폼에서 한국(ko_KR) 번역을 추가하게 되면 레포지토리 내에 ko_KR/dango.po 형태로 저장됩니다.

파일 마스크는 프로젝트 유형에 따라 다르므로, 분기를 통해 파일마스크를 지정해야 합니다.

new_base/zipfile
new_base는 신규 언어 번역 추가 시 기준이 되는 원본 파일 위치 또는 경로를 의미합니다. 저희 프로젝트에서 번역의 기준이 되는 파일은 POT가 됩니다.
여기서 전제 조건은 POT가 레포지토리에 저장되어야 합니다. 그러므로 POT 파일을 압축한 후 zipfile 속성을 통해 레포지토리를 초기화했습니다.

is_glossary
glossary 컴포넌트인지 확인하는 속성입니다.
glossary는 용어집으로 빈번하게 쓰이는 기술 용어를 저장함으로써 번역의 일관성과 정확성을 보장하는데 도움을 줍니다. Weblate에서도 쉽게 glossary를 저장하고 사용할 수 있도록 서비스를 제공해주고 있습니다.

컴포넌트는 glossary 컴포넌트, 일반 컴포넌트 유형이 있습니다.

glossary 컴포넌트는 용어집을 의미하며, 번역 플랫폼을 통해 쉽게 관리할 수 있습니다. 다양한 컴포넌트와 공유가 가능하므로, 프로젝트 당 하나의 glossary만 구성할 수 있도록 구현했습니다.

기능적인 차이점은 file_mask 유형입니다.
tbx는 TermBase eXchange 를 뜻하며 용어집을 교환하기 위한 표준 XML 형식입니다. 주로 용어집 데이터 포맷으로 많이 사용됩니다.

path = f'projects/{sanitize_slug(project_name)}/components/'
url = urljoin(self.base_url, path)
data = {
    'name': 'glossary',
    'slug': 'glossary',
    'file_format': 'tbx',
    'filemask': '*.tbx',
    'repo': 'local:',
    'vcs': 'local',
    'source_language': 'en_US',
    "is_glossary": True,
}
_ = self._post(url=url, data=data, raise_error=True)

일반 컴포넌트의 경우 프로젝트 유형에 따라 filemask, new_base를 다르게 설정해줘야 합니다.

path = f'projects/{sanitize_slug(project_name)}/components/'
url = urljoin(self.base_url, path)
category_id = self._get_category_id(project_name, category_name)
category_url = urljoin(
    self.base_url,
    f"categories/{category_id}/")

# Create a zip file containing the pot file for Weblate
# component initialization.
# The new_base parameter will be set to the pot file name.
zip_buf = io.BytesIO()
with zipfile.ZipFile(
        zip_buf, 'w', zipfile.ZIP_DEFLATED) as zip_file:
    zip_file.write(pot_path, os.path.basename(pot_path))
# Set the pointer to the beginning of the zip for uploading.
zip_buf.seek(0)
file = {
    'zipfile': (
        f'{component_name}.zip',
        zip_buf,
        'application/zip',
    ),
}
data = {
    'name': component_name,
    'slug': sanitize_slug(component_name),
    'file_format': 'po',
    'filemask': get_filemask(component_name),
    'repo': 'local:',
    'vcs': 'local',
    'source_language': 'en_US',
    'new_base': f'{component_name}.pot',
    'category': category_url,
}
_ = self._post(url=url, data=data, file=file, raise_error=True)

4. 번역(Translation) 생성

번역(Translation)은 작업의 단위입니다. 실제 사용자는 플랫폼을 통해 번역에서 번역 작업을 수행하게 됩니다.

만약에 releasenotes 파일을 한글로 번역하고 싶다고 가정해봅시다. releasenotes의 원문 파일을 가져와 한글로 번역할 것입니다.
Weblate의 구성요소에 빗대어 설명해봅시다. releasenotes 컴포넌트에서 new_base를 가져와 file_mask 형태로 번역 파일이 저장될 것입니다. 이 설정만으로는 어떤 언어로 번역할 것인가가 정의되어 있지 않습니다. (filemask는 * 로 locale 코드 위치만 지정할 뿐 정확하게 명시되어 있지 않습니다.)

즉, 번역할 언어를 나타내는 구성 요소가 Translation이라고 생각하시면 됩니다.

path = (f'components/{sanitize_slug(project_name)}/'
    f'{sanitize_slug(category_name)}%252F'
    f'{sanitize_slug(component_name)}/'
    f'translations/')
url = urljoin(self.base_url, path)
data = {
    'language_code': locale,
}
_ = self._post(url=url, data=data, raise_error=True)

Weblate API의 경우 카테고리를 <카테고리 slug>%252F<컴포넌트 slug> 로 나타냅니다.

5. 번역 파일 업로드

이제부터 Zanata에 있는 번역 파일(PO)을 Weblate의 번역(Translation)에 옮겨야 합니다.

예로 Horizon 프로젝트에서 2025.02 버전의 releasenotes 한글 번역본을 업로드하고 싶다고 가정해봅시다.
Weblate는 REST API 구조로 구성되어 있어 URL에 아래와 같은 정보가 필요합니다.

  • 프로젝트: Horizon
  • 카테고리: 2025.02
  • 컴포넌트: releasenotes
  • 번역(locale): ko_KR

파일 업로드의 성공률을 높이기 위해 재시도 로직을 3회 추가했습니다.
Weblate에서는 파일 문제가 아닌 이상 200으로 반환되며 result 응답값으로 상태를 표시합니다.

// 성공
{
  "not_found": 0,
  "skipped": 0,
  "accepted": 7,
  "total": 329,
  "result": true,
  "count": 329
}

// 실패 
{
  "not_found": 0,
  "skipped": 0,
  "accepted": 0,
  "total": 7,
  "result": false,
  "count": 7
}

테스트를 하면서 첫 업로드에서는 실패했지만 두 번째 업로드에는 성공한 패턴이 반복되었습니다. 서버 파일 완료까지 일정 시간이 소요되는 것으로 판단하여 일정한 대기 시간과 재시도 로직을 추가했습니다.

retry_count = 3
locale = sanitize_locale(locale)
path = (f'translations/{sanitize_slug(project_name)}/'
        f'{sanitize_slug(category_name)}%252F'
        f'{sanitize_slug(component_name)}/'
        f'{locale}/file/')
url = urljoin(self.base_url, path)
for cnt in range(retry_count):
    sleep_time = 30
    print(f"[DEBUG] Uploading PO file: {po_path}, "
          f"Retry count: {cnt + 1}")
    with open(po_path, 'rb') as f:
        file = {
            'file': f,
        }
        response = self._post(url=url, file=file, raise_error=True)

        # If the upload is successful, out of the loop.
        if (response.status_code == 200 and
                response.json()['result'] is True):
            print("[DEBUG] Upload successful: ",
                  component_name, locale)
            return

        time.sleep(sleep_time)

마무리


Zanata의 구조와 유사하게 Weblate로 이관하는 방법에 대해 설명했습니다.

현재 오픈스택 공식 사이트에 마이그레이션을 수행한 것은 아닙니다. 프로젝트 유형 지원 테스트, 마이그레이션 정확성 테스트를 수행하여 커뮤니티에서 원활하게 번역 작업을 수행할 수 있도록 보완할 예정입니다.