ข้ามไปที่เนื้อหา

การใช้งานจริง

บทแนะนำ หมุนวงจรนี้เพียงรอบเดียว ทำคนเดียว กับโปรแกรมที่มีข้อความเพียงข้อความเดียว แต่ในโปรเจกต์จริงวงจรจะหมุนต่อไปไม่หยุด: ข้อความเปลี่ยนแปลงหลังจากถูกแปลไปแล้ว นักแปลทำงานอยู่ที่อื่นตามกำหนดเวลาของตนเอง และแคตตาล็อกที่คอมไพล์แล้วต้องจัดส่งไปพร้อมทุกรีลีส หน้านี้ว่าด้วยแนวปฏิบัติดังกล่าว — อะไรควรอยู่ในรีโพซิทอรี อะไรเดินทางไปมา CI ต้องกั้นอะไรไว้ และรันไทม์ผูกภาษาไว้ที่จุดใด

ทั้งหมดนี้สรุปได้เป็นการตรวจหกอย่าง จึงขอวางไว้ตรงนี้ก่อน แต่ละหัวข้อด้านล่างจัดวางหนึ่งในนั้นให้

  • pybabel update --check ผ่าน — ไม่มีข้อความใดเปลี่ยนโดยที่แคตตาล็อกไม่รับรู้
  • pybabel compile กั้น build ด้วยสถานะออกของมัน
  • รายการ fuzzy ที่เหลืออยู่เป็นความตั้งใจ — แต่ละรายการจะเรนเดอร์เป็นข้อความต้นทางจนกว่านักแปลจะยืนยัน
  • ชุดทดสอบเรนเดอร์แต่ละภาษาที่จัดส่งหนึ่งครั้งด้วย strict=True
  • สิ่งประดิษฐ์โปรดักชันมีไฟล์ .mo และไม่มี Babel
  • ล็อกเกอร์ gettext_tstrings ถูก route ไปยังระบบมอนิเตอริง

รูปร่างของโปรเจกต์

myapp/
├── babel.cfg
├── pyproject.toml
├── src/
│   └── myapp/
└── locales/
    ├── messages.pot
    ├── ja/LC_MESSAGES/messages.po
    └── de/LC_MESSAGES/messages.po

คอมมิต babel.cfg เทมเพลต .pot และไฟล์ .po ทุกไฟล์ — สิ่งเหล่านี้คือซอร์สของการ build งานแปล และ diff ของมันคือวิธีที่คุณรีวิวการเปลี่ยนแปลงของคำแปล ส่วนไฟล์ .mo ที่คอมไพล์แล้วเป็นสิ่งประดิษฐ์จากการ build (build artifact): สร้างมันใน CI หรือตอนทำแพ็กเกจแทนที่จะคอมมิต เพื่อให้ .po กับ .mo ของมันไม่มีวันขัดแย้งกันว่าอะไรถูกจัดส่งออกไป

มีไฟล์หนึ่งที่มีบทบาทในแต่ละทิศทาง: .pot พาข้อความของคุณ ออกไป หานักแปล ส่วนไฟล์ .po พาคำแปล กลับมา ส่วนที่เหลือของหน้านี้คือสิ่งที่เคลื่อนที่อยู่ระหว่างสองสิ่งนั้น

flowchart LR
  code["ซอร์สโค้ด<br>จุดเรียก t-string"] -->|"pybabel extract"| pot["messages.pot"]
  pot -->|"pybabel update"| po["ไฟล์ .po หนึ่งไฟล์ต่อภาษา"]
  po --> tr["นักแปล<br>หรือแพลตฟอร์ม"]
  tr --> po
  po -->|"pybabel compile (CI)"| mo["ไฟล์ .mo"]
  mo --> app["แอปพลิเคชัน<br>ณ รันไทม์"]

วงจรหลังการแปลครั้งแรก

pybabel init ในบทแนะนำโดยปกติรันเพียงครั้งเดียว ตอนที่เพิ่มภาษาใหม่ นับจากนั้นวงจรการทำงานคือ extract → update → translate → compile และศูนย์กลางของมันคือ pybabel update ซึ่งพับเทมเพลตชุดใหม่เข้ากับแคตตาล็อกที่มีอยู่โดยไม่ทิ้งคำแปลที่อยู่ในนั้นอยู่แล้ว

