checkers.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. """
  2. babel.messages.checkers
  3. ~~~~~~~~~~~~~~~~~~~~~~~
  4. Various routines that help with validation of translations.
  5. :since: version 0.9
  6. :copyright: (c) 2013-2025 by the Babel Team.
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. from __future__ import annotations
  10. from collections.abc import Callable
  11. from babel.messages.catalog import PYTHON_FORMAT, Catalog, Message, TranslationError
  12. #: list of format chars that are compatible to each other
  13. _string_format_compatibilities = [
  14. {'i', 'd', 'u'},
  15. {'x', 'X'},
  16. {'f', 'F', 'g', 'G'},
  17. ]
  18. def num_plurals(catalog: Catalog | None, message: Message) -> None:
  19. """Verify the number of plurals in the translation."""
  20. if not message.pluralizable:
  21. if not isinstance(message.string, str):
  22. raise TranslationError("Found plural forms for non-pluralizable "
  23. "message")
  24. return
  25. # skip further tests if no catalog is provided.
  26. elif catalog is None:
  27. return
  28. msgstrs = message.string
  29. if not isinstance(msgstrs, (list, tuple)):
  30. msgstrs = (msgstrs,)
  31. if len(msgstrs) != catalog.num_plurals:
  32. raise TranslationError("Wrong number of plural forms (expected %d)" %
  33. catalog.num_plurals)
  34. def python_format(catalog: Catalog | None, message: Message) -> None:
  35. """Verify the format string placeholders in the translation."""
  36. if 'python-format' not in message.flags:
  37. return
  38. msgids = message.id
  39. if not isinstance(msgids, (list, tuple)):
  40. msgids = (msgids,)
  41. msgstrs = message.string
  42. if not isinstance(msgstrs, (list, tuple)):
  43. msgstrs = (msgstrs,)
  44. for msgid, msgstr in zip(msgids, msgstrs):
  45. if msgstr:
  46. _validate_format(msgid, msgstr)
  47. def _validate_format(format: str, alternative: str) -> None:
  48. """Test format string `alternative` against `format`. `format` can be the
  49. msgid of a message and `alternative` one of the `msgstr`\\s. The two
  50. arguments are not interchangeable as `alternative` may contain less
  51. placeholders if `format` uses named placeholders.
  52. If the string formatting of `alternative` is compatible to `format` the
  53. function returns `None`, otherwise a `TranslationError` is raised.
  54. Examples for compatible format strings:
  55. >>> _validate_format('Hello %s!', 'Hallo %s!')
  56. >>> _validate_format('Hello %i!', 'Hallo %d!')
  57. Example for an incompatible format strings:
  58. >>> _validate_format('Hello %(name)s!', 'Hallo %s!')
  59. Traceback (most recent call last):
  60. ...
  61. TranslationError: the format strings are of different kinds
  62. This function is used by the `python_format` checker.
  63. :param format: The original format string
  64. :param alternative: The alternative format string that should be checked
  65. against format
  66. :raises TranslationError: on formatting errors
  67. """
  68. def _parse(string: str) -> list[tuple[str, str]]:
  69. result: list[tuple[str, str]] = []
  70. for match in PYTHON_FORMAT.finditer(string):
  71. name, format, typechar = match.groups()
  72. if typechar == '%' and name is None:
  73. continue
  74. result.append((name, str(typechar)))
  75. return result
  76. def _compatible(a: str, b: str) -> bool:
  77. if a == b:
  78. return True
  79. for set in _string_format_compatibilities:
  80. if a in set and b in set:
  81. return True
  82. return False
  83. def _check_positional(results: list[tuple[str, str]]) -> bool:
  84. positional = None
  85. for name, _char in results:
  86. if positional is None:
  87. positional = name is None
  88. else:
  89. if (name is None) != positional:
  90. raise TranslationError('format string mixes positional '
  91. 'and named placeholders')
  92. return bool(positional)
  93. a, b = map(_parse, (format, alternative))
  94. if not a:
  95. return
  96. # now check if both strings are positional or named
  97. a_positional, b_positional = map(_check_positional, (a, b))
  98. if a_positional and not b_positional and not b:
  99. raise TranslationError('placeholders are incompatible')
  100. elif a_positional != b_positional:
  101. raise TranslationError('the format strings are of different kinds')
  102. # if we are operating on positional strings both must have the
  103. # same number of format chars and those must be compatible
  104. if a_positional:
  105. if len(a) != len(b):
  106. raise TranslationError('positional format placeholders are '
  107. 'unbalanced')
  108. for idx, ((_, first), (_, second)) in enumerate(zip(a, b)):
  109. if not _compatible(first, second):
  110. raise TranslationError('incompatible format for placeholder '
  111. '%d: %r and %r are not compatible' %
  112. (idx + 1, first, second))
  113. # otherwise the second string must not have names the first one
  114. # doesn't have and the types of those included must be compatible
  115. else:
  116. type_map = dict(a)
  117. for name, typechar in b:
  118. if name not in type_map:
  119. raise TranslationError(f'unknown named placeholder {name!r}')
  120. elif not _compatible(typechar, type_map[name]):
  121. raise TranslationError(
  122. f'incompatible format for placeholder {name!r}: '
  123. f'{typechar!r} and {type_map[name]!r} are not compatible',
  124. )
  125. def _find_checkers() -> list[Callable[[Catalog | None, Message], object]]:
  126. from babel.messages._compat import find_entrypoints
  127. checkers: list[Callable[[Catalog | None, Message], object]] = []
  128. checkers.extend(load() for (name, load) in find_entrypoints('babel.checkers'))
  129. if len(checkers) == 0:
  130. # if entrypoints are not available or no usable egg-info was found
  131. # (see #230), just resort to hard-coded checkers
  132. return [num_plurals, python_format]
  133. return checkers
  134. checkers: list[Callable[[Catalog | None, Message], object]] = _find_checkers()