Spaces:
Running
Running
| # Copyright (c) 2025 Riverbank Computing Limited <info@riverbankcomputing.com> | |
| # | |
| # 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 | |
| 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 | |
| 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 | |