# Copyright (c) 2025 Riverbank Computing Limited # # This file is part of PyQt6. # # This file may be used under the terms of the GNU General Public License # version 3.0 as published by the Free Software Foundation and appearing in # the file LICENSE included in the packaging of this file. Please review the # following information to ensure the GNU General Public License version 3.0 # requirements will be met: http://www.gnu.org/copyleft/gpl.html. # # If you do not wish to use this file under the terms of the GPL version 3.0 # then you may purchase a commercial license. For more information contact # info@riverbankcomputing.com. # # This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE # WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. import ast import re import tokenize from .source_file import SourceFile from .translations import Context, EmbeddedComments, Message from .user import User, UserException class PythonSource(SourceFile, User): """ Encapsulate a Python source file. """ # The regular expression to extract a PEP 263 encoding. _PEP_263 = re.compile(rb'^[ \t\f]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+)') def __init__(self, **kwargs): """ Initialise the object. """ super().__init__(**kwargs) # Read the source file. self.progress("Reading {0}...".format(self.filename)) with open(self.filename, 'rb') as f: source = f.read() # Implement universal newlines. source = source.replace(b'\r\n', b'\n').replace(b'\r', b'\n') # Try and extract a PEP 263 encoding. encoding = 'UTF-8' for line_nr, line in enumerate(source.split(b'\n')): if line_nr > 1: break match = re.match(self._PEP_263, line) if match: encoding = match.group(1).decode('ascii') break # Decode the source according to the encoding. try: source = source.decode(encoding) except LookupError: raise UserException("Unsupported encoding '{0}'".format(encoding)) # Parse the source file. self.progress("Parsing {0}...".format(self.filename)) try: tree = ast.parse(source, filename=self.filename) except SyntaxError as e: raise UserException( "Invalid syntax at line {0} of {1}:\n{2}".format( e.lineno, e.filename, e.text.rstrip())) # Look for translation contexts and their contents. visitor = Visitor(self) visitor.visit(tree) # Read the file again as a sequence of tokens so that we see the # comments. with open(self.filename, 'rb') as f: current = None for token in tokenize.tokenize(f.readline): if token.type == tokenize.COMMENT: # See if it is an embedded comment. parts = token.string.split(' ', maxsplit=1) if len(parts) == 2: if parts[0] == '#:': if current is None: current = EmbeddedComments() current.extra_comments.append(parts[1]) elif parts[0] == '#=': if current is None: current = EmbeddedComments() current.message_id = parts[1] elif parts[0] == '#~': parts = parts[1].split(' ', maxsplit=1) if len(parts) == 1: parts.append('') if current is None: current = EmbeddedComments() current.extras.append(parts) elif token.type == tokenize.NL: continue elif current is not None: # Associate the embedded comment with the line containing # this token. line_nr = token.start[0] # See if there is a message on that line. for context in self.contexts: for message in context.messages: if message.line_nr == line_nr: break else: message = None if message is not None: message.embedded_comments = current break current = None class Visitor(ast.NodeVisitor): """ A visitor that extracts translation contexts. """ def __init__(self, source): """ Initialise the visitor. """ self._source = source self._context_stack = [] super().__init__() def visit_Call(self, node): """ Visit a call. """ # Parse the arguments if a translation function is being called. call_args = None if isinstance(node.func, ast.Attribute): name = node.func.attr elif isinstance(node.func, ast.Name): name = node.func.id if name == 'QT_TR_NOOP': call_args = self._parse_QT_TR_NOOP(node) elif name == 'QT_TRANSLATE_NOOP': call_args = self._parse_QT_TRANSLATE_NOOP(node) else: name = '' # Allow these to be either methods or functions. if name == 'tr': call_args = self._parse_tr(node) elif name == 'translate': call_args = self._parse_translate(node) # Update the context if the arguments are usable. if call_args is not None and call_args.source != '': call_args.context.messages.append( Message(self._source.filename, node.lineno, call_args.source, call_args.disambiguation, (call_args.numerus))) self.generic_visit(node) def visit_ClassDef(self, node): """ Visit a class. """ try: name = self._context_stack[-1].name + '.' + node.name except IndexError: name = node.name self._context_stack.append(Context(name)) self.generic_visit(node) context = self._context_stack.pop() if context.messages: self._source.contexts.append(context) def _get_current_context(self): """ Return the current Context object if there is one. """ return self._context_stack[-1] if self._context_stack else None @classmethod def _get_first_str(cls, args): """ Get the first of a list of arguments as a str. """ # Check that there is at least one argument. if not args: return None return cls._get_str(args[0]) def _get_or_create_context(self, name): """ Return the Context object for a name, creating it if necessary. """ for context in self._source.contexts: if context.name == name: return context context = Context(name) self._source.contexts.append(context) return context @staticmethod def _get_str(node, allow_none=False): """ Return the str from a node or None if it wasn't an appropriate node. """ if isinstance(node, ast.Constant): if isinstance(node.value, str): return node.value if allow_none and node.value is None: return '' return None def _parse_QT_TR_NOOP(self, node): """ Parse the arguments to QT_TR_NOOP(). """ # Ignore unless there is a current context. context = self._get_current_context() if context is None: return None call_args = self._parse_noop_without_context(node.args, node.keywords) if call_args is None: return None call_args.context = context return call_args def _parse_QT_TRANSLATE_NOOP(self, node): """ Parse the arguments to QT_TRANSLATE_NOOP(). """ # Get the context. name = self._get_first_str(node.args) if name is None: return None call_args = self._parse_noop_without_context(node.args[1:], node.keywords) if call_args is None: return None call_args.context = self._get_or_create_context(name) return call_args def _parse_tr(self, node): """ Parse the arguments to tr(). """ # Ignore unless there is a current context. context = self._get_current_context() if context is None: return None call_args = self._parse_without_context(node.args, node.keywords) if call_args is None: return None call_args.context = context return call_args def _parse_translate(self, node): """ Parse the arguments to translate(). """ # Get the context. name = self._get_first_str(node.args) if name is None: return None call_args = self._parse_without_context(node.args[1:], node.keywords) if call_args is None: return None call_args.context = self._get_or_create_context(name) return call_args def _parse_without_context(self, args, keywords): """ Parse arguments for a message source and optional disambiguation and n. """ # The source is required. source = self._get_first_str(args) if source is None: return None if len(args) > 1: disambiguation = self._get_str(args[1], allow_none=True) else: for kw in keywords: if kw.arg == 'disambiguation': disambiguation = self._get_str(kw.value, allow_none=True) break else: disambiguation = '' # Ignore if the disambiguation is specified but isn't a string. if disambiguation is None: return None if len(args) > 2: numerus = True else: numerus = 'n' in keywords if len(args) > 3: return None return CallArguments(source, disambiguation, numerus) def _parse_noop_without_context(self, args, keywords): """ Parse arguments for a message source. """ # There must be exactly one positional argument. if len(args) != 1 or len(keywords) != 0: return None source = self._get_str(args[0]) if source is None: return None return CallArguments(source) class CallArguments: """ Encapsulate the possible arguments of a translation function. """ def __init__(self, source, disambiguation='', numerus=False): """ Initialise the object. """ self.context = None self.source = source self.disambiguation = disambiguation self.numerus = numerus