انتقل إلى المحتوى

لماذا t-strings؟

أربع طرق لإدخال قيمة في رسالة قابلة للترجمة، مقارنةً على الرسالة نفسها. تسمّي الطرق الأربع جميعها عناصرها النائبة وتتيح للمترجم إعادة ترتيبها؛ وهي تختلف فيما يحدث حين تكون الترجمة خاطئة، وفي مقدار ما يستطيع الكتالوج بلوغه من برنامجك، وفي كلفة اعتمادها.

تأتي الجداول أولاً، فتجد الصف الذي يهمك ثم تقرأ القسم الذي وراءه وحده.

ثلاثة أطراف تلمس كل رسالة مترجمة

الكتالوج هو ملف الترجمات — بصيغة .po أثناء تحرير البشر له، ويُجمّع إلى .mo كي يحمّله التطبيق (يمر الدرس التعليمي بكليهما). ثلاثة أطراف تلمس كل رسالة: المطوّر يكتب نص المصدر، والمترجم يحرر الكتالوج — على منصة خارجية في الغالب، بعيداً عن أي مراجعة للشيفرة — والتطبيق يعرض الاثنين معاً وقت التشغيل. يجيب كل أسلوب تنسيق أدناه عن السؤال نفسه بطريقة مختلفة: ما المقدار الذي ينبغي أن يتحكم فيه الكتالوج من لغة التنسيق؟ في الأمثلة، _ هو الاسم المتعارف عليه لدالة الترجمة، وtr هو اسم هذه المكتبة.

مقارنة مباشرة

حين يخطئ المترجم. يمر الكتالوج بأيدٍ كثيرة، ومعظم ما يسوء فيه عرضي:

%(name)s .format() flufl.i18n $name t"…"
ترجمة تحذف عنصراً نائباً — ماذا يُعرض؟ تختفي القيمة بصمت تختفي القيمة بصمت تختفي القيمة بصمت رسالة المصدر، مع تحذير (افتراضياً)
ترجمة تضيف عنصراً نائباً مجهولاً — ماذا يُعرض؟ استثناء استثناء يبقى العنصر النائب ظاهراً كنص رسالة المصدر، مع تحذير (افتراضياً)
ترجمة تعيد تنسيق عنصر نائب — ماذا يُعرض؟ ما طلبه الكتالوج، أو استثناء إن لم يعد حرف النوع يلائم القيمة ما طلبه الكتالوج غير قابل للتعبير عنه في سلاسل $ رسالة المصدر، مع تحذير
هل تُفحص العناصر النائبة وقت العرض؟ لا لا لا نعم (انظر أدناه)

ما السلطة التي يملكها الكتالوج. الترجمة بيانات آتية من خارج مستودعك، وكل أسلوب يمنحها قدراً مختلفاً من القوة:

%(name)s .format() flufl.i18n $name t"…"
من أين تأتي القيم؟ خريطة صريحة وسائط صريحة متغيرات المستدعي المحلية والعامة، مع extras اختيارية القيم الملتقطة داخل t-string
هل يستطيع الكتالوج تغيير طريقة تنسيق قيمة؟ نعم نعم لا لا
هل يستطيع الكتالوج النفاذ إلى الكائنات (الوصول إلى الخصائص)؟ لا نعم نعم، بأسماء منقوطة لا
أين تعيش "اللغة الحالية"؟ حيث يضعها التطبيق حيث يضعها التطبيق مكدس من رموز اللغات على كائن التطبيق المشترك ContextVar، لكل مهمة أو طلب

ما تكلفة الدمج. كل ما سبق مجاني إن لاءمتك الأدوات؛ وهنا قد لا تلائمك:

%(name)s .format() flufl.i18n $name t"…"
الحد الأدنى من Python أي إصدار أي إصدار 3.10 3.14
النضج المكتبة القياسية المكتبة القياسية إصدار مستقر alpha
يستخدم كتالوجات PO/MO عادية؟ نعم نعم نعم نعم
يحتاج إلى مستخرج مصدر مخصص؟ لا لا لا نعم، حالياً
ما علامة PO التي يستنتجها Babel كي تتحقق الأدوات الحالية؟ python-format python-brace-format لا توجد python-brace-format