สมมติว่าคำทักทาย Hello {name} — ซึ่งแปลไว้แล้วว่า こんにちは {name} — ถูกแก้ถ้อยคำในโค้ดเป็น Welcome back, {name} สกัดข้อความแล้วอัปเดต:

$ pybabel extract -F babel.cfg -c "Translators:" -o locales/messages.pot .
extracting messages from app.py (encoding="utf-8")
writing PO template file to locales/messages.pot
$ pybabel update -i locales/messages.pot -d locales
updating catalog locales/ja/LC_MESSAGES/messages.po based on locales/messages.pot

ตอนนี้แคตตาล็อกภาษาญี่ปุ่นมีเนื้อหาดังนี้:

#. gettext-tstrings
#: app.py:4
#, fuzzy, python-brace-format
msgid "Welcome back, {name}"
msgstr "こんにちは {name}"

Babel สังเกตว่า msgid ใหม่คล้ายกับอันที่ถูกลบไป จึงจับคู่มันกับคำแปลเดิม — แต่ติดธง fuzzy ให้กับคู่นั้น: การเดาของเครื่องที่รอมนุษย์มายืนยัน ธงนี้เปลี่ยนสิ่งที่ถูกคอมไพล์ pybabel compile ไม่รวมรายการ fuzzy ลงใน .mo ดังนั้นจนกว่านักแปลจะยืนยันคู่นี้ แอปพลิเคชันจะเรนเดอร์ข้อความภาษาอังกฤษตัวใหม่ ไม่ใช่ภาษาญี่ปุ่นที่ตกยุคไปแล้ว:

$ pybabel compile -d locales
compiling catalog locales/ja/LC_MESSAGES/messages.po to locales/ja/LC_MESSAGES/messages.mo
$ python app.py
Welcome back, Ada

ข้อความที่ถูกเปลี่ยนจึงถอยกลับ (degrade) แบบเดียวกับข้อความที่พัง — กลับไปยังภาษาต้นทาง ไม่มีวันกลับไปยังคำแปลที่ล้าสมัย ส่วนของนักแปลในวงจรนี้คือแก้ msgstr แล้วลบธง fuzzy ออก การคอมไพล์ครั้งถัดไปจะเก็บรายการนั้นขึ้นมาเอง

ชื่อตัวยึดตำแหน่งเป็นส่วนหนึ่งของอัตลักษณ์ของข้อความ

msgid คือคีย์ของแคตตาล็อก และ ชื่อ ของตัวยึดตำแหน่งอยู่ข้างในนั้น — ดังนั้นการเปลี่ยนชื่อตัวแปรในโค้ด (name → user_name) จะเปลี่ยน msgid และส่งคำแปลของข้อความนั้นในทุกภาษากลับเข้าวงจร fuzzy อีกครั้ง จงตั้งชื่อตัวแปรที่ถูกแทรกค่าด้วยคำที่นักแปลจะเข้าใจ และเปลี่ยนชื่อก็ต่อเมื่อมีเหตุผลจริง ๆ เท่านั้น

การจัดรูปแบบเป็นภาพสะท้อนกลับด้าน: !r และ :.2f ไม่ใช่ส่วนหนึ่งของ msgid ดังนั้นการปรับ {amount:,.2f} ให้เป็น {amount:,.0f} จะไม่เปลี่ยนอะไรในแคตตาล็อกใดเลย แน่นอนว่าการแก้ถ้อยคำของ ประโยค เป็นการเปลี่ยนแปลงจริง — นั่นคือวงจรที่อธิบายไว้ข้างบน

สิ่งที่ CI ต้องกั้นไว้

ความล้มเหลวสามแบบคุ้มค่ากับ build สีแดง: แคตตาล็อกตามโค้ดไม่ทัน คำแปลทำตัวยึดตำแหน่งพัง หรือรายการที่พังหลุดรอดไปถึงรันไทม์ หนึ่งขั้นตอนต่อหนึ่งความล้มเหลว:

