aboutsummaryrefslogtreecommitdiffstats
path: root/tools/snippets_translate/main.py
diff options
context:
space:
mode:
Diffstat (limited to 'tools/snippets_translate/main.py')
-rw-r--r--tools/snippets_translate/main.py535
1 files changed, 295 insertions, 240 deletions
diff --git a/tools/snippets_translate/main.py b/tools/snippets_translate/main.py
index c5f4b9690..01ea06c5e 100644
--- a/tools/snippets_translate/main.py
+++ b/tools/snippets_translate/main.py
@@ -1,54 +1,32 @@
-#############################################################################
-##
-## Copyright (C) 2021 The Qt Company Ltd.
-## Contact: https://www.qt.io/licensing/
-##
-## This file is part of Qt for Python.
-##
-## $QT_BEGIN_LICENSE:LGPL$
-## Commercial License Usage
-## Licensees holding valid commercial Qt licenses may use this file in
-## accordance with the commercial license agreement provided with the
-## Software or, alternatively, in accordance with the terms contained in
-## a written agreement between you and The Qt Company. For licensing terms
-## and conditions see https://www.qt.io/terms-conditions. For further
-## information use the contact form at https://www.qt.io/contact-us.
-##
-## GNU Lesser General Public License Usage
-## Alternatively, this file may be used under the terms of the GNU Lesser
-## General Public License version 3 as published by the Free Software
-## Foundation and appearing in the file LICENSE.LGPL3 included in the
-## packaging of this file. Please review the following information to
-## ensure the GNU Lesser General Public License version 3 requirements
-## will be met: https://www.gnu.org/licenses/lgpl-3.0.html.
-##
-## GNU General Public License Usage
-## Alternatively, this file may be used under the terms of the GNU
-## General Public License version 2.0 or (at your option) the GNU General
-## Public license version 3 or any later version approved by the KDE Free
-## Qt Foundation. The licenses are as published by the Free Software
-## Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3
-## included in the packaging of this file. Please review the following
-## information to ensure the GNU General Public License requirements will
-## be met: https://www.gnu.org/licenses/gpl-2.0.html and
-## https://www.gnu.org/licenses/gpl-3.0.html.
-##
-## $QT_END_LICENSE$
-##
-#############################################################################
-
-import argparse
+# Copyright (C) 2022 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
import logging
import os
import re
-import shutil
import sys
+from argparse import ArgumentParser, Namespace, RawDescriptionHelpFormatter
from enum import Enum
from pathlib import Path
from textwrap import dedent
+from typing import Dict, List
+from override import python_example_snippet_mapping
from converter import snippet_translate
+HELP = """Converts Qt C++ code snippets to Python snippets.
+
+Ways to override Snippets:
+
+1) Complete snippets from local files:
+ To replace snippet "[1]" of "foo/bar.cpp", create a file
+ "sources/pyside6/doc/snippets/foo/bar_1.cpp.py" .
+2) Snippets extracted from Python examples:
+ To use snippets from Python examples, add markers ("#! [id]") to it
+ and an entry to _PYTHON_EXAMPLE_SNIPPET_MAPPING.
+"""
+
+
# Logger configuration
try:
from rich.logging import RichHandler
@@ -73,9 +51,14 @@ log = logging.getLogger("snippets_translate")
# Filter and paths configuration
SKIP_END = (".pro", ".pri", ".cmake", ".qdoc", ".yaml", ".frag", ".qsb", ".vert", "CMakeLists.txt")
SKIP_BEGIN = ("changes-", ".")
-OUT_MAIN = Path("sources/pyside6/doc/codesnippets/")
-OUT_SNIPPETS = OUT_MAIN / "doc/src/snippets/"
-OUT_EXAMPLES = OUT_MAIN / "doc/codesnippets/examples/"
+CPP_SNIPPET_PATTERN = re.compile(r"//! ?\[([^]]+)\]")
+PYTHON_SNIPPET_PATTERN = re.compile(r"#! ?\[([^]]+)\]")
+
+ROOT_PATH = Path(__file__).parents[2]
+SOURCE_PATH = ROOT_PATH / "sources" / "pyside6" / "doc" / "snippets"
+
+
+OVERRIDDEN_SNIPPET = "# OVERRIDDEN_SNIPPET"
class FileStatus(Enum):
@@ -83,9 +66,14 @@ class FileStatus(Enum):
New = 1
-def get_parser():
- parser = argparse.ArgumentParser(prog="snippets_translate")
- # List pyproject files
+def get_parser() -> ArgumentParser:
+ """
+ Returns a parser for the command line arguments of the script.
+ See README.md for more information.
+ """
+ parser = ArgumentParser(prog="snippets_translate",
+ description=HELP,
+ formatter_class=RawDescriptionHelpFormatter)
parser.add_argument(
"--qt",
action="store",
@@ -95,11 +83,11 @@ def get_parser():
)
parser.add_argument(
- "--pyside",
+ "--target",
action="store",
- dest="pyside_dir",
+ dest="target_dir",
required=True,
- help="Path to the pyside-setup directory",
+ help="Directory into which to generate the snippets",
)
parser.add_argument(
@@ -135,6 +123,14 @@ def get_parser():
)
parser.add_argument(
+ "-f",
+ "--directory",
+ action="store",
+ dest="single_directory",
+ help="Path to a single directory to be translated",
+ )
+
+ parser.add_argument(
"--filter",
action="store",
dest="filter_snippet",
@@ -144,7 +140,7 @@ def get_parser():
def is_directory(directory):
- if not os.path.isdir(directory):
+ if not directory.is_dir():
log.error(f"Path '{directory}' is not a directory")
return False
return True
@@ -156,7 +152,7 @@ def check_arguments(options):
if options.write_files:
if not opt_quiet:
log.warning(
- f"Files will be copied from '{options.qt_dir}':\n" f"\tto '{options.pyside_dir}'"
+ f"Files will be copied from '{options.qt_dir}':\n" f"\tto '{options.target_dir}'"
)
else:
msg = "This is a listing only, files are not being copied"
@@ -165,11 +161,8 @@ def check_arguments(options):
if not opt_quiet:
log.info(msg, extra=extra)
- # Check 'qt_dir' and 'pyside_dir'
- if is_directory(options.qt_dir) and is_directory(options.pyside_dir):
- return True
-
- return False
+ # Check 'qt_dir'
+ return is_directory(Path(options.qt_dir))
def is_valid_file(x):
@@ -191,58 +184,154 @@ def is_valid_file(x):
return True
-def get_snippets(data):
- snippet_lines = ""
- is_snippet = False
- snippets = []
- for line in data:
- if not is_snippet and line.startswith("//! ["):
- snippet_lines = line
- is_snippet = True
- elif is_snippet:
- snippet_lines = f"{snippet_lines}\n{line}"
- if line.startswith("//! ["):
- is_snippet = False
- snippets.append(snippet_lines)
- # Special case when a snippet line is:
- # //! [1] //! [2]
- if line.count("//!") > 1:
- snippet_lines = ""
- is_snippet = True
- return snippets
+def get_snippet_ids(line: str, pattern: re.Pattern) -> List[str]:
+ # Extract the snippet ids for a line '//! [1] //! [2]'
+ result = []
+ for m in pattern.finditer(line):
+ result.append(m.group(1))
+ return result
+
+
+def overriden_snippet_lines(lines: List[str], start_id: str) -> List[str]:
+ """Wrap an overridden snippet with marker and id lines."""
+ id_string = f"//! [{start_id}]"
+ result = [OVERRIDDEN_SNIPPET, id_string]
+ result.extend(lines)
+ result.append(id_string)
+ return result
+
+
+def get_snippet_override(start_id: str, rel_path: str) -> List[str]:
+ """Check if the snippet is overridden by a local file under
+ sources/pyside6/doc/snippets."""
+ file_start_id = start_id.replace(' ', '_')
+ override_name = f"{rel_path.stem}_{file_start_id}{rel_path.suffix}.py"
+ override_path = SOURCE_PATH / rel_path.parent / override_name
+ if not override_path.is_file():
+ return []
+ lines = override_path.read_text().splitlines()
+ return overriden_snippet_lines(lines, start_id)
+
+
+def _get_snippets(lines: List[str],
+ comment: str,
+ pattern: re.Pattern) -> Dict[str, List[str]]:
+ """Helper to extract (potentially overlapping) snippets from a C++ file
+ indicated by pattern ("//! [1]") and return them as a dict by <id>."""
+ snippets: Dict[str, List[str]] = {}
+ snippet: List[str]
+ done_snippets : List[str] = []
+
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+ i += 1
+
+ start_ids = get_snippet_ids(line, pattern)
+ while start_ids:
+ # Start of a snippet
+ start_id = start_ids.pop(0)
+ if start_id in done_snippets:
+ continue
+ # Reconstruct a single ID line to avoid repetitive ID lines
+ # by consecutive snippets with multi-ID lines like "//! [1] [2]"
+ id_line = f"{comment}! [{start_id}]"
+ done_snippets.append(start_id)
+ snippet = [id_line] # The snippet starts with this id
+
+ # Find the end of the snippet
+ j = i
+ while j < len(lines):
+ l = lines[j]
+ j += 1
+
+ # Add the line to the snippet
+ snippet.append(l)
+
+ # Check if the snippet is complete
+ if start_id in get_snippet_ids(l, pattern):
+ # End of snippet
+ snippet[len(snippet) - 1] = id_line
+ snippets[start_id] = snippet
+ break
+
+ return snippets
-def get_license_from_file(filename):
- lines = []
- with open(filename, "r") as f:
- line = True
- while line:
- line = f.readline().rstrip()
+def get_python_example_snippet_override(start_id: str, rel_path: str) -> List[str]:
+ """Check if the snippet is overridden by a python example snippet."""
+ key = (os.fspath(rel_path), start_id)
+ value = python_example_snippet_mapping().get(key)
+ if not value:
+ return []
+ path, id = value
+ file_lines = path.read_text().splitlines()
+ snippet_dict = _get_snippets(file_lines, '#', PYTHON_SNIPPET_PATTERN)
+ lines = snippet_dict.get(id)
+ if not lines:
+ raise RuntimeError(f'Snippet "{id}" not found in "{os.fspath(path)}"')
+ lines = lines[1:-1] # Strip Python snippet markers
+ return overriden_snippet_lines(lines, start_id)
+
+
+def get_snippets(lines: List[str], rel_path: str) -> List[List[str]]:
+ """Extract (potentially overlapping) snippets from a C++ file indicated
+ by '//! [1]'."""
+ result = _get_snippets(lines, '//', CPP_SNIPPET_PATTERN)
+ id_list = result.keys()
+ for snippet_id in id_list:
+ # Check file overrides and example overrides
+ snippet = get_snippet_override(snippet_id, rel_path)
+ if not snippet:
+ snippet = get_python_example_snippet_override(snippet_id, rel_path)
+ if snippet:
+ result[snippet_id] = snippet
+
+ return result.values()
+
+
+def get_license_from_file(lines):
+ result = []
+ spdx = len(lines) >= 2 and lines[0].startswith("//") and "SPDX" in lines[1]
+ if spdx: # SPDX, 6.4
+ for line in lines:
+ if line.startswith("//"):
+ result.append("# " + line[3:])
+ else:
+ break
+ else: # Old style, C-Header, 6.2
+ for line in lines:
if line.startswith("/*") or line.startswith("**"):
- lines.append(line)
+ result.append(line)
# End of the comment
if line.endswith("*/"):
break
- if lines:
- # We know we have the whole block, so we can
- # perform replacements to translate the comment
- lines[0] = lines[0].replace("/*", "**").replace("*", "#")
- lines[-1] = lines[-1].replace("*/", "**").replace("*", "#")
+ if result:
+ # We know we have the whole block, so we can
+ # perform replacements to translate the comment
+ result[0] = result[0].replace("/*", "**").replace("*", "#")
+ result[-1] = result[-1].replace("*/", "**").replace("*", "#")
- for i in range(1, len(lines) - 1):
- lines[i] = re.sub(r"^\*\*", "##", lines[i])
+ for i in range(1, len(result) - 1):
+ result[i] = re.sub(r"^\*\*", "##", result[i])
+ return "\n".join(result)
- return "\n".join(lines)
- else:
- return ""
-def translate_file(file_path, final_path, debug, write):
- with open(str(file_path)) as f:
- snippets = get_snippets(f.read().splitlines())
+def translate_file(file_path, final_path, qt_path, debug, write):
+ lines = []
+ snippets = []
+ try:
+ with file_path.open("r", encoding="utf-8") as f:
+ lines = f.read().splitlines()
+ rel_path = file_path.relative_to(qt_path)
+ snippets = get_snippets(lines, rel_path)
+ except Exception as e:
+ log.error(f"Error reading {file_path}: {e}")
+ raise
if snippets:
# TODO: Get license header first
- license_header = get_license_from_file(str(file_path))
+ license_header = get_license_from_file(lines)
if debug:
if have_rich:
console = Console()
@@ -250,11 +339,13 @@ def translate_file(file_path, final_path, debug, write):
table.add_column("C++")
table.add_column("Python")
- file_snippets = []
+ translated_lines = []
for snippet in snippets:
- lines = snippet.split("\n")
- translated_lines = []
- for line in lines:
+ if snippet and snippet[0] == OVERRIDDEN_SNIPPET:
+ translated_lines.extend(snippet[1:])
+ continue
+
+ for line in snippet:
if not line:
continue
translated_line = snippet_translate(line)
@@ -268,43 +359,45 @@ def translate_file(file_path, final_path, debug, write):
if not opt_quiet:
print(line, translated_line)
- if debug and have_rich:
- if not opt_quiet:
- console.print(table)
-
- file_snippets.append("\n".join(translated_lines))
+ if debug and have_rich:
+ if not opt_quiet:
+ console.print(table)
if write:
# Open the final file
- with open(str(final_path), "w") as out_f:
+ new_suffix = ".h.py" if final_path.name.endswith(".h") else ".py"
+ target_file = final_path.with_suffix(new_suffix)
+
+ # Directory where the file will be placed, if it does not exists
+ # we create it. The option 'parents=True' will create the parents
+ # directories if they don't exist, and if some of them exists,
+ # the option 'exist_ok=True' will ignore them.
+ if not target_file.parent.is_dir():
+ if not opt_quiet:
+ log.info(f"Creating directories for {target_file.parent}")
+ target_file.parent.mkdir(parents=True, exist_ok=True)
+
+ with target_file.open("w", encoding="utf-8") as out_f:
+ out_f.write("//! [AUTO]\n\n")
out_f.write(license_header)
- out_f.write("\n")
+ out_f.write("\n\n")
- for s in file_snippets:
+ for s in translated_lines:
out_f.write(s)
- out_f.write("\n\n")
+ out_f.write("\n")
- # Rename to .py
- written_file = shutil.move(str(final_path), str(final_path.with_suffix(".py")))
if not opt_quiet:
- log.info(f"Written: {written_file}")
+ log.info(f"Written: {target_file}")
else:
if not opt_quiet:
log.warning("No snippets were found")
+def copy_file(file_path, qt_path, out_path, write=False, debug=False):
-def copy_file(file_path, py_path, category, category_path, write=False, debug=False):
-
- if not category:
- translate_file(file_path, Path("_translated.py"), debug, write)
- return
- # Get path after the directory "snippets" or "examples"
- # and we add +1 to avoid the same directory
- idx = file_path.parts.index(category) + 1
- rel_path = Path().joinpath(*file_path.parts[idx:])
-
- final_path = py_path / category_path / rel_path
+ # Replicate the Qt path including module under the PySide snippets directory
+ qt_path_count = len(qt_path.parts)
+ final_path = out_path.joinpath(*file_path.parts[qt_path_count:])
# Check if file exists.
if final_path.exists():
@@ -328,140 +421,102 @@ def copy_file(file_path, py_path, category, category_path, write=False, debug=Fa
else:
log.info(f"{status_msg:10s} {final_path}")
- # Directory where the file will be placed, if it does not exists
- # we create it. The option 'parents=True' will create the parents
- # directories if they don't exist, and if some of them exists,
- # the option 'exist_ok=True' will ignore them.
- if write and not final_path.parent.is_dir():
- if not opt_quiet:
- log.info(f"Creating directories for {final_path.parent}")
- final_path.parent.mkdir(parents=True, exist_ok=True)
-
- # Change .cpp to .py
- # TODO:
- # - What do we do with .h in case both .cpp and .h exists with
- # the same name?
-
+ # Change .cpp to .py, .h to .h.py
# Translate C++ code into Python code
- if final_path.name.endswith(".cpp"):
- translate_file(file_path, final_path, debug, write)
+ if final_path.name.endswith(".cpp") or final_path.name.endswith(".h"):
+ translate_file(file_path, final_path, qt_path, debug, write)
return status
-def process(options):
- qt_path = Path(options.qt_dir)
- py_path = Path(options.pyside_dir)
+def single_directory(options, qt_path, out_path):
+ # Process all files in the directory
+ directory_path = Path(options.single_directory)
+ for file_path in directory_path.glob("**/*"):
+ if file_path.is_dir() or not is_valid_file(file_path):
+ continue
+ copy_file(file_path, qt_path, out_path, write=options.write_files, debug=options.debug)
+
- # (new, exists)
+def single_snippet(options, qt_path, out_path):
+ # Process a single file
+ file = Path(options.single_snippet)
+ if is_valid_file(file):
+ copy_file(file, qt_path, out_path, write=options.write_files, debug=options.debug)
+
+
+def all_modules_in_directory(options, qt_path, out_path):
+ """
+ Process all Qt modules in the directory. Logs how many files were processed.
+ """
+ # New files, already existing files
valid_new, valid_exists = 0, 0
- # Creating directories in case they don't exist
- if not OUT_SNIPPETS.is_dir():
- OUT_SNIPPETS.mkdir(parents=True)
-
- if not OUT_EXAMPLES.is_dir():
- OUT_EXAMPLES.mkdir(parents=True)
-
- if options.single_snippet:
- f = Path(options.single_snippet)
- if is_valid_file(f):
- if "snippets" in f.parts:
- status = copy_file(
- f,
- py_path,
- "snippets",
- OUT_SNIPPETS,
- write=options.write_files,
- debug=options.debug,
- )
- elif "examples" in f.parts:
- status = copy_file(
- f,
- py_path,
- "examples",
- OUT_EXAMPLES,
- write=options.write_files,
- debug=options.debug,
- )
- else:
- log.warning("Path did not contain 'snippets' nor 'examples'."
- "File will not be copied over, just generated locally.")
- status = copy_file(
- f,
- py_path,
- None,
- None,
- write=options.write_files,
- debug=options.debug,
- )
+ for module in qt_path.iterdir():
+ module_name = module.name
- else:
- for i in qt_path.iterdir():
- module_name = i.name
- # FIXME: remove this, since it's just for testing.
- if i.name != "qtbase":
+ # Filter only Qt modules
+ if not module_name.startswith("qt"):
+ continue
+
+ if not opt_quiet:
+ log.info(f"Module {module_name}")
+
+ # Iterating everything
+ for f in module.glob("**/*.*"):
+ # Proceed only if the full path contain the filter string
+ if not is_valid_file(f):
continue
- # Filter only Qt modules
- if not module_name.startswith("qt"):
+ if options.filter_snippet and options.filter_snippet not in str(f.absolute()):
continue
- if not opt_quiet:
- log.info(f"Module {module_name}")
-
- # Iterating everything
- for f in i.glob("**/*.*"):
- if is_valid_file(f):
- if options.filter_snippet:
- # Proceed only if the full path contain the filter string
- if options.filter_snippet not in str(f.absolute()):
- continue
- if "snippets" in f.parts:
- status = copy_file(
- f,
- py_path,
- "snippets",
- OUT_SNIPPETS,
- write=options.write_files,
- debug=options.debug,
- )
- elif "examples" in f.parts:
- status = copy_file(
- f,
- py_path,
- "examples",
- OUT_EXAMPLES,
- write=options.write_files,
- debug=options.debug,
- )
-
- # Stats
- if status == FileStatus.New:
- valid_new += 1
- elif status == FileStatus.Exists:
- valid_exists += 1
- if not opt_quiet:
- log.info(
- dedent(
- f"""\
- Summary:
- Total valid files: {valid_new + valid_exists}
- New files: {valid_new}
- Existing files: {valid_exists}
- """
- )
+ status = copy_file(f, qt_path, out_path, write=options.write_files, debug=options.debug)
+
+ # Stats
+ if status == FileStatus.New:
+ valid_new += 1
+ elif status == FileStatus.Exists:
+ valid_exists += 1
+
+ if not opt_quiet:
+ log.info(
+ dedent(
+ f"""\
+ Summary:
+ Total valid files: {valid_new + valid_exists}
+ New files: {valid_new}
+ Existing files: {valid_exists}
+ """
)
+ )
+
+
+def process_files(options: Namespace) -> None:
+ qt_path = Path(options.qt_dir)
+ out_path = Path(options.target_dir)
+
+ # Creating directories in case they don't exist
+ if not out_path.is_dir():
+ out_path.mkdir(parents=True)
+
+ if options.single_directory:
+ single_directory(options, qt_path, out_path)
+ elif options.single_snippet:
+ single_snippet(options, qt_path, out_path)
+ else:
+ # General case: process all Qt modules in the directory
+ all_modules_in_directory(options, qt_path, out_path)
if __name__ == "__main__":
parser = get_parser()
- options = parser.parse_args()
- opt_quiet = False if options.verbose else True
- opt_quiet = False if options.debug else opt_quiet
+ opt: Namespace = parser.parse_args()
+ opt_quiet = not (opt.verbose or opt.debug)
- if not check_arguments(options):
+ if not check_arguments(opt):
+ # Error, invalid arguments
parser.print_help()
- sys.exit(0)
+ sys.exit(-1)
- process(options)
+ process_files(opt)