عن الفحص وقت العرض: تُفحص الرسائل المفردة بحثاً عن تطابق تام للعناصر النائبة. وتُفحص رسائل الجمع أيضاً، وفق قاعدة الاتحاد والتقاطع التي تسمح لصيغ جمع اللغة الهدف بأن تختلف عن صيغ المصدر؛ أما الفحص الأدق لكل صيغة فيجري عند تجميع الكتالوجات (الاستخراج).

يتعلق صف علامة التنسيق بالتحقق المدرك للعناصر النائبة، لا بتوافق الكتالوج. تعني لا توجد أن أدوات gettext القياسية لا تزال تقرأ الرسالة وتجمعها، لكن msgfmt --check-format لا يملك قواعد لعناصر $ النائبة كي يطبقها.

التوافق والنضج

الصفان الأولان من الجدول الأخير هما اللذان يحسمان قرار الاعتماد، ولذلك يستحقان أن يُقالا صراحةً لا أن يبقيا خانتين.

صيغة % و.format() مدمجتان في Python ولا تحتاجان إلى أي تبعية على الإطلاق. وflufl.i18n حزمة ناضجة، صدرت وتُستخدم في الإنتاج، وتعمل على Python 3.10 وما بعده. أما gettext-tstrings فهي alpha وتتطلب Python 3.14 أو أحدث، لأن t-strings صيغة جديدة في 3.14 — فلا يوجد نقل خلفي ولا يمكن أن يوجد. ومواصفتها هي الجزء المستقر منها، أما Python API فقد يتغير قبل الإصدار 1.0.

أما ما لا تكلّفه أي منها فهو توافق الكتالوج. فالطرق الأربع جميعها تنتج ملفات POT/PO/MO عادية يقرأها بالفعل كل محرر PO وكل منصة ترجمة وكل أداة من أدوات GNU gettext، ولذلك فالاختيار أدناه قابل للتراجع بصورة لا تتيحها تغييرات صيغ الكتالوجات. ويغطي الترحيل نقل مشروع قائم.

تعرض الأقسام التالية كل مفاضلة بالتفصيل، طريقةً تلو الأخرى.

التنسيق بعلامة %

_("Hello %(name)s") % {"name": name}

ما الذي قد يسوء: عنصر نائب متلف يصير استثناءً وقت التشغيل، ما لم يلتقطه التحقق من الكتالوج أولاً.

يحمل نص الكتالوج صيغة printf، بما فيها حرف نوع أخير — حرف s في %(name)s — يسهل إغفاله ويسهل إتلافه:

>>> "Hello %(name)" % {"name": "Ada"}  # the trailing "s" was deleted
Traceback (most recent call last):
  ...
ValueError: incomplete format

يصبح تعديل حرف واحد في محرر PO استثناءً وقت التشغيل ما لم يلتقطه التحقق من الكتالوج أولاً. يكتشف msgfmt --check-format من GNU هذه الحالة، لكن فقط للرسائل الموسومة python-format وعندما يمر الكتالوج فعلاً عبر msgfmt في طريقه إلى تطبيقك.

str.format

_("Hello {name}").format(name=name)

يزيل حرف النوع الأخير مع إبقاء العنصر النائب مسمّى وقابلاً لإعادة الترتيب بحرية. أما ما قد يسوء فينتقل إلى الجانب الآخر من التبادل: تكتسب الترجمة سلطة على كائناتك.

str.format لغة تعبيرات صغيرة، واستدعاؤها على نص يعني منح ذلك النص حق استخدام هذه اللغة:

>>> "{name.__class__.__mro__}".format(name="Ada")
"(<class 'str'>, <class 'object'>)"
>>> settings.api_key = "sk-live-…"
>>> "{conf.api_key}".format(conf=settings)
'sk-live-…'

