QGIS/scripts/pyqt5_to_pyqt6/pyqt5_to_pyqt6.py
Julien 99637cc430 fix(PyQt5toPyQt6): check if object has an attribute id
Error fixed running the migration script on a certain plugin:

```python
Traceback (most recent call last):
  File "/usr/local/bin/pyqt5_to_pyqt6.py", line 892, in <module>
    raise SystemExit(main())
                     ^^^^^^
  File "/usr/local/bin/pyqt5_to_pyqt6.py", line 887, in main
    ret |= fix_file(filename, not args.qgis3_incompatible_changes, dry_run)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/bin/pyqt5_to_pyqt6.py", line 461, in fix_file
    visit_call(node, parent)
  File "/usr/local/bin/pyqt5_to_pyqt6.py", line 338, in visit_call
    and _node.args[0].func.id == "QDate"
        ^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'Attribute' object has no attribute 'id'
```
2025-03-29 08:40:40 +10:00

894 lines
34 KiB
Python
Executable File

#!/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())