Marco Ricci commited on 2025-01-13 14:19:27
Zeige 1 geänderte Dateien mit 243 Einfügungen und 28 Löschungen.
Introduce a `DebugTranslations` object that returns the enum name of the given message as its translation, including the parameters to be interpolated. Rename the `_format_pot_file` to `_format_po_file` and expand it to emit either a template file or a debug translation `.po` file. This differs slightly in the header, and in whether the translations are empty by default or filled in with the enum name. The ad-hoc command-line interface now accepts options to select the template or the debug translation, and a way to explicitly override the declared version of the `.po` template. Because of negative experience with the `poedit` translations editor, the message-ID (enum name) is no longer embedded as a (pseudo-)location of the message string, but rather embedded as a translators' comment.
| ... | ... |
@@ -12,17 +12,18 @@ import enum |
| 12 | 12 |
import gettext |
| 13 | 13 |
import inspect |
| 14 | 14 |
import os |
| 15 |
+import string |
|
| 15 | 16 |
import sys |
| 16 | 17 |
import textwrap |
| 17 | 18 |
import types |
| 18 | 19 |
from typing import TYPE_CHECKING, NamedTuple, TextIO, Union, cast |
| 19 | 20 |
|
| 20 |
-from typing_extensions import TypeAlias |
|
| 21 |
+from typing_extensions import TypeAlias, override |
|
| 21 | 22 |
|
| 22 | 23 |
import derivepassphrase as dpp |
| 23 | 24 |
|
| 24 | 25 |
if TYPE_CHECKING: |
| 25 |
- from collections.abc import Iterable, Mapping, Sequence |
|
| 26 |
+ from collections.abc import Iterable, Iterator, Mapping, Sequence |
|
| 26 | 27 |
|
| 27 | 28 |
from typing_extensions import Any, Self |
| 28 | 29 |
|
| ... | ... |
@@ -88,6 +89,112 @@ def load_translations( |
| 88 | 89 |
|
| 89 | 90 |
|
| 90 | 91 |
translation = load_translations() |
| 92 |
+_debug_translation_message_cache: dict[tuple[str, str], MsgTemplate] = {}
|
|
| 93 |
+ |
|
| 94 |
+ |
|
| 95 |
+class DebugTranslations(gettext.NullTranslations): |
|
| 96 |
+ """A debug object indicating which known message is being requested. |
|
| 97 |
+ |
|
| 98 |
+ Each call to the `*gettext` methods will return the enum name if the |
|
| 99 |
+ message is a known translatable message for the `derivepassphrase` |
|
| 100 |
+ command-line interface, or the message itself otherwise. |
|
| 101 |
+ |
|
| 102 |
+ """ |
|
| 103 |
+ |
|
| 104 |
+ @staticmethod |
|
| 105 |
+ def _load_cache() -> None: |
|
| 106 |
+ cache = _debug_translation_message_cache |
|
| 107 |
+ for enum_class in MSG_TEMPLATE_CLASSES: |
|
| 108 |
+ for member in enum_class.__members__.values(): |
|
| 109 |
+ value = cast('TranslatableString', member.value)
|
|
| 110 |
+ singular = value.singular |
|
| 111 |
+ plural = value.plural |
|
| 112 |
+ context = value.l10n_context |
|
| 113 |
+ cache.setdefault((context, singular), member) |
|
| 114 |
+ if plural: |
|
| 115 |
+ cache.setdefault((context, plural), member) |
|
| 116 |
+ |
|
| 117 |
+ @classmethod |
|
| 118 |
+ def _locate_message( |
|
| 119 |
+ cls, |
|
| 120 |
+ message: str, |
|
| 121 |
+ /, |
|
| 122 |
+ *, |
|
| 123 |
+ context: str = '', |
|
| 124 |
+ message_plural: str = '', |
|
| 125 |
+ n: int = 1, |
|
| 126 |
+ ) -> str: |
|
| 127 |
+ try: |
|
| 128 |
+ enum_value = _debug_translation_message_cache[context, message] |
|
| 129 |
+ except KeyError: |
|
| 130 |
+ return message if not message_plural or n == 1 else message_plural |
|
| 131 |
+ return cls._format_enum_name_maybe_with_fields( |
|
| 132 |
+ enum_name=str(enum_value), |
|
| 133 |
+ ts=cast('TranslatableString', enum_value.value),
|
|
| 134 |
+ ) |
|
| 135 |
+ |
|
| 136 |
+ @staticmethod |
|
| 137 |
+ def _format_enum_name_maybe_with_fields( |
|
| 138 |
+ enum_name: str, |
|
| 139 |
+ ts: TranslatableString, |
|
| 140 |
+ ) -> str: |
|
| 141 |
+ formatter = string.Formatter() |
|
| 142 |
+ fields: dict[str, int] = {}
|
|
| 143 |
+ for _lit, field, _spec, _conv in formatter.parse(ts.singular): |
|
| 144 |
+ if field is not None and field not in fields: |
|
| 145 |
+ fields[field] = len(fields) |
|
| 146 |
+ sorted_fields = [ |
|
| 147 |
+ f'{field}={{{field}!r}}'
|
|
| 148 |
+ for field in sorted(fields.keys(), key=fields.__getitem__) |
|
| 149 |
+ ] |
|
| 150 |
+ return ( |
|
| 151 |
+ '{!s}({})'.format(enum_name, ', '.join(sorted_fields))
|
|
| 152 |
+ if sorted_fields |
|
| 153 |
+ else str(enum_name) |
|
| 154 |
+ ) |
|
| 155 |
+ |
|
| 156 |
+ @override |
|
| 157 |
+ def gettext( |
|
| 158 |
+ self, |
|
| 159 |
+ message: str, |
|
| 160 |
+ /, |
|
| 161 |
+ ) -> str: # pragma: no cover |
|
| 162 |
+ return self._locate_message(message) |
|
| 163 |
+ |
|
| 164 |
+ @override |
|
| 165 |
+ def ngettext( |
|
| 166 |
+ self, |
|
| 167 |
+ msgid1: str, |
|
| 168 |
+ msgid2: str, |
|
| 169 |
+ n: int, |
|
| 170 |
+ /, |
|
| 171 |
+ ) -> str: # pragma: no cover |
|
| 172 |
+ return self._locate_message(msgid1, message_plural=msgid2, n=n) |
|
| 173 |
+ |
|
| 174 |
+ @override |
|
| 175 |
+ def pgettext( |
|
| 176 |
+ self, |
|
| 177 |
+ context: str, |
|
| 178 |
+ message: str, |
|
| 179 |
+ /, |
|
| 180 |
+ ) -> str: |
|
| 181 |
+ return self._locate_message(message, context=context) |
|
| 182 |
+ |
|
| 183 |
+ @override |
|
| 184 |
+ def npgettext( |
|
| 185 |
+ self, |
|
| 186 |
+ context: str, |
|
| 187 |
+ msgid1: str, |
|
| 188 |
+ msgid2: str, |
|
| 189 |
+ n: int, |
|
| 190 |
+ /, |
|
| 191 |
+ ) -> str: # pragma: no cover |
|
| 192 |
+ return self._locate_message( |
|
| 193 |
+ msgid1, |
|
| 194 |
+ context=context, |
|
| 195 |
+ message_plural=msgid2, |
|
| 196 |
+ n=n, |
|
| 197 |
+ ) |
|
| 91 | 198 |
|
| 92 | 199 |
|
| 93 | 200 |
class TranslatableString(NamedTuple): |
| ... | ... |
@@ -1678,9 +1785,18 @@ MSG_TEMPLATE_CLASSES = ( |
| 1678 | 1785 |
ErrMsgTemplate, |
| 1679 | 1786 |
) |
| 1680 | 1787 |
|
| 1788 |
+DebugTranslations._load_cache() # noqa: SLF001 |
|
| 1789 |
+ |
|
| 1790 |
+ |
|
| 1681 | 1791 |
|
| 1682 |
-def _write_pot_file(fileobj: TextIO) -> None: # pragma: no cover |
|
| 1683 |
- r"""Write a .po template to the given file object. |
|
| 1792 |
+def _write_po_file( # noqa: C901 |
|
| 1793 |
+ fileobj: TextIO, |
|
| 1794 |
+ /, |
|
| 1795 |
+ *, |
|
| 1796 |
+ is_template: bool = True, |
|
| 1797 |
+ version: str = __version__, |
|
| 1798 |
+) -> None: # pragma: no cover |
|
| 1799 |
+ r"""Write a .po file to the given file object. |
|
| 1684 | 1800 |
|
| 1685 | 1801 |
Assumes the file object is opened for writing and accepts string |
| 1686 | 1802 |
inputs. The file will *not* be closed when writing is complete. |
| ... | ... |
@@ -1695,8 +1811,9 @@ def _write_pot_file(fileobj: TextIO) -> None: # pragma: no cover |
| 1695 | 1811 |
entries: dict[str, dict[str, MsgTemplate]] = {}
|
| 1696 | 1812 |
for enum_class in MSG_TEMPLATE_CLASSES: |
| 1697 | 1813 |
for member in enum_class.__members__.values(): |
| 1698 |
- ctx = member.value.l10n_context |
|
| 1699 |
- msg = member.value.singular |
|
| 1814 |
+ value = cast('TranslatableString', member.value)
|
|
| 1815 |
+ ctx = value.l10n_context |
|
| 1816 |
+ msg = value.singular |
|
| 1700 | 1817 |
if ( |
| 1701 | 1818 |
msg in entries.setdefault(ctx, {})
|
| 1702 | 1819 |
and entries[ctx][msg] != member |
| ... | ... |
@@ -1706,49 +1823,113 @@ def _write_pot_file(fileobj: TextIO) -> None: # pragma: no cover |
| 1706 | 1823 |
f'{entries[ctx][msg]!r} and {member!r}'
|
| 1707 | 1824 |
) |
| 1708 | 1825 |
entries[ctx][msg] = member |
| 1709 |
- now = datetime.datetime.now().astimezone() |
|
| 1826 |
+ build_time = datetime.datetime.now().astimezone() |
|
| 1827 |
+ if is_template: |
|
| 1710 | 1828 |
header = ( |
| 1711 | 1829 |
inspect.cleandoc(rf""" |
| 1712 | 1830 |
# English translation for {PROG_NAME!s}.
|
| 1713 |
- # Copyright (C) {now.strftime('%Y')} AUTHOR
|
|
| 1831 |
+ # Copyright (C) {build_time.strftime('%Y')} AUTHOR
|
|
| 1832 |
+ # This file is distributed under the same license as {PROG_NAME!s}.
|
|
| 1833 |
+ # AUTHOR <someone@example.com>, {build_time.strftime('%Y')}.
|
|
| 1834 |
+ # |
|
| 1835 |
+ msgid "" |
|
| 1836 |
+ msgstr "" |
|
| 1837 |
+ """).removesuffix('\n')
|
|
| 1838 |
+ + '\n' |
|
| 1839 |
+ ) |
|
| 1840 |
+ else: |
|
| 1841 |
+ header = ( |
|
| 1842 |
+ inspect.cleandoc(rf""" |
|
| 1843 |
+ # English debug translation for {PROG_NAME!s}.
|
|
| 1844 |
+ # Copyright (C) {build_time.strftime('%Y')} {__author__}
|
|
| 1714 | 1845 |
# This file is distributed under the same license as {PROG_NAME!s}.
|
| 1715 |
- # AUTHOR <someone@example.com>, {now.strftime('%Y')}.
|
|
| 1716 | 1846 |
# |
| 1717 | 1847 |
msgid "" |
| 1718 | 1848 |
msgstr "" |
| 1719 |
- "Project-Id-Version: {PROG_NAME!s} {__version__!s}\n"
|
|
| 1720 |
- "Report-Msgid-Bugs-To: software@the13thletter.info\n" |
|
| 1721 |
- "POT-Creation-Date: {now.strftime('%Y-%m-%d %H:%M%z')}\n"
|
|
| 1722 |
- "PO-Revision-Date: {now.strftime('%Y-%m-%d %H:%M%z')}\n"
|
|
| 1723 |
- "Last-Translator: AUTHOR <someone@example.com>\n" |
|
| 1724 |
- "Language: en\n" |
|
| 1725 |
- "MIME-Version: 1.0\n" |
|
| 1726 |
- "Content-Type: text/plain; charset=UTF-8\n" |
|
| 1727 |
- "Content-Transfer-Encoding: 8bit\n" |
|
| 1728 |
- "Plural-Forms: nplurals=2; plural=(n != 1);\n" |
|
| 1729 | 1849 |
""").removesuffix('\n')
|
| 1730 | 1850 |
+ '\n' |
| 1731 | 1851 |
) |
| 1732 | 1852 |
fileobj.write(header) |
| 1853 |
+ po_info = {
|
|
| 1854 |
+ 'Project-Id-Version': f'{PROG_NAME} {version}',
|
|
| 1855 |
+ 'Report-Msgid-Bugs-To': 'software@the13thletter.info', |
|
| 1856 |
+ 'PO-Revision-Date': build_time.strftime('%Y-%m-%d %H:%M%z'),
|
|
| 1857 |
+ 'MIME-Version': '1.0', |
|
| 1858 |
+ 'Content-Type': 'text/plain; charset=UTF-8', |
|
| 1859 |
+ 'Content-Transfer-Encoding': '8bit', |
|
| 1860 |
+ 'Plural-Forms': 'nplurals=2; plural=(n != 1);', |
|
| 1861 |
+ } |
|
| 1862 |
+ if is_template: |
|
| 1863 |
+ po_info.update({
|
|
| 1864 |
+ 'POT-Creation-Date': build_time.strftime('%Y-%m-%d %H:%M%z'),
|
|
| 1865 |
+ 'Last-Translator': 'AUTHOR <someone@example.com>', |
|
| 1866 |
+ 'Language': 'en', |
|
| 1867 |
+ 'Language-Team': 'English', |
|
| 1868 |
+ }) |
|
| 1869 |
+ else: |
|
| 1870 |
+ po_info.update({
|
|
| 1871 |
+ 'Last-Translator': __author__, |
|
| 1872 |
+ 'Language': 'en_DEBUG', |
|
| 1873 |
+ 'Language-Team': 'English', |
|
| 1874 |
+ }) |
|
| 1875 |
+ print(*_format_po_info(po_info), sep='\n', end='\n', file=fileobj) |
|
| 1733 | 1876 |
for _ctx, subdict in sorted(entries.items()): |
| 1734 | 1877 |
for _msg, enum_value in sorted( |
| 1735 | 1878 |
subdict.items(), |
| 1736 | 1879 |
key=lambda kv: str(kv[1]), |
| 1737 | 1880 |
): |
| 1738 |
- fileobj.writelines(_format_po_entry(enum_value)) |
|
| 1881 |
+ fileobj.writelines( |
|
| 1882 |
+ _format_po_entry( |
|
| 1883 |
+ enum_value, is_debug_translation=not is_template |
|
| 1884 |
+ ) |
|
| 1885 |
+ ) |
|
| 1886 |
+ |
|
| 1887 |
+ |
|
| 1888 |
+def _format_po_info( |
|
| 1889 |
+ data: Mapping[str, Any], |
|
| 1890 |
+ /, |
|
| 1891 |
+) -> Iterator[str]: # pragma: no cover |
|
| 1892 |
+ sortorder = [ |
|
| 1893 |
+ 'project-id-version', |
|
| 1894 |
+ 'report-msgid-bugs-to', |
|
| 1895 |
+ 'pot-creation-date', |
|
| 1896 |
+ 'po-revision-date', |
|
| 1897 |
+ 'last-translator', |
|
| 1898 |
+ 'language', |
|
| 1899 |
+ 'language-team', |
|
| 1900 |
+ 'mime-version', |
|
| 1901 |
+ 'content-type', |
|
| 1902 |
+ 'content-transfer-encoding', |
|
| 1903 |
+ 'plural-forms', |
|
| 1904 |
+ ] |
|
| 1905 |
+ |
|
| 1906 |
+ def _sort_position(s: str, /) -> int: |
|
| 1907 |
+ n = len(sortorder) |
|
| 1908 |
+ for i, x in enumerate(sortorder): |
|
| 1909 |
+ if s.lower().rstrip(':') == x:
|
|
| 1910 |
+ return i |
|
| 1911 |
+ return n |
|
| 1912 |
+ |
|
| 1913 |
+ for key in sorted(data.keys(), key=_sort_position): |
|
| 1914 |
+ value = data[key] |
|
| 1915 |
+ line = f"{key}: {value}\n"
|
|
| 1916 |
+ yield _cstr(line) |
|
| 1739 | 1917 |
|
| 1740 | 1918 |
|
| 1741 | 1919 |
def _format_po_entry( |
| 1742 | 1920 |
enum_value: MsgTemplate, |
| 1921 |
+ /, |
|
| 1922 |
+ *, |
|
| 1923 |
+ is_debug_translation: bool = False, |
|
| 1743 | 1924 |
) -> tuple[str, ...]: # pragma: no cover |
| 1744 | 1925 |
ret: list[str] = ['\n'] |
| 1745 | 1926 |
ts = enum_value.value |
| 1746 | 1927 |
if ts.translator_comments: |
| 1747 |
- ret.extend( |
|
| 1748 |
- f'#. {line}\n'
|
|
| 1749 |
- for line in ts.translator_comments.splitlines(False) # noqa: FBT003 |
|
| 1750 |
- ) |
|
| 1751 |
- ret.append(f'#: derivepassphrase/_cli_msg.py:{enum_value}\n')
|
|
| 1928 |
+ comments = ts.translator_comments.splitlines(False) # noqa: FBT003 |
|
| 1929 |
+ comments.extend(['', f'Message-ID: {enum_value}'])
|
|
| 1930 |
+ else: |
|
| 1931 |
+ comments = [f'TRANSLATORS: Message-ID: {enum_value}']
|
|
| 1932 |
+ ret.extend(f'#. {line}\n' for line in comments)
|
|
| 1752 | 1933 |
if ts.flags: |
| 1753 | 1934 |
ret.append(f'#, {", ".join(sorted(ts.flags))}\n')
|
| 1754 | 1935 |
if ts.l10n_context: |
| ... | ... |
@@ -1756,7 +1937,12 @@ def _format_po_entry( |
| 1756 | 1937 |
ret.append(f'msgid {_cstr(ts.singular)}\n')
|
| 1757 | 1938 |
if ts.plural: |
| 1758 | 1939 |
ret.append(f'msgid_plural {_cstr(ts.plural)}\n')
|
| 1759 |
- ret.append('msgstr ""\n')
|
|
| 1940 |
+ value = ( |
|
| 1941 |
+ DebugTranslations().pgettext(ts.l10n_context, ts.singular) |
|
| 1942 |
+ if is_debug_translation |
|
| 1943 |
+ else '' |
|
| 1944 |
+ ) |
|
| 1945 |
+ ret.append(f'msgstr {_cstr(value)}\n')
|
|
| 1760 | 1946 |
return tuple(ret) |
| 1761 | 1947 |
|
| 1762 | 1948 |
|
| ... | ... |
@@ -1786,9 +1972,38 @@ def _cstr(s: str) -> str: # pragma: no cover |
| 1786 | 1972 |
|
| 1787 | 1973 |
return '\n'.join( |
| 1788 | 1974 |
f'"{escape(line)}"'
|
| 1789 |
- for line in s.splitlines(True) # noqa: FBT003 |
|
| 1975 |
+ for line in s.splitlines(True) or [''] # noqa: FBT003 |
|
| 1790 | 1976 |
) |
| 1791 | 1977 |
|
| 1792 | 1978 |
|
| 1793 | 1979 |
if __name__ == '__main__': |
| 1794 |
- _write_pot_file(sys.stdout) |
|
| 1980 |
+ import argparse |
|
| 1981 |
+ ap = argparse.ArgumentParser() |
|
| 1982 |
+ ex = ap.add_mutually_exclusive_group() |
|
| 1983 |
+ ex.add_argument( |
|
| 1984 |
+ '--template', |
|
| 1985 |
+ action='store_true', |
|
| 1986 |
+ dest='is_template', |
|
| 1987 |
+ default=True, |
|
| 1988 |
+ help='Generate a template file (default)', |
|
| 1989 |
+ ) |
|
| 1990 |
+ ex.add_argument( |
|
| 1991 |
+ '--debug-translation', |
|
| 1992 |
+ action='store_false', |
|
| 1993 |
+ dest='is_template', |
|
| 1994 |
+ default=True, |
|
| 1995 |
+ help='Generate a "debug" translation file', |
|
| 1996 |
+ ) |
|
| 1997 |
+ ap.add_argument( |
|
| 1998 |
+ '--set-version', |
|
| 1999 |
+ action='store', |
|
| 2000 |
+ dest='version', |
|
| 2001 |
+ default=__version__, |
|
| 2002 |
+ help='Override declared software version', |
|
| 2003 |
+ ) |
|
| 2004 |
+ args = ap.parse_args() |
|
| 2005 |
+ _write_po_file( |
|
| 2006 |
+ sys.stdout, |
|
| 2007 |
+ version=args.version, |
|
| 2008 |
+ is_template=args.is_template, |
|
| 2009 |
+ ) |
|
| 1795 | 2010 |