الآن استبدل بتلك النصوص الحرفية ما تعيده _(). إذا عادت ترجمة Hello {name} على شكل {conf.api_key}، فإن عرضها يطبع مفتاح API الخاص بك — الكتالوج، لا شيفرتك، هو من قرر ما الذي يُقرأ. الكتالوج ليس شيفرة، لكنه يسافر كما تسافر البيانات: إلى منصة ترجمة، عبر أيدٍ عديدة، عائداً بصيغة .po، مجمّعاً إلى .mo، وأحياناً مورَّداً من خارج مشروعك كلياً. تمنح .format() كل محطة في تلك الرحلة حق الوصول إلى خصائص الكائنات التي تمررها.

سلاسل $ وflufl.i18n

from flufl.i18n import initialize

_ = initialize("example")

name = "Ada"
print(_("Hello $name"))  # Hello Ada — the value came from the caller's locals

توفر المكتبة القياسية لغة الاستيفاء $name عبر string.Template، لكنها ليست بحد ذاتها API للترجمة. يجمع flufl.i18n هذا الأسلوب مع البحث في كتالوجات gettext. لاحظ أن القيمة لا تُمرر أبداً: يبني flufl.i18n نطاق أسماء الاستبدال من المتغيرات العامة والمحلية للمستدعي — فأي متغير موجود عند موضع الاستدعاء يصبح متاحاً للرسالة. وتكون لخريطة extras الاختيارية الأولوية على كليهما. لا تتضمن الصيغة الموجهة للمترجم حرف نوع أخيراً أو محدد تنسيق، وتبقى العناصر النائبة قابلة لإعادة الترتيب بحرية.

لا يثير الاستبدال غير المتاح استثناءً. مع name = "Ada" وعدم وجود nombre في نطاق أسماء المستدعي، تُعرض ترجمة الكتالوج Hello $nombre على شكل Hello $nombre، فيبقى العنصر النائب غير المحلول ظاهراً. يحافظ هذا السلوك الموثق على بقية الرسالة المترجمة بدلاً من إفشال الاستدعاء. ومع ذلك، قد تستمر الاستثناءات المثارة أثناء حل خاصية أو تحويل قيمة في الانتشار.

يتفوق flufl.i18n في جانب ذي صلة على string.Template المجرد. يقبل Template المخصص الخاص به عناصر نائبة منقوطة مثل $settings.api_key، ويحل المترجم تلك المسارات بالنسبة إلى قيم المستدعي. يمكن لعنصر نائب مترجم تسمية أي متغير محلي أو عام متاح للمستدعي، واجتياز خصائصه باستخدام صيغة النقاط. وهذا مناسب عندما تحتاج الرسالة إلى خاصية، لكنه يجعل إطار المستدعي أيضاً جزءاً من نطاق أسماء الاستبدال للكتالوج. تصف المقارنة هنا flufl.i18n 6.0.0، لا كل استخدام ممكن لـstring.Template.

كما تجيب عن سؤال يتركه أسلوبا التنسيق الآخران للتطبيق بالكامل: ما اللغة الحالية، وكيف تُغيَّر. يحتفظ كائن التطبيق بمكدس من اللغات، وتحركه _.push(code) و_.pop()، وتتداخل معه with _.using(code):، وتعثر الاستراتيجية على الكتالوج المقابل لرمز لغة ما، فلا يتعامل التطبيق مع كائنات الكتالوج بنفسه أبداً. والحالة التي وُجد هذا من أجلها هي خادم عليه أن ينتج نصاً بأكثر من لغة خلال وحدة عمل واحدة — صفحة للقارئ، وإشعار لشخص ضُبط حسابه على لغة أخرى.

يعيش المكدس على كائن التطبيق ذاك، وتتشاركه العملية كلها. ولذلك يتشارك طلبان متداخلان مكدساً واحداً، وتسلّم الكتل التي لا تتداخل تداخلاً صارماً في الزمن إحداها الأخرى اللغة الخطأ:

async def greet(code, delay):
    with _.using(code):
        await asyncio.sleep(delay)
        return _("Hello $name")