- run: pybabel extract -F babel.cfg -c "Translators:" -o locales/messages.pot .
- run: pybabel update -i locales/messages.pot -d locales --check
- run: pybabel compile -d locales
- run: pytest

pybabel update --check ไม่เขียนทับสิ่งใดและออกด้วยสถานะไม่เป็นศูนย์เมื่อแคตตาล็อกล้าหลังเทมเพลตที่เพิ่งสกัดมาใหม่ — เป็นด่านป้องกันการ merge โค้ดที่ไม่มีใครสกัดข้อความซ้ำ ส่วน pybabel compile จะรันการตรวจตัวยึดตำแหน่งทั้งของ Babel และของตัวตรวจที่แพ็กเกจนี้ลงทะเบียนไว้

Babel 2.18.0: --check กั้นแคตตาล็อกที่ใช้บริบทไม่ได้

บน Babel 2.18.0 pybabel update --check จะรายงานว่าแคตตาล็อกที่มี msgctxt ทุกอัน ล้าสมัย ทุกครั้งที่รัน ไม่ว่ามันจะทันสมัยเพียงใดก็ตาม ด่านที่ล้มเหลวตลอดเวลาแย่กว่าการไม่มีด่านเลย เพราะทีมจะปิดมันทิ้ง — ดังนั้นถ้าคุณใช้ pgettext หรือ npgettext แม้เพียงเล็กน้อย ก็จงแทนที่ขั้นตอนนี้แทนที่จะทนอยู่กับมัน การอ่านเทมเพลตและแคตตาล็อกแต่ละอันด้วย babel.messages.pofile.read_po แล้วเปรียบเทียบ {(m.context, m.id) for m in catalog if m.id} คือทั้งหมดของการตรวจนี้ และเป็นสิ่งที่การ build ของเว็บไซต์นี้เองทำอยู่ ส่วนสาเหตุนั้นมีบันทึกไว้ในหน้าหลุมพราง

ตรวจสถานะออก (exit status) ไม่ใช่ตรวจล็อก

pybabel compile รายงานข้อผิดพลาดตัวยึดตำแหน่งทีละรายการ ออกด้วยสถานะไม่เป็นศูนย์ — แต่ก็ยังเขียนไฟล์ .mo อยู่ดี ไปป์ไลน์ที่คอมไพล์แล้วคัดลอก locales/ เข้าอิมเมจจะจัดส่งแคตตาล็อกที่พังออกไป เว้นแต่สถานะไม่เป็นศูนย์นั้นจะหยุดมันได้จริง การปล่อยให้ขั้นตอนนี้ทำให้ build ล้มเหลวดังตัวอย่างข้างบนคือการแก้ไขทั้งหมดที่ต้องทำ

บรรทัดสุดท้ายคือชุดทดสอบตามปกติของคุณ โดยเพิ่มนิสัยหนึ่งอย่าง: ที่ไหนสักแห่งในนั้น ให้เรนเดอร์อย่างน้อยหนึ่งข้อความต่อหนึ่งภาษาที่จัดส่ง ผ่านตัวแปลแบบเข้มงวด —

import gettext

from gettext_tstrings import Translator


def test_catalogs_render(language: str) -> None:
    translations = gettext.translation("messages", localedir="locales", languages=[language])
    _ = Translator(translations, strict=True)
    name = "Ada"
    assert _(t"Welcome back, {name}")

— เพราะ strict=True ยกข้อยกเว้นตรงจุดที่โปรดักชันจะถอยกลับอย่างเงียบ ๆ และการเรนเดอร์ ณ รันไทม์คือการตรวจเพียงหนึ่งเดียวที่เห็นแคตตาล็อกในแบบเดียวกับที่แอปพลิเคชันจะเห็นเป๊ะ ๆ รวมทั้ง .mo และทุกสิ่ง

การทำงานกับนักแปลและแพลตฟอร์ม

ไฟล์ .po คือฟอร์แมตแลกเปลี่ยนของโลก gettext ทั้งใบ ซึ่งเป็นเหตุผลที่ไลบรารีนี้นำมันกลับมาใช้: การส่งมอบงานแปลหมายถึงการส่งมอบไฟล์หนึ่งไฟล์ ไม่ว่าผู้รับจะเป็นเพื่อนร่วมงานที่ใช้โปรแกรมแก้ไข PO หรือแพลตฟอร์มอย่าง Weblate หรือ Crowdin สามสิ่งต่อไปนี้ทำให้การส่งมอบราบรื่น:

บอกว่าข้อความนี้มีไว้เพื่ออะไร คอมเมนต์ในโค้ดจะเดินทางไปพร้อมกับข้อความ — นั่นคือสิ่งที่แฟล็ก -c "Translators:" เก็บรวบรวม:

from gettext_tstrings import tr

name = "Ada"
# Translators: shown on the dashboard right after sign-in
print(tr(t"Welcome back, {name}"))
#. Translators: shown on the dashboard right after sign-in
#. gettext-tstrings
#: app.py:5
#, python-brace-format
msgid "Welcome back, {name}"
msgstr ""

นักแปลจะเห็นคอมเมนต์นั้นในโปรแกรมแก้ไขของตนเอง อยู่ข้างข้อความ ที่อีกซีกโลกหนึ่ง มันคือคันโยกคุณภาพที่ถูกที่สุดในเวิร์กโฟลว์ทั้งหมด สำหรับคำที่พ้องรูปกับตัวเอง — "Open" ที่เป็นปุ่ม กับ "Open" ที่เป็นสถานะ — ให้ใส่บริบทให้ข้อความด้วย pgettext ซึ่งจะกลายเป็น msgctxt ที่มองเห็นได้ในแคตตาล็อก

ให้แพลตฟอร์มตรวจสอบตัวยึดตำแหน่ง ทุกข้อความที่สกัดจาก t-string จะติดแฟล็ก python-brace-format มาด้วย และบรรทัดเดียวนั้นคือสิ่งที่เปิดการทำ QA ตัวยึดตำแหน่งในเครื่องมือที่คุณควบคุมไม่ได้ — Weblate มีเอกสารอธิบายการตรวจนี้ แพลตฟอร์มเชิงพาณิชย์ผูกการตรวจของตนกับแฟล็กเดียวกัน และ msgfmt --check-format บังคับใช้มันในไปป์ไลน์ GNU ใดก็ได้ รายละเอียด รวมทั้งสิ่งที่ตัวตรวจที่มาพร้อมแพ็กเกจจับได้เกินกว่านั้น อยู่ที่หน้าการสกัดข้อความ

เชื่อตาข่ายนิรภัยเพียงเท่าที่มันครอบคลุมจริง สิ่งที่กลับมาจากแพลตฟอร์มยังคงเป็นข้อมูลที่เข้าสู่ build ของคุณ ด่าน CI ข้างบนคือสิ่งที่เปลี่ยน "แพลตฟอร์มน่าจะตรวจแล้วแหละ" ให้กลายเป็น "สิ่งนี้จัดส่งแบบพัง ๆ ออกไปไม่ได้"

การผูกภาษา ณ รันไทม์

ทุกอย่างที่ผ่านมาผลิตแคตตาล็อก การตัดสินใจที่เหลือคือแอปพลิเคชันจะเลือกแคตตาล็อกที่จุดใด จงผูกหนึ่งครั้งต่อ ขอบเขตของภาษา — โปรเซสสำหรับ CLI และคำขอ (request) สำหรับเว็บเซอร์วิส

เครื่องมือบรรทัดคำสั่งหรือแอปพลิเคชันเดสก์ท็อปอ่าน environment ของผู้ใช้เพียงครั้งเดียวตอนเริ่มโปรแกรม การไม่ส่ง languages= ปล่อยให้ไลบรารีมาตรฐานเจรจาจาก LANGUAGE, LC_ALL, LC_MESSAGES และ LANG ส่วน fallback=True คืนแคตตาล็อกว่าง — ข้อความต้นทาง — แทนที่จะยกข้อยกเว้นเมื่อไม่มีตัวใดตรงกับแคตตาล็อกที่คุณจัดส่ง

import gettext

from gettext_tstrings import Translator

_ = Translator(gettext.translation("messages", localedir="locales", fallback=True))

name = "Ada"
print(_(t"Welcome back, {name}"))

