mirror of
https://github.com/qgis/QGIS.git
synced 2025-10-09 00:08:52 -04:00
Auto replace qApp with QApplication.instance() Raise warnings if script imports QRegExp. We can't auto replace with QRegularExpression here, the API is just way too different.
515 lines
20 KiB
Python
Executable File
515 lines
20 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 os
|
|
import inspect
|
|
import sys
|
|
from enum import Enum
|
|
|
|
from collections import defaultdict
|
|
|
|
from tokenize_rt import Offset, src_to_tokens, tokens_to_src, reversed_enumerate, Token
|
|
|
|
from typing import Sequence
|
|
|
|
from PyQt6 import QtCore, QtGui, QtWidgets, QtTest, QtSql, QtSvg, QtXml, QtNetwork, QtPrintSupport, Qsci
|
|
from PyQt6.QtCore import * # noqa: F403
|
|
from PyQt6.QtGui import * # noqa: F403
|
|
from PyQt6.QtWidgets import * # noqa: F403
|
|
from PyQt6.QtTest import * # noqa: F403
|
|
from PyQt6.QtSql import * # noqa: F403
|
|
from PyQt6.QtXml import * # noqa: F403
|
|
from PyQt6.QtNetwork import * # noqa: F403
|
|
from PyQt6.QtPrintSupport import * # noqa: F403
|
|
from PyQt6.Qsci import * # noqa: F403
|
|
|
|
try:
|
|
import qgis.core as qgis_core # noqa: F403
|
|
import qgis.gui as qgis_gui # noqa: F403
|
|
import qgis.analysis as qgis_analysis # noqa: F403
|
|
import qgis._3d as qgis_3d # noqa: F403
|
|
from qgis.core import * # noqa: F403
|
|
from qgis.gui import * # noqa: F403
|
|
from qgis.analysis import * # noqa: F403
|
|
from qgis._3d import * # noqa: F403
|
|
except ImportError:
|
|
qgis_core = None
|
|
qgis_gui = None
|
|
qgis_analysis = None
|
|
qgis_3d = None
|
|
print('QGIS classes not available for introspection, only a partial upgrade will be performed')
|
|
|
|
target_modules = [QtCore,
|
|
QtGui,
|
|
QtWidgets,
|
|
QtTest,
|
|
QtSql,
|
|
QtSvg,
|
|
QtXml,
|
|
QtNetwork,
|
|
QtPrintSupport,
|
|
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'),
|
|
}
|
|
|
|
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) -> int:
|
|
|
|
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 = {}
|
|
|
|
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 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
|
|
|
|
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'})
|
|
|
|
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):
|
|
import_offsets[Offset(node.lineno, node.col_offset)] = (node.module, set(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:
|
|
print(f'{filename}: {import_warnings[name.name]}')
|
|
|
|
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)
|
|
|
|
if isinstance(node, ast.Attribute):
|
|
visit_attribute(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 (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}'
|
|
print(f'{filename}: Missing import, manually add \n\t{import_statement}')
|
|
|
|
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:])
|
|
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
|
|
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:
|
|
custom_updates[token.offset](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)
|
|
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 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')
|
|
args = parser.parse_args(argv)
|
|
|
|
# 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
|
|
for filename in glob.glob(os.path.join(args.directory, "**/*.py"), recursive=True):
|
|
# print(f'Processing {filename}')
|
|
if 'auto_additions' in filename:
|
|
continue
|
|
|
|
ret |= fix_file(filename, not args.qgis3_incompatible_changes)
|
|
return ret
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|