async def main():
    return await asyncio.gather(greet("fr", 0.01), greet("ja", 0.02))
>>> asyncio.run(main())  # "fr" entered first and left first, so it read "ja" off the top
['こんにちは Ada', 'Bonjour Ada']

تحتفظ هذه المكتبة بالقدرة نفسها — إذ تتداخل الروابط وتُفك بالترتيب ذاته — لكن داخل ContextVar بدلاً من مكدس مشترك، فيُحل التشابك أعلاه لكل مهمة على حدة. وتجد المكافئات في عدة لغات في آن واحد. أما ما لا توفره فهو البحث عن الكتالوج انطلاقاً من رمز اللغة: أنت من يمرر كائن الترجمة، وهو في الحالة الشائعة استدعاء واحد لـgettext.translation()، وتحتفظ المكتبة القياسية بالكتالوج المحلَّل في ذاكرة مؤقتة.

t-strings

tr(t"Hello {name}")

لا يزال الكتالوج يرى Hello {name} ويبقى كتالوج PO/MO عادياً. الفرق هو ما يُسمح للترجمة أن تقوله، ومن الذي يتحقق من ذلك.

تفحص هذه المكتبة كل ترجمة مقابل عناصر الرسالة المصدر النائبة قبل العرض، ولا تقبل إلا الأسماء البسيطة. مقابل t"Hello {name}":

ما تحتويه الترجمة سبب الرفض
{name.__class__.__mro__} placeholder {name.__class__.__mro__} must be a plain name, copied from the source message unchanged
{name!r} placeholder {name} adds formatting; write {name} on its own, because the source message decides how the value is formatted
{0} placeholder {0} must be a plain name, copied from the source message unchanged
{nombre} translation does not match the source placeholders: {name} is missing; {nombre} is not in the source message

الرفض لا يعني الانهيار: تسجل المكتبة افتراضياً تحذيراً وتعرض رسالة المصدر، فلا يُسقط كتالوج سيئ التطبيق أبداً — وهو العقد نفسه الذي يحافظ عليه gettext.

يبقى التنسيق حيث كُتب، في الشيفرة:

amount = 1234.5
tr(t"Total: {amount:,.2f}")  # msgid is "Total: {amount}"

لا تصل :,.2f إلى الكتالوج أبداً، فلا تستطيع أي ترجمة تغييرها، ولا يضطر أي مترجم إلى النظر إليها. لكنه تنسيق ثابت لا تنسيق موطَّن — فاختيار الأرقام والفواصل بحسب اللغة مهمة Babel، قبل الاستدعاء.

فرق أخير هو الأدوات: t-strings صيغة جديدة، ولذلك يتطلب استخراجها إلى .pot حالياً مستخرجاً يدرك t-string، مثل الذي توفره هذه الحزمة لـBabel.

كلفة القيد

بعد شرط إصدار Python، ثمن هذا كله قاعدة واحدة: يجب أن يكون الاستيفاء اسماً بسيطاً.

tr(t"Hello {user.name}")  # raises InvalidTemplateError at the call site
name = user.name  # compute it first
tr(t"Hello {name}")

هذا قيد حقيقي، وهو القيد نفسه الذي ينتج الضمانات السابقة. وإلى جانب ربط القيم في المصدر وفحص العناصر النائبة وقت التشغيل، يمنع نصوص الكتالوج من تقييم التعبيرات ويُبقي أسماء العناصر النائبة ذات معنى لمن يترجمها.

ولا يمكن استخدام f-string بهذه الطريقة مطلقاً؛ فحين تراها أي مكتبة تكون نصاً مكتملاً بالفعل، ولذلك فإن ترجمتها تعني ترجمة جزء. تُبقي t-strings (PEP 750) النص الثابت والقيم منفصلين مع الحفاظ على صيغة تشبه f-string وربط القيم صراحةً.

أما كيف وصلت Python إلى هنا — مقترحا PEP بينهما عشر سنوات، ونقاش المكتبة القياسية الذي أُغلق دون إجابة — فتُروى القصة مع مصادرها في صفحة الخلفية.