เว็บแอปพลิเคชันตัดสินใจต่อคำขอ โหลดแต่ละแคตตาล็อกเพียงครั้งเดียวตอน import แล้วผูกตัวที่เจรจาได้เข้ากับ context ก่อนที่ view จะรัน — set_translations เป็นแบบ context-local ดังนั้นคำขอที่มาพร้อมกันในภาษาต่างกันจะไม่มีวันเห็นการผูกของกันและกัน

import gettext

from flask import Flask, request

from gettext_tstrings import set_translations, tr

LANGUAGES = ("en", "ja", "de")
CATALOGS = {
    language: gettext.translation(
        "messages", localedir="locales", languages=[language], fallback=True
    )
    for language in LANGUAGES
}

app = Flask(__name__)


@app.before_request
def bind_language() -> None:
    language = request.accept_languages.best_match(LANGUAGES) or "en"
    set_translations(CATALOGS[language])


@app.get("/")
def home() -> str:
    name = "Ada"
    return tr(t"Welcome back, {name}")

ภายใต้เฟรมเวิร์กแบบ async — FastAPI, Starlette และอะไรก็ตามที่เป็น ASGI — ให้ห่อคำขอด้วย use_translations: การผูกอาศัยอยู่ใน ContextVar ซึ่งการสลับ async task รักษาไว้ให้เป็นรายคำขอ

import gettext

from fastapi import FastAPI, Request

from gettext_tstrings import tr, use_translations

LANGUAGES = ("en", "ja", "de")
CATALOGS = {
    language: gettext.translation(
        "messages", localedir="locales", languages=[language], fallback=True
    )
    for language in LANGUAGES
}

app = FastAPI()


@app.middleware("http")
async def bind_language(request: Request, call_next):
    language = negotiate_language(request.headers.get("accept-language"), LANGUAGES)
    with use_translations(CATALOGS[language]):
        return await call_next(request)

negotiate_language เป็นตัวแทนของการแยกวิเคราะห์ Accept-Language ของคุณ — เฟรมเวิร์กส่วนใหญ่หรือระบบนิเวศของมันมีให้อยู่แล้ว สิ่งที่สำคัญตรงนี้คือการผูกที่ล้อมรอบ call_next

การทดลอง ASGI รันมิดเดิลแวร์นี้ภายใต้ FastAPI ด้วยคำขอ 200 รายการที่ซ้อนทับกันในสองภาษา ทุกคำขอเรนเดอร์ด้วยภาษาของตัวเอง รวมถึงใน endpoint แบบ def เนื้อหาที่สตรีมออกไป และงานเบื้องหลังด้วย และการผูกก็ถูกคืนค่ากลับหลังจาก handler โยนข้อยกเว้น ให้ผูกที่นี่หรือใน dependency แบบ async def แต่อย่าผูกใน dependency แบบ def ธรรมดาที่ใช้ yield เพราะ FastAPI รันมันในเธรดผู้ทำงาน endpoint จึงไม่เห็นการผูกเลย และการออกจากบล็อกจะโยน ValueError

นิสัยรันไทม์อีกสองอย่างเติมภาพให้สมบูรณ์ สตริงที่ถูกสร้างตอน import — ป้ายกำกับฟอร์ม ชื่อแสดงผลของ enum — ต้องไม่จับเอาภาษาที่บังเอิญแอ็กทีฟอยู่ระหว่าง import ให้นิยามมันด้วย lazy_gettext แล้วมันจะเรนเดอร์ในภาษาที่แอ็กทีฟ ณ ตอน ใช้งาน และให้ route ล็อกเกอร์ gettext_tstrings ไปยังที่ที่มีมนุษย์คอยดู: คำเตือนของมันคือโหมดผ่อนปรนที่กำลังรายงานคำแปลซึ่งหลุดรอดทุกด่านมาได้ หนึ่งบรรทัดต่อหนึ่งข้อความที่พัง ไม่ใช่หนึ่งบรรทัดต่อหนึ่งการเรนเดอร์

การจัดส่ง

