diff --git a/frappe/__init__.py b/frappe/__init__.py index 1568aa9f20..7b2d04cef3 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -118,6 +118,23 @@ def _(msg: str, lang: str | None = None, context: str | None = None) -> str: return translated_string or non_translated_string +def _lt(msg: str, lang: str | None = None, context: str | None = None): + """Lazily translate a string. + + + This function returns a "lazy string" which when casted to string via some operation applies + translation first before casting. + + This is only useful for translating strings in global scope or anything that potentially runs + before `frappe.init()` + + Note: Result is not guaranteed to equivalent to pure strings for all operations. + """ + from frappe.translate import LazyTranslate + + return LazyTranslate(msg, lang, context) + + def as_unicode(text, encoding: str = "utf-8") -> str: """Convert to unicode if required.""" if isinstance(text, str): diff --git a/frappe/gettext/translate.py b/frappe/gettext/translate.py index 4e83ba96d3..e715d41ff8 100644 --- a/frappe/gettext/translate.py +++ b/frappe/gettext/translate.py @@ -7,7 +7,7 @@ from datetime import datetime from pathlib import Path from babel.messages.catalog import Catalog -from babel.messages.extract import extract_from_dir +from babel.messages.extract import DEFAULT_KEYWORDS, extract_from_dir from babel.messages.mofile import read_mo, write_mo from babel.messages.pofile import read_po, write_po @@ -128,6 +128,9 @@ def generate_pot(target_app: str | None = None): apps = [target_app] if target_app else frappe.get_all_apps(True) default_method_map = get_method_map("frappe") + keywords = DEFAULT_KEYWORDS.copy() + keywords["_lt"] = None + for app in apps: app_path = frappe.get_pymodule_path(app) catalog = get_catalog(app) @@ -138,7 +141,7 @@ def generate_pot(target_app: str | None = None): method_map.extend(default_method_map) for filename, lineno, message, comments, context in extract_from_dir( - app_path, method_map, directory_filter=directory_filter + app_path, method_map, directory_filter=directory_filter, keywords=keywords ): if not message: continue @@ -284,6 +287,8 @@ def get_translations_from_mo(lang, app): locale_dir = get_locale_dir() mo_file = gettext.find(app, locale_dir, (lang,)) + if not mo_file: + return translations with open(mo_file, "rb") as f: catalog = read_mo(f) for m in catalog: diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index d63579dff6..2dad3df4fd 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -3,7 +3,7 @@ # model __init__.py import frappe -from frappe import _ +from frappe import _, _lt data_fieldtypes = ( "Currency", @@ -134,22 +134,22 @@ log_types = ( ) std_fields = [ - {"fieldname": "name", "fieldtype": "Link", "label": _("ID")}, - {"fieldname": "owner", "fieldtype": "Link", "label": _("Created By"), "options": "User"}, - {"fieldname": "idx", "fieldtype": "Int", "label": _("Index")}, - {"fieldname": "creation", "fieldtype": "Datetime", "label": _("Created On")}, - {"fieldname": "modified", "fieldtype": "Datetime", "label": _("Last Updated On")}, + {"fieldname": "name", "fieldtype": "Link", "label": _lt("ID")}, + {"fieldname": "owner", "fieldtype": "Link", "label": _lt("Created By"), "options": "User"}, + {"fieldname": "idx", "fieldtype": "Int", "label": _lt("Index")}, + {"fieldname": "creation", "fieldtype": "Datetime", "label": _lt("Created On")}, + {"fieldname": "modified", "fieldtype": "Datetime", "label": _lt("Last Updated On")}, { "fieldname": "modified_by", "fieldtype": "Link", - "label": _("Last Updated By"), + "label": _lt("Last Updated By"), "options": "User", }, - {"fieldname": "_user_tags", "fieldtype": "Data", "label": _("Tags")}, - {"fieldname": "_liked_by", "fieldtype": "Data", "label": _("Liked By")}, - {"fieldname": "_comments", "fieldtype": "Text", "label": _("Comments")}, - {"fieldname": "_assign", "fieldtype": "Text", "label": _("Assigned To")}, - {"fieldname": "docstatus", "fieldtype": "Int", "label": _("Document Status")}, + {"fieldname": "_user_tags", "fieldtype": "Data", "label": _lt("Tags")}, + {"fieldname": "_liked_by", "fieldtype": "Data", "label": _lt("Liked By")}, + {"fieldname": "_comments", "fieldtype": "Text", "label": _lt("Comments")}, + {"fieldname": "_assign", "fieldtype": "Text", "label": _lt("Assigned To")}, + {"fieldname": "docstatus", "fieldtype": "Int", "label": _lt("Document Status")}, ] diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 232e2fcd81..7c4a1cf15e 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -22,7 +22,7 @@ from datetime import datetime import click import frappe -from frappe import _ +from frappe import _, _lt from frappe.model import ( child_table_fields, data_fieldtypes, @@ -42,17 +42,17 @@ from frappe.modules import load_doctype_module from frappe.utils import cast, cint, cstr DEFAULT_FIELD_LABELS = { - "name": lambda: _("ID"), - "creation": lambda: _("Created On"), - "docstatus": lambda: _("Document Status"), - "idx": lambda: _("Index"), - "modified": lambda: _("Last Updated On"), - "modified_by": lambda: _("Last Updated By"), - "owner": lambda: _("Created By"), - "_user_tags": lambda: _("Tags"), - "_liked_by": lambda: _("Liked By"), - "_comments": lambda: _("Comments"), - "_assign": lambda: _("Assigned To"), + "name": _lt("ID"), + "creation": _lt("Created On"), + "docstatus": _lt("Document Status"), + "idx": _lt("Index"), + "modified": _lt("Last Updated On"), + "modified_by": _lt("Last Updated By"), + "owner": _lt("Created By"), + "_user_tags": _lt("Tags"), + "_liked_by": _lt("Liked By"), + "_comments": _lt("Comments"), + "_assign": _lt("Assigned To"), } @@ -249,7 +249,7 @@ class Meta(Document): return df.get("label") if fieldname in DEFAULT_FIELD_LABELS: - return DEFAULT_FIELD_LABELS[fieldname]() + return str(DEFAULT_FIELD_LABELS[fieldname]) return "No Label" diff --git a/frappe/tests/test_translate.py b/frappe/tests/test_translate.py index ff776528f2..425ca28bb2 100644 --- a/frappe/tests/test_translate.py +++ b/frappe/tests/test_translate.py @@ -7,7 +7,7 @@ from unittest.mock import patch import frappe import frappe.translate -from frappe import _ +from frappe import _, _lt from frappe.gettext.extractors.javascript import extract_javascript from frappe.tests.utils import FrappeTestCase from frappe.translate import ( @@ -32,6 +32,9 @@ first_lang, second_lang, third_lang, fourth_lang, fifth_lang = choices( ) +_lazy_translations = _lt("Communication") + + class TestTranslate(FrappeTestCase): guest_sessions_required = [ "test_guest_request_language_resolution_with_cookie", @@ -46,6 +49,7 @@ class TestTranslate(FrappeTestCase): frappe.form_dict.pop("_lang", None) if self._testMethodName in self.guest_sessions_required: frappe.set_user("Administrator") + frappe.local.lang = "en" def test_clear_cache(self): _("Trigger caching") @@ -79,7 +83,6 @@ class TestTranslate(FrappeTestCase): self.assertEqual(ext_line, exp_line) def test_read_language_variant(self): - frappe.local.lang = "en" self.assertEqual(_("Mobile No"), "Mobile No") try: frappe.local.lang = "pt-BR" @@ -91,12 +94,24 @@ class TestTranslate(FrappeTestCase): self.assertEqual(_("Mobile No"), "Mobile No") def test_translation_with_context(self): - try: - frappe.local.lang = "fr" - self.assertEqual(_("Change"), "Changement") - self.assertEqual(_("Change", context="Coins"), "la monnaie") - finally: - frappe.local.lang = "en" + frappe.local.lang = "fr" + self.assertEqual(_("Change"), "Changement") + self.assertEqual(_("Change", context="Coins"), "la monnaie") + + def test_lazy_translations(self): + frappe.local.lang = "de" + eager_translation = _("Communication") + self.assertEqual(str(_lazy_translations), eager_translation) + self.assertRaises(NotImplementedError, lambda: _lazy_translations == "blah") + + # auto casts when added or radded + self.assertEqual(_lazy_translations + "A", eager_translation + "A") + x = _lazy_translations + x += "A" + self.assertEqual(x, eager_translation + "A") + + # f string usually auto-casts + self.assertEqual(f"{_lazy_translations}", eager_translation) def test_request_language_resolution_with_form_dict(self): """Test for frappe.translate.get_language @@ -185,6 +200,7 @@ class TestTranslate(FrappeTestCase): ) _(not_a_string) _(not_a_string, context="wat") + _lt("Communication") """ ) expected_output = [ @@ -194,6 +210,7 @@ class TestTranslate(FrappeTestCase): (5, "name with", "name context"), (6, "broken on", "new line"), (10, "broken on separate line", None), + (15, "Communication", None), ] output = extract_messages_from_python_code(code) diff --git a/frappe/translate.py b/frappe/translate.py index 643f3bf3e5..94c9db918a 100644 --- a/frappe/translate.py +++ b/frappe/translate.py @@ -632,7 +632,7 @@ def extract_messages_from_python_code(code: str) -> list[tuple[int, str, str | N for message in extract_python( io.BytesIO(code.encode()), - keywords=["_"], + keywords=["_", "_lt"], comment_tags=(), options={}, ): @@ -1105,6 +1105,44 @@ def print_language(language: str): frappe.local.jenv = _jenv +@functools.total_ordering +class LazyTranslate: + __slots__ = ("msg", "lang", "context") + + def __init__(self, msg: str, lang: str | None = None, context: str | None = None) -> None: + self.msg = msg + self.lang = lang + self.context = context + + @property + def value(self) -> str: + return frappe._(str(self.msg), self.lang, self.context) + + def __str__(self): + return self.value + + def __add__(self, other): + if isinstance(other, (str, LazyTranslate)): + return self.value + str(other) + raise NotImplementedError + + def __radd__(self, other): + if isinstance(other, (str, LazyTranslate)): + return str(other) + self.value + return NotImplementedError + + def __repr__(self) -> str: + return f"'{self.value}'" + + # NOTE: it's required to override these methods and raise error as default behaviour will + # return `False` in all cases. + def __eq__(self, other): + raise NotImplementedError + + def __lt__(self, other): + raise NotImplementedError + + # Backward compatibility get_full_dict = get_all_translations load_lang = get_translations_from_apps diff --git a/frappe/utils/password_strength.py b/frappe/utils/password_strength.py index 253421a6d4..70ca375938 100644 --- a/frappe/utils/password_strength.py +++ b/frappe/utils/password_strength.py @@ -7,7 +7,7 @@ from zxcvbn import zxcvbn from zxcvbn.scoring import ALL_UPPER, START_UPPER import frappe -from frappe import _ +from frappe import _, _lt if TYPE_CHECKING: from collections.abc import Iterable @@ -41,8 +41,8 @@ def test_password_strength(password: str, user_inputs: "Iterable[object]" = None default_feedback: "PasswordStrengthFeedback" = { "warning": "", "suggestions": [ - _("Use a few words, avoid common phrases."), - _("No need for symbols, digits, or uppercase letters."), + _lt("Use a few words, avoid common phrases."), + _lt("No need for symbols, digits, or uppercase letters."), ], }