#!/usr/bin/env python3 """ *************************************************************************** 3to4.py --------------------- Date : 2023 December Copyright : (C) 2023 by Julien Cabieces Email : julien dot cabieces at oslandia dot com *************************************************************************** * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * *************************************************************************** """ """ Migrate a folder containing python files from QGIS3/Qt5 to QGIS4/Qt6 Highly inspired from this video https://www.youtube.com/watch?v=G1omxo5pphw needed tools: pip install astpretty tokenize-rt Few useful commands: - display python file ast astpretty --no-show-offsets myfile.py - display file tokens tokenize-rt myfile.py """ __author__ = "Julien Cabieces" __date__ = "2023 December" __copyright__ = "(C) 2023, Julien Cabieces" import argparse import ast import glob import inspect import logging import os import re import sys from collections import defaultdict from collections.abc import Sequence from enum import Enum from pathlib import Path try: import PyQt5 logging.warning("WARNING: PyQt5 has been found. It may result in wrong behavior.\n") except ImportError: pass from PyQt6 import ( Qsci, QtCore, QtGui, QtNetwork, QtPrintSupport, QtQuickWidgets, QtSql, QtSvg, QtTest, QtWidgets, QtXml, ) from PyQt6.Qsci import * # noqa: F403 from PyQt6.QtCore import * # noqa: F403 from PyQt6.QtGui import * # noqa: F403 from PyQt6.QtNetwork import * # noqa: F403 from PyQt6.QtPrintSupport import * # noqa: F403 from PyQt6.QtQuickWidgets import * # noqa: F403 from PyQt6.QtSql import * # noqa: F403 from PyQt6.QtTest import * # noqa: F403 from PyQt6.QtWidgets import * # noqa: F403 from PyQt6.QtXml import * # noqa: F403 from tokenize_rt import Offset, Token, reversed_enumerate, src_to_tokens, tokens_to_src try: import qgis._3d as qgis_3d # noqa: F403 import qgis.analysis as qgis_analysis # noqa: F403 import qgis.core as qgis_core # noqa: F403 import qgis.gui as qgis_gui # noqa: F403 from qgis._3d import * # noqa: F403 from qgis.analysis import * # noqa: F403 from qgis.core import * # noqa: F403 from qgis.gui import * # noqa: F403 except ImportError: qgis_core = None qgis_gui = None qgis_analysis = None qgis_3d = None logging.warning( "QGIS classes not available for introspection, only a partial upgrade will be performed" ) target_modules = [ QtCore, QtGui, QtWidgets, QtTest, QtSql, QtSvg, QtXml, QtNetwork, QtPrintSupport, QtQuickWidgets, Qsci, ] if qgis_core is not None: target_modules.extend([qgis_core, qgis_gui, qgis_analysis, qgis_3d]) # qmetatype which have been renamed qmetatype_mapping = { "Invalid": "UnknownType", "BitArray": "QBitArray", "Bitmap": "QBitmap", "Brush": "QBrush", "ByteArray": "QByteArray", "Char": "QChar", "Color": "QColor", "Cursor": "QCursor", "Date": "QDate", "DateTime": "QDateTime", "EasingCurve": "QEasingCurve", "Uuid": "QUuid", "ModelIndex": "QModelIndex", "PersistentModelIndex": "QPersistentModelIndex", "Font": "QFont", "Hash": "QVariantHash", "Icon": "QIcon", "Image": "QImage", "KeySequence": "QKeySequence", "Line": "QLine", "LineF": "QLineF", "List": "QVariantList", "Locale": "QLocale", "Map": "QVariantMap", "Transform": "QTransform", "Matrix4x4": "QMatrix4x4", "Palette": "QPalette", "Pen": "QPen", "Pixmap": "QPixmap", "Point": "QPoint", "PointF": "QPointF", "Polygon": "QPolygon", "PolygonF": "QPolygonF", "Quaternion": "QQuaternion", "Rect": "QRect", "RectF": "QRectF", "RegularExpression": "QRegularExpression", "Region": "QRegion", "Size": "QSize", "SizeF": "QSizeF", "SizePolicy": "QSizePolicy", "String": "QString", "StringList": "QStringList", "TextFormat": "QTextFormat", "TextLength": "QTextLength", "Time": "QTime", "Url": "QUrl", "Vector2D": "QVector2D", "Vector3D": "QVector3D", "Vector4D": "QVector4D", "UserType": "User", } deprecated_renamed_enums = { ("Qt", "MidButton"): ("MouseButton", "MiddleButton"), ("Qt", "TextColorRole"): ("ItemDataRole", "ForegroundRole"), ("Qt", "BackgroundColorRole"): ("ItemDataRole", "BackgroundRole"), ("QPainter", "HighQualityAntialiasing"): ("RenderHint", "Antialiasing"), } rename_function_attributes = {"exec_": "exec"} rename_function_definitions = {"exec_": "exec"} import_warnings = { "QRegExp": "QRegExp is removed in Qt6, please use QRegularExpression for Qt5/Qt6 compatibility" } # { (class, enum_value) : enum_name } qt_enums = {} ambiguous_enums = defaultdict(set) def fix_file(filename: str, qgis3_compat: bool, dry_run: bool = False) -> int: """ Parameters ---------- filename : str Name of file to check qgis3_compat : bool Apply modifications that would break behavior on QGIS 3, hence code may not work on QGIS 3 dry_run : bool, optional Reports only errors and does not modify files, by default False Returns ------- int Return 0 if no file is modified. """ with open(filename, encoding="UTF-8") as f: contents = f.read() fix_qvariant_type = [] # QVariant.Int, QVariant.Double ... fix_pyqt_import = [] # from PyQt5.QtXXX fix_qt_enums = {} # Unscoping of enums member_renames = {} token_renames = {} function_def_renames = {} rename_qt_enums = [] # Renaming deprecated removed enums custom_updates = {} imported_modules = set() extra_imports = defaultdict(set) removed_imports = defaultdict(set) import_offsets = {} object_types = {} def visit_assign(_node: ast.Assign, _parent): if ( isinstance(_node.value, ast.Call) and isinstance(_node.value.func, ast.Name) and _node.value.func.id in ("QFontMetrics", "QFontMetricsF") ): object_types[_node.targets[0].id] = _node.value.func.id def visit_call(_node: ast.Call, _parent): if isinstance(_node.func, ast.Attribute): if _node.func.attr in rename_function_attributes: attr_node = _node.func member_renames[ Offset( _node.func.lineno, attr_node.end_col_offset - len(_node.func.attr) - 1, ) ] = rename_function_attributes[_node.func.attr] if _node.func.attr == "addAction": if len(_node.args) >= 4: sys.stderr.write( f"{filename}:{_node.lineno}:{_node.col_offset} WARNING: fragile call to addAction. Use my_action = QAction(...), obj.addAction(my_action) instead.\n" ) if _node.func.attr == "desktop": if len(_node.args) == 0: sys.stderr.write( f"{filename}:{_node.lineno}:{_node.col_offset} WARNING: QDesktopWidget is deprecated and removed in Qt6. Replace with alternative approach instead.\n" ) if isinstance(_node.func, ast.Name) and _node.func.id == "QVariant": if not _node.args: extra_imports["qgis.core"].update({"NULL"}) def _invalid_qvariant_to_null(start_index: int, tokens): assert tokens[start_index].src == "QVariant" assert tokens[start_index + 1].src == "(" assert tokens[start_index + 2].src == ")" tokens[start_index] = tokens[start_index]._replace(src="NULL") for i in range(start_index + 1, start_index + 3): tokens[i] = tokens[i]._replace(src="") custom_updates[Offset(_node.lineno, _node.col_offset)] = ( _invalid_qvariant_to_null, "Invalid conversion of QVariant(QVariant.Null). Use from qgis.core import NULL instead", ) elif ( len(_node.args) == 1 and isinstance(_node.args[0], ast.Attribute) and isinstance(_node.args[0].value, ast.Name) and _node.args[0].value.id == "QVariant" ): extra_imports["qgis.core"].update({"NULL"}) def _fix_null_qvariant(start_index: int, tokens): assert tokens[start_index].src == "QVariant" assert tokens[start_index + 1].src == "(" assert tokens[start_index + 2].src == "QVariant" assert tokens[start_index + 3].src == "." assert tokens[start_index + 5].src == ")" tokens[start_index] = tokens[start_index]._replace(src="NULL") for i in range(start_index + 1, start_index + 6): tokens[i] = tokens[i]._replace(src="") custom_updates[Offset(_node.lineno, _node.col_offset)] = ( _fix_null_qvariant, "Invalid conversion of QVariant() to NULL. Use from qgis.core import NULL instead", ) elif isinstance(_node.func, ast.Name) and _node.func.id == "QDateTime": if len(_node.args) == 8: # QDateTime(yyyy, mm, dd, hh, MM, ss, ms, ts) doesn't work anymore, # so port to more reliable QDateTime(QDate, QTime, ts) form extra_imports["qgis.PyQt.QtCore"].update({"QDate", "QTime"}) def _fix_qdatetime_construct(start_index: int, tokens): i = start_index + 1 assert tokens[i].src == "(" tokens[i] = tokens[i]._replace(src="(QDate(") while tokens[i].offset < Offset( _node.args[2].lineno, _node.args[2].col_offset ): i += 1 assert tokens[i + 1].src == "," i += 1 tokens[i] = tokens[i]._replace(src="), QTime(") i += 1 while not tokens[i].src.strip(): tokens[i] = tokens[i]._replace(src="") i += 1 while tokens[i].offset < Offset( _node.args[6].lineno, _node.args[6].col_offset ): i += 1 i += 1 assert tokens[i].src == "," tokens[i] = tokens[i]._replace(src="),") custom_updates[Offset(_node.lineno, _node.col_offset)] = ( _fix_qdatetime_construct, "QDateTime(yyyy, mm, dd, hh, MM, ss, ms, ts) doesn't work anymore, so port to more reliable QDateTime(QDate, QTime, ts) form", ) elif ( len(_node.args) == 1 and isinstance(_node.args[0], ast.Call) and hasattr(_node.args[0].func, "id") and _node.args[0].func.id == "QDate" ): # QDateTime(QDate(..)) doesn't work anymore, # so port to more reliable QDateTime(QDate(...), QTime(0,0,0)) form extra_imports["qgis.PyQt.QtCore"].update({"QTime"}) def _fix_qdatetime_construct(start_index: int, tokens): assert tokens[start_index].src == "QDateTime" assert tokens[start_index + 1].src == "(" assert tokens[start_index + 2].src == "QDate" assert tokens[start_index + 3].src == "(" i = start_index + 4 while tokens[i].offset < Offset( _node.args[0].end_lineno, _node.args[0].end_col_offset ): i += 1 assert tokens[i - 1].src == ")" tokens[i - 1] = tokens[i - 1]._replace(src="), QTime(0, 0, 0)") custom_updates[Offset(_node.lineno, _node.col_offset)] = ( _fix_qdatetime_construct, "QDateTime(QDate(..)) doesn't work anymore, so port to more reliable QDateTime(QDate(...), QTime(0,0,0)) form", ) def visit_attribute(_node: ast.Attribute, _parent): if isinstance(_node.value, ast.Name): if _node.value.id == "qApp": token_renames[Offset(_node.value.lineno, _node.value.col_offset)] = ( "QApplication.instance()" ) extra_imports["qgis.PyQt.QtWidgets"].update({"QApplication"}) removed_imports["qgis.PyQt.QtWidgets"].update({"qApp"}) if _node.value.id == "QVariant" and _node.attr == "Type": def _replace_qvariant_type(start_index: int, tokens): # QVariant.Type.XXX doesn't exist, it should be QVariant.XXX assert tokens[start_index].src == "QVariant" assert tokens[start_index + 1].src == "." assert tokens[start_index + 2].src == "Type" assert tokens[start_index + 3].src == "." tokens[start_index + 2] = tokens[start_index + 2]._replace(src="") tokens[start_index + 3] = tokens[start_index + 3]._replace(src="") custom_updates[Offset(node.lineno, node.col_offset)] = ( _replace_qvariant_type ) if object_types.get(_node.value.id) in ("QFontMetrics", "QFontMetricsF"): if _node.attr == "width": sys.stderr.write( f"{filename}:{_node.lineno}:{_node.col_offset} WARNING: QFontMetrics.width() " "has been removed in Qt6. Use QFontMetrics.horizontalAdvance() if plugin can " "safely require Qt >= 5.11, or QFontMetrics.boundingRect().width() otherwise.\n" ) elif isinstance(_node.value, ast.Call): if _node.attr == "width" and ( ( isinstance(_node.value.func, ast.Attribute) and _node.value.func.attr == "fontMetrics" ) or ( isinstance(_node.value.func, ast.Name) and _node.value.func.id == "QFontMetrics" ) ): sys.stderr.write( f"{filename}:{_node.lineno}:{_node.col_offset} WARNING: QFontMetrics.width() " "has been removed in Qt6. Use QFontMetrics.horizontalAdvance() if plugin can " "safely require Qt >= 5.11, or QFontMetrics.boundingRect().width() otherwise.\n" ) def visit_subscript(_node: ast.Subscript, _parent): if isinstance(_node.value, ast.Attribute): if ( _node.value.attr == "activated" and isinstance(_node.slice, ast.Name) and _node.slice.id == "str" ): sys.stderr.write( f"{filename}:{_node.lineno}:{_node.col_offset} WARNING: activated[str] " "has been removed in Qt6. Consider using QComboBox.activated instead if the string is not required, " "or QComboBox.textActivated if the plugin can " "safely require Qt >= 5.14. Otherwise conditional Qt version code will need to be introduced.\n" ) def visit_import(_node: ast.ImportFrom, _parent): import_offsets[Offset(node.lineno, node.col_offset)] = ( node.module, {name.name for name in node.names}, node.end_lineno, node.end_col_offset, ) imported_modules.add(node.module) for name in node.names: if name.name in import_warnings: logging.warning(f"{filename}: {import_warnings[name.name]}") if name.name == "resources_rc": logging.warning( f"{filename}:{_node.lineno}:{_node.col_offset} - WARNING: support for compiled resources " "is removed in Qt6. Directly load icon resources by file path and load UI fields using " "uic.loadUiType by file path instead.\n" ) if _node.module == "qgis.PyQt.Qt": extra_imports["qgis.PyQt.QtCore"].update({"Qt"}) removed_imports["qgis.PyQt.Qt"].update({"Qt"}) tree = ast.parse(contents, filename=filename) for parent in ast.walk(tree): for node in ast.iter_child_nodes(parent): if isinstance(node, ast.ImportFrom): visit_import(node, parent) if ( not qgis3_compat and isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id == "QVariant" ): fix_qvariant_type.append(Offset(node.lineno, node.col_offset)) if isinstance(node, ast.Call): visit_call(node, parent) elif isinstance(node, ast.Attribute): visit_attribute(node, parent) elif isinstance(node, ast.Subscript): visit_subscript(node, parent) elif isinstance(node, ast.Assign): visit_assign(node, parent) if ( isinstance(node, ast.FunctionDef) and node.name in rename_function_definitions ): function_def_renames[Offset(node.lineno, node.col_offset)] = ( rename_function_definitions[node.name] ) if ( isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and (node.value.id, node.attr) in ambiguous_enums ): disambiguated = False try: actual = eval(f"{node.value.id}.{node.attr}") obj = globals()[node.value.id] if isinstance(obj, type): for attr_name in dir(obj): attr = getattr(obj, attr_name) if attr is actual.__class__: # print(f'Found alias {node.value.id}.{attr_name}') disambiguated = True fix_qt_enums[Offset(node.lineno, node.col_offset)] = ( node.value.id, attr_name, node.attr, ) break except AttributeError: pass if not disambiguated: possible_values = [ f"{node.value.id}.{e}.{node.attr}" for e in ambiguous_enums[(node.value.id, node.attr)] ] sys.stderr.write( f'{filename}:{node.lineno}:{node.col_offset} WARNING: ambiguous enum, cannot fix: {node.value.id}.{node.attr}. Could be: {", ".join(possible_values)}\n' ) elif ( isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and not isinstance(parent, ast.Attribute) and (node.value.id, node.attr) in qt_enums ): fix_qt_enums[Offset(node.lineno, node.col_offset)] = ( node.value.id, qt_enums[(node.value.id, node.attr)], node.attr, ) if ( isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and (node.value.id, node.attr) in deprecated_renamed_enums ): rename_qt_enums.append(Offset(node.lineno, node.col_offset)) elif ( isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("PyQt5.") ): fix_pyqt_import.append(Offset(node.lineno, node.col_offset)) for module, classes in extra_imports.items(): if module not in imported_modules: class_import = ", ".join(classes) import_statement = f"from {module} import {class_import}" logging.warning( f"{filename}: Missing import, manually add {import_statement}" ) if dry_run: for key, value in fix_qt_enums.items(): logging.warning( f"{filename}:{key.line}:{key.utf8_byte_offset} - Enum error, add '{value[1]}' before '{value[2]}'" ) for key, value in member_renames.items(): logging.warning( f"{filename}:{key.line}:{key.utf8_byte_offset} - This member should be renamed to '{value}'" ) for key, value in function_def_renames.items(): logging.warning( f"{filename}:{key.line}:{key.utf8_byte_offset} - This function should be renamed to '{value}'" ) for key, value in token_renames.items(): logging.warning( f"{filename}:{key.line}:{key.utf8_byte_offset} - Use '{value}' instead" ) for key, value in custom_updates.items(): _, text = value logging.warning(f"{filename}:{key.line}:{key.utf8_byte_offset} - {text}") for elem in fix_qvariant_type: logging.warning( f"{filename}:{elem.line}:{elem.utf8_byte_offset} - Replace QVariant.X with QMetaType.Type.X" ) for elem in fix_pyqt_import: logging.warning( f"{filename}:{elem.line}:{elem.utf8_byte_offset} - Fix PyQT import, you must import from qgis.PyQt" ) for elem in rename_qt_enums: logging.warning( f"{filename}:{elem.line}:{elem.utf8_byte_offset} - This enum was renamed" ) return 0 if not any( [ fix_qvariant_type, fix_pyqt_import, fix_qt_enums, rename_qt_enums, member_renames, function_def_renames, custom_updates, extra_imports, removed_imports, token_renames, ] ): return 0 tokens = src_to_tokens(contents) for i, token in reversed_enumerate(tokens): if token.offset in import_offsets: end_import_offset = Offset(*import_offsets[token.offset][-2:]) del import_offsets[token.offset] assert tokens[i].src == "from" token_index = i + 1 while not tokens[token_index].src.strip(): token_index += 1 module = "" while tokens[token_index].src.strip(): module += tokens[token_index].src token_index += 1 if extra_imports.get(module) or removed_imports.get(module): current_imports = set() while True: token_index += 1 if tokens[token_index].offset == end_import_offset: break if tokens[token_index].src.strip() in ("", ",", "import", "(", ")"): continue import_ = tokens[token_index].src if import_ in removed_imports.get(module, set()): tokens[token_index] = tokens[token_index]._replace(src="") prev_token_index = token_index - 1 while True: if tokens[prev_token_index].src.strip() in ("", ","): tokens[prev_token_index] = tokens[ prev_token_index ]._replace(src="") prev_token_index -= 1 else: break none_forward = True current_index = prev_token_index + 1 while True: if tokens[current_index].src in ("\n", ")"): break elif tokens[current_index].src.strip(): none_forward = False break current_index += 1 none_backward = True current_index = prev_token_index while True: if tokens[current_index].src in ("import",): break elif tokens[current_index].src.strip(): none_backward = False break current_index -= 1 if none_backward and none_forward: # no more imports from this module, remove whole import while True: if tokens[current_index].src in ("from",): break current_index -= 1 while True: if tokens[current_index].src in ("\n",): tokens[current_index] = tokens[ current_index ]._replace(src="") break tokens[current_index] = tokens[current_index]._replace( src="" ) current_index += 1 else: current_imports.add(import_) imports_to_add = extra_imports.get(module, set()) - current_imports if imports_to_add: additional_import_string = ", ".join(imports_to_add) if tokens[token_index - 1].src == ")": token_index -= 1 while tokens[token_index].src.strip() in ("", ",", ")"): tokens[token_index] = tokens[token_index]._replace(src="") token_index -= 1 tokens[token_index + 1] = tokens[token_index + 1]._replace( src=f", {additional_import_string})" ) else: tokens[token_index] = tokens[token_index]._replace( src=f", {additional_import_string}{tokens[token_index].src}" ) if token.offset in fix_qvariant_type: assert tokens[i].src == "QVariant" assert tokens[i + 1].src == "." tokens[i] = tokens[i]._replace(src="QMetaType.Type") attr = tokens[i + 2].src if attr in qmetatype_mapping: tokens[i + 2] = tokens[i + 2]._replace(src=qmetatype_mapping[attr]) if token.offset in custom_updates: method, _ = custom_updates[token.offset] method(i, tokens) if token.offset in fix_pyqt_import: assert tokens[i + 2].src == "PyQt5" tokens[i + 2] = tokens[i + 2]._replace(src="qgis.PyQt") if token.offset in function_def_renames and tokens[i].src == "def": tokens[i + 2] = tokens[i + 2]._replace( src=function_def_renames[token.offset] ) if token.offset in token_renames: tokens[i] = tokens[i]._replace(src=token_renames[token.offset]) if token.offset in member_renames: counter = i while tokens[counter].src != ".": counter += 1 tokens[counter + 1] = tokens[counter + 1]._replace( src=member_renames[token.offset] ) if token.offset in fix_qt_enums: assert tokens[i + 1].src == "." _class, enum_name, value = fix_qt_enums[token.offset] # make sure we CAN import enum! try: eval(f"{_class}.{enum_name}.{value}") tokens[i + 2] = tokens[i + 2]._replace( src=f"{enum_name}.{tokens[i + 2].src}" ) except AttributeError: # let's see if we can find what the replacement should be automatically... # print(f'Trying to find {_class}.{value}.') actual = eval(f"{_class}.{value}") # print(f'Trying to find aliases for {actual.__class__}.') obj = globals()[_class] recovered = False if isinstance(obj, type): for attr_name in dir(obj): try: attr = getattr(obj, attr_name) if attr is actual.__class__: # print(f'Found alias {_class}.{attr_name}') recovered = True tokens[i + 2] = tokens[i + 2]._replace( src=f"{attr_name}.{tokens[i + 2].src}" ) except AttributeError: continue if not recovered: sys.stderr.write( f"{filename}:{token.line}:{token.utf8_byte_offset} ERROR: wanted to replace with {_class}.{enum_name}.{value}, but does not exist\n" ) continue if token.offset in rename_qt_enums: assert tokens[i + 1].src == "." enum_name = deprecated_renamed_enums[(tokens[i].src, tokens[i + 2].src)] assert enum_name tokens[i + 2] = tokens[i + 2]._replace(src=f"{enum_name[0]}.{enum_name[1]}") new_contents = tokens_to_src(tokens) # Files can only be modified if dry_run mode is not activated. with open(filename, "w") as f: f.write(new_contents) return new_contents != contents def get_class_enums(item): if not inspect.isclass(item): return # enums might be referenced using a subclass instead of their # parent class, so we need to loop through all those too... def all_subclasses(cls): if cls is object: return set() return {cls}.union(s for c in cls.__subclasses__() for s in all_subclasses(c)) matched_classes = {item}.union(all_subclasses(item)) for key, value in item.__dict__.items(): if key == "baseClass": continue if inspect.isclass(value) and type(value).__name__ == "EnumType": for ekey, evalue in value.__dict__.items(): for matched_class in matched_classes: if isinstance(evalue, value): try: test_value = getattr(item, str(ekey)) if not issubclass(type(test_value), Enum): # There's a naming clash between an enum value (Eg QgsAggregateMappingModel.ColumnDataIndex.Aggregate) # and a class (QgsAggregateMappingModel.Aggregate) # So don't do any upgrades for these values, as current code will always be referring # to the CLASS continue except AttributeError: pass if (matched_class.__name__, ekey) in ambiguous_enums: if ( value.__name__ not in ambiguous_enums[(matched_class.__name__, ekey)] ): ambiguous_enums[(matched_class.__name__, ekey)].add( value.__name__ ) continue existing_entry = qt_enums.get((matched_class.__name__, ekey)) if existing_entry != value.__name__ and existing_entry: ambiguous_enums[(matched_class.__name__, ekey)].add( existing_entry ) ambiguous_enums[(matched_class.__name__, ekey)].add( value.__name__ ) del qt_enums[(matched_class.__name__, ekey)] else: qt_enums[(matched_class.__name__, ekey)] = ( f"{value.__name__}" ) elif inspect.isclass(value): get_class_enums(value) def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("directory") parser.add_argument( "--qgis3-incompatible-changes", action="store_true", help="Apply modifications that would break behavior on QGIS 3, hence code may not work on QGIS 3", ) parser.add_argument( "--dry_run", action="store_true", help="Displays the changes that would be made, but does not modify any files.", ) parser.add_argument( "--logfile", action="store", help="Path to logging file", ) args = parser.parse_args(argv) log_format = "%(message)s" if args.logfile: logging.basicConfig( level=logging.DEBUG, format=log_format, filename=Path(args.logfile), filemode="w", ) else: logging.basicConfig(level=logging.DEBUG, format=log_format) # get all scope for all qt enum for module in target_modules: for key, value in module.__dict__.items(): get_class_enums(value) ret = 0 dry_run = args.dry_run if args.dry_run else False if dry_run: logging.info("=== dry_run mode | Start Logs ===") for filename in glob.glob(os.path.join(args.directory, "**/*.py"), recursive=True): if "auto_additions" in filename: continue ret |= fix_file(filename, not args.qgis3_incompatible_changes, dry_run) return ret if __name__ == "__main__": raise SystemExit(main())