โปรดักชันต้องการเพียงแพ็กเกจกับไฟล์ .mo และไม่ต้องการอะไรอื่นอีก Babel เป็น dependency สำหรับการพัฒนาและ CI — เก็บ gettext-tstrings[babel] ให้พ้นจากอิมเมจโปรดักชันแล้วติดตั้งแพ็กเกจเปล่าที่นั่น การเรนเดอร์รันบนไลบรารีมาตรฐานล้วน ๆ ให้คอมไพล์แคตตาล็อกใน build เดียวกับที่ผลิตสิ่งประดิษฐ์ (artifact) ที่คุณ deploy เพื่อให้ไฟล์ .mo ข้างในเป็นไฟล์ .po ที่ผ่านการรีวิวแล้วเป๊ะ ๆ และไม่มีสิ่งใดที่คอมไพล์บนแล็ปท็อปของใครบางคนหลุดออกไปได้เลย

วิธีที่ไฟล์เหล่านี้เดินทางไปนั้นขึ้นอยู่กับสิ่งที่คุณ deploy โดย wheel พามันไปในฐานะข้อมูลของแพ็กเกจ (package data) ซึ่งหมายความว่าแคตตาล็อกต้องอยู่ ข้างใน ไดเรกทอรีของแพ็กเกจ — src/myapp/locales/ ไม่ใช่ locales/ ที่ระดับบนสุด — และต้องบอกแบ็กเอนด์ของการ build ให้รวมไฟล์ที่ปกติ .gitignore ซ่อนไว้ด้วย:

[tool.hatch.build]
# .mo files are build output, so they are gitignored; name them or the
# wheel ships without a single translation.
artifacts = ["src/myapp/locales/**/*.mo"]
[tool.setuptools.package-data]
myapp = ["locales/*/LC_MESSAGES/*.mo"]

จงอ่านมันกลับผ่านตัวแพ็กเกจ แทนที่จะอ่านผ่านพาธที่อ้างอิงกับ source tree ซึ่งจะไม่มีอยู่อีกต่อไปทันทีที่ wheel ถูกติดตั้ง:

import gettext
from importlib.resources import as_file, files

with as_file(files("myapp") / "locales") as localedir:
    translations = gettext.translation("messages", localedir=localedir, languages=["ja"])

อิมเมจคอนเทนเนอร์มีงานที่ง่ายกว่า: คอมไพล์ระหว่าง build stage แล้วคัดลอกผลลัพธ์ออกมา โดยทิ้ง Babel ไว้ใน stage นั้น

FROM python:3.14-slim AS build
COPY . /src
RUN cd /src && python -m pip install ".[babel]" \
    && pybabel compile -d src/myapp/locales

FROM python:3.14-slim
COPY --from=build /src /src
RUN python -m pip install /src   # no [babel]: rendering needs the stdlib only

ก่อนออกรีลีส เช็กลิสต์ที่หน้านี้ย่อลงมาเหลือคือ:

  • pybabel update --check ผ่าน — ไม่มีข้อความใดเปลี่ยนโดยที่แคตตาล็อกไม่รับรู้
  • pybabel compile กั้น build ด้วยสถานะออกของมัน
  • รายการ fuzzy ที่เหลืออยู่เป็นความตั้งใจ — แต่ละรายการจะเรนเดอร์เป็นข้อความต้นทางจนกว่านักแปลจะยืนยัน
  • ชุดทดสอบเรนเดอร์แต่ละภาษาที่จัดส่งหนึ่งครั้งด้วย strict=True
  • สิ่งประดิษฐ์โปรดักชันมีไฟล์ .mo และไม่มี Babel
  • ล็อกเกอร์ gettext_tstrings ถูก route ไปยังระบบมอนิเตอริง

อ่านต่อ

  • การสกัดข้อความ — เอกสารอ้างอิงสำหรับครึ่งฝั่งเครื่องมือของหน้านี้: ตัวเลือกการ mapping ชื่อฟังก์ชันกำหนดเอง โหมดเข้มงวด และตัวตรวจทุกตัว
  • คู่มือ — ครึ่งฝั่งรันไทม์: รูปพหูพจน์ บริบท สตริงแบบเลื่อนการแปล และโหมดความล้มเหลวโดยละเอียด
  • หลักการทำงาน — ทำไม msgid ถึงหน้าตาแบบนั้น และการตรวจสอบตรวจอะไรกันแน่