python_source.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. # Copyright (c) 2026 Riverbank Computing Limited <info@riverbankcomputing.com>
  2. #
  3. # This file is part of PyQt6.
  4. #
  5. # This file may be used under the terms of the GNU General Public License
  6. # version 3.0 as published by the Free Software Foundation and appearing in
  7. # the file LICENSE included in the packaging of this file. Please review the
  8. # following information to ensure the GNU General Public License version 3.0
  9. # requirements will be met: http://www.gnu.org/copyleft/gpl.html.
  10. #
  11. # If you do not wish to use this file under the terms of the GPL version 3.0
  12. # then you may purchase a commercial license. For more information contact
  13. # info@riverbankcomputing.com.
  14. #
  15. # This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE
  16. # WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
  17. import ast
  18. import re
  19. import tokenize
  20. from .source_file import SourceFile
  21. from .translations import Context, EmbeddedComments, Message
  22. from .user import User, UserException
  23. class PythonSource(SourceFile, User):
  24. """ Encapsulate a Python source file. """
  25. # The regular expression to extract a PEP 263 encoding.
  26. _PEP_263 = re.compile(rb'^[ \t\f]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+)')
  27. def __init__(self, **kwargs):
  28. """ Initialise the object. """
  29. super().__init__(**kwargs)
  30. # Read the source file.
  31. self.progress("Reading {0}...".format(self.filename))
  32. with open(self.filename, 'rb') as f:
  33. source = f.read()
  34. # Implement universal newlines.
  35. source = source.replace(b'\r\n', b'\n').replace(b'\r', b'\n')
  36. # Try and extract a PEP 263 encoding.
  37. encoding = 'UTF-8'
  38. for line_nr, line in enumerate(source.split(b'\n')):
  39. if line_nr > 1:
  40. break
  41. match = re.match(self._PEP_263, line)
  42. if match:
  43. encoding = match.group(1).decode('ascii')
  44. break
  45. # Decode the source according to the encoding.
  46. try:
  47. source = source.decode(encoding)
  48. except LookupError:
  49. raise UserException("Unsupported encoding '{0}'".format(encoding))
  50. # Parse the source file.
  51. self.progress("Parsing {0}...".format(self.filename))
  52. try:
  53. tree = ast.parse(source, filename=self.filename)
  54. except SyntaxError as e:
  55. raise UserException(
  56. "Invalid syntax at line {0} of {1}:\n{2}".format(
  57. e.lineno, e.filename, e.text.rstrip()))
  58. # Look for translation contexts and their contents.
  59. visitor = Visitor(self)
  60. visitor.visit(tree)
  61. # Read the file again as a sequence of tokens so that we see the
  62. # comments.
  63. with open(self.filename, 'rb') as f:
  64. current = None
  65. for token in tokenize.tokenize(f.readline):
  66. if token.type == tokenize.COMMENT:
  67. # See if it is an embedded comment.
  68. parts = token.string.split(' ', maxsplit=1)
  69. if len(parts) == 2:
  70. if parts[0] == '#:':
  71. if current is None:
  72. current = EmbeddedComments()
  73. current.extra_comments.append(parts[1])
  74. elif parts[0] == '#=':
  75. if current is None:
  76. current = EmbeddedComments()
  77. current.message_id = parts[1]
  78. elif parts[0] == '#~':
  79. parts = parts[1].split(' ', maxsplit=1)
  80. if len(parts) == 1:
  81. parts.append('')
  82. if current is None:
  83. current = EmbeddedComments()
  84. current.extras.append(parts)
  85. elif token.type == tokenize.NL:
  86. continue
  87. elif current is not None:
  88. # Associate the embedded comment with the line containing
  89. # this token.
  90. line_nr = token.start[0]
  91. # See if there is a message on that line.
  92. for context in self.contexts:
  93. for message in context.messages:
  94. if message.line_nr == line_nr:
  95. break
  96. else:
  97. message = None
  98. if message is not None:
  99. message.embedded_comments = current
  100. break
  101. current = None
  102. class Visitor(ast.NodeVisitor):
  103. """ A visitor that extracts translation contexts. """
  104. def __init__(self, source):
  105. """ Initialise the visitor. """
  106. self._source = source
  107. self._context_stack = []
  108. super().__init__()
  109. def visit_Call(self, node):
  110. """ Visit a call. """
  111. # Parse the arguments if a translation function is being called.
  112. call_args = None
  113. if isinstance(node.func, ast.Attribute):
  114. name = node.func.attr
  115. elif isinstance(node.func, ast.Name):
  116. name = node.func.id
  117. if name == 'QT_TR_NOOP':
  118. call_args = self._parse_QT_TR_NOOP(node)
  119. elif name == 'QT_TRANSLATE_NOOP':
  120. call_args = self._parse_QT_TRANSLATE_NOOP(node)
  121. else:
  122. name = ''
  123. # Allow these to be either methods or functions.
  124. if name == 'tr':
  125. call_args = self._parse_tr(node)
  126. elif name == 'translate':
  127. call_args = self._parse_translate(node)
  128. # Update the context if the arguments are usable.
  129. if call_args is not None and call_args.source != '':
  130. call_args.context.messages.append(
  131. Message(self._source.filename, node.lineno,
  132. call_args.source, call_args.disambiguation,
  133. (call_args.numerus)))
  134. self.generic_visit(node)
  135. def visit_ClassDef(self, node):
  136. """ Visit a class. """
  137. try:
  138. name = self._context_stack[-1].name + '.' + node.name
  139. except IndexError:
  140. name = node.name
  141. self._context_stack.append(Context(name))
  142. self.generic_visit(node)
  143. context = self._context_stack.pop()
  144. if context.messages:
  145. self._source.contexts.append(context)
  146. def _get_current_context(self):
  147. """ Return the current Context object if there is one. """
  148. return self._context_stack[-1] if self._context_stack else None
  149. @classmethod
  150. def _get_first_str(cls, args):
  151. """ Get the first of a list of arguments as a str. """
  152. # Check that there is at least one argument.
  153. if not args:
  154. return None
  155. return cls._get_str(args[0])
  156. def _get_or_create_context(self, name):
  157. """ Return the Context object for a name, creating it if necessary. """
  158. for context in self._source.contexts:
  159. if context.name == name:
  160. return context
  161. context = Context(name)
  162. self._source.contexts.append(context)
  163. return context
  164. @staticmethod
  165. def _get_str(node, allow_none=False):
  166. """ Return the str from a node or None if it wasn't an appropriate
  167. node.
  168. """
  169. if isinstance(node, ast.Constant):
  170. if isinstance(node.value, str):
  171. return node.value
  172. if allow_none and node.value is None:
  173. return ''
  174. return None
  175. def _parse_QT_TR_NOOP(self, node):
  176. """ Parse the arguments to QT_TR_NOOP(). """
  177. # Ignore unless there is a current context.
  178. context = self._get_current_context()
  179. if context is None:
  180. return None
  181. call_args = self._parse_noop_without_context(node.args, node.keywords)
  182. if call_args is None:
  183. return None
  184. call_args.context = context
  185. return call_args
  186. def _parse_QT_TRANSLATE_NOOP(self, node):
  187. """ Parse the arguments to QT_TRANSLATE_NOOP(). """
  188. # Get the context.
  189. name = self._get_first_str(node.args)
  190. if name is None:
  191. return None
  192. call_args = self._parse_noop_without_context(node.args[1:],
  193. node.keywords)
  194. if call_args is None:
  195. return None
  196. call_args.context = self._get_or_create_context(name)
  197. return call_args
  198. def _parse_tr(self, node):
  199. """ Parse the arguments to tr(). """
  200. # Ignore unless there is a current context.
  201. context = self._get_current_context()
  202. if context is None:
  203. return None
  204. call_args = self._parse_without_context(node.args, node.keywords)
  205. if call_args is None:
  206. return None
  207. call_args.context = context
  208. return call_args
  209. def _parse_translate(self, node):
  210. """ Parse the arguments to translate(). """
  211. # Get the context.
  212. name = self._get_first_str(node.args)
  213. if name is None:
  214. return None
  215. call_args = self._parse_without_context(node.args[1:], node.keywords)
  216. if call_args is None:
  217. return None
  218. call_args.context = self._get_or_create_context(name)
  219. return call_args
  220. def _parse_without_context(self, args, keywords):
  221. """ Parse arguments for a message source and optional disambiguation
  222. and n.
  223. """
  224. # The source is required.
  225. source = self._get_first_str(args)
  226. if source is None:
  227. return None
  228. if len(args) > 1:
  229. disambiguation = self._get_str(args[1], allow_none=True)
  230. else:
  231. for kw in keywords:
  232. if kw.arg == 'disambiguation':
  233. disambiguation = self._get_str(kw.value, allow_none=True)
  234. break
  235. else:
  236. disambiguation = ''
  237. # Ignore if the disambiguation is specified but isn't a string.
  238. if disambiguation is None:
  239. return None
  240. if len(args) > 2:
  241. numerus = True
  242. else:
  243. numerus = 'n' in keywords
  244. if len(args) > 3:
  245. return None
  246. return CallArguments(source, disambiguation, numerus)
  247. def _parse_noop_without_context(self, args, keywords):
  248. """ Parse arguments for a message source. """
  249. # There must be exactly one positional argument.
  250. if len(args) != 1 or len(keywords) != 0:
  251. return None
  252. source = self._get_str(args[0])
  253. if source is None:
  254. return None
  255. return CallArguments(source)
  256. class CallArguments:
  257. """ Encapsulate the possible arguments of a translation function. """
  258. def __init__(self, source, disambiguation='', numerus=False):
  259. """ Initialise the object. """
  260. self.context = None
  261. self.source = source
  262. self.disambiguation = disambiguation
  263. self.numerus = numerus